Skip to content

fix(reporting): key status checkpoints by resolved offering - #1775

Merged
bokelley merged 2 commits into
nextfrom
claude/issue-1695-checkpoint-offering
Nov 1, 2026
Merged

bokelley merged 2 commits into
nextfrom
claude/issue-1695-checkpoint-offering

Conversation

@bokelley

@bokelley bokelley commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

What was wrong

Since #1729 a reporting subscription keeps one configuration generation per resolved offering of a version (ReportingConfigurationGenerationKey includes offering_id), but reporting status checkpoints were still keyed per subscription version: ReportingStatusScope.checkpoint_key was (account_id, consumer, delivery_config_id, delivery_config_version, scope_kind, obligation_id). Two offerings of one subscription version, for one account and owner, mapped to the same configuration-scope checkpoint:

  • memory (InMemoryStatusNotificationStore): the second offering's scope silently replaced the first, so one offering had no checkpoint or status events;
  • PostgreSQL (PgStatusNotificationStore): the ledger refused owed sets whenever status notifications were on (UNSUPPORTED_FEATURE), and with that guard removed, ON CONFLICT DO NOTHING on the six-column key dropped the second offering's checkpoint;
  • the production stores (memory and PostgreSQL) refused a second offering of a version with reporting_production_offering_conflict, for the same reason.

Reproduction: test_each_resolved_offering_of_a_subscription_version_has_its_own_checkpoint in test_reporting_status_projection_contract.py runs the same case on both stores. On next the memory case fails with three checkpoints instead of four (one configuration scope lost), and the PostgreSQL case fails with the UNSUPPORTED_FEATURE refusal. With the guard lifted but the key unchanged, it also fails with a missing configuration checkpoint.

The fix

  • ReportingStatusScope.checkpoint_key ends with the generation's offering_id ('' for an account-level generation). Both stores key checkpoints, leases and status events by it.
  • New checkpoint_digest feeds the status cause_id (rpsc_...) and the projection history scope_keys. For offering_id == '' it digests the original six columns, so every id and digest existing deployments already store is byte-identical. A unit test pins this.
  • The memory store re-keys an old six-column shared-state image when it loads it.
  • The _status_covers_owed_sets guard in PgReportingLedgerStore and the reporting_production_offering_conflict checks in both production stores are removed. They existed only because of the shared checkpoint. The production test now asserts that a second offering of one version is served.

PostgreSQL and the migration

New idempotent DDL file ledger/reporting_status_checkpoint_offerings.sql:

  • widens reporting_status_scope_checkpoints' primary key to (account_id, consumer_namespace, delivery_config_id, version, scope_kind, obligation_namespace, offering_id). The table already had offering_id, but it was not part of the key;
  • adds offering_id to reporting_status_notification_events, and to reporting_projection_notification_events where the projection is installed. The column is filled from each event's checkpoint (the immutable-event trigger is disabled only for that one-time backfill). Each table's checkpoint foreign key and per-checkpoint-generation unique key are re-pointed at the widened key under their existing PostgreSQL names. The other tables' auto-generated constraint names, and so the manifests' keys, therefore don't shift;
  • adds CHECKs that tie each table's offering_id to its scope document, as the other key columns already are;
  • defines the checkpoint and event guards (reporting_status_scope_guard, reporting_status_event_guard, reporting_projection_event_guard), which now compare the offering. Each function body lives only in this file;
  • refuses, instead of dropping, a foreign key outside the SDK that references the checkpoint key.

migrations/0006_reporting_status_checkpoint_offerings.sql is -- include: of that file. It is marked -- adcp-upgrade: stopped-worker because workers of earlier releases insert checkpoints with ON CONFLICT on the six-column key, which no longer exists, and swapping the primary key takes exclusive locks on the checkpoint and status event tables. On a schema without status notifications it is a no-op. The status store, projection, production and maintenance (migrate_legacy_reporting) create_schema chains run the same file, after reporting_projection.sql and before reporting_projection_notifications.sql. That last file now creates the projection event table on the widened key.

outbox/required_status_schema.json and projection/required_schema.json were regenerated from a fresh install. A schema without the widening fails validate_status_schema (the checkpoint primary key and guard fingerprints changed), and postgres_storage startup names 0006 as pending, with the python -m adcp migrate command and the stopped-worker note. That message now pluralizes correctly when two stopped-worker upgrades are pending.

Tests

  • New contract test (memory and PostgreSQL): one subscription version resolves two offerings for one account and owner. Both offerings get their own configuration and obligation checkpoints and publish distinct events with distinct cause_ids.
  • New unit test: the account-level checkpoint digest is unchanged.
  • New PG test test_a_six_column_status_checkpoint_schema_is_keyed_by_offering. It builds checkpoints and events on the published six-column key, with an account-level checkpoint and an offering checkpoint, and applies 0006 through migrate(). It then checks idempotency, the widened key, the event offering backfill, and validate_status_schema, and that a second offering's checkpoint and generation-1 event are then accepted.
  • test_status_notifications_cover_subscription_generations replaces the refusal test. The production test now asserts that a second offering is served. The migration list and pending-message tests now include 0006.
  • Locally, against PostgreSQL 16: all of test_reporting_status_*.py except the frozen-artifact actual_* cases, which need a full checkout of the reviewed SHAs (267 passed), plus the migration, subscription, projection, production, empty-ledger and catalog suites (1037 passed). Under -n 2 on a loaded machine, about 25 timing-sensitive production and projection cases failed. Their failures are the same on unchanged next (9 of those 25 failed there). Run sequentially, every one passes on this branch. I also ran a separate check against real next code: it built a production schema, wrote status events with the old code, applied 0006 alone, and continued writing with the new code. The schema matched every manifest, existing cause_ids were unchanged, and new events were written.

Note: the cherry-picked test commit

This branch also carries ed7a19fcf test(reporting): run the row-upload lease test at the wall clock, the single commit of #1767, cherry-picked unchanged. Since 2026-11-01, Postgres conformance tests (Postgres 16, core) fails on every PR into next in test_the_producer_stages_object_uploads_outside_its_publication_transaction (STATE_UNAVAILABLE), because the harness clock is fixed in the past. On this PR's first CI run that was the only failure: 2889 passed. Whichever of the two merges first, the other's copy becomes a no-op.

🤖 Generated with Claude Code

A reporting subscription keeps one configuration generation per resolved
offering of a version, but status checkpoints were keyed per subscription
version: (account, consumer, delivery_config_id, version, scope kind,
obligation). Two offerings of one version for one account and owner shared a
configuration checkpoint. The memory store silently merged the second into
the first; the PostgreSQL store refused owed sets whenever status
notifications were on, and would otherwise have dropped the second scope on
ON CONFLICT DO NOTHING. The production stores refused a second offering with
reporting_production_offering_conflict for the same reason.

ReportingStatusScope.checkpoint_key now ends with the generation's
offering_id ('' for an account-level generation). checkpoint_digest feeds
the status cause_id and the projection history keys; it digests the six
original columns when the offering is '', so every id and digest that
deployments already store is unchanged. The memory store re-keys an old
six-column state image.

PostgreSQL: reporting_status_checkpoint_offerings.sql widens the checkpoint
primary key to include offering_id, adds offering_id to the status and
projection status event tables (filled from their checkpoint), re-points
their checkpoint references and per-generation unique keys at the widened
key under their existing names, and defines the checkpoint and event guards,
which now compare the offering. Migration 0006 includes it; it is a
stopped-worker upgrade, and a no-op without status notifications. The status
store, projection and production create_schema chains run it too, and the
status and projection manifests are regenerated. The refusals that existed
only because of the shared checkpoint are lifted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@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 — clean review, no blocking or medium findings.

The reviewer verified the load-bearing concerns on this fix(reporting): change that keys status checkpoints by resolved offering:

  • checkpoint_key grows to a 7-tuple; checkpoint_digest digests six columns when offering_id == '' so account-level cause_id/history keys stay byte-identical (pinned by test_account_level_checkpoint_identity_is_unchanged).
  • The _lock_scopes_on INSERT (status_pg.py:296-307) stays aligned (7 plain %s → key, 8th %s::jsonb → scope); _enqueue_on gained exactly one %s.
  • Backfill ordering in reporting_status_checkpoint_offerings.sql is safe (lines 299-311 set offering_id under ACCESS EXCLUSIVE with USER triggers disabled, FK re-added only after at line 336); migration fail-closes on a foreign FK rather than dropping it (259-265).
  • Cross-release wire surface unchanged (offering_id on the wire since #1729), so no DR-0032/0033 pathway owed. Manifests regenerated; all five create_schema chains run the new file in the correct slot; lock orderings end in offering_id consistently.

Gate checks: no critical/high findings (row 1 N/A); gated_paths false (row 2 N/A); high_risk false (rows 3/5 N/A); no medium findings (rows 4/8 N/A); no prior decision (row 6 N/A); no no-auto-approve team match (row 7 N/A). Falls through to row 9 → approve.

The producer row-upload test stamps its worker lease with the settling
harness clock, fixed at 2026-11-01T02:10Z, while the row store checks the
lease against the database's clock_timestamp. Once the wall clock passed that
instant plus the lease, every run failed with STATE_UNAVAILABLE: no live
worker lease covers this row upload, on next and on every pull request. The
test now runs its turn at the wall clock, so the lease lies in the real
future.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@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 — test-only fix, no blocking or medium findings.

This PR's reviewable delta is a single test-only change in tests/conformance/reporting/test_reporting_row_storage_object_pg.py: it sets clock[0] = datetime.now(timezone.utc) before run_worker() so the Postgres worker lease (compared against the DB's clock_timestamp()) lands in the real future rather than the harness's fixed instant. The inline reasoning is correct and no production path is touched.

Gate checks:

  • No critical/high/medium findings (rows 1, 4, 5, 8 don't fire).
  • gated_paths: false and review_decision: APPROVED (row 2 doesn't fire).
  • high_risk: false, no deletions/modifications triggers (rows 3, 5 don't fire).
  • Prior decision was approve, so sticky escalation (row 6) doesn't apply.
  • No no-auto-approve team match (row 7 doesn't fire).

Falls through to row 9 → approve.

@bokelley
bokelley merged commit d490481 into next Nov 1, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1695-checkpoint-offering branch November 1, 2026 22:13
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