KV Sync Service: Writes Nobody Reads
smo1 intermediate 6 min read
ELI5
The API was built to copy every link into Cloudflare’s global key-value store so the edge could answer without phoning home. It still makes those copies when it has Cloudflare credentials. But the edge looks them up under different names, expects a different layout, and in production is not even connected to the store. The copies sit there unread.
Technical Deep Dive
Source: purr-api internal/service/kv_sync_service.go:19-138, 140-226, 228-320, internal/service/link_service.go:183-193, 376-390, 416-421, cmd/server/main.go:193-209, internal/config/config.go:144-146 (origin/main bb6cfc2); zoomies-edge wrangler.toml:50-63, src/handlers/redirect.ts:26-42, 85-90, src/index.ts:236-241 (5bdde59), read 2026-10-25.
What it does
flowchart LR A[LinkService Create, Update, Delete] --> B[Postgres write] B --> C{"kvSyncService set ?"} C -->|No| X[nothing] C -->|Yes| D[goroutine] D --> E{"kvEligible: AuthorizeRedirect allowed and expiry over 120 s away ?"} E -->|Yes| F["PUT key = uid, and key = custom_slug"] E -->|No| G["DELETE uid and custom_slug keys"] F --> H[(Cloudflare KV namespace)] G --> H H -.->|"never read"| W[zoomies-edge]- Enabled only when
CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_KV_NAMESPACE_IDandCLOUDFLARE_API_TOKENare all set (kv_sync_service.go:52-67,main.go:193-209). Whether they are set on Railway production is UNVERIFIED. - Triggers: create (
link_service.go:191-193), update, which also deletes a stale custom-slug key on rename (376-390), and delete (419-421). Each runs in a fire-and-forget goroutine withcontext.Background(); errors are dropped. - Eligibility reuses the redirect rule: protected, paused and expired links are deleted from KV instead of written, because the value has no protection field (
kv_sync_service.go:73-86). - Expiry is passed as Cloudflare’s
?expiration=so KV drops the entry when the link expires. - Bulk:
SyncAllLinksandBulkSyncbatch 10,000 entries per bulk write. Nothing calls them.
Why it is a no-op at the edge
| purr-api writes | Worker reads | |
|---|---|---|
| Key | abc123 (bare uid), my-slug (bare custom slug) | link:{host}:{slug} in handleRedirect, link:{slug} in fetchLinkData |
| Value | {url, userId, isActive, expiresAt, utmSource…} | {id, slug, destination, is_active, expires_at, protection_type, redirect_mode, …} |
| Binding | REST API to a namespace id | env.LINKS_CACHE, commented out in [env.lion] |
The key formats never meet, and a value read under the right key would still be missing destination, id and redirect_mode. wrangler.toml:50-55 states that enabling the binding as-is would be a regression.
Before binding LINKS_CACHE
- Pick one key format, host-scoped (
link:{host}:{slug}), because custom slugs are unique only per user and domain. - Make the value the edge’s
LinkResponseshape, includingid,redirect_modeandprotection_type. - Make the proxy probe in
index.tsuse the same host-scoped key. - Decide whether protected links stay KV-ineligible (today they must, since the prompt needs the link id but never the destination).
Key Terms
- kvEligible —
AuthorizeRedirect == allowedand expiry at least 120 s away - putKV / deleteKV — single-key Cloudflare REST calls with the API token
- bulkWrite — 10,000-entry Cloudflare bulk endpoint; uncalled
- LinkResponse — the edge’s expected link shape, which KVLinkData does not match
Q&A
Q: What triggers a KV sync?
A: Link create, link update (including removing the old custom-slug key on rename) and link delete in LinkService, each only when the KV sync service was enabled at boot. There is no scheduled refresh and no caller for the bulk paths.
Q: What is the Cloudflare KV batch size limit?
A: The code batches 10,000 entries per bulk write (batchSize = 10000), Cloudflare’s bulk-write limit, but no code path calls the bulk sync.
Q: Why are KV syncs performed asynchronously? A: So a slow or failing Cloudflare API call never delays or fails the dashboard request; the Postgres write is the durable one. The cost is silent loss: errors from the goroutine are discarded.
Q: Why is the KV sync effectively a no-op at the edge, and what has to change before LINKS_CACHE is bound?
A: purr-api writes bare-uid and bare-slug keys with a camelCase value; the Worker reads link:{host}:{slug} or link:{slug} expecting the snake_case LinkResponse, and in production has no KV binding at all. Key format, value shape and the proxy probe’s key must all be reconciled first.
Examples
An owner pauses a link. If KV credentials are set, a goroutine deletes keys abc123 and my-slug from the namespace. The Worker never looked there, so the pause takes effect at the edge immediately anyway, through the next /api/links/resolve call returning is_active: false.
neighbors on the map
- Edge Redirect Flow debugging why a slug does not redirect
- Request Routing & Edge Resolution debugging why a slug returns 404 instead of redirecting