Artifact dbdd80ed146544c3d960060b38ba67a567cebd15e70a85b3b13985867caaf1fa:
- File
db/db.go
— part of check-in
[0269bd9461]
at
2026-10-19 14:52:58
on branch feat/submanifest-reader
— fix(db): forget a receipt's row when its transaction rolls back
Receipt.ID took (rcvid, mtime) to identify its own row and checked it with a SELECT on every use. After a rollback the rcvid can be reused by another receipt's insert, and under a frozen clock the mtime matches too, so the receipt attached to the wrong row.
Fossil holds g.rcvid for its process and never re-reads the row (content_rcvid_init); a rollback ends the process with it. A Receipt outlives transactions, so the transaction or savepoint that makes its row now records that, forgets the id if it rolls back, and hands it to the enclosing level when released. A kept id costs no query, which takes one SELECT per stored blob off the clone path.
Also checks that a tree check-in's sub-manifests share the commit's receipt. (user: [email protected], size: 15921) [annotate] [blame] [check-ins using] [more...]
package db import ( "database/sql" "errors" "fmt" "net/url" "os" "sort" "strings" "sync/atomic" ) // CheckpointMode mirrors SQLite's PRAGMA wal_checkpoint(<mode>) argument. type CheckpointMode int const ( // CheckpointPassive checkpoints frames not held by readers; never blocks. // Appropriate for periodic background checkpoints. CheckpointPassive CheckpointMode = iota // CheckpointFull blocks new writers until all frames are checkpointed. CheckpointFull // CheckpointRestart is FULL plus restarts the WAL file. CheckpointRestart // CheckpointTruncate is RESTART plus truncates the WAL file to zero bytes. // Produces the most compact on-disk file and is what Close uses. CheckpointTruncate ) func (m CheckpointMode) String() string { switch m { case CheckpointPassive: return "PASSIVE" case CheckpointFull: return "FULL" case CheckpointRestart: return "RESTART" case CheckpointTruncate: return "TRUNCATE" default: return fmt.Sprintf("CheckpointMode(%d)", int(m)) } } // DB wraps a SQLite database connection. type DB struct { conn *sql.DB path string driver string stmts stmtCache poolExecs atomic.Int64 writeTxns atomic.Int64 // root is set on a DB that WithBoundTx hands its callback: the pool's own // DB, which keeps the counters. bound is the transaction every statement // on it runs in, nil once that transaction has ended. root *DB bound *Tx } // Open opens a SQLite database with the registered driver and default pragmas. func Open(path string) (*DB, error) { return OpenWith(path, OpenConfig{}) } // OpenSQL opens a raw *sql.DB using the registered driver and the same DSN // construction as OpenWith. params are appended as URI query parameters after // driver pragmas, so callers can request options such as mode=ro without // constructing driver-specific DSNs. func OpenSQL(path string, cfg OpenConfig, params map[string]string) (*sql.DB, error) { driver, dsn := SQLDriverAndDSN(path, cfg, params) conn, err := sql.Open(driver, dsn) if err != nil { return nil, fmt.Errorf("db.OpenSQL(%s): %w", driver, err) } if err := applyWASMPragmas(conn, mergedPragmas(cfg.Pragmas)); err != nil { conn.Close() return nil, fmt.Errorf("db.OpenSQL: %w", err) } return conn, nil } // SQLDriverAndDSN returns the driver name and DSN OpenWith would use, with // optional URI query params appended after driver-specific options. func SQLDriverAndDSN(path string, cfg OpenConfig, params map[string]string) (string, string) { if path == "" { panic("db.SQLDriverAndDSN: path must not be empty") } if registered == nil { panic("db.SQLDriverAndDSN: no driver registered — import a driver package (e.g., _ \"github.com/danmestas/go-libfossil/db/driver/modernc\")") } driver := cfg.Driver if driver == "" { driver = registered.Name } pragmas := mergedPragmas(cfg.Pragmas) dsn := buildSQLDSN(path, pragmas) return driver, appendURIParams(dsn, params) } func mergedPragmas(overrides map[string]string) map[string]string { pragmas := DefaultPragmas() for k, v := range overrides { pragmas[k] = v } return pragmas } func buildSQLDSN(path string, pragmas map[string]string) string { if wasmClearPragmas { suffix := wasmDSNSuffix() if suffix != "" { return fmt.Sprintf("file:%s?%s", path, suffix) } return path } return appendURIParamString(registered.BuildDSN(path, pragmas), wasmDSNSuffix()) } func appendURIParamString(dsn, params string) string { if params == "" { return dsn } if strings.Contains(dsn, "?") { return dsn + "&" + params } if strings.HasPrefix(dsn, "file:") { return dsn + "?" + params } return "file:" + dsn + "?" + params } func appendURIParams(dsn string, params map[string]string) string { if len(params) == 0 { return dsn } keys := make([]string, 0, len(params)) for k := range params { keys = append(keys, k) } sort.Strings(keys) var b strings.Builder if strings.Contains(dsn, "?") { b.WriteString(dsn) b.WriteByte('&') } else { if strings.HasPrefix(dsn, "file:") { b.WriteString(dsn) } else { b.WriteString("file:") b.WriteString(dsn) } b.WriteByte('?') } for i, k := range keys { if i > 0 { b.WriteByte('&') } b.WriteString(url.QueryEscape(k)) b.WriteByte('=') b.WriteString(url.QueryEscape(params[k])) } return b.String() } func applyWASMPragmas(conn *sql.DB, pragmas map[string]string) error { if !wasmClearPragmas { return nil } // Apply safe pragmas via SQL. Skip journal_mode (WAL not supported on WASI). // Only apply known-safe pragmas to prevent SQL injection. safePragmas := map[string]bool{ "busy_timeout": true, "foreign_keys": true, } for k, v := range pragmas { if !safePragmas[k] { continue } if _, err := conn.Exec(fmt.Sprintf("PRAGMA %s = %s", k, v)); err != nil { return fmt.Errorf("PRAGMA %s: %w", k, err) } } return nil } // OpenWith opens a SQLite database with explicit configuration. func OpenWith(path string, cfg OpenConfig) (*DB, error) { driver, dsn := SQLDriverAndDSN(path, cfg, nil) pragmas := mergedPragmas(cfg.Pragmas) // WASM targets: skip DSN pragmas (ncruces _pragma syntax fails under WASI // file locking). Open with nolock=1, then apply safe pragmas via SQL. conn, err := sql.Open(driver, dsn) if err != nil { return nil, fmt.Errorf("db.Open(%s): %w", driver, err) } if err := applyWASMPragmas(conn, pragmas); err != nil { conn.Close() return nil, fmt.Errorf("db.Open %w", err) } return &DB{conn: conn, path: path, driver: driver, stmts: stmtCache{src: conn}}, nil } // SqlDB returns the underlying *sql.DB connection. func (d *DB) SqlDB() *sql.DB { if d == nil { panic("db.SqlDB: receiver must not be nil") } d.mustBePool("SqlDB") return d.conn } // Close runs PRAGMA wal_checkpoint(TRUNCATE) before closing the underlying // connection so the on-disk file is readable by external SQLite/fossil // tooling. The WASM/WASI build path does not use WAL, so the checkpoint is // skipped there. Checkpoint errors are joined with the close error so the // connection is always closed regardless of checkpoint outcome. func (d *DB) Close() error { d.mustBePool("Close") var ckptErr error if !wasmClearPragmas { ckptErr = d.Checkpoint(CheckpointTruncate) } // Release cached prepared statements before closing the pool so no // statement handle outlives the connection it was compiled against. d.stmts.closeAll() closeErr := d.conn.Close() return errors.Join(ckptErr, closeErr) } // Checkpoint runs PRAGMA wal_checkpoint(<mode>) against the database. // Safe to call on a live database. PASSIVE never blocks; TRUNCATE produces // the most compact on-disk layout. On a non-WAL database the underlying // PRAGMA is a no-op and returns nil. func (d *DB) Checkpoint(mode CheckpointMode) error { if d == nil { panic("db.Checkpoint: receiver must not be nil") } d.mustBePool("Checkpoint") if mode < CheckpointPassive || mode > CheckpointTruncate { return fmt.Errorf("db.Checkpoint: invalid mode %d", int(mode)) } var busy, log, ckpt int err := d.conn.QueryRow(fmt.Sprintf("PRAGMA wal_checkpoint(%s)", mode)).Scan(&busy, &log, &ckpt) if err != nil { return fmt.Errorf("db.Checkpoint(%s): %w", mode, err) } return nil } func (d *DB) Path() string { return d.path } func (d *DB) Driver() string { return d.driver } // PoolExecs returns how many statements have been executed on the pool rather // than inside a transaction. Each one runs in its own implicit transaction, so // it costs a BEGIN IMMEDIATE and an fsync of its own and contests the database's // single write lock independently of every other. A path that issues one per // artifact therefore degrades from slow to failing the moment anything else // writes, which is what issue #200 cost a large pull. Tests pin the per-round // budget against this counter. func (d *DB) PoolExecs() int64 { if d == nil { panic("db.PoolExecs: receiver must not be nil") } if d.root != nil { return d.root.PoolExecs() } return d.poolExecs.Load() } // WriteTxns counts transactions begun through WithTx. Each is its own // BEGIN IMMEDIATE and fsync, and each takes the single write lock // independently, so a path that opens one per artifact is both slow and // fragile under contention -- the cost issue #200 measured at roughly three // per received file. Tests pin the per-round transaction budget against this, // which PoolExecs cannot see: moving a per-artifact write from the pool into // its own transaction leaves PoolExecs unchanged while the real cost stays. func (d *DB) WriteTxns() int64 { if d == nil { panic("db.WriteTxns: receiver must not be nil") } if d.root != nil { return d.root.WriteTxns() } return d.writeTxns.Load() } func (d *DB) Exec(query string, args ...any) (sql.Result, error) { if tx := d.boundTx(); tx != nil { return tx.Exec(query, args...) } d.poolExecs.Add(1) if !cacheableStmt(query) { return d.conn.Exec(query, args...) } st, err := d.stmts.get(query) if err != nil { return nil, err } return st.Exec(args...) } func (d *DB) QueryRow(query string, args ...any) *sql.Row { if tx := d.boundTx(); tx != nil { return tx.QueryRow(query, args...) } if !cacheableStmt(query) { return d.conn.QueryRow(query, args...) } st, err := d.stmts.get(query) if err != nil { // Preserve database/sql's deferred-error contract: a prepare failure // must surface from Row.Scan, never here. The raw QueryRow re-attempts // the prepare internally and returns a *sql.Row that yields the error // on Scan — identical to the pre-cache behavior. return d.conn.QueryRow(query, args...) } return st.QueryRow(args...) } func (d *DB) Query(query string, args ...any) (*sql.Rows, error) { if tx := d.boundTx(); tx != nil { return tx.Query(query, args...) } if !cacheableStmt(query) { return d.conn.Query(query, args...) } st, err := d.stmts.get(query) if err != nil { return nil, err } return st.Query(args...) } func (d *DB) SetApplicationID(id int32) error { if tx := d.boundTx(); tx != nil { _, err := tx.tx.Exec(fmt.Sprintf("PRAGMA application_id=%d", id)) return err } _, err := d.conn.Exec(fmt.Sprintf("PRAGMA application_id=%d", id)) return err } func (d *DB) ApplicationID() (int32, error) { var id int32 if tx := d.boundTx(); tx != nil { err := tx.tx.QueryRow("PRAGMA application_id").Scan(&id) return id, err } err := d.conn.QueryRow("PRAGMA application_id").Scan(&id) return id, err } type Tx struct { tx *sql.Tx stmts stmtCache // savepoints is how many savepoints a bound DB's nested WithTx has open // in this transaction; it names the next one. savepoints int // receipt is what content stored in this transaction is recorded under; // see ReceiptID. receipt *Receipt // made are the receipts whose rows this transaction made and has not // committed; see Receipt.ID. made []madeReceipt } func (t *Tx) Exec(query string, args ...any) (sql.Result, error) { if !cacheableStmt(query) { return t.tx.Exec(query, args...) } st, err := t.stmts.get(query) if err != nil { return nil, err } return st.Exec(args...) } func (t *Tx) QueryRow(query string, args ...any) *sql.Row { if !cacheableStmt(query) { return t.tx.QueryRow(query, args...) } st, err := t.stmts.get(query) if err != nil { // Preserve the deferred-error contract; see DB.QueryRow. return t.tx.QueryRow(query, args...) } return st.QueryRow(args...) } func (t *Tx) Query(query string, args ...any) (*sql.Rows, error) { if !cacheableStmt(query) { return t.tx.Query(query, args...) } st, err := t.stmts.get(query) if err != nil { return nil, err } return st.Query(args...) } // WithTx runs fn in a write transaction, which commits when fn returns nil // and rolls back otherwise. On a DB that WithBoundTx bound, it runs fn in a // savepoint of the bound transaction instead, so it neither waits for a // second connection nor commits on its own: fn's error undoes only fn's // writes, and the bound transaction decides whether the rest commits. func (d *DB) WithTx(fn func(tx *Tx) error) error { if d == nil { panic("db.WithTx: receiver must not be nil") } if fn == nil { panic("db.WithTx: fn must not be nil") } if tx := d.boundTx(); tx != nil { return tx.withSavepoint(fn) } d.writeTxns.Add(1) sqlTx, err := d.conn.Begin() if err != nil { return fmt.Errorf("db.WithTx begin: %w", err) } tx := &Tx{tx: sqlTx, stmts: stmtCache{src: sqlTx}} committed := false defer func() { if !committed { tx.forgetFrom(0) } // A statement prepared against this transaction cannot outlive it, so // close the whole cache before the transaction ends. This runs on // every path (commit, error, panic); Close is idempotent, so closing // again after a successful Commit is a safe no-op. tx.stmts.closeAll() if rbErr := sqlTx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone { fmt.Fprintf(os.Stderr, "db.WithTx: rollback failed: %v\n", rbErr) } }() if err := fn(tx); err != nil { return err } if err := sqlTx.Commit(); err != nil { return err } committed = true return nil } // WithBoundTx runs fn in one write transaction and hands it a DB bound to that // transaction. Every statement on the bound DB runs in the transaction, and // every WithTx or WithBoundTx on it is a savepoint there, so code written // against a *DB, including code that opens its own transactions, joins this // one instead of contending for the write lock from a second connection. The // transaction commits when fn returns nil and rolls back everything fn did // otherwise. The bound DB is valid only until fn returns, and it cannot be // closed or checkpointed, nor hand out its *sql.DB. On a DB that is itself // bound, WithBoundTx is a savepoint of that transaction, as WithTx is. func (d *DB) WithBoundTx(fn func(bound *DB) error) error { if d == nil { panic("db.WithBoundTx: receiver must not be nil") } if fn == nil { panic("db.WithBoundTx: fn must not be nil") } if d.boundTx() != nil { return d.WithTx(func(*Tx) error { return fn(d) }) } return d.WithTx(func(tx *Tx) error { b := &DB{path: d.path, driver: d.driver, root: d, bound: tx} // Unbind on every path, so a bound DB kept past fn fails loudly // rather than running on a finished transaction. defer func() { b.bound = nil }() return fn(b) }) } // boundTx returns the transaction a bound DB runs in, nil for the pool's DB. // A bound DB used after its transaction ended is a programmer error. func (d *DB) boundTx() *Tx { if d.root != nil && d.bound == nil { panic("db: a bound DB was used after its transaction ended") } return d.bound } // mustBePool panics when op is called on a bound DB, which does not own the // pool it would act on. func (d *DB) mustBePool(op string) { if d.root != nil { panic("db." + op + ": not allowed on a DB bound to a transaction") } } // withSavepoint runs fn in a savepoint of t. fn's error rolls back to the // savepoint and is returned, leaving t open for the writes before it. func (t *Tx) withSavepoint(fn func(tx *Tx) error) (err error) { t.savepoints++ level := t.savepoints name := fmt.Sprintf("withtx_%d", level) defer func() { t.savepoints-- }() if _, err := t.tx.Exec("SAVEPOINT " + name); err != nil { return fmt.Errorf("db.WithTx savepoint: %w", err) } done := false defer func() { if done { t.keepTo(level) return } t.forgetFrom(level) // fn failed or panicked: undo its writes. A failure here means // SQLite already ended the whole transaction, which its commit // will then report. _, rbErr := t.tx.Exec("ROLLBACK TO " + name) _, relErr := t.tx.Exec("RELEASE " + name) if rbErr != nil || relErr != nil { err = errors.Join(err, rbErr, relErr) } }() if err := fn(t); err != nil { return err } if _, err := t.tx.Exec("RELEASE " + name); err != nil { return fmt.Errorf("db.WithTx release: %w", err) } done = true return nil }