From 9e2b22ef86e41a24b97ec204332d2ff8b724b292 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 19:22:28 -0300 Subject: [PATCH] =?UTF-8?q?fix(D9):=20flake=20do=20consent=20restore=20sob?= =?UTF-8?q?=20carga=20=E2=80=94=20janela=20leaseada,=20Onda0=20shared=20co?= =?UTF-8?q?m=20restore=20de=20ids,=20asser=C3=A7=C3=B5es=20por=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - withMissingInviteConsentFixture serializa a janela de operação sob lease (serializeWindow default true; teste de ensures concorrentes migrou para key/lease privada; re-verificação/re-delete do gap setup→janela) - spec Onda0 volta a leases shared (exclusivo causava ABBA com escritores de invite) e o down-migration restaura as linhas com os ids originais (restoreConsentRows compartilhado; no-op para o resto da suíte) - asserções de restore referenciam o snapshot do fixture (contrato real); rollback asserta id+key; ORDER BY/sort nas leituras limit-1 - contraprova: suíte int completa ×5 + 6 arquivos da key ×13/14 verdes --- docs/CHANGELOG-AGENTS.md | 4 + docs/plans/escala-dry-pos-d9.md | 55 +++++ .../test-database-lease-consent-flake-impl.md | 100 +++++++++ tests/helpers/testDatabaseLease.ts | 190 +++++++++++------- tests/int/onda0Provision.int.spec.ts | 30 ++- tests/int/testDatabaseLease.int.spec.ts | 124 +++++++++--- 6 files changed, 407 insertions(+), 96 deletions(-) create mode 100644 docs/plans/escala-dry-pos-d9.md create mode 100644 docs/plans/test-database-lease-consent-flake-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 56c42ced..2dc16a93 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,6 +6,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue ` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue ` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md). +**Recently resolved (2026-08-10):** **D9** (Issue #596) — flake do consent restore do `testDatabaseLease` sob carga eliminado. **Caracterização com reprodução** (3 falhas em 8 runs da suíte int completa; depois isolada a 2 arquivos): o assertion "restores the exact configured consent after deletion and recreation" via leituras voláteis de uma linha canônica que escritores legítimos de outras specs deletavam/recriavam no meio do teste. **Causa raiz em três camadas:** (1) a janela de operação de `withMissingInviteConsentFixture` era a única transição de estado sem lease da suíte (outras specs criavam linha efêmera que o restore deletava sob elas); (2) `removeOnda0ConsentAndPrivacyDb` rodava sob leases **shared** e deletava as 4 keys estáveis no meio de testes alheios (o id restaurado sumia irreversivelmente); (3) asserções de restore comparavam contra leituras `before` que o provision Onda0 podia re-textar legitimamente. **Fix (só testes, zero `src/`):** janela de operação sob lease shared (`serializeWindow`, default true; o teste de ensures concorrentes migrou para key/lease privada — único caso que deliberadamente mantém janela sem lease, com re-verificação/re-delete do gap setup→janela; exclusivo descartado: alargava o ABBA com os escritores de invite sob carga); spec Onda0 com leases **shared** + down-migration **restaura as linhas com os ids originais** dentro da janela (`restoreConsentRows` compartilhado; no-op para o resto da suíte); asserções de restore referenciam o **snapshot do fixture** (contrato real), rollback asserta id+key; `ORDER BY id`/`sort: 'id'` nas leituras limit-1; core `withDatabaseLease`; `ensureInviteConsent` órfão removido (knip). Contraprova: suíte completa ×5 + par mínimo ×8 + 6 arquivos da key ×13/14 verdes (runs com restart externo do Postgres descartados por uptime). Intenção [`test-database-lease-consent-flake.md`](plans/test-database-lease-consent-flake.md); impl [`test-database-lease-consent-flake-impl.md`](plans/test-database-lease-consent-flake-impl.md). + +**Recently resolved (2026-08-10):** **B193** (Issue #542) — card de município mobile denso + edit-where-you-see + última atualização expandível. O card de `/campanha/municipios` no mobile deixou o grid de labels e virou composição do wireframe: cabeçalho nome+território com **posição de voto 2022 à direita** (percentual em cima; colocação · votos embaixo), **barra de cenários** (pessimista·central·otimista + marcador do ativo — `VoteEstimateScenarioStrip` como trigger da sheet de votos existente), **chips** classe/tendência/nível (tendência e nível com label à esquerda, dentro do trigger — tocar no label abre a sheet; classe com razão via tooltip que abre no toque), **3 grupos de avatares com label** (Assessores/Lideranças/Dobradinhas — um avatar por entidade, wrap, sem cap; leitura de assessores para não-coordenador), rodapé **"Última atualização há X dias" + chevron que expande o limite inferior** revelando o último card de atualização (badges + corpo + autor · data, sem moldura) + CTA "Registrar atualização" (sheet existente); **sem atualização → o rodapé vira CTA direto** (sem chevron/expansão); prioritário = **borda lateral direita 6px** `border-primary` no lugar do flag; Salvador read-only; "Cobertura" (conta da cadeira) sai do card (fica na tabela/detalhe — gate humano); rodapé mede a idade da **atualização** (não o frescor update∨pledge — coluna desktop intacta). **C109 corrigida no caminho (a "sheet quebra ao abrir" era real):** os callbacks dos controles (autosave `onOpenChange`, `handleOpenChange` do nível) são recriados por render — qualquer re-render com a sheet aberta (digitar no sheet!) fazia o host descartar a sessão e o corpo sumia; sessões do host agora são **keyed por id de sessão** (imune a identidade) e o **footer customizado** (submit + `DialogClose`) passou a ser **registrado e renderizado pelo host dentro da árvore do Drawer** (portal perdia o Dialog context — o e2e antigo só abria a sheet de tendência, sem footer custom, por isso a C109 não reproduzia). Controles ganharam prop `trigger`/`triggerClassName` (precedente `RelationChipCell.trigger`); `MunicipalityRelationAvatarStack` ganhou modo wrap. Dado: `MunicipalityListViewModel.lastUpdate` (batch por página via `loadMunicipalityLastUpdates`, scoped por portfólio). E2E novos (390×844): barra abre sheet e reflete valores salvos, chip Nível abre a sheet, borda de prioridade (6px + cor), expansão/CTA vazio. Sem migration/access/Consent. Intenção [`municipios-card-mobile-edit-where-you-see.md`](plans/municipios-card-mobile-edit-where-you-see.md); impl [`municipios-card-mobile-edit-where-you-see-impl.md`](plans/municipios-card-mobile-edit-where-you-see-impl.md). + **Recently resolved (2026-08-10):** **OPS28** (Issue #584) — `worktree next`/`plan` passam a aplicar o **seed mínimo** além das migrations em cada banco provisionado (`migrate` + `db:seed:minimal` por banco, dev e test) — paridade com a CI, que roda o mesmo par antes do e2e: o e2e de worktree deixa de divergir do de CI (specs dependentes dos pins `priority: 'alta'` de `salvador-ze-1`/`camacari` — FAB suggest e `campaignSavedFilters` — falhavam só localmente com banco migrado cru 435×normal, o padrão que o ledger TECH-DEBT row 19 ficha como "masks real failures"). `--no-migrate` pula migrate **e** seed (o seed depende do catálogo migrado); re-provisionamento continua idempotente (upserts). Fallback sem Docker (Cursor Cloud) também roda migrate+seed nos `teqo`/`teqo_test` compartilhados (decisão de produto do gate: paridade total). **Guard de regressão** `tests/e2e/seedParity.setup.e2e.spec.ts` no projeto `setup` do Playwright: falha cedo com mensagem clara ("rode `pnpm migrate && pnpm db:seed:minimal`") se o banco de teste não estiver seedado — dependentes do setup pulam, nada de spec vermelho confuso; a evidência checada são os **users do seed** (`MINIMAL_CAMPAIGN_USERS`), deliberadamente não os pins `priority: 'alta'` — o cleanup dos fixtures e2e reseta `priority` dos municípios que toca (round-robin por sequência persistente), então pins morrem legitimamente após runs repetidas no mesmo banco de worktree e só o `pnpm worktree next` re-aplica (guard nunca checaria pins); `scripts/worktree.mjs` entrou em `HIGH_RISK_EXACT` (mudança no provisionador roda a suíte cheia na CI). Sem migration/access/Consent/UI. Intenção [`worktree-seed-minimal-paridade-e2e-ci.md`](plans/worktree-seed-minimal-paridade-e2e-ci.md); impl [`worktree-seed-minimal-paridade-e2e-ci-impl.md`](plans/worktree-seed-minimal-paridade-e2e-ci-impl.md). **Recently resolved (2026-08-10):** **OPS34** (Issue #599) — o job `e2e` do CI (o mais longo da cascata, 435 s medidos no run 31412425553: ~67 s fixos + build 172 s + testes 196 s) roda agora **em 2 shards paralelos** (`--shard=N/2`, matrix `fail-fast: false`, build por shard — opção A da intenção validada pela medição: por shard ≈ 5–5,5 min; artefato de build compartilhado rejeitado porque os shards só começam após o build → wall maior). **Cadeia de projetos do Playwright (`setup → campaign → frontend → admin`) + prewarm do `setup` viraram dev-only:** `playwright.config.ts` ganhou `isProdMode = CI || E2E_PROD=1` (mesma condição do `webServer.command`, agora reusada) — em prod o projeto `setup` sai do array (arquivo não casado = não coletado) e as `dependencies` zeram (famílias em paralelo contra o build de produção, onde não há compile a preaquecer); dev intocado (provado: `--list` mostra `[setup]` só sem CI; smoke e2e dev rodou setup→campaignAuth). `ci-pr.yml`: o job `scope` emite `e2e_matrix`/`e2e_shards` via novo `e2eShardConfig(mode)` em `scripts/lib/test-affected-core.mjs` (fonte única, unit-pinada: full → `[1,2]`/2, selected/none → `[1]`/1 — pagar 2 builds por 3 specs é desperdício); `ci.yml` hardcodeia `[1,2]`/2 com comentário apontando o single-source. `PLAYWRIGHT_WORKERS: 2` explícito no env dos jobs e2e (aceite: explícito, default local intocado). Armadilha registrada nos workflows: flags do playwright vão **sem** o separador `--` — pnpm repassa o `--` literal ao playwright e a flag vira file pattern ("No tests found"). Specs intactas (o `setup` não foi removido — virou dev-only, já documentado como prewarm dev-only); sem migration/access/Consent/UI. **Medição pós-merge (main, run 31418206512):** job e2e 435 s → **326 s (shard 1) / 369 s (shard 2)** — shard 2 = fixo 68 s + build 176 s + testes 124 s (a fase de testes caiu de 196 s seriais para 124 s — metade da suite por runner + cadeia removida; o shard 2 ainda pagou retry da flake conhecida de `campaignMunicipalities`; shard 1: testes ~82 s); gate completo do pipeline (checks) ~7,5 → ~6,4 min. Build passou a dominar o shard — gatilho da intenção (opção B, artefato compartilhado) reavaliável quando os testes encolherem mais. Intenção [`ci-e2e-paralelizar-job.md`](plans/ci-e2e-paralelizar-job.md); impl [`ci-e2e-paralelizar-job-impl.md`](plans/ci-e2e-paralelizar-job-impl.md). diff --git a/docs/plans/escala-dry-pos-d9.md b/docs/plans/escala-dry-pos-d9.md new file mode 100644 index 00000000..7f90a9dc --- /dev/null +++ b/docs/plans/escala-dry-pos-d9.md @@ -0,0 +1,55 @@ +--- +id: D10 +depends: + - 596 +serializes: [] +priority: P3 +model: composer-2.5 +model-local: deepseek-v4-flash-high +--- + +# Escala/DRY pós-D9: auto-cura de leftovers de consent/supporter + ABBA do shared-write do lease de consent + +Lote de engenharia triado pelo `capture-review-debts` da sessão D9 (#596). Só testes/helpers; zero `src/` e zero migration. + +## F1 — Self-heal de renames órfãos de consent (expensive_lock, unicidade) + +**Problema:** uma run abortada no meio de um fixture de rename (kill do worker, restart do Postgres, timeout em cascata) deixa a linha canônica renomeada (`temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`, `temporarily-unkeyed-`) e o snapshot perdido. O run seguinte: o fixture snapshotta a key canônica como ausente, cria uma linha nova, e o rename do teste bate no **UNIQUE** (`Valor deve ser único`) — a poluição é **auto-sustentada** (observado na sessão D9: 1 restart do container → 2–3 falhas por run até a limpeza manual). Mesma classe: contatos/supporters órfãos de runs abortados causam o conflito transitório 'Esta pessoa já está cadastrada como apoiador neste município' (1/14 nos runs da sessão). + +**Direção:** os fixtures de consent (`withMutableConsentFixture`/`withMissingInviteConsentFixture`) e o `campaignFixtures.cleanup()` reconhecem e removem renames órfãos das keys estáveis no início do fixture (limpeza determinística por lista explícita dos nomes temporários + padrão `temporarily-unkeyed-%`), e o cleanup de supporter faz o mesmo para contatos/supporters órfãos. A decisão exata (lista vs padrão vs re-snapshot por id) fica para o impl plan — o aceite é: uma run abortada nunca mais envenena o run seguinte. + +## F2 — ABBA latente do shared-write no lease de consent (expensive_lock) + +**Problema:** `withLeasedConsent` (e o `withInviteConsent` de `campaignInviteUi`) segura o lease **shared** enquanto a operation **escreve** linhas do domínio de invite (redeem/consume) — a escrita segura row locks de invite; um holder exclusivo de consent (fixtures de `campaignInvite`) que escreve invites segura os row locks e espera o exclusivo → **deadlock ABBA** (consent-shared ↔ invite-row) — observado na sessão D9: cascata de 43–45 timeouts sob 6 workers (o lease exclusivo da Onda0 alargou a superfície; o revert para shared mitigou, mas o ABBA pré-existente permanece latente). O `campaignSupporter:95` tem o mesmo padrão (janela shared + operation que faz upsert de contato). + +**Direção:** operations escritoras não devem rodar sob lease **shared** de consent — ou o lease sobe para exclusivo quando a operation escreve (decisão por contrato: `withLeasedConsent` ganha variante para leitura vs escrita), ou as escritas saem do escopo do lease shared. A ordem canônica (consent → invite) e o FIFO do advisory lock devem ser auditados para eliminar o ciclo. Decisão no impl plan; o aceite é: a cascata de timeouts não reproduz sob 6 workers + carga. + +## Fases verificáveis + +1. **F1** — self-heal de renames; repro: plantar um rename órfão, rodar a suíte 2×, provar a auto-cura; gates. +2. **F2** — contrato de leitura/escrita do `withLeasedConsent`; repro: 6 arquivos da key sob carga ×N sem cascata; suíte completa. + +## Rabbit holes / Não escopo + +- Mecanismo de lease (advisory lock) — canônico, fora. +- Orçamento de 15s do vitest (documentado na `vitest.config.mts`) — fora (gatilho: recorrência de cascatas com pool ativo). +- 4ª key da Onda0 (`campanha-notificacoes-push`) sem lease — comentado; fora (gatilho: primeiro escritor do push key). + +## Já resolvido no simplify/critique (não reabrir) + +- Asserções de restore por snapshot do fixture (não leituras voláteis). +- Janela de `withMissingInviteConsentFixture` sob lease + re-verificação/re-delete do gap setup→janela. +- Down da Onda0 restaura ids originais; leases shared (revert do exclusivo). +- `ORDER BY id`/`sort: 'id'` nas leituras limit-1; core `withDatabaseLease`; `restoreConsentRows` compartilhado; `ensureInviteConsent` órfão removido. + +## Explicitamente fora (com gatilho) + +- **S3** — cascata de 15s sob carga extrema: gatilho = recorrência com pool ativo (medir antes de mexer no orçamento). +- **S6** — push key sem lease: gatilho = primeiro escritor da key. +- Poluição residual de contatos (702 linhas runID-scoped, inertes): coberta pela limpeza da F1. + +## Aceite de engenharia + +- [ ] Run abortado não envenena o run seguinte (F1) +- [ ] Cascata de timeouts não reproduz sob 6 workers (F2) +- [ ] Invariantes AGENTS/engineering-standards; só testes; sem migration diff --git a/docs/plans/test-database-lease-consent-flake-impl.md b/docs/plans/test-database-lease-consent-flake-impl.md new file mode 100644 index 00000000..666fa532 --- /dev/null +++ b/docs/plans/test-database-lease-consent-flake-impl.md @@ -0,0 +1,100 @@ +# Impl: Flake do consent restore do `testDatabaseLease` sob carga (race de fixtures paralelas) + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #596 +Intenção: docs/plans/test-database-lease-consent-flake.md +Appetite restante: herdado (fix focado em testes; zero mudança em `src/`) + +## Leitura da intenção + +- **Outcome:** `pnpm test:int` estável sob carga; o teste "restores the exact configured consent after deletion and recreation" passa N execuções consecutivas com os arquivos suspeitos rodando em paralelo. +- **O que NÃO negociar:** nenhuma mudança em `src/` (código de produto); mecanismo de lease (advisory lock) intacto; outras fixtures/leads não entram no escopo. +- **O que reavaliar:** a hipótese "leituras `LIMIT 1` sem `ORDER BY` escolhem a linha errada quando existe duplicata" — a caracterização mostrou que **duplicata persistente é impossível** (`consent_key_idx` é UNIQUE, verificado no banco); a hipótese "algum escritor NÃO toma o lease" é parcialmente verdadeira: o único estado não serializado é a **janela de operação** de `withMissingInviteConsentFixture` (deliberadamente sem lease para o fail-closed ver a key ausente). + +## Caracterização (fase 1, reproduzida) + +Reprodução local: suíte int completa (81 arquivos, `maxWorkers = min(8, cores)`), 3 falhas em 8 runs — o teste exato da intenção: + +``` +AssertionError: expected { id: 552, …(4) } to match object { id: 527, …(2) } + ❯ tests/int/testDatabaseLease.int.spec.ts:217:19 +``` + +`before.id = 527`, `after.id = 552` (key/text idênticos; ids diferentes por run; banco self-heal — 1 linha por key ao final). + +**Mecanismo:** o `after` (e `before`, no sentido oposto) é um `withInviteConsent` cujo loop faz `ensureLeasedConsent` (find→create sob lease **exclusivo**) e depois uma leitura sob lease **shared**. A janela de `withMissingInviteConsentFixture` (setup commita o DELETE → `operation()` **sem lease** → restore) deixa a key commitada-ausente por ms; nessa janela, escritores de outros arquivos (fixtures `withMutableConsentFixture` com snapshot `undefined`, `ensureInviteConsent` de specs paralelos) rodam legitimamente — criam uma linha efêmera, que o restore do fixture deleta e substitui pelo snapshot. Um `withInviteConsent` que caiu na janela retorna a linha efêmera (ex.: 552) e o assertion compara com o snapshot restaurado (527) → flake. O `ORDER BY` sozinho **não** corrige: no momento da leitura existe uma linha só. + +**Fix mínimo que mata o mecanismo:** serializar a janela com o lease existente (precedente exato do `onda0Provision`, comentário "without the shared leases this test races that window"). Com a janela sob lease exclusivo, nenhum escritor/leitor de outra spec observa o estado ausente → nenhuma linha efêmera → o `after` lê sempre o snapshot restaurado. + +**Conflito identificado:** o teste "creates a missing configured consent only once under concurrent ensures" (linha 146) **deliberadamente** roda 3× `ensureInviteConsent` DENTRO da janela (elas próprias adquirem o lease exclusivo → deadlock com uma janela leaseada). Resolvido em duas camadas: (1) o teste migra para **key/lease privada** (`ensure-race-`), então sua janela (que permanece `serializeWindow: false`) não expõe mais a key estável a specs paralelas; (2) documenta-se o opt-out. + +**Evidência da contraprova (2ª iteração):** após o fix inicial, 8 runs da suíte completa: o teste-alvo D9 passou em todos, mas 2 testes irmãos flakearam em 1 run com dois mecanismos adicionais: + +- **`rolls back a failed missing-consent setup…` (703→712):** o mesmo mecanismo id, via a janela ainda sem lease da linha 146 — fixtures de outros arquivos (snapshot `undefined`) rodavam nela e seu cleanup `DELETE … OR key = X` matava a linha 703 criada pelo `before` de outra spec. Fechado pela key privada acima (a key estável nunca mais fica ausente na suíte). +- **`restores the configured consent and releases after a callback assertion failure` (text mismatch):** o `provisionOnda0ConsentAndPrivacy` (spec paralela, shared lease) atualiza legitimamente o texto da linha entre o `before`-read e o snapshot do fixture — o restore reproduz fielmente o snapshot, mas a asserção comparava com a leitura volátil. Corrigido: as asserções dos 3 testes de restore passam a referenciar o **snapshot capturado no callback do fixture** (o contrato real do restore), não leituras `before` separadas; o teste de rollback (sem restore) asserta id+key (texto não é contrato dele). + +**Raiz definitiva (3ª iteração — reproduzida com só 2 arquivos: `testDatabaseLease` + `onda0Provision`):** o `removeOnda0ConsentAndPrivacyDb` (teste 2 da spec Onda0, **`DELETE WHERE key IN (4 keys estáveis)`**) rodava sob leases **shared** — shared não bloqueia os reads shared de outras specs, então a remoção deletava a linha canônica **no meio** do teste D9 (entre o restore do fixture e o `after`-read): o id restaurado sumia irreversivelmente e o `after`-ensure recriava com id novo. Nem o lease exclusivo resolve esse gap (a remoção é um escritor legítimo). Fix: (a) a spec Onda0 passou para **leases exclusivos** (`withTestDatabaseLease` exportado do helper) — provision e remoção serializam contra todos os usuários de consent; (b) o teste de down **restaura as linhas com os ids originais** dentro da mesma janela exclusiva (invisível para as outras specs) — a key estável nunca fica ausente e nunca troca de id no meio da suíte; o SQL do down continua exercitado (DELETE + count-0 dentro da janela). `ensureInviteConsent` ficou órfão e foi removido (knip). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[withMissingInviteConsentFixture] --> B[setup lease: snapshot + DELETE + commit] + B --> C{serializeWindow?} + C -->|true default| D[acquire restore-lease EXCLUSIVE] + D --> E[operation roda DENTRO do lease] + E --> F[beforeRestoreLeaseAcquire?] + F --> G[restore no mesmo lease: DELETE all + INSERT snapshot] + G --> H[release] + C -->|false linha 146| I[operation sem lease — comportamento atual] + I --> J[acquire restore-lease] + J --> G +``` + +**Opções consideradas:** A (serializar a janela sob lease exclusivo + opt-out) | B (remover o create de `withLeasedConsent`) | C (só `ORDER BY`/invariante de unicidade) +**Recomendação:** A — mata o mecanismo na raiz; preserva todos os testes; segue o precedente `onda0Provision` citado na intenção. +**Rejeitadas:** B porque o bootstrapping da linha em DB fresco/ausência pós-fault depende do create no loop (removê-lo faria `withInviteConsent` rodar quente até timeout quando a key fica ausente pelo fault-test da linha 295); C porque a duplicata é impossível (UNIQUE `consent_key_idx` verificado) e o `ORDER BY` não muda leitura de linha única. + +**Descoberta da contraprova (4ª iteração — deadlock):** com a spec Onda0 sob leases **exclusivos**, a suíte passou a cascatear ~50% das vezes (43–45 timeouts de 15s) sob 6 workers — capturado por poller de `pg_locks`: um detentor **shared** `idle in transaction` por minutos + fila de exclusivos. É um **ABBA**: o ONDA0-exclusivo (novo holder) entra no ciclo `ONDA0(exclusivo) → campaignInviteUi(shared + write de invite dentro do lease) → campaignInvite(row-lock de invite + espera do exclusivo) → ONDA0`. O lease exclusivo não era o fix necessário — o **restore-com-ids** era — e o exclusivo alargou a superfície de deadlock. **Revertido para shared** (como pré-fix; o restore-com-ids + as janelas leaseadas + as asserções por snapshot fecham o flake sem adicionar holders exclusivos). Pós-revert: 6 arquivos ×13/14 verdes (1 falha transitória pré-existente de supporter-conflict, auto-curável, fora do escopo) e **suíte completa ×5/5 verdes**. A cascata de timeouts por carga extrema + o conflito de supporter ficam registrados como débitos (o orçamento de 15s é conhecido da vitest.config.mts). + +### Componentes / mudanças (só testes — nenhum `src/`) + +- **`MissingInviteConsentFixtureFaults`** (`tests/helpers/testDatabaseLease.ts`): novo campo `serializeWindow?: boolean` (default `true`). +- **`withMissingInviteConsentFixture`** (`tests/helpers/testDatabaseLease.ts`): quando `serializeWindow`, o restore-lease (exclusivo) é adquirido **antes** da `operation()` e o restore roda no mesmo lease — sem janela sem-lease observável; `beforeRestoreLeaseAcquire` continua entre operation e restore (se lançar, rollback do lease sem restore — mesmo outcome observável de hoje); fault hooks do setup intactos. Quando `false`, comportamento atual (janela sem lease). +- **Leituras determinísticas** (`ORDER BY "id"` nos dois snapshot SELECTs; `sort: 'id'` nos `payload.find` de `ensureLeasedConsent`/`withLeasedConsent`) — defesa em profundidade, barata, alinhada à hipótese (b) da intenção. +- **Migration:** nenhuma (só código de teste). +- **Access / Consent:** nenhuma (testes). + +### Testes + +- **Novo teste** em `testDatabaseLease.int.spec.ts`: "blocks other consent users while the missing-consent operation window is open" — dentro da `operation()` (janela leaseada), inicia `startTestDatabaseLeaseAcquisition(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY)` e asserta via `waitForAdvisoryLockWaiter` + `expectExactWaiter` que o escritor concorrente fica bloqueado (ExclusiveLock) até a janela fechar; então libera (try/finally). Usa só ferramentas existentes do arquivo. +- **Linha 146** ("creates a missing configured consent only once…"): passa `serializeWindow: false` com comentário explicando o opt-out deliberado (spec do ensure-durante-janela; mesma spec não pode ser afetada — arquivo sequencial). +- Fault tests (299–399): devem passar sem mudança — hooks preservados. + +## Fases verificáveis + +1. **Tracer/helpers** — restructure do fixture + opt-out linha 146 (key privada) + novo teste de serialização; `pnpm test:int` verde. +2. **ORDERS BYs + ONDA0** — leituras determinísticas; spec Onda0 sob leases exclusivos + restore de ids no down; `pnpm gate:fast` + knip limpo. +3. **Contraprova sob carga** — suíte int completa ×5 consecutivas sem falha; par mínimo (2 arquivos) ×8 verde; 6 arquivos da key ×13/14 verde (runs com restart externo do Postgres descartados por uptime do container; 1 falha transitória de supporter-conflict pré-existente). + +## Rabbit holes / Não escopo (engenharia) + +- Audit de todos os escritores não-leaseados (`submitWhatsapp` whatsapp-key, `campaignSupporter` vote-intention delete): mesmas keys só no próprio arquivo — sem race cross-file — ficam para follow-up se surgirem (não poluir o escopo). +- Invariante de unicidade no restore (hipótese c da intenção): impossível de disparar com `consent_key_idx` UNIQUE — não implementar, documentar por quê. +- Mudanças no mecanismo de lease, `src/`, ou outras fixtures. + +## Riscos e mitigação + +- **Deadlock janela × operation auto-leaseada:** mitigado pelo opt-out `serializeWindow: false` na linha 146 (única operation que adquire o lease sozinha). +- **Bloqueio extra durante janelas:** as janelas são checks fail-closed curtos (ações de ms); o precedente `onda0Provision` já bloqueia o mesmo período — sem orçamento de 15s em risco. +- **Semântica dos fault hooks alterada:** hooks preservados; os testes de fault (295–399) validam o contrato — rodar junto na fase 1. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (suíte estável sob carga; teste-alvo passa em execuções consecutivas) +- [x] Invariantes AGENTS/engineering-standards (só teste; sem DB de prod; sem migration) +- [x] Testes de domínio previstos: novo teste de serialização + suíte int completa + contraprova sob carga +- [x] Nenhuma mudança em `src/` (fora de escopo da intenção respeitado) + +Self-score decision-quality: 5/5 (decisões caras com rejeitadas; appetite respeitado; rabbit holes nomeados; depth check — reusa `beginTestDatabaseLease`/`waitForAdvisoryLockWaiter`; aceite de produto mantido). diff --git a/tests/helpers/testDatabaseLease.ts b/tests/helpers/testDatabaseLease.ts index a01dac67..0b67ba5b 100644 --- a/tests/helpers/testDatabaseLease.ts +++ b/tests/helpers/testDatabaseLease.ts @@ -14,6 +14,16 @@ export type MissingInviteConsentFixtureFaults = { beforeSnapshotSetup?: () => Promise consentKey?: string leaseKey?: string + /** + * When true (default), the operation window runs under the exclusive lease: + * parallel spec files can never observe the committed-absent state, so their + * ensure-then-read helpers cannot create an ephemeral row that the restore + * deletes out from under them (D9 flake). Set to false only when the + * operation deliberately exercises concurrent consent writers during the + * window (see the "creates a missing configured consent only once under + * concurrent ensures" test). + */ + serializeWindow?: boolean } type LeaseTransaction = { @@ -42,7 +52,7 @@ export type WaitingAdvisoryLock = { virtualTransaction: string } -type ConsentSnapshot = { +export type ConsentSnapshot = { id: number key: string | null text: Consent['text'] @@ -50,6 +60,34 @@ type ConsentSnapshot = { created_at: Date } +/** + * Restores consent rows captured before a fixture deleted them, keeping their + * original ids so parallel spec files never observe the rows absent or with a + * new id (D9 flake). Shared by the consent fixtures and the Onda0 down test. + */ +export const restoreConsentRows = async ( + execute: (query: ReturnType) => Promise, + rows: readonly ConsentSnapshot[], +): Promise => { + for (const row of rows) { + await execute(sql` + INSERT INTO "consent" ("id", "key", "text", "updated_at", "created_at") + VALUES ( + ${row.id}, + ${row.key}, + ${row.text}, + ${row.updated_at}, + ${row.created_at} + ) + ON CONFLICT ("id") DO UPDATE SET + "key" = EXCLUDED."key", + "text" = EXCLUDED."text", + "updated_at" = EXCLUDED."updated_at", + "created_at" = EXCLUDED."created_at" + `) + } +} + export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent' export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent' export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent' @@ -321,12 +359,14 @@ export const acquireSharedTestDatabaseLease = async ( leaseKey: string, ): Promise => beginTestDatabaseLease(payload, leaseKey, 'shared') -const withTestDatabaseLease = async ( +const withDatabaseLease = async ( payload: Payload, leaseKey: string, + acquire: (payload: Payload, leaseKey: string) => Promise, + failureMessage: string, operation: () => Promise, ): Promise => { - const lease = await acquireTestDatabaseLease(payload, leaseKey) + const lease = await acquire(payload, leaseKey) let operationError: unknown try { return await operation() @@ -338,44 +378,38 @@ const withTestDatabaseLease = async ( await lease.release() } catch (releaseError) { if (operationError !== undefined) { - throw combineErrors( - operationError, - releaseError, - 'The leased operation and database lease release both failed.', - ) + throw combineErrors(operationError, releaseError, failureMessage) } throw releaseError } } } +const withTestDatabaseLease = async ( + payload: Payload, + leaseKey: string, + operation: () => Promise, +): Promise => + withDatabaseLease( + payload, + leaseKey, + acquireTestDatabaseLease, + 'The leased operation and database lease release both failed.', + operation, + ) + export const withSharedTestDatabaseLease = async ( payload: Payload, leaseKey: string, operation: () => Promise, -): Promise => { - const lease = await acquireSharedTestDatabaseLease(payload, leaseKey) - let operationError: unknown - try { - return await operation() - } catch (error) { - operationError = error - throw error - } finally { - try { - await lease.release() - } catch (releaseError) { - if (operationError !== undefined) { - throw combineErrors( - operationError, - releaseError, - 'The shared leased operation and database lease release both failed.', - ) - } - throw releaseError - } - } -} +): Promise => + withDatabaseLease( + payload, + leaseKey, + acquireSharedTestDatabaseLease, + 'The shared leased operation and database lease release both failed.', + operation, + ) export const ensureLeasedConsent = async ( payload: Payload, @@ -386,6 +420,7 @@ export const ensureLeasedConsent = async ( collection: 'consent', where: { key: { equals: consentKey } }, limit: 1, + sort: 'id', depth: 0, }) if (existing.docs[0]) return existing.docs[0] @@ -416,6 +451,7 @@ export const withLeasedConsent = async ( collection: 'consent', where: { key: { equals: consentKey } }, limit: 1, + sort: 'id', depth: 0, }) if (!configured.docs[0]) { @@ -441,12 +477,6 @@ export const withLeasedConsent = async ( } } -export const ensureInviteConsent = async (payload: Payload): Promise => - ensureLeasedConsent(payload, { - consentKey: CAMPAIGN_INVITE_CONSENT_KEY, - leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY, - }) - export const withInviteConsent = async ( payload: Payload, operation: (consent: Consent) => Promise, @@ -464,6 +494,7 @@ export const withMissingInviteConsentFixture = async ( ): Promise => { const consentKey = faults.consentKey ?? CAMPAIGN_INVITE_CONSENT_KEY const leaseKey = faults.leaseKey ?? CAMPAIGN_INVITE_CONSENT_LEASE_KEY + const serializeWindow = faults.serializeWindow ?? true const setupLease = await beginTestDatabaseLease(payload, leaseKey) let snapshot: ConsentSnapshot | undefined let setupError: unknown @@ -474,6 +505,7 @@ export const withMissingInviteConsentFixture = async ( SELECT "id", "key", "text", "updated_at", "created_at" FROM "consent" WHERE "key" = ${consentKey} + ORDER BY "id" LIMIT 1 `), )[0] @@ -513,6 +545,44 @@ export const withMissingInviteConsentFixture = async ( } if (setupError !== undefined) throw setupError + // Serialize the operation window under a SHARED lease (same precedent as + // onda0Provision's leases on the stable keys): parallel spec files must + // never observe the committed-absent state, or their ensure-then-read + // helpers create an ephemeral row that this restore deletes out from under + // them. Shared is enough — the ephemeral-row creators (ensures and other + // fixtures) are all EXCLUSIVE holders, which a shared window blocks without + // stalling the harmless shared readers behind it (the 15s test budget + // stayed tight under load with an exclusive window). + // `serializeWindow: false` keeps the unleased window for operations that + // deliberately exercise concurrent consent writers. NOTE: with the window + // leased, the operation must NOT acquire the same lease itself (e.g. via + // ensureLeasedConsent on the same key) — that would self-deadlock. + const windowLease = serializeWindow + ? await beginTestDatabaseLease(payload, leaseKey, 'shared') + : undefined + + // Close the gap between the setup's committed delete and this acquisition: + // a writer that queued during the setup (advisory locks grant FIFO) can + // re-create the row here, which would break the fail-closed operation and + // make the restore delete a live row. Re-verify committed state while we + // hold the window lease and delete again if needed. + if (windowLease) { + const recreated = await payload.find({ + collection: 'consent', + where: { key: { equals: consentKey } }, + limit: 1, + sort: 'id', + depth: 0, + }) + if (recreated.docs[0]) { + await payload.delete({ + collection: 'consent', + where: { key: { equals: consentKey } }, + depth: 0, + }) + } + } + let result: Result | undefined let operationError: unknown try { @@ -525,29 +595,26 @@ export const withMissingInviteConsentFixture = async ( let restoreLease: Awaited> | undefined try { await faults.beforeRestoreLeaseAcquire?.() - restoreLease = await beginTestDatabaseLease(payload, leaseKey) + restoreLease = windowLease ?? (await beginTestDatabaseLease(payload, leaseKey)) } catch (error) { cleanupError = error + if (windowLease && !restoreLease) { + try { + await windowLease.rollback() + } catch (rollbackError) { + cleanupError = addFailure( + cleanupError, + rollbackError, + 'The missing consent fixture restore acquisition and window lease rollback both failed.', + ) + } + } } if (restoreLease) { try { await restoreLease.transaction.execute(sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`) if (snapshot) { - await restoreLease.transaction.execute(sql` - INSERT INTO "consent" ("id", "key", "text", "updated_at", "created_at") - VALUES ( - ${snapshot.id}, - ${snapshot.key}, - ${snapshot.text}, - ${snapshot.updated_at}, - ${snapshot.created_at} - ) - ON CONFLICT ("id") DO UPDATE SET - "key" = EXCLUDED."key", - "text" = EXCLUDED."text", - "updated_at" = EXCLUDED."updated_at", - "created_at" = EXCLUDED."created_at" - `) + await restoreConsentRows((query) => restoreLease.transaction.execute(query), [snapshot]) } } catch (error) { cleanupError = error @@ -591,6 +658,7 @@ export const withMutableConsentFixture = async ( SELECT "id", "key", "text", "updated_at", "created_at" FROM "consent" WHERE "key" = ${CAMPAIGN_INVITE_CONSENT_KEY} + ORDER BY "id" LIMIT 1 `), )[0] @@ -622,21 +690,7 @@ export const withMutableConsentFixture = async ( WHERE "key" = ${CAMPAIGN_INVITE_CONSENT_KEY} AND "id" <> ${snapshot.id} `) - await lease.transaction.execute(sql` - INSERT INTO "consent" ("id", "key", "text", "updated_at", "created_at") - VALUES ( - ${snapshot.id}, - ${snapshot.key}, - ${snapshot.text}, - ${snapshot.updated_at}, - ${snapshot.created_at} - ) - ON CONFLICT ("id") DO UPDATE SET - "key" = EXCLUDED."key", - "text" = EXCLUDED."text", - "updated_at" = EXCLUDED."updated_at", - "created_at" = EXCLUDED."created_at" - `) + await restoreConsentRows((query) => lease.transaction.execute(query), [snapshot]) } else if (createdConsentID !== undefined) { await lease.transaction.execute(sql` DELETE FROM "consent" diff --git a/tests/int/onda0Provision.int.spec.ts b/tests/int/onda0Provision.int.spec.ts index 771aaff5..07281a63 100644 --- a/tests/int/onda0Provision.int.spec.ts +++ b/tests/int/onda0Provision.int.spec.ts @@ -19,7 +19,9 @@ import { CAMPAIGN_INVITE_CONSENT_LEASE_KEY, SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, + restoreConsentRows, withSharedTestDatabaseLease, + type ConsentSnapshot, } from '../helpers/testDatabaseLease' let payload: Payload @@ -38,9 +40,19 @@ describe('Onda 0 provision (integration)', () => { }) it('upserts consent keys and publishes privacy-policy idempotently', async () => { - // Shared leases on every stable consent key: parallel spec files exercise - // "consent missing" fail-closed paths by deleting these rows under an - // EXCLUSIVE lease — without the shared leases this test races that window. + // Shared leases on the stable keys other spec files exercise: the + // provision UPSERTS the shared rows and the spec below REMOVES them, so + // the writes must never interleave a parallel file's reads of the same + // rows (D9 flake). SHARED — not exclusive — because this spec only + // touches the four canonical keys and an exclusive holder here widened + // the consent-lease deadlock surface with the invite-domain writers + // (cascade under parallel load). The missing-consent fail-closed paths in + // other files now serialize their own windows under the lease, and the + // down-test below restores the removed rows with their original ids, so + // the shared rows are never observed absent or re-created with a new id. + // (The fourth Onda0 key, campanha-notificacoes-push, has no lease + // constant because no spec file writes it today; the down-test below + // still restores it.) await withSharedTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, () => withSharedTestDatabaseLease(payload, SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, () => withSharedTestDatabaseLease( @@ -81,7 +93,7 @@ describe('Onda 0 provision (integration)', () => { await provisionOnda0ConsentAndPrivacyDb(db) const consentRows = await db.execute(sql` - SELECT "key" + SELECT "id", "key", "text", "updated_at", "created_at" FROM "consent" WHERE "key" IN (${sql.join( ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`), @@ -119,6 +131,16 @@ describe('Onda 0 provision (integration)', () => { LIMIT 1 `) expect(privacyAfterDown.rows[0]?.published).toBe(false) + + // The down-migration must stay a no-op for parallel spec files: + // restoring the removed rows with their ORIGINAL ids (inside the + // exclusive lease, invisible to them) keeps the stable keys + // present with unchanged ids, so no other file's restore assertion + // can observe the absence (D9 flake). + await restoreConsentRows( + (query) => db.execute(query), + consentRows.rows as ConsentSnapshot[], + ) }, ), ), diff --git a/tests/int/testDatabaseLease.int.spec.ts b/tests/int/testDatabaseLease.int.spec.ts index 27b292b1..7bb01d0b 100644 --- a/tests/int/testDatabaseLease.int.spec.ts +++ b/tests/int/testDatabaseLease.int.spec.ts @@ -3,18 +3,20 @@ import { getPayload, type Payload } from 'payload' import { beforeAll, describe, expect, it } from 'vitest' +import type { Consent } from '@/payload-types' import config from '@/payload.config' import { acquireSharedTestDatabaseLease, acquireTestDatabaseLease, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, - ensureInviteConsent, + ensureLeasedConsent, startTestDatabaseLeaseAcquisition, waitForAdvisoryLockWaiter, withInviteConsent, withMissingInviteConsentFixture, withMutableConsentFixture, withSharedTestDatabaseLease, + type TestDatabaseLease, } from '../helpers/testDatabaseLease' let payload: Payload @@ -144,26 +146,79 @@ describe('test database lease', () => { }) it('creates a missing configured consent only once under concurrent ensures', async () => { - await withMissingInviteConsentFixture(payload, async () => { - const ensured = await Promise.all([ - ensureInviteConsent(payload), - ensureInviteConsent(payload), - ensureInviteConsent(payload), - ]) - const configured = await withSharedTestDatabaseLease( - payload, - CAMPAIGN_INVITE_CONSENT_LEASE_KEY, - () => + // Private key/lease: this test deliberately exercises ensure-then-create + // racing the missing window, so its window stays unleased + // (serializeWindow: false — the ensures lease themselves). Running the + // race on a private key keeps the stable invite row untouched: an + // unleased window on a shared key would let other files' fixtures observe + // the committed-absent state and delete the row out from under them. + const raceKey = `ensure-race-${Date.now()}-${Math.random()}` + const raceLeaseKey = `ensure-race-lease-${Date.now()}-${Math.random()}` + await withMissingInviteConsentFixture( + payload, + async () => { + const ensured = await Promise.all([ + ensureLeasedConsent(payload, { consentKey: raceKey, leaseKey: raceLeaseKey }), + ensureLeasedConsent(payload, { consentKey: raceKey, leaseKey: raceLeaseKey }), + ensureLeasedConsent(payload, { consentKey: raceKey, leaseKey: raceLeaseKey }), + ]) + const configured = await withSharedTestDatabaseLease(payload, raceLeaseKey, () => payload.find({ collection: 'consent', - where: { key: { equals: 'lideranca-autopreenchimento' } }, + where: { key: { equals: raceKey } }, depth: 0, limit: 10, }), - ) + ) - expect(new Set(ensured.map((document) => document.id))).toHaveLength(1) - expect(configured.docs).toHaveLength(1) + expect(new Set(ensured.map((document) => document.id))).toHaveLength(1) + expect(configured.docs).toHaveLength(1) + }, + { consentKey: raceKey, leaseKey: raceLeaseKey, serializeWindow: false }, + ) + }) + + it('blocks other consent users while the missing-consent operation window is open', async () => { + const before = await withInviteConsent(payload, async (consent) => consent) + + let writerAcquisition: Promise | undefined + try { + await withMissingInviteConsentFixture(payload, async () => { + let writerEntered = false + const pendingWriter = startTestDatabaseLeaseAcquisition( + payload, + CAMPAIGN_INVITE_CONSENT_LEASE_KEY, + ) + writerAcquisition = pendingWriter.acquisition.then((lease) => { + writerEntered = true + return lease + }) + const writerPID = await pendingWriter.backendPID + + const waiting = await waitForAdvisoryLockWaiter(payload, { + key: `test:${CAMPAIGN_INVITE_CONSENT_LEASE_KEY}`, + mode: 'ExclusiveLock', + waiterPID: writerPID, + }) + expectExactWaiter(waiting, writerPID, 'ExclusiveLock') + expect(writerEntered).toBe(false) + }) + } finally { + // Release the waiter lease even when an assertion above fails, so the + // pending acquisition never blocks parallel spec files until teardown. + if (writerAcquisition) { + const writer = await writerAcquisition + await writer.release() + } + } + + const after = await withInviteConsent(payload, async (consent) => consent) + // id+key only: a concurrent legitimate writer (e.g. the Onda0 provision) + // may update the row's text between these reads — the window's contract is + // that no exclusive writer interleaves, not that the text is frozen. + expect(after).toMatchObject({ + id: before.id, + key: before.key, }) }) @@ -181,9 +236,13 @@ describe('test database lease', () => { }) it('restores the exact configured consent after deletion and recreation', async () => { - const before = await withInviteConsent(payload, async (consent) => consent) - + // The reference is the fixture's own snapshot (the configured consent the + // callback receives), not a separate read: a concurrent legitimate writer + // (e.g. the Onda0 provision) may update the row's text between reads, and + // the restore faithfully reproduces whatever the snapshot captured. + let before: Consent | undefined await withMutableConsentFixture(payload, async (consent) => { + before = consent await payload.delete({ collection: 'consent', id: consent.id }) await payload.create({ collection: 'consent', @@ -214,6 +273,7 @@ describe('test database lease', () => { }) const after = await withInviteConsent(payload, async (consent) => consent) + if (!before) throw new Error('The mutable consent fixture must have provided a snapshot.') expect(after).toMatchObject({ id: before.id, key: before.key, @@ -234,9 +294,16 @@ describe('test database lease', () => { }), ) const callbackFailure = new Error('intentional callback assertion failure') + // The reference is the fixture's own snapshot (what the callback received), + // not the `before` read: a concurrent legitimate writer (e.g. the Onda0 + // provision) may update the row's text between reads, and the restore + // faithfully reproduces whatever the snapshot captured. `before` still + // pins the existence state (restored vs created-then-deleted). + let snapshot: Consent | undefined await expect( withMutableConsentFixture(payload, async (consent) => { + snapshot = consent await payload.update({ collection: 'consent', id: consent.id, @@ -278,14 +345,14 @@ describe('test database lease', () => { limit: 1, }), ) - if (before.docs[0]) { + expect(after.docs).toHaveLength(before.docs.length) + if (after.docs[0]) { + if (!snapshot) throw new Error('The mutable consent fixture must have provided a snapshot.') expect(after.docs[0]).toMatchObject({ - id: before.docs[0].id, - key: before.docs[0].key, - text: before.docs[0].text, + id: snapshot.id, + key: snapshot.key, + text: snapshot.text, }) - } else { - expect(after.docs).toHaveLength(0) } const subsequent = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY) @@ -295,6 +362,11 @@ describe('test database lease', () => { it('aggregates callback and restore lease acquisition failures in original order', async () => { const callbackFailure = new Error('intentional callback failure') const restoreAcquisitionFailure = new Error('intentional restore acquisition failure') + // Private key: this fault skips the restore, so the committed-absent state + // would leak for the rest of the run on a shared key (id churn in every + // other file). A private key keeps the leak invisible. + const faultKey = `restore-fault-${Date.now()}-${Math.random()}` + const faultLeaseKey = `restore-fault-lease-${Date.now()}-${Math.random()}` const failure = await withMissingInviteConsentFixture( payload, @@ -302,6 +374,8 @@ describe('test database lease', () => { throw callbackFailure }, { + consentKey: faultKey, + leaseKey: faultLeaseKey, beforeRestoreLeaseAcquire: async () => { throw restoreAcquisitionFailure }, @@ -357,10 +431,12 @@ describe('test database lease', () => { expect(failure).toBeInstanceOf(AggregateError) expect((failure as AggregateError).errors).toEqual([releaseFailure, rollbackFailure]) const after = await withInviteConsent(payload, async (consent) => consent) + // The rollback preserves the exact row; the text is not part of the + // contract here (no restore ran, and a concurrent legitimate writer such + // as the Onda0 provision may update it between reads). expect(after).toMatchObject({ id: before.id, key: before.key, - text: before.text, }) const subsequent = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY)