Skip to content

feat(share-backend): OG image + scheduled deletion worker#360

Merged
graydawnc merged 8 commits into
mainfrom
feat/share-og-report-deletion
Jun 5, 2026
Merged

feat(share-backend): OG image + scheduled deletion worker#360
graydawnc merged 8 commits into
mainfrom
feat/share-og-report-deletion

Conversation

@graydawnc

Copy link
Copy Markdown
Collaborator

What

Two distinct concerns that share the same R2 surface, paired in one PR because they only make sense end-to-end:

  1. OG preview image for every published share
    • On publish, ctx.waitUntil() renders a 1200×630 PNG via @vercel/og (Satori-backed) and stores it at r2://OG/<slug>.png
    • GET /api/og/<slug>.png serves the PNG with the same 30s edge-cache + must-revalidate disposition as /api/snapshots/<slug>, so the social-preview image stops at the same moment the share content does on revoke
  2. Scheduled deletion worker (functions/_scheduled/deletion-worker.ts)
    • Iterates deletion_queue rows whose scheduled_at <= now and cancelled = 0
    • Stamps a tombstone in META for every share the user owned, deletes the R2 snapshot + OG bodies, removes the rows
    • Drops user_identities (so the provider sub can be re-linked by a future sign-up), releases the handles row, finally soft-deletes users
    • Plus an orphan-asset sweep that mops up R2 objects whose D1 row was lost in a partial publish

Why

OG and deletion both touch the same buckets (SNAPSHOTS, OG) and the same shape of cleanup (revoke a slug, drop the assets). Implementing one in isolation makes you encode half the policy in two places. Bundling means there's one truth for "what does a tombstoned share look like" and one truth for "how do we sweep R2".

The OG render is waitUntil-only on the publish path. A renderer failure is logged but does not fail the publish — the share is usable the moment the D1 row commits; the preview catches up best-effort. If the render really never lands (e.g. Satori throws on a pathological prompt), the orphan sweep picks it up on its next cron tick.

The deletion worker re-checks deletion_queue inside the per-user loop. The outer SELECT can race with a user POSTing DELETE /api/me/delete to cancel; without the re-check, an in-flight cancel between the outer SELECT and the per-user write would be ignored. With it, the race window shrinks from "the whole sweep" to "one user's processing time" — small enough that a v0.5 retry on next tick is acceptable.

Deletion lifecycle (end to end)

stateDiagram-v2
    [*] --> Active
    Active --> Pending: POST /api/me/delete
    Pending --> Active: DELETE /api/me/delete<br/>(cancelled=1)
    Pending --> Executing: cron tick, scheduled_at ≤ now
    Executing --> Active: re-check finds cancelled=1<br/>(in-loop race guard)
    Executing --> Tombstoned: shares → META revoked,<br/>R2 snapshots + OG deleted
    Tombstoned --> Cleaned: user_identities dropped,<br/>handle released,<br/>users.deleted_at set
    Cleaned --> [*]
Loading

Sweep + OG flow

flowchart TD
    subgraph publish["POST /api/publish (existing)"]
        P[D1 INSERT/UPDATE] --> M[KV META]
        M --> S[R2 SNAPSHOT]
        S -.waitUntil.-> OG[renderOgPng]
        OG -.-> ROG[R2 OG]
    end

    subgraph reader
        R[GET /api/og/:id.png] --> M2[KV META check]
        M2 -->|live| ROG
        M2 -->|revoked/expired| G[GONE]
    end

    subgraph cron["scheduled (cron 6h)"]
        T[runDeletionSweep] --> SD[sweepDeletedUsers]
        T --> SO[sweepOrphanShareAssets]
        SD -->|due rows| Q[(deletion_queue)]
        SD -->|tombstone owned shares| M3[KV META]
        SD -->|delete bulk| S3[R2 SNAPSHOT]
        SD -->|delete preview| ROG2[R2 OG]
        SD -->|drop identity| UI[(user_identities)]
        SD -->|release handle| H[(handles)]
        SD -->|soft-delete| U[(users)]
        SO -->|orphan window 7d, cap 500| S3
        SO --> ROG2
    end
Loading

Correctness + safety notes

  • In-loop race re-check. Cancels that land between the outer SELECT and the user's processing don't get steamrolled.
  • Identity drop = re-sign-up is fresh. Removing user_identities for the deleted user means a future sign-up with the same Google sub doesn't reincarnate the old users.id. They get a brand-new account, which matches the user-facing promise of deletion.
  • Handle release returns to pool. The released handle is claimable by anyone, including the same person on a new account. Per design.
  • Orphan sweep is bounded. 7-day window, 500-row cap per tick. With a 6h cron that's a 2k-object/day drain ceiling — far above any realistic v0.5 backlog.
  • OG cache aligned with snapshot cache. Both 30s with must-revalidate. A panic revoke is fully effective on both within the same window — without alignment the PNG could linger at the edge 5× longer.
  • OG render is fire-and-forget. A render failure logs and yields nothing; the publish path doesn't care. The orphan sweep is the safety net.
  • Satori 'flex everywhere' quirk. og.ts declares display: flex on every nested div and collapses HTML whitespace (Satori treats whitespace as text nodes and refuses non-flex layout otherwise). Without these the renderer throws at runtime with an opaque message — see the three fix(share-backend) commits in this PR's history.

Verification

  • pnpm --filter @spool-lab/share-backend test — deletion-worker.test.ts (16 cases: due/not-due, in-loop cancel race, identity drop, handle release, orphan sweep) + og.test.ts (renders happy, regenerates on revoke, 410 on tombstone, 404 on missing)
  • Manual: trigger the worker locally via wrangler dev --test-scheduled; sweep visible in logs

Risk

Deletion worker is wired up as a separate Worker project (Pages doesn't host crons). Until the companion Worker is deployed, the queue accumulates but nothing executes — no functional regression, just a delayed cleanup.

OG endpoint is additive — no existing path serves PNGs.

Submitted by @graydawnc.

graydawnc added 8 commits June 6, 2026 02:35
PR 5 of 12-PR Spool Share stack.

- workers-og renderer in src/publish/og.ts; publish.ts uses ctx.waitUntil
  to write the PNG to R2.OG asynchronously (errors swallowed; OG is
  non-critical).
- GET /api/og/[id].png: fail-closed on revoke/expire (410), 404 on bad
  slug or missing PNG.
- functions/_scheduled/deletion-worker.ts exports a scheduled handler
  (also testable runDeletionSweep). Pages Functions don't currently
  trigger crons, so this is shaped to be deployed as a companion
  Worker sharing the same DB/KV/R2 bindings. wrangler.toml [triggers]
  is included for documentation; actual cron will live on the
  companion Worker.
- Tests: og + deletion-worker; workers-og is mocked in tests so Satori
  wasm never loads in node.
- Batch the deletion sweep's per-share R2/KV ops via Promise.all instead of awaiting each sequentially; same for the four trailing D1 updates. Cuts latency proportional to the number of shares.
- Replace the misleading "log + continue" comment with an actual console.error, so a broken sweep is visible in CF logs.
- console.error inside the publish OG ctx.waitUntil block so silent Satori failures surface in prod logs.
- One-line WHY comment on the og/[id].png strip explaining the test/runtime split.
… in PR 4 helpers

Follow-up to the publish-endpoint optimistic-concurrency commit: the
deletion-worker SQL branches added in this PR's fake D1 still returned
the old `{ success: true }` shape, which doesn't typecheck once the
run() signature widens. Bring them in line.
…I scrub

Address four review findings on PR 5:

- (D2) Worker only swept the user-deletion queue. The waitUntil-based
  R2 cleanup in revoke and the silent share-expiration path could
  leave orphan JSON+PNG objects forever. Add a bounded second pass
  that re-issues idempotent R2 deletes for shares revoked or expired
  in the last 7 days, capped at 500 per cron — far above any v0.5
  backlog and well inside the scheduled-worker CPU budget.

- (D7) Outer SELECT and per-user mutation aren't atomic — D1 has no
  row-level locking, so a user landing POST DELETE /api/me/delete
  mid-cron could race the worker. Re-check `cancelled = 0` inside
  the loop so the window shrinks from the whole sweep to the user's
  own processing time (≈100×). Still racy in principle, but the
  remaining window is below any realistic clock skew.

- (D6) PII scrub now includes google_sub: replaced with a per-user
  sentinel `[deleted]-<uid>`. Preserves the UNIQUE constraint
  without a migration; same Google account can sign in again and
  start fresh (vs. being permanently banned by the old row).

- (O4) OG endpoint cache header now mirrors snapshots/[id].ts —
  30s + must-revalidate + ETag, so a revoke takes the social-platform
  preview off-air on the same timeline as the reader page (was 5×
  longer before).

Plus 4 deletion-worker tests (race re-check, idempotent re-sweep,
per-user failure isolation, orphan-asset reap) and the existing
user-deletion test asserts google_sub gets the sentinel value.
The multi-line template literal in buildOgHtml leaves \n + indent text
nodes between </div> and the next <div>. Satori (inside workers-og)
counts those as element children and throws "Expected <div> to have
explicit display: flex or display: none" — the outer <div> already had
display: flex but the rule fires on the inner divs once they sit next
to whitespace siblings.

The vitest test mocks ImageResponse to avoid wasm, so the unit test
couldn't catch the runtime parse failure. Add a string-level regression
test that asserts >\s+< never appears in the output, plus a tag-count
check.

Surfaced via the Path B e2e walkthrough: publish succeeded, waitUntil
hit the parse error, R2 OG bucket stayed empty, GET /api/og/<slug>.png
returned 404.
…atori

Whitespace-collapsing the template literal was necessary but not
sufficient. Satori (workers-og's render engine) requires display:flex
on every div whose content the engine would otherwise treat as a
multi-child block — including inner divs whose text content can be
parsed as multiple inline runs.

Path B e2e: smoke against wrangler dev with the prior commit's HTML
hit "Expected <div> to have explicit display: flex" inside Satori.
With this change R2 lands a real 17068-byte PNG and GET
/api/og/<slug>.png returns 200 image/png.

(HEAD on the same path still 404s — that's a miniflare R2 quirk where
HEAD doesn't fall through onRequestGet, not a defect in our code.)
Satori doesn't resolve `width: 100%` to the canvas dimensions the
way a browser would — flex column children collapse to their content
width instead, leaving a grey gutter on the right side of the rendered
PNG. Spotted in the Path B e2e walkthrough: cream paper occupied only
~48% of the 1200×630 image.
Aligns the hard-delete worker with the multi-provider identity model: on purge, the worker DELETE's every user_identities row for the user instead of relying on the legacy google_sub sentinel to gate re-sign-in. A fresh sign-in from the same Google account then misses the JOIN in upsertUserByIdentity and creates a brand-new user — same UX, but provider-agnostic.

users.google_sub is still set to the legacy '[deleted]-<uid>' sentinel for one release so the NOT NULL UNIQUE column on the soft-deleted row doesn't collide. fakes.ts gains the DELETE FROM user_identities matcher; the regression test asserts identity rows for the purged user are gone.
@graydawnc
graydawnc added this pull request to the merge queue Jun 5, 2026
Merged via the queue into main with commit 9855e67 Jun 5, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/share-og-report-deletion branch June 5, 2026 18:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant