perf(console): Gitea 等leo清單讀取加 CF Cache 層(TTL 90s,#36 審查①)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
This commit is contained in:
2026-07-07 11:19:19 +00:00
parent 99da35a885
commit 0d9a6e8eed
3 changed files with 193 additions and 3 deletions
@@ -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 };
}
@@ -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<WaitingM
}
}
export type GiteaWaitingFetcher = (env: Bindings, nowMs: number) => Promise<WaitingModel | null>;
/**
* fetchGiteaWaiting 的快取層(總管 #36 審查要求):CF Cache APIcaches.default)、
* TTL 90sGITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
* 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
*
* - key:合成 URLCache 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<unknown>) => 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/missfallback 路徑為 null)——快取生效的客觀證據
cache: waitingCache,
},
inbox_new: inboxNew,
system: {
@@ -0,0 +1,99 @@
/**
* cachedGiteaWaiting 快取層測試(總管 #36 審查要求的證據)。
*
* 跑在 vitest-pool-workersworkerd)裡,caches.default 是真的 Cache API——
* 不是 mock cache 物件;fetcher 用注入的假函式計數,證明:
* 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hitfetcher 不再被打)
* 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 的 promiseflush 後 cache.put 才保證落地 */
function collector() {
const pending: Promise<unknown>[] = [];
return { waitUntil: (p: Promise<unknown>) => pending.push(p), flush: () => Promise.all(pending) };
}
describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s', () => {
it('第一次 miss 打 fetcher90s 內第二次 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);
});
});