@aiquants/daily-report 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/client.d.mts +11 -2
- package/dist/client.d.ts +11 -2
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/dist/server.d.mts +43 -1
- package/dist/server.d.ts +43 -1
- package/dist/server.js +9 -9
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +9 -9
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client/contexts/daily-report-action-context.spec.tsx +58 -0
- package/src/client/contexts/daily-report-action-context.tsx +23 -1
- package/src/client/hooks/use-daily-report.spec.ts +57 -0
- package/src/client/hooks/use-daily-report.ts +7 -2
- package/src/client/utils/constants.ts +11 -1
- package/src/server/cache.spec.ts +365 -1
- package/src/server/cache.ts +79 -3
- package/src/server/service.spec.ts +103 -1
- package/src/server/service.ts +48 -22
- package/src/server/sse-reader.spec.ts +248 -1
- package/src/server/sse-reader.ts +52 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/daily-report",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Reusable daily-report feature package: shared types/schemas, React (Router v7) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).",
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"**/*.css"
|
|
@@ -276,3 +276,61 @@ describe("initialItems の既定値 (無限レンダー防止)", () => {
|
|
|
276
276
|
expect(contextRef.current?.items).toEqual([])
|
|
277
277
|
})
|
|
278
278
|
})
|
|
279
|
+
|
|
280
|
+
describe("SSE 自動接続の遅延 (初回描画へ接続枠を譲る)", () => {
|
|
281
|
+
// isSseEnabled は render 時点で読む (effect 経由の contextRef はタイマー flush と 1 ティックずれる)
|
|
282
|
+
const renderState: { current: DailyReportActionContextType | null } = { current: null }
|
|
283
|
+
const RenderCapture = () => {
|
|
284
|
+
renderState.current = useDailyReportActionContext()
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
it("初期は未接続で、遅延後に自動接続する", async () => {
|
|
289
|
+
vi.useFakeTimers()
|
|
290
|
+
try {
|
|
291
|
+
// セッションは張らない (このテストは SSE 遅延だけを見る)。userId ありで自動接続対象
|
|
292
|
+
render(
|
|
293
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A">
|
|
294
|
+
<RenderCapture />
|
|
295
|
+
</DailyReportActionProvider>,
|
|
296
|
+
)
|
|
297
|
+
await act(async () => {
|
|
298
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
299
|
+
})
|
|
300
|
+
// 初回描画時点では未接続 (HTTP/1.1 の接続枠を初回の中身フェッチへ譲る)
|
|
301
|
+
expect(renderState.current?.isSseEnabled).toBe(false)
|
|
302
|
+
// 遅延経過で自動接続する
|
|
303
|
+
await act(async () => {
|
|
304
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
305
|
+
})
|
|
306
|
+
expect(renderState.current?.isSseEnabled).toBe(true)
|
|
307
|
+
} finally {
|
|
308
|
+
vi.useRealTimers()
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it("手動トグルは自動接続タイマーに上書きされない", async () => {
|
|
313
|
+
vi.useFakeTimers()
|
|
314
|
+
try {
|
|
315
|
+
render(
|
|
316
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A">
|
|
317
|
+
<RenderCapture />
|
|
318
|
+
</DailyReportActionProvider>,
|
|
319
|
+
)
|
|
320
|
+
await act(async () => {
|
|
321
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
322
|
+
})
|
|
323
|
+
// ユーザーが手動で ON → OFF した後、遅延タイマーが発火しても手動 OFF を尊重する
|
|
324
|
+
act(() => renderState.current?.toggleSse())
|
|
325
|
+
expect(renderState.current?.isSseEnabled).toBe(true)
|
|
326
|
+
act(() => renderState.current?.toggleSse())
|
|
327
|
+
expect(renderState.current?.isSseEnabled).toBe(false)
|
|
328
|
+
await act(async () => {
|
|
329
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
330
|
+
})
|
|
331
|
+
expect(renderState.current?.isSseEnabled).toBe(false)
|
|
332
|
+
} finally {
|
|
333
|
+
vi.useRealTimers()
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
})
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "../hooks/use-daily-report"
|
|
25
25
|
import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
|
|
26
26
|
import { getDailyReportIdsStreamSessionUserKey, refreshDailyReportIdsStream, removeDailyReportIdsStreamItem, upsertDailyReportIdsStreamItem, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
27
|
+
import { SSE_INITIAL_CONNECT_DEFER_MS } from "../utils/constants"
|
|
27
28
|
import { useDailyReportErrorSurface } from "./daily-report-error-context"
|
|
28
29
|
|
|
29
30
|
const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
|
|
@@ -181,7 +182,10 @@ export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY
|
|
|
181
182
|
const resolvedIdMap = useRef<Map<number, number>>(new Map())
|
|
182
183
|
const [version, setVersion] = useState(0)
|
|
183
184
|
const [items, setItems] = useState<DailyReportItem[]>(initialItems)
|
|
184
|
-
|
|
185
|
+
// SSE は初回描画とその最初の中身フェッチに接続枠を譲るため、少し遅らせて自動接続する
|
|
186
|
+
// (HTTP/1.1 の 6 接続のうち 1 枠を初回描画へ回す)。手動トグル済みなら自動接続しない
|
|
187
|
+
const [isSseEnabled, setIsSseEnabled] = useState(false)
|
|
188
|
+
const hasManualSseToggleRef = useRef(false)
|
|
185
189
|
const pendingAddPromises = useRef<Map<number, Promise<number>>>(new Map())
|
|
186
190
|
const manuallyTrackingIds = useRef<Set<number>>(new Set())
|
|
187
191
|
/** 削除済み ID を記録し、マージロジックでの復活を防止する */
|
|
@@ -369,9 +373,27 @@ export const DailyReportActionProvider = ({ children, user, initialItems = EMPTY
|
|
|
369
373
|
// 未登録ユーザーは SSE を有効化できない
|
|
370
374
|
const toggleSse = () => {
|
|
371
375
|
if (!userId) return
|
|
376
|
+
// 手動操作後は自動接続タイマーに上書きさせない
|
|
377
|
+
hasManualSseToggleRef.current = true
|
|
372
378
|
setIsSseEnabled((prev) => !prev)
|
|
373
379
|
}
|
|
374
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Defers the initial SSE auto-connect until just after first paint.
|
|
383
|
+
* 初回 SSE 自動接続を初回描画直後まで遅らせる副作用。
|
|
384
|
+
*
|
|
385
|
+
* 依存配列: userId (ユーザーごとに provider が key リマウントされるため、切替で張り直す)。
|
|
386
|
+
* クリーンアップ: タイマー解除 (アンマウント/再マウントでのリークと二重発火を防ぐ)。
|
|
387
|
+
*/
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (!userId) return
|
|
390
|
+
const timer = setTimeout(() => {
|
|
391
|
+
// 手動でトグルされていなければ自動接続する
|
|
392
|
+
if (!hasManualSseToggleRef.current) setIsSseEnabled(true)
|
|
393
|
+
}, SSE_INITIAL_CONNECT_DEFER_MS)
|
|
394
|
+
return () => clearTimeout(timer)
|
|
395
|
+
}, [userId])
|
|
396
|
+
|
|
375
397
|
/**
|
|
376
398
|
* Remove a stale item from the list as a safety net.
|
|
377
399
|
* フェッチ後にレポートが見つからなかった場合の安全弁としてアイテムを除去する。
|
|
@@ -201,3 +201,60 @@ describe("getGlobal behavior (window グローバル変数の読み取り)", ()
|
|
|
201
201
|
expect(mod.getCachedReport(1)).toBeNull()
|
|
202
202
|
})
|
|
203
203
|
})
|
|
204
|
+
|
|
205
|
+
describe("useDailyReportDetail の初回フェッチ (デバウンス適用範囲)", () => {
|
|
206
|
+
let mod: typeof import("./use-daily-report")
|
|
207
|
+
|
|
208
|
+
beforeEach(async () => {
|
|
209
|
+
vi.resetModules()
|
|
210
|
+
mod = await import("./use-daily-report")
|
|
211
|
+
vi.useFakeTimers()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
vi.useRealTimers()
|
|
216
|
+
vi.unstubAllGlobals()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/** business-date 応答を 1 件返す fetch スタブ。呼び出し回数を記録する。 */
|
|
220
|
+
const stubFetch = () => {
|
|
221
|
+
const calls: string[] = []
|
|
222
|
+
vi.stubGlobal(
|
|
223
|
+
"fetch",
|
|
224
|
+
vi.fn(async (input: RequestInfo | URL) => {
|
|
225
|
+
calls.push(String(input))
|
|
226
|
+
return new Response(JSON.stringify({ reports: [makeMockReport({ reportHubId: 7, date: "2026-01-01" })] }), { status: 200, headers: { "Content-Type": "application/json" } })
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
return calls
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
it("未キャッシュの初回は即フェッチする (120ms のデバウンスを掛けない)", async () => {
|
|
233
|
+
const { renderHook, act } = await import("@testing-library/react")
|
|
234
|
+
const calls = stubFetch()
|
|
235
|
+
renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
|
|
236
|
+
// タイマーを進めずマイクロタスクだけ流す → 即時 (0ms) 発火の検証
|
|
237
|
+
await act(async () => {
|
|
238
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
239
|
+
})
|
|
240
|
+
expect(calls.length).toBe(1)
|
|
241
|
+
expect(calls[0]).toContain("business-date?businessDate=2026-01-01")
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("キャッシュ済み (stale) の再取得はデバウンスされる", async () => {
|
|
245
|
+
const { renderHook, act } = await import("@testing-library/react")
|
|
246
|
+
const calls = stubFetch()
|
|
247
|
+
// 期限切れのキャッシュを用意 → 表示は即、再取得はデバウンス対象
|
|
248
|
+
mod.writeCache(makeMockReport({ reportHubId: 7, date: "2026-01-01" }), Date.now() - 1)
|
|
249
|
+
renderHook(() => mod.useDailyReportDetail(7, "2026-01-01"))
|
|
250
|
+
await act(async () => {
|
|
251
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
252
|
+
})
|
|
253
|
+
// 0ms 時点では発火しない (スクロール churn 吸収のためのデバウンス)
|
|
254
|
+
expect(calls.length).toBe(0)
|
|
255
|
+
await act(async () => {
|
|
256
|
+
await vi.advanceTimersByTimeAsync(120)
|
|
257
|
+
})
|
|
258
|
+
expect(calls.length).toBe(1)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
@@ -7,6 +7,7 @@ import { normalizeBusinessDateKey } from "../../shared/business-date"
|
|
|
7
7
|
import { createLogger, LogLevel } from "../../shared/logger"
|
|
8
8
|
import type { DailyReportDetail } from "../../shared/types"
|
|
9
9
|
import { useDailyReportConfig } from "../config-context"
|
|
10
|
+
import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
|
|
10
11
|
|
|
11
12
|
const logger = createLogger(LogLevel.INFO, "use-daily-report")
|
|
12
13
|
|
|
@@ -22,7 +23,6 @@ const getGlobal = (k: string, d: number): number => {
|
|
|
22
23
|
}
|
|
23
24
|
const TTL = getGlobal("__DAILY_REPORT_CACHE_TTL__", 300_000)
|
|
24
25
|
const REVIVE_MS = getGlobal("__DAILY_REPORT_CACHE_REVIVE_MS__", 60_000)
|
|
25
|
-
const FETCH_DEBOUNCE_MS = 120
|
|
26
26
|
|
|
27
27
|
// --- Cache Store (Centralized State) ---
|
|
28
28
|
type CacheEntry<T> = { data: T; expiresAt: number }
|
|
@@ -430,6 +430,11 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
|
|
|
430
430
|
setIsLoading(!cached)
|
|
431
431
|
setIsRefetching(true)
|
|
432
432
|
|
|
433
|
+
// 未キャッシュ (骨組み表示中) の初回取得は即時に走らせる。デバウンスはスクロール連打の
|
|
434
|
+
// 無駄取りを抑える目的なので、スクロール churn の無い初回に効かせると純粋な遅延になる。
|
|
435
|
+
// キャッシュ済み (内容表示中) の stale 再取得だけデバウンスして churn を吸収する
|
|
436
|
+
const debounceMs = cached ? DAILY_REPORT_FETCH_DEBOUNCE_MS : 0
|
|
437
|
+
|
|
433
438
|
const timer = setTimeout(async () => {
|
|
434
439
|
if (reqRef.current.token !== token) return
|
|
435
440
|
try {
|
|
@@ -453,7 +458,7 @@ export const useDailyReportDetail = (reportHubId: number, businessDate: string |
|
|
|
453
458
|
setIsRefetching(false)
|
|
454
459
|
}
|
|
455
460
|
}
|
|
456
|
-
},
|
|
461
|
+
}, debounceMs)
|
|
457
462
|
|
|
458
463
|
return () => clearTimeout(timer)
|
|
459
464
|
}, [reportHubId, businessDate, apiBasePath])
|
|
@@ -6,7 +6,17 @@ import type { DailyReportDetailResource } from "../hooks/use-daily-report"
|
|
|
6
6
|
* Shared constants for Daily Report data fetching parameters.
|
|
7
7
|
* 日報データ取得で利用する共通パラメーター。
|
|
8
8
|
*/
|
|
9
|
-
export const DAILY_REPORT_FETCH_DEBOUNCE_MS =
|
|
9
|
+
export const DAILY_REPORT_FETCH_DEBOUNCE_MS = 120
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Delay before the provider auto-connects SSE, freeing a browser connection slot for first paint.
|
|
13
|
+
* SSE を自動接続するまでの遅延。初回描画とその中身フェッチへ接続枠を譲るための猶予。
|
|
14
|
+
*
|
|
15
|
+
* HTTP/1.1 のオリジン当たり 6 接続のうち、ids ストリームと SSE で 2 枠を常時占有する。
|
|
16
|
+
* SSE 接続を初回描画直後まで遅らせ、その 1 枠を初回の中身フェッチ (`/business-date`) に回す。
|
|
17
|
+
* リアルタイム更新の初動がこの分だけ遅れるが、その間の状態はストリーム / SWR が担保する。
|
|
18
|
+
*/
|
|
19
|
+
export const SSE_INITIAL_CONNECT_DEFER_MS = 1000
|
|
10
20
|
|
|
11
21
|
export const DEFAULT_ITEM_HEIGHT = 160
|
|
12
22
|
export const ITEM_VERTICAL_PADDING = 6
|
package/src/server/cache.spec.ts
CHANGED
|
@@ -11,7 +11,7 @@ describe("SqlResultCache Memory Cleanup", () => {
|
|
|
11
11
|
|
|
12
12
|
// 1. 期限切れのキャッシュを 500 件作成 (expireAt を過去に設定)
|
|
13
13
|
// 直接プライベートフィールド buckets に流し込むことでモック状態を作る
|
|
14
|
-
const buckets = (cache as
|
|
14
|
+
const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[]; expireAt: number; epoch: number }> }).buckets
|
|
15
15
|
for (let i = 0; i < 505; i++) {
|
|
16
16
|
buckets.set(`expired-key-${i}`, {
|
|
17
17
|
records: [i],
|
|
@@ -45,3 +45,367 @@ describe("SqlResultCache Memory Cleanup", () => {
|
|
|
45
45
|
expect(buckets.has("expired-key-0")).toBe(false)
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
|
+
|
|
49
|
+
describe("SqlResultCache refresh-ahead", () => {
|
|
50
|
+
/** テスト用のキャッシュと覗き穴 (プライベート buckets / inFlight) を組み立てるヘルパー。 */
|
|
51
|
+
const makeCache = (ttlMs = 200_000, epochValue: { value: number } = { value: 0 }) => {
|
|
52
|
+
const epochStore = createEpochStore(undefined)
|
|
53
|
+
// 可変 epoch: フェッチ中の他ワーカー更新 (incrementEpoch) を模擬できるようにする
|
|
54
|
+
vi.spyOn(epochStore, "getEpoch").mockImplementation(async () => epochValue.value)
|
|
55
|
+
const cache = new SqlResultCache({ defaultTtlMs: ttlMs }, epochStore)
|
|
56
|
+
const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[]; expireAt: number; epoch: number }> }).buckets
|
|
57
|
+
const inFlight = (cache as unknown as { inFlight: Map<string, Promise<readonly unknown[]>> }).inFlight
|
|
58
|
+
return { cache, buckets, inFlight }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* バケットの残余 TTL を書き換えて「年齢」を偽装する (expireAt = now + remainingMs)。
|
|
63
|
+
* CI ストール耐性のため残余は大きく取る (残余が小さいとテスト中に本当に失効し、
|
|
64
|
+
* 手動解決フェッチへ相乗りしてデッドロックする)。
|
|
65
|
+
*/
|
|
66
|
+
const ageBucket = (buckets: Map<string, { expireAt: number }>, key: string, remainingMs: number) => {
|
|
67
|
+
const bucket = buckets.get(key)
|
|
68
|
+
if (!bucket) throw new Error(`bucket not found: ${key}`)
|
|
69
|
+
bucket.expireAt = Date.now() + remainingMs
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it("refreshAheadRatio 未指定なら、老化したヒットでも裏フェッチは走らない (従来動作)", async () => {
|
|
73
|
+
const { cache, buckets, inFlight } = makeCache()
|
|
74
|
+
const fetcher = vi.fn().mockResolvedValue(["v1"])
|
|
75
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher })
|
|
76
|
+
ageBucket(buckets, "k", 60_000) // 年齢 140 秒相当 (ratio 0.5 なら発火する老化度)
|
|
77
|
+
|
|
78
|
+
const hit = await cache.getOrFetch({ cacheKey: "k", fetcher })
|
|
79
|
+
expect(hit).toEqual(["v1"])
|
|
80
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
81
|
+
expect(inFlight.size).toBe(0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("若いヒット (年齢 < ttl × ratio) では裏フェッチは走らない", async () => {
|
|
85
|
+
const { cache, inFlight } = makeCache()
|
|
86
|
+
const fetcher = vi.fn().mockResolvedValue(["v1"])
|
|
87
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
88
|
+
|
|
89
|
+
// 直後のヒット = 年齢ほぼ 0
|
|
90
|
+
const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
91
|
+
expect(hit).toEqual(["v1"])
|
|
92
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
93
|
+
expect(inFlight.size).toBe(0)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("refreshAheadRatio 0 は毎ヒット裏更新 (undefined との区別 = falsy 判定への退行を検知)", async () => {
|
|
97
|
+
const { cache, inFlight } = makeCache()
|
|
98
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockResolvedValue(["v2"])
|
|
99
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0 })
|
|
100
|
+
|
|
101
|
+
// 年齢 0 でも ratio 0 なら発火する (`if (!ratio)` へ書き換えると発火せず失敗する)
|
|
102
|
+
const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0 })
|
|
103
|
+
expect(hit).toEqual(["v1"])
|
|
104
|
+
await vi.waitFor(() => expect(inFlight.size).toBe(0))
|
|
105
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("老化したヒットは旧値を即返しつつ、裏フェッチはちょうど 1 本だけ走ってバケットを差し替える", async () => {
|
|
109
|
+
const { cache, buckets, inFlight } = makeCache()
|
|
110
|
+
// 2 回目以降のフェッチは手動解決 (裏フェッチ進行中の並行ヒットを再現するため)
|
|
111
|
+
let releaseSecond!: (v: readonly unknown[]) => void
|
|
112
|
+
const second = new Promise<readonly unknown[]>((resolve) => {
|
|
113
|
+
releaseSecond = resolve
|
|
114
|
+
})
|
|
115
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
|
|
116
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
117
|
+
ageBucket(buckets, "k", 60_000)
|
|
118
|
+
|
|
119
|
+
// ヒット A: 旧値を即返し + 裏フェッチ起動
|
|
120
|
+
const hitA = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
121
|
+
expect(hitA).toEqual(["v1"])
|
|
122
|
+
expect(inFlight.has("k")).toBe(true)
|
|
123
|
+
|
|
124
|
+
// ヒット B: 裏フェッチ進行中でも旧値を即返し、2 本目は起動しない (in-flight ガード)
|
|
125
|
+
const hitB = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
126
|
+
expect(hitB).toEqual(["v1"])
|
|
127
|
+
expect(fetcher).toHaveBeenCalledTimes(2) // 初回 + 裏フェッチ 1 本のみ
|
|
128
|
+
|
|
129
|
+
// 裏フェッチ完了 → バケットが新値へ差し替わり、inFlight は掃除される
|
|
130
|
+
const backgroundPromise = inFlight.get("k")
|
|
131
|
+
releaseSecond(["v2"])
|
|
132
|
+
await backgroundPromise
|
|
133
|
+
await vi.waitFor(() => expect(inFlight.size).toBe(0))
|
|
134
|
+
expect(buckets.get("k")?.records).toEqual(["v2"])
|
|
135
|
+
const hitC = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
136
|
+
expect(hitC).toEqual(["v2"])
|
|
137
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("ttl は ttlMsOverride を優先する: 発火閾値も差し替え後の expireAt も override 基準 (本番構成)", async () => {
|
|
141
|
+
// 本番は defaultTtlMs より大きい ttlMsOverride を渡す構成 (60s default / 180s override)。
|
|
142
|
+
// ここでは逆向きの大小関係 (default 200s ≫ override 1s) にして、defaultTtlMs へ
|
|
143
|
+
// 退行すると (a) 発火せず (b) 寿命が伸びる、の両方を検知できるようにする
|
|
144
|
+
const { cache, buckets, inFlight } = makeCache(200_000)
|
|
145
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockResolvedValue(["v2"])
|
|
146
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, ttlMsOverride: 1000, refreshAheadRatio: 0.5 })
|
|
147
|
+
|
|
148
|
+
// 残余 300ms = override 基準の年齢 700ms (> 500ms 閾値)。default 基準なら年齢は負で発火しない
|
|
149
|
+
ageBucket(buckets, "k", 300)
|
|
150
|
+
const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, ttlMsOverride: 1000, refreshAheadRatio: 0.5 })
|
|
151
|
+
expect(hit).toEqual(["v1"])
|
|
152
|
+
await vi.waitFor(() => expect(inFlight.size).toBe(0))
|
|
153
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
154
|
+
// 差し替え後の寿命は override 基準 (default の 200 秒ではない)
|
|
155
|
+
const bucket = buckets.get("k")
|
|
156
|
+
expect(bucket?.records).toEqual(["v2"])
|
|
157
|
+
expect((bucket?.expireAt ?? 0) - Date.now()).toBeLessThanOrEqual(1000)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("裏フェッチが失敗しても、バケットが有効な間は旧値で即答し続ける (safe 方向)", async () => {
|
|
161
|
+
const { cache, buckets, inFlight } = makeCache()
|
|
162
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockRejectedValue(new Error("db down"))
|
|
163
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher })
|
|
164
|
+
ageBucket(buckets, "k", 60_000)
|
|
165
|
+
|
|
166
|
+
const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
167
|
+
expect(hit).toEqual(["v1"])
|
|
168
|
+
// 裏フェッチの失敗を消化させる (unhandled rejection にならないことも暗黙に検証)
|
|
169
|
+
await vi.waitFor(() => expect(inFlight.size).toBe(0))
|
|
170
|
+
expect(buckets.get("k")?.records).toEqual(["v1"])
|
|
171
|
+
const hitAfterFailure = await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
172
|
+
expect(hitAfterFailure).toEqual(["v1"])
|
|
173
|
+
await vi.waitFor(() => expect(inFlight.size).toBe(0))
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("裏フェッチ進行中に invalidate されたら結果を書き込まない (前面経路と同じ無効化レース規約)", async () => {
|
|
177
|
+
const { cache, buckets, inFlight } = makeCache()
|
|
178
|
+
let releaseSecond!: (v: readonly unknown[]) => void
|
|
179
|
+
const second = new Promise<readonly unknown[]>((resolve) => {
|
|
180
|
+
releaseSecond = resolve
|
|
181
|
+
})
|
|
182
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
|
|
183
|
+
await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher, refreshAheadRatio: 0.5 })
|
|
184
|
+
ageBucket(buckets, "daily-report:ids", 60_000)
|
|
185
|
+
|
|
186
|
+
await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher, refreshAheadRatio: 0.5 })
|
|
187
|
+
const backgroundPromise = inFlight.get("daily-report:ids")
|
|
188
|
+
expect(backgroundPromise).toBeDefined()
|
|
189
|
+
|
|
190
|
+
// 進行中に SSE 由来の無効化が走ったと想定
|
|
191
|
+
cache.invalidatePrefix("daily-report:ids")
|
|
192
|
+
releaseSecond(["stale-write"])
|
|
193
|
+
await backgroundPromise
|
|
194
|
+
|
|
195
|
+
// 無効化後のバケットへ古い結果を書き戻さないこと
|
|
196
|
+
expect(buckets.has("daily-report:ids")).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("epochKey 併用 (本番構成) でも同様: getEpoch 中に落ちた無効化でも書き込まない", async () => {
|
|
200
|
+
// 本番の ids 経路は必ず epochKey 併用。ガード判定と書き込みの間に epoch の Redis await が
|
|
201
|
+
// 挟まると、その間の invalidate を取りこぼしてバケットを復活させてしまう退行を検知する
|
|
202
|
+
const { cache, buckets, inFlight } = makeCache(200_000, { value: 0 })
|
|
203
|
+
let releaseSecond!: (v: readonly unknown[]) => void
|
|
204
|
+
const second = new Promise<readonly unknown[]>((resolve) => {
|
|
205
|
+
releaseSecond = resolve
|
|
206
|
+
})
|
|
207
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
|
|
208
|
+
await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
|
|
209
|
+
ageBucket(buckets, "daily-report:ids", 60_000)
|
|
210
|
+
|
|
211
|
+
await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
|
|
212
|
+
expect(inFlight.has("daily-report:ids")).toBe(true)
|
|
213
|
+
const backgroundPromise = inFlight.get("daily-report:ids")
|
|
214
|
+
cache.invalidatePrefix("daily-report:ids")
|
|
215
|
+
releaseSecond(["stale-write"])
|
|
216
|
+
await backgroundPromise
|
|
217
|
+
expect(buckets.has("daily-report:ids")).toBe(false)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("epoch はフェッチ開始前に採取する: フェッチ中の他ワーカー更新を新 epoch で偽装しない", async () => {
|
|
221
|
+
const epochValue = { value: 7 }
|
|
222
|
+
const { cache, buckets, inFlight } = makeCache(200_000, epochValue)
|
|
223
|
+
let releaseSecond!: (v: readonly unknown[]) => void
|
|
224
|
+
const second = new Promise<readonly unknown[]>((resolve) => {
|
|
225
|
+
releaseSecond = resolve
|
|
226
|
+
})
|
|
227
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
|
|
228
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
|
|
229
|
+
expect(buckets.get("k")?.epoch).toBe(7)
|
|
230
|
+
ageBucket(buckets, "k", 60_000)
|
|
231
|
+
|
|
232
|
+
// 老化ヒット → 裏フェッチ起動 (epoch 7 を開始前に採取)
|
|
233
|
+
const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
|
|
234
|
+
expect(hit).toEqual(["v1"])
|
|
235
|
+
// フェッチ中に他ワーカーが更新 → epoch 9 へ (incrementEpoch 相当)
|
|
236
|
+
epochValue.value = 9
|
|
237
|
+
releaseSecond(["pre-mutation-data"])
|
|
238
|
+
await inFlight.get("k")
|
|
239
|
+
await vi.waitFor(() => expect(inFlight.size).toBe(0))
|
|
240
|
+
|
|
241
|
+
// ❗ 差し替えバケットの epoch は「開始前」の 7 であること。完了後採取 (9) だと
|
|
242
|
+
// 旧データが新 epoch を纏い、次のヒットの epoch 照合をすり抜けて stale を配ってしまう
|
|
243
|
+
expect(buckets.get("k")?.epoch).toBe(7)
|
|
244
|
+
// 次のヒットは epoch 不一致 (7 ≠ 9) を検出して再フェッチする (自己回復)
|
|
245
|
+
const fetcherAfter = vi.fn().mockResolvedValue(["post-mutation-data"])
|
|
246
|
+
const healed = await cache.getOrFetch({ cacheKey: "k", fetcher: fetcherAfter, epochKey: "e", refreshAheadRatio: 0.5 })
|
|
247
|
+
expect(healed).toEqual(["post-mutation-data"])
|
|
248
|
+
expect(fetcherAfter).toHaveBeenCalledTimes(1)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("epochKey 併用のヒット経路でも老化ヒットで裏フェッチが起動する", async () => {
|
|
252
|
+
const { cache, buckets, inFlight } = makeCache()
|
|
253
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockResolvedValue(["v2"])
|
|
254
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "epoch:k", refreshAheadRatio: 0.5 })
|
|
255
|
+
ageBucket(buckets, "k", 60_000)
|
|
256
|
+
|
|
257
|
+
const hit = await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "epoch:k", refreshAheadRatio: 0.5 })
|
|
258
|
+
expect(hit).toEqual(["v1"])
|
|
259
|
+
await vi.waitFor(() => expect(inFlight.size).toBe(0))
|
|
260
|
+
expect(buckets.get("k")?.records).toEqual(["v2"])
|
|
261
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it("裏フェッチ進行中に TTL が切れた前面リクエストは裏フェッチへ相乗りする (二重クエリ防止)", async () => {
|
|
265
|
+
const { cache, buckets, inFlight } = makeCache()
|
|
266
|
+
let releaseSecond!: (v: readonly unknown[]) => void
|
|
267
|
+
const second = new Promise<readonly unknown[]>((resolve) => {
|
|
268
|
+
releaseSecond = resolve
|
|
269
|
+
})
|
|
270
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValue(second)
|
|
271
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
272
|
+
ageBucket(buckets, "k", 60_000)
|
|
273
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 }) // 裏フェッチ起動
|
|
274
|
+
|
|
275
|
+
// 裏フェッチ完了前に TTL が完全に切れる (意図的な失効。相乗りするので手動解決とデッドロックしない)
|
|
276
|
+
ageBucket(buckets, "k", -1)
|
|
277
|
+
const ridePromise = cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
278
|
+
releaseSecond(["v2"])
|
|
279
|
+
expect(await ridePromise).toEqual(["v2"])
|
|
280
|
+
expect(fetcher).toHaveBeenCalledTimes(2) // 相乗りできており 3 本目は走らない
|
|
281
|
+
await vi.waitFor(() => expect(inFlight.size).toBe(0))
|
|
282
|
+
expect(buckets.get("k")?.records).toEqual(["v2"])
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe("SqlResultCache foreground write-guard integrity", () => {
|
|
287
|
+
it("前面 finally の inFlight 掃除は identity チェック付き: 先行フェッチの完了が後続の登録を壊さない", async () => {
|
|
288
|
+
// シーケンス: F1 コールド開始 → 無効化 (F1 の登録を正しく削除) → F2 開始 (登録)
|
|
289
|
+
// → F1 完了 (finally が F2 の登録を消してはならない) → F2 完了 (バケット保存成功)
|
|
290
|
+
const epochStore = createEpochStore(undefined)
|
|
291
|
+
vi.spyOn(epochStore, "getEpoch").mockResolvedValue(0)
|
|
292
|
+
const cache = new SqlResultCache({ defaultTtlMs: 200_000 }, epochStore)
|
|
293
|
+
const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[] }> }).buckets
|
|
294
|
+
|
|
295
|
+
let releaseF1!: (v: readonly unknown[]) => void
|
|
296
|
+
const f1Gate = new Promise<readonly unknown[]>((resolve) => {
|
|
297
|
+
releaseF1 = resolve
|
|
298
|
+
})
|
|
299
|
+
let releaseF2!: (v: readonly unknown[]) => void
|
|
300
|
+
const f2Gate = new Promise<readonly unknown[]>((resolve) => {
|
|
301
|
+
releaseF2 = resolve
|
|
302
|
+
})
|
|
303
|
+
const fetcher = vi.fn().mockReturnValueOnce(f1Gate).mockReturnValueOnce(f2Gate)
|
|
304
|
+
|
|
305
|
+
const p1 = cache.getOrFetch({ cacheKey: "k", fetcher })
|
|
306
|
+
cache.invalidatePrefix("k") // F1 進行中に SSE 由来の無効化
|
|
307
|
+
const p2 = cache.getOrFetch({ cacheKey: "k", fetcher }) // 後続 F2 が登録し直す
|
|
308
|
+
releaseF1(["f1-stale"])
|
|
309
|
+
await p1 // F1 完了 (旧実装: ここで無条件 delete が F2 の登録を clobber していた)
|
|
310
|
+
releaseF2(["f2-fresh"])
|
|
311
|
+
expect(await p2).toEqual(["f2-fresh"])
|
|
312
|
+
|
|
313
|
+
// F2 の結果が正しくバケットへ保存されていること (毒殺されていないこと)
|
|
314
|
+
expect(buckets.get("k")?.records).toEqual(["f2-fresh"])
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it("前面経路も epoch はフェッチ開始前に採取する (フェッチ中の他ワーカー更新を新 epoch で偽装しない)", async () => {
|
|
318
|
+
const epochValue = { value: 7 }
|
|
319
|
+
const epochStore = createEpochStore(undefined)
|
|
320
|
+
vi.spyOn(epochStore, "getEpoch").mockImplementation(async () => epochValue.value)
|
|
321
|
+
const cache = new SqlResultCache({ defaultTtlMs: 200_000 }, epochStore)
|
|
322
|
+
const buckets = (cache as unknown as { buckets: Map<string, { epoch: number }> }).buckets
|
|
323
|
+
|
|
324
|
+
let release!: (v: readonly unknown[]) => void
|
|
325
|
+
const gate = new Promise<readonly unknown[]>((resolve) => {
|
|
326
|
+
release = resolve
|
|
327
|
+
})
|
|
328
|
+
const fetchPromise = cache.getOrFetch({ cacheKey: "k", fetcher: () => gate, epochKey: "e" })
|
|
329
|
+
// コールドフェッチ中に他ワーカーが更新 → epoch 9 へ
|
|
330
|
+
epochValue.value = 9
|
|
331
|
+
release(["v1"])
|
|
332
|
+
await fetchPromise
|
|
333
|
+
// 開始前の 7 で刻まれていること (9 だと次ヒットの照合をすり抜けて stale を配る)
|
|
334
|
+
expect(buckets.get("k")?.epoch).toBe(7)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it("裏フェッチ finally の掃除も identity チェック付き: 無効化後に登録し直した後続を壊さない", async () => {
|
|
338
|
+
// 前面テストの鏡像。裏フェッチ (refresh-ahead) の finally が無条件 delete へ退行すると、
|
|
339
|
+
// 無効化 → 後続コールド F2 登録 → 裏フェッチ完了、の順で F2 の登録が消え書き込みガードが壊れる
|
|
340
|
+
const epochStore = createEpochStore(undefined)
|
|
341
|
+
vi.spyOn(epochStore, "getEpoch").mockResolvedValue(0)
|
|
342
|
+
const cache = new SqlResultCache({ defaultTtlMs: 200_000 }, epochStore)
|
|
343
|
+
const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[]; expireAt: number }> }).buckets
|
|
344
|
+
const inFlight = (cache as unknown as { inFlight: Map<string, Promise<readonly unknown[]>> }).inFlight
|
|
345
|
+
|
|
346
|
+
let releaseBg!: (v: readonly unknown[]) => void
|
|
347
|
+
const bgGate = new Promise<readonly unknown[]>((resolve) => {
|
|
348
|
+
releaseBg = resolve
|
|
349
|
+
})
|
|
350
|
+
let releaseF2!: (v: readonly unknown[]) => void
|
|
351
|
+
const f2Gate = new Promise<readonly unknown[]>((resolve) => {
|
|
352
|
+
releaseF2 = resolve
|
|
353
|
+
})
|
|
354
|
+
const fetcher = vi.fn().mockResolvedValueOnce(["v1"]).mockReturnValueOnce(bgGate).mockReturnValueOnce(f2Gate)
|
|
355
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 })
|
|
356
|
+
const bucket = buckets.get("k")
|
|
357
|
+
if (!bucket) throw new Error("bucket not found")
|
|
358
|
+
bucket.expireAt = Date.now() + 60_000 // 老化偽装
|
|
359
|
+
|
|
360
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, refreshAheadRatio: 0.5 }) // 裏フェッチ起動 (bgGate)
|
|
361
|
+
cache.invalidatePrefix("k") // 裏フェッチの登録を正しく削除
|
|
362
|
+
const p2 = cache.getOrFetch({ cacheKey: "k", fetcher }) // 後続コールド F2 が登録し直す
|
|
363
|
+
const f2Promise = inFlight.get("k")
|
|
364
|
+
expect(f2Promise).toBeDefined()
|
|
365
|
+
|
|
366
|
+
releaseBg(["bg-stale"])
|
|
367
|
+
await bgGate
|
|
368
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
369
|
+
// 裏フェッチの finally が F2 の登録を消していないこと
|
|
370
|
+
expect(inFlight.get("k")).toBe(f2Promise)
|
|
371
|
+
|
|
372
|
+
releaseF2(["f2-fresh"])
|
|
373
|
+
expect(await p2).toEqual(["f2-fresh"])
|
|
374
|
+
expect(buckets.get("k")?.records).toEqual(["f2-fresh"])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it("epoch 照合の await 中にバケットが無効化されたら refresh-ahead は静かに何もしない (生きバケット再読)", async () => {
|
|
378
|
+
// ヒット判定はエントリ時点のバケット参照で行われるが、epoch 照合の Redis await 中に
|
|
379
|
+
// invalidatePrefix が落ちるとバケットは消えている。maybeRefreshAhead が呼び出し元の
|
|
380
|
+
// スナップショットに触れると TypeError や不要な裏フェッチになるため、生きバケットの
|
|
381
|
+
// 再読 + null ガードで無反応になることを固定する
|
|
382
|
+
const epochStore = createEpochStore(undefined)
|
|
383
|
+
let releaseEpoch!: (v: number) => void
|
|
384
|
+
const epochGate = new Promise<number>((resolve) => {
|
|
385
|
+
releaseEpoch = resolve
|
|
386
|
+
})
|
|
387
|
+
const getEpoch = vi.spyOn(epochStore, "getEpoch")
|
|
388
|
+
getEpoch.mockResolvedValueOnce(0) // 初回保存時
|
|
389
|
+
getEpoch.mockReturnValueOnce(epochGate) // ヒット時の照合 (ゲート)
|
|
390
|
+
const cache = new SqlResultCache({ defaultTtlMs: 200_000 }, epochStore)
|
|
391
|
+
const buckets = (cache as unknown as { buckets: Map<string, { records: readonly unknown[]; expireAt: number }> }).buckets
|
|
392
|
+
const inFlight = (cache as unknown as { inFlight: Map<string, Promise<readonly unknown[]>> }).inFlight
|
|
393
|
+
|
|
394
|
+
const fetcher = vi.fn().mockResolvedValue(["v1"])
|
|
395
|
+
await cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
|
|
396
|
+
const bucket = buckets.get("k")
|
|
397
|
+
if (!bucket) throw new Error("bucket not found")
|
|
398
|
+
bucket.expireAt = Date.now() + 60_000 // 老化偽装
|
|
399
|
+
|
|
400
|
+
const hitPromise = cache.getOrFetch({ cacheKey: "k", fetcher, epochKey: "e", refreshAheadRatio: 0.5 })
|
|
401
|
+
cache.invalidatePrefix("k") // 照合 await 中の無効化
|
|
402
|
+
releaseEpoch(0) // epoch は一致 → ヒット継続 → maybeRefreshAhead 到達
|
|
403
|
+
const hit = await hitPromise
|
|
404
|
+
// エントリ時点の参照から旧値は返る (この挙動は従来どおり)
|
|
405
|
+
expect(hit).toEqual(["v1"])
|
|
406
|
+
// 消えたバケットに対して裏フェッチを起動しない (TypeError にもならない)
|
|
407
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
408
|
+
expect(inFlight.size).toBe(0)
|
|
409
|
+
expect(buckets.has("k")).toBe(false)
|
|
410
|
+
})
|
|
411
|
+
})
|