mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
fix(D9): flake do consent restore sob carga — janela leaseada, Onda0 shared com restore de ids, asserções por snapshot
- 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
This commit is contained in:
parent
f460dac6cf
commit
9e2b22ef86
6 changed files with 407 additions and 96 deletions
|
|
@ -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 <N>` 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 <N>` (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).
|
||||
|
|
|
|||
55
docs/plans/escala-dry-pos-d9.md
Normal file
55
docs/plans/escala-dry-pos-d9.md
Normal file
|
|
@ -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-<id>`) 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
|
||||
100
docs/plans/test-database-lease-consent-flake-impl.md
Normal file
100
docs/plans/test-database-lease-consent-flake-impl.md
Normal file
|
|
@ -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-<random>`), 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).
|
||||
|
|
@ -14,6 +14,16 @@ export type MissingInviteConsentFixtureFaults = {
|
|||
beforeSnapshotSetup?: () => Promise<void>
|
||||
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<typeof sql>) => Promise<unknown>,
|
||||
rows: readonly ConsentSnapshot[],
|
||||
): Promise<void> => {
|
||||
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<TestDatabaseLease> => beginTestDatabaseLease(payload, leaseKey, 'shared')
|
||||
|
||||
const withTestDatabaseLease = async <Result>(
|
||||
const withDatabaseLease = async <Result>(
|
||||
payload: Payload,
|
||||
leaseKey: string,
|
||||
acquire: (payload: Payload, leaseKey: string) => Promise<TestDatabaseLease>,
|
||||
failureMessage: string,
|
||||
operation: () => Promise<Result>,
|
||||
): Promise<Result> => {
|
||||
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 <Result>(
|
|||
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 <Result>(
|
||||
payload: Payload,
|
||||
leaseKey: string,
|
||||
operation: () => Promise<Result>,
|
||||
): Promise<Result> =>
|
||||
withDatabaseLease(
|
||||
payload,
|
||||
leaseKey,
|
||||
acquireTestDatabaseLease,
|
||||
'The leased operation and database lease release both failed.',
|
||||
operation,
|
||||
)
|
||||
|
||||
export const withSharedTestDatabaseLease = async <Result>(
|
||||
payload: Payload,
|
||||
leaseKey: string,
|
||||
operation: () => Promise<Result>,
|
||||
): Promise<Result> => {
|
||||
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<Result> =>
|
||||
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 <Result>(
|
|||
collection: 'consent',
|
||||
where: { key: { equals: consentKey } },
|
||||
limit: 1,
|
||||
sort: 'id',
|
||||
depth: 0,
|
||||
})
|
||||
if (!configured.docs[0]) {
|
||||
|
|
@ -441,12 +477,6 @@ export const withLeasedConsent = async <Result>(
|
|||
}
|
||||
}
|
||||
|
||||
export const ensureInviteConsent = async (payload: Payload): Promise<Consent> =>
|
||||
ensureLeasedConsent(payload, {
|
||||
consentKey: CAMPAIGN_INVITE_CONSENT_KEY,
|
||||
leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
|
||||
})
|
||||
|
||||
export const withInviteConsent = async <Result>(
|
||||
payload: Payload,
|
||||
operation: (consent: Consent) => Promise<Result>,
|
||||
|
|
@ -464,6 +494,7 @@ export const withMissingInviteConsentFixture = async <Result>(
|
|||
): Promise<Result> => {
|
||||
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 <Result>(
|
|||
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 <Result>(
|
|||
}
|
||||
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 <Result>(
|
|||
let restoreLease: Awaited<ReturnType<typeof beginTestDatabaseLease>> | 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 <Result>(
|
|||
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 <Result>(
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<TestDatabaseLease> | 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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue