8.1 KiB
8.1 KiB
M6.3 — store.go query port to Postgres
| Field | Value |
|---|---|
| Phase | M6 — agent-manager migration |
| Size | L — 3+ days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M6.2 |
Goal
Every query in internal/store/store.go rewritten against the M6.2 schema,
sqlite dropped entirely (decided: full port, not a dual sqlite/Postgres
backend — this is single-machine usage, no standalone-without-cluster
requirement to preserve).
Facts (inlined — no spec read needed)
Read directly from internal/store/store.go (1397 lines) on the
add-headless-spawn branch:
- 65 call sites (
db.Exec,db.QueryRow,db.Query,tx.Exec,tx.QueryRow) use sqlite?positional placeholders. Postgres (pgxorlib/pq) needs$1, $2, ...— mechanical but must be done per-statement since arg count varies 1–16 across sites. - Upserts are already Postgres-compatible. All 9 upsert sites
(
SetReviewRepo,SetReviewBase,SetReviewScope,SetSetting,ensureGroup,CreateGroup,AddGroup,createSession's group insert,PlaceSession's group insert) already useON CONFLICT(...) DO UPDATE SET ... = excluded..../ON CONFLICT(...) DO NOTHING, valid as-is in Postgres. No rewrite beyond the placeholder swap — this corrects an earlier assumption that these were sqliteINSERT OR REPLACEand needed real rework. boolToInt()/archived != 0-style conversions at ~8 sites go away — scanint archived/int acked/int pendingClaimed/intlocals inListSessions,Getand replace withbooldirectly.encodeTime()/decodeTime()calls at every session-timestamp read/write go away — passtime.Timestraight through;pgxhandlesTIMESTAMPTZnatively. Deletetimeenc_test.goalong with the functions it tests (M6.2 already noted its coverage folds into the schema tests instead).db.SetMaxOpenConns(1)+PRAGMA journal_mode=WAL(store.go:76-79) gave sqlite serialized writes for free. Some call sites lean on that implicit serialization — most notablycreateSession'ssort_order = (SELECT COALESCE(MAX(sort_order)+1, 0) FROM sessions WHERE group_name = ? AND parent_id = ?)subquery, which races under concurrent Postgres writers with a real connection pool. Wrap it inSELECT ... FOR UPDATEinside the existing transaction (the function already opens one) rather than trusting single-writer semantics that no longer exist. Same pattern applies toReorderSession/SwapSessionOrder/ReorderGroup/SwapGroupOrder's read-then-renumber-then-write sequences.- FK violations are new failure modes M6.2 introduced (group/parent FKs).
createSessionandPlaceSessionalready validategroup_name/parent_idthroughvalidParent/ensureGroupbefore writing, so those paths shouldn't hit a live constraint in practice — but wrap the actualINSERT/UPDATEerror and translate a23503(foreign_key_violation) SQLSTATE into the same descriptive errors the pre-validation already produces, so a race between the check and the write degrades to a clear error instead of a raw driver error reaching the CLI. driver: modernc.org/sqliteimport and the blank_ "modernc.org/ sqlite"inOpen()are deleted; replaced withpgx(github.com/ jackc/pgx/v5/stdlibfordatabase/sqlcompatibility, keeping the rest of the file's*sql.DB-based code unchanged) or a nativepgx.Pool— pickpgx/v5/stdlibunless a later task needs pgx-native features (e.g.COPY), since it's the smaller diff against the existingdatabase/sqlcode.
Steps
- Swap the driver import and
Open()'s connection string handling (sqlite file path -> Postgres DSN, likely from an env var or flag the CLI already has a slot for — checkcmd/for howOpen()is called today). - Delete
db.SetMaxOpenConns(1)/PRAGMA journal_mode=WAL; size the pool deliberately instead (SetMaxOpenConnsto something sane for a single-machine client, e.g. 5–10). - Delete
init()'sCREATE TABLE IF NOT EXISTS+ 14-migration list — M6.2's migration owns schema creation now;Open()just connects and optionally runs aschema_versionsanity check. - Mechanically convert every
?to$Nacross the 65 call sites, in file order, verifying arg count against placeholder count each time (this is where an off-by-one is easiest to introduce silently). - Remove
boolToInt/archived != 0conversions; scan struct fields directly asbool. - Remove
encodeTime/decodeTime/secondsCeiling; passtime.Timedirectly. Deletetimeenc_test.go. - Add
SELECT ... FOR UPDATE(or equivalent explicit locking) to the sort-order read-then-write sequences named above. - Add FK-violation (
23503) error translation at the write sites that can theoretically race past their own pre-validation. - Run agent-manager's full existing test suite against a real Postgres
(M6.1's cluster, or local
postgres:16for iteration) — every current test should still pass unmodified in intent, only in backing store.
Acceptance
- Zero references to
modernc.org/sqlite,?placeholders,boolToInt,encodeTime/decodeTimeremain ininternal/store/. - Full existing store test suite passes against Postgres.
- Concurrent
CreateSessioncalls (simulated) never produce duplicatesort_ordervalues within the samegroup_name/parent_id. - A
spawnCLI round trip (create session, update status, delete) works end-to-end against the M6.1 cluster.
Verify
Harness: internal/store/*_test.go running against the disposable
Postgres from M6.2's harness.
Integration test — extend/rename the existing store test files:
a1_no_sqlite_references—grep -r "modernc.org/sqlite" internal/returns nothing.a2_no_bare_placeholders—grep -rE '\?[,)]' internal/store/store.goinside SQL string literals returns nothing (manual review of any?that's part of a non-SQL string, e.g. a Go format verb, to avoid a false positive).a3_existing_suite_passes—go test ./internal/store/...green against Postgres.a4_concurrent_sort_order— spawn N goroutines each callingCreateSessioninto the same group concurrently; assert the resultingsort_ordervalues are a dense 0..N-1 permutation with no duplicates.a5_fk_violation_translated— attemptPlaceSessionwith aparentIDdeleted between the read and the write (simulate via a second connection); assert the returned error is the existing descriptive one, not a raw pgx driver error.a6_spawn_roundtrip—spawnCLI subcommand creates a session,UpdateStatus, thenDelete; assert no error and the row is gone.
Command: go test ./internal/... -run TestStore -v
False pass:
- Running the suite against sqlite still (leftover build tag or import) while believing it validated Postgres. Assertion 1 is the guard — without it, the whole task could report green while the driver swap never actually happened.
- Skipping assertion 4. A
sort_orderrace is invisible in every single-threaded test and only shows up as duplicate/out-of-order sessions under real concurrent use, which is exactly the class of bug removingSetMaxOpenConns(1)introduces.
Traps
- Converting
?to$Nby simple find-and-replace in file order without re-checking each statement's actual arg list — a multi-arg statement reordered during earlier edits (e.g.createSession's 15-arg INSERT) will silently bind the wrong value to the wrong column, and Go'sdatabase/sqlwon't catch a type-compatible mismatch (e.g. twoTEXTcolumns swapped). - Forgetting
pending_inputsis nowJSONB(M6.2), notTEXT— the existingjson.Marshal/Unmarshalround-trip inencodePendingInputs/pendingInputStatestill works unchanged (pgx scansJSONBinto[]bytethe same asTEXT), but don't add an extra marshal layer thinking the column type changed the Go-side contract.
Background: M6.2 · internal/store/store.go, internal/spawn/spawn.go (agent-manager, add-headless-spawn branch)