Skip to content

Commit 0e114d9

Browse files
authored
fix: Thread index database migrations are now atomic (#2280)
* fix: Thread index database migrations are now atomic A single process will now migrate the database when needed. Migrations will no longer be run every time the RPC service starts.
1 parent 3308312 commit 0e114d9

File tree

6 files changed

+110
-9
lines changed

6 files changed

+110
-9
lines changed

packages/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@types/lunr": "^2.3.7",
5050
"@types/moo": "^0.5.5",
5151
"@types/node": "^16",
52+
"@types/proper-lockfile": "^4.1.4",
5253
"@types/semver": "^7.3.10",
5354
"@types/sinon": "^10.0.2",
5455
"@types/tmp": "^0.2.3",
@@ -141,6 +142,7 @@
141142
"pkg": "^5.8.1",
142143
"port-pid": "^0.0.7",
143144
"pretty-bytes": "^5.6.0",
145+
"proper-lockfile": "^4.1.2",
144146
"ps-node": "^0.1.6",
145147
"read-pkg-up": "^7.0.1",
146148
"reflect-metadata": "^0.2.2",

packages/cli/src/cmds/index/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export const handler = async (argv) => {
136136
const buildRemoteNavie = () => new NopNavie();
137137

138138
const navieProvider = useLocalNavie() ? buildLocalNavie : buildRemoteNavie;
139-
ThreadIndexService.useDefault();
139+
await ThreadIndexService.useDefault();
140140
NavieService.bindNavieProvider(navieProvider);
141141

142142
await configureRpcDirectories([process.cwd()]);

packages/cli/src/cmds/index/rpc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export const handler = async (argv: HandlerArguments) => {
116116

117117
const navie = buildNavieProvider(argv);
118118

119-
ThreadIndexService.useDefault();
119+
await ThreadIndexService.useDefault();
120120
NavieService.bindNavieProvider(navie);
121121

122122
let codeEditor: string | undefined = argv.codeEditor;

packages/cli/src/rpc/navie/services/threadIndexService.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@ import { homedir } from 'node:os';
33
import { dirname, join } from 'node:path';
44
import configuration from '../../configuration';
55
import { container, inject, injectable, singleton } from 'tsyringe';
6-
import { mkdirSync } from 'node:fs';
6+
import { mkdir } from 'node:fs/promises';
7+
import { lock } from 'proper-lockfile';
8+
import { isNativeError } from 'node:util/types';
79

8-
const INITIALIZE_SQL = `
10+
const SCHEMA_VERSION = 1;
11+
12+
const INITIALIZE_SESSION_SQL = `
13+
PRAGMA busy_timeout = 3000;
914
PRAGMA foreign_keys = ON;
10-
PRAGMA journal_mode = WAL;
15+
`;
1116

17+
const INITIALIZE_DB_SQL = `
1218
CREATE TABLE IF NOT EXISTS threads (
1319
id INTEGER PRIMARY KEY AUTOINCREMENT,
1420
uuid TEXT NOT NULL UNIQUE,
@@ -31,6 +37,8 @@ CREATE TABLE IF NOT EXISTS project_directories (
3137
);
3238
3339
CREATE INDEX IF NOT EXISTS idx_thread_id ON project_directories (thread_id);
40+
41+
PRAGMA user_version = ${SCHEMA_VERSION};
3442
`;
3543

3644
const QUERY_INSERT_THREAD_SQL = `INSERT INTO threads (uuid, path, title) VALUES (?, ?, ?)
@@ -63,20 +71,70 @@ export interface ThreadIndexItem {
6371
@singleton()
6472
@injectable()
6573
export class ThreadIndexService {
74+
static readonly MIGRATION_RETRIES = 5;
6675
static readonly DEFAULT_DATABASE_PATH = join(homedir(), '.appmap', 'navie', 'thread-index.db');
6776
static readonly DATABASE = 'ThreadIndexDatabase';
6877

6978
constructor(@inject(ThreadIndexService.DATABASE) private readonly db: sqlite3.Database) {
70-
this.db.exec(INITIALIZE_SQL);
79+
this.db.exec(INITIALIZE_SESSION_SQL);
80+
}
81+
82+
private shouldMigrate(): boolean {
83+
const currentVersion = this.db.get('PRAGMA user_version')?.user_version;
84+
return currentVersion !== SCHEMA_VERSION;
85+
}
86+
87+
/**
88+
* Migrates the database schema to the latest version. This should be called upon application
89+
* initialization to ensure that the database is up to date.
90+
*/
91+
public async migrate(databaseFilePath = ThreadIndexService.DEFAULT_DATABASE_PATH) {
92+
if (!this.shouldMigrate()) return;
93+
94+
let lockRelease: (() => Promise<void>) | undefined;
95+
try {
96+
await mkdir(dirname(databaseFilePath), { recursive: true });
97+
98+
lockRelease = await lock(databaseFilePath, {
99+
retries: ThreadIndexService.MIGRATION_RETRIES,
100+
stale: 10000,
101+
lockfilePath: `${databaseFilePath}.migration.lock`,
102+
});
103+
104+
if (!this.shouldMigrate()) {
105+
console.info('ThreadIndexService: migration completed by another process');
106+
return;
107+
}
108+
109+
console.info(`ThreadIndexService: migrating schema to v${SCHEMA_VERSION}`);
110+
try {
111+
this.db.exec('PRAGMA journal_mode = WAL');
112+
this.db.exec('BEGIN TRANSACTION');
113+
this.db.exec(INITIALIZE_DB_SQL);
114+
this.db.exec('COMMIT');
115+
console.info(`ThreadIndexService: successfully migrated schema to v${SCHEMA_VERSION}`);
116+
return;
117+
} catch (e) {
118+
this.db.exec('ROLLBACK');
119+
throw e;
120+
}
121+
} finally {
122+
if (lockRelease) {
123+
await lockRelease();
124+
}
125+
}
71126
}
72127

73128
/**
74129
* Binds the database to a sqlite3 instance on disk at the default database path
75130
*/
76-
static useDefault() {
77-
mkdirSync(dirname(this.DEFAULT_DATABASE_PATH), { recursive: true });
131+
static async useDefault() {
132+
await mkdir(dirname(this.DEFAULT_DATABASE_PATH), { recursive: true });
133+
78134
const db = new sqlite3.Database(this.DEFAULT_DATABASE_PATH);
79135
container.registerInstance(this.DATABASE, db);
136+
137+
await container.resolve(ThreadIndexService).migrate();
80138
}
81139

82140
/**

packages/cli/tests/unit/rpc/navie/services/threadIndexService.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,31 @@ import {
1111
} from '../../../../../src/rpc/navie/services/threadIndexService';
1212
import sqlite3 from 'node-sqlite3-wasm';
1313
import configuration from '../../../../../src/rpc/configuration';
14+
import { mkdtemp, rm, writeFile } from 'fs/promises';
15+
import { join } from 'path';
1416

1517
describe('ThreadIndexService', () => {
18+
let tmpDir: string;
1619
let threadIndexService: ThreadIndexService;
1720
let db: sqlite3.Database;
1821
const threadId = '00000000-0000-0000-0000-000000000000';
1922

20-
beforeEach(() => {
23+
beforeEach(async () => {
2124
container.reset();
2225
db = new sqlite3.Database(':memory:');
2326
container.registerInstance(ThreadIndexService.DATABASE, db);
2427
threadIndexService = container.resolve(ThreadIndexService);
28+
29+
// Create a fake database file on disk for file locking
30+
tmpDir = await mkdtemp('thread-index-test-');
31+
const mockDbFile = join(tmpDir, 'mock.db');
32+
await writeFile(mockDbFile, '');
33+
34+
await threadIndexService.migrate(mockDbFile);
2535
});
2636

37+
afterEach(() => rm(tmpDir, { recursive: true, force: true }));
38+
2739
describe('indexThread', () => {
2840
it('indexes a thread', () => {
2941
const path = 'example-thread-history.jsonl';

yarn.lock

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ __metadata:
171171
"@types/lunr": ^2.3.7
172172
"@types/moo": ^0.5.5
173173
"@types/node": ^16
174+
"@types/proper-lockfile": ^4.1.4
174175
"@types/semver": ^7.3.10
175176
"@types/sinon": ^10.0.2
176177
"@types/tmp": ^0.2.3
@@ -232,6 +233,7 @@ __metadata:
232233
port-pid: ^0.0.7
233234
prettier: ^2.7.1
234235
pretty-bytes: ^5.6.0
236+
proper-lockfile: ^4.1.2
235237
ps-node: ^0.1.6
236238
puppeteer: ^19.7.5
237239
read-pkg-up: ^7.0.1
@@ -11407,6 +11409,15 @@ __metadata:
1140711409
languageName: node
1140811410
linkType: hard
1140911411

11412+
"@types/proper-lockfile@npm:^4.1.4":
11413+
version: 4.1.4
11414+
resolution: "@types/proper-lockfile@npm:4.1.4"
11415+
dependencies:
11416+
"@types/retry": "*"
11417+
checksum: b0d1b8e84a563b2c5f869f7ff7542b1d83dec03d1c9d980847cbb189865f44b4a854673cdde59767e41bcb8c31932e613ac43822d358a6f8eede6b79ccfceb1d
11418+
languageName: node
11419+
linkType: hard
11420+
1141011421
"@types/q@npm:^1.5.1":
1141111422
version: 1.5.5
1141211423
resolution: "@types/q@npm:1.5.5"
@@ -11448,6 +11459,13 @@ __metadata:
1144811459
languageName: node
1144911460
linkType: hard
1145011461

11462+
"@types/retry@npm:*":
11463+
version: 0.12.5
11464+
resolution: "@types/retry@npm:0.12.5"
11465+
checksum: 3fb6bf91835ca0eb2987567d6977585235a7567f8aeb38b34a8bb7bbee57ac050ed6f04b9998cda29701b8c893f5dfe315869bc54ac17e536c9235637fe351a2
11466+
languageName: node
11467+
linkType: hard
11468+
1145111469
"@types/retry@npm:0.12.0":
1145211470
version: 0.12.0
1145311471
resolution: "@types/retry@npm:0.12.0"
@@ -36013,6 +36031,17 @@ __metadata:
3601336031
languageName: node
3601436032
linkType: hard
3601536033

36034+
"proper-lockfile@npm:^4.1.2":
36035+
version: 4.1.2
36036+
resolution: "proper-lockfile@npm:4.1.2"
36037+
dependencies:
36038+
graceful-fs: ^4.2.4
36039+
retry: ^0.12.0
36040+
signal-exit: ^3.0.2
36041+
checksum: 00078ee6a61c216a56a6140c7d2a98c6c733b3678503002dc073ab8beca5d50ca271de4c85fca13b9b8ee2ff546c36674d1850509b84a04a5d0363bcb8638939
36042+
languageName: node
36043+
linkType: hard
36044+
3601636045
"proto-list@npm:~1.2.1":
3601736046
version: 1.2.4
3601836047
resolution: "proto-list@npm:1.2.4"

0 commit comments

Comments
 (0)