gastownhall/beads · error

snapshotting dirty tables before %s: %w

Error message

snapshotting dirty tables before %s: %w

What it means

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.

Source

Thrown at internal/storage/schema/schema.go:1661

		// Snapshot the working set BEFORE the pre-migration repair runs, not
		// just before the migration's own SQL. preMigrationRepair (below) can
		// itself mutate synced tables (e.g. #4690's ensureDependenciesIDColumn
		// ALTERs `dependencies`); snapshotting after it ran would misclassify
		// that mutation as pre-existing dirt to exclude from this step's
		// commit, so the repair would sit uncommitted in the working set while
		// the cursor row for this version was already committed -- a killed
		// process between this step and the pass's final commit would leave
		// history claiming the version applied while the repaired table's
		// change was never durably recorded, and the version-gated repair
		// hook cannot re-run to fix it (its version is no longer pending).
		// Snapshotting first makes repair-hook mutations count as this step's
		// own newly-dirtied work, so they land in the same atomic commit as
		// the migration and its cursor row.
		var dirtyBeforeStep map[string]dirtyTableState
		if commitEachStep {
			dirtyBeforeStep, err = dirtyTables(ctx, db, true)
			if err != nil {
				return count, fmt.Errorf("snapshotting dirty tables before %s: %w", mf.name, err)
			}
		}

		if err := src.preMigrationRepair(ctx, db, mf.version); err != nil {
			return count, fmt.Errorf("pre-repair for migration %s: %w", mf.name, err)
		}

		fmt.Fprintf(stderr, "Applying migration %04d: %s…\n", mf.version, humanMigrationName(mf.name))
		start := time.Now()
		if err := execMigrationBody(ctx, db, string(data)); err != nil {
			return count, fmt.Errorf("migration %s: %w", mf.name, err)
		}
		sum := sha256.Sum256(data)
		contentHash := hex.EncodeToString(sum[:])
		if _, err := db.ExecContext(ctx, "INSERT IGNORE INTO "+src.cursorTable+" (version, content_hash) VALUES (?, ?)", mf.version, contentHash); err != nil {
			return count, fmt.Errorf("recording %s in %s: %w", mf.name, src.cursorTable, err)
		}
		count++

View on GitHub (pinned to 71377f2769)

Solutions

  1. Inspect the wrapped error for the exact SQL/connection failure
  2. Reconnect and re-run migrations — dirty-state snapshotting is safe to retry before a step
  3. Verify the DB user can read the dirty-tracking/bookkeeping tables used by dirtyTables
  4. Increase connection idle/hold timeouts for long migration runs and ensure keepalives
  5. Run against the primary, not a read-only replica, since snapshots may need current local state

Example fix

// before: single pooled connection dies mid-run
rows, err := db.QueryContext(ctx, snapshotSQL)
// after: validate/refresh the connection before snapshotting
if err := db.PingContext(ctx); err != nil {
    return count, fmt.Errorf("db connection lost before snapshot: %w", err)
}
dirtyBeforeStep, err = dirtyTables(ctx, db, true)
Defensive patterns

Strategy: retry

Validate before calling

// Ensure the connection is alive and dirty-tracking tables are readable before migrating.
if err := db.PingContext(ctx); err != nil {
    return fmt.Errorf("connection not ready: %w", err)
}
probe, err := dirtyTables(ctx, db, true)
if err != nil {
    return fmt.Errorf("dirty-table snapshot unavailable before migrate: %w", err)
}
_ = probe

Type guard

func snapshotAvailable(ctx context.Context, db DBConn) bool {
    _, err := dirtyTables(ctx, db, true)
    return err == nil
}

Try / catch

var dirtyBeforeStep map[string]dirtyTableState
for attempt := 0; attempt < 3; attempt++ {
    dirtyBeforeStep, err = dirtyTables(ctx, db, true)
    if err == nil {
        break
    }
    if isConnectionError(err) { // check wrapped driver error codes
        time.Sleep(backoff(attempt))
        continue
    }
    return count, fmt.Errorf("snapshotting dirty tables: %w", err)
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/bcf0a093676449c0. Report an issue: GitHub.