Repository navigation
feat(TC-1274): maintain epoch_head so writes stop scanning epoch history - #343
Conversation
transact found a space's head epochs (a new epoch's parents) with an epoch/epoch_order anti-join over the space's whole history on every write, under the space's sequence lock. It now reads them from a new epoch_head table (PK (space, id)) and records the new epoch as the space's only head in the same transaction: one UPDATE in the usual one-row case. The cache is trusted only when its newest seq is the space's newest (MAX(event_order.seq) + 1, which transact already computes). Every epoch, including one appended by a release that does not write epoch_head, outranks all existing ones under the same lock, so a stale cache is always detected; that space falls back to the anti-join once and the write repairs it. Heads are taken in id byte order; nothing re-derives an epoch hash from its parents. m20261018_000000_epoch_head creates only the empty table, with no foreign keys, so it takes no lock on existing tables. A gentle online backfill ([epoch_head_backfill], on by default at 2 spaces/s, pausing at least as long as each space took, stopping at its first error) fills spaces written before the table existed; writes are correct without it. The TC-951 round-trip budgets rise by the measured cache write: sign-in 75 -> 77, kv put 37 -> 38. Also folds in two #341 review nits: the in-progress check's pg_locks is limited to the current database, and a failed index plus a non-building SHARE UPDATE EXCLUSIVE holder still gets the DROP advice.
|
REQUEST_CHANGES: see TC-1274 Head Blocker (one, trivial)
Apart from that, everything I checked holds. 1. Seq-check proof: sound. No counterexample for any supported writer
2. Concurrency: OK
3. Single-
|
…abled pacing - Reflow the epoch_head module doc so no line starts with '+ 1' (clippy doc_lazy_continuation). - CHANGELOG: rolling back to v1.20.x first needs the seaql_migrations rows of m20261017 and m20261018 deleted (sea-orm-migration refuses unknown applied versions); keep the index and the table. - A disabled backfill boots whatever its pacing fields say; an enabled one still needs spaces_per_second and progress_every of at least 1.
|
APPROVE: see TC-1274 Head The new commit (
|
| Build | Result |
|---|---|
Unmodified (360751f; its tinycloud-core code is the same as 447ad70's) |
11 of 11 pass |
RED 2: is_current trusts any non-empty cache (no seq check) |
{sqlite,postgres}_old_writer_history_with_a_fork_is_detected fail at the "id order" assertion: the parents are the stale cached head, not the fork's two heads. The unit test also fails. |
RED 1: advance never writes the cache |
8 of 11 fail, all on cache assertions |
Both RED claims in the PR body are reproduced exactly. postgres_repair_concurrent_with_writers_ends_exact still passes under RED 1, because the last repair can run after the last write. That test exercises repair running alongside writes, not cache maintenance, so it doesn't weaken the RED evidence. The patches were throwaway, in a scratch worktree. It's been removed, with its target dir and PG instance, and the host-slot claim is released.
CI
Every check on 447ad70 passes: 13 pass and 9 are skipped (deploy and release jobs). That covers clippy, fmt, build, test (workspace, PostgreSQL-backed) (all epoch_head tests, the new config test and postgres_round_trip_budget), the at-rest harness, m1-realdata and the Docker build.
My earlier findings on 360751f stand: the seq-check proof, concurrency, the single UPDATE, the backfill and the migration.
TC-1274 PR B: a maintained
epoch_headtable, so a write finds its space's head epochs in O(heads) instead of running theepoch/epoch_orderanti-join over the space's whole history. Design note (approved):~/scratch/tc1274/epoch-head-table.md.Based on main
1cbeb7d(#341 merged).What changes
transact: under the existing per-space sequence lock, it readsepoch_head(a PK range of about one row) instead of the anti-join.UPDATE; with none, anINSERT; with two or more (a backfilled fork),DELETE+INSERT.seq+ 1 equals the space's nextseq, whichtransactalready computes. Every epoch, including one appended by v1.20.x, getsseq = MAX(event_order.seq) + 1under the same lock, so any epoch the cache doesn't know about makes the check fail.idbyte order. The audit (in the design note, §0) found that nothing re-derives an epoch hash from its parents, and parents never leave the node.m20261018_000000_epoch_head:CREATE TABLE IF NOT EXISTS epoch_head (space, id, seq, PK(space, id)), with no FKs, so it takes no lock on any existing table.downdrops the table.[epoch_head_backfill]):enabled = false/TINYCLOUD_EPOCH_HEAD_BACKFILL__ENABLED=false. Startup validates the pacing fields only when the backfill is enabled.pg_locksis filtered to the current database;Parent-order audit: nothing recomputes or verifies an epoch hash from its parents
Searched: tinycloud-node at this PR's head (
360751f), js-sdkorigin/master0431d61b(allpackages/*/src, includingpackages/replica(TC-858),packages/cli,sdk-core,node-sdk,web-sdk), tcos689ff21, tinycloud-app-kit5059c5b, tinycloud-sdk-rs/wasm, tinycloud-auth.epoch_hashis defined attinycloud-core/src/events/mod.rs:179. It's the only encoder ofEpoch { parents, events }and has exactly one caller,transact, attinycloud-core/src/db.rs:3397. It runs once per new epoch, and the result is stored asepoch.id. No code anywhere re-derives or verifies it.Commit.consumed_epochs(db.rs:208, set at:3593). The field has no reader:git grep consumed_epochsfinds only those lines.epoch_order.libp2por peer code touches epochs.tinycloud-core/src/kv_sync.rs:298,:308);commit.rev.to_cid(0x55)(tinycloud-node-server/src/routes/mod.rs:2370,:2384);current_kv(seq, epoch, epoch_seq)tie-break on stored bytes.epochas an opaque string (packages/sdk-services/src/hooks/types.ts:48,HooksService.ts:1178).epoch_hashappears only in comments describing the node's TC-332 PK race (packages/sdk-core/src/space.ts:107,:114, andspace.test.ts). The TC-858 replicas sync KV through kv/sync and never see parents. tcos, app-kit, sdk-rs/wasm and auth have no epoch-hash use.ORDER BY idconsistent? There's no verifier to match.ORDER BY, so on a multi-head space the parent order depended on the plan.idorder changes only the (new, opaque) hash of a future merge after a pre-TC-732 fork, and makes it deterministic.Rollback
Rolling back to v1.20.x has the same requirement as #341. The older node refuses to start while
seaql_migrationslists migrations it doesn't ship (sea-orm-migrationget_migration_with_status). So first runDELETE FROM seaql_migrations WHERE version IN ('m20261017_000000_epoch_order_space_index', 'm20261018_000000_epoch_head');.epoch_headtable: v1.20.x ignores the table.seqcheck repairs every space the older node wrote to.Follow-up (not in this PR): backfill completion marker
Each boot re-runs the pass. Spaces that are already current cost one short transaction each (advisory lock,
MAX(seq)probe, PK lookup) at no more thanspaces_per_second. A marker would be safe across nodes, because lazy repair covers any space a rolled-back node later dirties. But it needs a new schema object, and its "done" state outlives rollbacks, so I've kept it out of this PR.Round-trip budget (TC-951)
postgres_round_trip_budget: the budgets go up by exactly the measured amount: sign-in 75 → 77, kv put 37 → 38, kv get unchanged at 22. Reason, also in the test: the epoch_head cache write replaces the anti-join (TC-1274). - Measured on this machine, main1cbeb7dgives 75 / 37 / 22 and this PR gives 77 / 38 / 22. Statements go 51 → 53 and 31 → 32.UPDATEorINSERT; sign-in has two such commits, the host and session/delegates.Measurements (native PG 18.6, synthetic: 1 space × 1,000,000 epochs + 20 × 50,000)
EXPLAIN (ANALYZE, BUFFERS)throughPREPARE/EXECUTE, one warm-up then 5 runs; median of execution + planning time. Parallel workers off. Script:~/scratch/tc1274/bench/{seed.sql,explain.py}.epoch_order(2M rows) and ofepoch, 8-batch hash spilling to tempidx_epoch_order_space_parent_child(#341)idx_epoch_space_id, the new index), 0 heap fetchesSELECT space, id, seq FROM epoch_head WHERE space IN ($1)(this PR), 200,021 cached headsepoch_head_pkey, 1 rowMAX(event_order.seq)probe (unchanged, both before and after)Tests (RED first)
New in
tinycloud-core/src/epoch_head.rs(SQLite and PG):heads_follow_every_write;old_writer_history_with_a_fork_is_detected: a v1.20.x-style writer appends 2 epochs and then a 2-way fork. The new writer takes exactly the anti-join's heads, in id order, and the cache ends at one row;repair_fills_upgraded_history_once: an empty cache on existing linear and forked history. The second repair is a no-op, and a write on the 2-row fork collapses it;postgres_concurrent_writers_keep_exactly_one_head: 24 puts from two node instances on one space;postgres_repair_concurrent_with_writers_ends_exact;postgres_v1_20_6_upgrade_with_operator_index: a v1.20.6 schema with history and a fork, plus the operator'sCREATE INDEX CONCURRENTLY, thendb::migrate, backfill and next write;backfill_pages_every_space_in_id_order,cache_is_current_only_when_it_holds_the_newest_seq.Also:
assert_linear_historyhelper now also asserts that the cache holds exactly the chain's last epoch. That covers every existing concurrent-write test, including multi-space ones.tinycloud-node-server/src/epoch_head_backfill.rs, with paused time: every space is checked once across pages at the configured rate; the pause is at least the time each space took; it stops at the first error without retrying; it stops on shutdown.RED evidence (native PG 18.6 and SQLite):
transactnot writing the cache: 8 of the new tests fail, and the failures are the cache assertions.{sqlite,postgres}_old_writer_history_with_a_fork_is_detectedfail, because the parents are the stale cached head rather than the fork's two heads.GREEN:
cargo test -p tinycloud-corepasses on SQLite and PG: 470 lib tests plus every integration target. -tinycloud-node:postgres_round_trip_budgetwas run locally with this change (77 / 38 / 22). The rest of the node-server suite, including theepoch_head_backfillunit tests and clippy, was cut off by the TC-1355 host pause; CI on this PR runs it.tinycloud-coretests that don't touch epochs (meeting_legacy_guardSQLite "database is locked", and thesql::service10 s timeout) failed once at load average 25. Both pass in isolation.No merge or deploy is implied.