Podcast Import Executor
Queue-consumer Worker that drives two independent flows behind one deployment:
podcast-importsconsumer — per-episode pipeline for both back-catalogue imports (is_hosting_migration = false, create new episodes) and hosting migrations (is_hosting_migration = true, merge audio into existing episodes byexternal_guid, then cut overhosting_typeonce the parent import landscompleted).external-episode-linkconsumer (Epic 11) — lightweight feed-fetch + DB-write loop that resolves a newly-published external episode to its matching RSS<guid>by pubDate proximity (with title-similarity disambiguation), or marks itunmatchedafter exhausting the backoff schedule.
Worker name: podcasterplus-podcast-import-executorTrigger: Queue consumer on podcast-imports AND external-episode-linkSource: workers/podcast-import-executor/
Message routing
src/index.ts discriminates on message.body.type so both consumers share one worker. Cloudflare delivers each queue's messages to this worker; the entry handler dispatches by type:
type | Queue | Handler |
|---|---|---|
import_item | podcast-imports | processItem (back-catalogue or hosting-migration depending on podcast_imports.is_hosting_migration) |
attempt_link | external-episode-link | processLinkAttempt |
Unknown types are logged (unknown_message_type) and ack'd so a future schema mismatch cannot DLQ-flood the worker.
podcast-imports consumer
Every terminal branch calls finaliseIfLastItem, which increments counters and — only when the last outstanding item closes — fires the side effects. Finalisation is wrapped in a swallow-all try/catch so a transient DB or Resend failure never rolls back the item's terminal state.
import_item message schema
interface PodcastImportMessage {
type: 'import_item';
import_id: string; // podcast_imports.id
import_item_id: string; // import_items.id
podcast_id: string;
}Produced by POST /api/imports/podcast, /api/imports/:id/confirm, /:id/resume, /:id/retry-failed, /:id/items/:itemId/retry, and POST /api/podcasts/:id/upgrade-hosting/commit. See Imports API and External Link API.
Hosting-migration merge mode (Epic 11)
When podcast_imports.is_hosting_migration = true, processItem first probes episodes by (podcast_id, external_guid = payload.guid). If a match is found, it branches into processMergeMode:
- Stream the source enclosure into
podcasts/{podcastId}/e/{existingEpisodeId}/audio/migrated.{ext}(R2 key keyed on the existing episode id so per-podcast cleanup still finds it on deletion). - Mirror the source episode artwork into
cover-migrated.{ext}only if the existing episode has nocover_image_url(fill-if-missing). - Call
mergeAudioIntoExistingEpisode(workers/podcast-import-executor/src/db.ts) to:- Unconditionally rewrite
audio_url,audio_content_type,audio_file_size_bytes,audio_duration_seconds,episode_guid,external_rss_pub_date,published_at,is_explicit. - Conditionally rewrite
title/descriptionpermigration_config.use_rss_titles/use_rss_descriptions. - Fill-if-missing:
cover_image_url,episode_number,season_number. show_notesandshow_note_sectionsare never touched.
- Unconditionally rewrite
When the GUID does not match any existing episode, the migration falls through to the standard create path — but the new episode is inserted with external_guid = payload.guid so the migration's bookkeeping is complete and subscriber-facing <guid> continuity is preserved.
Hosting-migration cutover (last-item side effect)
sendSideEffects (called from finaliseIfLastItem when the parent enters a terminal state) handles the cutover:
- Only on
outcome === 'completed'— a partial migration would publish an incomplete feed to subscribers; the user retries failed items, and once they land the parent re-enterscompletedand this block fires again. - Calls
flipHostingTypeToInternal(podcastId)to flippodcasts.hosting_typefromexternaltopodcasterplus. The UPDATE is guarded byWHERE hosting_type = 'external'so it is idempotent on retry. - On cutover failure: flips the import to
failedwith a specificlast_errorso the wizard surfaces the issue, and skips the post-completion notifications (RSS invalidation is moot — hosting is still external). - On cutover success: emits one
rss-invalidationmessage (type: 'import.completed') and one completion email.
Queue configuration — podcast-imports
[[queues.consumers]]
queue = "podcast-imports"
max_batch_size = 1
max_batch_timeout = 5
max_retries = 5
dead_letter_queue = "podcast-imports-dlq"
max_concurrency = 4max_batch_size = 1— each message is a long-running stream (median ~5 s wall-time). Batching would block the whole batch on the slowest download.max_concurrency = 4— keeps the per-host request rate polite for small podcast hosts.max_retries = 5— on transient failure the item goes back on the queue with exponential-backoff jitter; final attempt terminalises.
Retry & terminalisation
The entry handler wraps processItem in a try/catch. Error classification is explicit:
| Error class | Behaviour |
|---|---|
FatalImportError | markItemFailed(reason, detail) inside processItem; finalise. |
RetryableImportError | Throw; entry handler calls message.retry({ delaySeconds }). |
| Unclassified throw | Release the item (resetItemForRetry) and bubble so the queue retries. |
On the final attempt (message.attempts >= 5) any remaining retry is converted to a terminalise call so the row never stays stuck:
// workers/podcast-import-executor/src/index.ts
if (onFinalAttempt || isFatal) {
await terminaliseItem(env, message.body.import_item_id, reason, errMessage);
message.ack();
}Backoff formula:
base = min(900, 5 * 4^(attempts - 1))
jitter = random(0, min(30, base / 4))
delaySeconds = floor(base + jitter)Caps at 900 s (Cloudflare Queues per-message delay limit).
Per-item pipeline (create path)
src/processItem.ts handles one message. Key guarantees:
- Cancel-safe. If the parent import's status is
canceledorpreviewat claim time, the message is ack'd and the item is left atpending. Resume / commit-activation re-dispatches it. - Exactly-once episode insert.
claimedInsertAndCompletere-verifies the processing claim under a row lock before touchingepisodes, so a stale worker whose claim was rescued by the lifecycle-manager watchdog cannot durably create an episode that a fresh worker's terminal write would then orphan. OnON CONFLICT(parallel insert of same GUID), the helper marks the item as a race duplicate and links it to the winningepisode_idin the same transaction. - All assets mirrored. Cover image, chapters JSON, and every
<podcast:transcript>the feed advertises are copied to R2 viamirrorAssetBestEffort(failure is logged and ignored — the episode still inserts). The preferred transcript (VTT > SRT > JSON > first) populatesepisodes.transcript_url. - Status from
pubDate. Future-dated items becomescheduled; past or undated items becomepublished. Slug is derived from title + stable 6-char hash of the GUID.
R2 keys written
Create path:
podcasts/{podcastId}/e/{episodeId}/audio/original.{mp3|m4a|aac|wav|ogg|webm}
podcasts/{podcastId}/e/{episodeId}/cover.{jpg|png|webp|gif}
podcasts/{podcastId}/e/{episodeId}/chapters.json
podcasts/{podcastId}/e/{episodeId}/transcript.{vtt|srt|txt|json}
podcasts/{podcastId}/e/{episodeId}/transcript.1.{…} // 2nd format, 3rd, …Merge path (hosting migration):
podcasts/{podcastId}/e/{existingEpisodeId}/audio/migrated.{mp3|m4a|aac|wav|ogg|webm}
podcasts/{podcastId}/e/{existingEpisodeId}/cover-migrated.{jpg|png|webp|gif} # fill-if-missing onlyBoth paths live under podcasts/{podcastId}/e/{episodeId}/... so the lifecycle-manager's per-podcast R2 cleanup removes migrated assets when a podcast is deleted.
Finalisation side effects
When finaliseIfLastItem detects the last outstanding row, sendSideEffects fires:
- Hosting migration cutover — only when
is_hosting_migrationandoutcome === 'completed'. Flipshosting_typetopodcasterplus; on failure, marks the importfailedwith a specificlast_errorand short-circuits the rest of the block. - RSS invalidation — one message to the
rss-invalidationqueue:typescriptConsumed by the RSS Feed Worker which deletes the KV cache entry for the slug.{ type: 'import.completed', podcast_id: string, podcast_slug: string, timestamp: string } - Completion email via Resend, with per-outcome copy (
completed,partial,failed) including counts of published vs scheduled episodes.
RSS invalidation and email are best-effort — failures are logged and swallowed to protect the item's already-written terminal state.
external-episode-link consumer (Epic 11)
attempt_link message schema
interface ExternalEpisodeLinkMessage {
type: 'attempt_link';
episode_id: string;
podcast_id: string;
target_published_at: string; // ISO 8601
attempt: number; // starts at 0, incremented by consumer on retry
}Producers (see src/lib/external-link/enqueue.ts):
- SvelteKit publish-handoff actions (initial enqueue on
confirmPublished/confirmScheduled). POST /api/episodes/:id/external-link/reattempt(manual re-attempt).workers/lifecycle-manager(stalled-link watchdog re-enqueue).- This worker (consumer self-retry on backoff).
Backoff schedule
Driven by Cloudflare Queues' native delaySeconds; the worker uses a fresh queue.send for self-retries rather than message.retry() so the domain backoff is not constrained by max_retries = 2.
attempt | Delay |
|---|---|
| 0 | 5 minutes |
| 1 | 15 minutes |
| 2 | 1 hour |
| 3 | 6 hours |
| 4 | 24 hours |
| 5+ | (schedule exhausted → mark unmatched) |
Shared between worker and main app via the parallel definitions in workers/podcast-import-executor/src/external-link/types.ts and src/lib/external-link/queue-message.ts.
Disambiguation rules
- ±24 h pubDate window around the user-entered handoff time (
PROXIMITY_WINDOW_MS). Wide enough to absorb host timezone shifts and platforms that backdatepubDateto upload time. - Already-linked GUIDs excluded from the candidate set so an item already linked to another PP episode in the same podcast cannot win.
- Title similarity (Sørensen–Dice bigram,
src/external-link/title-similarity.ts): when >1 candidate falls in the pubDate window, the picker requirestop_score ≥ 0.8ANDtop_score − runner_up ≥ 0.15to declare a unique winner. Otherwise: retry or mark unmatched. - Tenant guard: refuses to act if the message's
podcast_iddoes not match the episode's actualpodcast_id(defence-in-depth against a future producer writing one tenant's RSS identity onto another tenant's row).
Queue configuration — external-episode-link
[[queues.consumers]]
queue = "external-episode-link"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 2
dead_letter_queue = "external-episode-link-dlq"
max_concurrency = 4
[[queues.producers]]
queue = "external-episode-link"
binding = "EXTERNAL_EPISODE_LINK_QUEUE"- Higher batch + concurrency than the import consumer because each message is a single feed fetch + single DB write, not a long-running audio stream.
max_retries = 2keeps Cloudflare's automatic redelivery short — the domain backoff (5m → 24h) lives entirely in the consumer.
DB writes
The consumer writes via direct Postgres through Hyperdrive (not PostgREST), so auth.role() is NULL. The episode-update trigger added in 20260508093849_harden_external_link_authorization.sql bypasses on auth.role() = 'service_role' OR auth.role() IS NULL, leaving worker writes unblocked while PostgREST anon callers still hit the has_podcast_role gate.
Bindings
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "a81d477ff9264805989f5a72f0354ee8"
[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "podcasterplus-media"
[[queues.producers]]
queue = "rss-invalidation"
binding = "RSS_INVALIDATION_QUEUE"
[[queues.producers]]
queue = "external-episode-link"
binding = "EXTERNAL_EPISODE_LINK_QUEUE"
[vars]
PUBLIC_APP_URL = "https://app.podcasterplus.com"
PUBLIC_MEDIA_URL = "https://media.podcasterplus.com"
IMPORTER_USER_AGENT = "show.fm-importer/1.0 (+https://show.fm)"
PER_FILE_MAX_BYTES = "524288000" # 500 MB cap on audio streamSecrets: RESEND_API_KEY, RESEND_FROM_EMAIL (set via wrangler secret put).
Failure modes
podcast-imports consumer
| Condition | Classification | Effect |
|---|---|---|
Parsed payload missing audioUrl | FatalImportError | Item → failed, reason parse_error. |
Source HEAD / GET returns >= 400 | Varies | Fatal for 404/410, retryable for 5xx. |
Audio exceeds PER_FILE_MAX_BYTES | FatalImportError | Item → failed, reason skipped_oversize. |
| Duplicate GUID detected at processing time | (not an error) | Item → skipped_duplicate. |
| Hosting-migration cutover throws | (terminal-import write) | Import → failed with Hosting cutover failed: …; no RSS invalidation, no email. User re-runs migration to retry (merge is idempotent). |
| Unclassified throw | resetItemForRetry + rethrow | Queue retry with backoff. |
| All retries exhausted | Terminalise | Item → failed, reason retries_exhausted. |
| Finalisation throws after terminal write | Logged, swallowed | No state rollback. |
| Queue message lost in transit (never delivered) | Watchdog re-dispatch | Per-minute stalled-import sweep in lifecycle-manager detects import_items rows past their per-attempt threshold and re-sends the queue message. Safe because claimPendingItem is atomic — a late-arriving original ack-skips. |
external-episode-link consumer
| Condition | Outcome |
|---|---|
Episode no longer pending | idempotent_ack |
Tenant mismatch (podcast_id ≠ episode's) | idempotent_ack + structured error log |
| Podcast missing | Mark unmatched |
Podcast hosting_type no longer external | idempotent_ack (cutover happened) |
Podcast missing external_rss_url | Mark unmatched |
| Feed fetch / parse failure | retry (treats as host not yet propagated) |
Malformed target_published_at | Mark unmatched |
| 0 or >1 candidates with no title winner | retry until schedule exhausted, then unmatched |
| Exactly 1 candidate, or unique title winner | linked (external_link_method = 'auto') |
Observability
Structured JSON logs with a leading event field. Filter by event in wrangler tail.
podcast-imports consumer
import_item_ack # item successfully processed
import_item_retry # transient failure, scheduling retry
import_item_terminalised # final attempt failed, terminalised
import_item_threw # unexpected throw; released back to pending
import_item_reset_failed # couldn't release item (DB error)
import_item_skipped_non_running_import # parent canceled/preview
item_already_claimed # race lost to another invocation
import_item_late_duplicate # late ON CONFLICT match on episode_guid
import_item_claim_lost # claim token mismatched at terminal write
import_gone # orphaned message; ack
finalise_if_last_item_failed # logged, swallowed
terminalise_failed # catastrophic; logged
rss_invalidation_send_failed # queue send threw; logged
side_effects_skipped # missing podcast/importer refs
hosting_migration_cutover # hosting_type flipped to internal
hosting_migration_cutover_failed # cutover UPDATE threw; import marked failed
hosting_migration_mark_failed_after_cutover_failed # last-ditch fallbackexternal-episode-link consumer
external_link_attempt # outcome=linked|retry|unmatched|idempotent_ack|tenant_mismatch|podcast_internal_skip
external_link_attempt_error # unexpected throw; Cloudflare retry then DLQ
external_link_title_disambiguation # outcome=matched|still_ambiguous, top_score, runner_up_scoreEnqueue-side (emitted from src/api/routes/imports/index.ts::enqueueImportItems)
import_items_enqueued # sent=N, expected=M — diff N≠M signals send failure
import_items_enqueue_failed # loop aborted mid-way; includes sent count and errorWatchdog-side (emitted from workers/lifecycle-manager/src/index.ts, cron * * * * *)
import_watchdog_completed # combined: items_requeued, links_requeued, previews_canceled
import_watchdog_found_stalled # per-import summary
import_watchdog_resend_failed # per-item send failure
external_link_watchdog_resent # per-tick count
hosting_migration_preview_watchdog_canceled # per-tick countSee the RSS Import cost baseline for a known observability gap — not all per-phase events currently surface through wrangler tail, which is a remediation item rather than a cost concern.
Deployment
cd workers/podcast-import-executor
npx wrangler deploy
# Set secrets
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put RESEND_FROM_EMAIL
# Tail logs
npx wrangler tail podcasterplus-podcast-import-executor --format jsonRelated
- Imports API —
podcast-importsqueue producer for back-catalogue imports. - External Link API —
podcast-importsproducer for the hosting-migration commit, plusexternal-episode-linkproducer for manual reattempts (Epic 11). - External-to-Internal Hosting Migration Guide — End-to-end Epic 11 flow.
- Lifecycle Manager Worker — Watchdog producers for both queues.
- RSS Feed Worker — Consumes the
rss-invalidationmessage produced on completion. - RSS Import — Cost Baseline — Measured per-episode cost of this worker.
src/lib/utils/rss-parser.ts—parseRssFeedFull+ParsedEpisodeshape (payload stored onimport_items.parsed_payload).