Skip to content

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)"]
end

2. 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 Expiration

State Transitions:

  1. queued: Initial state written at ingress before msg.send() to PURGE_QUEUE. Includes enqueuedAt, tenantId, and tags.
  2. processing: Written when the queue consumer worker begins executing against Cloudflare API. Includes startedAt.
  3. completed: Cloudflare API returned success: true. Includes completedAt and verified purged tag count.
  4. failed: Written if fatal validation/auth errors occur or if maximum retry attempts are exhausted. Contains structured error envelope and failedAt.

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 > now and job status is queued or processing):
    • Ingress immediately reuses the existing jobId and returns { ok: true, jobId, status: "queued", coalesced: true }.
    • Zero additional queue messages are dispatched.
  • 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 to PURGE_QUEUE.

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:

  1. Marked as status: "failed" in PURGE_KV with error code max_retries_exceeded.
  2. Forwarded to PURGE_DLQ with diagnostic envelope (jobId, tenantId, tags, enqueuedAt, failedAt, attempts, error).
  3. 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" to PURGE_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 /__purges lists recorded purges; DELETE /__purges clears them.
  • When CLOUDFLARE_API_TOKEN is present, GET /__purges returns HTTP 404 (zero inspection surface exposed in production).

Monorepo Bindings Configuration

wrangler.jsonc
{
"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_TOKEN configured), any request lacking valid Bearer authentication strictly rejects with HTTP 401 { ok: false, error: "unauthorized" }.
  • In local development, an explicit ADMIN_API_KEY in wrangler.jsonc vars (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,800 seconds):
    • 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_KV under dlq:<jobId> and indexed in dlq:recent with 7-day retention for rapid auditability.