From a54d787be63a53dff43c31fcb1e1963fd4adf3a7 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 17:32:52 -0300 Subject: [PATCH] =?UTF-8?q?fix(social-feed):=20deadline=20pr=C3=B3prio=20d?= =?UTF-8?q?e=205s=20no=20hook=20do=20save=20de=20credenciais=20encurta=20a?= =?UTF-8?q?=20janela=20de=20row=20lock=20(Closes=20#762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-08-24-s11-followup.md | 1 + .../s11-followup-lock-sync-render-impl.md | 139 ++++++++++++++++++ src/globals/SocialFeedSettings.ts | 9 +- src/utilities/socialFeed/instagramSync.ts | 14 +- tests/unit/instagramSync.unit.spec.ts | 11 ++ 5 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/2026-08-24-s11-followup.md create mode 100644 docs/plans/s11-followup-lock-sync-render-impl.md diff --git a/docs/changelog/2026-08-24-s11-followup.md b/docs/changelog/2026-08-24-s11-followup.md new file mode 100644 index 00000000..7cf2f90a --- /dev/null +++ b/docs/changelog/2026-08-24-s11-followup.md @@ -0,0 +1 @@ +**S11-FOLLOWUP — janela de lock do save de credenciais do Instagram encurtada (2026-08-24):** o `afterChange` da global `social-feed-settings` roda DENTRO da transação do save (a row lock da `social_feed_settings` fica presa até o commit), e o sync awaited levava até `INSTAGRAM_SYNC_TIMEOUT_MS` (10s) de fetch da Graph API dentro dessa janela — acoplando a latência do persist do render ISR da home (que bloqueia no lock) à duração do sync. Correção cirúrgica: deadline próprio do hook, `INSTAGRAM_SYNC_HOOK_TIMEOUT_MS = 5_000`, usado só no `afterChange`; o botão de retry mantém 10s (sem transação aberta, seu fetch não segura lock e precisa da folga para refresh+retry). Janela de lock do save cai de ≤10s para ≤5s; aceite do S11 intacto (hook continua aguardado, persist tx-bound, reload mostra o status final com as credenciais novas, fail-closed preservado); zero mudança de UI/e2e. Candidatos alternativos da Issue rejeitados com razão verificada no source do Payload: "fetch antes do persist transacional" é inviável no hook (a transação/lock já estão abertos quando o afterChange roda — `db.updateGlobal` → hooks → commit) e o persist pós-commit (fire-and-forget) regride o aceite (reload mostraria a falha do token antigo). Plano: `docs/plans/s11-followup-lock-sync-render-impl.md`. Sem migration, sem schema. diff --git a/docs/plans/s11-followup-lock-sync-render-impl.md b/docs/plans/s11-followup-lock-sync-render-impl.md new file mode 100644 index 00000000..4223306f --- /dev/null +++ b/docs/plans/s11-followup-lock-sync-render-impl.md @@ -0,0 +1,139 @@ +# Impl: S11-FOLLOWUP — janela de lock/sync de render acoplada à duração do sync de credenciais do Instagram + +Status: rascunho +Atualizado em: 2026-08-24 +Issue: #762 +Intenção: body da Issue (sem plano de intenção linkado — body é a spec) +Appetite restante: herdado (P2, correção cirúrgica — 1 constante + 1 call site; sem migration) + +## Leitura da intenção + +- **Outcome:** dissociar a janela de row lock do `social_feed_settings` — e a latência + acoplada do render ISR da home, cujo persist bloqueia nesse lock — da duração do fetch + do Instagram no save de credenciais: no caminho do hook, a janela cai de ≤10s para ≤5s, + com o aceite do S11 intacto. +- **O que NÃO negociar:** o aceite do S11 — hook aguardado dentro da transação do save + (reload mostra o status final com as credenciais novas) e persist tx-bound (sem deadlock); + fail-closed do fluxo (status IS o resultado; falha nunca quebra o save); contratos públicos + (rota de retry, panel, URLs) intocados. +- **O que reavaliar:** a constante compartilhada `INSTAGRAM_SYNC_TIMEOUT_MS` — verificado + que só o caminho do hook segura row lock; o botão de retry (`route.ts`) não abre transação, + seu fetch não segura lock. Reordenar os dois hooks não ajuda (ver abaixo, timing verificado). + +## Abordagem recomendada + +```mermaid +flowchart LR + S[Save da global
social-feed-settings] --> T[db.updateGlobal
transação + row lock] + T --> H[afterChange aguardado
revalidateTag + sync com deadline do hook 5s] + H --> C[commitTransaction
libera o lock] + C --> R[Render da home ISR
persist via pool — sem bloqueio] +``` + +**Opções consideradas:** + +- **A — deadline específico do hook (recomendada):** `INSTAGRAM_SYNC_HOOK_TIMEOUT_MS = 5_000` + ao lado de `INSTAGRAM_SYNC_TIMEOUT_MS`, usado só pelo `afterChange`. +- **B — fetch antes do persist transacional:** reestruturar para buscar antes de persistir. +- **C — persist pós-commit (fire-and-forget) via `onPayloadTransactionCommit`** (`src/utilities/payloadTransaction.ts:37-48`, seam já usada por `createCampaignNotification`). +- **D — reordenar hooks (sync primeiro, depois `revalidateTag`).** +- **E — reduzir a constante compartilhada para 5s globalmente.** + +**Recomendação: A** — cirúrgica e única que encurta a janela sem tocar no aceite. Efeito: o +lock do save durante uma troca de credenciais cai de ≤10s para ≤5s; o reload continua +mostrando o status final (hook continua aguardado e tx-bound); zero mudança de UI; e2e +intocado (o stub responde instantaneamente — o deadline de 5s é invisível para +`tests/e2e/admin.e2e.spec.ts:134`). + +**Rejeitadas (com a razão verificada):** + +- **B** — infeasível no caminho do hook: verificado no source do Payload + (`node_modules/payload/dist/globals/operations/update.js`), a ordem é `db.updateGlobal` + (adquire o row lock) → afterRead → afterChange (aguardados) → `commitTransaction` + (libera o lock). A transação/lock **já estão abertos quando o hook roda** — o fetch + "antes dos persists" já é como `syncInstagramFeed` está estruturado; não encurta a janela. +- **C** — remove o acoplamento por completo, mas regride o aceite do S11: o reload do admin + após o save mostraria o status ANTIGO (a falha do token anterior) até o sync em background + chegar — o `InstagramSyncStatusPanel` lê o valor persistido do form e não tem auto-refresh. + A mitigação (marcador "sincronizando" + estado do panel + polling + rework de e2e) é + desproporcional à raridade do trigger (saves de credencial são raros; janela limitada a + segundos; sem deadlock; público recebe stale-while-revalidate, nunca bloqueio). +- **D** — neutro-a-pior: o fetch do render já roda concorrente ao sync hoje; atrasar o bust + do tag atrasa o início do render sem encurtar a espera dele no lock (análise de timing). +- **E** — encolheria a folga do retry do BOTÃO (refresh + retry = até 5 round trips + sequenciais do Graph API) sem benefício nenhum — o botão não segura lock. + +### Componentes / mudanças + +- **`INSTAGRAM_SYNC_HOOK_TIMEOUT_MS`** (`src/utilities/socialFeed/instagramSync.ts`): nova + constante `5_000` ao lado de `INSTAGRAM_SYNC_TIMEOUT_MS`, com comentário explicando o + acoplamento — o hook roda dentro da transação do save e segura o row lock durante o fetch; + o deadline do hook limita essa janela. O botão (`route.ts`) mantém a constante de 10s + (sem transação aberta, precisa da folga para refresh+retry). +- **`syncInstagramAfterChange`** (`src/globals/SocialFeedSettings.ts:36`): trocar + `AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS)` por `INSTAGRAM_SYNC_HOOK_TIMEOUT_MS` + (1 linha + import + ajuste do comentário do JSDoc). +- **Migration:** sem migration. **Access / Consent:** sem mudança. **UI:** zero mudança. + +## Fases verificáveis + +1. **Constante + hook** — adicionar `INSTAGRAM_SYNC_HOOK_TIMEOUT_MS` em `instagramSync.ts` + (com o comentário do acoplamento de row lock); trocar o `AbortSignal.timeout` do hook em + `SocialFeedSettings.ts`. `route.ts` intocado. +2. **Teste** — em `tests/unit/instagramSync.unit.spec.ts` (convenções do arquivo: testa + `syncInstagramFeed` direto com fetch stubado e payload fake; nunca importa o config global + — o hook vive no módulo do global, então o teste fica na seam de constantes): um teste + assertando `INSTAGRAM_SYNC_HOOK_TIMEOUT_MS < INSTAGRAM_SYNC_TIMEOUT_MS` (e o valor 5_000), + pinando o invariante de que a janela de lock do hook fica estritamente abaixo do deadline + do botão. Teste mais barato e honesto — 1 arquivo, 1 import, sem helper novo. +3. **Docs + gates** — entrada curta em `docs/changelog/2026-08-24-s11-followup.md`; + `pnpm gate:fast`; entrega via `pnpm push` (CI roda unit + int + e2e + lint + typecheck + + knip + cycles + prettier). O e2e existente (`admin.e2e.spec.ts:134`) continua cobrindo o + aceite do S11 sem mudança. + +## Rabbit holes / Não escopo (engenharia) + +- Mitigação do candidato C (marcador "sincronizando" + estado do panel + polling): fora de + escopo. Gatilho registrado: se o produto reportar que o status antigo após troca de + credencial é um problema real, é Issue própria com aquele desenho. +- Render path (`getInstagramFeed`) sem deadline no fetch do ISR: pré-existente e fora do + escopo (a Issue é o caminho do save; o render não segura row lock). Gatilho: flakiness + real da Graph API em prod segurando a home stale → fix de 1 linha (signal no render). +- Débito registrado na triage: hooks do sync do Google Calendar seguram row lock por I/O + de rede (mesmo anti-padrão) → Issue #870 (C114-LOCK, depends #762). +- Reduzir a constante compartilhada globalmente (E): não — ver rejeitadas. +- Reordenar hooks (D), mexer no render path (`getInstagramFeed`/`instagramFeedView.ts`), + no panel ou na rota de retry: não escopo. +- Nenhum helper novo para o teste (1 assert não justifica camada). + +## Riscos e mitigação + +- **Graph API mais lenta que 5s:** o sync falha com timeout e o status persistido mostra + "demorou demais" (mapeamento existente de `AbortError` em `describeInstagramError`) — + benigno: o panel reflete o estado real e o botão de retry mantém 10s de folga para a + tentativa manual. A janela de lock no save fica ≤5s mesmo assim. +- **Degradação do aceite S11:** nenhuma — hook continua aguardado, persist tx-bound, reload + mostra o status final; fail-closed preservado. +- **Sem migration / sem schema / sem access:** invariantes do repo intocados. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (janela de lock do save ≤5s; reload mostra + status final; deadlock-free preservado) +- [ ] Invariantes AGENTS/engineering-standards (sem migration, sem access, sem Consent; + sem mudança de contrato público) +- [ ] Testes de domínio previstos (unit na seam de constantes; e2e existente cobre o aceite) + +## Self-score decision-quality (gate ≥4) + +**5/5** + +1. Decisões caras têm rejeitadas? Sim — B/C/D/E registrados com razões verificadas no + source do Payload (`update.js`) e por análise de timing. +2. Abordagem cabe no appetite? Sim — 1 constante + 1 linha no hook + 1 teste; sem + migration/UI/e2e. +3. Rabbit holes nomeados? Sim — mitigação do C, redução global, reorder, render path. +4. Depth check? Reusa o seam existente (`syncInstagramFeed` + `AbortSignal.timeout`); sem + módulo novo; teste na seam de constantes (unit specs já evitam o config global). +5. Intenção (aceite S11) permanece satisfeita? Sim — a engenharia encurta a janela, não + reescreve o outcome. diff --git a/src/globals/SocialFeedSettings.ts b/src/globals/SocialFeedSettings.ts index 663c52fa..0ec0a4fc 100644 --- a/src/globals/SocialFeedSettings.ts +++ b/src/globals/SocialFeedSettings.ts @@ -1,7 +1,7 @@ import { payloadAdminOnly } from '@/utilities/campaignAccess' import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest' import { - INSTAGRAM_SYNC_TIMEOUT_MS, + INSTAGRAM_SYNC_HOOK_TIMEOUT_MS, instagramCredentialsChanged, syncInstagramFeed, } from '@/utilities/socialFeed/instagramSync' @@ -23,6 +23,11 @@ const revalidateFeed = async () => revalidateTag(REVALIDATE_SOCIAL_FEED_TAG) * failure is swallowed — the sync's own status IS the result, and a broken * sync must never break the save. * + * The hook runs inside the save's transaction, so its deadline is the + * shorter hook-specific one (S11-FOLLOWUP): the fetch must not stretch the + * row lock the render path's persists block on (the retry button has no + * transaction and keeps the longer deadline). + * * The sync modules must not import `@payload-config` (they do not), so the * static import here closes no cycle: `payload.config → this global → * instagramSync → instagramFeed` ends at `instagramFeed`, which is pure. @@ -33,7 +38,7 @@ const syncInstagramAfterChange: GlobalAfterChangeHook = async ({ doc, previousDo // `req` lets the sync write inside this very transaction (same row lock // held by the save) instead of blocking a second pool connection. await syncInstagramFeed(req.payload, { - signal: AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS), + signal: AbortSignal.timeout(INSTAGRAM_SYNC_HOOK_TIMEOUT_MS), req, }) } catch { diff --git a/src/utilities/socialFeed/instagramSync.ts b/src/utilities/socialFeed/instagramSync.ts index a319dd42..8816e717 100644 --- a/src/utilities/socialFeed/instagramSync.ts +++ b/src/utilities/socialFeed/instagramSync.ts @@ -17,10 +17,20 @@ import { } from '@/utilities/socialFeed/instagramFeed' import type { Payload, PayloadRequest } from 'payload' -/** Deadline for a sync triggered by the admin (hook or button) — a hanging - * Graph API must never stall a global save. */ +/** Deadline for the admin retry button (`POST /api/social-feed/sync`) — a + * hanging Graph API must never stall the request. The hook uses the shorter + * `INSTAGRAM_SYNC_HOOK_TIMEOUT_MS` (its fetch runs inside the save's + * transaction). */ export const INSTAGRAM_SYNC_TIMEOUT_MS = 10_000 +/** Deadline for the hook-triggered sync only (S11-FOLLOWUP): the afterChange + * hook runs INSIDE the save's transaction, holding the `social_feed_settings` + * row lock for its whole duration — the fetch must not stretch that lock + * window beyond ~5s. The retry button keeps the 10s headroom (no transaction + * is open there, so its fetch holds no lock and needs room for the refresh + + * retry round trips). */ +export const INSTAGRAM_SYNC_HOOK_TIMEOUT_MS = 5_000 + export type InstagramSyncOutcome = { ok: boolean status: InstagramSyncStatus diff --git a/tests/unit/instagramSync.unit.spec.ts b/tests/unit/instagramSync.unit.spec.ts index 3b505695..a9e96061 100644 --- a/tests/unit/instagramSync.unit.spec.ts +++ b/tests/unit/instagramSync.unit.spec.ts @@ -9,6 +9,8 @@ import { successInstagramSyncStatus, } from '@/utilities/socialFeed/instagramFeed' import { + INSTAGRAM_SYNC_HOOK_TIMEOUT_MS, + INSTAGRAM_SYNC_TIMEOUT_MS, instagramCredentialsChanged, syncInstagramFeed, } from '@/utilities/socialFeed/instagramSync' @@ -38,6 +40,15 @@ afterEach(() => { vi.unstubAllGlobals() }) +describe('sync deadlines', () => { + it('keeps the hook deadline strictly below the button deadline (S11-FOLLOWUP lock window)', () => { + // The hook fetch runs inside the save's transaction — its deadline bounds + // the row lock window; the retry button holds no transaction. + expect(INSTAGRAM_SYNC_HOOK_TIMEOUT_MS).toBe(5_000) + expect(INSTAGRAM_SYNC_HOOK_TIMEOUT_MS).toBeLessThan(INSTAGRAM_SYNC_TIMEOUT_MS) + }) +}) + describe('successInstagramSyncStatus / failedInstagramSyncStatus', () => { it('builds a success status with now and the raw post count', () => { const status = successInstagramSyncStatus(4)