Skip to content

fix(reporting)!: scope configuration generations by account - #1174

Open
bokelley wants to merge 2 commits into
mainfrom
conductor/reporting-account-scoped-keys-1169
Open

bokelley wants to merge 2 commits into
mainfrom
conductor/reporting-account-scoped-keys-1169

Conversation

@bokelley

@bokelley bokelley commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Two authorized accounts accepting daily@1 previously shared configuration storage and lease identity. This change introduces the frozen public ReportingConfigurationGenerationKey(account_id, delivery_config_id, delivery_config_version) and uses it across configuration maps, obligation lookup, worker resolution, leases, and status/issue joins in both stores.

PostgreSQL conflicts and lease updates select the complete account-qualified generation. Concurrent configuration writes check the retained winner's digest and reject changed content. Releases also match expiry so an expired handle cannot release a replacement held under the same worker ID. Reusing a global obligation ID for another logical period is rejected consistently.

Closes #1169

Migration and compatibility

  • create_schema() runs bootstrap and the bundled reporting_ledger_account_generations.sql in one transaction under a shared schema advisory lock. The standalone migration is also atomic in autocommit mode.
  • The migration replaces the literal beta.15 primary key under an exclusive table lock and preserves its name and all retained rows, hashes, leases, feed sequences, and unrelated constraints/indexes. Concurrent/repeated upgrades are idempotent. Unexpected keys or dependent foreign keys fail and roll back; no CASCADE or data deletion.
  • generation_key intentionally stops returning a two-tuple. Use its named fields. Existing constructors and low-level store method arguments remain compatible. Persisted consumer-status/obligation/issue identities retain their serialization.
  • Drain older reporting workers before migration. See docs/reporting-ledger-migration.md on this branch.

Fair lease scheduling

Independent review found that the in-memory store always reacquired the first released generation, starving later accounts while PostgreSQL rotated fairly. The reference store now ranks unheld before expired leases, oldest expiry first, then least recently leased. A shared memory/PostgreSQL regression proves three accounts receive a 4/4/4 split across 12 worker turns.

Validation

  • Initial implementation gate with PostgreSQL: 163 focused and 8,460 full-suite passed.
  • Independent review with PostgreSQL 16.14: 165 focused passed and 8,462 full-suite passed, 34 skipped, 9 deselected, 1 expected failure.
  • Real-database probes cover immutable contention, cross-account identity reuse, lease expiry/fencing, six-process beta.15 migration contention, autocommit atomicity, NULL rollback, renamed/unexpected keys, dependent foreign keys, and un-migrated worker failure.
  • Strict mypy for 1,352 source files and 26 adopter fixtures, Ruff/Black, commit hooks, and wheel resource byte-comparison passed.
  • GitHub CI covers Python 3.10–3.13, PostgreSQL conformance, schemas, security, downstream imports, and JavaScript storyboard lanes.

Residual risks

  • This is an intentional public return-type and storage-key migration, hence the breaking-change PR marker.
  • Mixed old/new workers and rollback to beta.15 after accounts reuse configuration IDs are unsafe.
  • The primary-key rebuild takes an ACCESS EXCLUSIVE lock; production-sized duration is not benchmarked. Adopter-added dependent schemas require an adopter-owned migration.
  • Cross-account reporting_status_id reuse fails closed with different error codes in memory and PostgreSQL; this behavior predates the PR.
  • In-memory list_configurations insertion order differs from PostgreSQL's sorted order; callers match by frozen key, so this is functionally inert.

Open workspace in Conductor

@property
def generation_key(self) -> tuple[str, int]:
return (self.delivery_config_id, self.delivery_config_version)
def generation_key(self) -> ReportingConfigurationGenerationKey:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MUST FIX: ReportingConfiguration.generation_key changes its public return type from tuple[str, int] to ReportingConfigurationGenerationKey. That is a breaking type-signature change to a public export in the adcp.* namespace — any adopter doing config_id, version = config.generation_key breaks. The commit is fix(reporting): scope configuration generations by account with no ! and no BREAKING CHANGE: footer. release-please reads the prefix, not the diff; the break ships without a major signal. Retag fix!: or add a BREAKING CHANGE: footer. Migration note is present (docs/reporting-ledger-migration.md), so only the semver signal is missing.

Context, not absolution: generation_key was introduced in #1153 within the 8.0.0-beta line and has never shipped in a stable release, so the practical blast radius is beta adopters only — the arbiter can weigh that.

@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: Request changes

Request changes — 1 blocking finding.

Blocking findings

  • src/adcp/reporting/ledger/models.py:338 — ReportingConfiguration.generation_key changes its public return type from tuple[str, int] to a dataclass under a non-breaking fix: commit prefix, with no ! / BREAKING CHANGE: footer and no migration note. Per the repo's mandatory semver-signal gate, a public-surface type-signature change shipped under a non-breaking conventional-commit prefix is a high finding: release-please would cut a minor from fix:, shipping the break without a major. Add fix!: / feat!: or a BREAKING CHANGE: footer plus a migration note.

The core account-qualifying work is well-executed — generation identity is consistently threaded across both stores (PK, ON CONFLICT targets, obligation period key, lease/release with expiry match, revision supersession, status/issue joins), the beta.15 migration SQL is fail-closed and idempotent, and new branches are covered by memory + Postgres conformance tests. No medium findings. Only the semver-signal blocker needs resolution.

Blocking findings

  • src/adcp/reporting/ledger/models.py:338 — generation_key public return type changed from tuple[str, int] to a dataclass under a non-breaking fix: prefix with no !/BREAKING CHANGE footer or migration note (semver-signal gate: high)

InMemoryReportingLedgerStore.lease_period_close handed back the first
leasable generation in insertion order, and release_period_close puts
that generation straight back into the free pool.
ReportingProducer.run_worker releases in a finally, so a worker loop
re-leased the same generation on every turn and never closed a period
for any other account. The SQL store orders by lease_expires_at NULLS
FIRST, and an update to that indexed column sends a released row to the
back of the queue, so the two stores only diverged once two accounts
could hold the same delivery_config_id -- which is what this branch
enables.

Rank leasable generations the way the SQL store does, unheld before
expired and oldest expiry first, then break the tie by whichever
generation went longest without a turn. The shared memory/PostgreSQL
suite now asserts that a worker releasing every turn reaches all three
accounts; it failed on memory and passed on PostgreSQL before this
change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bokelley bokelley changed the title fix(reporting): scope configuration generations by account fix(reporting)!: scope configuration generations by account Sep 16, 2026

@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 — delta review of fair-queuing fix is clean, no blocking findings.

This is a subsequent review (prior outcome: request-changes, with no recorded findings). The current run reviewed the in-memory store's lease_period_close fair-queuing fix (store.py L1109-1147) plus a conformance test running against both memory and postgres stores. The ranking mirrors the SQL ORDER BY lease_expires_at NULLS FIRST and adds a turn-based tiebreak that closes the account-starvation window when two accounts share a delivery_config_id. Store parity holds and it was validated on real PG (163 passed).

Decision-table walk:

  • Row 1 (critical/high): no findings — skip.
  • Row 2 (gated_paths + not APPROVED): gated_paths is false — skip. (Note: review_decision is CHANGES_REQUESTED, but that gate only applies when gated_paths is true.)
  • Rows 3-5 (high-risk deletions/modified + medium): high_risk is false — skip.
  • Row 6 (sticky escalate): prior was request-changes, not escalate — skip.
  • Row 7 (no-auto-approve team): no team match — skip.
  • Row 8 (≥3 medium): zero medium findings — skip.
  • Row 9: approve.

No author-team gate and no gated-paths gate apply. Net improvement over the prior request-changes with a clean current run.

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.

bug(reporting): scope configuration generations and leases by account

1 participant