a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
221 lines
8.9 KiB
Go
221 lines
8.9 KiB
Go
package persistence
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
sqlite "modernc.org/sqlite"
|
|
)
|
|
|
|
// Sidecar schema migrations.
|
|
//
|
|
// The sidecar DB has no migration framework historically: sidecarSchema was
|
|
// run with CREATE TABLE / CREATE INDEX IF NOT EXISTS, and columns added after
|
|
// a table's original shape were retrofitted with best-effort ALTERs. That
|
|
// breaks when an index (or any statement) inside sidecarSchema references a
|
|
// column the IF-NOT-EXISTS create never adds to a pre-existing table — the
|
|
// whole schema batch aborts with "no such column" and the install can no
|
|
// longer open its ledger.
|
|
//
|
|
// runMigrations replaces that with a forward-only, version-stamped sequence
|
|
// keyed on SQLite's built-in PRAGMA user_version. sidecarSchema keeps only the
|
|
// idempotent base shape (tables + original-shape indexes); every later column
|
|
// and every column-dependent index lives in a migration that runs after the
|
|
// column's ALTER. Existing installs upgrade in place on the next OpenSidecar —
|
|
// no user action, no data loss (migrations are additive only).
|
|
//
|
|
// Concurrency: applyOne relies on the sidecar DSN's _txlock=immediate so
|
|
// db.Begin() takes the write lock at BEGIN, making the in-transaction
|
|
// user_version check authoritative. Two processes opening a stale DB at once
|
|
// serialise on busy_timeout; the loser re-reads the bumped version and skips.
|
|
|
|
// migration is one forward step. Steps are append-only and ascending: never
|
|
// edit or renumber a migration that has shipped — add a new higher version.
|
|
type migration struct {
|
|
version int
|
|
name string
|
|
fn func(tx *sql.Tx) error
|
|
}
|
|
|
|
// currentSidecarVersion is the schema version a fully-migrated DB reports via
|
|
// PRAGMA user_version. It must equal the highest version in sidecarMigrations.
|
|
const currentSidecarVersion = 1
|
|
|
|
// sidecarMigrations is the ordered, forward-only migration list.
|
|
var sidecarMigrations = []migration{
|
|
{version: 1, name: "baseline-reconcile", fn: migrateV1Baseline},
|
|
}
|
|
|
|
// runMigrations applies every pending migration in order. Each runs in its own
|
|
// IMMEDIATE-locked transaction and bumps user_version on success, so a failure
|
|
// at version N leaves versions < N committed and stamped and is safe to retry
|
|
// on the next open.
|
|
func runMigrations(db *sql.DB) error {
|
|
for _, m := range sidecarMigrations {
|
|
if err := applyOne(db, m); err != nil {
|
|
return fmt.Errorf("persistence: sidecar migration v%d (%s): %w", m.version, m.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isSidecarBusyErr reports whether err is a SQLite BUSY/LOCKED result code
|
|
// from the modernc driver (matching extended codes by their base value).
|
|
func isSidecarBusyErr(err error) bool {
|
|
var se *sqlite.Error
|
|
if !errors.As(err, &se) {
|
|
return false
|
|
}
|
|
// Mask the high byte so extended codes (SQLITE_BUSY_SNAPSHOT, ...) match
|
|
// their base SQLITE_BUSY (5) / SQLITE_LOCKED (6).
|
|
switch se.Code() & 0xff {
|
|
case 5, 6:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// withSidecarBusyRetry runs fn, retrying on a SQLite BUSY/LOCKED error with
|
|
// bounded exponential backoff.
|
|
//
|
|
// busy_timeout (set in the sidecar DSN) covers ordinary write-lock contention
|
|
// once the file is in WAL mode, but it does NOT cover the rollback-journal ->
|
|
// WAL conversion the very first opener performs: that conversion takes a brief
|
|
// EXCLUSIVE lock whose acquisition SQLite answers with an immediate, un-retried
|
|
// SQLITE_BUSY (the busy handler is not consulted for a journal-mode change)
|
|
// when another process has the file open. The daemon, every per-repo
|
|
// `gortex mcp` subprocess, and the CLI can all open a fresh/stale sidecar at
|
|
// the same instant, so a bounded application-level retry is required on top of
|
|
// busy_timeout. fn must be idempotent — runBaseSchema (CREATE ... IF NOT
|
|
// EXISTS) and runMigrations (user_version-gated) both are.
|
|
func withSidecarBusyRetry(fn func() error) error {
|
|
const (
|
|
maxAttempts = 40
|
|
baseDelay = 5 * time.Millisecond
|
|
maxDelay = 250 * time.Millisecond
|
|
)
|
|
delay := baseDelay
|
|
var err error
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
|
if err = fn(); err == nil || !isSidecarBusyErr(err) {
|
|
return err
|
|
}
|
|
time.Sleep(delay)
|
|
if delay *= 2; delay > maxDelay {
|
|
delay = maxDelay
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// runBaseSchema applies the idempotent base shape (sidecarSchema) inside one
|
|
// IMMEDIATE-locked transaction. The sidecar DSN's _txlock=immediate makes
|
|
// db.Begin() emit BEGIN IMMEDIATE, so the reserved write lock is held from the
|
|
// start of the batch.
|
|
//
|
|
// This matters for the same reason applyOne wraps its work: the base schema's
|
|
// CREATE TABLE / CREATE INDEX IF NOT EXISTS statements write, and run in
|
|
// autocommit each one first takes a read lock and then tries to promote to a
|
|
// write lock. SQLite answers that promotion with an un-retryable SQLITE_BUSY
|
|
// (busy_timeout is not consulted on a lock upgrade) when another process is
|
|
// concurrently creating the same objects — so several gortex processes opening
|
|
// a fresh or stale DB at once would race here, before runMigrations' own
|
|
// IMMEDIATE transactions ever run, and the losers would fail the open. Taking
|
|
// the write lock at BEGIN instead makes a concurrent opener block on
|
|
// busy_timeout and then re-run the IF NOT EXISTS statements as clean no-ops, so
|
|
// every opener succeeds.
|
|
func runBaseSchema(db *sql.DB) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }() // no-op once Commit succeeds
|
|
if _, err := tx.Exec(sidecarSchema); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// applyOne applies a single migration if the DB's user_version is below it.
|
|
//
|
|
// The sidecar DSN sets _txlock=immediate, so db.Begin() emits BEGIN IMMEDIATE
|
|
// and the reserved write lock is held before the user_version read below —
|
|
// the gate is therefore authoritative across processes. A concurrent opener
|
|
// blocks on busy_timeout at BEGIN, then (this winner having committed) reads
|
|
// the bumped version and returns without writing. PRAGMA user_version is
|
|
// transactional, so on any failure the deferred Rollback reverts both the
|
|
// partial DDL and the version bump.
|
|
func applyOne(db *sql.DB, m migration) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("begin: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }() // no-op once Commit succeeds
|
|
|
|
var cur int
|
|
if err := tx.QueryRow("PRAGMA user_version").Scan(&cur); err != nil {
|
|
return fmt.Errorf("read user_version: %w", err)
|
|
}
|
|
if cur >= m.version {
|
|
return nil // already applied (this or another process)
|
|
}
|
|
|
|
if err := m.fn(tx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// PRAGMA takes no bound parameters; m.version is an int constant we own.
|
|
// Bumped last so it rolls back with the transaction on any earlier error.
|
|
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", m.version)); err != nil {
|
|
return fmt.Errorf("bump user_version: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// migrateV1Baseline reconciles any historical sidecar shape (which all report
|
|
// user_version 0) to the current schema. It is purely additive and idempotent:
|
|
// CREATE TABLE IF NOT EXISTS for every table runs earlier in sidecarSchema, so
|
|
// this only adds the columns and column-dependent indexes that a pre-existing
|
|
// table cannot gain from CREATE TABLE.
|
|
//
|
|
// savings_events.model / .client are the only column-level drift in the whole
|
|
// sidecar history; every other shape difference is a whole-table addition
|
|
// already covered by the base schema.
|
|
func migrateV1Baseline(tx *sql.Tx) error {
|
|
if err := addColumnIfMissingTx(tx, "savings_events", "model", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
if err := addColumnIfMissingTx(tx, "savings_events", "client", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
// Column-dependent indexes LAST — only after the columns are guaranteed to
|
|
// exist. These are exactly the statements that aborted sidecarSchema on a
|
|
// database created before model/client existed.
|
|
if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_savings_events_model ON savings_events (model, ts)`); err != nil {
|
|
return fmt.Errorf("index idx_savings_events_model: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_savings_events_client ON savings_events (client, ts)`); err != nil {
|
|
return fmt.Errorf("index idx_savings_events_client: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// addColumnIfMissingTx runs ALTER TABLE ... ADD COLUMN inside a transaction,
|
|
// swallowing ONLY the "duplicate column name" error that a table which already
|
|
// has the column returns (idempotency for fresh / already-upgraded databases).
|
|
// Every other error propagates — surfacing a genuine failure with its real
|
|
// cause instead of letting a later column-dependent statement fail with a
|
|
// misleading "no such column".
|
|
func addColumnIfMissingTx(tx *sql.Tx, table, column, decl string) error {
|
|
if _, err := tx.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + decl); err != nil {
|
|
if strings.Contains(err.Error(), "duplicate column name") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("add column %s.%s: %w", table, column, err)
|
|
}
|
|
return nil
|
|
}
|