Skip to content

feat(reporting-storage): make the contract-2 ledger upgrade rolling-deploy safe with expand and contract - #1868

Merged
bokelley merged 3 commits into
nextfrom
claude/issue-1856-expand-contract-upgrade
Nov 8, 2026
Merged

bokelley merged 3 commits into
nextfrom
claude/issue-1856-expand-contract-upgrade

Conversation

@bokelley

@bokelley bokelley commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1856. An independent review found one blocker (an old pod enabling status notifications after the expand left checkpoint tables new pods and the contract step could not handle); it is fixed in bb4bac4 and re-verified for both published pairs.

A default rolling update from adcp==9.3.1 + adcp-reporting-storage==0.1.0b4, or from 0.2.0b0 + 10.0.0b2, now works. While old and new processes share the ledger:

  • old processes keep booting, reading and writing account-level reporting, with or without notifications;
  • new processes work fully except for subscription generations;
  • rolling back to the earlier release works until the operator contracts.

Contracting then leaves exactly the fresh-install catalog.

Design

  • Expand. 0002 and 0006 now leave an expand state instead of widening in place, and are no longer stopped-worker. What the earlier release needs keeps its original name: reporting_configurations_pkey stays the 4-column PK, the 6-column obligation period key stays, and so do the old FKs and the checkpoint/event keys. The widened objects get new names (reporting_configurations_generation_key is the 5-column UNIQUE, and so on), and the widened tables carry CHECK (offering_id = ''). On the expand state, 0002 and 0006 are no-ops.
  • Shared function bodies. Both releases validate function bodies at runtime, not just at boot. So while uncontracted, both install the exact 0.1.0b4 bodies of the 3 reconciliation functions and the 2 status guards. An old pod restarting mid-rollout then changes nothing either side checks. The new validators accept the expand catalog through outbox/required_expand_schema.json: alternate fingerprints, each accepted only when the widened object replacing it is present.
  • Contract. New migration 0007_reporting_contract_generation_keys has the header -- adcp-upgrade: contract. Only python -m adcp migrate --contract (or migrate(contract=True)) applies it to an expanded ledger. Plain migrate, create_schema() and apply_pending stop before it, so migrate stays safe as a pre-deploy job. --status ends with "up to date except the contract step: run … once every earlier-release reporting process has stopped".
  • Storage still starts. postgres_storage().check_schema() doesn't count a pending contract step as behind.
  • Fresh installs are created contracted (0007 recorded). Ledgers 0.2.0b1/b2 already widened boot unchanged, and are treated as contracted.
  • Subscriptions before contract raise ReportingUpgradeNotContractedError, a LedgerConflictError with code REPORTING_UPGRADE_NOT_CONTRACTED. It names the command and is exported.

Tests (real PostgreSQL, published wheels)

The new rolling test runs the full sequence for both published pairs, with and without notifications:

  1. seed with the old pair;
  2. new boot (expand);
  3. old restart, reads, put_configuration, commit_obligation + commit_revision, and a worker turn;
  4. new reads and writes;
  5. old restart again, then new reconciliation and status writes;
  6. a subscription before contract gets the clear error;
  7. --contract;
  8. the catalog, including function definitions, equals a fresh install, and a subscription now works.

There are also tests for rollback before contract, migrate without the flag (plus --status / describe and check_schema), fresh installs, a 0.2.0b1-widened ledger, the expand manifest against a real expanded ledger, and concurrency (two new boots plus one old boot: each version recorded once, no deadlock).

Local validation:

  • make lint, make typecheck-all, make validate-generated: pass.
  • Full suite without PostgreSQL: 21,832 passed.
  • Reporting conformance with CI's ignore list: every remaining failure family also fails on origin/next, and the one test that failed only on this branch passes when run on its own.

Notes and limits

  • 0.1.0b5: needs fix(reporting-storage): let 0.1.x run on an expanded ledger until it is contracted #1867 on main (refuse on the key, not the column) before it's published. With that, 0.1.0b5 runs on the expand state like 0.1.0b4.
  • Extensions stay stopped-worker: the materializer, production and projection stores still re-key their own objects when the new release boots. The docs say so.
  • 0.2.0b1/b2: a process of those releases pointed at an expanded ledger fails with DuplicateColumn (nothing is corrupted). The docs say to skip them.
  • Contract locks: the contract step takes exclusive locks and re-validates the re-created FKs, so expect a pause on large reconciliation tables.

🤖 Generated with Claude Code

bokelley and others added 3 commits November 8, 2026 03:16
…eploy safe with expand and contract

A ledger created by adcp-reporting-storage 0.1.x/0.2.0b0 is now upgraded in
two halves, so a default rolling update works and a rollback keeps working
until the operator contracts (#1856, #1831).

Expand (0002, 0006; online-safe): offering_id is added with CHECK
(offering_id = ''), the keys and references the earlier release uses keep
their names and definitions (four-column reporting_configurations_pkey,
six-column period, checkpoint and event keys, four/six-column references),
and the widened ones are added beside them
(reporting_configurations_generation_key,
reporting_obligations_offering_period_key,
reporting_status_scope_checkpoints_offering_key,
reporting_status_notification_events_offering_fkey/_offering_key). While
uncontracted, both releases install the earlier release's bodies of the
three reconciliation functions and two status guards, so an old pod's
restart changes nothing either release validates. The validators accept the
expanded catalog through outbox/required_expand_schema.json, derived from a
real expanded ledger.

Contract (0007, "-- adcp-upgrade: contract"): drops the expand-only objects,
renames the widened keys back, re-creates the offering-aware references and
restores this release's function bodies; the result is catalog-identical to
a new ledger. Only "python -m adcp migrate --contract" (migrate(contract=True))
applies it to an expanded ledger; plain migrate, create_schema() and
apply_pending() stop before it, and record it unprompted on a ledger that
was never expanded. postgres_storage().check_schema() ignores a pending
contract step. A new ledger is created contracted.

A subscription generation written before contract raises
ReportingUpgradeNotContractedError (a LedgerConflictError, code
REPORTING_UPGRADE_NOT_CONTRACTED) naming the contract command.

The published-wheel tests drive the full rolling sequence for 0.1.0b4/9.3.1
and 0.2.0b0/10.0.0b2, with and without notifications, plus rollback,
concurrent old/new boots, the CLI, a 0.2.0b1-widened ledger and the expand
manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….0b0 ledger upgrade

Rewrite "Deploying the upgrade", the storage README and MIGRATION_v9_to_v10:
roll out normally, run python -m adcp migrate --contract once every
earlier-release reporting process has stopped, subscriptions need the
contract step, and a rollback works until it. Recreate/stopped-worker advice
remains only for the materializer, production and projection extensions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reates while the ledger is expanded

An earlier-release process that turns status notifications on after the
expand creates its own six-column status tables without offering_id. A later
new boot with notifications then failed on the seven-column offering key,
and the contract step failed too. The checkpoint-offerings DDL now adds
offering_id and the five-column generation reference to any checkpoint
table that lacks them, before the expand or in-place widening, and 0007
re-runs it first, so contract never meets a half-expanded table.

The published-wheel tests cover the sequence for both pairs: seed without
notifications, expand, old pod with notifications, then a new pod with
notifications or the contract step directly, and a contracted catalog equal
to a fresh install.

Docs: scope the rolling-update headline to the ledger, outbox and status
notifications; say the contract step can time out or deadlock against busy
new pods and is atomic, so retry; and say an old pod running past contract
can still touch subscription generations' lease columns, so stop it first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IF EXISTS (SELECT 1 FROM pg_constraint
WHERE conrelid = 'reporting_configurations'::regclass
AND conname = 'reporting_configurations_generation_key') THEN
LOCK TABLE reporting_configurations, reporting_obligations, reporting_consumer_statuses

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: This LOCK TABLE … ACCESS EXCLUSIVE runs with no bounded lock_timeout. The docs (docs/reporting-ledger-migration.md:291-293) explicitly support running --contract while new-release pods keep serving, and frame the risk as "it waits out a lock or statement timeout … run it again." But a queued ACCESS EXCLUSIVE request blocks all subsequent access (including new pods' reads) to reporting_configurations behind it, and with the server default lock_timeout = 0 that wait is unbounded — the contract does not fail fast, it wedges live new pods until a human kills it. The "atomic, retry it" model only holds if the lock actually times out.

The SDK already bounds this on worker paths: ledger/pg.py:1010-1016 caps lock_timeout to ≤5000ms before taking locks. The contract step should do the same (set a bounded lock_timeout before the LOCK TABLEs), or the docs must state the operator MUST set lock_timeout before --contract.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — expand/contract reporting ledger upgrade (#1856).

What I checked:

  • No critical/high findings (row 1 clear).
  • gated_paths: false, no deletions, high_risk: false (rows 2–5 clear).
  • No prior decision; author not on any no-auto-approve team (rows 6–7 clear).
  • Only 1 medium finding — below the 3-finding threshold (row 8 clear).
  • feat(reporting-storage): is the correct non-breaking semver signal; new public export is paired with the api snapshot update. Expand → side-by-side → subscription refusal → contract pathway is driven by real wheel pairs (0.1.0b4/9.3.1, 0.2.0b0/10.0.0b2) with byte-identical catalog assertions including function bodies, notifications, rollback, concurrency, and late-created status tables. put_configuration's CHECK → ReportingUpgradeNotContractedError path and needs_contract gating verified.

Falls through to row 9 → approve.

Medium findings (non-blocking):

  • packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_contract.sql:47 — Contract step takes ACCESS EXCLUSIVE locks with no bounded lock_timeout; a contract run against still-serving new pods can block live access indefinitely rather than failing fast. Worth bounding like the worker paths (pg.py:1010-1016), but a single medium with no escalation trigger does not block.

Medium findings

  • packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_contract.sql:47 — Contract step takes ACCESS EXCLUSIVE locks with no bounded lock_timeout

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