Skip to content

✨ feat(outbox)!: carry context, retry with backoff and claim under lock - #3

Merged
edgarmesquita merged 1 commit into
masterfrom
feat/outbox-context-retry-locking
Jul 25, 2026
Merged

edgarmesquita merged 1 commit into
masterfrom
feat/outbox-context-retry-locking

Conversation

@edgarmesquita

Copy link
Copy Markdown
Collaborator

Fecha as três lacunas do outbox: a mensagem não carregava contexto, uma falha era terminal, e GetPending entregava o mesmo lote a todos os relays.

Contrato (breaking)

  • IOutboxMessage ganha NextAttemptAt e Context
  • IOutbox.Enqueue recebe context antes do CancellationToken
  • IOutboxRepository.MarkFailed recebe maxAttempts e backoff — a política de retry vai como parâmetro, então quem decide é o relay, onde a falha é observada, sem reconfigurar o store

Comportamento

Uma falha com tentativas restantes deixa a mensagem Pending e reagenda com backoff escalado pelo número de tentativas; só ao esgotar vira Failed — dead letter preservada para inspeção. GetPending ignora quem ainda está em backoff.

Onde o store sabe travar, o lote passa a ser reivindicado atomicamente e com lease de 1 min: PostgreSQL via FOR UPDATE SKIP LOCKED dentro de um CTE (com ORDER BY created_at no select externo, porque RETURNING sozinho não garante ordem), Redis via script Lua, Mongo via findOneAndUpdate. Onde não sabe — EF Core e NativeOutboxRepository, já que IAsyncRepository não expressa claim limitada sob lock — a implementação documenta que é at-least-once e espera um único relay.

Correção de bug

MarkProcessed no PostgreSQL gravava Processing (1) onde queria dizer Processed (2). CleanupProcessed filtrava pelo mesmo valor errado, então os dois bugs se escondiam e mensagens processadas nunca eram varridas.

Testes

93 → 119. O outbox só tinha cobertura no Redis; agora tem em cinco stores — PostgreSQL, MongoDB, Redis, EF Core e in-memory — cobrindo contexto no round-trip, ordem/batch, claim que não entrega a mesma mensagem a um segundo relay, backoff que segura, backoff vencido que reoferece, e dead letter.

Migração

Dados existentes sobrevivem: a tabela PostgreSQL ganha as duas colunas via ADD COLUMN IF NOT EXISTS. Deployments EF Core precisam de migration para NextAttemptAt/Context e para o índice ampliado (State, NextAttemptAt, CreatedAt). Implementações próprias de IOutboxRepository ou IOutbox precisam ser atualizadas.

A relay delivers out of band, so anything that only exists during the
originating request — the acting user, its address, a tenant — is gone by
the time a handler runs. `Enqueue` now takes a `context` that travels with
the message; `CorrelationId` is a single opaque string, enough to stitch
traces together but not to answer "who did this".

A failed delivery used to be terminal: one exception and the message was
`Failed` for good. `MarkFailed` now takes the retry policy and decides —
while attempts remain the message stays pending and is rescheduled with
backoff, and only once they are exhausted is it dead-lettered. Passing the
policy in keeps the decision where the failure is observed, so a relay can
be tuned without reconfiguring the store.

`GetPending` used to hand the same batch to every relay that asked. Where
the store can claim rows it now does so atomically and leases them —
PostgreSQL through `FOR UPDATE SKIP LOCKED`, Redis through a script, Mongo
through `findOneAndUpdate`; where it cannot, the implementation says it is
at-least-once and expects a single relay.

Also fixes `MarkProcessed` on PostgreSQL writing `Processing` where it meant
`Processed`: `CleanupProcessed` matched the same wrong state, so the two
bugs hid each other and processed messages were never swept.

Outbox behaviour now has tests on every store it ships for: PostgreSQL,
MongoDB, Redis, EF Core and in-memory.

BREAKING CHANGE: `IOutboxRepository.MarkFailed` takes `maxAttempts` and
`backoff`; `IOutbox.Enqueue` takes an optional `context` ahead of the
cancellation token; `IOutboxMessage` gains `NextAttemptAt` and `Context`.
Custom implementations of either interface must be updated. Existing data
survives: the PostgreSQL table picks up its two columns through `ADD COLUMN
IF NOT EXISTS`, while EF Core deployments need a migration for the new
`NextAttemptAt`/`Context` properties and the widened outbox index.
@edgarmesquita
edgarmesquita merged commit 319abb9 into master Jul 25, 2026
10 checks passed
@edgarmesquita
edgarmesquita deleted the feat/outbox-context-retry-locking branch July 25, 2026 17:49
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 3.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants