Skip to content

Claim only delivery types supported by each worker - #120

Merged
rawo merged 7 commits into
mainfrom
fix/route-aware-claim-116
Nov 3, 2026
Merged

rawo merged 7 commits into
mainfrom
fix/route-aware-claim-116

Conversation

@endrju19

@endrju19 endrju19 commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Workers now claim only PENDING entries for their registered delivery types. The processor rotates its starting type and uses at most two claim queries per batch, independent of the number of types: one for the starting type and, when needed, one IN query for the rest. A mismatched entry is also rejected before delivery.

PostgreSQL gets a partial (delivery_type, created_at, id) WHERE status = 'PENDING' index; MySQL gets (status, delivery_type, created_at, id) plus an exact, binary type check (CAST(... AS BINARY)) for case-insensitive collations. The unrestricted OutboxStore.claimPending(limit) remains available to direct callers and keeps its existing index. Custom stores used by Okapi's processor must implement RouteAwareOutboxStore, and so must hand-written wrappers around a built-in store. Spring autoconfiguration rejects an unsafe store at startup and names its class in the error.

Behaviour changes (see CHANGELOG)

  • BREAKING: OutboxProcessor requires a RouteAwareOutboxStore.
  • BREAKING: OutboxEntryProcessor.process/processBatch throw IllegalStateException for an entry with no registered deliverer. Previously such an entry was marked FAILED. Inside the processor the transaction rolls back and the row stays PENDING.
  • BREAKING: new migration 002__route_aware_claim_index.
  • Cross-type claim order is no longer globally oldest first. When the starting type doesn't fill the batch, the leftover capacity goes to the other types alphabetically. Every type is still claimed first at least once every N batches, so none is starved. This keeps each claim a cheap ordered index range.

Verification

  • ./gradlew ktlintCheck build test passes on top of current main.
  • The HTTP batch concurrency test now uses a server-side request barrier. Every request must arrive before any successful response is sent; intentionally switching delivery to sequential makes the test fail. Both PR Claim only delivery types supported by each worker #120 and chore(deps): bump the production group with 8 updates #121 include the same test fix.
  • Core, Spring Boot and integration tests passed locally on PostgreSQL and MySQL Docker containers, including migrations, concurrent heterogeneous workers, exact type matching, HTTP and Kafka.
  • MysqlOutboxStoreTest asserts that the route-aware claim raises no server warnings. The deprecated BINARY operator raised warning 1287 on every claim.
  • On a 1,000,000-row outbox with 900,000 older unsupported rows, a 100-type absent IN claim used the route index and took about 0.16 ms on PostgreSQL and 0.21 ms on MySQL in local measurements. The previous full PostgreSQL index produced a 165 ms scan for that case. Scripts and context are in benchmarks/route-aware-claim/.
  • There is a write cost from the additional index. In exploratory repeated local runs, PostgreSQL insert/update medians were 1.47×/1.07× versus the legacy index alone; MySQL medians were 1.18×/1.29×. These are workload-specific measurements, not throughput guarantees.

Rollout

The branch includes the current main, preserving #119 startup validation and #115 outbox-id headers. For manually managed schemas, the README links both migrations: apply 001 and 002 in order for a new installation, or apply 002 before starting updated workers on an existing schema.

Run PostgreSQL migration 002 outside a transaction because it creates the index concurrently. It first drops any INVALID index left by an interrupted earlier attempt. CREATE INDEX CONCURRENTLY waits for every older transaction in the database, so run it when no long transactions are open, or apply it ahead of the rollout.

Older workers can still claim unsupported types, so publish a new type only after all old processors are upgraded or stopped. Previously FAILED rows need separate recovery.

Closes #116

🤖 Generated with Claude Code

Reject duplicate MessageDeliverer types instead of silently replacing one transport.
Fail default processor startup when no deliverer is registered while preserving
publisher-only and custom processor configurations.

Closes #117
Closes #118
@endrju19
endrju19 changed the base branch from fix/deliverer-validation-117-118 to main September 26, 2026 10:10
@endrju19 endrju19 closed this Sep 26, 2026
@endrju19 endrju19 reopened this Sep 26, 2026
Add route-aware claims and a matching index for PostgreSQL and MySQL. Rotate
the first route between batches, reject unsafe custom stores, and prevent a
single deliverer from processing an entry of another type.

Cover heterogeneous workers, concurrent claims, migrations, and a million-row
Docker benchmark. Document the custom-store and rolling-upgrade requirements.

Closes #116
@endrju19
endrju19 force-pushed the fix/route-aware-claim-116 branch from 83c1985 to a4b6894 Compare September 26, 2026 10:18
Resolve conflicts with the merged deliverer validation and outbox-id changes. Link both schema migrations and explain the required MySQL index and PostgreSQL concurrent migration. Replace the timing-based HTTP concurrency test with a request barrier.

@rawo rawo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review focused on data loss, deadlocks/locking, performance, memory and structure. This fixes a real data-loss bug: previously a worker claimed rows of a type it couldn't deliver, and CompositeMessageDeliverer turned them into permanent FAILED. Two bounded queries per tick, a partial index on Postgres, and post-claim validation of what the store returns are a solid design. SKIP LOCKED means the two claims in one transaction can't deadlock with other workers. Findings below; the CONCURRENTLY migration one is the one I'd fix before merging.

Cross-PR note (#139): on MySQL at the default REPEATABLE READ, the new FORCE INDEX (status, delivery_type, created_at, id) claim gap-locks the end of each claimed type's range, which is where publish() for that type inserts. So the publisher-blocking problem from #129 carries over to this query. #139 fixes it via READ COMMITTED, but its regression test uses the legacy claimPending(10). Whichever PR merges second should make that test drive the route-aware claim.

Also, this PR's branch predates several merges to main (CI run is from an older base), so a rebase plus a re-run is needed before merge.

rawo added a commit that referenced this pull request Nov 3, 2026
…ccepts it (#129)

Addresses review on #139:
- Spring: pin READ COMMITTED only when the store is MysqlOutboxStore. Postgres already
  defaults to it, so other stores keep the template verbatim (no extra round trip, no
  behaviour change). Skip pinning with a startup WARN for PTMs that reject custom
  isolation levels (JtaTransactionManager, JpaTransactionManager without the
  Hibernate/EclipseLink JpaDialect); otherwise every tick would throw
  InvalidIsolationLevelException.
- Exposed: ExposedTransactionRunner(database) uses READ COMMITTED only on MySQL/MariaDB
  dialects; ExposedTransactionRunner(database, level) sets it explicitly.
- MysqlClaimDoesNotBlockPublishersTest drives a real OutboxProcessor tick with a blocking
  deliverer instead of calling claimPending directly, so it follows whatever claim query
  the processor runs (e.g. after #120). Adds Hibernate-JPA and DefaultJpaDialect cases.
- New OutboxAutoConfigurationIsolationTest, including a JtaTransactionManager case.
- CHANGELOG: behaviour-change note for Exposed overriding defaultIsolationLevel on MySQL.
  README: binlog_format=ROW/MIXED requirement and the JTA/JPA fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rawo and others added 2 commits November 3, 2026 14:05
- Postgres 002: drop a leftover INVALID index before CREATE INDEX
  CONCURRENTLY IF NOT EXISTS, so a retried changeset rebuilds it.
- MySQL claim: CAST(... AS BINARY) instead of the BINARY operator,
  deprecated since 8.0.27 (warning 1287 on every claim). Test asserts
  the claim raises no server warnings.
- Name the store class and mention wrappers/proxies in the
  RouteAwareOutboxStore errors (processor and Spring autoconfig).
- README: cross-type claim order favours alphabetically earlier types
  for leftover capacity; CREATE INDEX CONCURRENTLY waits for all older
  transactions in the database.
- CHANGELOG: BREAKING entries for RouteAwareOutboxStore, process()
  throwing on unsupported types, migration 002; Fixed entry for #116.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rawo rawo self-assigned this Nov 3, 2026
@rawo
rawo merged commit 4c90bb5 into main Nov 3, 2026
8 checks passed
@rawo
rawo deleted the fix/route-aware-claim-116 branch November 3, 2026 15:12
rawo added a commit that referenced this pull request Nov 3, 2026
After #120 the claim runs on (status, delivery_type, created_at, id), so
the gap at the end of each claimed type's range is what blocks publish()
for that type. Update KDoc, the regression test's description and the
CHANGELOG entry, and fold #120's Fixed entry into the single Unreleased
Fixed section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rawo added a commit that referenced this pull request Nov 3, 2026
The test already drove OutboxProcessor.processNext(), so after #120 it
exercised the route-aware claim, but nothing proved it. A recording
store now asserts each tick calls claimPending(deliveryTypes, limit)
and never the legacy claimPending(limit), so the test fails if the
processor's claim path changes.

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

Worker can fail an entry it cannot deliver

2 participants