Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions packages/core/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ export * as Database from "./database"

import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite"
// altimate_change start — upstream_fix: cross-process first-run setup lock
import * as SetupLock from "./setup-lock"
// altimate_change end
import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
Expand All @@ -19,26 +22,32 @@ export interface Interface {

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
// altimate_change start — upstream_fix: `layer` takes the database filename so first-run setup can be serialized
export const layer = (filename?: string) =>
Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase

yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* DatabaseMigration.apply(db)
yield* db.run("PRAGMA busy_timeout = 5000")
const setup = Effect.gen(function* () {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* DatabaseMigration.apply(db)
})
yield* filename ? SetupLock.hold(filename, setup) : setup

return { db }
}).pipe(Effect.orDie),
)
return { db }
}).pipe(Effect.orDie),
)

export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
return layer(filename).pipe(Layer.provide(sqliteLayer({ filename })))
}
// altimate_change end

export function path() {
if (Flag.OPENCODE_DB) {
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/database/setup-lock.ts
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.
Comment thread
anandgupta42 marked this conversation as resolved.
*/
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 () => {}
Comment thread
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),
Comment thread
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))
}
})
Comment thread
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))
}
31 changes: 28 additions & 3 deletions packages/core/src/database/sqlite.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { Sqlite } from "./sqlite"
// altimate_change start — upstream_fix: cross-process first-run setup lock
import * as SetupLock from "./setup-lock"
// altimate_change end

const ATTR_DB_SYSTEM_NAME = "db.system.name"

Expand Down Expand Up @@ -155,13 +158,27 @@ const nativeLayer = (config: Config) =>
Layer.effect(
Sqlite.Native,
Effect.gen(function* () {
// altimate_change start — upstream_fix: Bun honours readwrite/create over readonly, so a read-only open
// with the defaults could still write; read-only now disables both.
const native = new Database(config.filename, {
readonly: config.readonly,
readwrite: config.readwrite ?? true,
create: config.create ?? true,
readwrite: config.readonly === true ? false : (config.readwrite ?? true),
create: config.readonly === true ? false : (config.create ?? true),
})
// altimate_change end
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;")
// altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with
// SQLITE_BUSY, so install the busy handler first and serialize the switch across processes.
native.run("PRAGMA busy_timeout = 5000;")
if (config.disableWAL !== true && config.readonly !== true) {
const release = SetupLock.acquireSync(config.filename)
try {
native.run("PRAGMA journal_mode = WAL;")
} finally {
release()
}
}
// altimate_change end
return native
}),
)
Expand All @@ -181,3 +198,11 @@ export const layer = (config: Config) => {
Layer.provide(Reactivity.layer),
)
}

// altimate_change start — upstream_fix: sentinel database used as a cross-process mutex (see setup-lock.ts)
export function openSetupLock(filename: string, timeoutMs: number) {
const lock = new Database(filename, { create: true })
lock.run(`PRAGMA busy_timeout = ${timeoutMs}`)
return { exec: (sql: string) => void lock.run(sql), close: () => lock.close() }
}
// altimate_change end
24 changes: 23 additions & 1 deletion packages/core/src/database/sqlite.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { Sqlite } from "./sqlite"
// altimate_change start — upstream_fix: cross-process first-run setup lock
import * as SetupLock from "./setup-lock"
// altimate_change end

const ATTR_DB_SYSTEM_NAME = "db.system.name"

Expand Down Expand Up @@ -156,7 +159,18 @@ const nativeLayer = (config: Config) =>
open: true,
})
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;")
// altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with
// SQLITE_BUSY, so install the busy handler first and serialize the switch across processes.
native.exec(`PRAGMA busy_timeout = ${config.timeout ?? 5000};`)
if (config.disableWAL !== true && config.readonly !== true) {
const release = SetupLock.acquireSync(config.filename)
try {
native.exec("PRAGMA journal_mode = WAL;")
} finally {
release()
}
}
// altimate_change end
return native
}),
)
Expand All @@ -176,3 +190,11 @@ export const layer = (config: Config) => {
Layer.provide(Reactivity.layer),
)
}

// altimate_change start — upstream_fix: sentinel database used as a cross-process mutex (see setup-lock.ts)
export function openSetupLock(filename: string, timeoutMs: number) {
const lock = new DatabaseSync(filename, { timeout: timeoutMs })
lock.exec(`PRAGMA busy_timeout = ${timeoutMs}`)
return { exec: (sql: string) => void lock.exec(sql), close: () => lock.close() }
}
// altimate_change end
48 changes: 48 additions & 0 deletions packages/core/test/sqlite-readonly.test.ts
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)
})
})
36 changes: 32 additions & 4 deletions packages/opencode/src/storage/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { Flag } from "../flag/flag"
// altimate_change start — upstream_fix: cross-process first-run setup lock
import * as SetupLock from "@opencode-ai/core/database/setup-lock"
// altimate_change end
import { iife } from "@/util/iife"
import { Effect } from "effect"
import freshSchema from "@opencode-ai/core/database/schema.gen"
Expand Down Expand Up @@ -394,13 +397,28 @@ export namespace Database {

export const Client = lazy(() => {
log.info("opening database", { path: Path })
// altimate_change start — upstream_fix: serialize first-run setup across processes (see core setup-lock.ts)
const releaseSetupLock = SetupLock.acquireSync(Path)
try {
return open()
} finally {
releaseSetupLock()
}
// altimate_change end
})

// altimate_change start — upstream_fix: setup extracted from the lazy Client so it runs under the setup lock
function open() {
Comment thread
anandgupta42 marked this conversation as resolved.
const sqlite = new BunDatabase(Path, { create: true })
state.sqlite = sqlite

// altimate_change start — upstream_fix: install the busy handler before the first statement that
// takes a lock. Switching a fresh file to WAL needs an exclusive lock, so concurrent first-run
// processes failed immediately with "database is locked" when this came after journal_mode.
sqlite.run("PRAGMA busy_timeout = 5000")
// altimate_change end
sqlite.run("PRAGMA journal_mode = WAL")
sqlite.run("PRAGMA synchronous = NORMAL")
sqlite.run("PRAGMA busy_timeout = 5000")
sqlite.run("PRAGMA cache_size = -64000")
sqlite.run("PRAGMA foreign_keys = ON")
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
Expand All @@ -422,8 +440,17 @@ export namespace Database {
item.sql = "select 1;"
}
}
const adopted = adoptCoreOwnedDatabase(sqlite, entries)
const initialized = adopted ? false : initializeFreshDatabase(sqlite, entries)
// altimate_change start — upstream_fix: the schema-state checks share the write transaction, so a
// same-process core migration cannot create the schema between a check and the CREATE TABLEs
let adopted = false
let initialized = false
sqlite
.transaction(() => {
adopted = adoptCoreOwnedDatabase(sqlite, entries)
initialized = adopted ? false : initializeFreshDatabase(sqlite, entries)
})
.immediate()
// altimate_change end
const repaired = adopted || initialized ? false : repairLegacyDatabase(sqlite, entries)
// altimate_change start — backfill migration names before migrate
backfillMigrationNames(sqlite, entries)
Expand All @@ -446,7 +473,8 @@ export namespace Database {
}

return db
})
}
// altimate_change end

export function close() {
const sqlite = state.sqlite
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/storage/concurrent-open.test.ts
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)
})
Loading
Loading