Skip to content

Keys the chart lock by store - #246

Merged
johnnyt merged 1 commit into
mainfrom
sp-j6vh-chart-lock-store-key
Sep 30, 2026
Merged

johnnyt merged 1 commit into
mainfrom
sp-j6vh-chart-lock-store-key

Conversation

@johnnyt

@johnnyt johnnyt commented Sep 30, 2026

Copy link
Copy Markdown
Member

What

The Ecto adapter's per-chart advisory lock on Postgres (a shared lock for a tombstone read, an exclusive one for a retirement) is now keyed by the store as well as the content hash. The second key is hashtext(store <> " " <> content_hash), where the store is the chart schema's table under its prefix, each written as a double-quoted identifier (chart_store/1 in lib/statifier_persistence/storage/ecto.ex, read the way supports_chart_retirement?/1 reads them). The first key stays @chart_lock_namespace, so the upgrading page's first-key sentence still holds.

Two stores in one database no longer wait on each other for a hash they share. Two host modules on one physical charts table are one store and keep sharing the key.

The unit is the store, never the tenant, ruled by the operator, 2026-09-29: beside its primary key, the charts table's unique index is on content_hash alone (V01), so tenants sharing one charts table race on one tombstone row and must keep sharing the per-hash lock.

Changes

  • lib/statifier_persistence/storage/ecto.ex: chart_lock/3 passes the store identity with the content hash; new private chart_store/1 and quote_identifier/1.
  • test/statifier_persistence/ecto/retire_chart_race_test.exs: two live Postgres cases. "two stores in one database retire one hash without either waiting" (the Scoped and SharedIdScoped fixture stores: one table name under two prefixes); "two host modules on one charts table still share the hash's lock" (Default and Bigserial on one table: a tombstone read through one waits for a retirement held through the other). The existing cases in the file are unchanged.
  • docs/adr/0012-retention-and-retirement.md: a dated Amendment at proposed, appended at the foot (zero removed lines): the key, its unit, why a per-tenant key is refused, the prefix-free search_path edge. It adds to the 2026-09-29 Note's "The key is database-wide" sentence without removing it.
  • changelog.d/sp-j6vh.md: under Changed.

Callers of the lock body (read, not written)

statifier_oban calls none of fetch_retired_info/2, retire_chart/3 or check_chart_retired/2. statifier_router reaches the lock only through StatifierPersistence.Executions.create/4 (persistence_create/4 in its delivery module), inside one store, where the key changes no wait and no answer.

Provenance

  • Identity encoding (an engineering choice): each part a quoted identifier with inner quotes doubled, joined by a dot, then a space and the content hash, so no two stores and hashes join to one text.
  • The same-table case reads the tombstone rather than retiring a second time: a second retirement waits on the uncommitted row lock of the first whatever the advisory key is, so only the shared read discriminates the key.
  • The two-stores case is a live Ecto race case beside the existing ones, decided under the night rule by the conductor, 2026-09-29: the conformance suite builds one store per host module and cannot build two.

Sabotage

  • chart_lock/3 with the content hash alone as the second key's text: red, "two stores in one database retire one hash without either waiting" (Task.yield answered nil).
  • chart_store/1 with the prefix dropped: red, the same case.
  • chart_store/1 appending the schema module's name to a prefix-free identity: red, "two host modules on one charts table still share the hash's lock" (the read did not wait).

Each reverted from a copy, byte-equal.

Gate

Full mix quality on the committed tree, quoted whole from the gate's own output:

Running quality checks...

✓ Format: No changes needed (452ms)
✓ Compile: dev + test compiled (warnings as errors) (2.1s)

Running analysis stages in parallel...

○ Doctor: skipped (:doctor not installed)
○ Gettext: skipped (:gettext not installed)
○ Sobelow: skipped (:sobelow not installed)
✓ Doc links: 8 links checked (24ms)
✓ Dependencies: No unused dependencies (617ms)
✓ Credo: No issues (3.3s)
✓ Docs: No warnings (3.3s)
✓ Tests: 1,349 of 1,349 passed, 95.8% coverage (21.3s)
⋯ Dialyzer: building PLT (this is a one-time cost)
✓ Dialyzer: No warnings (PLT built this run) (71.8s)

✓ All quality checks passed!

Refs: sp-j6vh

The per-hash advisory lock's second key now hashes the store's
identity with the content hash: the chart schema's table under its
prefix, each a quoted identifier (chart_store/1). Two stores in one
database no longer wait on each other for a hash they share; two host
modules on one physical charts table are one store and keep sharing
the key. The first key stays @chart_lock_namespace.

The unit is the store and never the tenant, ruled by the operator,
2026-09-29: the charts table's unique index is on content_hash alone,
so tenants sharing one table race on one tombstone row.

Records the key in a dated Amendment at proposed on ADR-0012, and
pins it with two live Postgres race cases.

Refs: sp-j6vh
@johnnyt
johnnyt merged commit c720da0 into main Sep 30, 2026
1 check passed
@johnnyt
johnnyt deleted the sp-j6vh-chart-lock-store-key branch September 30, 2026 06:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant