Idempotência
Efeito uma vez só sobre entrega pelo menos uma vez, com Valkey na frente e PostgreSQL como verdade.
A fila entrega cada mensagem pelo menos uma vez. Para que o efeito aconteça uma vez só, todo handler roda pelo
GatedIdempotencyRunner, com o run_id como chave (ou uma chave de negócio que a task declare).
| Camada | Papel |
|---|---|
Valkey (SET NX PX) | Portão: barra uma duplicata concorrente sem abrir transação e guarda o resultado para replay rápido |
| PostgreSQL, na transação do efeito | Fonte da verdade: a chave e o resultado fazem commit ou somem junto com o efeito |
| Depois do commit | O resultado é copiado para o Valkey |
| Valkey sem a chave | Lê do PostgreSQL e repopula |
| Valkey fora do ar | Cai para o PostgreSQL: mais lento, ainda correto |
A gravação no PostgreSQL é síncrona, na mesma transação do efeito, e é dela que vem a garantia. Gravar a chave de forma assíncrona abriria dois buracos: o Valkey perder a chave antes da gravação (o efeito rodaria de novo) ou o efeito fazer rollback com a chave já marcada como concluída (o efeito se perderia).
Uma reentrega que encontra a chave em processamento volta para a fila depois do lease; um resultado completed é
devolvido como replay.
Duas tabelas, por dono
Chaves de tenant ficam em idempotency_keys, com FK para tenants; chaves de trabalho acima dos tenants ficam em
platform_idempotency_keys, sem coluna de tenant. Não há valor sentinela (como um tenant 0): uma chave de tenant só
existe para um tenant real, e o banco recusa o resto.
A chave e a impressão digital da requisição são digests de 32 bytes. outcome é {} enquanto processing e o
resultado (ou o detalhe da falha) depois; o banco garante as duas coisas por CHECK.
idempotency_keys
Ledger de idempotência das operações de tenant: uma linha por (tenant, chave), reivindicada com INSERT … ON CONFLICT DO NOTHING na mesma transação do efeito. expires_at é o prazo do lease enquanto processing e o prazo de retenção depois.
| Coluna | Tipo | Nulo | Observação |
|---|---|---|---|
id | bigint | não | identity (GENERATED ALWAYS) |
tenant_id | bigint | não | |
idempotency_key | bytea | não | |
request_fingerprint | bytea | não | |
status | text | não | padrão 'processing' |
attempt | integer | não | padrão 1 |
outcome | jsonb | não | padrão '{}'::jsonb |
expires_at | timestamptz | não | |
created_at | timestamptz | não | padrão clock_timestamp() |
updated_at | timestamptz | não | padrão clock_timestamp() |
Chaves estrangeiras
tenant_id→tenants.id
Garantido pelo banco
| Tipo | Nome | Definição |
|---|---|---|
| CHECK | idempotency_keys_attempt_check | CHECK ((attempt > 0)) |
| CHECK | idempotency_keys_check | CHECK (((status <> 'processing') OR (outcome = '{}'::jsonb))) |
| CHECK | idempotency_keys_idempotency_key_check | CHECK ((octet_length(idempotency_key) = 32)) |
| CHECK | idempotency_keys_outcome_check | CHECK (((jsonb_typeof(outcome) = 'object') AND (octet_length((outcome)::text) <= 4096))) |
| CHECK | idempotency_keys_request_fingerprint_check | CHECK ((octet_length(request_fingerprint) = 32)) |
| CHECK | idempotency_keys_status_check | CHECK ((status IN ('processing', 'completed', 'failed_retryable', 'failed_permanent'))) |
| PK | idempotency_keys_pkey | PRIMARY KEY (id) |
| UNIQUE | idempotency_keys_owner_key | UNIQUE (tenant_id, idempotency_key) |
Fora do banco
- A limpeza de chaves vencidas é feita pela task
maintenance.purge.
platform_idempotency_keys
O mesmo ledger para trabalho acima dos tenants, sem coluna de tenant: nenhuma chave de tenant usa valor sentinela.
| Coluna | Tipo | Nulo | Observação |
|---|---|---|---|
id | bigint | não | identity (GENERATED ALWAYS) |
idempotency_key | bytea | não | |
request_fingerprint | bytea | não | |
status | text | não | padrão 'processing' |
attempt | integer | não | padrão 1 |
outcome | jsonb | não | padrão '{}'::jsonb |
expires_at | timestamptz | não | |
created_at | timestamptz | não | padrão clock_timestamp() |
updated_at | timestamptz | não | padrão clock_timestamp() |
Chaves estrangeiras
- nenhuma
Garantido pelo banco
| Tipo | Nome | Definição |
|---|---|---|
| CHECK | platform_idempotency_keys_attempt_check | CHECK ((attempt > 0)) |
| CHECK | platform_idempotency_keys_check | CHECK (((status <> 'processing') OR (outcome = '{}'::jsonb))) |
| CHECK | platform_idempotency_keys_idempotency_key_check | CHECK ((octet_length(idempotency_key) = 32)) |
| CHECK | platform_idempotency_keys_outcome_check | CHECK (((jsonb_typeof(outcome) = 'object') AND (octet_length((outcome)::text) <= 4096))) |
| CHECK | platform_idempotency_keys_request_fingerprint_check | CHECK ((octet_length(request_fingerprint) = 32)) |
| CHECK | platform_idempotency_keys_status_check | CHECK ((status IN ('processing', 'completed', 'failed_retryable', 'failed_permanent'))) |
| PK | platform_idempotency_keys_pkey | PRIMARY KEY (id) |
| UNIQUE | platform_idempotency_keys_key | UNIQUE (idempotency_key) |
Fora do banco
- A limpeza de chaves vencidas é feita pela task
maintenance.purge.