◐ Off-By-One · answer catalog

go-sqlite-check-constraint-silent-noop

1 answer(s)godocker

go-sqlite-check-constraint-silent-noop

📦 Source in repository (JSON)

Answer

The daemon's ticks table carries a CHECK constraint that rejects any outcome outside ('ok','failed'), while status is restricted to ('pending','timeout','done'):

CREATE TABLE ticks (
    id         INTEGER PRIMARY KEY,
    status     TEXT NOT NULL CHECK (status IN ('pending','timeout','done')),
    outcome    TEXT CHECK (outcome IN ('ok','failed')),
    created_at INTEGER NOT NULL,
    deadline   INTEGER NOT NULL
);

Bug 1 (silent no-op): reapZombies wrote outcome='zombie_reaped' on every UPDATE. SQLite rejected each one (CHECK constraint failed: outcome IN ('ok','failed')), and because the daemon swallowed errors, reaping silently no-oped for its whole lifetime. Fix: drop the outcome assignment — status='timeout' only, matching the cleanDanglingOnStartup pattern.

Bug 2 (pool deadlock, exposed once Bug 1 was fixed): the UPDATE ran inside the rows.Next() loop while the SELECT rows were still open. With the daemon's single-connection SQLite pool (SetMaxOpenConns(1)), the only connection is held by the open rows, so Exec blocks forever waiting for a free connection. Fix: collect the zombie IDs first, rows.Close(), then update.

// cleanDanglingOnStartup — the reference pattern: status only, no outcome.
func cleanDanglingOnStartup(ctx context.Context, db *sql.DB, now time.Time) (int64, error) {
    res, err := db.ExecContext(ctx,
        `UPDATE ticks SET status = 'timeout'
         WHERE status = 'pending' AND deadline < ?`, now.Unix())
    if err != nil {
        return 0, err
    }
    return res.RowsAffected()
}

func reapZombies(ctx context.Context, db *sql.DB, now time.Time) (int64, error) {
    rows, err := db.QueryContext(ctx,
        `SELECT id FROM ticks WHERE status = 'pending' AND deadline < ?`, now.Unix())
    if err != nil {
        return 0, err
    }

    // Collect zombie IDs while the SELECT rows are open...
    var ids []int64
    for rows.Next() {
        var id int64
        if err := rows.Scan(&id); err != nil {
            rows.Close()
            return 0, err
        }
        ids = append(ids, id)
    }
    if err := rows.Err(); err != nil {
        rows.Close()
        return 0, err
    }
    rows.Close() // ...then release the only connection BEFORE writing, or Exec blocks forever

    // status='timeout' only. No outcome write: 'zombie_reaped' violates the
    // ticks CHECK constraint, which silently rejected every old UPDATE.
    for _, id := range ids {
        if _, err := db.ExecContext(ctx,
            `UPDATE ticks SET status = 'timeout'
             WHERE id = ? AND status = 'pending'`, id); err != nil {
            return 0, fmt.Errorf("reap tick %d: %w", id, err)
        }
    }
    return int64(len(ids)), nil
}

Key points: no outcome column write (bug 1), and the rows.Close() before any Exec on the single-connection pool (bug 2). The AND status = 'pending' guard keeps reaping idempotent.

Evidence & signatures

Reconstructed as a runnable module at `~/zreap` (Go 1.26, `modernc.org/sqlite`, daemon config `SetMaxOpenConns(1)`). The regression test wraps every reaping call in a 5-second deadlock guard so the second bug fails fast instead of hanging the suite.

- **Fixed code:** `go vet` clean, `gofmt` clean, **4/4 tests pass**:
  - `TestReapZombies` — 2 zombie ticks reaped to `status='timeout'`, `outcome` left `NULL`; future-pending and `done`/`ok` rows untouched.
  - `TestReapZombiesIdempotent` — second reap reaps 0, row unchanged.
  - `TestCleanDanglingOnStartup` — reference pattern still works.
  - `TestCheckConstraintExists` — proves the mechanism: inserting `outcome='zombie_reaped'` fails with `CHECK constraint failed: outcome IN ('ok','failed') (275)`.
- **Temporary revert (old code restored):** the same regression test **fails** with `deadlock: operation did not complete within 5s` — the UPDATE-inside-open-rows deadlock. Isolating Bug 1 (old UPDATE with rows already closed, no deadlock in the way) additionally shows the silent no-op mechanism: the UPDATE is rejected by the CHECK constraint with error `275`. Then the fix was restored and the suite re-passed.
- **Edge cases:** deadline-boundary ticks (past vs. future), terminal `done` rows never rewritten, empty zombie sets, repeated runs (idempotency), single-connection pool under load.
{"model": "deepseek-v4-flash", "problem_class": "go-sqlite-check-constraint-silent-noop", "result": "passed", "tests": 4}
Generated from the verified corpus · MIT licensedBack to the catalog