Skip to content

perf(api): serve the org warehouse config from a durable KV tier - #387

Merged
Makisuo merged 3 commits into
mainfrom
feat/web-events-mv
Aug 10, 2026
Merged

perf(api): serve the org warehouse config from a durable KV tier#387
Makisuo merged 3 commits into
mainfrom
feat/web-events-mv

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Why

OrgClickHouseSettingsService.resolveCachedSettings runs on the hot path of every warehouse SQL execution, and the bucket-cache fan-out re-runs it once per missing range. The SWR memo from 445d3ee9c only helps an isolate that has already paid once — and Workers evict isolates constantly.

Prod, the three days since that memo landed:

clickhouse.config.source /day p50 blocked wall time/day
memo ~190k 0ms 0
memo_stale ~23k 0ms 0
postgres 10–13k 482–636ms 5,000–9,400 s

~4.5% of resolutions still cost half a second each — 1.4–2.6 hours of blocked wall time per day. That cost is the per-.execute() postgres.js dial, not the query. (The "~26ms Postgres read" in the old comment was the edge-cache read p50, not this one.)

Only a shared cache removes it: the whole point is to help an isolate that has never seen the org.

Why KV and not caches.default

The Cache API is not slow — a completed cache.match() is 7ms p50. Every read in maple-api on 2026-08-10:

cache.read_status n p50 p95 total blocked
hit 297 7ms 17ms 3s
miss 740 135ms 800ms 200s
timeout 190 (15%) 40ms 16,371ms 1,003s

15% of reads never complete. cache.match() holds one of the Worker's six simultaneous-connection slots while awaiting headers and is not cancellable, so the 40ms deadline abandons the wait but not the slot — compute then opens a seventh connection and queues behind the read it just gave up on. That's the timeout p50 of 40ms (deadline fired on time) next to a p95 of 16.4s (fallback starved). It's why the tier was removed from this path in the first place.

A KV get is an ordinary subrequest, so abandoning one is closer to actually free.

This is a hypothesis, not a measurement. If KV contends for the same six slots it will show the same bimodal split. cache.backend is on every getOrCompute span so the two are directly comparable, and dropping the EDGE_CACHE binding reverts to the Cache API with no code change.

What changed

  • CacheBackend gains a KV implementation (CacheBackendLive.ts), selected when EDGE_CACHE is bound. This swaps the backend for every bucket, so qe-direct / qe-execute / autumn-customer — which show the same pathology — get measured too.
  • The config read goes through EdgeCacheService between memo and Postgres, resolved with Effect.serviceOption so tests and unbound hosts degrade to a direct read. readTimeoutMs is 150ms rather than the 40ms default: that default assumes a cheap compute, and here compute is the ~500ms dial.
  • Invalidation becomes load-bearing. A missed memo bust self-healed in minutes; a missed KV bust is an hour of the whole fleet reading a config that no longer exists. invalidateOrgRuntimeConfig drops both tiers, and every writer calls it — including both schema-apply workflow writes, which stamp schema_version (part of the cached projection).
  • Memo hard ceiling 15min → 6h. It's an isolate-lifetime backstop, not a staleness bound — the 5min soft TTL is. At 15min it made a bursty widget fan-out block at the head of every burst.

Reviewer notes

The bug worth looking at. KV returns null for a missing key, while EdgeCacheBackend uses undefined for "not cached" — and a cached null is a real value here, in fact the common one ("managed org, use Tinybird"). A bare null write would have made every managed org a permanent miss and sent it to Postgres forever. Values are stored wrapped as { v }. The memory backend can't catch this class of bug because it stores raw values, so CacheBackendLive.test.ts tests the KV backend directly.

Test semantics changed. Four existing tests failed on the first run: they used a raw UPDATE org_clickhouse_settings as the instrument for observing memo mechanics, and a raw write is now invisible to the durable tier too. They now call an evictDurable helper so they still test the memo rather than silently asserting nothing. The same trap will catch the next test written against this path.

Scope. apps/api only. apps/alerting has no KV binding and still uses caches.default — sharing one namespace across two alchemy stacks needs an ID reference rather than a second create, which I didn't want to do silently. Alerting reads this config but never writes it, so there's no invalidation correctness issue.

Deploy note. Adds an EDGE_CACHE KV namespace binding (wrangler.jsonc + alchemy.run.ts).

Verification

  • bunx vitest run CacheBackendLive OrgClickHouseSettingsService — 29 passed
  • bunx vitest run WarehouseQueryService ClickHouseSchemaApplyWorkflow — passed
  • lib/cache suite — 17 passed
  • bun typecheck — clean

Post-deploy, before trusting this: group cache.read_status by cache.backend. If workers-kv shows a timeout rate anything like the Cache API's 15%, pull the binding. Then confirm clickhouse.config.source = "postgres" collapses toward zero.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Open in Devin Review

The SWR memo shipped in 445d3ee only helps an isolate that has already
paid once, and Workers evict isolates constantly. Measured over the three
days since it landed, `resolveCachedSettings` still fell through to
Postgres 10-13k times/day at a p50 of 482-636ms each -- 1.4-2.6 hours of
blocked wall time per day, on a read that sits in front of every widget
query on a dashboard. That cost is the per-`.execute()` postgres.js dial,
not the query; the "~26ms Postgres read" in the old comment was the
edge-cache read p50, not this.

Only a shared cache removes it, since the whole point is to help an
isolate that has never seen the org.

Put that tier on Workers KV rather than `caches.default`. The Cache API
is not slow -- a completed `cache.match()` is 7ms p50 -- but it holds one
of the Worker's six simultaneous-connection slots while awaiting headers
and is not cancellable, so the read deadline abandons the wait but not
the slot and `compute` queues behind a seventh connection. Still true on
2026-08-10 across the remaining buckets: 15% of reads time out, p95
16.4s, 1003s blocked in a day. A KV `get` is an ordinary subrequest, so
abandoning one is closer to actually free.

That last point is a hypothesis, not a measurement. `cache.backend` is on
every getOrCompute span so the two can be compared; if KV contends for
the same six slots it will show the same bimodal split, and dropping the
EDGE_CACHE binding reverts to the Cache API with no code change.

- CacheBackend gains a KV implementation, selected when EDGE_CACHE is
  bound. Swaps the backend for every bucket, so qe-direct / qe-execute /
  autumn-customer -- which show the same pathology -- get measured too.
- The config read goes through EdgeCacheService between memo and
  Postgres, resolved with `Effect.serviceOption` so tests and unbound
  hosts degrade to a direct read. `readTimeoutMs` is 150ms, not the 40ms
  default, because that default assumes a cheap `compute` and here
  `compute` is the ~500ms dial.
- Invalidation becomes load-bearing: a missed memo bust self-healed in
  minutes, a missed KV bust is an hour of the whole fleet reading a
  config that no longer exists. `invalidateOrgRuntimeConfig` drops both
  tiers and every writer calls it, including both schema-apply workflow
  writes (they stamp `schema_version`, which is in the cached
  projection).
- Raise the memo hard ceiling 15min -> 6h. It is an isolate-lifetime
  backstop, not a staleness bound -- the soft TTL is -- and at 15min it
  made a bursty widget fan-out block at the head of every burst.

KV returns null for a missing key while EdgeCacheBackend uses undefined
for "not cached", and a cached null is a real value -- on this bucket the
common one ("managed org, use Tinybird"). Values are stored wrapped as
`{ v }`; a bare null would have made every managed org a permanent miss.
The memory backend cannot catch that class of bug because it stores raw
values, so CacheBackendLive.test.ts covers it directly.

Scoped to apps/api. apps/alerting has no KV binding and still uses
`caches.default`; sharing one namespace across two alchemy stacks needs
an ID reference rather than a second create.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread apps/api/src/services/org/OrgClickHouseSettingsService.ts
Comment thread apps/api/src/platform/CacheBackendLive.ts Outdated
Comment thread apps/api/src/services/org/OrgClickHouseSettingsService.ts
Two CI failures from the KV tier, both invisible locally because I only
ran targeted files.

`WorkerEnvironment` destructures `env` off the `cloudflare:workers`
module, but the vitest stub only exported `DurableObject` and
`WorkflowEntrypoint`. Every consumer so far layered its bindings in
rather than reading them off the service, so nothing had dereferenced
the result before; `CacheBackendLive` reading `env.EDGE_CACHE` is the
first, and it crashed 10 alerts.http tests with "Cannot read properties
of undefined". Export `env` from the stub so the double matches the real
module, and make `WorkerEnvironment.layer` fall back to `{}` so a host
without one hands out an empty binding record rather than `undefined`
behind a non-nullable type.

The SWR refresh assertions also went flaky. A forked refresh completes
when real promises settle -- a PGlite read, plus a durable-tier read
whose key hashing goes through `crypto.subtle` -- and none of that is
driven by `TestClock`, so `adjust(1)` was never a guarantee. Adding the
durable tier lengthened that chain enough for a slower CI machine to
lose the race. Poll for the refreshed value with `resolveUntilUrl`
instead, bounded so a refresh that never lands still fails. The stale
assertion stays exactly where it was, so "nobody blocked" is still
asserted on the nose.
Two findings from review, both real.

`apps/alerting` runs OrgClickHouseSettingsService too, but it has no
EDGE_CACHE binding, so its CacheBackend is `caches.default` -- a store
no `invalidateOrgRuntimeConfig` ever reaches, because every writer lives
in apps/api and busts KV. An hour-long entry there would let alert
evaluation keep using a warehouse config the customer already rotated or
deleted: firing on stale data, or missing incidents outright. My PR
description waved this away on the grounds that alerting never writes
the config, which was the wrong question -- the hazard is that it reads
from a store the writers cannot bust.

Expose `backendName` on EdgeCacheServiceShape and use the durable tier
only where invalidation lands: `workers-kv` (the binding apps/api owns,
where the busts go) and `memory` (per-isolate, self-consistent by
construction). A `workers-cache` host falls back to memo + Postgres --
slower, and right. The proper fix is binding the same namespace into the
alerting stack, which already binds Hyperdrive by ID across stacks;
until then the tier stays off there rather than silently serving stale
routing.

Second: KV refuses an `expirationTtl` under 60s and the backend clamped
upward, but real buckets ask for less -- `qe-evaluate` and the
integrations routes use 30s. The other two backends carry their own
deadline (`Cache-Control: max-age`, `expiresAt`), so a 30s entry was
being served for 60s. Carry the real deadline in the envelope as `exp`
and expire on read; `expirationTtl` still goes out at the floor to bound
storage.
@Makisuo
Makisuo merged commit 9e9f742 into main Aug 10, 2026
22 checks passed
@Makisuo
Makisuo deleted the feat/web-events-mv branch August 10, 2026 21:33
@Makisuo
Makisuo deployed to pr-preview August 10, 2026 21:33 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit 78f124b · View workflow run

Makisuo added a commit that referenced this pull request Aug 10, 2026
#387 put the org warehouse config on a durable KV tier, on the hypothesis
that a KV `get` is a cancellable subrequest and so cheaper to abandon than
an uncancellable `cache.match()`. `makeKvBackend` said to treat it as an
experiment until `cache.read_status` per `cache.backend` said otherwise.
It does. Measured over 24h on the live deploy:

- KV reads that COMPLETE take 92ms (vs 6ms on the Cache API), and 79% of
  them hit their deadline anyway.
- It was bound where the cost isn't. 94% of the Postgres fallback it
  targets is apps/alerting (4,646 resolutions/day at p50 573ms, ~44min of
  blocked wall time), which has no KV binding and so could never use the
  tier. apps/api, which had it, falls back at p50 24ms — cheaper than a
  KV read.
- It regressed three unrelated buckets: CacheBackendLive returned KV
  whenever EDGE_CACHE was bound, so qe-direct, qe-execute and
  autumn-customer flipped to KV at the 40ms service default — below KV's
  ~92ms floor, so they time out 100% of the time at span p50 9.0s.

The reason this and the earlier Cache API attempt both failed is that the
cost is not a cold-isolate miss. Grouping alerting's Postgres resolutions
by trace: 1,033 traces do zero, while 106 traces do 22 each — half of all
of them. It is an in-request fan-out where every branch misses the memo
because none has finished writing it yet. No shared cache fixes concurrent
siblings; it just turns N Postgres reads into N cache reads contending for
the same six connection slots. The fix is to resolve the config once
before the fan-out, the way `warehouse.warmRoute` already does in
query-engine.http.ts — follow-up, not in this change.

With KV gone the durable tier is dead in prod (durableTierIsInvalidated
would admit only "memory"), so it is removed entirely rather than left
alive only under test. The memo + Postgres path is unchanged, and it is
strictly more correct: no entry can outlive an invalidation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant