Repository navigation
feat(db): chronicle and ledger on native Postgres — #251 - #254
Merged
Merged
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The map chronicle, its wipe audit and the economy ledger read and write through src.database: %s placeholders, RETURNING, ON CONFLICT, jsonb and timestamptz values, and COPY for a day's faction and guild rows. - Ledger upsert columns qualified; scalar MIN/MAX become LEAST/GREATEST. - The TEMP table of present keys becomes `faction_key <> ALL(%s)`. - Read routes take one REPEATABLE READ, read-only snapshot per request. - index_snapshot takes a per-map advisory lock behind the file lock. - Stored times read back in the `…Z` form they were written in, and the global block's keys are sorted again, so every ledger ETag is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Strip NUL from every string and key at the end of normalize_snapshot and again on entry to index_snapshot, so a NUL can no longer roll back a day's index write on every promote and reindex (Postgres text and jsonb refuse it). Guild ids that collide after the scrub keep the last, as for any repeated id. - Registry order breaks first-day ties by faction_key. - The global block is sorted at every depth. - The chronicle index reads its rows and the wipe stamp in one snapshot. - The wipe and reindex CLIs validate the map first, skip migrations on --dry-run and report a missing or broken database as a usage error. - Cursors are closed; two blank lines before the audit dataclass. - Tests: NUL promote and reindex, float types and the 1e16 flip, the half-promotion race paused after all five writes, the lock race waits on pg_stat_activity, the CLI behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ryanbarlow97
force-pushed
the
pg-area-ledger
branch
from
October 16, 2026 22:43
f098593 to
7c7faf0
Compare
This was referenced Oct 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The map chronicle, its wipe audit and the economy ledger move to native Postgres (#251, plan item 7): every read and write in the area goes through
src.databasewith%splaceholders,RETURNING,ON CONFLICT, the new column types andCOPYfor a day's rows. The ledger read routes take one snapshot per request, a promotion is backed by an advisory lock, and every ledger ETag is unchanged for the same data. No unconverted area calls into this one with a SQLite connection, so there are noPG-MIGRATIONmarks.Files converted
backend/src/scripts/ledger/store.py: every read and write;open_connection()is now a pooleddatabase.connect()block, andread_view()is new.backend/src/scripts/ledger/ingest.py:index_snapshotis onewithblock that takesledger:index:<map>first.backend/src/scripts/ledger/wipe.py,reindex.py: Postgres connections. Their CLIs validate the map first and runsrc.database.migrate(no seed) for writes;--dry-runapplies no migrations and database failures become usage errors.remap_wealth_keys.pynever touched the database and drops itsmigrate().backend/src/scripts/chronicle/store.py,audit.py,wipe.py(CLI migrates like the ledger's),restore.py.capture.pyandverify.pyonly callstoreand are unchanged.backend/src/scripts/maps/archive_copy.py: the chronicle rows are cloned with oneINSERT … SELECT … RETURNING dayin the same transaction as the delete, in place of a read and an upsert per day.backend/src/scripts/tools/generate_spoof_ledger.py:existing_ledger_daysanswers[]or the days, and raisesLedgerIndexUnreadablewhenDATABASE_URLis unset or the database cannot be reached. The old "noDB_PATHfile means no history" answer is gone, because on Postgres there is no file to look for.backend/src/api/ledger_routes.py:/ledger/index,/ledger/seriesand/ledger/faction/{key}each read inside onestore.read_view().backend/src/api/maps_routes.py:/maps/accessibleis a plaindef, so the archived chapters' chronicle reads run in the threadpool rather than on the event loop.backend/src/api/chronicle_routes.py,chronicle_staff_routes.py,data_routes.py: only call the converted modules. The chronicle index now reads its day rows and wipe stamp in one read-only repeatable-read snapshot; the staff routes already usedrun_in_threadpool.Dialect fixes
scripts/ledger/store.py:399-416touch_registryON CONFLICT … SET first_seen_day = MIN(first_seen_day, …)with unqualified columns and scalarMIN/MAXAS f,LEAST(f.first_seen_day, excluded.first_seen_day),GREATEST(f.last_seen_day, …), every right-hand column qualifiedscripts/ledger/store.py:424-451mark_deletedCREATE TEMP TABLE+INSERT OR IGNOREof the present keys,NOT IN (SELECT …)faction_key <> ALL(%s::text[]), one array parameter for up to 2,000 keysscripts/ledger/store.py:612read_faction_daysIN (?, ?, …)= ANY(%s::text[])scripts/ledger/store.py:504latest_complete_daycomplete = 1completescripts/ledger/store.py:293-300day rowsexecutemanyINSERTCOPY … FROM STDIN(a third of the time for 2,000 rows)scripts/chronicle/wipe.py:63-73_archive_rowsINSERT OR REPLACE … SELECTINSERT … SELECT … ON CONFLICT (map_id, day, archived_at) DO UPDATEscripts/chronicle/restore.py:139-146_reinsert_rowsINSERT OR IGNOREone at a timeINSERT … SELECT … WHERE day <> ALL(%s) ON CONFLICT (map_id, day) DO NOTHINGscripts/chronicle/audit.py:91record_wipecursor.lastrowidRETURNING idscripts/chronicle/audit.pylast_wiped_atsqlite3.OperationalErrorfor a missing tablescripts/maps/archive_copy.py:109-121INSERT … SELECT … RETURNING dayjson_extract, SQLstrftimeandrowiddo not occur in this area. The onlystrftimecalls are Python's, on aware datetimes, for day strings.json_extractwas inapi/database_routes.py, which #253 converts.Type adaptations
map_ledger_days.captured_at,map_ledger_factions.first_seen_at/last_seen_at/deleted_at(timestamptz): writes still pass the snapshot's…Zstring. Reads go throughstore._iso()(store.py:123), which turns them back intoisoformat()withZ, the formschema.normalize_snapshotwrites:…T12:00:00Z,…T08:30:15.123456Z,…T23:59:59.500000Z. The API output and the series ETag inputs are byte-identical to SQLite's. Nanoseconds (only in old daily files, sincenormalize_snapshotalready truncates to microseconds) round to microseconds once on write. Times are never compared: the registry's first/last-seen logic compares the*_daytext columns.map_ledger_days.globals(jsonb): jsonb reorders keys, and the series ETag hashes the block's repr. Promotion always indexes the snapshot read back from its packed file, whichingest._packwrites with sorted keys, so SQLite held them sorted.store._global_block()sorts them again, including nested keys.wealth_breakdown,prestige_breakdown,subjects,wars) and chroniclemanifest: these arrive parsed. Thejson.loads … except (TypeError, ValueError)fallbacks that would have returned{}/[]on every row are gone. A manifest that is not an object still degrades to{}(chronicle/store.py:127).days_referencingtreats such a manifest as a blocker, as the old code meant to do for an unparsable one; before, a list manifest raisedAttributeErrorthere. Writes wrap values inJsonb().complete(boolean): written asbool, read asbool(it was already coerced).founded_atstaystext, sofaction_key = sha1(id \0 founded_at)is unchanged. A test stores a nine-digitfounded_atand reads it back byte-exact.captured_at, wipe stamps: unchangedbigintepoch seconds.ETags.
test_series_etags_match_the_sqlite_indexpins the four ledger ETags (series core, series full, index, faction) for a fixed dataset with microsecond and nanosecond times. The values were taken from the SQLite build onpostgresand match exactly, so clients keep their 304s across the cutover. Chronicle and ledger bodies that are hashed whole are encoded withsort_keys=True, so jsonb's key order cannot reach them.Locking and races
#252 lists no
BEGIN IMMEDIATEpath or read-then-write race in this area. Promotion, reindex, wipe and restore are already serialised per map by the cross-process file locks (scripts/util/maplock.py), which are unchanged. Postgres's READ COMMITTED needed two additions:store.read_view(),store.py:160):/ledger/seriesbuilds its ETag from the day rows and registry and then reads the faction rows. Under READ COMMITTED each statement sees its own snapshot, so a promotion committing in between could tag a body with an ETag from a different state, or return a faction row for a day the axis lacks. The routes now read inside oneREPEATABLE READ, READ ONLYtransaction, which keeps the "one consistent view" the code already promised.ingest.py:236):index_snapshottakesledger:index:<map>(D) as its first statement, so two index writes for one map cannot interleave their 2,000-row upserts even without the file lock.Tests in
scripts/ledger/test_postgres.py, each racing two connections:test_a_reader_never_sees_half_a_promotion: the writer is paused inside its transaction after all five writes but before commit, and the reader sees the old day.test_a_read_view_keeps_one_snapshot_across_a_promotion: a promotion commits mid-request, and the view still sees one day.test_index_writes_for_one_map_take_turns: the writer waits for a held lock.test_index_writes_for_other_maps_do_not_wait.I checked that the second and third fail when the isolation level or the advisory lock is removed.
Timings
backend/benchmarks/ledger_promotion.pybuilds--daysof 2,000 factions and 200 guilds per day, then times promotion and the routes through the real code. Results for 365 days (730,000 faction-day rows), Postgres 17 in Docker on the same 4-core host, against the same script on SQLite (thepostgresbranch,province.dbon local disk):promote_day, 2,000 factions (raw read, daily file write, index)index_snapshotonly (n=360)/ledger/seriesdefault (top 12, core, 365 days)/ledger/series40 factions,fields=full/ledger/index(2,000-faction registry)With 30 days the series routes take about 100 ms on Postgres. Promotion runs as a background task after the upload's 200, so it costs no request latency. Before
COPY, the Postgres index write took 270 ms; the faction rows were most of that. The default series' time grows with history on both engines, mostly incount_factions_in_range'sCOUNT(DISTINCT faction_key)over the range. That was already the case on SQLite.PG-MIGRATIONmarksNone. Nothing in this area takes a caller's connection from an unconverted area, and nothing here reads tables that still live in SQLite. Map access (Skins sessions) is a separate call that keeps its own SQLite connection.
Tests
Converted to the
postgresfixture:scripts/ledger/*(throughconftest.env),scripts/chronicle/test_audit,test_capture,test_restore,test_verify,test_wipe,scripts/maps/test_archive_copy,scripts/tools/test_generate_spoof_ledger,api/test_ledger_routes.api/test_chronicle_routes,test_chronicle_staff_routes,test_chronicle_archive_routesandtest_data_routes_ledgertakepostgresand keep their SQLite isolation, because the full app's start-up and the staff session checks still touchprovince.db.test_series_opens_one_connection_per_request, the chronicle index ones) now count pool borrows. The chronicle index borrows once for a repeatable-read snapshot containing both the day rows and the newest wipe stamp. It still does not grow with the number of days.scripts/ledger/test_postgres.py(10: time format, nanosecond rounding, parsed JSON columns, byte-exactfounded_at, pinned ETags, four races, read-only view) andtest_a_later_manifest_that_is_not_an_object_blocks_the_overwrite.scripts/chronicle/test_schema_indexes.pystill checksschema.sqlon SQLite and stays until area 10 removes it.Full backend suite against
pgvector/pgvector:pg17withTEST_DATABASE_URLset:postgres(b5e161e): 2,432 passed, 4 skipped, 175 subtests passed (18 min 0 s)test_postgres.py, the chronicle manifest test, and one net spoof-ledger test (the "no database file" pass removed, "reads the index" and "raises without a database" added).Review fixes and current validation
The first review found a NUL character could permanently wedge a saved day. Normalisation now recursively strips NUL from keys and values before files are saved; indexing applies the same scrub to older daily files. Colliding scrubbed guild ids keep the last row. Further fixes add a faction-key ordering tiebreak, recursive key sorting, real lock-wait assertions and CLI error checks. The whole-number float boundary is pinned: JSONB preserves float values through 1e15; values written in exponent notation at 1e16 can return as integers.
/tmp/ps-pg-ledger-full2.log).postgresat7d2a1ee7): 440 affected tests passed, including chronicle/ledger, archive copy, spoof data, and API routes. The final full suite is checked by CI for the rebased head.Not deployed anywhere.
🤖 Generated with Claude Code
Sol round 2 follow-up
The independent Sol review confirmed the NUL, identity/deletion-safety, COPY rollback, snapshot and ETag fixes. One CLI gap remained: dry-run wipes on an unmigrated database raised
UndefinedTable, aProgrammingErrorrather than anOperationalError. The ledger and chronicle wipe handlers now catchDatabaseError. Two fresh-schema tests reproduced the old failure, then passed with the fix; all 50 targeted Postgres and wipe tests passed. The tests prove dry runs apply no migrations and leave the database schema empty. Final independent Sol review: VERDICT: OK atce6ae3e9, with all 50 targeted tests independently passing. The new Build is the remaining integration gate.