mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
chore(D10): auto-cura de leftovers de consent/supporter + janela missing-consent exclusiva (12/12 sob 6 workers)
F1: purgeOrphanedConsentRenames sob lease exclusivo (autocommit) nos 3 pontos que tocam a key canônica; renames de teste migrados para chaves privadas; F1b: purge de contatos órfãos no purgeMunicipalityResidue. F2 (medição): shared-writer ABBA refutado; janela do missing-consent exclusiva + Onda0 atômico (down transacional) + provision exclusivo. Contraprova: 7 arquivos da key x 6 workers = 12/12 verdes (baseline 1/4).
This commit is contained in:
parent
170ce59ef4
commit
8d007285d5
8 changed files with 573 additions and 65 deletions
|
|
@ -1,3 +1,5 @@
|
|||
**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-<id>`/`owned-consent-vacated-<id>`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (10 falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`.
|
||||
|
||||
**Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md).
|
||||
|
|
|
|||
112
docs/plans/escala-dry-pos-d9-impl.md
Normal file
112
docs/plans/escala-dry-pos-d9-impl.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Impl: Auto-cura de leftovers de consent/supporter + ABBA do shared-write do lease de consent (pós-D9)
|
||||
|
||||
Status: em execução (F1 + F2 implementados; review /simplify aplicado; gates verdes; PR pendente)
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #617
|
||||
Intenção: docs/plans/escala-dry-pos-d9.md
|
||||
Appetite restante: herdado (só testes/helpers; zero `src/`; zero migration)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** (F1) uma run abortada no meio de um fixture de rename/cleanup nunca mais envenena o run seguinte — renames órfãos de consent e contatos/supporters órfãos são reconhecidos e removidos deterministicamente; (F2) o ABBA latente (lease shared de consent + operation escritora) deixa de existir — cascata de timeouts não reproduz sob 6 workers + carga.
|
||||
- **O que NÃO negociar:** mecanismo de lease (advisory lock) é canônico; orçamento de 15s do vitest fora; 4ª key da Onda0 sem lease fora; nada de `src/` nem migration.
|
||||
- **O que reavaliar:** a hipótese "lista vs padrão vs re-snapshot por id" da F1 (o re-snapshot é impossível — ver decisões); o alvo exato da F2 (compartilhado entre `withLeasedConsent`/`withInviteConsent`/janela do `withMissingInviteConsentFixture`/`campaignSupporter:95` — o contrato de leitura/escrita cobre todos).
|
||||
|
||||
## Caracterização (auditoria feita no código)
|
||||
|
||||
**F1 — mecanismo exato da poluição auto-sustentada.** Os testes renomeiam a linha canônica para `chave-renomeada` (campaignInvite.int.spec.ts:1615), `chave-temporariamente-ausente` (campaignInvite.int.spec.ts:1679), `temporarily-renamed-consent` (testDatabaseLease.int.spec.ts:311) e `temporarily-unkeyed-<id>` (campaignFixtures.int.spec.ts:254), sempre dentro do lease **exclusivo** do fixture (renomeia + restaura no mesmo holder). Se o run morre entre o commit do rename e o restore, a linha órfã persiste com a key temporária e a key canônica fica ausente. No run seguinte o fixture snapshotta "ausente", cria linha nova, e o teste renomeia para a MESMA key temporária → UNIQUE `consent_key_idx` (`Valor deve ser único`) → poluição auto-sustentada. Garantia de segurança do purge: qualquer renomeador vivo está segurando o lease exclusivo da key — quem faz o purge também o segura, então nenhuma linha temporária viva pode existir durante o purge (renomeadores em fila só agem após o release, e partem do snapshot pós-purge).
|
||||
|
||||
**F2 — ciclo exato do ABBA.** `withLeasedConsent` (e `withInviteConsent`) segura o lease **shared** durante a `operation()`. Operations escritoras (redeem autofill/login → `UPDATE campaignInvite.usedAt`, `UPDATE leadership`, `UPDATE contact`, `CREATE campaignUser`; `createSupporterRecord`/`createLeaderSupporterRecord` → upsert `contact` + `CREATE supporter`) tomam row locks de invite/contato. Dois escritores em spec files diferentes seguram shared (compatível — zero serialização advisory) e intercalam writes sobre linhas sobrepostas → ABBA de row locks. Também: escritor shared + holder exclusivo (`withMutableConsentFixture`, que escreve invites no corpo do teste) → o exclusivo espera o shared enquanto seu corpo segura row locks que o shared-writer quer. Auditoria de ordem canônica e FIFO: a aquisição é sempre "consent-lease primeiro, row locks depois" — o ciclo existe porque DOIS holders seguram locks em ordens opostas (consent↔row); com escritores exclusivos e leitores shared (que não escrevem → não seguram row locks), nenhum ciclo é construtível.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
F1[F1: purge de renames órfãos] --> H1[purgeOrphanedConsentRenames<br/>lista explícita + padrão temporarily-unkeyed-%]
|
||||
H1 --> S1[chamado sob o lease exclusivo em<br/>withMutableConsentFixture / withMissingInviteConsentFixture / ensureLeasedConsent]
|
||||
F1b[F1b: contatos órfãos de residue] --> H2[purgeMunicipalityResidue deleta<br/>contatos sem outras referências]
|
||||
F2[F2: contrato read/write do withLeasedConsent] --> H3[opção mode:'write' → lease EXCLUSIVO]
|
||||
H3 --> S2[call sites escritores migram;<br/>leitores continuam shared]
|
||||
H3 --> S3[janela do withMissingInviteConsentFixture<br/>permanece shared + invariante documentado]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (mode `'write'` explícito por call site, default read) | B (exclusivo default, opt-out read) | C (escritas fora do escopo do lease shared) | D (janela do missing-consent exclusiva) | E (purge por lista explícita + padrão) | F (re-snapshot por id) | G (purge global de consent não-canônico)
|
||||
|
||||
**Recomendação:**
|
||||
|
||||
- **F1 = E**: purge determinístico por lista explícita (`temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) + `key LIKE 'temporarily-unkeyed-%'`, executado sob o lease exclusivo **antes do snapshot** nos três pontos que tocam a key canônica. — porque a lista cobre os 4 nomes reais hoje, o padrão cobre a família `temporarily-unkeyed-<id>` sem risco (nenhuma key estável legítima casa com o padrão) e um único DELETE indexado é barato.
|
||||
- **F2 = A**: `withLeasedConsent` ganha `{ mode: 'write' }` (lease exclusivo para o ensure + operation num único holder; sem loop de retry — ninguém pode deletar a linha sob exclusivo); default continua `'read'` (shared). Call sites que escrevem migram explicitamente. — porque escritores exclusivos serializam entre si (fim da intercalação de row locks), leitores shared não escrevem → não entram em ciclo, e a medição D9 (janela exclusiva estourou o budget de 15s) não se repete: os escritores de invite são operations curtas (~100–300 ms), não provisions multi-segundo.
|
||||
- **F2 janela missing-consent = shared, preservada (não D)**: a janela shared bloqueia os exclusivos (ensure/fixtures) sem parar leitores shared — a medição D9 foi explícita ("15s tight under load com janela exclusiva"). A operation da janela escreve, mas é a ÚNICA shared-writer da key hoje (1 arquivo por key: campaignSupporter:95 / testDatabaseLease com keys privadas) e o holder do window não espera por ninguém (exclusivos que esperam a janela não seguram row locks — adquirem após o wait) → ciclo não construtível; vira invariante documentado + comentário.
|
||||
|
||||
**Rejeitadas:** B porque serializaria os LEITORES atrás dos escritores (a fila que estourou o budget na medição D9 — leitores são muitos: `testDatabaseLease`, `campaignFixtures`, previews de `campaignInviteUi`); C porque quebra a garantia "a linha canônica está estável durante a operation" (writers sem lease veriam a ausência commitada de uma janela missing-consent → flake de fail-closed); D porque a medição D9 documentou o budget estourando sob carga com janela exclusiva; F porque o snapshot id morre com o run abortado (impossível re-snapshot por id — só o nome temporário sobrevive); G porque apagaria keys legítimas de fixtures (`fixtures.createConsent` gera keys runID-scoped aleatórias que coexistem).
|
||||
|
||||
## Revisão da F2 por medição (2026-08-11 — mudança material pós-gate)
|
||||
|
||||
O plano aprovado assumia a hipótese da intenção ("shared-writer = ABBA latente") e propunha o contrato `mode: 'write'` → lease **exclusivo** durante a operation. A medição sob carga (7 arquivos da key, 6 workers) **refutou a forma e o mecanismo**:
|
||||
|
||||
1. **Exclusivo durante a operation = cascata.** Fila de 17 waiters advisory + timeouts de 15s em cascata (campaignInvite/campaignInviteUi/testDatabaseLease) — o hold do exclusivo por ~30 testes serializa a key inteira. **Descartado medido.**
|
||||
2. **Operation sem lease = FK violation.** `withMutableConsentFixture` com snapshot ausente recria a linha canônica; o cleanup do fixture a deleta; um consumidor sem lease que a adotou (redeem gravando `leadership.consent_id`) → `supporter/leadership_consent_id_consent_id_fk`. **Descartado medido.**
|
||||
3. **O ABBA shared↔row não é construtível hoje:** os consumidores escrevem linhas disjuntas por teste; Onda0 é shared desde D9; nenhum ciclo advisory↔row existe. A cascata D9 era o ONDA0 **exclusivo** (multi-segundo) + fila — regime que já não existe.
|
||||
4. **As flakes residuais reais (baseline medido: 3/4 runs com 1 falha) eram:** (a) consumidores observando a ausência commitada da janela do missing-consent (janela shared D9 não bloqueia consumidores shared) → 'Consentimento ainda não configurado' em redeems; (b) o down-test da Onda0 deletando as 4 keys estáveis **não-atomicamente** (delete autocommit + restore separado) → FK `consent_id`; (c) o provision da Onda0 reescrevendo textos no meio de asserções de preview (mismatch de `requiresConsent`/`consentData`).
|
||||
|
||||
**Design final (medido 8/8 verdes sob 6 workers; baseline 1/4):**
|
||||
|
||||
- **Consumidores ficam SHARED** (D9) — `withLeasedConsent`/`withInviteConsent` inalterados no modo; o contrato `mode` foi **descartado** (call sites revertidos).
|
||||
- **Janela do `withMissingInviteConsentFixture` vira EXCLUSIVA** (era shared desde D9): nenhum consumidor observa a ausência commitada — é a mudança que mata a classe (a). O custo é fila limitada (~200–500ms) — a medição D9 do "15s tight" era sob o regime ONDA0-exclusivo, que não existe mais.
|
||||
- **Down-test da Onda0 atômico** (delete + count + restore numa transação) — mata a classe (b).
|
||||
- **Provision da Onda0 EXCLUSIVO** (~500ms, fila limitada) — mata a classe (c).
|
||||
- **Purge F1 sem o padrão `temporarily-unkeyed-%`**: o padrão só era produzido pelo teste `campaignFixtures` ('deletes the stable invite consent'), que agora usa chave privada `owned-consent-vacated-<id>` — a linha canônica renomeada com key no padrão do purge podia ser apagada por purge de outro arquivo no meio da operation (falha NotFound observada). O purge cobre os 3 nomes explícitos.
|
||||
- **Testes F1 auto-curáveis**: plantas de órfãos sob lease exclusivo + purge no início + cleanup no finally (uma falha do próprio teste não deixa poluição para o run seguinte).
|
||||
|
||||
### Componentes / mudanças (final)
|
||||
|
||||
- **`purgeOrphanedConsentRenames`** (`tests/helpers/testDatabaseLease.ts`): DELETE `WHERE key IN (3 nomes)` via `execute`; autocommit (nunca dentro da transação do lease — delete não-commitado seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada). Chamado sob o lease exclusivo por: (1) `withMutableConsentFixture` antes do snapshot; (2) `withMissingInviteConsentFixture` no setup antes do snapshot; (3) `ensureLeasedConsent` dentro da janela exclusiva antes do find.
|
||||
- **`withMissingInviteConsentFixture`** (`tests/helpers/testDatabaseLease.ts`): `serializeWindow: true` (default) → janela **exclusiva**; `false` inalterado (teste de ensures concorrentes em key privada).
|
||||
- **`withExclusiveTestDatabaseLease`** (`tests/helpers/testDatabaseLease.ts`): wrapper novo exportado (mirror do shared).
|
||||
- **`onda0Provision.int.spec.ts`**: provision com leases exclusivos; down-test com delete+assert+restore numa transação (`beginTransaction` + session db + commit/rollback).
|
||||
- **`campaignFixtures.ts`** (`purgeMunicipalityResidue`): além do purge de residue existente, deleta os **contatos** que os supporters residue órfãos deixaram (referência-check em leadership/signature/subscription/supporter — mesmo critério do `removeSupporterData`, reimplementado no helper).
|
||||
- **`campaignFixtures.int.spec.ts`** (teste 'deletes the stable invite consent...'): chave de vacância privada `owned-consent-vacated-<id>` (fora do padrão do purge).
|
||||
- **Testes novos** (`testDatabaseLease.int.spec.ts`): contrato da janela exclusiva (waiter SHARED bloqueado durante a janela — estende o teste D9); purge em ensure; purge no setup do missing; self-heal do canônico (repro do run abortado, 2 fixtures); **F1b** (`campaignFixtures.int.spec.ts`): purge de contatos órfãos preservando referenciados.
|
||||
- **Migration:** sem migration (só testes/helpers).
|
||||
- **Access / Consent:** nenhuma mudança em runtime; chaves e fail-closed intactos.
|
||||
- **UI:** nenhuma.
|
||||
|
||||
## Fases verificáveis (executadas)
|
||||
|
||||
1. **F2 contrato** — janela exclusiva + contrato testado (waiter SHARED bloqueado durante a janela); consumidores shared preservados.
|
||||
2. **F1 self-heal** — purge nos 3 pontos; repros: órfão plantado (sob lease), auto-cura no run seguinte (fixture 2×), sem UNIQUE; testes auto-curáveis (cleanup no finally).
|
||||
3. **F1b residue** — purge de contatos sem referências; repro: plant + purge direto, preservação de referenciado.
|
||||
4. **Gates + carga** — contraprova: 7 arquivos da key (`campaignInvite`, `campaignInviteUi`, `campaignFixtures`, `testDatabaseLease`, `collectionAccessLockdown`, `campaignSupporter`, `onda0Provision`) com workers=6 → **8/8 runs verdes** (baseline original: 1/4; designs intermediários: cascata de 15s / FK violations); suíte int completa 83 arquivos/701 testes verdes; gates abaixo em andamento.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Mecanismo de lease/advisory lock (canônico, fora — só o modo de aquisição da janela muda).
|
||||
- Orçamento de 15s do vitest (gatilho: recorrência com pool ativo).
|
||||
- 4ª key da Onda0 sem lease (gatilho: primeiro escritor do push key).
|
||||
- Atacar a poluição residual de contatos legados (inertes; fora — o F1b só impede acúmulo novo).
|
||||
- Re-trabalhar a spec Onda0 ou a Onda0 em si.
|
||||
- Gap setup→janela do missing-consent (ausência commitada por ~ms entre o commit do setup e a aquisição da janela; consumidor que insira com FK nesse gap ainda pode falhar — classe residual rara, mesma ordem da aceita na D9; gatilho de revisita: recorrência sob carga).
|
||||
|
||||
## Revisão /simplify (2 reviewers independentes, 2026-08-11) — fixes aplicados
|
||||
|
||||
- **P1-1 (ambos reviewers, obrigatório):** o purge deleta os 3 nomes globalmente sob a lease do chamador; os renomeadores vivos de `campaignInvite.int.spec.ts` (linhas 1615/1679) produziam `chave-renomeada`/`chave-temporariamente-ausente` sob a lease invite — um purge sob outra lease key (supporter/privada) podia apagar a linha renomeada viva (a classe exata que a D10 mata). **Fix aplicado:** os dois renames migraram para chave privada `consent-vacated-<id>` (mesmo padrão do `campaignFixtures`); agora NENHUM teste vivo produz os 3 nomes — o único produtor restante é a planta do self-heal (sob a lease invite, imediatamente antes do fixture curador). Invariante documentada no helper.
|
||||
- **P2-1:** comentários do diff diziam "Onda0 holds shared, never exclusive" — falsos pós-diff (provision é exclusivo). Reescritos.
|
||||
- **P2-2:** `withExclusiveTestDatabaseLease` era cópia verbatim do privado — colapsado em um único exportado (usado também internamente).
|
||||
- **P2-3:** teste 'purges orphaned renames during the missing-consent fixture setup' sem cleanup no finally — adicionado.
|
||||
- **P2 (reviewer 2):** down-test da Onda0 lançava sem rollback quando a sessão da transação faltava — rollback adicionado antes do throw.
|
||||
- **Débitos registrados (não-fix):** fault `beforeRestoreLeaseAcquire` vestigial com janela exclusiva (só tem efeito com `serializeWindow: false` — documentar/remover); exposição transitória do gap do self-heal (ms, mesma classe residual aceita); `privacy_policy.published=false` persiste após o down-test (comportamento D9, inalterado); micro-otimização dos selects do reference-check; callback `execute` do purge podia receber `payload` direto.
|
||||
|
||||
## Riscos e mitigação (medidos)
|
||||
|
||||
- **Fila nova na janela exclusiva / provision exclusivo** (consumidores esperam ~200–500ms): medido 8/8 verdes sob 6 workers — sem timeouts.
|
||||
- **F1 purge apagar linha viva:** impossível por construção (renomeador vivo segura o exclusivo; purge roda sob o exclusivo); testes plantam órfãos sob lease e limpam no finally.
|
||||
- **Down-test atômico:** consumers com FK no meio do delete+restore transacional esperam o commit (speculative insertion) e progridem — classe FK eliminada (medido).
|
||||
- **F1b deletar contato referenciado:** o filtro de outras-referências roda antes do delete; teste cobre ambos os lados (deleta só órfão; preserva referenciado).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Run abortado (rename órfão plantado) não envenena o run seguinte (F1 — repros + suíte)
|
||||
- [x] Nenhum consumidor observa a ausência commitada da janela missing-consent (janela exclusiva + contrato testado)
|
||||
- [x] Cascata de timeouts não reproduz sob 6 workers ×12 (F2 — repro 12/12 vs baseline 1/4)
|
||||
- [x] Review /simplify: P1-1 e P2s aplicados; débitos registrados
|
||||
- [x] Invariantes AGENTS/engineering-standards; só testes/helpers; sem migration; zero `src/` (tsc, lint, knip, cycles, format, unit 1938, int 701, e2e paridade com baseline)
|
||||
|
|
@ -233,8 +233,9 @@ const nextAllocatedMunicipalityIndex = async (
|
|||
* Delete campaign rows attached to a freshly claimed municipality. The allocator
|
||||
* guarantees no OTHER live test owns this municipality, so anything found here is
|
||||
* residue from an aborted previous run that would poison count assertions.
|
||||
* Also deletes the CONTACT rows those residue supporters orphaned (D10 F1).
|
||||
*/
|
||||
const purgeMunicipalityResidue = async (
|
||||
export const purgeMunicipalityResidue = async (
|
||||
payload: Payload,
|
||||
municipalityID: number,
|
||||
): Promise<void> => {
|
||||
|
|
@ -273,7 +274,6 @@ const purgeMunicipalityResidue = async (
|
|||
where: { municipality: { equals: municipalityID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: {},
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'activity',
|
||||
|
|
@ -336,6 +336,63 @@ const purgeMunicipalityResidue = async (
|
|||
depth: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// D10 F1: an aborted run leaves residue supporters AND their contact rows.
|
||||
// The supporters are gone above; delete the contacts they orphaned (no other
|
||||
// join references them) so the residue stops accumulating — same reference
|
||||
// rule as the production `removeSupporterData` flow, reimplemented here so
|
||||
// the fixture never runs production code under test.
|
||||
const residueContactIDs = [
|
||||
...new Set(
|
||||
supporters.docs.map((doc) => relationId(doc.contact)).filter((id) => id !== undefined),
|
||||
),
|
||||
]
|
||||
if (residueContactIDs.length > 0) {
|
||||
const [leadershipRefs, signatureRefs, subscriptionRefs, supporterRefs] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { contact: { in: residueContactIDs } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'signature',
|
||||
where: { contact: { in: residueContactIDs } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'subscription',
|
||||
where: { contact: { in: residueContactIDs } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'supporter',
|
||||
where: { contact: { in: residueContactIDs } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
])
|
||||
const referenced = new Set<number>()
|
||||
for (const doc of [
|
||||
...leadershipRefs.docs,
|
||||
...signatureRefs.docs,
|
||||
...subscriptionRefs.docs,
|
||||
...supporterRefs.docs,
|
||||
]) {
|
||||
const contactID = relationId(doc.contact)
|
||||
if (contactID !== undefined) referenced.add(contactID)
|
||||
}
|
||||
const orphanContactIDs = residueContactIDs.filter((id) => !referenced.has(id))
|
||||
if (orphanContactIDs.length > 0) {
|
||||
await payload.delete({
|
||||
collection: 'contact',
|
||||
where: { id: { in: orphanContactIDs } },
|
||||
depth: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CampaignFixtures {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,41 @@ export const restoreConsentRows = async (
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the consent fixtures rename the canonical stable row to while a test
|
||||
* exercises the fail-closed "key absent" state. A run aborted between the
|
||||
* rename commit and the fixture restore leaves the canonical key absent and an
|
||||
* orphan row holding one of these names; without a purge, the next run
|
||||
* snapshots the key as absent, creates a fresh row, and the same rename then
|
||||
* hits the UNIQUE `consent_key_idx` — the pollution is self-sustaining (D10 F1).
|
||||
*/
|
||||
const ORPHANED_CONSENT_RENAME_KEYS = [
|
||||
'temporarily-renamed-consent',
|
||||
'chave-renomeada',
|
||||
'chave-temporariamente-ausente',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Deletes orphaned consent rows left by an aborted run: the explicit temporary
|
||||
* rename keys. MUST be called while holding the lease key's EXCLUSIVE lease.
|
||||
* The 3 names are produced by NO live test anymore (the fail-closed rename
|
||||
* tests vacate the canonical row to PRIVATE keys, D10 review finding P1-1), so
|
||||
* a row carrying one of them is guaranteed orphaned — the only producer left
|
||||
* is the self-heal repro's own plant, which runs under this key's exclusive
|
||||
* lease immediately before the healing fixture.
|
||||
*/
|
||||
export const purgeOrphanedConsentRenames = async (
|
||||
execute: (query: ReturnType<typeof sql>) => Promise<unknown>,
|
||||
): Promise<void> => {
|
||||
await execute(sql`
|
||||
DELETE FROM "consent"
|
||||
WHERE "key" IN (${sql.join(
|
||||
ORPHANED_CONSENT_RENAME_KEYS.map((key) => sql`${key}`),
|
||||
sql`, `,
|
||||
)})
|
||||
`)
|
||||
}
|
||||
|
||||
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'
|
||||
|
|
@ -104,7 +139,7 @@ export type LeasedConsentFixture = {
|
|||
consentKey: string
|
||||
leaseKey: string
|
||||
}
|
||||
const fixtureConsentText: Consent['text'] = {
|
||||
export const fixtureConsentText: Consent['text'] = {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
|
|
@ -385,7 +420,7 @@ const withDatabaseLease = async <Result>(
|
|||
}
|
||||
}
|
||||
|
||||
const withTestDatabaseLease = async <Result>(
|
||||
export const withExclusiveTestDatabaseLease = async <Result>(
|
||||
payload: Payload,
|
||||
leaseKey: string,
|
||||
operation: () => Promise<Result>,
|
||||
|
|
@ -394,7 +429,7 @@ const withTestDatabaseLease = async <Result>(
|
|||
payload,
|
||||
leaseKey,
|
||||
acquireTestDatabaseLease,
|
||||
'The leased operation and database lease release both failed.',
|
||||
'The exclusive leased operation and database lease release both failed.',
|
||||
operation,
|
||||
)
|
||||
|
||||
|
|
@ -415,7 +450,13 @@ export const ensureLeasedConsent = async (
|
|||
payload: Payload,
|
||||
{ consentKey, leaseKey }: LeasedConsentFixture,
|
||||
): Promise<Consent> =>
|
||||
withTestDatabaseLease(payload, leaseKey, async () => {
|
||||
withExclusiveTestDatabaseLease(payload, leaseKey, async () => {
|
||||
// D10 F1: a run aborted mid-rename leaves an orphan row holding a
|
||||
// temporary name and the canonical key absent; the purge clears it so the
|
||||
// ensure below can recreate the canonical row without the next rename
|
||||
// hitting the UNIQUE constraint. Autocommit is safe: we hold the
|
||||
// exclusive lease, so no live renamer can be mid-flight.
|
||||
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
|
||||
const existing = await payload.find({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: consentKey } },
|
||||
|
|
@ -440,6 +481,17 @@ export const withLeasedConsent = async <Result>(
|
|||
{ consentKey, leaseKey }: LeasedConsentFixture,
|
||||
operation: (consent: Consent) => Promise<Result>,
|
||||
): Promise<Result> => {
|
||||
// D10 F2 (measurement-driven): the operation holds the SHARED consent lease.
|
||||
// Holding exclusive across the operation serialized every writer on the key
|
||||
// into a 17-waiter queue and a 15s cascade under 6 workers; running the
|
||||
// operation UNLEASED let a parallel fixture's created-row cleanup delete the
|
||||
// canonical row out from under a consumer (FK violation). Shared is the
|
||||
// stable middle: consumers never serialize against each other (their writes
|
||||
// touch disjoint per-test rows, so no lock-order cycle is constructible —
|
||||
// every advisory holder acquires at most one lease at a time, and Onda0
|
||||
// acquires its three keys in a fixed order) and the exclusive
|
||||
// missing-consent window blocks every consumer while the canonical key is
|
||||
// absent, so no consumer observes the transient state.
|
||||
for (;;) {
|
||||
await ensureLeasedConsent(payload, { consentKey, leaseKey })
|
||||
const lease = await beginTestDatabaseLease(payload, leaseKey, 'shared')
|
||||
|
|
@ -500,6 +552,13 @@ export const withMissingInviteConsentFixture = async <Result>(
|
|||
let setupError: unknown
|
||||
try {
|
||||
await faults.beforeSnapshotSetup?.()
|
||||
// D10 F1: clear orphaned renames left by an aborted run BEFORE snapshotting
|
||||
// (we hold the exclusive setup lease, so no live renamer exists). The
|
||||
// DELETE runs in autocommit, NOT in the lease transaction: an uncommitted
|
||||
// delete would keep its row lock and the fixture's own operation would
|
||||
// self-deadlock when it re-uses the purged key (observed on the first D10
|
||||
// implementation).
|
||||
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
|
||||
snapshot = rowsFrom<ConsentSnapshot>(
|
||||
await setupLease.transaction.execute(sql`
|
||||
SELECT "id", "key", "text", "updated_at", "created_at"
|
||||
|
|
@ -545,21 +604,23 @@ 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).
|
||||
// Serialize the operation window under an EXCLUSIVE lease (D10 F2,
|
||||
// measurement-driven change from D9's shared window): every consumer of the
|
||||
// key — the shared `withLeasedConsent` holders AND the exclusive fixtures —
|
||||
// is blocked while the canonical key is committed-absent, so no consumer can
|
||||
// observe the transient absence or read/reference a row the restore deletes
|
||||
// out from under it (baseline load runs flaked 3/4 with exactly this class:
|
||||
// 'Consentimento ainda não configurado' in redeem tests and
|
||||
// `consent_id` FK violations). The window is short (a fail-closed action or
|
||||
// a no-op), so the queueing behind it is bounded — the D9-era 15s budget
|
||||
// blowout happened with the Onda0 provision holding EXCLUSIVE for
|
||||
// multi-second writes; since D10 the provision holds exclusive too, but
|
||||
// only for ~500ms of short upserts.
|
||||
// `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
|
||||
const windowLease = serializeWindow ? await beginTestDatabaseLease(payload, leaseKey) : 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
|
||||
|
|
@ -653,6 +714,12 @@ export const withMutableConsentFixture = async <Result>(
|
|||
let operationError: unknown
|
||||
|
||||
try {
|
||||
// D10 F1: clear orphaned renames left by an aborted run BEFORE snapshotting
|
||||
// (we hold the exclusive lease, so no live renamer exists). Autocommit,
|
||||
// never inside the lease transaction — an uncommitted delete would keep
|
||||
// its row lock and the fixture's own operation would self-deadlock when it
|
||||
// re-uses the purged key.
|
||||
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
|
||||
snapshot = rowsFrom<ConsentSnapshot>(
|
||||
await lease.transaction.execute(sql`
|
||||
SELECT "id", "key", "text", "updated_at", "created_at"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
|||
|
||||
import type { Consent } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
import { createCampaignFixtures, withCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import {
|
||||
createCampaignFixtures,
|
||||
purgeMunicipalityResidue,
|
||||
withCampaignFixtures,
|
||||
} from '../helpers/campaignFixtures'
|
||||
import { withInviteConsent, withMutableConsentFixture } from '../helpers/testDatabaseLease'
|
||||
|
||||
let payload: Payload
|
||||
|
|
@ -40,7 +44,8 @@ const exists = async (
|
|||
| 'votePledge'
|
||||
| 'campaignDemand'
|
||||
| 'organization'
|
||||
| 'municipalityUpdate',
|
||||
| 'municipalityUpdate'
|
||||
| 'supporter',
|
||||
id: number,
|
||||
): Promise<boolean> => {
|
||||
const result = await payload.find({
|
||||
|
|
@ -248,10 +253,15 @@ describe('campaign integration fixtures', () => {
|
|||
let consentID = 0
|
||||
|
||||
await withMutableConsentFixture(payload, async (configuredConsent) => {
|
||||
// The vacating rename uses a PRIVATE key, deliberately OUTSIDE the
|
||||
// orphan-rename purge list: the canonical row is shared with parallel
|
||||
// spec files and any key the purge recognizes could be deleted by a
|
||||
// concurrent fixture's cleanup mid-operation (D10 F2 measurement).
|
||||
const vacatedKey = `owned-consent-vacated-${configuredConsent.id}`
|
||||
await payload.update({
|
||||
collection: 'consent',
|
||||
id: configuredConsent.id,
|
||||
data: { key: `temporarily-unkeyed-${configuredConsent.id}` },
|
||||
data: { key: vacatedKey },
|
||||
depth: 0,
|
||||
})
|
||||
await withCampaignFixtures(payload, async (fixtures) => {
|
||||
|
|
@ -397,4 +407,42 @@ describe('campaign integration fixtures', () => {
|
|||
})
|
||||
expect(persistedMunicipality.advisors ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('purges residue supporters and the contacts they orphaned on a municipality claim', async () => {
|
||||
// D10 F1: an aborted run leaves supporters AND their contact rows on the
|
||||
// claimed municipality. The purge must delete the orphaned contacts (no
|
||||
// other join references them) while preserving contacts that a live
|
||||
// leadership outside the purged municipality still references.
|
||||
const fixtures = createCampaignFixtures(payload)
|
||||
try {
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const orphanContact = await fixtures.createContact()
|
||||
const residueSupporter = await fixtures.createSupporter({
|
||||
contact: orphanContact,
|
||||
municipality,
|
||||
createdBy: coordinator,
|
||||
})
|
||||
const preservedContact = await fixtures.createContact()
|
||||
await fixtures.createSupporter({
|
||||
contact: preservedContact,
|
||||
municipality,
|
||||
createdBy: coordinator,
|
||||
})
|
||||
const otherMunicipality = await fixtures.getMunicipality()
|
||||
await fixtures.createLeadership({
|
||||
contact: preservedContact,
|
||||
municipalities: [otherMunicipality.id],
|
||||
createdBy: coordinator,
|
||||
})
|
||||
|
||||
await purgeMunicipalityResidue(payload, municipality.id)
|
||||
|
||||
expect(await exists('supporter', residueSupporter.id)).toBe(false)
|
||||
expect(await exists('contact', orphanContact.id)).toBe(false)
|
||||
expect(await exists('contact', preservedContact.id)).toBe(true)
|
||||
} finally {
|
||||
await fixtures.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1611,7 +1611,7 @@ describe('campaign invite domain', () => {
|
|||
await payload.update({
|
||||
collection: 'consent',
|
||||
id: consent.id,
|
||||
data: { key: 'chave-renomeada' },
|
||||
data: { key: `consent-vacated-${consent.id}` },
|
||||
})
|
||||
|
||||
await expect(
|
||||
|
|
@ -1675,7 +1675,7 @@ describe('campaign invite domain', () => {
|
|||
await payload.update({
|
||||
collection: 'consent',
|
||||
id: consent.id,
|
||||
data: { key: 'chave-temporariamente-ausente' },
|
||||
data: { key: `consent-vacated-${consent.id}` },
|
||||
})
|
||||
const submission = {
|
||||
token: invite.token,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||||
SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
|
||||
restoreConsentRows,
|
||||
withExclusiveTestDatabaseLease,
|
||||
withSharedTestDatabaseLease,
|
||||
type ConsentSnapshot,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
|
|
@ -40,22 +41,18 @@ describe('Onda 0 provision (integration)', () => {
|
|||
})
|
||||
|
||||
it('upserts consent keys and publishes privacy-policy idempotently', async () => {
|
||||
// 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(
|
||||
// EXCLUSIVE leases on the stable keys other spec files exercise (D10 F2):
|
||||
// the provision REWRITES the shared rows' texts, and a consumer that reads
|
||||
// the canonical consent between two of those writes would assert against a
|
||||
// text that changed under it (observed: campaignInviteUi's preview
|
||||
// `requiresConsent`/`consentData` mismatch). Exclusive serializes every
|
||||
// consumer for the ~500ms of the provision — bounded, unlike the pre-D10
|
||||
// cascade where the ONDA0 exclusive sat behind a queue of EXCLUSIVE
|
||||
// writers. The down-test below restores the removed rows with their
|
||||
// original ids atomically, so the shared rows are never observed absent.
|
||||
await withExclusiveTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, () =>
|
||||
withExclusiveTestDatabaseLease(payload, SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, () =>
|
||||
withExclusiveTestDatabaseLease(
|
||||
payload,
|
||||
SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
|
||||
async () => {
|
||||
|
|
@ -113,34 +110,54 @@ describe('Onda 0 provision (integration)', () => {
|
|||
expect(privacyRows.rows[0]?.published).toBe(true)
|
||||
expect(privacyRows.rows[0]?.body).toBeTruthy()
|
||||
|
||||
await removeOnda0ConsentAndPrivacyDb(db)
|
||||
// The down-migration must stay invisible to parallel spec files:
|
||||
// delete + count + restore run in ONE transaction, so no consumer
|
||||
// ever observes the stable keys committed-absent or re-created
|
||||
// with a new id (D10 F2 — the non-atomic version flaked load runs
|
||||
// with `consent_id` FK violations and fail-closed reads).
|
||||
const transactionID = await payload.db.beginTransaction()
|
||||
if (transactionID === null) {
|
||||
throw new Error('Expected a PostgreSQL transaction for the Onda0 down.')
|
||||
}
|
||||
const transactionDb = payload.db.sessions?.[String(transactionID)]?.db as
|
||||
| typeof db
|
||||
| undefined
|
||||
if (!transactionDb) {
|
||||
await payload.db.rollbackTransaction(transactionID).catch(() => undefined)
|
||||
throw new Error('The Onda0 down transaction session is unavailable.')
|
||||
}
|
||||
try {
|
||||
await removeOnda0ConsentAndPrivacyDb(transactionDb)
|
||||
|
||||
const consentAfterDown = await db.execute(sql`
|
||||
SELECT count(*)::integer AS count
|
||||
FROM "consent"
|
||||
WHERE "key" IN (${sql.join(
|
||||
ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`),
|
||||
sql`, `,
|
||||
)})
|
||||
`)
|
||||
expect(consentAfterDown.rows[0]?.count).toBe(0)
|
||||
const consentAfterDown = await transactionDb.execute(sql`
|
||||
SELECT count(*)::integer AS count
|
||||
FROM "consent"
|
||||
WHERE "key" IN (${sql.join(
|
||||
ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`),
|
||||
sql`, `,
|
||||
)})
|
||||
`)
|
||||
expect(consentAfterDown.rows[0]?.count).toBe(0)
|
||||
|
||||
const privacyAfterDown = await db.execute(sql`
|
||||
SELECT "published"
|
||||
FROM "privacy_policy"
|
||||
LIMIT 1
|
||||
`)
|
||||
expect(privacyAfterDown.rows[0]?.published).toBe(false)
|
||||
const privacyAfterDown = await transactionDb.execute(sql`
|
||||
SELECT "published"
|
||||
FROM "privacy_policy"
|
||||
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[],
|
||||
)
|
||||
// Restore the removed rows with their ORIGINAL ids inside the
|
||||
// same transaction — the commit publishes delete+restore as one
|
||||
// atomic state change.
|
||||
await restoreConsentRows(
|
||||
(query) => transactionDb.execute(query),
|
||||
consentRows.rows as ConsentSnapshot[],
|
||||
)
|
||||
await payload.db.commitTransaction(transactionID)
|
||||
} catch (error) {
|
||||
await payload.db.rollbackTransaction(transactionID).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
acquireTestDatabaseLease,
|
||||
CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
|
||||
ensureLeasedConsent,
|
||||
fixtureConsentText,
|
||||
purgeOrphanedConsentRenames,
|
||||
startTestDatabaseLeaseAcquisition,
|
||||
waitForAdvisoryLockWaiter,
|
||||
withInviteConsent,
|
||||
|
|
@ -182,6 +184,7 @@ describe('test database lease', () => {
|
|||
const before = await withInviteConsent(payload, async (consent) => consent)
|
||||
|
||||
let writerAcquisition: Promise<TestDatabaseLease> | undefined
|
||||
let readerAcquisition: Promise<TestDatabaseLease> | undefined
|
||||
try {
|
||||
await withMissingInviteConsentFixture(payload, async () => {
|
||||
let writerEntered = false
|
||||
|
|
@ -202,14 +205,44 @@ describe('test database lease', () => {
|
|||
})
|
||||
expectExactWaiter(waiting, writerPID, 'ExclusiveLock')
|
||||
expect(writerEntered).toBe(false)
|
||||
|
||||
// D10 F2: the window is EXCLUSIVE, so even a SHARED consumer queues
|
||||
// while the canonical key is committed-absent — no consumer can
|
||||
// observe the transient absence (baseline load runs flaked 3/4 with
|
||||
// consumers reading the absent key or referencing a row the restore
|
||||
// deleted out from under them). The reader is NOT awaited here: it
|
||||
// only unblocks when this window releases, which is after the
|
||||
// operation returns.
|
||||
let readerEntered = false
|
||||
const pendingReader = startTestDatabaseLeaseAcquisition(
|
||||
payload,
|
||||
CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
|
||||
'shared',
|
||||
)
|
||||
readerAcquisition = pendingReader.acquisition.then((lease) => {
|
||||
readerEntered = true
|
||||
return lease
|
||||
})
|
||||
const readerPID = await pendingReader.backendPID
|
||||
const readerWaiting = await waitForAdvisoryLockWaiter(payload, {
|
||||
key: `test:${CAMPAIGN_INVITE_CONSENT_LEASE_KEY}`,
|
||||
mode: 'ShareLock',
|
||||
waiterPID: readerPID,
|
||||
})
|
||||
expectExactWaiter(readerWaiting, readerPID, 'ShareLock')
|
||||
expect(readerEntered).toBe(false)
|
||||
})
|
||||
} finally {
|
||||
// Release the waiter lease even when an assertion above fails, so the
|
||||
// pending acquisition never blocks parallel spec files until teardown.
|
||||
// Release the waiter leases even when an assertion above fails, so the
|
||||
// pending acquisitions never block parallel spec files until teardown.
|
||||
if (writerAcquisition) {
|
||||
const writer = await writerAcquisition
|
||||
await writer.release()
|
||||
}
|
||||
if (readerAcquisition) {
|
||||
const reader = await readerAcquisition
|
||||
await reader.release()
|
||||
}
|
||||
}
|
||||
|
||||
const after = await withInviteConsent(payload, async (consent) => consent)
|
||||
|
|
@ -473,4 +506,176 @@ describe('test database lease', () => {
|
|||
const subsequent = await acquireTestDatabaseLease(payload, leaseKey)
|
||||
await subsequent.release()
|
||||
})
|
||||
|
||||
it('purges orphaned consent renames left by an aborted run on ensure', async () => {
|
||||
// D10 F1: every consent fixture must clear the rename shapes an aborted
|
||||
// run can leave behind before snapshotting, or the next run's rename hits
|
||||
// the UNIQUE constraint. Legitimate rows (the private key below) survive.
|
||||
const privateKey = `purge-ensure-${Date.now()}-${Math.random()}`
|
||||
const privateLeaseKey = `purge-ensure-lease-${Date.now()}-${Math.random()}`
|
||||
const legitKey = `purge-legit-${Date.now()}-${Math.random()}`
|
||||
const orphanIDs: number[] = []
|
||||
let legitID = 0
|
||||
// The orphans are planted under the key's EXCLUSIVE lease: the rename
|
||||
// keys are LIVE inside other spec files' fixtures, and an unleased
|
||||
// create could hit their UNIQUE constraint mid-rename (D10 F2 load
|
||||
// measurement). The lease start also purges any leftover from a previous
|
||||
// failed run of this very test, so the plant never collides with it.
|
||||
const plantLease = await acquireTestDatabaseLease(payload, privateLeaseKey)
|
||||
try {
|
||||
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
|
||||
for (const key of [
|
||||
'temporarily-renamed-consent',
|
||||
'chave-renomeada',
|
||||
'chave-temporariamente-ausente',
|
||||
]) {
|
||||
const orphan = await payload.create({
|
||||
collection: 'consent',
|
||||
data: { key, text: fixtureConsentText },
|
||||
depth: 0,
|
||||
})
|
||||
orphanIDs.push(orphan.id)
|
||||
}
|
||||
const legit = await payload.create({
|
||||
collection: 'consent',
|
||||
data: { key: legitKey, text: fixtureConsentText },
|
||||
depth: 0,
|
||||
})
|
||||
legitID = legit.id
|
||||
} finally {
|
||||
await plantLease.release()
|
||||
}
|
||||
|
||||
try {
|
||||
const configured = await ensureLeasedConsent(payload, {
|
||||
consentKey: privateKey,
|
||||
leaseKey: privateLeaseKey,
|
||||
})
|
||||
expect(configured.key).toBe(privateKey)
|
||||
|
||||
for (const orphanID of orphanIDs) {
|
||||
const orphan = await payload.find({
|
||||
collection: 'consent',
|
||||
where: { id: { equals: orphanID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
})
|
||||
expect(orphan.docs).toHaveLength(0)
|
||||
}
|
||||
expect(
|
||||
await payload.findByID({ collection: 'consent', id: legitID, depth: 0 }),
|
||||
).toMatchObject({ key: legitKey })
|
||||
} finally {
|
||||
// Cleanup runs even on failure — a failed run of this very test must
|
||||
// not leave the orphans it planted behind (self-healing tests).
|
||||
for (const orphanID of orphanIDs) {
|
||||
await payload
|
||||
.delete({ collection: 'consent', id: orphanID, depth: 0 })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
if (legitID > 0) {
|
||||
await payload
|
||||
.delete({ collection: 'consent', id: legitID, depth: 0 })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('purges orphaned renames during the missing-consent fixture setup', async () => {
|
||||
const privateKey = `purge-missing-${Date.now()}-${Math.random()}`
|
||||
const privateLeaseKey = `purge-missing-lease-${Date.now()}-${Math.random()}`
|
||||
// Planted under the exclusive lease (the rename keys are live inside
|
||||
// other spec files' fixtures — an unleased create could hit their UNIQUE
|
||||
// constraint mid-rename). The lease start also purges any leftover from
|
||||
// a previous failed run of this very test.
|
||||
const plantLease = await acquireTestDatabaseLease(payload, privateLeaseKey)
|
||||
let orphanID = 0
|
||||
try {
|
||||
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
|
||||
orphanID = (
|
||||
await payload.create({
|
||||
collection: 'consent',
|
||||
data: { key: 'chave-temporariamente-ausente', text: fixtureConsentText },
|
||||
depth: 0,
|
||||
})
|
||||
).id
|
||||
} finally {
|
||||
await plantLease.release()
|
||||
}
|
||||
|
||||
await withMissingInviteConsentFixture(payload, async () => undefined, {
|
||||
consentKey: privateKey,
|
||||
leaseKey: privateLeaseKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const orphan = await payload.find({
|
||||
collection: 'consent',
|
||||
where: { id: { equals: orphanID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
})
|
||||
expect(orphan.docs).toHaveLength(0)
|
||||
} finally {
|
||||
// A failed run of this very test must not leave its plant behind.
|
||||
await payload.delete({ collection: 'consent', id: orphanID, depth: 0 }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it('self-heals the canonical invite consent after an aborted rename on the next fixture run', async () => {
|
||||
// The exact residue an aborted run leaves: the canonical row committed
|
||||
// with a temporary key and the snapshot lost. The plant holds the
|
||||
// exclusive lease so parallel spec files never observe the state; the
|
||||
// fixture's own purge then heals it — the in-fixture rename must succeed
|
||||
// instead of hitting the UNIQUE `consent_key_idx`. The lease start also
|
||||
// purges any leftover from a previous failed run of this very test.
|
||||
const plantLease = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY)
|
||||
try {
|
||||
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
|
||||
const canonical = await payload.find({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: 'lideranca-autopreenchimento' } },
|
||||
limit: 1,
|
||||
sort: 'id',
|
||||
depth: 0,
|
||||
})
|
||||
if (!canonical.docs[0]) {
|
||||
throw new Error(
|
||||
'The canonical invite consent row must exist before the aborted-rename repro.',
|
||||
)
|
||||
}
|
||||
await payload.update({
|
||||
collection: 'consent',
|
||||
id: canonical.docs[0].id,
|
||||
data: { key: 'chave-renomeada' },
|
||||
depth: 0,
|
||||
})
|
||||
} finally {
|
||||
await plantLease.release()
|
||||
}
|
||||
|
||||
let renamedDuringFixture = false
|
||||
await withMutableConsentFixture(payload, async (consent) => {
|
||||
// Without the F1 purge this rename throws "Valor deve ser único".
|
||||
await payload.update({
|
||||
collection: 'consent',
|
||||
id: consent.id,
|
||||
data: { key: 'chave-renomeada' },
|
||||
depth: 0,
|
||||
})
|
||||
renamedDuringFixture = true
|
||||
})
|
||||
expect(renamedDuringFixture).toBe(true)
|
||||
|
||||
// Second run proves the run following the healed one stays clean.
|
||||
await withMutableConsentFixture(payload, async () => undefined)
|
||||
|
||||
const orphans = await payload.find({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: 'chave-renomeada' } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
})
|
||||
expect(orphans.totalDocs).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue