Repository navigation
Outbox: make the generic outbox usable for multiple consumers - #54139
thomasdarimont wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved transaction isolation, lifecycle fencing, delivery control, replay, metrics, and migration issues prevent approval.
7 open issues
SQL export assumes missing legacy OUTBOX_ENTRY table exists · New Deadline can prevent all processing after slow claim · New Tick thread exceeds configured delivery concurrency · New Outbox ticks retain outer transaction during delivery · New Lifecycle updates fail to invalidate claims · New Age-based dead-lettering omits callbacks and metrics · New Requeued rows retain stale pending-age timestamps · New
What changed in this PR
Generalizes Keycloak’s transactional outbox so SSF and additional consumers can share delivery infrastructure.
Changes:
- Adds per-kind scheduling, leased delivery, deferral, fairness, and parallel processing.
- Introduces consumer interfaces, replay operations, and shared metrics.
- Migrates timestamps to epoch milliseconds and expands test coverage.
| File | Description |
|---|---|
| tests/base/src/test/java/org/keycloak/tests/events/outbox/OutboxStoreTests.java | Tests leases, replay, deferral, and queries. |
| tests/base/src/test/java/org/keycloak/tests/events/outbox/OutboxDrainerTaskTests.java | Tests delivery transitions and scheduling behavior. |
| ssf/transmitter/src/test/java/org/keycloak/ssf/transmitter/outbox/SsfOutboxMetricsListenerTest.java | Tests SSF outbox metrics. |
| ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/outbox/SsfPushDeliveryHandler.java | Splits preparation from delivery. |
| ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/outbox/SsfOutboxMetricsListener.java | Adds SSF-specific metrics integration. |
| ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/metrics/SsfMetricsBinder.java | Delegates generic outbox metrics. |
| ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/delivery/SecurityEventTokenDispatcher.java | Updates status Javadoc link. |
| ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/delivery/poll/PollRequest.java | Updates status Javadoc link. |
| ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/delivery/poll/PollDeliveryService.java | Imports relocated status enum. |
| ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/DefaultSsfTransmitterProviderFactory.java | Configures scheduling, delivery, and metrics. |
| ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/admin/SsfEventStatsRepresentation.java | Updates status Javadoc link. |
| ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterStreamManagementTests.java | Imports relocated status enum. |
| ssf/services/src/main/java/org/keycloak/ssf/services/admin/SsfAdminResource.java | Imports relocated status enum. |
| server-spi-private/src/main/java/org/keycloak/events/outbox/OutboxEntryStatus.java | Relocates statuses and defines terminal states. |
| server-spi-private/src/main/java/org/keycloak/events/outbox/OutboxEntry.java | Defines read-only consumer view. |
| model/storage-private/src/main/java/org/keycloak/services/scheduled/ClusterAwareScheduledTaskRunner.java | Supports explicit cluster lock keys. |
| model/jpa/src/test/java/org/keycloak/events/outbox/OutboxMetricsListenerTest.java | Tests generic outbox metrics. |
| model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml | Registers the migration. |
| model/jpa/src/main/resources/META-INF/jpa-changelog-27.0.0.xml | Adds leases and converts timestamps. |
| model/jpa/src/main/java/org/keycloak/models/jpa/entities/OutboxEntryEntity.java | Adds lease fields and consumer queries. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxStore.java | Implements claiming, deferral, replay, and listings. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxMetricsListener.java | Publishes shared outbox metrics. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxDrainerTickSummary.java | Exposes per-tick counters. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxDrainerTask.java | Implements phased, leased delivery. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxDrainerScheduler.java | Dispatches ticks to an executor. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxDrainerListener.java | Defines observation callbacks. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxDeliveryResult.java | Adds deferred-delivery results. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxDeliveryOutcome.java | Adds DEFER outcome. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxDeliveryHandler.java | Defines preparation contract. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxDelivery.java | Defines executable delivery phase. |
| model/jpa/src/main/java/org/keycloak/events/outbox/OutboxConfig.java | Adds lease, budget, and parallelism settings. |
| model/jpa/src/main/java/org/keycloak/connections/jpa/updater/liquibase/custom/JpaUpdate27_0_0_OutboxTimestampsToEpochMillis.java | Backfills epoch-millisecond timestamps. |
Review effort: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Cross-node ordering, listener transaction isolation, and minimum tick progress remain unresolved.
3 open issues
7 resolved since last review
Requeued rows retain stale pending-age timestamps Age-based dead-lettering omits callbacks and metrics Lifecycle updates fail to invalidate claims Outbox ticks retain outer transaction during delivery Tick thread exceeds configured delivery concurrency Deadline can prevent all processing after slow claim SQL export assumes missing legacy OUTBOX_ENTRY table exists
Review effort: Balanced
|
Thanks @thomasdarimont. I rebased the SCIM provisioning port onto this branch without conflicts: thomasdarimont#1254. Below is feedback from building a second consumer on it. 1. Cluster lock TTL vs. tick length.
(A sub-second interval also truncates to a 0s lock, since 2. Effects that must commit with the transition. A consumer often has state to write together with the outcome. SCIM writes the link (remote id and hash), the destination health, and its per-entry metadata. Today the only hook in the record transaction is
3. Store operations a consumer still has to write HQL for. The SCIM provider holds about ten statements on
Not all of these need to land in this PR. The first three in particular look generic, though: SSF could also coalesce per subject. Putting them on the store would keep consumers off the entity. 4. Upgrade docs. The issue lists a release-notes and upgrading-guide entry for the 5. Migration coverage. The description says the DDL ran on H2 and the data conversion on Postgres. MySQL/MariaDB ( 6. Logging in With the SCIM port on top of this branch, the following pass: SCIM provisioning on dev-mem, PostgreSQL and MySQL, including two nodes; inbound SCIM; SSF |
|
Thanks @xgp for your feedback! I think "1. Cluster lock TTL vs. tick length." was already adressed inbetween - you might want to update your branch. I'll take a look at 2. and 3. (for the most common operations). I'll also update the docs for 4. |
|
@xgp I adressed 2., 3., 4. and 6. now. You might want to rebase your SCIM branch again. Here are the details:
|
|
@thomasdarimont Thanks, I rebased the SCIM branch onto these changes and moved it to the new APIs ( |
|
@thomasdarimont Any update on where this sits in terms of getting it merged for a future version? |
Porting a second consumer (outbound SCIM) to the generic outbox showed that it could not share the table with SSF push delivery: every OutboxDrainerTask locked on the same class-name key, a handler could not reschedule a row without burning an attempt, handlers ran inside the drainer transaction with a connection pinned during HTTP calls, delivery was serial per kind, one owner could fill every batch, ticks ran on the shared timer thread, and the TIMESTAMP columns had whole-second precision on MySQL and MariaDB. Key each drainer's cluster lock by entry kind and run ticks on an executor. Add a DEFER outcome that reschedules without counting an attempt. Rework a tick as claim, prepare, deliver, record: a claim transaction leases the due rows with a per-tick token and a lease end (CLAIM_TOKEN, CLAIMED_UNTIL), the handler's prepare phase returns an OutboxDelivery that executes outside any transaction, and the record is conditional on the token. A tick budget hands unprocessed rows back, an optional per-owner batch cap claims owner by owner, and optional delivery workers deliver different owners in parallel with one owner's rows kept in order. Expose a read-only OutboxEntry view in server-spi-private to handlers and listeners, add requeue, makeDueForOwner, listByOwner and delayed enqueue to OutboxStore, add OutboxDrainerListener with tick and per-transition callbacks, and a generic Micrometer OutboxMetricsListener that SSF extends instead of keeping its own series. Store OUTBOX_ENTRY timestamps as BIGINT epoch milliseconds like every other table, converted by a 27.0.0 changeset with a back-fill task. Log handler reasons at DEBUG only. Closes keycloak#54137 Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Run ticks through a dedicated OutboxDrainerRunner that takes the cluster lock on a session without a transaction, run the tick callbacks in short transactions and bind no session during delivery. Require token, PENDING status and matching kind before preparing and recording, and clear the claim in every lifecycle bulk update so an in-flight delivery cannot overwrite an admin transition. Start the tick budget after the claim, count the tick thread as one of the delivery workers, reset created_at on requeue, expire stale queued rows one by one with a PENDING_MAX_AGE dead-letter cause, and skip the timestamp backfill when OUTBOX_ENTRY does not exist. Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
… commit Hold the cluster lock for the longer of the interval and the claim lease, and skip owners with a row in flight when claiming (except rows the claiming tick holds itself), so an owner is delivered by one tick at a time even when ticks overlap across nodes. Let the first row of a tick through the budget check so a tick always makes progress. Run row listener callbacks after the record transaction committed, in a transaction of their own, so a failing listener cannot undo a transition. Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
…le sentinel Wait for every delivery worker even when the tick thread is interrupted, so the tick never releases its lock while deliveries are still running. Run each expiry callback in its own transaction, as the listener contract promises. Use a non-empty no-token sentinel for the owner fence, since Oracle stores an empty string as NULL. Document what the owner fence does and does not serialize. Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
CodeQL flagged an implicit long-to-int narrowing in the interrupted worker test Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Let a delivery result carry an effect the drainer runs inside the record transaction, so consumer state commits with the transition and a failing effect rolls the record back. Add a composite listener so a consumer can stack its own listener with the metrics one. Add the subject-level store operations a second consumer needed: list a subject's rows, update a row's metadata, settle a superseded row as delivered. Log handler exceptions by class at WARN and in full at DEBUG. Document the OUTBOX_ENTRY conversion in the upgrading guide and the release notes. Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
ea40366 to
f298605
Compare
|
I rebased concurrent main and updated the PR description. I'll ask for another review pass in todays maintainers call. The 1 failing test is unrelated to these changes and is due to general flakiness in the RefreshTokenTest. |

Makes the generic transactional outbox (
org.keycloak.events.outbox,OUTBOX_ENTRY) shareable, so that a second consumer such as outbound SCIM can use it next to SSF push delivery. It follows the feedback in #53786; a SCIM port to this branch runs on H2, PostgreSQL and MySQL with two nodes.Background and usage: Outbox explainer and user guide.
What changes
outbox-drainer:<entryKind>, so drainers of different kinds no longer exclude each other. Ticks run on their own executor instead of the shared timer thread.CLAIM_TOKEN,CLAIMED_UNTIL). Per row, the handler'sprepareruns in a short transaction, the delivery runs with no transaction and no bound session, and a record transaction applies the result only if the row still holds the lease. No connection is held during a destination call, and the rows of a crashed node become due again when the lease expires.DEFERoutcome. A handler can reschedule a row without counting an attempt;pendingMaxAgestill bounds a defer loop.perOwnerBatchSizecaps how many rows one owner takes per round.deliveryConcurrencydelivers different owners in parallel and keeps one owner's rows in order.tickBudgethands unreached rows back to the next tick; the first row is always delivered.OutboxEntry(inserver-spi-private).OutboxDrainerListenerobserves ticks and transitions, andcomposite(...)stacks several.OutboxDeliveryResult.withEffect(...)carries state that must commit with the transition.OutboxStoregains requeue, make-due, delayed enqueue, per-owner and per-subject listing,updateMetadataandmarkSuperseded.OutboxMetricsListenerpublisheskeycloak.outbox.*meters per kind. SSF extends it instead of keeping its own series; the SSF binder's never-wired tick and depth members are removed.Schema (
jpa-changelog-27.0.0.xml)CLAIM_TOKENandCLAIMED_UNTIL.CREATED_AT,NEXT_ATTEMPT_ATandDELIVERED_ATchange fromTIMESTAMPtoBIGINTepoch milliseconds, like every other Keycloak table.TIMESTAMPhas whole-second precision on MySQL and MariaDB, which left the order within a second undefined.Covered in the 27.0.0 upgrading guide and release notes.
SSF configuration
outbox-drainer-claim-leaseoutbox-drainer-tick-budgetoutbox-drainer-per-receiver-batch-sizeoutbox-drainer-delivery-concurrencyThe defaults keep today's behaviour.
Decisions and limits
withEffect. A failing effect rolls the record back and the row is retried after its lease, so effects must be idempotent, like deliveries.Testing
OutboxDrainerTaskTestscovers transitions, failing handlers, effects and listeners, budget, lease takeover, owner fairness and parallel delivery.OutboxStoreTestsis extended for leases, requeue, listing and the subject operations.OutboxMetricsListenerTestandSsfOutboxMetricsListenerTestare new.Closes #54137
Co-Authored-By: Claude Fable 5.1