{"record":{"id":"bcf0a093676449c0","repo":"gastownhall/beads","slug":"snapshotting-dirty-tables-before-s-w","errorCode":null,"errorMessage":"snapshotting dirty tables before %s: %w","messagePattern":"snapshotting dirty tables before (.+?): %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/schema/schema.go","lineNumber":1661,"sourceCode":"\t\t// Snapshot the working set BEFORE the pre-migration repair runs, not\n\t\t// just before the migration's own SQL. preMigrationRepair (below) can\n\t\t// itself mutate synced tables (e.g. #4690's ensureDependenciesIDColumn\n\t\t// ALTERs `dependencies`); snapshotting after it ran would misclassify\n\t\t// that mutation as pre-existing dirt to exclude from this step's\n\t\t// commit, so the repair would sit uncommitted in the working set while\n\t\t// the cursor row for this version was already committed -- a killed\n\t\t// process between this step and the pass's final commit would leave\n\t\t// history claiming the version applied while the repaired table's\n\t\t// change was never durably recorded, and the version-gated repair\n\t\t// hook cannot re-run to fix it (its version is no longer pending).\n\t\t// Snapshotting first makes repair-hook mutations count as this step's\n\t\t// own newly-dirtied work, so they land in the same atomic commit as\n\t\t// the migration and its cursor row.\n\t\tvar dirtyBeforeStep map[string]dirtyTableState\n\t\tif commitEachStep {\n\t\t\tdirtyBeforeStep, err = dirtyTables(ctx, db, true)\n\t\t\tif err != nil {\n\t\t\t\treturn count, fmt.Errorf(\"snapshotting dirty tables before %s: %w\", mf.name, err)\n\t\t\t}\n\t\t}\n\n\t\tif err := src.preMigrationRepair(ctx, db, mf.version); err != nil {\n\t\t\treturn count, fmt.Errorf(\"pre-repair for migration %s: %w\", mf.name, err)\n\t\t}\n\n\t\tfmt.Fprintf(stderr, \"Applying migration %04d: %s…\\n\", mf.version, humanMigrationName(mf.name))\n\t\tstart := time.Now()\n\t\tif err := execMigrationBody(ctx, db, string(data)); err != nil {\n\t\t\treturn count, fmt.Errorf(\"migration %s: %w\", mf.name, err)\n\t\t}\n\t\tsum := sha256.Sum256(data)\n\t\tcontentHash := hex.EncodeToString(sum[:])\n\t\tif _, err := db.ExecContext(ctx, \"INSERT IGNORE INTO \"+src.cursorTable+\" (version, content_hash) VALUES (?, ?)\", mf.version, contentHash); err != nil {\n\t\t\treturn count, fmt.Errorf(\"recording %s in %s: %w\", mf.name, src.cursorTable, err)\n\t\t}\n\t\tcount++","sourceCodeStart":1643,"sourceCodeEnd":1679,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/schema/schema.go#L1643-L1679","documentation":"This error wraps a failure from dirtyTables(ctx, db, true) which snapshots the current state of dirty (uncommitted-adjacent) tables before each migration step, when commitEachStep is enabled (internal/storage/schema/schema.go:1661). The library throws it because per-step commits require knowing which tables were dirty BEFORE the pre-migration repair runs, so re-dirtied work can be folded into the migration's atomic commit; without a valid snapshot the step cannot proceed safely.","triggerScenarios":"Running migrations with commitEachStep=true when the dirtyTables query against the live database fails — the underlying snapshot query errors due to connection loss, permissions on bookkeeping/system tables, or a corrupted dirty-state tracking table.","commonSituations":"Connection dropped mid-migration (network blip, server restart, wait_timeout); DB user lacks rights to read the dirty-state bookkeeping tables; migrating against a replica or managed DB that restricts the snapshot queries; long-running migration window exceeding connection timeouts.","solutions":["Inspect the wrapped error for the exact SQL/connection failure","Reconnect and re-run migrations — dirty-state snapshotting is safe to retry before a step","Verify the DB user can read the dirty-tracking/bookkeeping tables used by dirtyTables","Increase connection idle/hold timeouts for long migration runs and ensure keepalives","Run against the primary, not a read-only replica, since snapshots may need current local state"],"exampleFix":"// before: single pooled connection dies mid-run\nrows, err := db.QueryContext(ctx, snapshotSQL)\n// after: validate/refresh the connection before snapshotting\nif err := db.PingContext(ctx); err != nil {\n    return count, fmt.Errorf(\"db connection lost before snapshot: %w\", err)\n}\ndirtyBeforeStep, err = dirtyTables(ctx, db, true)","handlingStrategy":"retry","validationCode":"// Ensure the connection is alive and dirty-tracking tables are readable before migrating.\nif err := db.PingContext(ctx); err != nil {\n    return fmt.Errorf(\"connection not ready: %w\", err)\n}\nprobe, err := dirtyTables(ctx, db, true)\nif err != nil {\n    return fmt.Errorf(\"dirty-table snapshot unavailable before migrate: %w\", err)\n}\n_ = probe","typeGuard":"func snapshotAvailable(ctx context.Context, db DBConn) bool {\n    _, err := dirtyTables(ctx, db, true)\n    return err == nil\n}","tryCatchPattern":"var dirtyBeforeStep map[string]dirtyTableState\nfor attempt := 0; attempt < 3; attempt++ {\n    dirtyBeforeStep, err = dirtyTables(ctx, db, true)\n    if err == nil {\n        break\n    }\n    if isConnectionError(err) { // check wrapped driver error codes\n        time.Sleep(backoff(attempt))\n        continue\n    }\n    return count, fmt.Errorf(\"snapshotting dirty tables: %w\", err)\n}","preventionTips":["Take a fresh snapshot of dirty state immediately before starting migrations","Run migrations on a stable primary connection with adequate idle timeouts","Schedule migrations in a maintenance window to avoid connection churn","Grant the migration user read access to all bookkeeping tables used by dirtyTables","Retry the whole run from scratch after a connection loss — per-step snapshots are pre-commit and safe to redo"],"tags":["database","migration","snapshot","connection"],"backgroundTag":"dirty-table-snapshot-failed","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}