| @@ -1,5 +1,14 @@ |
| 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: DB ownership test must close the held DB/lock handle even when assertions fail */ | 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: DB ownership tests must tear down held DB/lock handles and child processes even when assertions fail */ |
| 2 | import { mkdtempSync, realpathSync, rmSync } from "node:fs"; | 2 | import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; |
| | 3 | import { |
| | 4 | existsSync, |
| | 5 | mkdirSync, |
| | 6 | mkdtempSync, |
| | 7 | realpathSync, |
| | 8 | rmSync, |
| | 9 | symlinkSync, |
| | 10 | writeFileSync, |
| | 11 | } from "node:fs"; |
| 3 | import { tmpdir } from "node:os"; | 12 | import { tmpdir } from "node:os"; |
| 4 | import { join } from "node:path"; | 13 | import { join } from "node:path"; |
| 5 | | 14 | |
| @@ -7,21 +16,33 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line |
| 7 | import { collectTables } from "@executor-js/api/server"; | 16 | import { collectTables } from "@executor-js/api/server"; |
| 8 | | 17 | |
| 9 | import { acquireDataDirOwnership, DataDirOwnershipHeld } from "./data-dir-ownership"; | 18 | import { acquireDataDirOwnership, DataDirOwnershipHeld } from "./data-dir-ownership"; |
| | 19 | import { executeSql, openLocalLibsql } from "./libsql"; |
| 10 | import { openOwnedLocalDatabase, type OwnedLocalDatabase } from "./owned-database"; | 20 | import { openOwnedLocalDatabase, type OwnedLocalDatabase } from "./owned-database"; |
| 11 | | 21 | |
| 12 | const LOCK_DATABASE_FILENAME = "data.db.owner-lock"; | 22 | const LOCK_DATABASE_FILENAME = "data.db.owner-lock"; |
| | 23 | const TEST_NAMESPACE = "executor_local_owned_database_test"; |
| | 24 | const appRoot = join(import.meta.dirname, "../.."); |
| 13 | | 25 | |
| 14 | const makeOwnedTestDatabase = (dataDir: string) => | 26 | const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); |
| | 27 | |
| | 28 | const makeOwnedTestDatabase = (dataDir: string, namespace = TEST_NAMESPACE) => |
| 15 | openOwnedLocalDatabase({ | 29 | openOwnedLocalDatabase({ |
| 16 | dataDir, | 30 | dataDir, |
| 17 | tables: collectTables(), | 31 | tables: collectTables(), |
| 18 | namespace: "executor_local_owned_database_test", | 32 | namespace, |
| 19 | tenantId: "owned-database-test-tenant", | 33 | tenantId: "owned-database-test-tenant", |
| 20 | }); | 34 | }); |
| 21 | | 35 | |
| | 36 | const makeWorkDir = (prefix: string): string => |
| | 37 | mkdtempSync(join(tmpdir(), `executor-owned-database-${prefix}-`)); |
| | 38 | |
| | 39 | // data.db exists only after the sole owner opens it, so it is the witness that a |
| | 40 | // non-owner never wrote anything: an ownership loser must fail before any DB I/O. |
| | 41 | const servingDbPath = (dataDir: string): string => join(realpathSync(dataDir), "data.db"); |
| | 42 | |
| 22 | describe("openOwnedLocalDatabase", () => { | 43 | describe("openOwnedLocalDatabase", () => { |
| 23 | it("holds data-dir ownership until the serving database is closed", async () => { | 44 | it("holds data-dir ownership until the serving database is closed", async () => { |
| 24 | const workDir = mkdtempSync(join(tmpdir(), "executor-owned-database-")); | 45 | const workDir = makeWorkDir("lifetime"); |
| 25 | const dataDir = join(workDir, "data"); | 46 | const dataDir = join(workDir, "data"); |
| 26 | let owned: OwnedLocalDatabase | null = null; | 47 | let owned: OwnedLocalDatabase | null = null; |
| 27 | | 48 | |
| @@ -53,4 +74,357 @@ describe("openOwnedLocalDatabase", () => { · 25 unmodified lines |
| 53 | rmSync(workDir, { recursive: true, force: true }); | 74 | rmSync(workDir, { recursive: true, force: true }); |
| 54 | } | 75 | } |
| 55 | }); | 76 | }); |
| | 77 | |
| | 78 | it("refuses to open (and never creates data.db) when ownership is already held", async () => { |
| | 79 | const workDir = makeWorkDir("gate-order"); |
| | 80 | const dataDir = join(workDir, "data"); |
| | 81 | const ownership = await acquireDataDirOwnership(dataDir); |
| | 82 | |
| | 83 | try { |
| | 84 | await expect(makeOwnedTestDatabase(dataDir)).rejects.toBeInstanceOf(DataDirOwnershipHeld); |
| | 85 | // The gate runs before any migration or createSqliteFumaDb work, so the |
| | 86 | // loser cannot have written the serving database. |
| | 87 | expect(existsSync(servingDbPath(dataDir))).toBe(false); |
| | 88 | } finally { |
| | 89 | await ownership.release(); |
| | 90 | } |
| | 91 | |
| | 92 | // Once the owner releases, a fresh open succeeds and creates data.db. |
| | 93 | const owned = await makeOwnedTestDatabase(dataDir); |
| | 94 | try { |
| | 95 | expect(existsSync(owned.sqlitePath)).toBe(true); |
| | 96 | } finally { |
| | 97 | await owned.close(); |
| | 98 | rmSync(workDir, { recursive: true, force: true }); |
| | 99 | } |
| | 100 | }); |
| | 101 | |
| | 102 | it("keeps the gate independent of a deleted or corrupt server.json hint", async () => { |
| | 103 | const workDir = makeWorkDir("hint"); |
| | 104 | const dataDir = join(workDir, "data"); |
| | 105 | const owned = await makeOwnedTestDatabase(dataDir); |
| | 106 | |
| | 107 | try { |
| | 108 | // server.json is only a coordination hint. A garbage manifest must not let |
| | 109 | // a second process open the DB while the real owner is alive. |
| | 110 | const serverControlDir = join(realpathSync(dataDir), "server-control"); |
| | 111 | mkdirSync(serverControlDir, { recursive: true }); |
| | 112 | const manifestPath = join(serverControlDir, "server.json"); |
| | 113 | writeFileSync(manifestPath, "{ this is not valid json"); |
| | 114 | await expect(makeOwnedTestDatabase(dataDir)).rejects.toBeInstanceOf(DataDirOwnershipHeld); |
| | 115 | |
| | 116 | // Deleting the hint entirely changes nothing: the kernel lock is the gate. |
| | 117 | rmSync(manifestPath, { force: true }); |
| | 118 | await expect(makeOwnedTestDatabase(dataDir)).rejects.toBeInstanceOf(DataDirOwnershipHeld); |
| | 119 | } finally { |
| | 120 | await owned.close(); |
| | 121 | rmSync(workDir, { recursive: true, force: true }); |
| | 122 | } |
| | 123 | }); |
| | 124 | |
| | 125 | it("collides on one ownership lock across symlinked data dirs", async () => { |
| | 126 | const workDir = makeWorkDir("symlink"); |
| | 127 | const realDir = join(workDir, "real"); |
| | 128 | const linkDir = join(workDir, "link"); |
| | 129 | mkdirSync(realDir, { recursive: true }); |
| | 130 | symlinkSync(realDir, linkDir, "dir"); |
| | 131 | |
| | 132 | const owned = await makeOwnedTestDatabase(realDir); |
| | 133 | try { |
| | 134 | const expectedLockPath = join(realpathSync(realDir), LOCK_DATABASE_FILENAME); |
| | 135 | expect(owned.lockPath).toBe(expectedLockPath); |
| | 136 | |
| | 137 | // The symlinked path resolves to the same real inode, so it must lose the |
| | 138 | // ownership race rather than open a second handle on the same data.db. |
| | 139 | await expect(acquireDataDirOwnership(linkDir)).rejects.toMatchObject({ |
| | 140 | _tag: "DataDirOwnershipHeld", |
| | 141 | lockPath: expectedLockPath, |
| | 142 | }); |
| | 143 | } finally { |
| | 144 | await owned.close(); |
| | 145 | rmSync(workDir, { recursive: true, force: true }); |
| | 146 | } |
| | 147 | }); |
| | 148 | |
| | 149 | it("opens distinct real data dirs independently", async () => { |
| | 150 | const workDir = makeWorkDir("distinct"); |
| | 151 | const dataDirA = join(workDir, "a"); |
| | 152 | const dataDirB = join(workDir, "b"); |
| | 153 | |
| | 154 | const [ownedA, ownedB] = await Promise.all([ |
| | 155 | makeOwnedTestDatabase(dataDirA), |
| | 156 | makeOwnedTestDatabase(dataDirB), |
| | 157 | ]); |
| | 158 | try { |
| | 159 | expect(ownedA.lockPath).not.toBe(ownedB.lockPath); |
| | 160 | expect(existsSync(ownedA.sqlitePath)).toBe(true); |
| | 161 | expect(existsSync(ownedB.sqlitePath)).toBe(true); |
| | 162 | } finally { |
| | 163 | await Promise.all([ownedA.close(), ownedB.close()]); |
| | 164 | rmSync(workDir, { recursive: true, force: true }); |
| | 165 | } |
| | 166 | }); |
| | 167 | |
| | 168 | it("recovers a v1->v2 migration killed mid-flip under ownership", async () => { |
| | 169 | const workDir = makeWorkDir("migration"); |
| | 170 | const dataDir = join(workDir, "data"); |
| | 171 | mkdirSync(dataDir, { recursive: true }); |
| | 172 | const realDataDir = realpathSync(dataDir); |
| | 173 | const sqlitePath = join(realDataDir, "data.db"); |
| | 174 | const journalPath = `${sqlitePath}.v1-v2-migration.json`; |
| | 175 | const tenantId = "owned-migration-workspace"; |
| | 176 | const marker = join(workDir, "paused-flip"); |
| | 177 | |
| | 178 | await seedMinimalV1Db(sqlitePath, tenantId); |
| | 179 | |
| | 180 | // Pause mid-flip: at "canonical-moved" the original data.db has already been |
| | 181 | // moved aside to the backup and the staged v2 DB has not been swapped in |
| | 182 | // yet, so a SIGKILL here leaves a journal the next owner must RESUME (via |
| | 183 | // completeJournaledFlip) rather than re-run from scratch. |
| | 184 | const child = spawnOpenOwnedChild({ |
| | 185 | dataDir, |
| | 186 | tenantId, |
| | 187 | pauseAt: "canonical-moved", |
| | 188 | pauseFile: marker, |
| | 189 | }); |
| | 190 | |
| | 191 | try { |
| | 192 | // Wait until the flip is mid-way under the child's ownership lock. |
| | 193 | const paused = await waitForMarker(marker, child, 30_000); |
| | 194 | expect( |
| | 195 | paused.markerFound, |
| | 196 | `child never reached the flip pause:\nstdout: ${child.stdout}\nstderr: ${child.stderr}`, |
| | 197 | ).toBe(true); |
| | 198 | // Mid-flip witness: the canonical data.db has been moved to the backup and |
| | 199 | // the staged copy is not yet installed. |
| | 200 | expect(existsSync(sqlitePath)).toBe(false); |
| | 201 | |
| | 202 | // SIGKILL the holder so the OS releases the ownership lock mid-flip. |
| | 203 | child.child.kill("SIGKILL"); |
| | 204 | await waitForChildClose(child, 5_000); |
| | 205 | expect(child.child.signalCode).toBe("SIGKILL"); |
| | 206 | |
| | 207 | // The journal proves the flip was interrupted; ownership must now be free |
| | 208 | // because the holder died. |
| | 209 | expect(existsSync(journalPath)).toBe(true); |
| | 210 | const reclaimed = await acquireDataDirOwnership(dataDir); |
| | 211 | await reclaimed.release(); |
| | 212 | |
| | 213 | // The next sole owner resumes the journaled flip during open (recovery |
| | 214 | // runs completeJournaledFlip before the serving connection opens). The |
| | 215 | // steady-state migrate then sees an already-v2 DB, so migration.migrated |
| | 216 | // is false even though the on-disk DB is now v2 — the cleared journal and |
| | 217 | // the v2 rows are the real proof. |
| | 218 | const owned = await openOwnedLocalDatabase({ |
| | 219 | dataDir, |
| | 220 | tables: collectTables(), |
| | 221 | namespace: "executor_local", |
| | 222 | tenantId, |
| | 223 | }); |
| | 224 | try { |
| | 225 | expect(existsSync(journalPath)).toBe(false); |
| | 226 | const integrations = await owned.db.client.execute("SELECT tenant, slug FROM integration"); |
| | 227 | expect(integrations.rows).toEqual([{ tenant: tenantId, slug: "stripe_api" }]); |
| | 228 | } finally { |
| | 229 | await owned.close(); |
| | 230 | } |
| | 231 | } finally { |
| | 232 | if (child.child.exitCode === null && child.child.signalCode === null) { |
| | 233 | child.child.kill("SIGKILL"); |
| | 234 | await waitForChildClose(child, 5_000); |
| | 235 | } |
| | 236 | child.child.stdout.destroy(); |
| | 237 | child.child.stderr.destroy(); |
| | 238 | rmSync(workDir, { recursive: true, force: true }); |
| | 239 | } |
| | 240 | }); |
| 56 | }); | 241 | }); |
| | 242 | |
| | 243 | // --------------------------------------------------------------------------- |
| | 244 | // Minimal v1 fixture + child-process harness |
| | 245 | // --------------------------------------------------------------------------- |
| | 246 | |
| | 247 | // A purpose-built v1-final database with exactly the tables readV1Snapshot |
| | 248 | // reads. plugin_storage is present so the legacy schema replay is skipped, and |
| | 249 | // a single openapi source makes isLocalV1Database return true and gives the |
| | 250 | // migration real content to carry into v2. |
| | 251 | const seedMinimalV1Db = async (dbPath: string, scopeId: string): Promise<void> => { |
| | 252 | const client = await openLocalLibsql(dbPath); |
| | 253 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: close the SQLite client even if seeding fails |
| | 254 | try { |
| | 255 | await client.execute("PRAGMA foreign_keys = OFF"); |
| | 256 | await client.execute( |
| | 257 | "CREATE TABLE source (id text NOT NULL, scope_id text NOT NULL, plugin_id text NOT NULL, kind text NOT NULL, name text NOT NULL, PRIMARY KEY(scope_id, id))", |
| | 258 | ); |
| | 259 | await client.execute( |
| | 260 | "CREATE TABLE plugin_storage (id text NOT NULL, scope_id text NOT NULL, plugin_id text NOT NULL, collection text NOT NULL, key text NOT NULL, data text NOT NULL, created_at integer NOT NULL, updated_at integer NOT NULL, PRIMARY KEY(scope_id, id))", |
| | 261 | ); |
| | 262 | await client.execute( |
| | 263 | "CREATE TABLE credential_binding (id text NOT NULL, scope_id text NOT NULL, plugin_id text NOT NULL, source_id text NOT NULL, source_scope_id text NOT NULL, slot_key text NOT NULL, kind text NOT NULL, text_value text, secret_id text, connection_id text, created_at integer NOT NULL, updated_at integer NOT NULL, PRIMARY KEY(scope_id, id))", |
| | 264 | ); |
| | 265 | await client.execute( |
| | 266 | "CREATE TABLE secret (id text NOT NULL, scope_id text NOT NULL, name text NOT NULL, provider text NOT NULL, owned_by_connection_id text, created_at integer NOT NULL, PRIMARY KEY(scope_id, id))", |
| | 267 | ); |
| | 268 | await client.execute( |
| | 269 | "CREATE TABLE connection (id text NOT NULL, scope_id text NOT NULL, provider text NOT NULL, identity_label text, access_token_secret_id text, refresh_token_secret_id text, expires_at integer, provider_state text, PRIMARY KEY(scope_id, id))", |
| | 270 | ); |
| | 271 | await client.execute( |
| | 272 | "CREATE TABLE tool_policy (id text NOT NULL, scope_id text NOT NULL, pattern text NOT NULL, action text NOT NULL, position text NOT NULL, created_at integer NOT NULL, updated_at integer NOT NULL, PRIMARY KEY(scope_id, id))", |
| | 273 | ); |
| | 274 | await client.execute( |
| | 275 | "CREATE TABLE tool (id text NOT NULL, scope_id text NOT NULL, source_id text NOT NULL, plugin_id text NOT NULL, name text NOT NULL, description text NOT NULL, input_schema text, output_schema text, created_at integer NOT NULL, updated_at integer NOT NULL, PRIMARY KEY(scope_id, id))", |
| | 276 | ); |
| | 277 | |
| | 278 | const now = Date.now(); |
| | 279 | await executeSql( |
| | 280 | client, |
| | 281 | "INSERT INTO source (id, scope_id, plugin_id, kind, name) VALUES (?, ?, ?, ?, ?)", |
| | 282 | ["stripe_api", scopeId, "openapi", "openapi", "Stripe"], |
| | 283 | ); |
| | 284 | await executeSql( |
| | 285 | client, |
| | 286 | "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", |
| | 287 | [ |
| | 288 | "openapi-source-stripe", |
| | 289 | scopeId, |
| | 290 | "openapi", |
| | 291 | "source", |
| | 292 | "stripe_api", |
| | 293 | JSON.stringify({ |
| | 294 | config: { |
| | 295 | spec: "{}", |
| | 296 | headers: { |
| | 297 | Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, |
| | 298 | }, |
| | 299 | }, |
| | 300 | }), |
| | 301 | now, |
| | 302 | now, |
| | 303 | ], |
| | 304 | ); |
| | 305 | await executeSql( |
| | 306 | client, |
| | 307 | "INSERT INTO credential_binding (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| | 308 | [ |
| | 309 | "stripe-auth", |
| | 310 | scopeId, |
| | 311 | "openapi", |
| | 312 | "stripe_api", |
| | 313 | scopeId, |
| | 314 | "header:authorization", |
| | 315 | "secret", |
| | 316 | null, |
| | 317 | "stripe-key", |
| | 318 | null, |
| | 319 | now, |
| | 320 | now, |
| | 321 | ], |
| | 322 | ); |
| | 323 | await executeSql( |
| | 324 | client, |
| | 325 | "INSERT INTO secret (id, scope_id, name, provider, owned_by_connection_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", |
| | 326 | ["stripe-key", scopeId, "Stripe key", "file", null, now], |
| | 327 | ); |
| | 328 | } finally { |
| | 329 | client.close(); |
| | 330 | } |
| | 331 | }; |
| | 332 | |
| | 333 | interface OwnedChild { |
| | 334 | readonly child: ChildProcessWithoutNullStreams; |
| | 335 | stdout: string; |
| | 336 | stderr: string; |
| | 337 | } |
| | 338 | |
| | 339 | interface ChildCloseResult { |
| | 340 | readonly markerFound: boolean; |
| | 341 | readonly exitCode: number | null; |
| | 342 | readonly signalCode: NodeJS.Signals | null; |
| | 343 | } |
| | 344 | |
| | 345 | const spawnOpenOwnedChild = (input: { |
| | 346 | readonly dataDir: string; |
| | 347 | readonly tenantId: string; |
| | 348 | readonly pauseAt: string; |
| | 349 | readonly pauseFile: string; |
| | 350 | }): OwnedChild => { |
| | 351 | const code = ` |
| | 352 | import { collectTables } from "@executor-js/api/server"; |
| | 353 | import { openOwnedLocalDatabase } from "./src/db/owned-database.ts"; |
| | 354 | const owned = await openOwnedLocalDatabase({ |
| | 355 | dataDir: process.env.EXECUTOR_TEST_DATA_DIR, |
| | 356 | tables: collectTables(), |
| | 357 | namespace: "executor_local", |
| | 358 | tenantId: process.env.EXECUTOR_TEST_TENANT, |
| | 359 | }); |
| | 360 | console.log("OPENED"); |
| | 361 | await owned.close(); |
| | 362 | `; |
| | 363 | const child = spawn(process.execPath, ["-e", code], { |
| | 364 | cwd: appRoot, |
| | 365 | env: { |
| | 366 | ...process.env, |
| | 367 | NODE_ENV: "test", |
| | 368 | EXECUTOR_TEST_DATA_DIR: input.dataDir, |
| | 369 | EXECUTOR_TEST_TENANT: input.tenantId, |
| | 370 | EXECUTOR_V1_V2_MIGRATION_PAUSE_AT: input.pauseAt, |
| | 371 | EXECUTOR_V1_V2_MIGRATION_PAUSE_FILE: input.pauseFile, |
| | 372 | }, |
| | 373 | stdio: "pipe", |
| | 374 | }); |
| | 375 | child.unref(); |
| | 376 | |
| | 377 | const owned: OwnedChild = { child, stdout: "", stderr: "" }; |
| | 378 | child.stdout.setEncoding("utf-8"); |
| | 379 | child.stderr.setEncoding("utf-8"); |
| | 380 | child.stdout.on("data", (chunk: string) => { |
| | 381 | owned.stdout += chunk; |
| | 382 | }); |
| | 383 | child.stderr.on("data", (chunk: string) => { |
| | 384 | owned.stderr += chunk; |
| | 385 | }); |
| | 386 | return owned; |
| | 387 | }; |
| | 388 | |
| | 389 | const waitForMarker = async ( |
| | 390 | markerPath: string, |
| | 391 | owned: OwnedChild, |
| | 392 | timeoutMs: number, |
| | 393 | ): Promise<ChildCloseResult> => { |
| | 394 | const deadline = Date.now() + timeoutMs; |
| | 395 | while (Date.now() < deadline) { |
| | 396 | if (existsSync(markerPath)) { |
| | 397 | return { |
| | 398 | markerFound: true, |
| | 399 | exitCode: owned.child.exitCode, |
| | 400 | signalCode: owned.child.signalCode, |
| | 401 | }; |
| | 402 | } |
| | 403 | if (owned.child.exitCode !== null || owned.child.signalCode !== null) { |
| | 404 | return { |
| | 405 | markerFound: false, |
| | 406 | exitCode: owned.child.exitCode, |
| | 407 | signalCode: owned.child.signalCode, |
| | 408 | }; |
| | 409 | } |
| | 410 | await delay(25); |
| | 411 | } |
| | 412 | return { markerFound: false, exitCode: owned.child.exitCode, signalCode: owned.child.signalCode }; |
| | 413 | }; |
| | 414 | |
| | 415 | const waitForChildClose = async (owned: OwnedChild, timeoutMs: number): Promise<void> => |
| | 416 | new Promise((resolve) => { |
| | 417 | if (owned.child.exitCode !== null || owned.child.signalCode !== null) { |
| | 418 | resolve(); |
| | 419 | return; |
| | 420 | } |
| | 421 | const timer = setTimeout(() => { |
| | 422 | owned.child.off("close", onClose); |
| | 423 | resolve(); |
| | 424 | }, timeoutMs); |
| | 425 | const onClose = () => { |
| | 426 | clearTimeout(timer); |
| | 427 | resolve(); |
| | 428 | }; |
| | 429 | owned.child.once("close", onClose); |
| | 430 | }); |