Repository navigation
fix: avoid "database is locked" when several CLI processes start on a fresh data dir #1541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
741e197
fix(storage): serialize first-run database setup across concurrent pr…
anandgupta42 31d9ea1
fix(storage): address review findings on the first-run setup lock
anandgupta42 5a1f1c4
fix(storage): mark the extracted open() as a fork change and isolate …
anandgupta42 1395113
fix(storage): make read-only Bun opens actually read-only
anandgupta42 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { Effect, Semaphore } from "effect" | ||
| import { openSetupLock } from "#sqlite" | ||
|
|
||
| /** | ||
| * Cross-process mutex for first-run database setup (WAL switch and migrations). | ||
| * | ||
| * SQLite cannot queue a deferred read-to-write upgrade, so concurrent fresh starts failed at once with | ||
| * "database is locked". An exclusive transaction on a sentinel database next to the real one serializes | ||
| * them, and the OS releases it if the holder dies. | ||
| * | ||
| * `acquireSync` blocks the thread, so it is only for sections that never yield. A section that yields | ||
| * (the effect-based migration) uses `hold`, which polls without blocking. A synchronous caller in the | ||
| * same process as an active `hold` skips the lock: blocking would deadlock the holder, and connections | ||
| * in one process already queue through their own busy handlers. | ||
| */ | ||
| const semaphores = new Map<string, Semaphore.Semaphore>() | ||
| const active = new Set<string>() | ||
|
|
||
| const sentinel = (filename: string) => filename + ".setup-lock" | ||
| const SYNC_TIMEOUT_MS = 60_000 | ||
| const POLL_MS = 25 | ||
| const HOLD_TIMEOUT_MS = 60_000 | ||
|
|
||
| export function acquireSync(filename: string): () => void { | ||
| if (filename === ":memory:" || active.has(filename)) return () => {} | ||
|
anandgupta42 marked this conversation as resolved.
|
||
| const lock = openSetupLock(sentinel(filename), SYNC_TIMEOUT_MS) | ||
| try { | ||
| lock.exec("BEGIN EXCLUSIVE") | ||
| } catch (error) { | ||
| lock.close() | ||
| throw error | ||
| } | ||
| return () => { | ||
| try { | ||
| lock.exec("ROLLBACK") | ||
| } finally { | ||
| lock.close() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function isBusy(error: unknown) { | ||
| return /locked|busy/i.test(error instanceof Error ? error.message : String(error)) | ||
| } | ||
|
|
||
| /** Runs `body` holding the lock; waiting is async so in-process peers keep making progress. */ | ||
| export function hold<A, E, R>(filename: string, body: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { | ||
| if (filename === ":memory:") return body | ||
| let semaphore = semaphores.get(filename) | ||
| if (!semaphore) { | ||
| semaphore = Semaphore.makeUnsafe(1) | ||
| semaphores.set(filename, semaphore) | ||
| } | ||
| const acquire = Effect.gen(function* () { | ||
| const started = Date.now() | ||
| while (true) { | ||
| const lock = openSetupLock(sentinel(filename), 0) | ||
| const result = yield* Effect.try({ try: () => lock.exec("BEGIN EXCLUSIVE"), catch: (error) => error }).pipe( | ||
| Effect.map(() => lock), | ||
| Effect.catch((error) => | ||
| Effect.sync(() => lock.close()).pipe( | ||
| Effect.flatMap(() => | ||
| isBusy(error) && Date.now() - started < HOLD_TIMEOUT_MS ? Effect.succeed(undefined) : Effect.die(error), | ||
|
anandgupta42 marked this conversation as resolved.
|
||
| ), | ||
| ), | ||
| ), | ||
| ) | ||
| if (result) { | ||
| active.add(filename) | ||
| return result | ||
| } | ||
| // `acquireUseRelease` makes `acquire` uninterruptible; no handle is open while sleeping, so cancel here. | ||
| yield* Effect.interruptible(Effect.sleep(POLL_MS)) | ||
| } | ||
| }) | ||
|
anandgupta42 marked this conversation as resolved.
|
||
| const release = (lock: { exec: (sql: string) => void; close: () => void }) => | ||
| Effect.sync(() => { | ||
| active.delete(filename) | ||
| try { | ||
| lock.exec("ROLLBACK") | ||
| } finally { | ||
| lock.close() | ||
| } | ||
| }) | ||
| return semaphore.withPermits(1)(Effect.acquireUseRelease(acquire, () => body, release)) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { describe, expect, test } from "bun:test" | ||
| import { Database as BunDatabase } from "bun:sqlite" | ||
| import path from "path" | ||
| import { Effect } from "effect" | ||
| import { layer } from "../src/database/sqlite.bun" | ||
| import { Sqlite } from "../src/database/sqlite" | ||
| import { tmpdir } from "./fixture/tmpdir" | ||
|
|
||
| describe("sqlite.bun read-only open", () => { | ||
| test("rejects writes and leaves the journal mode alone", async () => { | ||
| await using tmp = await tmpdir() | ||
| const filename = path.join(tmp.path, "ro.sqlite") | ||
| const seed = new BunDatabase(filename, { create: true }) | ||
| seed.run("CREATE TABLE t (x INTEGER)") | ||
| seed.close() | ||
|
|
||
| const result = await Effect.runPromise( | ||
| Effect.gen(function* () { | ||
| const native = (yield* Sqlite.Native) as BunDatabase | ||
| const mode = native.query("PRAGMA journal_mode").get() as { journal_mode: string } | ||
| const write = (() => { | ||
| try { | ||
| native.run("INSERT INTO t VALUES (1)") | ||
| return "written" | ||
| } catch (error) { | ||
| return (error as Error).message | ||
| } | ||
| })() | ||
| return { mode: mode.journal_mode, write } | ||
| }).pipe(Effect.provide(layer({ filename, readonly: true })), Effect.scoped), | ||
| ) | ||
|
|
||
| expect(result.mode).toBe("delete") | ||
| expect(result.write).toContain("readonly") | ||
| }) | ||
|
|
||
| test("does not create a missing file", async () => { | ||
| await using tmp = await tmpdir() | ||
| const filename = path.join(tmp.path, "missing.sqlite") | ||
| const exit = await Effect.runPromiseExit( | ||
| Effect.gen(function* () { | ||
| yield* Sqlite.Native | ||
| }).pipe(Effect.provide(layer({ filename, readonly: true })), Effect.scoped), | ||
| ) | ||
| expect(exit._tag).toBe("Failure") | ||
| expect(await Bun.file(filename).exists()).toBe(false) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { describe, expect, test } from "bun:test" | ||
| import fs from "fs/promises" | ||
| import os from "os" | ||
| import path from "path" | ||
|
|
||
| const fixture = path.join(import.meta.dir, "fixture", "open-database.ts") | ||
|
|
||
| async function launch(data: string) { | ||
| const proc = Bun.spawn([process.execPath, "run", fixture], { | ||
| cwd: path.join(import.meta.dir, "..", ".."), | ||
| env: { ...process.env, XDG_DATA_HOME: data, OPENCODE_DB: "", ALTIMATE_CLI_DB: "" }, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }) | ||
| const [stdout, stderr, code] = await Promise.all([ | ||
| new Response(proc.stdout).text(), | ||
| new Response(proc.stderr).text(), | ||
| proc.exited, | ||
| ]) | ||
| return { stdout, stderr, code } | ||
| } | ||
|
|
||
| describe("concurrent first-run database setup", () => { | ||
| test("simultaneous fresh processes never fail with SQLITE_BUSY", async () => { | ||
| // Fresh data directories exercise the WAL switch and migrations, where concurrent starts raced. | ||
| for (let round = 0; round < 3; round++) { | ||
| const data = await fs.mkdtemp(path.join(os.tmpdir(), "altimate-concurrent-db-")) | ||
| try { | ||
| const results = await Promise.all(Array.from({ length: 4 }, () => launch(data))) | ||
| for (const result of results) { | ||
| expect(result.stdout + result.stderr).not.toMatch(/database is locked|SQLITE_BUSY/i) | ||
| expect(result.code).toBe(0) | ||
| expect(result.stdout).toContain("opened") | ||
| } | ||
| } finally { | ||
| await fs.rm(data, { recursive: true, force: true }) | ||
| } | ||
| } | ||
| }, 120_000) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.