Class RedisOutboxBackend
- All Implemented Interfaces:
OutboxBackend
OutboxBackend SPI (Wave A.2 Phase
0b, design doc .planning/32-wave-a2-redis-outbox-design.md). The store
that keeps identity-service's ticket mint and its outbox event atomic, because
the mint is itself a Redis op (ADR-0014 D-3 / 0014-supporting/identity-redis-outbox-backend.md).
Transaction contract — requiresActiveTransaction() returns
false. Atomicity is achieved by a single-slot Lua EVAL
(refinement #8 — hash-tagged keys), NOT by a Spring transaction. This relaxes
the publisher's persistPending active-transaction guard. CRUCIALLY,
per the OutboxBackend.requiresActiveTransaction() @apiNote,
the PG OutboxPublisher's AFTER_COMMIT hot-relay registration is
UNCONDITIONAL and still throws outside a Spring transaction — so this backend
does NOT route through that publisher. Phase 0b owns its OWN inline relay
(RedisOutboxRelay) + cold poller (RedisOutboxPollerWorker); the
SPI's persistPending / findPendingBatch / markSent /
markFailureAttempt are still implemented faithfully so the contract is
uniform across backends, but the orchestration around them is Redis-specific.
Atomicity (refinement #1). Every mutation is a single Lua script:
outbox-enqueue.lua (HSET entry + ZADD pending, fail-closed ordered),
outbox-mark-sent.lua, outbox-mark-failure.lua. A runtime error
mid-script does NOT roll back earlier writes (Redis is atomic, NOT
transactional — verified against https://redis.io/docs/latest/develop/interact/programmability/eval-intro/),
so the scripts pre-check key types and order writes so any partial state is
recoverable by RedisOutboxRepairWorker (refinement #10).
Pending score (refinement #5). The :pending sorted-set is
scored by nextAttemptAt, NOT createdAt, so a Kafka outage does
not hot-loop the oldest entries. findPendingBatch(int) fetches
ZRANGEBYSCORE … -inf <now> (only DUE entries).
Parked FAILED (refinement #3). markFailureAttempt parks an
entry to FAILED at the retry budget — retained in the Hash, removed
from :pending, never auto-deleted; an operator re-arms it.
This backend bean is stateless + thread-safe; the ObjectMapper and
StringRedisTemplate are thread-safe singletons.
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionstatic enum -
Constructor Summary
ConstructorsConstructorDescriptionRedisOutboxBackend(org.springframework.data.redis.core.StringRedisTemplate redis, RedisOutboxScripts scripts, RedisOutboxKeys keys, com.fasterxml.jackson.databind.ObjectMapper objectMapper, RedisOutboxBackoff backoff, RedisOutboxMetrics metrics) Constructs the backend. -
Method Summary
Modifier and TypeMethodDescriptiondecideExpiry(UUID ticketId, UUID expiredEventId, RedisOutboxEntry expiredEntry, Instant dueCutoff, boolean alreadyRevoked) Atomic due-time decision (refinement #2 primitive): runsexpiry-decide.luato enqueue aTicketExpiredoutbox entry for a due ticket, unless it was already revoked (suppress) or already expired-enqueued (idempotent collapse via the deterministic UUIDv3expiredEventId).findDueExpiries(int limit) Fetches up tolimittickets whose expiry is due (score ≤now) for the Phase-1 due-time worker to decide.findPendingBatch(int batchSize) intmarkFailureAttempt(UUID aggregateId, UUID eventId, String lastError, int maxRetries) intvoidpersistPending(OutboxRecord record) voidrecordExpiryDue(UUID ticketId, Instant expiresAt) Mint-time primitive (refinement #2): records a ticket's expiration intent by ZADDing it into the:expiry-duesorted-set (score =expiresAtepoch-ms).boolean
-
Constructor Details
-
RedisOutboxBackend
public RedisOutboxBackend(org.springframework.data.redis.core.StringRedisTemplate redis, RedisOutboxScripts scripts, RedisOutboxKeys keys, com.fasterxml.jackson.databind.ObjectMapper objectMapper, RedisOutboxBackoff backoff, RedisOutboxMetrics metrics) Constructs the backend.- Parameters:
redis- string-typed Redis template (keys + JSON values are strings, sorted-set scores numeric); nevernullscripts- the loaded Lua script holder; nevernullkeys- the hash-tagged key set; nevernullobjectMapper- JSON (de)serialiser forRedisOutboxEntry; nevernullbackoff- exponential-backoff calculator for failure re-scoring (refinement #5); nevernullmetrics- metrics binder (parked-FAILED + enqueue counters); nevernull
-
-
Method Details
-
persistPending
Enqueues a fresh PENDING entry via
outbox-enqueue.lua— HSET the Hash field + ZADD the:pendingindex in one fail-closed ordered script (refinement #1). A freshly-minted entry is immediately due, so its:pendingscore isnow(refinement #5 — subsequent failure re-scores push it into the future). The script's HEXISTS guard makes a duplicate logical enqueue an idempotent no-op (refinement #12); the SPI returns void so a no-op is silently absorbed (it is logged at DEBUG + counted).- Specified by:
persistPendingin interfaceOutboxBackend- Throws:
RedisOutboxStorageException- when JSON encoding or the EVAL fails
-
findPendingBatch
Fetches up to
batchSizeDUE entries:ZRANGEBYSCORE :pending -inf nowbounded byLIMIT 0 batchSize(refinement #5 — only entries whosenextAttemptAthas elapsed), then a singleHMGETfor their JSON bodies. Members whose Hash field is missing (an orphan zset member — refinement #10) are skipped here and reconciled byRedisOutboxRepairWorker. The returnedOutboxRecordviews are in ascending-score (oldest-due-first) order.- Specified by:
findPendingBatchin interfaceOutboxBackend
-
markSent
Runs
outbox-mark-sent.lua: status-guarded PENDING→SENT (setsentAt, clearlastError, ZREM from:pending). Returns the real applied row-count —1when the transition applied,0when the entry was missing or already terminal (a racing terminal transition is a no-op, matching the SPI contract).- Specified by:
markSentin interfaceOutboxBackend- Parameters:
aggregateId- unused for the Redis key (the event_id is the Hash field); accepted for SPI symmetry + logging correlation
-
markFailureAttempt
Runs
outbox-mark-failure.lua: status-guarded retries++ + stored error. On a retryable attempt the:pendingscore is re-set tonow + backoff(retries)(refinement #5 — exponential, capped); at the budget the entry is PARKED toFAILED(refinement #3 — retained in the Hash, removed from:pending, never auto-deleted) and a parked-FAILED metric fires.The backoff exponent uses the entry's CURRENT retries from the store — the script reads it server-side, so the re-score is computed against the authoritative count, not a stale snapshot. The Java side computes the candidate
nextAttemptAtfrommaxRetries-bounded backoff and passes it in; the script applies it only on the retryable branch.- Specified by:
markFailureAttemptin interfaceOutboxBackend- Returns:
1when the attempt was recorded;0when no-op (missing or already terminal)
-
requiresActiveTransaction
public boolean requiresActiveTransaction()Always
false: atomicity is the single-slot LuaEVAL, not a Spring transaction. See the class Javadoc for why this backend cannot reuse the PGOutboxPublisher's AFTER_COMMIT relay and owns its own relay/poller instead.- Specified by:
requiresActiveTransactionin interfaceOutboxBackend
-
recordExpiryDue
Mint-time primitive (refinement #2): records a ticket's expiration intent by ZADDing it into the:expiry-duesorted-set (score =expiresAtepoch-ms). The companion key shares the hash-tag so a future cluster keeps it co-located with the outbox (refinement #8).This is the durable expiration intent that
expiry-decide.lualater consumes — Redis key TTL alone is NOT a business callback (keyspace notifications are timing-imprecise fire-and-forget Pub/Sub), so the due-time decision is driven off this explicit zset, not off key expiry.- Parameters:
ticketId- the ticket identifier (the zset member); nevernullexpiresAt- the ticket's expiry instant (the zset score); nevernull
-
findDueExpiries
Fetches up tolimittickets whose expiry is due (score ≤now) for the Phase-1 due-time worker to decide. Read-only primitive (ZRANGEBYSCORE -inf now LIMIT 0 limit); the actual Expired-vs-Revoked decision + enqueue isdecideExpiry(java.util.UUID, java.util.UUID, com.aim2be.platform.outbox.redis.RedisOutboxEntry, java.time.Instant, boolean).- Parameters:
limit- maximum due tickets to return- Returns:
- the due ticket ids (oldest-due-first), empty when none; never
null
-
decideExpiry
public RedisOutboxBackend.ExpiryDecision decideExpiry(UUID ticketId, UUID expiredEventId, RedisOutboxEntry expiredEntry, Instant dueCutoff, boolean alreadyRevoked) Atomic due-time decision (refinement #2 primitive): runsexpiry-decide.luato enqueue aTicketExpiredoutbox entry for a due ticket, unless it was already revoked (suppress) or already expired-enqueued (idempotent collapse via the deterministic UUIDv3expiredEventId).NB: the
expiredEventIdMUST be a deterministicUUIDv3(ticket_id|expires_at)so a double-fire (worker crash + replay) produces the SAME outbox field — the script collapses it. Computing that id + supplying the TicketExpiredRedisOutboxEntrypayload is the Phase-1 identity wiring; Phase 0b tests it with a synthetic id + payload.TODO(Phase 1) — close the
alreadyRevokedTOCTOU (reviewer R3 INFO).alreadyRevokedis computed by the caller BEFORE thisEVAL; a concurrentTicketRevokedlanding in that window makes the script enqueueTicketExpiredanyway, so consumers can see BOTH events for one ticket. The deterministic UUIDv3 dedup (ADR-0014 D-4) + the audit pipeline's idempotency mitigate it, but the revocation-suppression branch is bypassed in the race. The robust fix is to move the revocation check INSIDEexpiry-decide.luaagainst a Redis-resident revocation marker (e.g.EXISTSthe revoked-ticket key / a revoked-set membership test) so the decision is atomic — this lands with the Phase-1 due-time worker that first calls this method (it is unreachable in Phase 0b, which only tests the primitive).- Parameters:
ticketId- the due ticket (the:expiry-duemember)expiredEventId- deterministic UUIDv3 outbox event idexpiredEntry- the TicketExpired entry to enqueue (status PENDING)dueCutoff- only decide members scored ≤ this instantalreadyRevoked-truewhen the ticket was already revoked (suppress the expiry)- Returns:
- the decision outcome
-