feat(share-backend): OG image + scheduled deletion worker#360
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two distinct concerns that share the same R2 surface, paired in one PR because they only make sense end-to-end:
ctx.waitUntil()renders a 1200×630 PNG via@vercel/og(Satori-backed) and stores it atr2://OG/<slug>.pngGET /api/og/<slug>.pngserves 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 revokefunctions/_scheduled/deletion-worker.ts)deletion_queuerows whosescheduled_at <= nowandcancelled = 0METAfor every share the user owned, deletes the R2 snapshot + OG bodies, removes the rowsuser_identities(so the provider sub can be re-linked by a future sign-up), releases thehandlesrow, finally soft-deletesusersWhy
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_queueinside the per-user loop. The outer SELECT can race with a user POSTingDELETE /api/me/deleteto 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 --> [*]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 endCorrectness + safety notes
user_identitiesfor the deleted user means a future sign-up with the same Google sub doesn't reincarnate the oldusers.id. They get a brand-new account, which matches the user-facing promise of deletion.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.tsdeclaresdisplay: flexon 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 threefix(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)wrangler dev --test-scheduled; sweep visible in logsRisk
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.