Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGESET.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ Newest first. Each entry names the upstream range brought in.

### Runtime fixes

- **#155** Restore the SQLite lock-wait window that #88 cut to about 250ms (swxtchio/swx-opencode#153): every
runtime client keeps its short native attempt but retries a busy statement asynchronously for 5s of elapsed
contention, the old `busy_timeout` window, so a competing writer on the shared store no longer fails accepted
prompts and the event loop keeps turning. A stale-snapshot write (`SQLITE_BUSY_SNAPSHOT`), which no statement retry
can recover, now fails at once instead of holding the connection for the window (swxtchio/swx-opencode#154).
_Fork-only._
- **#144** Make `opencode db path` print the selected path without initializing `AppRuntime` or SQLite, defer global directory creation to runtime initialization, and preserve explicit and channel-specific database path selection. _Upstreamable._
- **#141** Reconcile `.git/HEAD` after an early or missed native callback through a shared read/change/publish path; keep a
closed native watch `unavailable` while polling still delivers changed branch values. Cover root-off switches, native
Expand Down
35 changes: 27 additions & 8 deletions packages/core/src/database/sqlite.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,23 @@ import { Sqlite } from "./sqlite"

const ATTR_DB_SYSTEM_NAME = "db.system.name"
const nativeBusyTimeoutMs = 5
// Five 5ms native attempts plus four 25/50/75/75ms backoffs target ~250ms total.
const retrySchedule = Schedule.exponential("25 millis").pipe(
Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 75)))),
Schedule.take(4),
// Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts
// so a contended statement never blocks the event loop. The window runs from the first failed retry, and the
// backoff cap matches SQLite's own busy-handler sleep.
const retrySchedule = Schedule.exponential("10 millis").pipe(
Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))),
Schedule.jittered,
Schedule.both(Schedule.during("5 seconds")),
)

// A stale read snapshot fails every retry of the same statement; only restarting its transaction can recover.
const retryable = (error: SqlError) => {
const cause = error.reason.cause
const snapshot =
typeof cause === "object" && cause !== null && "code" in cause && cause.code === "SQLITE_BUSY_SNAPSHOT"
return error.reason.isRetryable && !snapshot
}

const statementError = (cause: unknown) => {
const reason = classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" })
if (reason._tag !== "LockTimeoutError") return new SqlError({ reason })
Expand Down Expand Up @@ -85,21 +96,29 @@ const make = (options: Config) =>
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers))
return Effect.try({
const execute = Effect.try({
try: () => (statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>,
catch: statementError,
}).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable }))
})
// Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off.
return execute.pipe(
Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })),
)
})

const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers))
return Effect.try({
const execute = Effect.try({
try: () => (statement.values(...(params as any)) ?? []) as Array<unknown[]>,
catch: statementError,
}).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable }))
})
// Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off.
return execute.pipe(
Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })),
)
})

const connection = identity<SqliteConnection>({
Expand Down
36 changes: 28 additions & 8 deletions packages/core/src/database/sqlite.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,24 @@ import { Sqlite } from "./sqlite"

const ATTR_DB_SYSTEM_NAME = "db.system.name"
const nativeBusyTimeoutMs = 5
// Five 5ms native attempts plus four 25/50/75/75ms backoffs target ~250ms total.
const retrySchedule = Schedule.exponential("25 millis").pipe(
Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 75)))),
Schedule.take(4),
const sqliteBusySnapshot = 517
// Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts
// so a contended statement never blocks the event loop. The window runs from the first failed retry, and the
// backoff cap matches SQLite's own busy-handler sleep.
const retrySchedule = Schedule.exponential("10 millis").pipe(
Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))),
Schedule.jittered,
Schedule.both(Schedule.during("5 seconds")),
)

// A stale read snapshot fails every retry of the same statement; only restarting its transaction can recover.
const retryable = (error: SqlError) => {
const cause = error.reason.cause
const snapshot =
typeof cause === "object" && cause !== null && "errcode" in cause && cause.errcode === sqliteBusySnapshot
return error.reason.isRetryable && !snapshot
}

const statementError = (cause: unknown) => {
const errcode =
typeof cause === "object" && cause !== null && "errcode" in cause && typeof cause.errcode === "number"
Expand Down Expand Up @@ -93,21 +105,29 @@ const make = (options: Config) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
return Effect.try({
const execute = Effect.try({
try: () => statement.all(...(params as SQLInputValue[])) as Array<Record<string, unknown>>,
catch: statementError,
}).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable }))
})
// Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off.
return execute.pipe(
Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })),
)
})

const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
statement.setReturnArrays(true)
return Effect.try({
const execute = Effect.try({
try: () => statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray<ReadonlyArray<unknown>>,
catch: statementError,
}).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable }))
})
// Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off.
return execute.pipe(
Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })),
)
})

const connection = identity<SqliteConnection>({
Expand Down
Loading
Loading