Skip to content

Commit e4a44a8

Browse files
authored
Create a key based file databse so each key has its own file (#538)
* Create a key based file databse so each key has its own file * Add safeguards to locking and reduce file recreates
1 parent 769b0fa commit e4a44a8

14 files changed

Lines changed: 533 additions & 186 deletions

src/datastore/DataStore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Closeable } from '../utils/Closeable';
22
import { isWindows } from '../utils/Environment';
33
import { pathToStorage } from '../utils/Storage';
4-
import { FileStoreFactory } from './FileStore';
5-
import { LMDBStoreFactory } from './LMDB';
4+
import { FileStoreFactory } from './FileStoreFactory';
5+
import { LMDBStoreFactory } from './LMDBStoreFactory';
66
import { MemoryStoreFactory } from './MemoryStore';
77

88
export enum Persistence {
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import { ScopedTelemetry } from '../telemetry/ScopedTelemetry';
66
import { Telemetry } from '../telemetry/TelemetryDecorator';
77
import { formatNumber } from '../utils/String';
88
import { DataStore, DataStoreFactory, PersistedStores, StoreName } from './DataStore';
9-
import { EncryptedFileStore } from './file/EncryptedFileStore';
109
import { encryptionKey } from './file/Encryption';
10+
import { KeyedFileStore } from './file/KeyedFileStore';
1111

1212
export class FileStoreFactory implements DataStoreFactory {
1313
private readonly log: Logger;
1414
@Telemetry({ scope: 'FileStore.Global' }) private readonly telemetry!: ScopedTelemetry;
1515

16-
private readonly stores = new Map<StoreName, EncryptedFileStore>();
16+
private readonly stores = new Map<StoreName, KeyedFileStore>();
1717
private readonly fileDbRoot: string;
1818
private readonly fileDbDir: string;
1919

@@ -35,7 +35,7 @@ export class FileStoreFactory implements DataStoreFactory {
3535
}
3636

3737
for (const store of storeNames) {
38-
this.stores.set(store, new EncryptedFileStore(encryptionKey(VersionNumber), store, this.fileDbDir));
38+
this.stores.set(store, new KeyedFileStore(encryptionKey(VersionNumber), store, this.fileDbDir));
3939
}
4040

4141
this.metricsInterval = setInterval(() => {
@@ -116,5 +116,5 @@ export class FileStoreFactory implements DataStoreFactory {
116116
}
117117
}
118118

119-
const VersionNumber = 2;
119+
const VersionNumber = 3;
120120
const Version = `v${VersionNumber}`;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { existsSync, readFileSync, statSync, unlinkSync } from 'fs'; // eslint-disable-line no-restricted-imports -- files being checked
2+
import { rename, unlink, writeFile } from 'fs/promises';
3+
import { join } from 'path';
4+
import { Logger } from 'pino';
5+
import { lock, LockOptions, lockSync } from 'proper-lockfile';
6+
import { LoggerFactory } from '../../telemetry/LoggerFactory';
7+
import { TelemetryService } from '../../telemetry/TelemetryService';
8+
import { decrypt, encrypt } from './Encryption';
9+
10+
const LOCK_OPTIONS_SYNC: LockOptions = { stale: 10_000 };
11+
const LOCK_OPTIONS: LockOptions = { ...LOCK_OPTIONS_SYNC, retries: { retries: 20, minTimeout: 50, maxTimeout: 1000 } };
12+
13+
/**
14+
* Encrypted on-disk envelope. Stores the original key alongside the value
15+
* so the key can be recovered from the file during startup.
16+
*/
17+
export type EncryptedEntry<T = unknown> = {
18+
readonly key: string;
19+
readonly value: T;
20+
};
21+
22+
export class EncryptedFile {
23+
private readonly log: Logger;
24+
private readonly file: string;
25+
private key: string | undefined;
26+
private content: EncryptedEntry | undefined = undefined;
27+
28+
constructor(
29+
private readonly KEY: Buffer,
30+
storeName: string,
31+
fileName: string,
32+
fileDbDir: string,
33+
) {
34+
this.log = LoggerFactory.getLogger(`EncryptedFile.${storeName}`);
35+
this.file = join(fileDbDir, fileName);
36+
37+
if (this.exists()) {
38+
const release = lockSync(this.file, LOCK_OPTIONS_SYNC);
39+
try {
40+
this.content = this.readFile();
41+
} catch (error) {
42+
this.log.error(error, 'Failed to decrypt file store, deleting store');
43+
TelemetryService.instance.get(`FileStore.${storeName}`).count('filestore.recreate', 1);
44+
unlinkSync(this.file);
45+
} finally {
46+
release();
47+
}
48+
}
49+
}
50+
51+
setKey(key: string) {
52+
if (this.key !== undefined) {
53+
throw new Error('File key was already set');
54+
}
55+
this.key = key;
56+
}
57+
58+
exists() {
59+
return existsSync(this.file);
60+
}
61+
62+
entry(): EncryptedEntry | undefined {
63+
return this.content;
64+
}
65+
66+
get<T>(): T | undefined {
67+
return this.content?.value as T | undefined;
68+
}
69+
70+
async put<T>(value: T): Promise<boolean> {
71+
if (this.key === undefined) {
72+
throw new Error('File key is not set');
73+
}
74+
75+
this.content = { key: this.key, value };
76+
77+
if (!this.exists()) {
78+
await this.save();
79+
return true;
80+
}
81+
82+
const release = await this.tryLock();
83+
if (!release) {
84+
await this.save();
85+
return true;
86+
}
87+
try {
88+
await this.save();
89+
return true;
90+
} finally {
91+
await release();
92+
}
93+
}
94+
95+
async remove() {
96+
this.content = undefined;
97+
98+
if (!this.exists()) {
99+
return true;
100+
}
101+
102+
const release = await this.tryLock();
103+
if (!release) {
104+
return true;
105+
}
106+
try {
107+
await unlink(this.file);
108+
return true;
109+
} catch (error: unknown) {
110+
if (isFileNotFound(error)) {
111+
return true;
112+
}
113+
throw error;
114+
} finally {
115+
await release();
116+
}
117+
}
118+
119+
/** Returns the release function, or undefined if the file was deleted by another process. */
120+
private async tryLock(): Promise<(() => Promise<void>) | undefined> {
121+
try {
122+
return await lock(this.file, LOCK_OPTIONS);
123+
} catch (error: unknown) {
124+
if (isFileNotFound(error)) {
125+
return undefined;
126+
}
127+
throw error;
128+
}
129+
}
130+
131+
fileSize(): number {
132+
return existsSync(this.file) ? statSync(this.file).size : 0;
133+
}
134+
135+
private readFile(): EncryptedEntry {
136+
return JSON.parse(decrypt(this.KEY, readFileSync(this.file))) as EncryptedEntry;
137+
}
138+
139+
private async save() {
140+
const tmp = `${this.file}.${process.pid}.tmp`;
141+
await writeFile(tmp, encrypt(this.KEY, JSON.stringify(this.content)));
142+
await rename(tmp, this.file);
143+
}
144+
}
145+
146+
const ENOENT = 'ENOENT'; // File was deleted by another process (e.g. a concurrent IDE session sharing the same storage directory).
147+
148+
function isFileNotFound(error: unknown): boolean {
149+
return error instanceof Error && (error as NodeJS.ErrnoException).code === ENOENT;
150+
}

src/datastore/file/EncryptedFileStore.ts

Lines changed: 0 additions & 128 deletions
This file was deleted.

src/datastore/file/Encryption.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { stableMachineSpecificKey } from '../../utils/MachineKey';
33

44
export function encryptionKey(version: number): Buffer {
55
switch (version) {
6+
case 3:
67
case 2:
78
case 1: {
89
return stableMachineSpecificKey('filedb-static-salt', 'filedb-encryption-key-derivation', 32);

0 commit comments

Comments
 (0)