Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/parts/storage/node/storage.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as fs from 'fs';
7
import { timeout } from '../../../common/async.js';
8
import { Event } from '../../../common/event.js';
9
import { mapToString, setToString } from '../../../common/map.js';
10
import { basename } from '../../../common/path.js';
11
import { Promises } from '../../../node/pfs.js';
12
import { IStorageDatabase, IStorageItemsChangeEvent, IUpdateRequest } from '../common/storage.js';
13
import type { Database, Statement } from '@vscode/sqlite3';
14
15
interface IDatabaseConnection {
16
readonly db: Database;
17
readonly isInMemory: boolean;
18
19
isErroneous?: boolean;
20
lastError?: string;
21
}
22
23
export interface ISQLiteStorageDatabaseOptions {
24
readonly logging?: ISQLiteStorageDatabaseLoggingOptions;
25
}
26
27
export interface ISQLiteStorageDatabaseLoggingOptions {
28
logError?: (error: string | Error) => void;
29
logTrace?: (msg: string) => void;
30
}
31
32
export class SQLiteStorageDatabase implements IStorageDatabase {
33
34
static readonly IN_MEMORY_PATH = ':memory:';
35
36
get onDidChangeItemsExternal(): Event<IStorageItemsChangeEvent> { return Event.None; } // since we are the only client, there can be no external changes
37
38
private static readonly BUSY_OPEN_TIMEOUT = 2000; // timeout in ms to retry when opening DB fails with SQLITE_BUSY
39
private static readonly MAX_HOST_PARAMETERS = 256; // maximum number of parameters within a statement
40
41
private readonly name: string;
42
43
private readonly logger: SQLiteStorageDatabaseLogger;
44
45
private readonly whenConnected: Promise<IDatabaseConnection>;
46
47
constructor(
48
private readonly path: string,
49
options: ISQLiteStorageDatabaseOptions = Object.create(null)
50
) {
51
this.name = basename(this.path);
52
this.logger = new SQLiteStorageDatabaseLogger(options.logging);
53
this.whenConnected = this.connect(this.path);
54
}
55
56
async getItems(): Promise<Map<string, string>> {
57
const connection = await this.whenConnected;
58
59
const items = new Map<string, string>();
60
61
const rows = await this.all(connection, 'SELECT * FROM ItemTable');
62
rows.forEach(row => items.set(row.key, row.value));
63
64
if (this.logger.isTracing) {
65
this.logger.trace(`[storage ${this.name}] getItems(): ${items.size} rows`);
66
}
67
68
return items;
69
}
70
71
async updateItems(request: IUpdateRequest): Promise<void> {
72
const connection = await this.whenConnected;
73
74
return this.doUpdateItems(connection, request);
75
}
76
77
private doUpdateItems(connection: IDatabaseConnection, request: IUpdateRequest): Promise<void> {
78
if (this.logger.isTracing) {
79
this.logger.trace(`[storage ${this.name}] updateItems(): insert(${request.insert ? mapToString(request.insert) : '0'}), delete(${request.delete ? setToString(request.delete) : '0'})`);
80
}
81
82
return this.transaction(connection, () => {
83
const toInsert = request.insert;
84
const toDelete = request.delete;
85
86
// INSERT
87
if (toInsert && toInsert.size > 0) {
88
const keysValuesChunks: (string[])[] = [];
89
keysValuesChunks.push([]); // seed with initial empty chunk
90
91
// Split key/values into chunks of SQLiteStorageDatabase.MAX_HOST_PARAMETERS
92
// so that we can efficiently run the INSERT with as many HOST parameters as possible
93
let currentChunkIndex = 0;
94
toInsert.forEach((value, key) => {
95
let keyValueChunk = keysValuesChunks[currentChunkIndex];
96
97
if (keyValueChunk.length > SQLiteStorageDatabase.MAX_HOST_PARAMETERS) {
98
currentChunkIndex++;
99
keyValueChunk = [];
100
keysValuesChunks.push(keyValueChunk);
101
}
102
103
keyValueChunk.push(key, value);
104
});
105
106
keysValuesChunks.forEach(keysValuesChunk => {
107
this.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), () => {
108
const keys: string[] = [];
109
let length = 0;
110
toInsert.forEach((value, key) => {
111
keys.push(key);
112
length += value.length;
113
});
114
115
return `Keys: ${keys.join(', ')} Length: ${length}`;
116
});
117
});
118
}
119
120
// DELETE
121
if (toDelete && toDelete.size) {
122
const keysChunks: (string[])[] = [];
123
keysChunks.push([]); // seed with initial empty chunk
124
125
// Split keys into chunks of SQLiteStorageDatabase.MAX_HOST_PARAMETERS
126
// so that we can efficiently run the DELETE with as many HOST parameters
127
// as possible
128
let currentChunkIndex = 0;
129
toDelete.forEach(key => {
130
let keyChunk = keysChunks[currentChunkIndex];
131
132
if (keyChunk.length > SQLiteStorageDatabase.MAX_HOST_PARAMETERS) {
133
currentChunkIndex++;
134
keyChunk = [];
135
keysChunks.push(keyChunk);
136
}
137
138
keyChunk.push(key);
139
});
140
141
keysChunks.forEach(keysChunk => {
142
this.prepare(connection, `DELETE FROM ItemTable WHERE key IN (${new Array(keysChunk.length).fill('?').join(',')})`, stmt => stmt.run(keysChunk), () => {
143
const keys: string[] = [];
144
toDelete.forEach(key => {
145
keys.push(key);
146
});
147
148
return `Keys: ${keys.join(', ')}`;
149
});
150
});
151
}
152
});
153
}
154
155
async optimize(): Promise<void> {
156
this.logger.trace(`[storage ${this.name}] vacuum()`);
157
158
const connection = await this.whenConnected;
159
160
return this.exec(connection, 'VACUUM');
161
}
162
163
async close(recovery?: () => Map<string, string>): Promise<void> {
164
this.logger.trace(`[storage ${this.name}] close()`);
165
166
const connection = await this.whenConnected;
167
168
return this.doClose(connection, recovery);
169
}
170
171
private doClose(connection: IDatabaseConnection, recovery?: () => Map<string, string>): Promise<void> {
172
return new Promise((resolve, reject) => {
173
connection.db.close(closeError => {
174
if (closeError) {
175
this.handleSQLiteError(connection, `[storage ${this.name}] close(): ${closeError}`);
176
}
177
178
// Return early if this storage was created only in-memory
179
// e.g. when running tests we do not need to backup.
180
if (this.path === SQLiteStorageDatabase.IN_MEMORY_PATH) {
181
return resolve();
182
}
183
184
// If the DB closed successfully and we are not running in-memory
185
// and the DB did not get errors during runtime, make a backup
186
// of the DB so that we can use it as fallback in case the actual
187
// DB becomes corrupt in the future.
188
if (!connection.isErroneous && !connection.isInMemory) {
189
return this.backup().then(resolve, error => {
190
this.logger.error(`[storage ${this.name}] backup(): ${error}`);
191
192
return resolve(); // ignore failing backup
193
});
194
}
195
196
// Recovery: if we detected errors while using the DB or we are using
197
// an inmemory DB (as a fallback to not being able to open the DB initially)
198
// and we have a recovery function provided, we recreate the DB with this
199
// data to recover all known data without loss if possible.
200
if (typeof recovery === 'function') {
201
202
// Delete the existing DB. If the path does not exist or fails to
203
// be deleted, we do not try to recover anymore because we assume
204
// that the path is no longer writeable for us.
205
return fs.promises.unlink(this.path).then(() => {
206
207
// Re-open the DB fresh
208
return this.doConnect(this.path).then(recoveryConnection => {
209
const closeRecoveryConnection = () => {
210
return this.doClose(recoveryConnection, undefined /* do not attempt to recover again */);
211
};
212
213
// Store items
214
return this.doUpdateItems(recoveryConnection, { insert: recovery() }).then(() => closeRecoveryConnection(), error => {
215
216
// In case of an error updating items, still ensure to close the connection
217
// to prevent SQLITE_BUSY errors when the connection is reestablished
218
closeRecoveryConnection();
219
220
return Promise.reject(error);
221
});
222
});
223
}).then(resolve, reject);
224
}
225
226
// Finally without recovery we just reject
227
return reject(closeError || new Error('Database has errors or is in-memory without recovery option'));
228
});
229
});
230
}
231
232
private backup(): Promise<void> {
233
const backupPath = this.toBackupPath(this.path);
234
235
return Promises.copy(this.path, backupPath, { preserveSymlinks: false });
236
}
237
238
private toBackupPath(path: string): string {
239
return `${path}.backup`;
240
}
241
242
async checkIntegrity(full: boolean): Promise<string> {
243
this.logger.trace(`[storage ${this.name}] checkIntegrity(full: ${full})`);
244
245
const connection = await this.whenConnected;
246
const row = await this.get(connection, full ? 'PRAGMA integrity_check' : 'PRAGMA quick_check');
247
248
const integrity = full ? (row as any)['integrity_check'] : (row as any)['quick_check'];
249
250
if (connection.isErroneous) {
251
return `${integrity} (last error: ${connection.lastError})`;
252
}
253
254
if (connection.isInMemory) {
255
return `${integrity} (in-memory!)`;
256
}
257
258
return integrity;
259
}
260
261
private async connect(path: string, retryOnBusy: boolean = true): Promise<IDatabaseConnection> {
262
this.logger.trace(`[storage ${this.name}] open(${path}, retryOnBusy: ${retryOnBusy})`);
263
264
try {
265
return await this.doConnect(path);
266
} catch (error) {
267
this.logger.error(`[storage ${this.name}] open(): Unable to open DB due to ${error}`);
268
269
// SQLITE_BUSY should only arise if another process is locking the same DB we want
270
// to open at that time. This typically never happens because a DB connection is
271
// limited per window. However, in the event of a window reload, it may be possible
272
// that the previous connection was not properly closed while the new connection is
273
// already established.
274
//
275
// In this case we simply wait for some time and retry once to establish the connection.
276
//
277
if (error.code === 'SQLITE_BUSY' && retryOnBusy) {
278
await timeout(SQLiteStorageDatabase.BUSY_OPEN_TIMEOUT);
279
280
return this.connect(path, false /* not another retry */);
281
}
282
283
// Otherwise, best we can do is to recover from a backup if that exists, as such we
284
// move the DB to a different filename and try to load from backup. If that fails,
285
// a new empty DB is being created automatically.
286
//
287
// The final fallback is to use an in-memory DB which should only happen if the target
288
// folder is really not writeable for us.
289
//
290
try {
291
await fs.promises.unlink(path);
292
try {
293
await Promises.rename(this.toBackupPath(path), path, false /* no retry */);
294
} catch (error) {
295
// ignore
296
}
297
298
return await this.doConnect(path);
299
} catch (error) {
300
this.logger.error(`[storage ${this.name}] open(): Unable to use backup due to ${error}`);
301
302
// In case of any error to open the DB, use an in-memory
303
// DB so that we always have a valid DB to talk to.
304
return this.doConnect(SQLiteStorageDatabase.IN_MEMORY_PATH);
305
}
306
}
307
}
308
309
private handleSQLiteError(connection: IDatabaseConnection, msg: string): void {
310
connection.isErroneous = true;
311
connection.lastError = msg;
312
313
this.logger.error(msg);
314
}
315
316
private doConnect(path: string): Promise<IDatabaseConnection> {
317
return new Promise((resolve, reject) => {
318
import('@vscode/sqlite3').then(sqlite3 => {
319
const ctor = (this.logger.isTracing ? sqlite3.default.verbose().Database : sqlite3.default.Database);
320
const connection: IDatabaseConnection = {
321
db: new ctor(path, (error: (Error & { code?: string }) | null) => {
322
if (error) {
323
return (connection.db && error.code !== 'SQLITE_CANTOPEN' /* https://github.com/TryGhost/node-sqlite3/issues/1617 */) ? connection.db.close(() => reject(error)) : reject(error);
324
}
325
326
// The following exec() statement serves two purposes:
327
// - create the DB if it does not exist yet
328
// - validate that the DB is not corrupt (the open() call does not throw otherwise)
329
return this.exec(connection, [
330
'PRAGMA user_version = 1;',
331
'CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)'
332
].join('')).then(() => {
333
return resolve(connection);
334
}, error => {
335
return connection.db.close(() => reject(error));
336
});
337
}),
338
isInMemory: path === SQLiteStorageDatabase.IN_MEMORY_PATH
339
};
340
341
// Errors
342
connection.db.on('error', error => this.handleSQLiteError(connection, `[storage ${this.name}] Error (event): ${error}`));
343
344
// Tracing
345
if (this.logger.isTracing) {
346
connection.db.on('trace', sql => this.logger.trace(`[storage ${this.name}] Trace (event): ${sql}`));
347
}
348
}, reject);
349
});
350
}
351
352
private exec(connection: IDatabaseConnection, sql: string): Promise<void> {
353
return new Promise((resolve, reject) => {
354
connection.db.exec(sql, error => {
355
if (error) {
356
this.handleSQLiteError(connection, `[storage ${this.name}] exec(): ${error}`);
357
358
return reject(error);
359
}
360
361
return resolve();
362
});
363
});
364
}
365
366
private get(connection: IDatabaseConnection, sql: string): Promise<object> {
367
return new Promise((resolve, reject) => {
368
connection.db.get(sql, (error, row) => {
369
if (error) {
370
this.handleSQLiteError(connection, `[storage ${this.name}] get(): ${error}`);
371
372
return reject(error);
373
}
374
375
return resolve(row);
376
});
377
});
378
}
379
380
private all(connection: IDatabaseConnection, sql: string): Promise<{ key: string; value: string }[]> {
381
return new Promise((resolve, reject) => {
382
connection.db.all(sql, (error, rows) => {
383
if (error) {
384
this.handleSQLiteError(connection, `[storage ${this.name}] all(): ${error}`);
385
386
return reject(error);
387
}
388
389
return resolve(rows);
390
});
391
});
392
}
393
394
private transaction(connection: IDatabaseConnection, transactions: () => void): Promise<void> {
395
return new Promise((resolve, reject) => {
396
connection.db.serialize(() => {
397
connection.db.run('BEGIN TRANSACTION');
398
399
transactions();
400
401
connection.db.run('END TRANSACTION', error => {
402
if (error) {
403
this.handleSQLiteError(connection, `[storage ${this.name}] transaction(): ${error}`);
404
405
return reject(error);
406
}
407
408
return resolve();
409
});
410
});
411
});
412
}
413
414
private prepare(connection: IDatabaseConnection, sql: string, runCallback: (stmt: Statement) => void, errorDetails: () => string): void {
415
const stmt = connection.db.prepare(sql);
416
417
const statementErrorListener = (error: Error) => {
418
this.handleSQLiteError(connection, `[storage ${this.name}] prepare(): ${error} (${sql}). Details: ${errorDetails()}`);
419
};
420
421
stmt.on('error', statementErrorListener);
422
423
runCallback(stmt);
424
425
stmt.finalize(error => {
426
if (error) {
427
statementErrorListener(error);
428
}
429
430
stmt.removeListener('error', statementErrorListener);
431
});
432
}
433
}
434
435
class SQLiteStorageDatabaseLogger {
436
437
// to reduce lots of output, require an environment variable to enable tracing
438
// this helps when running with --verbose normally where the storage tracing
439
// might hide useful output to look at
440
private static readonly VSCODE_TRACE_STORAGE = 'VSCODE_TRACE_STORAGE';
441
442
private readonly logTrace: ((msg: string) => void) | undefined;
443
private readonly logError: ((error: string | Error) => void) | undefined;
444
445
constructor(options?: ISQLiteStorageDatabaseLoggingOptions) {
446
if (options && typeof options.logTrace === 'function' && process.env[SQLiteStorageDatabaseLogger.VSCODE_TRACE_STORAGE]) {
447
this.logTrace = options.logTrace;
448
}
449
450
if (options && typeof options.logError === 'function') {
451
this.logError = options.logError;
452
}
453
}
454
455
get isTracing(): boolean {
456
return !!this.logTrace;
457
}
458
459
trace(msg: string): void {
460
this.logTrace?.(msg);
461
}
462
463
error(error: string | Error): void {
464
this.logError?.(error);
465
}
466
}
467
468