Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/api/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export const createMapleApi = ({ stage, domains }: CreateMapleApiOptions) =>
title: resolveWorkerName("mcp-sessions", stage),
})

// EdgeCacheService backend. Binding present => KV; drop it to fall back to
// `caches.default` (see CacheBackendLive.ts for the measured reason KV is
// being trialled).
const edgeCache = yield* Cloudflare.KV.Namespace("EDGE_CACHE", {
title: resolveWorkerName("edge-cache", stage),
})

// Long-running schema-apply: chunks heavy backfill migrations across durable
// steps so they never hit the Worker request budget. Class is exported from
// src/worker.ts. The first Workflow arg IS the physical workflow name; the
Expand Down Expand Up @@ -196,6 +203,7 @@ export const createMapleApi = ({ stage, domains }: CreateMapleApiOptions) =>
AI: Cloudflare.AI.Gateway("maple-api-ai"),
CHAT_SESSION: chatSession,
MCP_SESSIONS: mcpSessions,
EDGE_CACHE: edgeCache,
// Read side of the replay payload store; absent bindings degrade to
// inline-only hydration (see platform/ReplayBlobStore.ts).
REPLAY_BLOBS: replayBlobs,
Expand Down
101 changes: 101 additions & 0 deletions apps/api/src/platform/CacheBackendLive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "@effect/vitest"
import { makeKvBackend } from "./CacheBackendLive"

/**
* Stand-in for a Workers KV binding with the two semantics that matter here:
* values are stored as strings and round-trip through JSON, and a missing key
* reads back as `null` — indistinguishable, without care, from a stored `null`.
*/
const makeFakeKv = () => {
const store = new Map<string, string>()
return {
store,
get: async (key: string, _type: "json") => {
const raw = store.get(key)
return raw === undefined ? null : (JSON.parse(raw) as unknown)
},
put: async (key: string, value: string, _options?: { expirationTtl?: number }) => {
store.set(key, value)
},
delete: async (key: string) => {
store.delete(key)
},
}
}

describe("makeKvBackend", () => {
// The regression this guards: `EdgeCacheBackend` signals "not cached" with
// `undefined`, but a cached `null` is a real value — and on the org-config
// bucket it is the common one ("managed org, use Tinybird"). KV returns
// `null` for a missing key, so a bare `null` write would make every managed
// org a permanent miss and send it to Postgres forever.
it("round-trips a cached null as a hit, not a miss", async () => {
const kv = makeFakeKv()
const backend = makeKvBackend(kv)

await backend.put("org-clickhouse-config", "hash", null, 3600, Date.now())

expect(await backend.get("org-clickhouse-config", "hash", Date.now())).toBeNull()
})

it("reports an absent key as undefined", async () => {
const backend = makeKvBackend(makeFakeKv())

expect(await backend.get("org-clickhouse-config", "never-written", Date.now())).toBeUndefined()
})

it("round-trips a struct value and drops it on delete", async () => {
const kv = makeFakeKv()
const backend = makeKvBackend(kv)
const value = { chUrl: "https://a.example", schemaVersion: "4" }

await backend.put("org-clickhouse-config", "hash", value, 3600, Date.now())
expect(await backend.get("org-clickhouse-config", "hash", Date.now())).toEqual(value)

await backend.delete("org-clickhouse-config", "hash")
expect(await backend.get("org-clickhouse-config", "hash", Date.now())).toBeUndefined()
})

// KV rejects a TTL below 60s; real buckets ask for less (`qe-evaluate` and the
// integrations routes use 30s).
it("raises a sub-minute TTL to KV's 60s storage floor", async () => {
const puts: Array<{ expirationTtl?: number }> = []
const kv = makeFakeKv()
const backend = makeKvBackend({
...kv,
put: async (key, value, options) => {
puts.push(options ?? {})
await kv.put(key, value, options)
},
})

await backend.put("qe-direct", "hash", { a: 1 }, 15, Date.now())
await backend.put("org-clickhouse-config", "hash", { a: 1 }, 3600, Date.now())

expect(puts[0]?.expirationTtl).toBe(60)
expect(puts[1]?.expirationTtl).toBe(3600)
})

// ...but the floor must not extend the entry's observable life. The other two
// backends carry their own deadline (Cache-Control max-age / expiresAt), so
// without an envelope deadline a 30s entry would be served for 60s.
it("expires a sub-minute entry at its real TTL, not at the 60s floor", async () => {
const backend = makeKvBackend(makeFakeKv())
const writtenAt = 1_000_000

await backend.put("qe-evaluate", "hash", { a: 1 }, 30, writtenAt)

expect(await backend.get("qe-evaluate", "hash", writtenAt + 29_000)).toEqual({ a: 1 })
expect(await backend.get("qe-evaluate", "hash", writtenAt + 31_000)).toBeUndefined()
})

it("keeps a long entry alive to its full TTL", async () => {
const backend = makeKvBackend(makeFakeKv())
const writtenAt = 1_000_000

await backend.put("org-clickhouse-config", "hash", null, 3600, writtenAt)

expect(await backend.get("org-clickhouse-config", "hash", writtenAt + 3_000_000)).toBeNull()
expect(await backend.get("org-clickhouse-config", "hash", writtenAt + 3_601_000)).toBeUndefined()
})
})
95 changes: 94 additions & 1 deletion apps/api/src/platform/CacheBackendLive.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Effect, Layer, Metric } from "effect"
import { WorkersCache } from "@maple/effect-cloudflare/workers-cache"
import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment"
import { CacheBackend, type EdgeCacheBackend, makeMemoryBackend } from "@maple/cache"
import * as QueryEngineMetrics from "@/observability/QueryEngineMetrics"

Expand Down Expand Up @@ -44,9 +45,101 @@ const makeWorkersBackend = (cache: Cache): EdgeCacheBackend => ({
},
})

/**
* Minimal structural view of a Workers KV binding — enough for the three
* `EdgeCacheBackend` operations. Duck-typed rather than importing
* `@cloudflare/workers-types`, matching how `worker.ts` reads `MCP_SESSIONS`.
*/
interface KvLike {
get(key: string, type: "json"): Promise<unknown | null>
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>
delete(key: string): Promise<void>
}

const isKvLike = (candidate: unknown): candidate is KvLike =>
typeof candidate === "object" &&
candidate !== null &&
"get" in candidate &&
"put" in candidate &&
"delete" in candidate

// KV rejects an `expirationTtl` below 60s.
const KV_MIN_TTL_SECONDS = 60

/**
* Workers KV as the edge-cache backend, trialled against `caches.default`.
*
* The Cache API is not slow — measured on 2026-08-10, a completed `cache.match()`
* costs a p50 of 7ms. The problem is that 15% of reads never complete: a
* `cache.match()` holds one of the Worker's six simultaneous-connection slots
* while it waits for response headers, and it 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 (p95 16.4s,
* 1003s of blocked wall time in one day). See `DEFAULT_EDGE_CACHE_READ_TIMEOUT_MS`.
*
* KV is worth trying because a `get` is an ordinary subrequest rather than an
* uncancellable cache read, so abandoning one is closer to actually free. That
* is the hypothesis, NOT a measured fact — if KV turns out to contend for the
* same six slots it will show the same bimodal split. `cache.backend` is on
* every `getOrCompute` span precisely so the two can be compared; check the
* `cache.read_status` distribution per backend before trusting this.
*
* `cacheTtl` is left at the KV default (60s). Raising it would buy more
* colo-local hits, but a colo that has the key cached would keep serving it for
* that long after an explicit `delete` — and invalidation-on-mutation is the
* property the org config cache is relying on.
*
* Values are stored wrapped as `{ v, exp }`, which the other two backends do not
* need:
*
* - `v` because `EdgeCacheBackend` uses `undefined` to mean "not cached" while a
* cached `null` is a legitimate value — for the org config bucket it is the
* COMMON one ("this org is managed, use Tinybird"), and caching it is the
* single biggest win there. KV's `get` returns `null` for a missing key, so
* storing a bare `null` would make every managed org a permanent miss.
* - `exp` because KV refuses an `expirationTtl` under 60s, and real buckets ask
* for less (`qe-evaluate` and the integrations routes use 30s). The other
* backends carry their own deadline (`Cache-Control: max-age`, `expiresAt`),
* so without this a 30s entry would be served for 60s. `expirationTtl` still
* goes out at the 60s floor to bound storage; `exp` is what expires the read.
*/
export const makeKvBackend = (kv: KvLike): EdgeCacheBackend => {
const composite = (bucket: string, hash: string) => `${bucket}:${hash}`

return {
name: "workers-kv",
get: async (bucket, hash, nowMs) => {
const envelope = await kv.get(composite(bucket, hash), "json")
if (envelope === null || typeof envelope !== "object" || !("v" in envelope)) return undefined
const { v, exp } = envelope as { v: unknown; exp?: number }
// KV cannot expire an entry sooner than 60s, so short-TTL buckets carry
// their real deadline in the envelope and expire on read.
if (typeof exp === "number" && nowMs >= exp) return undefined
return v
},
put: async (bucket, hash, value, ttlSeconds, nowMs) => {
const ttl = Math.floor(ttlSeconds)
await kv.put(
composite(bucket, hash),
JSON.stringify({ v: value, exp: nowMs + ttl * 1_000 }),
{ expirationTtl: Math.max(KV_MIN_TTL_SECONDS, ttl) },
)
},
delete: async (bucket, hash) => {
await kv.delete(composite(bucket, hash))
},
}
}

export const CacheBackendLive = Layer.effect(
CacheBackend,
Effect.gen(function* () {
const env = yield* WorkerEnvironment
const kv = env.EDGE_CACHE
if (isKvLike(kv)) {
return CacheBackend.of(makeKvBackend(kv))
}

const cache = yield* WorkersCache
if (!cache) {
// The fallback is per-isolate, so nothing is shared across requests that
Expand All @@ -62,4 +155,4 @@ export const CacheBackendLive = Layer.effect(
}
return CacheBackend.of(makeWorkersBackend(cache))
}),
).pipe(Layer.provide(WorkersCache.layer))
).pipe(Layer.provide(Layer.mergeAll(WorkersCache.layer, WorkerEnvironment.layer)))
Loading
Loading