Repository navigation
Claim only delivery types supported by each worker - #120
Conversation
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
83c1985 to
a4b6894
Compare
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
left a comment
There was a problem hiding this comment.
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.
…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>
- 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>
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>
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>
Summary
Workers now claim only
PENDINGentries 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, oneINquery 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 unrestrictedOutboxStore.claimPending(limit)remains available to direct callers and keeps its existing index. Custom stores used by Okapi's processor must implementRouteAwareOutboxStore, 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)
OutboxProcessorrequires aRouteAwareOutboxStore.OutboxEntryProcessor.process/processBatchthrowIllegalStateExceptionfor an entry with no registered deliverer. Previously such an entry was markedFAILED. Inside the processor the transaction rolls back and the row staysPENDING.002__route_aware_claim_index.Verification
./gradlew ktlintCheck build testpasses on top of current main.MysqlOutboxStoreTestasserts that the route-aware claim raises no server warnings. The deprecatedBINARYoperator raised warning 1287 on every claim.INclaim 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 inbenchmarks/route-aware-claim/.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
INVALIDindex left by an interrupted earlier attempt.CREATE INDEX CONCURRENTLYwaits 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
FAILEDrows need separate recovery.Closes #116
🤖 Generated with Claude Code