mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 21:05:25 +08:00
Issue #2980: opening an index whose schema is behind read its version, then ran each pending migration in a deferred transaction. When several processes opened it at the same moment, those that read the version just before another one migrated applied the same migration again, and their open failed: on this Windows machine with "UNIQUE constraint failed: schema_versions.version", since the version was already recorded, and in the report with SQLITE_BUSY, which a deferred transaction that has already read gets at once when it comes to write. Each migration now runs in a transaction that takes the write lock up front (BEGIN IMMEDIATE, a new option of the adapter's transaction()), so a process that finds another one migrating waits it out on busy_timeout, and reads the version again inside it, skipping a migration already applied. The version the caller read stays a floor, as before: a database built from schema.sql records only v1 and the current version, and tests replay migrations above a version they rewound to. An open of a current index takes no lock and waits for nothing, as before. Validation: __tests__/migration-concurrent-opens.test.ts. A second process holds the write lock with version 16 recorded but uncommitted, so the open reads 15: main fails with the UNIQUE error after waiting 1.1 s; the fix waits, opens and leaves the migration to the other process. Three processes released at once from a stdin barrier on an index with 150k unresolved references: main fails in round 0, the fix opens all three in both rounds. On excalidraw's real index, 8 rounds of three openers: main 7 rounds with failures, the fix 0. The single process and the current-index cases pass on both. Fixes #2980 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.3 KiB
TypeScript
203 lines
8.3 KiB
TypeScript
/**
|
|
* Opening an index that still needs a schema migration, while another process
|
|
* opens it too, must not fail (#2980).
|
|
*
|
|
* The open read the schema version, then ran each pending migration in its own
|
|
* deferred transaction. A process that read the version just before another
|
|
* one migrated went on to apply the same migration again: it either failed
|
|
* recording a version that was already recorded ("UNIQUE constraint failed:
|
|
* schema_versions.version"), or, for a migration that reads before it writes,
|
|
* hit SQLITE_BUSY at once, since SQLite does not wait on a lock for a
|
|
* transaction that has already read. Either way the open failed.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { spawn, type ChildProcess } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import CodeGraph from '../src';
|
|
import { DatabaseConnection } from '../src/db';
|
|
import { createDatabase } from '../src/db/sqlite-adapter';
|
|
import { CURRENT_SCHEMA_VERSION, getCurrentVersion } from '../src/db/migrations';
|
|
|
|
const DIST_INDEX = path.resolve(__dirname, '../dist/index.js');
|
|
/** The index migration 16 adds: the one migration the fixture leaves pending. */
|
|
const V16_INDEX = 'idx_unresolved_failed_module_member';
|
|
|
|
/** Opens the project once the test writes a byte to its stdin, and says how it went. */
|
|
const OPENER = `
|
|
const fs = require('fs');
|
|
const CodeGraph = require(process.argv[1]).default;
|
|
process.stdout.write('READY\\n');
|
|
try { fs.readSync(0, Buffer.alloc(1), 0, 1, null); } catch (_) {}
|
|
try {
|
|
CodeGraph.openSync(process.argv[2]).close();
|
|
process.stdout.write('OPENED\\n');
|
|
} catch (err) {
|
|
process.stdout.write('FAILED ' + String((err && err.message) || err).replace(/\\s+/g, ' ') + '\\n');
|
|
}
|
|
`;
|
|
|
|
/**
|
|
* Another process in the middle of migrating: it holds the write lock with
|
|
* version 16 recorded but not committed, and commits on its own after a while.
|
|
* Until then any other connection still reads version 15.
|
|
*/
|
|
const MIGRATING = `
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const db = new DatabaseSync(process.argv[1]);
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
db.exec('BEGIN IMMEDIATE');
|
|
db.prepare("INSERT INTO schema_versions (version, applied_at, description) VALUES (16, ?, 'another process')").run(Date.now());
|
|
process.stdout.write('HOLDING\\n');
|
|
setTimeout(() => { db.exec('COMMIT'); db.close(); process.stdout.write('COMMITTED\\n'); }, Number(process.argv[2]));
|
|
`;
|
|
|
|
/** A writer holding the lock on a current index, until the test ends it. */
|
|
const WRITING = `
|
|
const fs = require('fs');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const db = new DatabaseSync(process.argv[1]);
|
|
db.exec('BEGIN IMMEDIATE');
|
|
db.exec("INSERT INTO project_metadata (key, value, updated_at) VALUES ('cg2980', '1', 0) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
|
|
process.stdout.write('HOLDING\\n');
|
|
try { fs.readSync(0, Buffer.alloc(1), 0, 1, null); } catch (_) {}
|
|
db.exec('ROLLBACK');
|
|
db.close();
|
|
`;
|
|
|
|
interface Child {
|
|
child: ChildProcess;
|
|
output: () => string;
|
|
exited: Promise<number | null>;
|
|
}
|
|
|
|
describe('opening an index with a pending migration from several processes (#2980)', () => {
|
|
let dir: string;
|
|
let dbPath: string;
|
|
let children: Child[] = [];
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-migrate-'));
|
|
CodeGraph.initSync(dir).close();
|
|
dbPath = path.join(dir, '.codegraph', 'codegraph.db');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const { child, exited } of children) {
|
|
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
await exited;
|
|
}
|
|
children = [];
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
/** Take the index back to version 15, the way an index built before migration 16 is. */
|
|
function pendingV16(refs = 0): void {
|
|
const { db } = createDatabase(dbPath);
|
|
try {
|
|
db.exec('PRAGMA foreign_keys = OFF');
|
|
db.exec(`DROP INDEX IF EXISTS ${V16_INDEX};
|
|
DELETE FROM schema_versions WHERE version >= 16;
|
|
INSERT OR IGNORE INTO schema_versions (version, applied_at, description) VALUES (15, 0, 'fixture');`);
|
|
if (refs > 0) {
|
|
// Enough rows that building the index takes a while, so openers overlap.
|
|
db.exec(`WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${refs})
|
|
INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status, name_tail)
|
|
SELECT 'n' || (i % 500), 'store.get' || i, 'calls', i % 900, 1, 'src/f' || (i % 300) || '.ts', 'typescript',
|
|
CASE WHEN i % 3 = 0 THEN 'failed' ELSE 'pending' END,
|
|
CASE WHEN i % 3 = 0 THEN 'module:./m' || (i % 50) ELSE 'get' || i END
|
|
FROM n`);
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function state(): { version: number; v16Records: number; index: boolean } {
|
|
const { db } = createDatabase(dbPath, { readOnly: true });
|
|
try {
|
|
return {
|
|
version: getCurrentVersion(db),
|
|
v16Records: (db.prepare('SELECT COUNT(*) AS n FROM schema_versions WHERE version = 16').get() as { n: number }).n,
|
|
index: db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?").get(V16_INDEX) !== undefined,
|
|
};
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function run(args: string[]): Child {
|
|
const child = spawn(process.execPath, args, {
|
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
// Off the fixture, so a child still exiting cannot hold it (Windows EBUSY).
|
|
cwd: os.tmpdir(),
|
|
windowsHide: true,
|
|
});
|
|
let out = '';
|
|
child.stdout!.on('data', (chunk) => { out += String(chunk); });
|
|
const exited = new Promise<number | null>((resolve) => child.once('exit', (code) => resolve(code)));
|
|
const handle = { child, output: () => out, exited };
|
|
children.push(handle);
|
|
return handle;
|
|
}
|
|
|
|
async function says(who: Child, word: string): Promise<void> {
|
|
const deadline = Date.now() + 30_000;
|
|
while (!who.output().includes(word)) {
|
|
if (who.child.exitCode !== null || Date.now() > deadline) {
|
|
throw new Error(`expected "${word}", got: ${who.output()}`);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
}
|
|
|
|
it('waits for a process already migrating, then skips the migration it applied', async () => {
|
|
pendingV16();
|
|
const migrating = run(['-e', MIGRATING, dbPath, '1500']);
|
|
await says(migrating, 'HOLDING');
|
|
|
|
// Version 16 is not committed yet: this open reads 15, so it has a migration to run.
|
|
const started = Date.now();
|
|
const conn = DatabaseConnection.open(dbPath);
|
|
const waited = Date.now() - started;
|
|
try {
|
|
expect(getCurrentVersion(conn.getDb())).toBe(CURRENT_SCHEMA_VERSION);
|
|
} finally {
|
|
conn.close();
|
|
}
|
|
await says(migrating, 'COMMITTED');
|
|
expect(waited).toBeGreaterThanOrEqual(500);
|
|
// Recorded once, by the other process, and not run again here: its index was never built.
|
|
expect(state()).toEqual({ version: CURRENT_SCHEMA_VERSION, v16Records: 1, index: false });
|
|
}, 60_000);
|
|
|
|
it('opens in every one of three processes that start at the same moment', async () => {
|
|
for (let round = 0; round < 2; round++) {
|
|
pendingV16(round === 0 ? 150_000 : 0);
|
|
const openers = [0, 1, 2].map(() => run(['-e', OPENER, DIST_INDEX, dir]));
|
|
await Promise.all(openers.map((opener) => says(opener, 'READY')));
|
|
for (const opener of openers) opener.child.stdin!.write('g');
|
|
await Promise.all(openers.map((opener) => opener.exited));
|
|
for (const opener of openers) expect(opener.output(), `round ${round}`).toContain('OPENED');
|
|
expect(state()).toEqual({ version: CURRENT_SCHEMA_VERSION, v16Records: 1, index: true });
|
|
}
|
|
}, 120_000);
|
|
|
|
it('still applies a pending migration in a single process', () => {
|
|
pendingV16();
|
|
DatabaseConnection.open(dbPath).close();
|
|
expect(state()).toEqual({ version: CURRENT_SCHEMA_VERSION, v16Records: 1, index: true });
|
|
});
|
|
|
|
it('opens a current index without waiting for another process that is writing', async () => {
|
|
const writing = run(['-e', WRITING, dbPath]);
|
|
await says(writing, 'HOLDING');
|
|
const started = Date.now();
|
|
DatabaseConnection.open(dbPath).close();
|
|
expect(Date.now() - started).toBeLessThan(2_000);
|
|
writing.child.stdin!.write('g');
|
|
expect(await writing.exited).toBe(0);
|
|
}, 60_000);
|
|
});
|