fix(meta): name the branch partition on every metadata statement - #37
Conversation
Red baseline: 80 branch-scoped sites build a metadata table name from a parent-name helper, and 2 TODO(CHA-546) markers remain. The refcount-gate converse guard passes already. CHA-546
Holds ACCESS EXCLUSIVE on the 8 metadata parents from an out-of-band session — the lock state a mid-DROP TABLE teardown creates — and requires every branch-scoped op to finish anyway. Red: the write path blocks on persist, the read path on compact. CHA-546
search() accepted any one of the three probes, so narrowing two of them would keep the converse guard green while the forward guard exempts the whole gate body. Assert the exact set, and that no partition helper appears there. CHA-546
Every statement in persist.rs named the catalog-wide parent of table_persist_metadata / table_persist_segment_metadata and let Postgres route on branch_uuid. Naming a parent takes a lock on the parent; naming a partition takes none. That is what makes a writer deadlock branch teardown, and what makes enumerate_unsealed_persist_segments_for_scope hold ROW SHARE on the parent across its whole cold read and merged write so DeleteBranch trips its 5s lock_timeout. Follows the CHA-539 conversion precedent: switch to the *_partition helper and keep the now-redundant `WHERE branch_uuid = $1` so the predicate still documents the scope. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without ONLY, LOCK TABLE covers the named table and every descendant, so the fixture held ACCESS EXCLUSIVE on the branch's own leaves — blocking a partition-targeted statement too. The test could never have gone green, and its failure message would have blamed parent-naming for a lock the fixture itself took. Teardown locks the deleted branch's leaves plus the parent descriptor, never a sibling's leaves, so parents-only is the state being modelled. Lock all 8 in one statement: lock_timeout is per-statement, so eight statements let acquisition run to 8x the intended bound. Assert the holder actually acquired before yielding. `_held` is also set on the failure path so `__enter__` cannot hang, so waiting on it alone let a timed-out holder pass through with no locks held — both tests would then run unimpeded and report green. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Converts the 19 parent-name sites in snapshot.rs and the seed helper in tests/retention_floor.rs, same shape as persist.rs: swap to the *_partition helper and keep the now-redundant WHERE branch_uuid = $1. insert_carried_snapshot_segments is the one two-branch statement here — CHA-531's carry-forward reads the source branch's rows and writes this branch's, so it needs two distinct partition names. The INSERT target takes `branch_uuid`; the JOIN source takes `source_branch_uuid`. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Converts the 11 remaining parent-name sites in segment_index.rs (list_all_segment_index_uris was already partition-named by CHA-539). insert_carried_segment_indexes is two-branch like its snapshot.rs counterpart: the INSERT targets this branch's partition ($1) while the JOIN reads the source branch's ($3). On a non-fork carry-forward both names resolve to the same relation; the `old` alias keeps every column reference unambiguous. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every statement in fork_copy.rs spans the fork edge, so each of the six tables now resolves two names: the parent's partition for the reads and the `... old` source of each INSERT..SELECT, the child's for the insert target. Audited against the bind order — every plain SELECT pairs `_parent` with `parent_branch_uuid`, every INSERT pairs `_child` with `*child` and `_parent` with `parent_branch_uuid`. The child's leaves exist by then: CreateBranch calls ensure_branch_partitions before this copy, in the same transaction, so a direct leaf INSERT cannot hit a missing relation. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Converts the last branch-scoped statements in penca-storage-meta: purge.rs (5 sites), compact.rs (3 sites), lifecycle.rs (2 sites). Naming a partition takes no lock on the parent, so these writers no longer contend with branch teardown's DROP TABLE. The CHA-531 refcount gate in compact.rs is deliberately left naming the three parents: eligible_segment_delete_set_rows and reap_referenced_segment_delete_set_rows are catalog-wide because carry-forward crosses fork edges, and neither takes a branch_uuid. That makes them the only reason the lock-ordering invariant on insert_segment_delete_set_rows survives, so its doc-comment and the three caller comments that repeated the old rationale (compact's opening SELECT ... FOR UPDATE naming the parent) are rewritten around the sweep instead. Teardown is now the only writer the invariant binds. CHA-546
The read half of CHA-546: meta_plan.rs's 12 remaining parent-name sites across phase_one_fence_and_existence, hot_min_and_snapshot_pick, read_snapshot_segments_for_table, inherited_own_arm_floor, read_and_classify_persist_segments, and max_persisted_segment_seq_for_window now target the reading branch's partition. read_and_classify_persist_segments is the one two-branch site: enumerate_base_cold_source calls it with a fork's PARENT branch to resolve inherited cold, so its names come from the branch_uuid argument and never an ambient current-branch value. No fence, floor, ceiling, or grace arithmetic changed — only the relation each statement reads. CHA-546
Both markers described compact holding ROW SHARE on the catalog-wide segment-metadata parent across a whole merge. No branch-scoped statement names a parent any more, so the surviving contention is the CHA-531 refcount gate's catalog-wide probes — one statement, not a wave — plus this branch's own lifecycle work on the leaves teardown locks. Also restates write/mod.rs's residual conflict as a wait rather than a deadlock cycle: the gate takes no lock the teardown transaction holds, so it surfaces as lock_timeout. CHA-546
Since CHA-546 the snapshot-segment read path names the branch's partition, so the catalog-wide parent name that integration_snapshot_list_cache_test.py matched on stopped matching anything. The sanity check caught that (assert 0 > 0), but every `== 0` assertion in the file had quietly become a tautology. Add table_snapshot_segment_metadata_partition to the client naming module -- the first metadata leaf a Python caller has to name -- with parity goldens on both sides so a drifted leaf name fails loudly rather than making a statement-count assertion vacuous. CHA-546
The file first held one fixture at ACCESS EXCLUSIVE for both halves. That is a green no correct implementation can earn: naming a leaf removes the parent lock outright for SELECT, SELECT ... FOR UPDATE and DELETE, but an INSERT or UPDATE still evaluates the leaf's partition constraint, which opens the parent for its partition key and leaves AccessShare held to commit. ACCESS EXCLUSIVE conflicts with every mode, so the write half blocked even on an idle stack. Split by the mode each half actually clears: writes under a parent EXCLUSIVE (which AccessShare does not conflict with, and the pre-CHA-546 RowExclusive did), reads under ACCESS EXCLUSIVE. Both halves still go red before the change. The measured lock table is in the module docstring. CHA-546
Four comments asserted that a leaf-naming statement takes no parent lock at all. Measured `pg_locks` says otherwise for INSERT and UPDATE: evaluating the leaf's partition constraint opens the parent for its partition key and holds AccessShare to commit. The claim was only ever true for SELECT, SELECT ... FOR UPDATE and DELETE. Nothing about the lock-ordering invariant changes -- AccessShare cannot conflict with the sweep gate's AccessShare, so compact and retire still pay nothing for ordering. But "they touch no parent" was false, and a future reader who believed it might drop the rule. Say instead that teardown is the one writer that can CONFLICT, and name what EXCLUSIVE clears that ACCESS EXCLUSIVE does not. CHA-546
The previous commit generalized the measured leaf-INSERT AccessShare to both non-teardown callers of insert_segment_delete_set_rows. It holds for compact, which UPDATEs persist/snapshot segment leaves and INSERTs a compact_segment_metadata row. It does not hold for retire: every statement in its transaction is a leaf SELECT or DELETE, which by the same measured table takes nothing on the parent, and segment_delete_set is unpartitioned. Both still clear the lock-ordering invariant, but for different reasons, and flattening them loses the one that is stronger. CHA-546
Same falsified claim as the code comments: "naming a partition takes no parent lock at all" holds for SELECT, SELECT ... FOR UPDATE and DELETE, but an INSERT or UPDATE still leaves AccessShare on the parent to commit. The fix stands either way -- AccessShare clears teardown's EXCLUSIVE step where RowExclusive did not -- so say that rather than the stronger thing that is not true. CHA-546
nhobin219
left a comment
There was a problem hiding this comment.
Penca PR Review Summary
Reviewed at 4c4515c. CI is green (Analyze x4, Rust clippy+fmt+test, Python lint + unit, fresh-clone examples; integration skipped by path filter, as expected on a branch).
No Critical findings. All five prioritized focus areas check out - details below. One Important finding (a stale comment this PR's own guard cannot catch) and five Suggestions.
Focus-area verification
- Two-branch statement directions - correct. Traced all 7 statements in
copy_inherited_snapshotand all 4 incopy_inherited_persist(fork_copy.rs), plusinsert_carried_segment_indexes(segment_index.rs:495-606) andinsert_carried_snapshot_segments(snapshot.rs:596-640). In every case rows are SELECTed from the parent/source partition and INSERTed into the child/this-branch partition, and thebranch_uuidbind params agree with the relation names ($3= source,$1/$2= child). No swaps. - Refcount gate still catalog-wide - correct. The only surviving parent-helper call sites anywhere under
crates/(outsidenaming/tables.rs,naming/mod.rs,dialect/pg.rs) arecompact.rs:347,355,365and523,524,525- exactly the two CHA-531 gate functions. The branch-scoped sites in that file (compact.rs:57,94,138,168) were all converted. read_and_classify_persist_segmentsuses its argument - correct. Both relation names are built from thebranch_uuidparameter, andenumerate_base_cold_sourcedemonstrably threads a fork's parent branch into it. The new comment documents exactly this.- No new
relation does not exist- confirmed. Every converted read targets a leaf materialized earlier on the same path:create_catalog_tables->ensure_metadata_branch_partitionsfor main/genesis, andensure_branch_partitions(write/mod.rs:897) runs beforematerialize_fork_cold_references(write/mod.rs:960) inside onewith_pg_tx. Bothretention_floor.rstests seed against the samebranch_uuidthey pass tocreate_catalog_tables. The only residual42P01is the pre-existing teardown race, which already applied to the hot data tables. - Lock-ordering docstrings accurate. Walked
retire.rs:90-209statement by statement: inside the tx it is one SELECT and four DELETEs against this branch's partitions, theninsert_segment_delete_set_rowslast. So the new claim that retire's tx "holds no segment-metadata parent lock at all" is exactly right, and the LAST-ordering invariant oninsert_segment_delete_set_rowscorrectly identifies teardown as the only remaining writer subject to it.
Important
crates/penca-api/src/write/mod.rs:1108-1117- a parent-lock mechanism comment that CHA-546 falsified, left unrewritten while every sibling paragraph in the same function was rewritten. See the inline comment on the hunk above it.
Suggestions
crates/penca-api/src/query/meta_plan.rs:713- the newparse_uuidline splits a comment from the statement it describes. Inline.tests/integration/integration_cha546_partition_lock_footprint_test.py:257- the module-scoped fixture docstring understates cross-test coupling. Inline.- Redundant double-parse of
branch_uuid(~50 sites, follow-up-ticket shaped). Every converted function now parsesbranch_uuidtwice: the new panickingparse_uuid(branch_uuid)for the partition name, thenSqlValue::uuid_str(branch_uuid)?for the bind param (e.g.meta_plan.rs:713then:726; the same shape repeats throughoutpurge.rs,segment_index.rs,fork_copy.rs,snapshot.rs). The second parse can never fail once the first has succeeded, so it is a redundant parse plus a permanently dead error path - bindingSqlValue::Uuid(branch)removes both. Mechanical but ~50 sites, so better as a follow-up than as churn on this PR. (The panicking parse itself is fine:branch_uuidischeck_opt_uuid-validated in all three gRPC validation modules, and it matches the existing treatment ofcatalog_uuid.) - Commit
docs: correct the parent-lock claims measurement falsifiedis missing a scope. The other 17 commits all carry one;docs(db)ordocs(lifecycle)would fit. All 18 headlines are within 72 chars and theCHA-546footers are present. .claude/memory/reference_roborev_severity_threshold_met_is_clean.mdrides along in this PR (commitdocs(agent): note roborev show --json exits non-zero pre-review). The content is good but unrelated to CHA-546 - this is the known symlinked-memory-dir leak. Harmless to merge; flagging only so that keeping it is a choice rather than an accident.
Strengths
- The measurement discipline is the best thing here. The PR does not assert the lock behaviour it needs - it measures it against PG 17 via
pg_locks, publishes the per-statement table in the integration test's module docstring, and then corrects its own earlier claims where the measurement contradicted them (docs: correct the parent-lock claims measurement falsified,docs(db): correct the static test's parent-lock rationale). The result is that the residualAccessShareanINSERT/UPDATEtakes on the parent is documented as a deliberate survivor rather than quietly assumed away. - The integration test is a genuine red test, and says why the obvious alternatives are not. Locking
ONLYthe 8 parents is precisely the state teardown creates for every other branch, and the docstring enumerates three rejected designs (racing a real compact; a rawSELECT ... FOR UPDATEthat passes pre-fix; a singleACCESS EXCLUSIVEfixture for both halves) so a later reader cannot collapse it back into one. Splitting intoEXCLUSIVE-for-writes andACCESS EXCLUSIVE-for-reads is exactly right: the read half then proves no parent lock rather than merely a compatible one. _ParentLockHolderis carefully built. Dedicated connection rather than the shared pool (a pooled connection handed back mid-hold would carry the locks to another caller), all 8 parents locked in a single statement becauselock_timeoutis per-statement, and a failure to acquire raises instead of silently running both tests with no locks held - which is the exact failure mode that would turn a red test green.- The static guard has a converse.
test_refcount_gate_still_probes_parents_catalog_widepins the exact three-parent set and forbids any*_metadata_partitionin those bodies, so narrowing CHA-531's gate cannot slip through the forward guard's whole-function exemption. Scoping thecompact.rsallowance to two named functions rather than the whole file is the right granularity. - The
pg_stat_statementsneedle fix inintegration_snapshot_list_cache_test.pyis a real catch - the parent-name needle had quietly made every== 0assertion vacuous, and the new Pythonnaming.pyhelper carries a docstring explaining exactly that trap. - Rust/Python naming parity kept in lockstep, with a golden added on both sides.
- No new
.unwrap()/.expect(), no new.clone(), noSessionContext::new(), no query-count regressions, and no function-level imports anywhere in the diff.
| // it, the pre-CHA-546 `RowExclusive` did not. | ||
| // | ||
| // The branch's HOT data tables (`schema_uuid = None` = catalog-wide), | ||
| // resolved BEFORE the teardown transaction and used only for their drops. |
There was a problem hiding this comment.
Important: The paragraph immediately below this hunk (lines 1108-1117, starting // It cannot move inside.) is falsified by this PR and was not updated - even though every sibling paragraph in this same function was rewritten (this hunk at 1086-1106, and the delete-set ordering comment at 1248-1260).
It currently claims:
list_table_uuids_for_branchplans throughQueryManager::plan, which reads the catalog-wide metadata parents BY NAME - so running it in the transaction takesACCESS SHAREon every one of them [...] and it sets up theACCESS SHARE->ACCESS EXCLUSIVEupgrade at the drops that deadlocks two concurrent teardowns of DIFFERENT branches.
Post-CHA-546 that plan path names leaves, not parents: list_table_uuids_for_branch (crates/penca-api/src/query/meta_resolve.rs:872) -> resolve_table_metadata -> the meta_plan.rs reads this PR converted. So planning inside the transaction no longer takes ACCESS SHARE on any parent, and the cross-branch ACCESS SHARE -> ACCESS EXCLUSIVE upgrade cycle it describes can no longer form. The closing clause - "Partition-scoping the enumerations bought exactly that property; planning inside the lock gives it back" - is falsified for the same reason.
The conclusion still holds, but on different grounds worth stating explicitly: list_table_uuids_for_branch is a cold-capable read that can block on object storage, so moving it inside would stretch a transaction that holds EXCLUSIVE on this branch's leaves under a 5s lock_timeout.
This is worth fixing in this PR specifically because the PR's own guard cannot catch it: tests/static/static_cha546_partition_naming_test.py::test_no_open_cha546_todos greps only for the TODO(CHA-546) marker (correctly removed from this block), while that test's own comment names "the comments in pg.rs::lock_branch_teardown_partitions and write/mod.rs" as exactly the prose that "describe[s] something that no longer happens." One of the two named sites still does.
(Anchored here because 1108-1117 fall just outside the diff hunk.)
| // Parse fallibly (unlike the panicking `parse_uuid` above): a malformed | ||
| // `table_uuid` surfaces as the same typed protocol error the | ||
| // `meta_resolve` getters produce. | ||
| let branch = parse_uuid(branch_uuid); |
There was a problem hiding this comment.
Suggestion: This new line landed between the comment and the statement that comment describes. The comment above ("Parse fallibly (unlike the panicking parse_uuid above): a malformed table_uuid surfaces as the same typed protocol error...") is about parse_meta_uuid(table_uuid, "table_uuid")? on the next line, but it now reads as annotating a panicking parse_uuid call - the opposite of its point.
Moving let branch = parse_uuid(branch_uuid); up one paragraph, next to let catalog = parse_uuid(catalog_uuid); on line 709, restores the pairing and groups the two panicking parses together.
Related, non-blocking: 13 lines further down this same function binds the same string fallibly via SqlValue::uuid_str(branch_uuid)?, so branch_uuid is now parsed twice with two different failure modes and the fallible one is unreachable for malformed input. SqlValue::Uuid(branch) would reuse the value already parsed here. The same shape repeats across the converted call sites - called out as a follow-up in the review summary rather than as churn here.
| """Catalog + partitioned table + a branch with persist and snapshot state. | ||
|
|
||
| Module-scoped: the setup is DDL-heavy and identical for both tests, and | ||
| neither test mutates state the other reads. |
There was a problem hiding this comment.
Suggestion: "neither test mutates state the other reads" is not quite accurate for this module-scoped fixture. test_branch_writes_proceed_under_parent_exclusive appends a row via write_and_persist and then runs snapshot, compact_persist_segments, and purge against the same table that test_branch_reads_proceed_under_parent_access_exclusive subsequently reads.
It is benign today - the read asserts only num_rows > 0, which survives anything the write test does - but the pair is order-dependent, and this docstring is the thing that would stop someone from strengthening the read assertion later and getting a surprise.
Either restate it accurately ("the read test asserts only non-emptiness, so it tolerates whatever the write test leaves behind") or make the fixture function-scoped and pay the DDL twice.
CHA-546 falsified this block: `list_table_uuids_for_branch` resolves `sys_tables` through the converted read path, which names only this branch's partitions, so it takes no parent lock and the cross-branch ACCESS SHARE -> ACCESS EXCLUSIVE cycle it described cannot form. The conclusion is unchanged but the grounds are duration, not footprint: it is a cold-capable read, and waiting on object storage inside the tx runs against the same 5s lock_timeout that bounds the EXCLUSIVE step. Retires the matching stale clause below, which named "does not plan through the parents" as the missing capability. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new partition-name `parse_uuid(branch_uuid)` landed between the comment and the `parse_meta_uuid` call it explains, so it read as describing the panicking parse it contrasts itself against. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The write test does mutate what the read test reads — it persists, snapshots and compacts the same table. Module scope is safe because the read test's only assertion is `num_rows > 0`, which those additions cannot falsify; say that instead, and say what a future assertion has to preserve. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`lock_timeout` aborts a statement only while it waits to ACQUIRE a lock; it does not bound execution, and no `statement_timeout` is set on this path (`pg.rs:1410` is the only timeout). So an object-storage wait inside the teardown tx cannot raise 55P03 — the previous wording had a cold miss failing teardown, which it cannot do. The real cost runs the other way: nothing bounds the read, so it holds this branch's 14 leaves EXCLUSIVE for the length of the fetch and widens the window before the drops, which are where the 5s bound actually bites. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CHA-546 gave every branch-scoped function a `let branch = parse_uuid(branch_uuid)` to build the partition name, but the bind kept going through `SqlValue::uuid_str(branch_uuid)?` — a second parse of the same string, behind a `?` that the first (panicking) parse has already made unreachable. Bind `SqlValue::Uuid(branch)` instead. 72 sites across 7 files, restricted to functions that provably parse the same argument already: 59 on `branch_uuid`, 11 on `parent_branch_uuid` and 2 on `source_branch_uuid`. Verified value-identical rather than assumed — every one of the 66 reused locals is bound by name-matched `parse_uuid` (`branch <= branch_uuid`, `parent <= parent_branch_uuid`, `source_branch <= source_branch_uuid`) and none of the three names is ever rebound to anything else, so no shadow can silently redirect a bind. The fork-copy sites matter most on that point: `parent` there is the parent's uuid, never the `child` parameter beside it. The 13 sites left alone are in functions with no local parse (`meta_plan::read_branch_lineage`, `tx_log.rs`, `branch.rs`, `storage-hot/tx.rs`); their `uuid_str` is the only parse and stays. CHA-546 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes CHA-546.
The 6 tx-log tables already resolved by partition name. The 8 metadata tables did not: ~80 sites in
penca-storage-metaandpenca-api/src/query/meta_plan.rsnamed the catalog-wide parent and let Postgres route onbranch_uuid. This makes the rule uniform — every branch-scoped access to a partitioned table targets the branch's partition directly, reads included.What it actually buys, measured
CHA-546's premise is only half true as originally written, and the difference matters enough that the PR pins it with an integration test rather than prose.
SELECTSELECT ... FOR UPDATEDELETEINSERTAccessShare, held to commitUPDATEAccessShare, held to commitReads get the total fix. Writes get a downgrade, not an elimination: evaluating the leaf's partition constraint opens the parent for its partition key, so
RowExclusivebecomesAccessShareand stays held to commit. This is Postgres behavior, not a leftover parent-naming bug.The read half is the larger win. Concretely, against
lock_branch_teardown_partitions:EXCLUSIVEstep conflicts withRowExclusivebut not withAccessShare— so the downgrade clears it.DROP TABLEneedsACCESS EXCLUSIVE, which conflicts with every mode, includingAccessShare— so the downgrade does not clear that, and an in-flight writer still delays the drops. That is a wait, not a cycle.tests/integration/integration_cha546_partition_lock_footprint_test.pyasserts both halves at the mode each can actually clear: branch writes proceed under a parentEXCLUSIVE, branch reads proceed under a parentACCESS EXCLUSIVE. A singleACCESS EXCLUSIVEfixture for both — what the file did first — is a green no correct implementation can earn; it fails on an idle stack.Deliberate exceptions to the partition-direct rule
Two, both enumerated in code:
crates/penca-db/src/dialect/pg.rs—CREATE TABLE ... PARTITION OF,ATTACH/DETACH, and the teardown lock step are parent operations by definition.crates/penca-storage-meta/src/compact.rs—eligible_segment_delete_set_rowsandreap_referenced_segment_delete_set_rowsprobe the three segment-metadata parents catalog-wide, deliberately: carry-forward crosses fork edges, so narrowing to one branch would collect a file a sibling still reads.segment_delete_setitself is unpartitioned, keyed onobject_urialone.Acceptance criterion is machine-checked
tests/static/static_cha546_partition_naming_test.pyscans the metadata SQL for parent-name construction, so the rule cannot silently regress. The two exceptions above are allowlisted there with their rationale.Also fixed:
integration_snapshot_list_cache_test.pymatchedpg_stat_statementson the parent name. Once the read path named leaves, that needle stopped matching anything — its sanity check went red, and every== 0assertion in the file had quietly become a tautology. Addedtable_snapshot_segment_metadata_partitionto the client naming module with parity goldens on both sides.Testing
just check