Path: blob/main/src/vs/base/parts/storage/node/storage.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as fs from 'fs';6import { timeout } from '../../../common/async.js';7import { Event } from '../../../common/event.js';8import { mapToString, setToString } from '../../../common/map.js';9import { basename } from '../../../common/path.js';10import { Promises } from '../../../node/pfs.js';11import { IStorageDatabase, IStorageItemsChangeEvent, IUpdateRequest } from '../common/storage.js';12import type { Database, Statement } from '@vscode/sqlite3';1314interface IDatabaseConnection {15readonly db: Database;16readonly isInMemory: boolean;1718isErroneous?: boolean;19lastError?: string;20}2122export interface ISQLiteStorageDatabaseOptions {23readonly logging?: ISQLiteStorageDatabaseLoggingOptions;24}2526export interface ISQLiteStorageDatabaseLoggingOptions {27logError?: (error: string | Error) => void;28logTrace?: (msg: string) => void;29}3031export class SQLiteStorageDatabase implements IStorageDatabase {3233static readonly IN_MEMORY_PATH = ':memory:';3435get onDidChangeItemsExternal(): Event<IStorageItemsChangeEvent> { return Event.None; } // since we are the only client, there can be no external changes3637private static readonly BUSY_OPEN_TIMEOUT = 2000; // timeout in ms to retry when opening DB fails with SQLITE_BUSY38private static readonly MAX_HOST_PARAMETERS = 256; // maximum number of parameters within a statement3940private readonly name: string;4142private readonly logger: SQLiteStorageDatabaseLogger;4344private readonly whenConnected: Promise<IDatabaseConnection>;4546constructor(47private readonly path: string,48options: ISQLiteStorageDatabaseOptions = Object.create(null)49) {50this.name = basename(this.path);51this.logger = new SQLiteStorageDatabaseLogger(options.logging);52this.whenConnected = this.connect(this.path);53}5455async getItems(): Promise<Map<string, string>> {56const connection = await this.whenConnected;5758const items = new Map<string, string>();5960const rows = await this.all(connection, 'SELECT * FROM ItemTable');61rows.forEach(row => items.set(row.key, row.value));6263if (this.logger.isTracing) {64this.logger.trace(`[storage ${this.name}] getItems(): ${items.size} rows`);65}6667return items;68}6970async updateItems(request: IUpdateRequest): Promise<void> {71const connection = await this.whenConnected;7273return this.doUpdateItems(connection, request);74}7576private doUpdateItems(connection: IDatabaseConnection, request: IUpdateRequest): Promise<void> {77if (this.logger.isTracing) {78this.logger.trace(`[storage ${this.name}] updateItems(): insert(${request.insert ? mapToString(request.insert) : '0'}), delete(${request.delete ? setToString(request.delete) : '0'})`);79}8081return this.transaction(connection, () => {82const toInsert = request.insert;83const toDelete = request.delete;8485// INSERT86if (toInsert && toInsert.size > 0) {87const keysValuesChunks: (string[])[] = [];88keysValuesChunks.push([]); // seed with initial empty chunk8990// Split key/values into chunks of SQLiteStorageDatabase.MAX_HOST_PARAMETERS91// so that we can efficiently run the INSERT with as many HOST parameters as possible92let currentChunkIndex = 0;93toInsert.forEach((value, key) => {94let keyValueChunk = keysValuesChunks[currentChunkIndex];9596if (keyValueChunk.length > SQLiteStorageDatabase.MAX_HOST_PARAMETERS) {97currentChunkIndex++;98keyValueChunk = [];99keysValuesChunks.push(keyValueChunk);100}101102keyValueChunk.push(key, value);103});104105keysValuesChunks.forEach(keysValuesChunk => {106this.prepare(connection, `INSERT INTO ItemTable VALUES ${new Array(keysValuesChunk.length / 2).fill('(?,?)').join(',')} ON CONFLICT (key) DO UPDATE SET value = excluded.value WHERE value != excluded.value`, stmt => stmt.run(keysValuesChunk), () => {107const keys: string[] = [];108let length = 0;109toInsert.forEach((value, key) => {110keys.push(key);111length += value.length;112});113114return `Keys: ${keys.join(', ')} Length: ${length}`;115});116});117}118119// DELETE120if (toDelete && toDelete.size) {121const keysChunks: (string[])[] = [];122keysChunks.push([]); // seed with initial empty chunk123124// Split keys into chunks of SQLiteStorageDatabase.MAX_HOST_PARAMETERS125// so that we can efficiently run the DELETE with as many HOST parameters126// as possible127let currentChunkIndex = 0;128toDelete.forEach(key => {129let keyChunk = keysChunks[currentChunkIndex];130131if (keyChunk.length > SQLiteStorageDatabase.MAX_HOST_PARAMETERS) {132currentChunkIndex++;133keyChunk = [];134keysChunks.push(keyChunk);135}136137keyChunk.push(key);138});139140keysChunks.forEach(keysChunk => {141this.prepare(connection, `DELETE FROM ItemTable WHERE key IN (${new Array(keysChunk.length).fill('?').join(',')})`, stmt => stmt.run(keysChunk), () => {142const keys: string[] = [];143toDelete.forEach(key => {144keys.push(key);145});146147return `Keys: ${keys.join(', ')}`;148});149});150}151});152}153154async optimize(): Promise<void> {155this.logger.trace(`[storage ${this.name}] vacuum()`);156157const connection = await this.whenConnected;158159return this.exec(connection, 'VACUUM');160}161162async close(recovery?: () => Map<string, string>): Promise<void> {163this.logger.trace(`[storage ${this.name}] close()`);164165const connection = await this.whenConnected;166167return this.doClose(connection, recovery);168}169170private doClose(connection: IDatabaseConnection, recovery?: () => Map<string, string>): Promise<void> {171return new Promise((resolve, reject) => {172connection.db.close(closeError => {173if (closeError) {174this.handleSQLiteError(connection, `[storage ${this.name}] close(): ${closeError}`);175}176177// Return early if this storage was created only in-memory178// e.g. when running tests we do not need to backup.179if (this.path === SQLiteStorageDatabase.IN_MEMORY_PATH) {180return resolve();181}182183// If the DB closed successfully and we are not running in-memory184// and the DB did not get errors during runtime, make a backup185// of the DB so that we can use it as fallback in case the actual186// DB becomes corrupt in the future.187if (!connection.isErroneous && !connection.isInMemory) {188return this.backup().then(resolve, error => {189this.logger.error(`[storage ${this.name}] backup(): ${error}`);190191return resolve(); // ignore failing backup192});193}194195// Recovery: if we detected errors while using the DB or we are using196// an inmemory DB (as a fallback to not being able to open the DB initially)197// and we have a recovery function provided, we recreate the DB with this198// data to recover all known data without loss if possible.199if (typeof recovery === 'function') {200201// Delete the existing DB. If the path does not exist or fails to202// be deleted, we do not try to recover anymore because we assume203// that the path is no longer writeable for us.204return fs.promises.unlink(this.path).then(() => {205206// Re-open the DB fresh207return this.doConnect(this.path).then(recoveryConnection => {208const closeRecoveryConnection = () => {209return this.doClose(recoveryConnection, undefined /* do not attempt to recover again */);210};211212// Store items213return this.doUpdateItems(recoveryConnection, { insert: recovery() }).then(() => closeRecoveryConnection(), error => {214215// In case of an error updating items, still ensure to close the connection216// to prevent SQLITE_BUSY errors when the connection is reestablished217closeRecoveryConnection();218219return Promise.reject(error);220});221});222}).then(resolve, reject);223}224225// Finally without recovery we just reject226return reject(closeError || new Error('Database has errors or is in-memory without recovery option'));227});228});229}230231private backup(): Promise<void> {232const backupPath = this.toBackupPath(this.path);233234return Promises.copy(this.path, backupPath, { preserveSymlinks: false });235}236237private toBackupPath(path: string): string {238return `${path}.backup`;239}240241async checkIntegrity(full: boolean): Promise<string> {242this.logger.trace(`[storage ${this.name}] checkIntegrity(full: ${full})`);243244const connection = await this.whenConnected;245const row = await this.get(connection, full ? 'PRAGMA integrity_check' : 'PRAGMA quick_check');246247const integrity = full ? (row as any)['integrity_check'] : (row as any)['quick_check'];248249if (connection.isErroneous) {250return `${integrity} (last error: ${connection.lastError})`;251}252253if (connection.isInMemory) {254return `${integrity} (in-memory!)`;255}256257return integrity;258}259260private async connect(path: string, retryOnBusy: boolean = true): Promise<IDatabaseConnection> {261this.logger.trace(`[storage ${this.name}] open(${path}, retryOnBusy: ${retryOnBusy})`);262263try {264return await this.doConnect(path);265} catch (error) {266this.logger.error(`[storage ${this.name}] open(): Unable to open DB due to ${error}`);267268// SQLITE_BUSY should only arise if another process is locking the same DB we want269// to open at that time. This typically never happens because a DB connection is270// limited per window. However, in the event of a window reload, it may be possible271// that the previous connection was not properly closed while the new connection is272// already established.273//274// In this case we simply wait for some time and retry once to establish the connection.275//276if (error.code === 'SQLITE_BUSY' && retryOnBusy) {277await timeout(SQLiteStorageDatabase.BUSY_OPEN_TIMEOUT);278279return this.connect(path, false /* not another retry */);280}281282// Otherwise, best we can do is to recover from a backup if that exists, as such we283// move the DB to a different filename and try to load from backup. If that fails,284// a new empty DB is being created automatically.285//286// The final fallback is to use an in-memory DB which should only happen if the target287// folder is really not writeable for us.288//289try {290await fs.promises.unlink(path);291try {292await Promises.rename(this.toBackupPath(path), path, false /* no retry */);293} catch (error) {294// ignore295}296297return await this.doConnect(path);298} catch (error) {299this.logger.error(`[storage ${this.name}] open(): Unable to use backup due to ${error}`);300301// In case of any error to open the DB, use an in-memory302// DB so that we always have a valid DB to talk to.303return this.doConnect(SQLiteStorageDatabase.IN_MEMORY_PATH);304}305}306}307308private handleSQLiteError(connection: IDatabaseConnection, msg: string): void {309connection.isErroneous = true;310connection.lastError = msg;311312this.logger.error(msg);313}314315private doConnect(path: string): Promise<IDatabaseConnection> {316return new Promise((resolve, reject) => {317import('@vscode/sqlite3').then(sqlite3 => {318const ctor = (this.logger.isTracing ? sqlite3.default.verbose().Database : sqlite3.default.Database);319const connection: IDatabaseConnection = {320db: new ctor(path, (error: (Error & { code?: string }) | null) => {321if (error) {322return (connection.db && error.code !== 'SQLITE_CANTOPEN' /* https://github.com/TryGhost/node-sqlite3/issues/1617 */) ? connection.db.close(() => reject(error)) : reject(error);323}324325// The following exec() statement serves two purposes:326// - create the DB if it does not exist yet327// - validate that the DB is not corrupt (the open() call does not throw otherwise)328return this.exec(connection, [329'PRAGMA user_version = 1;',330'CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)'331].join('')).then(() => {332return resolve(connection);333}, error => {334return connection.db.close(() => reject(error));335});336}),337isInMemory: path === SQLiteStorageDatabase.IN_MEMORY_PATH338};339340// Errors341connection.db.on('error', error => this.handleSQLiteError(connection, `[storage ${this.name}] Error (event): ${error}`));342343// Tracing344if (this.logger.isTracing) {345connection.db.on('trace', sql => this.logger.trace(`[storage ${this.name}] Trace (event): ${sql}`));346}347}, reject);348});349}350351private exec(connection: IDatabaseConnection, sql: string): Promise<void> {352return new Promise((resolve, reject) => {353connection.db.exec(sql, error => {354if (error) {355this.handleSQLiteError(connection, `[storage ${this.name}] exec(): ${error}`);356357return reject(error);358}359360return resolve();361});362});363}364365private get(connection: IDatabaseConnection, sql: string): Promise<object> {366return new Promise((resolve, reject) => {367connection.db.get(sql, (error, row) => {368if (error) {369this.handleSQLiteError(connection, `[storage ${this.name}] get(): ${error}`);370371return reject(error);372}373374return resolve(row);375});376});377}378379private all(connection: IDatabaseConnection, sql: string): Promise<{ key: string; value: string }[]> {380return new Promise((resolve, reject) => {381connection.db.all(sql, (error, rows) => {382if (error) {383this.handleSQLiteError(connection, `[storage ${this.name}] all(): ${error}`);384385return reject(error);386}387388return resolve(rows);389});390});391}392393private transaction(connection: IDatabaseConnection, transactions: () => void): Promise<void> {394return new Promise((resolve, reject) => {395connection.db.serialize(() => {396connection.db.run('BEGIN TRANSACTION');397398transactions();399400connection.db.run('END TRANSACTION', error => {401if (error) {402this.handleSQLiteError(connection, `[storage ${this.name}] transaction(): ${error}`);403404return reject(error);405}406407return resolve();408});409});410});411}412413private prepare(connection: IDatabaseConnection, sql: string, runCallback: (stmt: Statement) => void, errorDetails: () => string): void {414const stmt = connection.db.prepare(sql);415416const statementErrorListener = (error: Error) => {417this.handleSQLiteError(connection, `[storage ${this.name}] prepare(): ${error} (${sql}). Details: ${errorDetails()}`);418};419420stmt.on('error', statementErrorListener);421422runCallback(stmt);423424stmt.finalize(error => {425if (error) {426statementErrorListener(error);427}428429stmt.removeListener('error', statementErrorListener);430});431}432}433434class SQLiteStorageDatabaseLogger {435436// to reduce lots of output, require an environment variable to enable tracing437// this helps when running with --verbose normally where the storage tracing438// might hide useful output to look at439private static readonly VSCODE_TRACE_STORAGE = 'VSCODE_TRACE_STORAGE';440441private readonly logTrace: ((msg: string) => void) | undefined;442private readonly logError: ((error: string | Error) => void) | undefined;443444constructor(options?: ISQLiteStorageDatabaseLoggingOptions) {445if (options && typeof options.logTrace === 'function' && process.env[SQLiteStorageDatabaseLogger.VSCODE_TRACE_STORAGE]) {446this.logTrace = options.logTrace;447}448449if (options && typeof options.logError === 'function') {450this.logError = options.logError;451}452}453454get isTracing(): boolean {455return !!this.logTrace;456}457458trace(msg: string): void {459this.logTrace?.(msg);460}461462error(error: string | Error): void {463this.logError?.(error);464}465}466467468