Content Invalidation And Cache Freshness
Content Invalidation and Cache Freshness Specification
Standard: RFC-00560 / SiteSwarm Edge Invalidation
Component: @siteswarm/service-cache-purge
Status: Living Canonical Specification
Binding: env.CACHE_PURGE_SERVICE
1. Executive Summary & Architectural Philosophy
SiteSwarm delivers sub-50ms TTFB across multi-tenant edge sites by caching rendered HTML and SSR assets in Cloudflare’s tiered cache. To maintain zero perceived staleness while safeguarding CMS publish response times, cache invalidation is completely decoupled from synchronous request-response loops.
Rather than blocking an author’s CMS publish action on Cloudflare Purge API network roundtrips (which introduce 200ms–2000ms latency and risk upstream Cloudflare rate limits), @siteswarm/service-cache-purge implements an asynchronous, queue-backed, event-driven purge pipeline.
flowchart TD subgraph ClientCMS["Client CMS Worker (Worker A)"] A["CMS Publish / Unpublish Action"] --> B["env.CACHE_PURGE_SERVICE.purgeTags(tenantId, tags)"] end
subgraph PurgeIngress["@siteswarm/service-cache-purge (Ingress)"] B --> C{"Validation & Tenant Scope"} C -- Invalid / Cross-Tenant --> D["Immediate 4xx / Error Return"] C -- Valid --> E{"Ingress Coalesce Check (5s KV)"} E -- Active Job Exists --> F["Reuse Active jobId"] E -- No Active Job --> G["Create Job in PURGE_KV (24h TTL)"] G --> H["Push to PURGE_QUEUE"] H --> I["Immediate 200: { ok: true, jobId, status: 'queued' }"] end
subgraph PurgePipeline["Async Queue Processing (Worker B)"] H -.-> J["PURGE_QUEUE Consumer (Batch max: 10, Timeout: 5s)"] J --> K["Consumer Batch Deduplication by Tenant"] K --> L["Chunk Tags into <= 100-tag Batches"] L --> M["Cloudflare Purge API (POST /zones/:id/purge_cache)"] M -- Success 200 --> N["Mark PURGE_KV: completed & Ack Message"] M -- Rate Limit 429 / 5xx Server Error --> O{"Attempts < 3?"} O -- Yes --> P["Exponential Backoff Retry (msg.retry({ delaySeconds }))"] O -- No --> Q["Mark PURGE_KV: failed & Route to PURGE_DLQ & Ack"] M -- Poison Pill 400/403/Unknown Zone --> R["Mark PURGE_KV: failed & Ack (No Deadlock)"] end2. Service Binding RPC Contract
Client workers bind @siteswarm/service-cache-purge as env.CACHE_PURGE_SERVICE. The interface exposes two RPC methods over Cloudflare Service Bindings:
2.1 purgeTags(tenantId: string, tags: string[]): Promise<PurgeResult>
Ingests a cache purge request asynchronously. Performs strict input validation, tenant scoping, and ingress burst coalescing before placing the request into PURGE_QUEUE.
- Latency Guarantee: Non-blocking (<10ms edge processing time). Never awaits external Cloudflare API HTTP network hops.
- Fail-Safe Guarantee: Never throws exceptions across the RPC seam. All failures return structured
{ ok: false, error: { code, message, status? } }.
export interface PurgeResult { ok: boolean; jobId?: string; status?: "queued" | "processing" | "completed" | "failed"; enqueuedAt?: string; purged?: number; coalesced?: boolean; error?: PurgeError;}2.2 getPurgeStatus(jobId: string): Promise<PurgeJobRecord | null>
Allows callers to query the real-time execution lifecycle of any purge job stored in PURGE_KV.
export interface PurgeJobRecord { jobId: string; tenantId: string; tags: string[]; status: "queued" | "processing" | "completed" | "failed"; enqueuedAt: string; startedAt?: string; completedAt?: string; failedAt?: string; purged?: number; retryCount?: number; coalesced?: boolean; error?: PurgeError;}3. KV Job Lifecycle State Machine
All purge jobs are tracked in PURGE_KV under the key format:
job:<jobId>Every job record is assigned a 24-hour expiration TTL (expirationTtl: 86400) to guarantee self-cleaning operational storage while preserving auditability.
stateDiagram-v2 [*] --> queued: purgeTags ingress enqueued queued --> processing: Queue consumer picks up batch processing --> completed: Cloudflare Purge API confirmed (HTTP 200) processing --> processing: HTTP 429/5xx (attempts < 3, retry backoff scheduled) processing --> failed: Fatal 400/403 or Max Retries (3) Exceeded -> Routed to PURGE_DLQ completed --> [*]: 24h KV TTL Expiration failed --> [*]: 24h KV TTL ExpirationState Transitions:
queued: Initial state written at ingress beforemsg.send()toPURGE_QUEUE. IncludesenqueuedAt,tenantId, andtags.processing: Written when the queue consumer worker begins executing against Cloudflare API. IncludesstartedAt.completed: Cloudflare API returnedsuccess: true. IncludescompletedAtand verifiedpurgedtag count.failed: Written if fatal validation/auth errors occur or if maximum retry attempts are exhausted. Contains structurederrorenvelope andfailedAt.
4. Hybrid Burst Coalescing Architecture
Rapid bulk content updates (such as batch importing gallery photos or auto-saving CMS drafts) can trigger dozens of identical tag invalidation requests within fractions of a second. To prevent downstream queue flooding and API throttling, @siteswarm/service-cache-purge employs a Two-Tier Hybrid Coalescing model:
Tier 1: Ingress Coalescing (5-Second KV Hash Lock)
Before a message is pushed to PURGE_QUEUE, ingress calculates an order-invariant SHA-256 hash of the requested tags:
$$\text{hash} = \text{SHA-256}(\text{sort}(\text{unique}(\text{tags})))$$
Ingress checks KV key:
coalesce:<tenantId>:<hash>- If an active record exists (
expiresAt > nowand job status isqueuedorprocessing):- Ingress immediately reuses the existing
jobIdand returns{ ok: true, jobId, status: "queued", coalesced: true }. - Zero additional queue messages are dispatched.
- Ingress immediately reuses the existing
- If no active record exists:
- Ingress generates a fresh
jobId, writes the coalesce record with a 5-second logical TTL (and 60s physical KV retention), writes the job record, and enqueues toPURGE_QUEUE.
- Ingress generates a fresh
Tier 2: Consumer Batch Deduplication
When the queue consumer receives a batch of messages (max_batch_size: 10, max_batch_timeout: 5 seconds):
- Messages are grouped by
tenantId. - Tags across all messages within each tenant group are combined and deduplicated.
- All associated
jobIds for that tenant group advance through the lifecycle together. - Prevents redundant Cloudflare API calls if different jobs had overlapping tags.
5. Queue Consumer & 100-Tag Batch Chunking
Cloudflare’s Zone Purge API (POST /zones/:zone_id/purge_cache) enforces a hard limit of 100 tags per single HTTP request.
The consumer worker partitions the deduplicated tag set into chunks of at most 100 tags: $$\text{chunks} = \left\lceil \frac{|\text{unique_tags}|}{100} \right\rceil$$
For example, a purge request containing 250 tags is partitioned into chunks of $[100, 100, 50]$, dispatched sequentially with bearer token authorization (Authorization: Bearer <CLOUDFLARE_API_TOKEN>).
Anti-Lockout Invariant
No single tenant burst can saturate account concurrency. Ingress caps individual RPC calls at MAX_TAGS_PER_CALL = 500 (at most 5 API calls per job), and queue consumers process chunks sequentially per tenant.
6. Backpressure, Exponential Backoff, DLQ & Poison Pill Semantics
flowchart TD A["Consumer receives batch from PURGE_QUEUE"] --> B{"Evaluate HTTP Response"} B -- "200 OK (success: true)" --> C["Mark KV completed & msg.ack()"] B -- "429 Rate Limit or 5xx Server Error or Network Drop" --> D{"msg.attempts < 3?"} D -- "Yes (< 3)" --> E["Calculate delay: 2^(attempts-1) * 2s or Retry-After"] E --> F["msg.retry({ delaySeconds })"] D -- "No (>= 3)" --> G["Mark KV status: failed (max_retries_exceeded)"] G --> H["env.PURGE_DLQ.send(...)"] H --> I["msg.ack() (Prevent primary queue deadlock)"] B -- "400 Bad Request / 401/403 Auth / Unknown Zone" --> J["Mark KV status: failed (non-retryable)"] J --> K["msg.ack() (Poison Pill Guardrail)"]6.1 Retryable Errors (429 / 5xx / Network Drops)
- Retry Backoff Calculation:
$$\text{delaySeconds} = \min\left(300, \text{baseDelay} \times 2^{\max(0, \text{attempts} - 1)}\right)$$
- Attempt 1: 2 seconds
- Attempt 2: 4 seconds
- Attempt 3: 8 seconds
- If Cloudflare provides a valid
Retry-After: <seconds>HTTP header, the consumer respects it up to a 300s ceiling.
- Maximum Retries: Capped at 3 attempts (
MAX_RETRIES = 3).
6.2 Dead Letter Queue (PURGE_DLQ) Routing
When a message fails after 3 attempts:
- Marked as
status: "failed"inPURGE_KVwith error codemax_retries_exceeded. - Forwarded to
PURGE_DLQwith diagnostic envelope (jobId,tenantId,tags,enqueuedAt,failedAt,attempts,error). - Acknowledged (
msg.ack()) from the primary queue to avoid re-delivery loops.
6.3 Poison Pill Guardrail (Non-Retryable Errors)
Malformed payloads (HTTP 400), revoked API credentials (HTTP 401/403), or unknown tenant zones will never succeed on retry.
- The consumer immediately writes
status: "failed"toPURGE_KV. - Acknowledges the message (
msg.ack()). - Guarantees that poisoned messages never deadlock or starve valid tenant purges.
7. Surrogate Key / Cache-Tag Taxonomy
Cache tags follow a strict hierarchical namespace:
| Tag Format | Scope | Example | Description |
|---|---|---|---|
tenant:<tenantId> |
Global Tenant Root | tenant:green-leaf-bakery |
Purges every page for the given tenant site. |
tenant:<tenantId>:page:<path> |
Page Scope | tenant:clk-construction:page:/projects |
Purges specific route/page and subpaths. |
tenant:<tenantId>:<collection>:<id> |
Entity Scope | tenant:clk-construction:projects:commercial-reno |
Purges specific CMS entry references. |
Strict Tenant Scoping Security Rule
Tags submitted to purgeTags(tenantId, tags) must strictly match tenant:<tenantId> or tenant:<tenantId>:.... Any cross-tenant attempt (e.g. clk-construction attempting to purge tenant:green-leaf-bakery) is rejected immediately at ingress with cross_tenant_tag before touching the queue or Cloudflare API.
8. Multi-Worker Service Binding & Local Dev Mode
Local Dev Mode (No CLOUDFLARE_API_TOKEN)
In local development, developers do not require real Cloudflare credentials:
- Purges are processed locally by the Queue and KV workerd runtime.
- In-memory inspection endpoint
GET /__purgeslists recorded purges;DELETE /__purgesclears them. - When
CLOUDFLARE_API_TOKENis present,GET /__purgesreturns HTTP 404 (zero inspection surface exposed in production).
Monorepo Bindings Configuration
{ "name": "siteswarm-cache-purge", "main": "src/index.ts", "queues": { "producers": [ { "binding": "PURGE_QUEUE", "queue": "siteswarm-purge-queue" }, { "binding": "PURGE_DLQ", "queue": "siteswarm-purge-dlq" } ], "consumers": [ { "queue": "siteswarm-purge-queue", "max_batch_size": 10, "max_batch_timeout": 5, "max_retries": 3, "dead_letter_queue": "siteswarm-purge-dlq" } ] }, "kv_namespaces": [ { "binding": "PURGE_KV", "id": "siteswarm-purge-kv" } ]}Client workers declare:
"services": [ { "binding": "CACHE_PURGE_SERVICE", "service": "siteswarm-cache-purge" }]9. Administrative Observability Subsystem & Daily Telemetry Rollups
To monitor queue health, job status, dead-letter routing, and throughput without exposing unauthenticated attack surface, @siteswarm/service-cache-purge mounts an authenticated HTTP administrative router under /admin/*.
9.1 Authentication Guardrail
Administrative endpoints are protected by Authorization: Bearer <ADMIN_API_KEY>.
- In production (
CLOUDFLARE_API_TOKENconfigured), any request lacking valid Bearer authentication strictly rejects with HTTP 401{ ok: false, error: "unauthorized" }. - In local development, an explicit
ADMIN_API_KEYinwrangler.jsoncvars (dev-admin-key) ensures full local authentication validation.
9.2 Administrative Endpoint Surface
| Route | Method | Description |
|---|---|---|
/admin/health |
GET |
Probes KV and Queue bindings; returns system health status (healthy, degraded). |
/admin/queues/status |
GET |
Returns queue configuration parameters, daily throughput rollups across tenants, and DLQ volume. |
/admin/jobs/:jobId |
GET |
Returns lifecycle status, execution timestamps, purged count, and error state for a specific job from PURGE_KV. |
/admin/queues/dlq |
GET |
Returns recent dead-letter queue records stored in PURGE_KV (?limit=50). |
9.3 KV-Backed Daily Telemetry Rollups (metrics.ts)
To analyze tenant invalidation activity over time without high write contention:
- Daily telemetry keys follow the format:
stats:daily:<YYYY-MM-DD>:<tenantId>
- Each record retains counters with a 7-day expiration TTL (
604,800seconds):purges: Total count of purge jobs completed.tags: Cumulative number of tags invalidated.retries429: Count of Cloudflare 429 rate limit backoff retries.failures: Total number of permanent or DLQ failures.
- Dead-letter queue entries are mirrored to
PURGE_KVunderdlq:<jobId>and indexed indlq:recentwith 7-day retention for rapid auditability.