Lifecycle Manager Worker
The Lifecycle Manager is the project's dual-cadence housekeeping worker. It covers two unrelated problems behind one deployment:
- Daily lane (04:00 UTC) — pause-expiry reactivation, hard-deletion of
pending_deletionpodcasts, R2 cleanup, Stripe billing resumption, the Epic 13 storage reconciler (safe subset: server-measured audio sizes, aged orphan sweep, invalidation-outbox drain), and the hosted-content overage escalation sweep (downgrade-overages §7 — arm/nag/suspend/restore, never delete). A secret-guardedPOST /reconcileendpoint exposes the reconciler's destructive one-time backfills on demand. - Fast lane (every minute) — three independent self-healing sweeps that run in parallel:
sweepStalledImports— re-dispatches lostpodcast-importsqueue messages.sweepStalledExternalLinks— re-enqueues lostexternal-episode-linkqueue messages (Epic 11).sweepStuckHostingMigrationPreviews— cancels hosting-migration rows wedged in thepreviewstaging window (Epic 11).
The two cadences share one worker because each fast-lane sweep is cheap (single indexed query + bounded queue.send() calls) and keeping them in the same deploy avoids introducing more workers just for self-healing.
Overview
| Property | Value |
|---|---|
| Worker Name | podcasterplus-lifecycle-manager |
| Source Location | /workers/lifecycle-manager/ |
| Triggers | Cron: 0 4 * * * (daily lane) and * * * * * (fast lane) |
| Database | Supabase PostgreSQL via Hyperdrive |
| Storage | R2 (MEDIA_BUCKET) for cleanup + storage reconciliation |
| Queue producers | rss-invalidation, podcast-imports, external-episode-link, public-api-invalidation |
| HTTP endpoints | GET /health, GET /_health, POST /reconcile (secret-guarded — A.5 storage reconciler) |
The scheduled() handler dispatches by event.cron: * * * * * runs the three fast-lane sweeps in parallel (via Promise.all — a failing sweep does not block the others) and returns; anything else runs the daily lane.
Responsibilities
Daily lane
1. Auto-Reactivate Expired Pauses
Finds podcasts where status = 'paused' and pause_expires_at <= NOW(), then:
- Sets
status = 'active', clearspaused_atandpause_expires_at - Records
last_pause_ended_at = NOW()(starts the 12-month cooldown) - Resumes Stripe billing for the podcast owner
SELECT id, slug, title, pause_expires_at
FROM podcasts
WHERE status = 'paused'
AND pause_expires_at <= NOW()2. Hard-Delete Expired Podcasts
Finds podcasts where status = 'pending_deletion' and deletion_scheduled_at <= NOW(), then:
DELETE FROM podcasts WHERE id = $id(CASCADE handles all related tables)- R2 cleanup: Deletes all objects under
podcasts/{podcastId}/ - RSS cache invalidation: Sends message to
rss-invalidationqueue (or HTTP fallback)
SELECT id, slug, title, deletion_scheduled_at, deletion_redirect_url
FROM podcasts
WHERE status = 'pending_deletion'
AND deletion_scheduled_at <= NOW()3. Reap Unconfirmed Signups
Email confirmation is on, so an auth.users row without email_confirmed_at can never sign in, and since migration 20260906200000 it owns nothing (no profile, billing account, guest links or CRM contact). The lane calls public.reap_unconfirmed_users(interval) (src/reap-unconfirmed-users.ts, retention UNCONFIRMED_USER_RETENTION_DAYS = 7), which de-provisions and deletes every unconfirmed, non-invited user older than the retention. A row still held by a NO ACTION foreign key is skipped with a Postgres WARNING and counted in skipped; the lane logs unconfirmed_users_reaped with deleted, skipped and retention_days, and a failure logs unconfirmed_users_reap_failed without blocking the rest of the lane. Seven days is generous on purpose: the confirmation code lives 15 minutes, and a person who comes back later signs up again. Runbook: Bot protection.
Billing Resumption (Self-Healing)
When a paused podcast is auto-reactivated, the worker resumes Stripe billing for the owner. The resumeOwnerBillingAfterReactivation() function:
- Looks up the podcast owner via
podcast_members+user_profiles - Skips non-subscription billing models (
enterprise_manual,ltd) - Self-healing: If
stripe_subscription_idis not cached, resolves it from Stripe viastripe_customer_idand caches it - Clears
pause_collectionon the Stripe subscription
Worker uses raw Stripe REST API
Unlike the main app (which uses the Stripe SDK), this worker calls the Stripe REST API directly via fetch(). This avoids the SDK dependency in the Worker bundle.
R2 Cleanup
Deleted podcast storage is cleaned up by listing and deleting all objects under the podcast's R2 prefix:
podcasts/{podcastId}/images/ → cover, header, banner images
podcasts/{podcastId}/e/ → episode audio filesCleanup is best-effort: R2 failures are logged but do not prevent the database deletion.
RSS Cache Invalidation
After a hard delete, the RSS cache must be invalidated so the feed returns 404 immediately. The worker uses a two-tier strategy:
- Queue (preferred): Send
{ type: 'podcast.deleted', podcast_id, podcast_slug }torss-invalidationqueue - HTTP fallback: POST to the feed worker's
/_internal/invalidatewith the RSS invalidation secret; canonical addressing is{ id: podcastId }(Epic 17 —slugstays accepted as a transition alias, and an unresolvable slug triggers legacy-key cleanup)
Storage Reconciliation (Epic 13 A.5)
The daily lane also runs the storage reconciler — folded in from the former standalone podcasterplus-storage-reconciler worker (it shared this worker's daily 0 4 * * * cron and exact bindings — HYPERDRIVE, MEDIA_BUCKET, RSS_INVALIDATION_SECRET, PUBLIC_FEED_URL — so a second deployment, secret set, and cron added cost with no isolation benefit). The engine lives in workers/lifecycle-manager/src/reconcile.ts (+ pure helpers in reconcile-core.ts); the daily lane calls runReconcile(env, SCHEDULED_OPTIONS).
It reconciles R2 audio objects against the episodes audio metadata. It exists because production was exercised against the pre-hardening upload path (Workstream A): audio byte sizes were client-declared (so audio_file_size_bytes can be wrong/NULL), and the old removeAudio cleared the DB row but left the R2 object behind (leaked orphans). It is also the runtime garbage collector for superseded audio: the upload finalize and the removeAudio path deliberately retain a replaced/removed object for a cache-exposed (published/archived) episode instead of deleting it inline — so a stale edge/cache copy never 404s — and the orphan sweep (age-guarded ≥24h + a final DB-reference check) reclaims it later.
Four jobs
Size backfill — for every episode with
audio_url, derive the R2 key andHEADit. If the object exists andaudio_file_size_bytesisNULL/0or disagrees with the real size, record the measured size (apply: write it). A write on a published episode changes the feed's<enclosure length>, so it also records afeed_invalidation_outboxintent (reasonsize_backfill, drained by job 3) — the snapshot refreshes the same run instead of the nightly DR check having to heal it.The comparison MUST coerce (
sizeNeedsWriteinreconcile-core.ts)audio_file_size_bytesis a Postgres BIGINT, and thepostgresclient returns int8 as a string (OID 20 has no number parser). The original inlinerecorded !== actualcompared string to number, was true for every row, and rewrote every audio-bearing episode every day with its existing value — 149 rows across 10 podcasts, bumpingepisodes.updated_at, the parent podcast (touch trigger) and<lastBuildDate>— which kept the nightlydr-reconcilefeed-freshness check red for every podcast without organic feed traffic (11 of 12 nights to 2026-08-20). The comparison now lives insizeNeedsWrite()(unit-tested, coerces withNumber(...)likeoverage-escalation.tsalways did). When reading BIGINT/NUMERIC columns through thepostgresclient, coerce at the boundary and type the row field asstring | null, nevernumber.Missing objects — episodes whose
audio_urlobject no longer exists. Always reported; only quarantined withquarantineMissing. Quarantine is a COMPLETE transition in a SINGLE TRANSACTION: it locks the row and reads its current status (never the scan-time status — a draft can go live between scan and apply), then clears the audio columns AND — if the episode ispublished/scheduled— demotes it todraft, clearspublished_at/scheduled_for, and cancels its pending time-based automation jobs. A failure rolls everything back so the next run rediscovers the row. For a published demotion, the same transaction records a durable intent infeed_invalidation_outbox.Invalidation-outbox drain — runs on every apply run (independent of
quarantineMissing). Delivers undeliveredfeed_invalidation_outboxrows via the synchronous, confirmable HTTP path (POST {PUBLIC_FEED_URL}/_internal/invalidate— returns 2xx only after the cache is busted), markingdelivered_atonly on a confirmed 2xx / else bumpingattempts+last_errorand leaving the row pending. Therss-invalidationqueue is deliberately not used here: queue acceptance ≠ invalidation, and that consumer has no DLQ, so a dropped message would be lost while the outbox had already marked it delivered (round-6 finding 2).Orphan sweep — R2 audio objects no episode references (old
removeAudioleaks + retained superseded objects, plus post-normalise superseded sources). The referenced set covers both audio columns —audio_url(enclosure) andoriginal_audio_url(the media plane's retained master) — and the final pre-delete re-check matches either; without the union every FLAC master would be swept 24h after normalisation (media plane §6.3). Reported always; deleted in apply mode unlessskipOrphanDelete, only when older thanminOrphanAgeHours, and only after a final per-candidate DB re-check immediately before the delete. A companion episode-image sweep (job 3b, same age guard + final re-check againstepisodes.cover_image_url) reclaims per-episode cover-art objects (podcasts/{p}/e/{e}/images/…) as the backstop for the inline cleanups in the episode page'sremoveCoverImage/deleteactions; it skips the pending-invalidation protection since a stale cached feed pointing at a deleted image only degrades to the channel artwork. Podcast-level images (podcasts/{p}/images/…) are singletons overwritten in place and are never swept.
Plus an over-cap report — per billing account, SUM(audio_file_size_bytes + original_audio_file_size_bytes) (count-both: enclosure + master, mirroring the storage cap trigger — media plane §6.4) vs effective_cap(account, 'storage_gb_per_account') (NULL = unlimited). Report only — it will never delete audio to force an account under cap. The report survives unchanged, but the same position now also drives the overage escalation sweep below ("Job 4's graduation").
Two media-plane legs (plan 2026-07-18-media-processing-plane.md):
- Prep-artifact TTL sweep (job 3d, daily run) — transcription-prep objects (
podcasts/{p}/e/{e}/transcribe-prep/…) older than 7 days are deleted with no reference check: they are re-derivable on demand (the ai-processor enqueues a fresh prep job when the manifest is gone). - media_jobs watchdog (
sweepWedgedMediaJobs, MINUTE lane) —pending/runningrows older than 3 hours (a dead workflow, or a lost spawn whose failure-flip was itself lost) are flaggedfailed+ notified vianotify_media_job_result; a wedged row would otherwise hold the one-active unique index and block every future job for that episode+type. The sweep runs with the other minute watchdogs — inside the daily reconcile alone, a row wedged just after 04:00 would sit ~24h against a 3h promise; the daily run (job 5) still calls it so the reconcile report carries the flagged ids.
Daily cron (safe subset)
The daily lane runs SCHEDULED_OPTIONS: mode: apply, size backfill on, orphan sweep on (minOrphanAgeHours: 24), quarantineMissing and bypassCapForBackfill OFF. So the cron only ever writes measured sizes, deletes aged reference-checked orphans, and drains any pending invalidation outbox — it never quarantines (nulls) a row or disables the cap trigger. It is isolated in its own catch, so a reconcile failure cannot block the rest of daily maintenance. The run summary is logged as scheduled_reconcile.
Manual endpoint — POST /reconcile (destructive one-time backfills)
The destructive backfills stay manual, behind the worker's secret-guarded HTTP endpoint (handleReconcileRequest). Always dryrun first, review the JSON report, then apply:
# 1) Dry run — reports only, mutates nothing
curl -X POST "https://<lifecycle-manager-host>/reconcile" \
-H "Authorization: Bearer $RECONCILER_SECRET"
# 2) Apply — size backfill + aged orphan deletion (missing-quarantine still opt-in)
curl -X POST "https://<lifecycle-manager-host>/reconcile?mode=apply" \
-H "Authorization: Bearer $RECONCILER_SECRET"A request without a valid Authorization: Bearer <RECONCILER_SECRET> returns 401 (fails closed if the secret is unset, before the options are even parsed). No native R2 S3 credentials are required — it uses the native MEDIA_BUCKET binding, so it is not blocked by the presigned-upload (A-6) deploy blocker.
| Flag | Default | Effect |
|---|---|---|
mode | dryrun | apply to perform mutations. dryrun reports only. |
quarantineMissing | false | (apply) Null audio_url + audio_file_size_bytes for rows whose R2 object is gone (full demotion transition). |
skipOrphanDelete | false | Force the orphan sweep to report-only even in apply mode. |
bypassCapForBackfill | false | (apply) Disable only the storage-cap trigger during the size backfill (caveat below). |
minOrphanAgeHours | 24 | Minimum age before an orphan object may be deleted (clamped 24–720). The 24h floor is a hard guard — it cannot be lowered — so an in-flight upload's object is never swept early. |
The cap-bypass caveat: by default the size backfill runs with the storage-cap trigger active — a row whose true size would push its account over cap raises 23514, which the worker catches and lists under sizeBackfill.blockedOverCap (the run does not fail). With bypassCapForBackfill=true (apply only) the worker wraps the backfill in a transaction that DISABLEs only trigger_enforce_storage_account_cap (re-enabled in a finally; rolled back on error; falls back to the active path with capBypassFallback: true if the Hyperdrive role lacks ALTER privilege). Use only when backfilling already-stored historical audio whose true sizes legitimately exceed a now-stricter cap. The ALTER TABLE takes a brief ACCESS EXCLUSIVE lock on episodes — keep the run small / off-peak.
Guarantees
- Dryrun mutates nothing. Mutations only occur with
mode=apply, and each is independently flag-gated. - Orphan deletion respects the 24h age guard so it cannot race a finalize, plus a final per-candidate DB re-check before each delete.
- Over-cap is report-only. The worker never deletes a user's audio to bring an account under cap.
- It runs as the Hyperdrive/owner role (NULL
auth.uid()), so its size/quarantine writes are exempt from the browser-only audio-column lock (42501), and quarantine (size → NULL) is a shrink that never trips the storage cap.
Hosted-content overage escalation sweep (downgrade-overages)
The daily lane's last job is runOverageEscalation() (workers/lifecycle-manager/src/overage-escalation.ts) — Job 4's graduation from the report-only over-cap report into the 30-day hosted-content state machine (design: docs/planning/plans/2026-07-12-downgrade-overages.md §7). The Stripe webhook's flip-time reconciler (src/api/utils/overage-reconciler.ts) is the other entry point into the same machine; the sweep is its daily correctness backstop (missed webhooks, resource reductions, admin overrides). It is isolated in its own catch, so a failure cannot block the rest of daily maintenance.
What it scans
One query (Job 4's byte sums with podcast-slot counts added alongside, same shape) selects only accounts that can possibly need work:
- over the
podcasts_per_accountorstorage_gb_per_accounteffective cap (escalation occupancy =active/pausedpodcasts only, with no hosting-type filter — externally hosted podcasts count;pending_deletionis deliberately excluded even though it holds a create-time slot, because scheduling the excess feed for deletion is the resolution and counting it would drive an empty-sanction suspension; storage =SUM(episodes.audio_file_size_bytes)over non-pending_deletionpodcasts), or - armed (
billing_accounts.content_overage_since IS NOT NULL), or - stamped (any podcast with
overage_suspended_at IS NOT NULL).
effective_cap() resolves live — overrides and credit pools included — so an admin override is a support rescue lever the sweep honours by construction. Everything else never enters the machine.
Four legs
| Leg | Condition | Action |
|---|---|---|
| 1. Arm | Over either cap, content_overage_since IS NULL | Conditional UPDATE ... WHERE content_overage_since IS NULL sets since = NOW(), deadline = NOW() + 30d, nag_stage = -1. First-detection-wins: an armed clock is never re-armed or extended. Losing the arm race to the webhook reconciler re-reads the clock and continues. |
| 2. Nag | Armed, email stage due | Compute the highest email stage due by elapsed time (stages 0/1/2/3 at days 0/7/21/27, clamped to 3), send stage D's email first, then claim it atomically once the send outcome is final: UPDATE billing_accounts SET content_overage_nag_stage = D, content_overage_last_nag_at = NOW() WHERE ... AND content_overage_nag_stage < D RETURNING. A transient send failure (Resend 429/5xx/network) leaves the stage unclaimed so the next run retries the notice; a permanent fault (unconfigured Resend, no resolvable owner, definite 4xx) advances best-effort so a misconfiguration never stalls the escalation. A cron gap collapses skipped reminders into one email (the latest due) and a gap past day 30 still stops at the stage-3 warning, never at suspend. |
| 3. Suspend | nag_stage = 3 AND content_overage_last_nag_at + 3d <= NOW() | Gated on the final warning having aged its full 3-day notice, so suspension can never fire in the same run as the warning. The sweep stamps the sanction set first (suspension takes effect immediately), invalidates both caches, sends the suspension notice, then flips nag_stage to 4 via a separate atomic claim only when the notice's send outcome is final — a transient notice failure leaves nag_stage = 3 (feeds already stamped) so the next run re-derives, re-stamps, and retries the notice; a user is never suspended with no warning. Accounts already at stage 4 get their sanction set re-derived from live state every run (stamp missing rows, clear rows no longer sanctioned), so partial resolutions converge instead of replaying history. |
| 4. Restore | Armed or stamped, now under both caps | Disarm (clear all four clock columns) + clear every overage_suspended_at stamp, invalidate both caches, send the restored email. Restore clears the stamp and nothing else — never is_active or status, so a podcast the owner switched off themselves stays off. |
Send first, claim after — two guards, never a prior read (§7.2). Both entry points (this sweep and the webhook reconciler) send the due stage's notice, then advance through the same conditional-UPDATE claim. The stable Resend Idempotency-Key is the send-once guard — overlapping entry points may both send, and Resend collapses them to one delivery — while the row-locked conditional UPDATE is the advance-once guard (exactly one caller's claim matches). Claim-then-send would not be loss-free: a send failing after a committed claim would never be retried, suspending a user with no warning. The canonical key is overage:{account}:{since}:{stage}, scoped by content_overage_since so a resolved-then-reopened overage re-sends its fresh stage 0 instead of deduping against the prior episode (src/api/utils/overage-reconciler.ts:267). ⚠ The sweep currently sends overage:{account}:{stage} without the {since} scoping (overage-escalation.ts:363, :409) — until it is aligned, overlapping entry points do not dedupe and a same-day re-armed overage's stage-0 can be dropped. Keep the two entry points' keys in lockstep.
Sanction set
- Storage-byte overage (with or without count overage): all occupying feeds — bytes have no per-feed excess.
- Podcast-count overage only: the excess podcasts, newest-first by
created_at(tiebreakid), keeping the oldestcapoccupying podcasts serving. pending_deletionpodcasts are never selected — the deletion flow owns their feed behaviour.- Suspension is one platform-owned stamp,
podcasts.overage_suspended_at(guard-triggered — a user-JWT write raises42501). The escalation never reads or writesis_activeorstatus; this worker's own pause-expiry auto-reactivation therefore cannot un-suspend a feed, and suspension survives every lifecycle transition. - Suspension frees no slots (the stamp sits outside slot occupancy), so it cannot be used as slot arbitrage.
Cache invalidation (both caches, always)
Every suspend/restore stamp change fans out to both serving caches per podcast:
- RSS feed — the worker's established synchronous HTTP lane (
deliverInvalidation()→POST {PUBLIC_FEED_URL}/_internal/invalidate?wait=1), not therss-invalidationqueue: queue acceptance ≠ invalidation and that consumer has no DLQ. A 2xx confirms the KV entry is gone and the DR feed snapshot refreshed/purged. - Embed player / v1 API — the
PUBLIC_API_INVALIDATION_QUEUEproducer (net-new for this worker), same message shape as RSS invalidation, dropping thepublic-apiworker's KV payload cache.
Failures are collected into the run report's errors — the next run re-derives and retries.
Email delivery (Resend, quota-exempt)
Stage emails (0/7/21/27-day nags, the suspension notice, the restore confirmation) are platform billing mail sent directly via the Resend API (the crm-sync digest pattern) to the billing-account owner — they never consume the account's email/notification quotas and never touch the automation system. Requires the RESEND_API_KEY + RESEND_FROM_EMAIL secrets: unset = emails are skipped with a logged warning (overage_email_skipped) while the state machine still advances — the atomic claims, not the sends, are the correctness guard.
Never deletes
Job 4's invariant survives its graduation: the sweep suspends serving and restores it, but never deletes a podcast, an episode, or audio to bring an account under cap. Resolution is upgrade, admin override, or the user deleting content themselves (detected by the next sweep, which restores).
Fast lane — three sweeps
The minute-cadence cron fires all three sweeps in parallel. They share the worker and the structured import_watchdog_completed summary log, but operate on independent state.
Stalled-Import Watchdog
sweepStalledImports() re-dispatches import_items rows that are stuck at status='pending' (or rescues status='processing' rows whose worker died) under a parent podcast_imports row that is still running. This compensates for a rare Cloudflare Queues failure mode where queue.send() succeeds from the producer's perspective but the message never reaches the consumer — observed on 2026-04-22 on a 69-episode import (67 clean, 2 hung with attempts=0 until manually resumed).
The stale-pending predicate is per-attempt, sized to the executor's backoff() formula plus a 60 s grace. attempts=0 rows use pi.dispatch_end_at (the actual last delaySeconds moment of the dispatch loop) plus a short propagation grace; attempts>0 rows use the per-attempt table below.
Constants (workers/lifecycle-manager/src/index.ts):
| Constant | Value | Purpose |
|---|---|---|
ATTEMPTS_THRESHOLDS_SECONDS[1..4] | 67 / 85 / 160 / 410 seconds | Per-attempt rescue threshold = backoff(attempts) max + 60 s grace. |
STALLED_IMPORT_THRESHOLD_MINUTES | 10 | Safety cap for attempts ≥ 5 (the executor terminalises before reaching this in practice). |
STALLED_IMPORT_LEGACY_FALLBACK_MINUTES | 25 | Used only for rows predating podcast_imports.dispatch_end_at (rolling-upgrade transition). |
STALLED_IMPORT_RESEND_COOLDOWN_SECONDS | 60 | Excludes rows whose updated_at was bumped in the previous minute, preventing duplicate resends. |
STALLED_PROCESSING_THRESHOLD_MINUTES | 10 | How long a processing claim may sit before the worker is assumed dead and the row is reset to pending. |
STALLED_IMPORT_MAX_RESEND | 500 | Upper bound on re-dispatches per tick — cheap runaway protection. |
For each stalled item the worker sends a fresh podcast-imports queue message with the original import_id / import_item_id / podcast_id and bumps updated_at = NOW() so the cooldown clause excludes the row on the next tick. It makes no other state mutation — the executor's claimPendingItem does the atomic status flip when it picks up the message.
Safety properties:
- Idempotent.
claimPendingItemin the executor is an atomicUPDATEfiltered onstatus='pending'. A late-arriving original message ack-skips when it loses the race. - Bounded. Capped at 500 re-dispatches per tick to avoid a degenerate loop if the executor itself is broken.
- No status rollback. The worker only sends queue messages and resets
processingclaims back topending— it never advancesimport_items.statustoward a terminal state, and never touchespodcast_imports.status.
Stalled External-Link Watchdog (Epic 11)
sweepStalledExternalLinks() re-enqueues external-episode-link messages for externally-hosted episodes whose external_link_status='pending' and whose external_link_next_attempt_at is past due. It catches two failure modes:
- Lost initial messages — the publish-handoff producer set
next_attempt_atbut the consumer never ran (queue drop).last_attempt_atis stillNULL, which is whynext_attempt_at(notlast_attempt_at) is the primary predicate. - Lost retry messages — the consumer's self-retry
queue.sendwas dropped.attemptandnext_attempt_atreflect the consumer's last write, so re-enqueuing with the sameattemptpreserves the backoff progression.
The sweep is gated on podcasts.hosting_type = 'external': after a hosting migration cutover flips the row to podcasterplus, any lingering pending external-link rows are stale audit metadata and are ignored.
Constants:
| Constant | Value | Purpose |
|---|---|---|
STALLED_LINK_GRACE_SECONDS | 90 | Grace past next_attempt_at before re-enqueueing (absorbs queue propagation lag). |
STALLED_LINK_RESEND_COOLDOWN_SECONDS | 90 | Pushes next_attempt_at forward by this much on resend so the next sweep skips the row. |
STALLED_LINK_MAX_RESEND | 500 | Upper bound on stalled-link re-dispatches per tick. |
The consumer's UPDATE filter on external_link_status='pending' makes redelivery safe — a still-alive original message and a watchdog resend cannot both complete (the row's status check dedupes the last writer).
If external_link_next_attempt_at IS NULL (schema-drift defence), the sweep still rescues the row and logs external_link_watchdog_null_next_attempt.
Stuck Hosting-Migration Preview Watchdog (Epic 11)
sweepStuckHostingMigrationPreviews() cancels podcast_imports rows that wedged in the preview staging window of the hosting-migration commit endpoint. The commit flow inserts the row as status='preview' with started_at=NULL, dispatches every queue message, then flips to status='running' (started_at=NOW()). If the API request dies between insert and activation (container kill, network drop, worker eviction, timeout), the row stays preview forever and the unique partial index idx_podcast_imports_hosting_migration_active blocks every future commit attempt for that podcast with a 409.
The sweep detects exactly that state — is_hosting_migration = TRUE, status = 'preview', started_at IS NULL, older than the threshold — and flips the row to canceled so the user can retry. Any queue messages dispatched before the API died will see the now-canceled parent and ack-skip (processItem treats both preview and canceled parents as ack-skip signals).
Constants:
| Constant | Value | Purpose |
|---|---|---|
STUCK_HOSTING_MIGRATION_PREVIEW_MINUTES | 15 | Generous enough not to cancel a slow-but-healthy commit; tight enough that same-session retry works. |
STUCK_HOSTING_MIGRATION_PREVIEW_MAX | 50 | Upper bound on cancellations per run. |
The SQL uses FOR UPDATE SKIP LOCKED so concurrent worker invocations cannot double-cancel the same row.
Worker activation (status='preview' → 'running') is filtered on eq('status', 'preview') and inspects the returned row count; if the watchdog has already canceled the row, the commit endpoint returns a clean retry-friendly 409 instead of misleading the user with a 201.
Environment Bindings
wrangler.toml
name = "podcasterplus-lifecycle-manager"
main = "src/index.ts"
compatibility_date = "2024-12-30"
compatibility_flags = ["nodejs_compat"]
# Cron triggers:
# "0 4 * * *" — daily at 04:00 UTC: pause expiry, pending_deletion hard-delete, R2 cleanup
# "* * * * *" — every minute: three parallel watchdog sweeps
[triggers]
crons = ["0 4 * * *", "* * * * *"]
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "a81d477ff9264805989f5a72f0354ee8"
[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "podcasterplus-media"
[[queues.producers]]
queue = "rss-invalidation"
binding = "RSS_INVALIDATION_QUEUE"
# Used by sweepStalledImports to re-dispatch lost messages to the
# podcast-import-executor consumer.
[[queues.producers]]
queue = "podcast-imports"
binding = "PODCAST_IMPORTS_QUEUE"
# Used by sweepStalledExternalLinks (Epic 11) to re-dispatch lost
# external-episode link messages.
[[queues.producers]]
queue = "external-episode-link"
binding = "EXTERNAL_EPISODE_LINK_QUEUE"
# The public-api worker's payload cache (embed player / v1 API). The overage
# escalation fans its suspend/restore invalidations out to BOTH caches: the
# RSS leg via the synchronous HTTP lane, this queue for public-api.
[[queues.producers]]
queue = "public-api-invalidation"
binding = "PUBLIC_API_INVALIDATION_QUEUE"
[vars]
PUBLIC_FEED_URL = "https://feed.podcasterplus.com"
# Public base for R2 objects — the storage reconciler (A.5) derives object keys
# from audio_url against this base.
R2_PUBLIC_URL = "https://media.podcasterplus.com"
# Deep-link base for the overage escalation emails.
PUBLIC_APP_URL = "https://app.podcasterplus.com"
# The daily lane now also lists every R2 audio object + HEADs each audio-bearing
# episode (the reconciler). Match the former standalone worker's CPU budget.
[limits]
cpu_ms = 30000Secrets
Set via wrangler secret put:
| Secret | Purpose |
|---|---|
STRIPE_SECRET_KEY | Resume billing after auto-reactivation |
RSS_INVALIDATION_SECRET | HTTP fallback for RSS cache invalidation; also drains the storage reconciler's feed-invalidation outbox (A.5) and confirms the overage escalation's suspend/restore feed invalidations (wait=1 lane) |
RECONCILER_SECRET | Bearer token required to invoke the storage reconciler's destructive one-time backfills via POST /reconcile (A.5). The safe daily-cron subset does not need it. |
RESEND_API_KEY | Platform billing mail for the overage escalation (nags/suspension/restore). Unset = emails are skipped with a logged warning; the state machine still advances (the atomic claims are the guard). |
RESEND_FROM_EMAIL | From-address for the overage escalation mail (e.g. [email protected]) |
Health Check
The worker also exposes HTTP endpoints:
GET /healthorGET /_health→{ "status": "ok", "service": "lifecycle-manager" }POST /reconcile— secret-guarded storage reconciler (A.5; see Storage Reconciliation). Returns401without a validRECONCILER_SECRETBearer token.
Logging
All events are logged as structured JSON. Every scheduled() invocation emits lifecycle_cron_started with the cron field set, so you can filter by lane in wrangler tail.
Daily lane
| Event | Description |
|---|---|
lifecycle_cron_started | Cron trigger fired (any lane) |
lifecycle_cron_completed | Daily run finished with counts |
lifecycle_cron_failed | Top-level failure |
pause_auto_reactivated | Podcast pause expired, reactivated |
pause_reactivation_failed | Failed to reactivate a specific podcast |
billing_resumed_after_reactivation | Stripe billing resumed |
billing_resume_failed | Stripe billing resume failed |
podcast_hard_deleted | Podcast row deleted |
podcast_deletion_failed | Failed to delete a specific podcast |
r2_cleanup_completed | R2 objects deleted |
r2_cleanup_failed | R2 cleanup error |
cache_invalidation_failed | RSS cache invalidation error |
scheduled_reconcile | Storage reconciler (A.5) finished — sizeWritten, orphansDeleted, overCapAccounts, outboxDrained, errors, durationMs |
scheduled_reconcile_failed | Storage reconciler threw; rest of the daily lane unaffected |
overage_escalation_completed | Overage escalation sweep finished — scanned, armed, emails_sent, suspended_accounts, suspended_podcasts, restored_accounts, restored_podcasts, errors |
overage_escalation_failed | Overage escalation sweep threw; rest of the daily lane unaffected |
overage_escalation_account_failed | One account's escalation processing threw; the sweep continues |
overage_email_skipped | Resend not configured — stage email skipped, state machine advanced anyway |
overage_email_no_owner | No owner email resolvable for a billing account's stage email |
overage_email_failed | Resend API rejected or threw for a stage email |
Fast lane
| Event | Description |
|---|---|
import_watchdog_completed | Sweep finished — combined summary: items_requeued, links_requeued, previews_canceled (each 0 on idle ticks) |
import_watchdog_found_stalled | Per-import summary: import_id, count, oldest_stale_minutes |
import_watchdog_rescued_processing | Reset processing rows back to pending after the worker was assumed dead |
import_watchdog_resend_failed | queue.send() threw for a specific item; sweep continues |
import_watchdog_rescue_resend_failed | Resend of a rescued (processing→pending) item failed |
import_watchdog_misconfigured | PODCAST_IMPORTS_QUEUE binding missing at runtime |
import_watchdog_failed | sweepStalledImports threw; other sweeps unaffected |
external_link_watchdog_resent | Per-tick count of resent external-link messages |
external_link_watchdog_resend_failed | queue.send() threw for a specific episode |
external_link_watchdog_null_next_attempt | Defensive log: pending row had next_attempt_at = NULL (rescued anyway) |
external_link_watchdog_skipped_no_target | Pending row had no published_at or scheduled_for to use as target_published_at |
external_link_watchdog_misconfigured | EXTERNAL_EPISODE_LINK_QUEUE binding missing |
external_link_watchdog_failed | sweepStalledExternalLinks threw; other sweeps unaffected |
hosting_migration_preview_watchdog_canceled | Per-tick count of stuck preview rows flipped to canceled |
hosting_migration_preview_watchdog_failed | sweepStuckHostingMigrationPreviews threw; other sweeps unaffected |
Deployment
cd workers/lifecycle-manager
npx wrangler deploy
# Set secrets
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put RSS_INVALIDATION_SECRET
npx wrangler secret put RECONCILER_SECRET # A.5 storage reconciler (POST /reconcile)
npx wrangler secret put RESEND_API_KEY # overage escalation billing mail
npx wrangler secret put RESEND_FROM_EMAIL # overage escalation from-address
# View logs
npx wrangler tail podcasterplus-lifecycle-managerFailure Isolation
Each podcast is processed independently. If one podcast fails to reactivate or delete, the error is logged and processing continues for the remaining podcasts. Fast-lane sweeps run inside Promise.all with per-sweep catch blocks, so a thrown sweep cannot block the other two.
Related Documentation
- Podcast Lifecycle Guide — End-to-end lifecycle documentation
- External-to-Internal Hosting Migration — Epic 11 end-to-end flow
- Podcast Lifecycle API — API endpoints
- RSS Feed Worker — Feed behavior during pending_deletion
- Podcast Import Executor — Consumer of the
podcast-importsandexternal-episode-linkmessages re-dispatched by the watchdogs - Imports API — Original producer of
podcast-importsqueue messages - External Link API — Producer of
external-episode-linkmessages - Workers Overview — All worker documentation