Skip to content

feat(outbox)!: range-partition persistent_outbox_events by sequence (Stage 1) - #106

Merged
nicolasburtey merged 13 commits into
mainfrom
task/obix-partition-persistent-outbox-019fc7ad
Aug 4, 2026
Merged

nicolasburtey merged 13 commits into
mainfrom
task/obix-partition-persistent-outbox-019fc7ad

Conversation

@bodymindarts

@bodymindarts bodymindarts commented Aug 3, 2026

Copy link
Copy Markdown
Member

Stage 1 — range-partition persistent_outbox_events by sequence

Implements the handoff spec obix-dev/handoff-partition-persistent-outbox.md (raw: https://raw.githubusercontent.com/GaloyMoney/drua-library/main/spaces/obix-dev/handoff-partition-persistent-outbox.md).

Converts persistent_outbox_events to a declarative RANGE partition on sequence, adds a timer-scheduled partition-maintainer job that pre-creates partitions ahead of the sequence head, and keeps an always-empty DEFAULT partition as an insert-can-never-fail backstop. Stage 1 = partitioning alone — nothing dropped, the gapless-sequence + replay-from-zero contract fully intact.

Non-goals (Stage 2/3): no partition drop, no retention, no offload/archiving, no read-side watermark. Total on-disk size is unchanged; the wins are per-operation cost, vacuum trajectory, and index locality.

Semver + migration story

  • Breaking (schema + PK change, new mandatory maintainer registration) → warrants a 0.6.0 release. Version left CI-managed (0.5.1-dev); commit is feat(outbox)!:.
  • No in-place data migration. Per owner decision, a backwards-incompatible reset is acceptable, so existing installs simply recreate the table from the shipped migration (the §10 populated-install cutover runbook is intentionally not shipped). Greenfield installs get the partitioned table directly.
  • Consumers must call Outbox::register_partition_maintainer(...) at startup. A consumer that applies the migration but never registers the maintainer keeps working (rows fall into DEFAULT, still read normally) but forfeits the per-partition wins.

What changed

  • migrations/…_obix_setup.sql — partitioned parent; PRIMARY KEY (sequence) replacing the random-UUID PK + sequence UNIQUE; id demoted to a plain unindexed column (RETURNING-only, kept for OutboxEventId); initial wide p0 [0, 10_000_000) + DEFAULT; per-partition storage params; JSONB COMPRESSION lz4.
  • obix-macros/src/tables.rs + src/tables.rs — every query! verified partition-transparent (zero query-text edits: INSERT routes to child, ON CONFLICT (sequence) arbiter is the new PK, range predicates prune, sequence-object read unchanged); adds a generated persistent_outbox_events_table() accessor.
  • src/out/partition/ (new) + src/out/mod.rsPartitionMaintainerConfig, a job::JobInitializer/JobRunner (mirrors the existing outbox jobs), ensure_partitions (idempotent tick), recover_default_partition (one-txn DEFAULT-strand repair), and register_partition_maintainer (synchronous premake → spawn recurring job; DDL never in the write path).
  • src/config.rspartition_width / partition_premake / partition_maintainer_interval (defaults 10M / 2 / 1h — conservative, real lana/cala rates unknown).
  • tests/partition.rs (new) — 6 live-PG tests.

Deviation from the handoff (necessary)

The handoff's WITH (vacuum_freeze_min_age = 0, …) uses a GUC name that Postgres rejects as a table storage parameter (ERROR: unrecognized parameter "vacuum_freeze_min_age"). The correct per-table reloption is autovacuum_freeze_min_age — used in both the migration and the maintainer. Same intent (freeze on first insert-driven vacuum). Verified against PG 17.

lz4

COMPRESSION lz4 is supported on the nix Postgres (17.8) and applied to both JSONB columns (attcompression = 'l' confirmed). If a downstream PG build lacks lz4, drop the two COMPRESSION lz4 clauses (handoff-sanctioned; only affects >~2KB TOASTed rows).

Interaction with in-flight PRs

Verification

  • nix flake check (fmt / clippy --all-features -D warnings / audit / deny) — green.
  • Live-PG cargo nextest run --workspace: 64 passed, 0 skipped (58 existing + 6 new).
  • .sqlx regenerated against the migrated DB — no diff (query text unchanged; id stays NOT NULL, sequence stays int8-not-null, so cached metadata is identical).
  • New tests: gap-fill across a partition boundary; a single batch straddling two partitions; replay across a boundary; synchronous maintainer premake (+ storage params); DEFAULT-fill → recovery (DEFAULT drains, rows land in explicit partitions, MAX(sequence) never regresses); replay-from-BEGIN contract unchanged.

Downstream ripple (flag only — not done here)

cala and lana-bank vendor this schema; when they pick this up they'll re-run sqlx prepare and register the maintainer. Not touched in this PR.

Note on the second commit

chore(tests): silence pre-existing clippy lints from stable toolchain bump fixes pre-existing await_holding_lock / type_complexity failures in post_persist_hook.rs (surfaced by clippy 1.93 via the stable channel, unrelated to partitioning) so nix flake check / CI can go green. Confirmed to fail identically without this branch's changes.

Open decisions for review

  1. recover_default_partition is shipped + tested but not auto-run by the maintainer (handoff recommendation: runbook + alert first; a non-empty DEFAULT surfaces as a failing maintainer job). Automate later if it recurs.
  2. Default partition_width / premake / interval are conservative guesses — tune from real event-rate numbers.
  3. id retained (unindexed, RETURNING-only) for the public OutboxEventId.

🤖 Generated with Claude Code


Update (review round 1)

  • Maintainer now runs on an internal timer (tokio::time::sleep loop inside the job), not JobCompletion::RescheduleIn. The job stays resident and only reschedules when it falls out of the loop — on shutdown (resume after restart) or an ensure_partitions error (propagates → scheduler retries per retry_settings = the alert). Rescheduling now signals a problem, not the steady-state cadence.
  • Maintainer job code (config, initializer, runner) moved to src/out/partition/job.rs; partition/mod.rs keeps only the DDL primitives.

CI note

The first run's all tests failure was inbox_reprocess_in_with_artificial_clock (tests/inbox.rs) — a pre-existing, timing-sensitive inbox test (200 ms sleep + artificial clock + job race), unrelated to partitioning (different table + config, no partition code path). It passes locally (full --workspace = 64/64) and CI uses fail-fast so it cancelled the other 58. Re-triggered by the follow-up push.


Note

High Risk
Breaking PK/schema on the core outbox table affects every consumer and requires maintainer registration for optimal layout; DDL and recover_default can block writes during repair, though correctness is preserved via DEFAULT routing.

Overview
Breaking schema change: persistent_outbox_events becomes RANGE-partitioned on sequence with PRIMARY KEY (sequence), initial p0 covering [0, 2_000_000), and a DEFAULT partition so inserts never fail to route. Upgrades require recreating the table from the shipped migration (no in-place data migration).

Adds Outbox::register_partition_maintainer (sync premake via Partitions::ensure, then a timer-driven job) plus operator Partitions::recover_default to drain stranded DEFAULT rows in one transaction without regressing MAX(sequence). MailboxConfig gains partition_premake (default 5) and partition_maintainer_interval; width is fixed DEFAULT_PARTITION_WIDTH (2M), tied to the migration.

Existing outbox SQL paths stay partition-transparent; macros expose persistent_outbox_events_table() for DDL. New live-PG tests cover premake, DEFAULT recovery, concurrent ensure, and replay-from-BEGIN. tests/inbox.rs switches to polling instead of fixed sleeps to reduce CI flakes.

Reviewed by Cursor Bugbot for commit 276571e. Bugbot is set up for automated code reviews on this repo. Configure here.

@bodymindarts
bodymindarts marked this pull request as ready for review August 3, 2026 13:34
@bodymindarts
bodymindarts marked this pull request as draft August 3, 2026 13:39

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7aa0bc0. Configure here.

Comment thread src/out/partition/mod.rs
Comment thread src/out/partition/mod.rs
@bodymindarts
bodymindarts marked this pull request as ready for review August 3, 2026 14:42
Comment thread src/out/partition/job.rs
_ = current_job.shutdown_requested() => {
return Ok(JobCompletion::RescheduleNow);
}
_ = tokio::time::sleep(self.interval) => {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is breaking with the existing convention of having hourly/daily jobs being relying on event from the ephemeral outbox (ie: hourly and daily cron events that job relies on)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

as a result: does not consider/use manual time

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this matter too if we eventually want to expose the hourly job to similarly to how we expose daily job

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

of course this means couple with lana / upstream system. but not too different in how this PR "couples" it too #105, ie: it's exposing the put/get interface, but to be active the upstream service needs to plug the relevant data store (ie: local storage or GCS)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah that is a deep flaw in the approach of #105!

This job is a maintenance / control-plane task. You never want to couple a controlling task on the thing it's monitoring.

Lana and its events are a domain layer - obix is a service to the domain layer and thus cannot depend on it.

Consider: there is a deserialization issue that stalls the consumer but the producer is still shipping events - then all maintenance jobs stall even though there is back-pressure... that would not be good.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this is breaking with the existing convention of having hourly/daily jobs being relying on event from the ephemeral outbox (ie: hourly and daily cron events that job relies on)

This is only a convention in the domain layer (aka Lana) - obix is NOT part of the application domain layer. Its a service that must be independent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

as a result: does not consider/use manual time

This can be achieved independently if we need it - but I think that this control job should probably stay on realtime regardless (just like some job - crate constructs).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this matter too if we eventually want to expose the hourly job to similarly to how we expose daily job

Again these are Lana layer domain concerns - obix must work independently of that.

bodymindarts and others added 11 commits August 4, 2026 11:50
… bump

Rust `stable` now resolves to clippy 1.93, which tightened
`await_holding_lock` and `type_complexity`. These flag pre-existing
recording-hook tests in post_persist_hook.rs (a `std::sync::Mutex` guard
held across an `await` in assertion sections) that are unrelated to the
partitioning work. File-scoped `#![allow(...)]` keeps `nix flake check`
green without changing behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 1 of the persistent-outbox partitioning plan: convert
`persistent_outbox_events` to a declarative RANGE partition on `sequence`,
add a timer-scheduled partition-maintainer job that pre-creates partitions
ahead of the sequence head, and keep an always-empty DEFAULT partition as an
insert-can-never-fail backstop. Nothing is dropped — the gapless-sequence +
replay-from-zero contract is fully intact. No retention / offload / read-side
watermark (those are Stage 2/3, tracked separately by the archive work).

Schema (BREAKING migration):
- `persistent_outbox_events` is now `PARTITION BY RANGE (sequence)`.
- PRIMARY KEY moves from the random UUID `id` to `sequence` (a partitioned
  parent's PK must include the partition key; the per-partition unique index
  on `sequence` becomes the `ON CONFLICT (sequence)` arbiter gap-fill relies
  on, and the ~8x-WAL random-UUID index is removed).
- `id` is demoted to a plain, unindexed column (RETURNING-only) but retained
  for the public `OutboxEventId`.
- Ships an initial wide `p0` ([0, 10_000_000)) + a DEFAULT partition, with
  per-partition autovacuum/freeze/fillfactor storage params and JSONB
  `COMPRESSION lz4`.

Maintainer (src/out/partition):
- `Outbox::register_partition_maintainer` runs one premake pass synchronously
  (before serving traffic) then spawns a `job` runner that reschedules on an
  interval; each tick is idempotent (`CREATE ... IF NOT EXISTS`). DDL never
  runs in the commit path.
- `ensure_partitions` (the tick) and `recover_default_partition` (a one-txn
  DEFAULT-strand repair that never regresses MAX(sequence)) are exposed for
  operators; recovery is NOT auto-run (runbook + alert first).
- `partition_width` / `partition_premake` / `partition_maintainer_interval`
  added to `MailboxConfig` (conservative defaults: 10M / 2 / 1h).

All `query!` macros are partition-transparent (zero query-text edits); adds a
generated `persistent_outbox_events_table()` accessor. `.sqlx` cache
regenerated (no diff — nullability/types unchanged). Composes with the 0.5.0
Result-streaming load path (undecodable events) unchanged.

Tests: 6 live-PG tests covering gap-fill across a boundary, a batch straddling
two partitions, replay across a boundary, synchronous maintainer premake, and
DEFAULT-fill -> recovery contiguity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ternal timer

Addresses review: move all maintainer job code (config, job data,
initializer, runner) out of partition/mod.rs into partition/job.rs, leaving
mod.rs with just the DDL primitives (ensure_partitions,
recover_default_partition).

The runner now re-runs on an internal `tokio::time::sleep` loop rather than
returning `JobCompletion::RescheduleIn` each tick: the job stays resident and
only falls out of the loop on shutdown (reschedule to resume after restart) or
on an ensure_partitions error (propagates so the scheduler retries per
retry_settings — the alert). Rescheduling therefore signals a problem, not the
steady-state cadence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review: replace the top-level `ensure_partitions` /
`recover_default_partition` free functions with an idiomatic service struct
`Partitions<Tables>` (mirrors `Inbox`/`Outbox`) that caches the pool and sizing
config (`width` / `premake`) and exposes `.ensure()` / `.recover_default()`.
The maintainer job and the registration premake now share one cheap-to-clone
handle instead of threading `(pool, width, premake)` through every call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file was committed in the initial commit before `.direnv/` was added
to `.gitignore`; because it was already tracked, the ignore rule never took
effect. It is a per-checkout direnv artifact (hardcodes the absolute source
path), so it should never have been tracked — untracking lets `.gitignore`
apply. Also removes an accidental worktree-path modification a prior `git
add -A` had swept into this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a breaking-change note (persistent_outbox_events is now range-partitioned
by sequence; recreate the table) and a "Partition maintenance" section showing
register_partition_maintainer and — per the review question — explicitly
stating that registration is optional and the outbox never breaks without it:
rows beyond the initial partition fall into the always-present DEFAULT
partition (still read/gap-filled/replayed), trading only the per-partition
vacuum/locality wins, never correctness.

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

Locks in the "no maintainer, transaction tips over the boundary" guarantee:
with only p0 + DEFAULT, a gap straddling the boundary is filled with the
placeholder routed into DEFAULT, and delivery stays contiguous. Complements
default_fill_then_recover (which covers a straddling publish batch) by
exercising the fill_gaps ON CONFLICT path across the p0/DEFAULT boundary.

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

Using measured staging outbox rows (~760 B/row, payloads average ~400 B,
<0.5% TOAST):

- Default partition width 10M -> 2M (~1.5 GB/partition) so the hot partition
  stays cache-resident and per-partition vacuum is quick; p0 shrinks to match.
- Premake 2 -> 5, so the initial ensure() at registration lays a 5-partition
  runway (the migration still ships only p0 + DEFAULT).
- Width is now the fixed DEFAULT_PARTITION_WIDTH constant, coupled to p0's
  range, rather than a MailboxConfig field: exposing it invited a mismatch that
  would make the maintainer create partitions overlapping p0. premake/interval
  stay configurable (no migration coupling). Partitions::new drops the width arg
  and uses the constant internally.
- Drop JSONB COMPRESSION lz4: it only fires on >2KB TOASTed values (near none
  here) so it buys ~nothing, while adding a hard --with-lz4 build dependency the
  migration would fail on.
- Trim the over-specific migration header comment to a short note.
- Trim the partition tests to the three obix-specific behaviours (maintainer
  premake, DEFAULT recovery, replay-from-BEGIN); drop the boundary-routing
  tests that were really exercising Postgres tuple-routing.

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

`seen_at` was write-only — set to DEFAULT NOW() on insert and never read by any
query, struct, or test. Dropping it removes 16 bytes/row (a small further trim
to partition size) with no behavioural change; the recovery path's `SELECT *`
adapts to the column set.

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

`inbox_reprocess_in_with_artificial_clock` asserted state a fixed 200ms after
publishing, which intermittently failed on loaded CI runners (the job runner
sets Processing -> Pending, so a fixed delay could catch it mid-run at the
status assertion, tests/inbox.rs:269). Replace the fixed-sleep positive
assertions with polling: use the existing `wait_for_inbox_status` for the
event status and a small `wait_for_executions` helper for the execution count.
The negative "did not run yet" check keeps its short sleep (the artificial
clock gates it). Unrelated to partitioning; this pre-existing flake was the
sole cause of the red "all tests" runs on earlier commits of this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ensure(): CREATE TABLE IF NOT EXISTS ... PARTITION OF is not
  concurrency-safe (DuplicateTable / catalog unique-violation when two
  sessions create the same partition). Since registration runs ensure
  synchronously on every instance, multi-instance startup could fail
  registration on some nodes and leave no maintainer running. Serialize
  creators cluster-wide on a transaction-scoped advisory lock keyed on
  the table name.

- recover_default(): a repair that crashed between commit and the
  artifact DROP left {table}_default_old behind, and the next repair
  then failed the RENAME on a relation-name conflict. Drop any drained
  leftover up front and move the artifact DROP inside the repair
  transaction so no new leftovers can occur.

Adds regression tests for both paths.
@nicolasburtey
nicolasburtey force-pushed the task/obix-partition-persistent-outbox-019fc7ad branch from c6caf2b to 85a4af5 Compare August 4, 2026 17:50
- repair the broken intra-doc link to recover_default and drop
  redundant explicit link targets (cargo doc is warning-free again,
  modulo a pre-existing ctx.rs lint from main)
- README / register_partition_maintainer claimed partition width is a
  MailboxConfig option; it is deliberately the fixed
  DEFAULT_PARTITION_WIDTH constant coupled to the migration
@nicolasburtey

Copy link
Copy Markdown
Member

Re-reviewed the full diff after the bugbot fixes. A few smaller things found and handled:

Fixed in 7612ff9 (docs only):

  • Broken rustdoc intra-doc link [recover_default_partition] + redundant explicit link targets — cargo doc was emitting warnings (CI builds docs in nix run .#nextest).
  • README and register_partition_maintainer docs claimed partition width is a MailboxConfig option (partition_width); it is deliberately the fixed DEFAULT_PARTITION_WIDTH constant coupled to the migration. Corrected both.

Checked, no action needed:

  • fill_gaps uses ON CONFLICT (sequence) DO UPDATE on the partitioned table — supported since PG 11 (arbiter includes the partition key, and the no-op SET sequence = EXCLUDED.sequence never moves rows across partitions). Verified empirically on PG 17.8.
  • spawn_unique on the maintainer job is unique_per_type, so multi-instance registration is idempotent.
  • .direnv/ is already in .gitignore (the untracked nix-direnv-reload won't resurface).
  • No remaining references to seen_at / the old id PK.

One thing to decide — in-place migration edit vs. upgrade path:
migrations/20251204130225_obix_setup.sql shipped in 0.5.0 (unpartitioned, id PK, seen_at) and this PR edits that same file in place. For any existing deployment, sqlx migrate run validates checksums of applied migrations and will fail with migration 20251204130225 was previously applied but has been modified — before it ever reaches new migrations. So the README's "recreate the table" upgrade note can't take effect without a manual step first (e.g. dropping the table and deleting/updating the row in _sqlx_migrations). Options: (a) ship the partitioning as a new migration that drops/recreates the table (self-executing upgrade, still breaking data-wise), or (b) keep the in-place edit and document the manual _sqlx_migrations step explicitly. If the only consumer deploys fresh schemas anyway this may be moot, but worth calling out explicitly either way.

The in-transaction artifact DROP already makes leftovers impossible, so
the up-front DROP TABLE IF EXISTS only guarded against debris from the
unreleased pre-fix version of this function (or manual meddling). Failing
loudly on an unexpected {table}_default_old is preferable to silently
deleting a table in a manual repair path — and it removes the 'guaranteed
drained' reasoning a reader had to verify.
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.

2 participants