jevstrudel.git / worker / src / answer-cache.ts
1// Jev's answers, kept for an hour in KV (the CACHE binding) so a request
2// the relay has already forwarded is answered again without spending the
3// site's key: a song whose state repeats, a page reloaded, the art check
4// on a song someone just checked.
5//
6// A request is its exact bytes. The key is the SHA-256 of the body as the
7// relay would forward it, under the pinned model, so two requests share an
8// answer only when every byte of what TypeSafe would read is the same; a
9// space, a key order or one digit of the state apart is another key. Only
10// the body is hashed: the relay forwards nothing else of the caller's (not
11// a header, not the address), so nothing else can change the answer, and
12// the key holds nothing of the caller either.
13//
14// Only a 200 is kept, and only when it is at most MAX_BYTES and arrived
15// whole; a refusal, an error or TypeSafe asking to back off never is. The
16// answer is stored after the page has it (ctx.waitUntil), so a miss is not
17// slower than no cache. A failing cache is logged and skipped: the relay
18// then forwards as if there were none.
19export const TTL_S = 3600;
20export const MAX_BYTES = 64 * 1024; // an answer is a few hundred bytes; this only bounds the buffer
21export const CACHE_HEADER = 'Jev-Cache'; // `hit` or `miss`, on every answer the relay gives
22const PREFIX = 'jev.relay/v1';
23
24type Meta = { contentType: string };
25
26export async function cacheKey(model: string, body: ArrayBuffer): Promise<string> {
27  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', body));
28  const hex = Array.from(digest, (b) => b.toString(16).padStart(2, '0')).join('');
29  return `${PREFIX}/${model}/${hex}`;
30}
31
32const failed = (op: string, e: unknown) =>
33  console.error({ event: 'jev.relay.cache', op, error: e instanceof Error ? e.message : String(e) });
34
35// The kept answer for `key`, or null.
36export async function cached(cache: KVNamespace, key: string): Promise<{ body: ArrayBuffer; contentType: string } | null> {
37  try {
38    const { value, metadata } = await cache.getWithMetadata<Meta>(key, 'arrayBuffer');
39    if (!value) return null;
40    return { body: value, contentType: metadata?.contentType ?? 'application/json' };
41  } catch (e) {
42    failed('get', e);
43    return null;
44  }
45}
46
47// Reads `stream` (a tee of the answer the page is getting) and keeps it
48// under `key`, unless it runs past MAX_BYTES or breaks off.
49export async function keep(cache: KVNamespace, key: string, stream: ReadableStream<Uint8Array>, contentType: string) {
50  const reader = stream.getReader();
51  const chunks: Uint8Array[] = [];
52  let size = 0;
53  try {
54    for (;;) {
55      const { done, value } = await reader.read();
56      if (done) break;
57      size += value.byteLength;
58      if (size > MAX_BYTES) return void (await reader.cancel());
59      chunks.push(value);
60    }
61  } catch (e) {
62    return failed('read', e);
63  }
64  const body = new Uint8Array(size);
65  let at = 0;
66  for (const chunk of chunks) {
67    body.set(chunk, at);
68    at += chunk.byteLength;
69  }
70  try {
71    await cache.put(key, body, { expirationTtl: TTL_S, metadata: { contentType } satisfies Meta });
72  } catch (e) {
73    failed('put', e);
74  }
75}
76
77// How many answers are kept now, for the data tab (data.ts): the keys under
78// this cache's prefix, counted (never read), at most `pages` KV lists of
79// 1000; `complete` says whether that was all of them. A key is a request's
80// hash under the model, so a count is all there is to show.
81export async function cacheEntries(cache: KVNamespace, pages = 1): Promise<{ entries: number; complete: boolean }> {
82  let entries = 0;
83  let cursor: string | undefined;
84  for (let i = 0; i < pages; i++) {
85    const list = await cache.list({ prefix: `${PREFIX}/`, cursor });
86    entries += list.keys.length;
87    if (list.list_complete) return { entries, complete: true };
88    cursor = list.cursor;
89  }
90  return { entries, complete: false };
91}