From 0d9a6e8eed4fbe05451b7ff6faf4ecc42e19e2cf Mon Sep 17 00:00:00 2001 From: cloud-worker Date: Tue, 7 Jul 2026 11:19:19 +0000 Subject: [PATCH] =?UTF-8?q?perf(console):=20Gitea=20=E7=AD=89leo=E6=B8=85?= =?UTF-8?q?=E5=96=AE=E8=AE=80=E5=8F=96=E5=8A=A0=20CF=20Cache=20=E5=B1=A4?= =?UTF-8?q?=EF=BC=88TTL=2090s=EF=BC=8C#36=20=E5=AF=A9=E6=9F=A5=E2=91=A0?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cachedGiteaWaiting:caches.default + Cache-Control max-age=90;前端 60s 刷新下 Gitea 從每分鐘 3-4 call 降到 ≤1 輪/90s - hit 回放用 reviveWaitingAges 隨牆鐘補算「維護於 N 分鐘前」(不停走) - 失敗不快取(不把網路抖動放大成 90 秒盲區);cache.put 走 waitUntil 不阻塞 - waiting_meta.cache 吐 hit/miss,部署後 curl 兩次即得快取生效證據 - 測試:tests/console-dashboard-cache.test.ts 4 案例(真 workerd Cache API, 注入 fetcher 計數證明 hit 不再打 Gitea);全套 69/70(1 fail=main 既有) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk --- .../src/lib/console-dashboard-model.ts | 23 +++++ .../src/routes/console-dashboard.ts | 74 +++++++++++++- .../tests/console-dashboard-cache.test.ts | 99 +++++++++++++++++++ 3 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 cypher-executor/tests/console-dashboard-cache.test.ts diff --git a/cypher-executor/src/lib/console-dashboard-model.ts b/cypher-executor/src/lib/console-dashboard-model.ts index c2713b2..da8d2d0 100644 --- a/cypher-executor/src/lib/console-dashboard-model.ts +++ b/cypher-executor/src/lib/console-dashboard-model.ts @@ -257,3 +257,26 @@ export function humanAge(minutes: number): string { if (minutes < 48 * 60) return `${Math.round(minutes / 60)} 小時前`; return `${Math.round(minutes / (24 * 60))} 天前`; } + +// ── Gitea 讀取快取(總管 #36 審查要求:查詢面加快取——別讓前端 60 秒刷新變成 +// 每分鐘 3-4 個 API call 打自家 Gitea;快取是讀,不違「不輪詢」紅線)────────── + +/** 快取 TTL(秒)。90s = 前端 60s 刷新下,Gitea 實際被打 ≤1 輪/90s; + * 等leo清單由 progress-guard 每日維護,90 秒新鮮度綽綽有餘。 */ +export const GITEA_WAITING_CACHE_TTL_SECONDS = 90; + +/** Cache API 裡存的封套:模型 + fetch 當下的牆鐘(回放時補算 age 用)。 */ +export interface CachedWaitingEnvelope { + model: WaitingModel; + fetched_at_ms: number; +} + +/** 快取回放時把「維護於 N 分鐘前」隨牆鐘推進:存的是 fetch 當下算好的 ago, + * 直接回放會讓時間停走(最多差一個 TTL,雖小仍是假時間);補回經過的分鐘數、 + * stale 判定同步重算,才符合「不擺 stale 殘骸」的原則。 */ +export function reviveWaitingAges(model: WaitingModel, fetchedAtMs: number, nowMs: number): WaitingModel { + if (model.updated_ago_minutes < 0) return model; + const drift = Math.max(0, Math.round((nowMs - fetchedAtMs) / 60000)); + const ago = model.updated_ago_minutes + drift; + return { ...model, updated_ago_minutes: ago, stale: ago > 48 * 60 }; +} diff --git a/cypher-executor/src/routes/console-dashboard.ts b/cypher-executor/src/routes/console-dashboard.ts index 60ac8ae..e9636f8 100644 --- a/cypher-executor/src/routes/console-dashboard.ts +++ b/cypher-executor/src/routes/console-dashboard.ts @@ -42,6 +42,8 @@ import { type KbdbEntry, type WaitingItem, type WaitingModel, + type CachedWaitingEnvelope, + GITEA_WAITING_CACHE_TTL_SECONDS, parseCreatedAtMs, parseJsonContent, agoMinutes, @@ -49,6 +51,7 @@ import { buildWaitingFallback, parseSprintWaitingTable, pickLatestSprintFiles, + reviveWaitingAges, sortWaitingItems, } from '../lib/console-dashboard-model'; @@ -143,6 +146,66 @@ async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise Promise; + +/** + * fetchGiteaWaiting 的快取層(總管 #36 審查要求):CF Cache API(caches.default)、 + * TTL 90s(GITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從 + * 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。 + * + * - key:合成 URL(Cache API 要求合法 URL;host 用不會真的被打的保留名),帶 + * base/repo/dir 參數——設定變了自然 miss,不會吐到別的 Gitea 的殘資料。 + * - hit 回放時用 reviveWaitingAges 把「維護於 N 分鐘前」隨牆鐘補算(存的是 fetch + * 當下的 ago,直接回放會讓時間停走)。 + * - **失敗不快取**:negative cache 會把一時網路抖動放大成 90 秒盲區,caller 該 + * 當場 fallback dash_wait。 + * - cache.put 走 waitUntil(不阻塞回應);fetcher 參數可注入=單測不用真打網路。 + * - 回傳多帶 cache:'hit'|'miss',吐進 waiting_meta 當快取生效的客觀證據(curl 兩次 + * 第二次該是 hit)。 + */ +export async function cachedGiteaWaiting( + env: Bindings, + nowMs: number, + waitUntil: (p: Promise) => void, + fetcher: GiteaWaitingFetcher = fetchGiteaWaiting, +): Promise<(WaitingModel & { cache: 'hit' | 'miss' }) | null> { + if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null; + const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo'; + const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch'; + const cacheKey = new Request( + `https://console-dashboard.arcrun.internal/gitea-waiting?${new URLSearchParams({ base: env.GITEA_BASE_URL, repo, dir }).toString()}`, + ); + const cache = caches.default; + try { + const hit = await cache.match(cacheKey); + if (hit) { + const envelope = (await hit.json()) as CachedWaitingEnvelope; + return { ...reviveWaitingAges(envelope.model, envelope.fetched_at_ms, nowMs), cache: 'hit' }; + } + } catch { + /* cache 故障不致命,走 miss 路徑 */ + } + const fresh = await fetcher(env, nowMs); + if (!fresh) return null; // 失敗不快取,caller 誠實 fallback + const envelope: CachedWaitingEnvelope = { model: fresh, fetched_at_ms: nowMs }; + try { + waitUntil( + cache.put( + cacheKey, + new Response(JSON.stringify(envelope), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': `public, max-age=${GITEA_WAITING_CACHE_TTL_SECONDS}`, + }, + }), + ), + ); + } catch { + /* put 失敗只是少了快取,不影響本次回應 */ + } + return { ...fresh, cache: 'miss' }; +} + // GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值) consoleDashboardRouter.get('/console/dashboard-data', async (c) => { const tenant = c.env.CONSOLE_TENANT || 'leo'; @@ -167,7 +230,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => { fetchEntries(c.env, tenant, 'dash_task', 200), fetchEntries(c.env, tenant, 'dash_wait', 100), fetchEntries(c.env, tenant, 'inbox', 200), - fetchGiteaWaiting(c.env, now), + cachedGiteaWaiting(c.env, now, (p) => c.executionCtx.waitUntil(p)), fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders), fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders), fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`), @@ -198,10 +261,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => { // 今日路線:dash_task + 台北日曆日判定(純函式,見 lib/console-dashboard-model.ts) const route = buildRouteModel(taskEntries, now); - // 等你的事:Gitea sprint 等leo清單優先;讀不到 fallback dash_wait(帶 age + stale) + // 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale) let waiting: WaitingModel; + let waitingCache: 'hit' | 'miss' | null = null; if (giteaWaiting) { - waiting = giteaWaiting; + const { cache: cacheState, ...model } = giteaWaiting; + waiting = model; + waitingCache = cacheState; } else { waiting = buildWaitingFallback(waitEntries, now); if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) { @@ -265,6 +331,8 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => { stale: waiting.stale, sprint_files: waiting.sprint_files ?? null, note: waiting.note ?? null, + // Gitea 快取層狀態(hit/miss;fallback 路徑為 null)——快取生效的客觀證據 + cache: waitingCache, }, inbox_new: inboxNew, system: { diff --git a/cypher-executor/tests/console-dashboard-cache.test.ts b/cypher-executor/tests/console-dashboard-cache.test.ts new file mode 100644 index 0000000..8383583 --- /dev/null +++ b/cypher-executor/tests/console-dashboard-cache.test.ts @@ -0,0 +1,99 @@ +/** + * cachedGiteaWaiting 快取層測試(總管 #36 審查要求的證據)。 + * + * 跑在 vitest-pool-workers(workerd)裡,caches.default 是真的 Cache API—— + * 不是 mock cache 物件;fetcher 用注入的假函式計數,證明: + * 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hit(fetcher 不再被打) + * 2. hit 回放時 age 隨牆鐘推進(不回放停走的時間) + * 3. fetcher 失敗(回 null)不快取——下次仍會重試,不把抖動放大成 90 秒盲區 + * 4. 缺 GITEA_BASE_URL/GITEA_TOKEN 直接 null(快取層不啟動) + */ +import { describe, it, expect } from 'vitest'; +import { cachedGiteaWaiting } from '../src/routes/console-dashboard'; +import type { WaitingModel } from '../src/lib/console-dashboard-model'; +import type { Bindings } from '../src/types'; + +function fakeEnv(repo: string): Bindings { + return { + GITEA_BASE_URL: 'https://git.example.test', + GITEA_TOKEN: 'test-token', + GITEA_SPRINT_REPO: repo, // 每測試一個 repo → cache key 隔離 + GITEA_SPRINT_DIR: 'system-dev/docs/3-specs/autonomy-dispatch', + } as unknown as Bindings; +} + +function model(): WaitingModel { + return { + items: [{ title: 'mira .env 歷史憑證外洩', id: '13', urgency: '🔴', sprint: 'sprint-2026-07a.md' }], + source: 'gitea_sprint', + updated_ago_minutes: 10, + stale: false, + sprint_files: ['sprint-2026-07b.md', 'sprint-2026-07a.md'], + }; +} + +/** 收集 waitUntil 的 promise,flush 後 cache.put 才保證落地 */ +function collector() { + const pending: Promise[] = []; + return { waitUntil: (p: Promise) => pending.push(p), flush: () => Promise.all(pending) }; +} + +describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s)', () => { + it('第一次 miss 打 fetcher;90s 內第二次 hit、fetcher 不再被打', async () => { + const env = fakeEnv('Leo/cache-test-1'); + let calls = 0; + const fetcher = async () => { + calls++; + return model(); + }; + const c = collector(); + const t0 = Date.now(); + + const r1 = await cachedGiteaWaiting(env, t0, c.waitUntil, fetcher); + expect(r1?.cache).toBe('miss'); + expect(calls).toBe(1); + await c.flush(); // cache.put 落地 + + const r2 = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, fetcher); + expect(r2?.cache).toBe('hit'); // ← 快取生效的客觀證據 + expect(calls).toBe(1); // fetcher 沒有被第二次呼叫=Gitea 沒被打 + expect(r2?.items[0].id).toBe('13'); + }); + + it('hit 回放時 age 隨牆鐘推進(存 10 分鐘前、過 60s 讀 → 11 分鐘前),不停走', async () => { + const env = fakeEnv('Leo/cache-test-2'); + const c = collector(); + const t0 = Date.now(); + await cachedGiteaWaiting(env, t0, c.waitUntil, async () => model()); + await c.flush(); + const r = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, async () => model()); + expect(r?.cache).toBe('hit'); + expect(r?.updated_ago_minutes).toBe(11); // 10 + 1 分鐘 drift + }); + + it('fetcher 失敗(null)不快取:下一次仍重打 fetcher', async () => { + const env = fakeEnv('Leo/cache-test-3'); + const c = collector(); + let calls = 0; + const failing = async () => { + calls++; + return null; + }; + expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull(); + await c.flush(); + expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull(); + expect(calls).toBe(2); // 失敗沒有被 90 秒快取住 + }); + + it('缺 GITEA_BASE_URL/GITEA_TOKEN → null(快取層不啟動,caller 走 fallback)', async () => { + const env = { GITEA_SPRINT_REPO: 'Leo/x' } as unknown as Bindings; + let calls = 0; + const fetcher = async () => { + calls++; + return model(); + }; + const c = collector(); + expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, fetcher)).toBeNull(); + expect(calls).toBe(0); + }); +});