Repository navigation
perf(reporting-storage): drop the per-object advisory lock where nothing pairs on it - #1836
Conversation
…ing pairs on it Mirrors the cross-SDK contract from adcp-client#3606. Retirement, the intent sweep and a commit that adopts an object its own intent recorded take no object lock and hold no transaction or pooled connection across the provider delete. The lock stays in the two places that pair with each other: the orphan collector delete, which locks every binding key for the object, and a commit that adopts an object its intent did not record. A commit closes its intent with RETURNING created_objects to know which adopted objects it recorded. The lock key text is unchanged, so a Python deleter and a JavaScript adopter agree in one database. Closes #1492. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — concurrency-critical lock-removal refactor, verified safe with no blocking or medium findings.
What I checked:
gated_pathsis false;review_decisionis REVIEW_REQUIRED but no gated path or prose hard gate applies (this is a separate reporting-storage package, not the publicadcp.*surface). Row 2 does not fire.high_riskis false; no deletions, no sensitive-category findings.- No author no-auto-approve team match.
- Reviewer found nothing Critical/High/Medium. The lock removal in
delete_unreferencedis load-bearing-fence-enforced (assert_reporting_row_key_template_v1requires{revision_key}+{content_sha256}), the retained lock ondelete_orphaned_objectvslock_and_verify_adoptedis correct, lock order is consistent (no cycle), and signature changes are back-compatible. Theperf:commit prefix is correct — no wire/schema/public-export change. A 293-line PG conformance suite covers the exact races.
One non-blocking follow-up noted by the reviewer (stale "atomic step" comment at row_retention.py:144-145) is outside the diff and informational only.
No row 1–8 condition fired → row 9 approve.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Clean port of the cross-SDK lock contract (adcp-client#3606). The reviewer verified the safety rests on enforced invariants: _assert_binding_key_template requires {revision_key}+{content_sha256} on writable bindings (fence E), so lock-free deletes cannot race a same-key adopter within a writable binding; cross-binding stays on the orphan collector's any_binding+lock path. The per-object advisory lock is correctly dropped on retirement/sweep/recorded-adoption and kept only on the two paired paths (delete_orphaned_object, lock_and_verify_adopted). Confirmed: DELETE ... RETURNING is single-row (PK match), so fetchone()-is-None equals the old rowcount != 1; the int>0→bool migration in orphan_collector is correct; the lock-free paths no longer hold a connection across provider I/O. A comprehensive conformance suite was added.
No critical/high findings. No gated paths, no high-risk flag, no author-team gate. Only a single Low finding (stale atomicity comment at row_retention.py:144, not even in the diff), which does not block.
Medium findings
None.
Closes #1492
What
Mirrors the cross-SDK contract that merged in adcp-client#3606 (closes adcp-client#3281): the per-object advisory lock
adcp-reporting-object:{binding}:{object_ref}is removed where nothing pairs on it, and kept in the two places that pair with each other. It is a partial removal, not a full one, because removing it everywhere is unsafe (the collector case below).Contract (as implemented)
liveorunavailablerow set outside the operation (outside the obligation for retirement, none excluded for the sweep) names the same binding,object_refandnative_version_ref, and noopenintent in the binding lists it increated_objects(current and pre-alignmentobjectKey/nativeVersionspellings both read); then the exact version is deleted. No transaction or pooled connection is held across the provider delete.adcp-reporting-object:+ binding ID +:+ object ref, hashed in SQL withhashtextextended(text, 0), so a Python deleter and a JS adopter agree in one database.DELETE ... WHERE owner_token = ... AND state = 'open' RETURNING created_objects(no row failsSTATE_UNAVAILABLE). For each adopted object whose(object_ref, native_version_ref)is not in the returned list it takes its binding's object lock, in object-ref order, then proves every adopted object with a version-pinnedstat(full-read fallback), then inserts the chunk rows.delete_orphaned_object) locks every binding's key for the object (configured plus registered, sorted by binding ID), repeats the reference check across all bindings under those locks, then deletes. It is the only locked delete. A Python deleter holding every binding's lock pairs with a JS adopter holding its own binding's lock.write-unique-versionin provider conformance).Precondition, documented and not enforced: two writable bindings must not share one bucket and prefix, because the reference check is per binding outside the collector (the old per-binding lock keys did not order those either).
Why it is safe without the lock
Fences that exist independent of the lock: (A) the account lock held by a commit, retirement phase 1, intent takeover and the sweep claim; (B)
rows_statemoves topruningunder the account lock before any delete, andpruningrow sets neither count as references nor can be shared, so for any two deleters at least one deletes; (C) the intent movesopen -> sweepingagainst the commit'sDELETE ... WHERE state = 'open', in the same transaction as the chunk insert, so a reader sees the open intent or the chunk rows, never neither; (D) deletes name an exact native version; (E) every writable key includes{revision_key}and{content_sha256}, so only one row set can name it. The only delete no fence covers is the collector's, of an object no record names, against a commit adopting it unrecorded; that pair keeps the lock on both sides. The full case table is the one in adcp-client#3606, and the contract is written inReportingRowStorageV1(# -- object lifetime) anddocs/reporting-row-storage.md#the-object-lock-contract.Python-specific notes
pruningunder the account lock before phase 2 deletes, which the comment inrow_retention.pynow records.delete_unreferencedno longer takesany_binding;delete_orphaned_objectand_is_referencedreplace it.lock_and_verify_adoptedgains arecordedargument (default empty, which locks every adopted object as before, so an existing caller keeps the old behavior).recorded_intent_objectsreads both key spellings.!: no exported name or wire/schema change, and rolling upgrades between a version that locks everywhere and this one are safe because every pair that needs exclusion keeps the lock on both sides.perfbecause this removes work from commit and retention paths.Deployment, migration and rollback
No migration and no schema change. Deploy as usual. Rollback is a plain binary rollback in either direction at any time: the lock key text is unchanged, and the older code locks a superset of what this code locks.
Tests
Ported from JS
reporting-row-shared-object-pgwhere they apply (tests/conformance/reporting/test_reporting_row_object_lock_contract_pg.py):pg_stat_activityfrom inside the provider delete).pruningrow set does not count.stat; an orphan delete takes every binding's lock and keeps an object another binding's intent names.Not ported because they exercise JS-only paths: compaction (Python has none), the header-only sharer losing to compaction, and the readOnly-legacy retirement barrier test. The existing Postgres suites for row storage, retention, retirement, the collector and the fence pass unchanged.
make lintandmake typecheck-allpass; Postgres tests run locally, serially.🤖 Generated with Claude Code