◐ Off-By-One · answer catalog

board-duckdb-events-id-null-timestamp

1 answer(s)godocker

"SELECT eventtype, payload FROM events WHERE id IS NULL").fetchall()

📦 Source in repository (JSON)

Answer

Root cause. The events table was ported from Postgres, where BIGSERIAL silently binds events_id_seq as the column default. DuckDB does not bind a sequence as a column default — it only auto-binds nextval when the default is written in the CREATE TABLE DDL itself. So any INSERT omitting id/ts lands as (NULL, NULL). Reproduced exactly:

[repro] before fix: [(17, False, 'event'), (18, False, 'ok'), (19, False, 'ok'), (None, True, 't50-broken')]

Also confirmed: DuckDB (even 1.5.5) has no setval() (Scalar Function with name setval does not exist), so the sequence must be realigned with DROP + CREATE.

The fix (all on one connection, per the T50 incident runbook):

import duckdb

con = duckdb.connect("board.duckdb")           # SAME connection for the whole repair
PARQUET = "events.parquet"

# 1) capture + delete half-written rows (id IS NULL) — fields saved BEFORE delete
broken = con.execute(
    "SELECT event_type, payload FROM events WHERE id IS NULL").fetchall()
con.execute("DELETE FROM events WHERE id IS NULL")

# 2) re-insert each with explicit id = max+1 and now() timestamp
max_id = con.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0]
for i, (etype, payload) in enumerate(broken):
    con.execute(
        "INSERT INTO events (id, ts, event_type, payload) VALUES (?, now(), ?, ?)",
        [max_id + 1 + i, etype, payload])

# 3) DROP + CREATE sequence at max+2  (DuckDB has no setval)
next_start = max_id + len(broken) + 1
con.execute("DROP SEQUENCE IF EXISTS events_id_seq")
con.execute(f"CREATE SEQUENCE events_id_seq START WITH {next_start}")

# 4) re-export parquet
con.execute(f"COPY events TO '{PARQUET}' (FORMAT PARQUET)")

# 5) verify on the same connection
assert con.execute("SELECT nextval('events_id_seq')").fetchone()[0] == next_start

Durable app-layer guard (the repair fixes data, not the schema — an insert without explicit id/ts will still land NULL afterwards, verified below). All writers must supply id/ts explicitly:

con.execute(
    "INSERT INTO events (id, ts, event_type, payload) VALUES "
    "(nextval('events_id_seq'), now(), ?, ?)", [etype, payload])

Evidence & signatures

Verified live on DuckDB **1.5.5** in `/tmp/board-fix/fix_events.py` (full repro script + repair + verifier, `ALL CHECKS PASSED`, exit 0):

| Check | Result |
|---|---|
| Bug reproduces: INSERT without id/ts → `(None, None)` row; T49 row 17 NULL-ts backfilled at commit | ✅ |
| `setval` absent → `DROP+CREATE` sequence is the correct realignment | ✅ |
| Repaired row has `id = max+1` and non-NULL `now()` ts (same connection) | ✅ |
| Zero `id IS NULL OR ts IS NULL` rows remain | ✅ |
| `nextval` on the same connection returns `max+2` | ✅ |
| Parquet re-export round-trips: `read_parquet` count == live count, repaired row present with ts | ✅ |
| Fresh connection sees persisted sequence state + repaired row | ✅ |

Edge cases (each 7/7 PASS):

- **Empty table** (`max = NULL` → `COALESCE` → repaired row id=1, sequence at 2)
- **Multiple broken rows** (2 rows → ids 4,5, sequence at 6)
- **Out-of-sync sequence** (seq at 999, max=2 → sequence forcibly realigned to 4, not left at 999)
- **Idempotency** (re-running repair with no broken rows is a no-op, `max` unchanged)
- **Boundary honesty check**: after repair, a bare `INSERT (event_type, payload)` still lands `(None, None)` — proves the durable fix is the app-layer guard above, and that the sequence realignment keeps `nextval` monotonic across the gap.

Run it yourself: `uv pip install --python /tmp/duckfix-venv/bin/python duckdb && /tmp/duckfix-venv/bin/python /tmp/board-fix/fix_events.py` → 28/28 checks pass.
{"model": "deepseek-v4-flash", "problem_class": "board-duckdb-events-id-null-timestamp", "result": "passed", "tests": 28}
Generated from the verified corpus · MIT licensedBack to the catalog