RSS Feed Worker
The RSS Feed Worker generates standards-compliant podcast RSS feeds. Since Epic 17 D-1 the canonical feed URL is rss.cdn.media/{podcast_id} — the podcast UUID, the show's permanent, rename-proof key. Slug-form paths (the pre-Epic-17 scheme, and any renamed slug via the permanent alias table) answer a cacheable 301 to the ID form, resolved live-slug-first then podcast_slug_aliases; that redirect lane is monitored production surface, forever (R-6). The pre-flip host feed.podcasterplus.com never comes down either (live subscribers hold its URLs, Epic 16 D-23) and reaches the ID feed in at most two hops. Every internal key — the KV cache, the R2 snapshot, WAE analytics, queue grouping — is the podcast ID, which also closed the old freed-slug stale-cache hazard (a podcast ID is never reused). This is mission-critical infrastructure for podcast distribution to Apple Podcasts, Spotify, and other directories.
Overview
| Property | Value |
|---|---|
| URL | https://rss.cdn.media/{podcast_id} (slug-form paths 301 here; old hosts 301 in) |
| Worker Name | podcasterplus-rss-feed |
| Source Location | /workers/rss-feed/ |
| Deployment | Cloudflare Workers |
| Database | Supabase PostgreSQL via Hyperdrive |
| Cache | Cloudflare KV (no CDN caching — every request runs the worker) |
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ Request Flow │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Client Request │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Browser │ │ Worker │ │ KV Cache │ │
│ │ Cache │────▶│ (every │────▶│ (Global, 1hr TTL) │ │
│ │ (60s TTL) │ │ request) │ │ │ │
│ └─────────────┘ └─────────────┘ └───────────┬─────────────┘ │
│ │ │
│ Cache Miss │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Hyperdrive │ │
│ │ (Connection Pooling) │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Supabase PostgreSQL │ │
│ │ (Source Data) │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Cache Invalidation Flow │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Main App (podcast/episode update) │
│ │ │
│ ├───────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Queue │ │ HTTP │ (fallback if queue unavailable) │
│ │ Producer │ │ Endpoint │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ RSS Feed Worker │ │
│ │ (Queue Consumer / HTTP Handler) │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ KV Cache Invalidation │ │
│ │ (Delete feed:{slug} entries) │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘Cloudflare Services
Workers
The RSS Feed Worker is deployed as a Cloudflare Worker with route binding:
# workers/rss-feed/wrangler.toml
name = "podcasterplus-rss-feed"
main = "src/index.ts"
compatibility_date = "2024-12-30"
compatibility_flags = ["nodejs_compat"]
routes = [
{ pattern = "feed.podcasterplus.com/*", zone_name = "podcasterplus.com" },
{ pattern = "rss.cdn.media/*", zone_name = "cdn.media" }
]Current Version ID: 7a0c0f47-e392-42d2-b8b8-93c6238bab0a
KV Namespace
Global key-value store for feed caching:
| Property | Value |
|---|---|
| Binding | RSS_CACHE |
| Namespace ID | c22b685ea2bb4099a58a4788b58e1007 |
| TTL | 3600 seconds (1 hour) |
Key Structure:
feed:{slug} # Full XML content
feed:{slug}:etag # ETag hash for 304 responses
feed:{slug}:meta # Generation metadata (timestamp, version)Hyperdrive
Connection pooling for Supabase PostgreSQL (10-100x faster cold starts):
| Property | Value |
|---|---|
| Binding | HYPERDRIVE |
| Config ID | a81d477ff9264805989f5a72f0354ee8 |
| Target | Supabase PostgreSQL direct connection |
Configuration:
# workers/rss-feed/wrangler.toml
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "a81d477ff9264805989f5a72f0354ee8"Usage in Code:
// workers/rss-feed/src/db/client.ts
import postgres from 'postgres';
const sql = postgres(hyperdrive.connectionString, {
max: 1, // Hyperdrive handles pooling
prepare: false // Required for Hyperdrive compatibility
});Queue
Asynchronous cache invalidation:
| Property | Value |
|---|---|
| Queue Name | rss-invalidation |
| Producer | Main app (RSS_INVALIDATION_QUEUE binding) |
| Consumer | RSS Feed Worker |
| Max Batch Size | 10 |
| Max Retries | 3 |
Message Schema:
type RssInvalidationMessage = {
type: string; // e.g., "episode.published", "podcast.updated"
podcast_id: string; // UUID
podcast_slug: string; // Used for cache key
episode_id?: string; // Optional, for episode-specific invalidation
timestamp: string; // ISO 8601
};Windowed publishing from settings saves
The podcast settings pages save through one consolidated ?/save action per page (the explicit "Save changes" bar — the per-card autosave era is gone), so a save click issues exactly ONE feed publish. Feed-affecting saves (General's whole page including cover-art uploads via updateImage; Distribution's ownership fields, itunes/podcast flags, and embed branding/colour) call scheduleFeedSync() (src/lib/utils/rss-invalidate.ts): the first save in a window claims podcasts.feed_sync_scheduled_at with an atomic conditional UPDATE, publishes IMMEDIATELY (single edits reach the feed in seconds), and enqueues ONE trailing sweep delayed by FEED_SYNC_WINDOW_SECONDS (300 s — cheap coalescing, far below podcast apps' polling cadence); every further save inside the window rides along free. Hard cap: two invalidations per podcast per window regardless of how often the user saves, and rebuilds stay lazy (the next fetch after each invalidation publishes everything accumulated). The "Update feed now" button (Distribution page + settings header kebab) remains as an immediate accelerator (manual.invalidation) and resets the stamp. The only remaining immediate settings invalidation is the 24-hour owner-email visibility window (podcast.email_visibility.updated), which is a time-boxed switch rather than a draftable setting.
Project Structure
workers/rss-feed/
├── wrangler.toml # Worker configuration
├── package.json # Dependencies (postgres, hono)
├── tsconfig.json # TypeScript configuration
└── src/
├── index.ts # Entry point, request routing
├── types/
│ └── env.ts # Environment bindings, data types
├── db/
│ └── client.ts # Hyperdrive/postgres client with REST fallback
├── rss/
│ ├── generator.ts # Main XML generation orchestrator
│ ├── channel.ts # Channel-level RSS tags
│ ├── item.ts # Episode item tags
│ ├── podcast2.ts # Podcast 2.0 namespace elements
│ └── utils.ts # CDATA, escaping, date formatting
└── cache/
├── kv.ts # KV read/write operations
└── etag.ts # ETag generation and validationRSS Feed Specification
Namespaces
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:atom="http://www.w3.org/2005/Atom">Channel Tags (Required)
| Tag | Source Field | Notes |
|---|---|---|
<title> | podcasts.title | Plain text |
<description> | podcasts.description | CDATA wrapped |
<link> | podcasts.website_url | Website URL |
<language> | podcasts.language | ISO 639-1 |
<itunes:image> | podcasts.cover_image_url | 1400x1400 minimum |
<itunes:category> | podcasts.category | iTunes category |
<itunes:explicit> | podcasts.explicit | true/false |
<itunes:author> | podcasts.author | Creator name |
Channel Tags (Recommended)
| Tag | Source Field | Notes |
|---|---|---|
<itunes:owner> / <itunes:name> | owner_name | Always emitted when set (not privacy-sensitive) |
<itunes:owner> / <itunes:email> | owner_email | Gated by email_visible_until — see Privacy-First Ownership |
<itunes:type> | podcasts.show_type | episodic/serial |
<copyright> | podcasts.copyright | Rights statement |
<itunes:subtitle> | podcasts.subtitle | Short description |
<image> | podcasts.cover_image_url | RSS 2.0 image |
Podcast 2.0 Tags
| Tag | Source Field | Notes |
|---|---|---|
<podcast:guid> | podcasts.podcast_guid | Permanent UUID |
<podcast:locked> | podcasts.is_locked | Prevent hijacking; optional owner="…" attribute gated by email_visible_until |
<podcast:medium> | podcasts.medium | podcast/music/video |
<podcast:funding> | podcast_funding | Support links |
<podcast:txt purpose="applepodcastsverify"> | podcasts.apple_verify_token | Apple Podcasts Connect claim code |
<podcast:txt purpose="verify"> | podcasts.verification_tokens[] | One tag per entry, generic platform claim tokens |
<podcast:txt purpose="ai-content"> | podcasts.ai_content | true/false — emitted only when explicitly set |
Episode Item Tags
| Tag | Source Field | Notes |
|---|---|---|
<title> | episodes.title | Plain text |
<enclosure> | audio_url, audio_file_size_bytes, audio_content_type | Required |
<guid> | episodes.episode_guid | Permanent identifier |
<pubDate> | episodes.published_at | RFC 822 format |
<description> | episodes.description | CDATA wrapped |
<itunes:duration> | episodes.audio_duration_seconds | HH:MM:SS format |
<itunes:episode> | episodes.episode_number | Episode number |
<itunes:season> | episodes.season_number | Season number |
<itunes:episodeType> | episodes.episode_type | full/trailer/bonus |
Episode Podcast 2.0 Tags
| Tag | Source Field | Notes |
|---|---|---|
<podcast:transcript> | transcript_url, transcript_type, transcript_language | Transcript file. language is the TRANSCRIPT'S own language (episode_transcripts.language, LEFT JOINed one-to-one by both feed queries); falls back to the channel language only when NULL (#297 item 5) |
<podcast:chapters> | chapters_url | JSON chapters |
<podcast:location> | location_name, location_geo | Recording location |
<podcast:person> | episode_credits | Contributors |
<podcast:soundbite> | episode_soundbites | Audio clips |
<podcast:txt purpose="ai-content"> | episodes.ai_content | Item-level override; null inherits the show-level disclosure (no item tag emitted) |
Privacy-First Ownership and Verification
The feed is privacy-first by default: the owner email is hidden unless the user has deliberately opened a 24-hour visibility window (24h matches the longest platform confirmation-link lifetime — Amazon's expires after 24 hours), and platform verification is preferred via <podcast:txt> tokens instead of a public email.
Implemented in migration 20260422080013_privacy_and_verification.sql, enforced by workers/rss-feed/src/rss/channel.ts and workers/rss-feed/src/rss/item.ts, and surfaced in Settings → Distribution → Ownership & Verification at src/routes/(app)/p/[slug]/settings/distribution/+page.svelte (Content Disclosure remains on General settings).
New columns
| Table | Column | Type | Default | Meaning |
|---|---|---|---|---|
podcasts | email_visible_until | timestamptz | NULL | When in the future, owner email is rendered in the feed |
podcasts | apple_verify_token | text | NULL | Apple Podcasts Connect claim code (e.g. "05124") |
podcasts | verification_tokens | text[] | '{}' | Free-form per-platform verification strings |
podcasts | ai_content | boolean | NULL | Show-level AI disclosure — NULL = not disclosed, no tag emitted |
episodes | ai_content | boolean | NULL | Episode-level override — NULL = inherit show (no item tag emitted) |
Email visibility gating
Owner email visibility is controlled by podcasts.email_visible_until. The isEmailVisible() helper in channel.ts treats a NULL value — and any timestamp already in the past — as hidden:
export function isEmailVisible(podcast: PodcastRow): boolean {
if (!podcast.email_visible_until) return false;
const expiresAt = new Date(podcast.email_visible_until).getTime();
return Number.isFinite(expiresAt) && expiresAt > Date.now();
}The flag controls two distinct emissions in the channel:
<itunes:email>inside<itunes:owner>— omitted entirely when hidden.<itunes:name>still renders fromowner_namebecause a display name is not privacy-sensitive; if both are absent,<itunes:owner>is not emitted at all.- The optional
owner="…"attribute on<podcast:locked>— omitted when hidden, so other hosts cannot read the email from the lock tag either.
Existing rows are not backfilled. Every podcast deployed before the migration has email_visible_until = NULL, so the feed worker will stop emitting <itunes:email> on deploy. Users re-open a 24-hour window from Settings → Distribution.
Window lifecycle
Opening and closing the window is a form action on the Distribution settings page — see setEmailVisibility in +page.server.ts:
setEmailVisibility(show=true)setsemail_visible_until = now() + 24hand fires an immediate RSS invalidation. No at-expiry rebuild is scheduled: Cloudflare Queues caps message delays at 12 hours (the invalidation API rejectsdelay_seconds > 43200), so a 24h-out invalidation is unschedulable. Expiry is covered by the worker's 1-hour KV TTL — the last cached "visible" feed retires itself within ~an hour of the window closing andisEmailVisible()hides the address on the rebuild.setEmailVisibility(show=false)("Hide now") nulls the column and fires an immediate invalidation.- Opening a window also requires a non-empty
owner_email; the UI disables the button otherwise.
Platform verification via <podcast:txt>
<podcast:txt> tags replace the need to publish a contact email for platform claim flows. Spec: https://podcasting2.org/docs/podcast-namespace/tags/txt.
| Purpose | Source | Emission |
|---|---|---|
applepodcastsverify | podcasts.apple_verify_token | Single tag when non-null |
verify | podcasts.verification_tokens | One tag per non-empty element, in stored array order |
ai-content (channel) | podcasts.ai_content | Single tag when non-null (true/false) |
ai-content (item) | episodes.ai_content | Item-level override; null inherits show and emits none |
All token values go through escapeXml() so XML-unsafe characters (e.g. <, &) cannot break the feed — exercised by the privacy/disclosure test suites in workers/rss-feed/src/rss/__tests__/channel.privacy.test.ts and item.disclosure.test.ts.
Visibility flow
Cache invalidation triggers
These new settings feed into the existing rss-invalidation queue flow — see Cache Invalidation below. The settings page triggers invalidation on:
| Form action | Reason code |
|---|---|
updateOwnership | podcast.ownership.updated |
setEmailVisibility (on) | podcast.email_visibility.updated + delayed podcast.email_visibility.expired |
setEmailVisibility (off) | podcast.email_visibility.updated |
updateContentDisclosure | podcast.disclosure.updated |
Podcast Lifecycle Handling
The RSS Feed Worker is lifecycle-aware and handles each podcast status differently:
Active & Paused Podcasts
Both active and paused podcasts serve their RSS feed normally. Paused podcasts continue serving feeds so that subscribers and directories are unaffected — the pause only freezes app features.
Pending Deletion Podcasts
Podcasts with status = 'pending_deletion' follow a tiered response strategy:
| Condition | Response |
|---|---|
Past deletion_scheduled_at | 404 (hard delete imminent) |
Has deletion_redirect_url, days 1-7 | 200 with feed + <itunes:new-feed-url> tag |
Has deletion_redirect_url, days 8+ | 301 redirect to new URL |
| No redirect URL, within window | 200 with feed (grace period) |
The 7-day threshold allows podcast apps time to parse the <itunes:new-feed-url> tag before switching to a hard 301 redirect that saves Worker compute.
// workers/rss-feed/src/index.ts
if (feedData.podcast.status === 'pending_deletion') {
// Past scheduled date → 404
if (isPastScheduledDate) return 404;
if (redirectUrl) {
if (daysSinceDeletion > 7) return 301; // Hard redirect
feedData.podcast.redirect_url = redirectUrl; // Include tag in feed
}
// No redirect URL → serve normally (grace period)
}See Podcast Lifecycle Guide for the full deletion flow.
API Endpoints
GET /
Generate RSS feed for a podcast.
Response Codes:
| Code | Condition |
|---|---|
| 200 | Feed generated successfully |
| 301 | Podcast has redirect_url set, or pending_deletion with redirect URL after 7 days |
| 304 | ETag matches (Not Modified) |
| 400 | Invalid slug format |
| 404 | Podcast not found, inactive, or past deletion_scheduled_at |
| 500 | Database or generation error |
Response Headers:
Content-Type: application/rss+xml; charset=utf-8
Cache-Control: public, max-age=60, s-maxage=180
ETag: "3435b1c995479ff49be1002b1932540c"
X-Cache: HIT|MISSPOST /_internal/invalidate
Invalidate cached feed (authenticated).
Headers:
Authorization: Bearer {RSS_INVALIDATION_SECRET}
Content-Type: application/jsonBody:
{
"slug": "podcast-slug",
"reason": "episode.published"
}Response:
{
"success": true,
"invalidated": ["feed:podcast-slug", "feed:podcast-slug:etag"]
}Database Queries
The Worker executes optimized parallel queries via Hyperdrive:
-- 1. Podcast metadata (lifecycle-aware)
SELECT id, title, slug, description, subtitle, author, language,
cover_image_url, category, subcategory, explicit, website_url,
owner_name, owner_email, email_visible_until,
apple_verify_token, verification_tokens, ai_content,
copyright, podcast_guid, show_type,
medium, is_locked, is_blocked, is_complete, redirect_url,
is_active, status, overage_suspended_at,
deletion_redirect_url, deletion_requested_at,
deletion_scheduled_at, updated_at
FROM podcasts
WHERE slug = $1
AND hosting_type = 'podcasterplus'
AND (
(status IN ('active', 'paused') AND is_active = true AND overage_suspended_at IS NULL)
OR status = 'pending_deletion'
)
LIMIT 1;
-- 2. Published episodes (parallel; episode_transcripts is one-to-one, its
-- language rides along for <podcast:transcript>'s language attribute)
SELECT e.id, e.title, e.slug, e.description, e.show_notes, e.subtitle,
e.audio_url, e.audio_duration_seconds, e.audio_file_size_bytes,
e.audio_content_type, e.cover_image_url, e.episode_number, e.season_number,
e.episode_type, e.published_at, e.is_explicit, e.is_blocked, e.episode_guid,
e.transcript_url, e.transcript_type, et.language AS transcript_language,
e.chapters_url, e.location_name, e.location_geo,
e.ai_content
FROM episodes e
LEFT JOIN episode_transcripts et ON et.episode_id = e.id
WHERE e.podcast_id = $1 AND e.status = 'published' AND e.is_blocked != true
ORDER BY e.published_at DESC
LIMIT 1000;
-- 3. Funding links (parallel)
SELECT url, title, display_order
FROM podcast_funding
WHERE podcast_id = $1
ORDER BY display_order ASC;
-- 4. Episode credits (parallel)
SELECT ec.episode_id, ec.name, ec.role_label, ec.avatar_url,
ec.external_url, ec.display_order
FROM episode_credits ec
INNER JOIN episodes e ON ec.episode_id = e.id
WHERE e.podcast_id = $1 AND e.status = 'published'
ORDER BY ec.display_order ASC;
-- 5. Episode soundbites (parallel)
SELECT es.episode_id, es.start_time, es.duration, es.title, es.display_order
FROM episode_soundbites es
INNER JOIN episodes e ON es.episode_id = e.id
WHERE e.podcast_id = $1 AND e.status = 'published'
ORDER BY es.display_order ASC;Visibility contract: the suspension gate
overage_suspended_at IS NULL in the podcast predicate is the hosted-content suspension gate (downgrade-overages §7.4, docs/planning/plans/2026-07-12-downgrade-overages.md). podcasts.overage_suspended_at is a platform-owned stamp written only by the overage escalation (lifecycle-manager sweep / webhook reconciler — a user-JWT write raises 42501); while it is set, the feed is a clean null → 404, exactly like is_active = false.
- Both query paths gate. The Hyperdrive query above filters in SQL (
workers/rss-feed/src/db/client.ts:79-84); the REST fallback selects the column and applies the same check in code after the fetch (:193-215). Adding a visibility rule to one path only is a bug. pending_deletionbypasses the gate (both theis_activetoggle and the suspension stamp) — the deletion flow owns that feed's tiered 410/301 behaviour and its podcasts are never selected for suspension.- A suspension-gated
nullis a clean 404, not a backend failure: it does not engage the KVstale:/R2-snapshot DR fallback (that lives in thecatchpath), and the invalidation consumer'srefreshFeedSnapshotdeletes the DR snapshot for a no-longer-servable podcast. - The same contract is mirrored by the public-api worker, both listen pages, and the two
podcast_listen_linkguards — see Public API Worker and Automation Executor. Keep them in lockstep.
Caching Strategy
Multi-Tier Cache
| Tier | Location | TTL | Purpose |
|---|---|---|---|
| Browser | Client | 60s | Reduce repeat requests |
| KV | Global | 3600s | Persistent feed storage (per-colo read cache adds ~60s visibility lag) |
Cloudflare's CDN does not cache feed responses — no cache rule engages on this host, so s-maxage is inert and every request runs the worker. Freshness is governed by KV: the invalidation marker's TTL matches the feed entry TTL (3600 s) so a colo that sees the marker late can never be left with a stale entry it will never re-check.
Cache Headers
const headers = {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=60, s-maxage=180',
'ETag': etag,
'X-Cache': cacheHit ? 'HIT' : 'MISS'
};ETag Support
// Check for conditional request
const ifNoneMatch = request.headers.get('If-None-Match');
if (ifNoneMatch === cachedEtag) {
return new Response(null, {
status: 304,
headers: { 'ETag': cachedEtag }
});
}Cache Invalidation
Via Queue (Preferred)
// Main app: src/api/routes/rss.ts
await c.env.RSS_INVALIDATION_QUEUE.send({
type: 'episode.published',
podcast_id: podcast.id,
podcast_slug: podcast.slug,
episode_id: episode.id,
timestamp: new Date().toISOString()
});Via HTTP (Fallback)
// If queue unavailable
await fetch('https://feed.podcasterplus.com/_internal/invalidate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${RSS_INVALIDATION_SECRET}`
},
body: JSON.stringify({ slug, reason })
});Invalidation Triggers
| Event | Trigger Location | Invalidation Type |
|---|---|---|
| Podcast settings updated | Settings page | Full feed |
| Episode published | Episode editor | Full feed |
| Episode updated | Episode editor | Full feed |
| Episode deleted | Episode list | Full feed |
| Credits changed | Episode editor | Full feed |
| Episode people credit added/removed | Episode People API | Full feed |
| Episode people credits reordered | Episode People API | Full feed |
| Funding changed | Settings page | Full feed |
Environment Variables
Worker Secrets
Set via wrangler secret put:
| Secret | Description |
|---|---|
SUPABASE_SECRET_KEY | Database access (service role or secret key) |
RSS_INVALIDATION_SECRET | HTTP invalidation authentication |
Worker Variables
Set in wrangler.toml:
| Variable | Value |
|---|---|
PUBLIC_MEDIA_URL | https://media.podcasterplus.com |
PUBLIC_FEED_URL | https://feed.podcasterplus.com |
PUBLIC_SUPABASE_URL | https://cmhnfgvbfrkgayrrgrmf.supabase.co |
Deployment
Deploy Worker
cd workers/rss-feed
npx wrangler deploySet Secrets
cd workers/rss-feed
# Database access
npx wrangler secret put SUPABASE_SECRET_KEY
# HTTP invalidation auth
npx wrangler secret put RSS_INVALIDATION_SECRETView Logs
# Real-time logs
npx wrangler tail
# Or via dashboard
# https://dash.cloudflare.com > Workers > podcasterplus-rss-feed > LogsPerformance
Measured Results
| Metric | Value | Target |
|---|---|---|
| Uncached response | ~250ms | < 500ms |
| KV cached response | ~160ms | < 100ms |
| ETag 304 response | ~135ms | < 100ms |
| Feed size (empty) | ~2KB | N/A |
| Feed size (100 episodes) | ~150KB | < 500KB |
Optimization Techniques
- Hyperdrive: Connection pooling eliminates cold start connection overhead
- Parallel queries: All related data fetched concurrently
- KV caching: Avoids database queries for cached feeds
- No CDN caching: every request runs the worker; KV keeps it fast and invalidation honest
- ETag/304: Reduces bandwidth for unchanged feeds
- Streaming: XML generated incrementally (future enhancement)
Troubleshooting
Feed Returns 404
- Check podcast exists:
SELECT slug, status, is_active, overage_suspended_at, hosting_type, deletion_scheduled_at FROM podcasts WHERE slug = 'xxx'; - Verify
hosting_type = 'podcasterplus'(external podcasts have no feed) - Verify
statusisactive,paused, orpending_deletion(not yet pastdeletion_scheduled_at) - For active/paused podcasts: verify
is_active = trueANDoverage_suspended_at IS NULL(a set stamp = hosted-content suspension; restored by upgrade/override or getting under the caps, never by the Active switch) - Check Worker logs for errors
Feed Not Updating
- Verify invalidation is triggered (check main app logs)
- Check Queue is processing (Cloudflare Dashboard > Queues)
- Manually invalidate:
POST /_internal/invalidate - Check KV entries: Cloudflare Dashboard > KV > RSS_CACHE
Hyperdrive Fallback to REST
If logs show "Hyperdrive not configured, falling back to REST API":
- Verify Hyperdrive config exists:
npx wrangler hyperdrive list - Check wrangler.toml has correct ID
- Verify Supabase connection string is valid
Slow Response Times
- Check if hitting cache (
X-Cache: HITheader) - Verify Hyperdrive is connected (not REST fallback)
- Check episode count (>500 episodes may need pagination)
- Review Cloudflare Analytics for patterns
Related Documentation
- Cloudflare Services Overview
- Podcast Lifecycle Guide - Pause/delete flow and RSS behavior by status
- Lifecycle Manager Worker - Cron worker that hard-deletes and triggers RSS invalidation
- Main App RSS Routes
- RSS Feed Specification
- Original Implementation Plan