@aiquants/daily-report 0.9.1 → 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/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 +3 -3
- 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/src/server/service.ts
CHANGED
|
@@ -165,6 +165,51 @@ export type DailyReportService = ReturnType<typeof createDailyReportService>
|
|
|
165
165
|
* Creates the daily-report data-access service bound to the injected dependencies.
|
|
166
166
|
* 注入された依存に束縛された日報データアクセスサービスを生成する処理。
|
|
167
167
|
*/
|
|
168
|
+
type DatePattern = "YYYY-MM-DD HH:mm:ss" | "YYYY-MM-DD"
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Formats date-like values to a given pattern using local-time fields (no Intl allocation).
|
|
172
|
+
* 日付相当の値をローカル時刻フィールドで指定パターンへ整形する処理 (Intl 非生成)。
|
|
173
|
+
*
|
|
174
|
+
* ローカル時刻フィールドの手組み整形。旧実装 (ja-JP ロケール + 2-digit toLocale*) と出力互換。
|
|
175
|
+
* ❗ toLocale* 系へ戻さないこと: あれは呼び出しごとに Intl.DateTimeFormat を生成し
|
|
176
|
+
* (実測 約 66μs/呼)、全量 ids の 227k 行 map で 15〜20 秒の同期 CPU 洪水になる。
|
|
177
|
+
* business-date / SSE の全応答が人質化した実害バグの根本原因だった。
|
|
178
|
+
* モジュールスコープの export であり、バレル (src/server.ts の `export *`) 経由で
|
|
179
|
+
* `@aiquants/daily-report/server` の公開 API の一部になる (テストからの直接検証と
|
|
180
|
+
* ホスト側での汎用日時整形の再利用を意図した、意図的な公開)。
|
|
181
|
+
* 1000 年未満の年は旧実装 (年ゼロ詰めなし) から意図的に乖離し、ISO 準拠の 4 桁ゼロ詰めで出す
|
|
182
|
+
* (負の年は符号を除いた絶対値を 4 桁詰め。DB の date/datetime からは到達しない文字列入力のみの領域)。
|
|
183
|
+
*/
|
|
184
|
+
export const formatDateValue = (value: Date | string | null | undefined, pattern: DatePattern): string | null => {
|
|
185
|
+
// null / undefined / 空文字は null に正規化
|
|
186
|
+
if (!value) {
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Date 以外 (文字列) は Date コンストラクタで解釈
|
|
191
|
+
const dateValue = value instanceof Date ? value : new Date(value)
|
|
192
|
+
if (Number.isNaN(dateValue.getTime())) {
|
|
193
|
+
// 解釈不能な文字列は原文のまま返す (旧実装と同一の不変挙動)
|
|
194
|
+
return typeof value === "string" ? value : null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 年月日をゼロ詰めで組み立てる (負の年は符号を除いた絶対値を 4 桁詰め = ISO 8601 拡張表記に整合。
|
|
198
|
+
// String(-5).padStart は "00-5" と符号ごと詰めてしまうため符号を分離する)
|
|
199
|
+
const year = dateValue.getFullYear()
|
|
200
|
+
const yyyy = year < 0 ? `-${String(-year).padStart(4, "0")}` : String(year).padStart(4, "0")
|
|
201
|
+
const mm = String(dateValue.getMonth() + 1).padStart(2, "0")
|
|
202
|
+
const dd = String(dateValue.getDate()).padStart(2, "0")
|
|
203
|
+
if (pattern === "YYYY-MM-DD") {
|
|
204
|
+
return `${yyyy}-${mm}-${dd}`
|
|
205
|
+
}
|
|
206
|
+
// 時分秒付きパターン
|
|
207
|
+
const hh = String(dateValue.getHours()).padStart(2, "0")
|
|
208
|
+
const mi = String(dateValue.getMinutes()).padStart(2, "0")
|
|
209
|
+
const ss = String(dateValue.getSeconds()).padStart(2, "0")
|
|
210
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`
|
|
211
|
+
}
|
|
212
|
+
|
|
168
213
|
export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
169
214
|
const { db, tables, userTable: users, resolveUserId, encodeUserId, redis, cache: sqlResultCache, epochs } = config
|
|
170
215
|
const { hub: DailyReportHub, internal: DailyReportInternal, comment: DailyReportCommentModel, label: DailyReportLabel, hubLabel: DailyReportHub_Label, userStatus: DailyReportUserStatus } = tables
|
|
@@ -212,28 +257,6 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
212
257
|
}
|
|
213
258
|
}
|
|
214
259
|
|
|
215
|
-
type DatePattern = "YYYY-MM-DD HH:mm:ss" | "YYYY-MM-DD"
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Formats date-like values to a given pattern (ja-JP locale semantics preserved).
|
|
219
|
-
* 日付相当の値を指定パターンに整形する処理。
|
|
220
|
-
*/
|
|
221
|
-
const formatDateValue = (value: Date | string | null | undefined, pattern: DatePattern): string | null => {
|
|
222
|
-
if (!value) {
|
|
223
|
-
return null
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const dateValue = value instanceof Date ? value : new Date(value)
|
|
227
|
-
if (Number.isNaN(dateValue.getTime())) {
|
|
228
|
-
return typeof value === "string" ? value : null
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ja-JP ロケールの YYYY/MM/DD (HH:mm:ss) を生成しハイフン区切りへ正規化する
|
|
232
|
-
const opts: Intl.DateTimeFormatOptions = pattern === "YYYY-MM-DD HH:mm:ss" ? { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" } : { year: "numeric", month: "2-digit", day: "2-digit" }
|
|
233
|
-
const formatted = pattern === "YYYY-MM-DD HH:mm:ss" ? dateValue.toLocaleString("ja-JP", opts) : dateValue.toLocaleDateString("ja-JP", opts)
|
|
234
|
-
return formatted.replace(/\//g, "-")
|
|
235
|
-
}
|
|
236
|
-
|
|
237
260
|
/**
|
|
238
261
|
* Masks a raw audit-actor value (created_by / updated_by) before client exposure.
|
|
239
262
|
* 監査列 (created_by / updated_by) をクライアント公開前にマスクする処理。
|
|
@@ -529,6 +552,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
529
552
|
snapshot,
|
|
530
553
|
ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_IDS_TTL_MS,
|
|
531
554
|
epochKey: DAILY_REPORT_IDS_EPOCH_KEY,
|
|
555
|
+
// TTL の半分を過ぎたヒットは、返却後に裏で 1 本だけ再フェッチして差し替える。
|
|
556
|
+
// 90 秒に 1 回以上アクセスがある限り、対話リクエストはコールド (全量再クエリ) を踏まない
|
|
557
|
+
refreshAheadRatio: 0.5,
|
|
532
558
|
}) as Promise<DailyReportItem[]>
|
|
533
559
|
}
|
|
534
560
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest"
|
|
2
2
|
import { createEpochStore, SqlResultCache } from "./cache"
|
|
3
3
|
import type { DailyReportRedisProvider } from "./ports"
|
|
4
|
-
import { DailyReportSseReader, isStreamIdLte } from "./sse-reader"
|
|
4
|
+
import { DailyReportSseReader, isStreamIdLte, type StreamEntry } from "./sse-reader"
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Robustness tests for the SSE fan-out reader (createClient failure recovery + id compare).
|
|
@@ -53,3 +53,250 @@ describe("DailyReportSseReader createClient failure recovery", () => {
|
|
|
53
53
|
await reader.destroy()
|
|
54
54
|
})
|
|
55
55
|
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fake blocking client that scripts xRead results and records every requested start id.
|
|
59
|
+
* xRead の結果をスクリプト化し、要求された開始 ID をすべて記録する偽ブロッキングクライアント。
|
|
60
|
+
*
|
|
61
|
+
* スクリプト要素 null は BLOCK タイムアウト (新着なしの null 返し) を 1 回分模す。
|
|
62
|
+
* スクリプトが尽きたら BLOCK を模して未解決 Promise で待機し、quit() で null 解決して
|
|
63
|
+
* ループを正常終了させる (実クライアントの BLOCK + 切断挙動の最小モデル)。
|
|
64
|
+
*/
|
|
65
|
+
class FakeBlockingClient {
|
|
66
|
+
isOpen = true
|
|
67
|
+
/** xRead が要求された開始 ID の記録 (履歴再生の有無を判定する証拠)。 */
|
|
68
|
+
readonly requestedIds: string[] = []
|
|
69
|
+
private _pendingResolve: ((v: null) => void) | undefined
|
|
70
|
+
constructor(private readonly script: Array<Array<{ id: string; message: Record<string, string> }> | null>) {}
|
|
71
|
+
|
|
72
|
+
on(_event: "error", _listener: (err: Error) => void): void {}
|
|
73
|
+
|
|
74
|
+
async xRead(streams: Array<{ key: string; id: string }>): Promise<Array<{ name: string; messages: Array<{ id: string; message: Record<string, string> }> }> | null> {
|
|
75
|
+
this.requestedIds.push(streams[0].id)
|
|
76
|
+
if (this.script.length > 0) {
|
|
77
|
+
const batch = this.script.shift()
|
|
78
|
+
// null = BLOCK タイムアウト 1 回分
|
|
79
|
+
if (batch === null || batch === undefined) return null
|
|
80
|
+
return [{ name: streams[0].key, messages: batch }]
|
|
81
|
+
}
|
|
82
|
+
// スクリプト枯渇 = 新着なし。BLOCK を模して quit まで待機する
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
this._pendingResolve = resolve
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async quit(): Promise<void> {
|
|
89
|
+
this.isOpen = false
|
|
90
|
+
this._pendingResolve?.(null)
|
|
91
|
+
this._pendingResolve = undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("DailyReportSseReader history-replay poisoning prevention", () => {
|
|
96
|
+
/**
|
|
97
|
+
* 偽クライアント列を払い出す redis プロバイダーを組み立てるヘルパー。
|
|
98
|
+
* tailId: 共有クライアントの xRevRange が返す末尾 ID ("empty" は空ストリーム、
|
|
99
|
+
* undefined は共有クライアント自体が取得不能 = "$" 縮退経路)。
|
|
100
|
+
*/
|
|
101
|
+
const makeRedis = (clients: FakeBlockingClient[], tailId?: string | "empty" | "reject"): { redis: DailyReportRedisProvider; created: FakeBlockingClient[] } => {
|
|
102
|
+
const created: FakeBlockingClient[] = []
|
|
103
|
+
const queue = [...clients]
|
|
104
|
+
const sharedClient =
|
|
105
|
+
tailId === undefined
|
|
106
|
+
? undefined
|
|
107
|
+
: ({
|
|
108
|
+
xRevRange: async () => {
|
|
109
|
+
// "reject" はアンカー解決の失敗 (Redis エラー) を模す
|
|
110
|
+
if (tailId === "reject") throw new Error("xRevRange failed")
|
|
111
|
+
return tailId === "empty" ? [] : [{ id: tailId, message: {} }]
|
|
112
|
+
},
|
|
113
|
+
} as unknown as Awaited<ReturnType<DailyReportRedisProvider["getClient"]>>)
|
|
114
|
+
return {
|
|
115
|
+
created,
|
|
116
|
+
redis: {
|
|
117
|
+
getClient: async () => sharedClient,
|
|
118
|
+
createClient: async () => {
|
|
119
|
+
const client = queue.shift()
|
|
120
|
+
if (!client) throw new Error("script exhausted: unexpected createClient call")
|
|
121
|
+
created.push(client)
|
|
122
|
+
return client
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }
|
|
129
|
+
|
|
130
|
+
it("ループ開始時に xRevRange でストリーム末尾へアンカーし、初回 xRead から具体 ID で読む", async () => {
|
|
131
|
+
const client = new FakeBlockingClient([])
|
|
132
|
+
const { redis } = makeRedis([client], "50-3")
|
|
133
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
134
|
+
|
|
135
|
+
const unsub = reader.subscribe(() => {})
|
|
136
|
+
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
137
|
+
// "$" のまま読むと BLOCK タイムアウトごとにサーバー側で末尾へ再解決され、
|
|
138
|
+
// 再武装の合間に届いたエントリを永続的に取りこぼす。必ず具体 ID に固定されていること
|
|
139
|
+
expect(client.requestedIds[0]).toBe("50-3")
|
|
140
|
+
unsub()
|
|
141
|
+
await reader.destroy()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("BLOCK タイムアウト (null) 後も同じ具体 ID で再武装し、その後の新着を取りこぼさない", async () => {
|
|
145
|
+
const client = new FakeBlockingClient([null, [{ id: "100-1", message: { data: JSON.stringify({ type: "report-create" }) } }]])
|
|
146
|
+
const { redis } = makeRedis([client], "50-3")
|
|
147
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
148
|
+
|
|
149
|
+
const entries: StreamEntry[] = []
|
|
150
|
+
const unsub = reader.subscribe((entry) => entries.push(entry))
|
|
151
|
+
await vi.waitFor(() => expect(entries.length).toBe(1))
|
|
152
|
+
expect(entries[0].id).toBe("100-1")
|
|
153
|
+
// タイムアウト前後とも具体 ID ("$" の再武装が残っていれば 2 回目が "$" になり失敗する)
|
|
154
|
+
expect(client.requestedIds.slice(0, 2)).toEqual(["50-3", "50-3"])
|
|
155
|
+
// エントリ処理後は位置が前進する
|
|
156
|
+
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(3))
|
|
157
|
+
expect(client.requestedIds[2]).toBe("100-1")
|
|
158
|
+
unsub()
|
|
159
|
+
await reader.destroy()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('空ストリームは "0-0" にアンカーする (履歴が無いので毒殺は構造的に不能)', async () => {
|
|
163
|
+
const client = new FakeBlockingClient([])
|
|
164
|
+
const { redis } = makeRedis([client], "empty")
|
|
165
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
166
|
+
|
|
167
|
+
const unsub = reader.subscribe(() => {})
|
|
168
|
+
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
169
|
+
expect(client.requestedIds[0]).toBe("0-0")
|
|
170
|
+
unsub()
|
|
171
|
+
await reader.destroy()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('共有クライアント取得不能時は "$" のまま縮退する ("0-0" の全履歴再生には決して戻らない)', async () => {
|
|
175
|
+
const client = new FakeBlockingClient([])
|
|
176
|
+
const { redis } = makeRedis([client], undefined)
|
|
177
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
178
|
+
|
|
179
|
+
const unsub = reader.subscribe(() => {})
|
|
180
|
+
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
181
|
+
expect(client.requestedIds[0]).toBe("$")
|
|
182
|
+
unsub()
|
|
183
|
+
await reader.destroy()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("新着エントリは従来どおり配信され、report-create でキャッシュ無効化も発火する (陽性確認)", async () => {
|
|
187
|
+
const client = new FakeBlockingClient([[{ id: "100-1", message: { data: JSON.stringify({ type: "report-create" }) } }]])
|
|
188
|
+
const { redis } = makeRedis([client], "50-3")
|
|
189
|
+
const cache = makeCache()
|
|
190
|
+
const invalidateSpy = vi.spyOn(cache, "invalidatePrefix")
|
|
191
|
+
// 実プロセスのヒット状況を模して ids バケットを播種しておく
|
|
192
|
+
await cache.getOrFetch({ cacheKey: "daily-report:ids", fetcher: async () => ["seed"] })
|
|
193
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache, logger: silentLogger })
|
|
194
|
+
|
|
195
|
+
const entries: StreamEntry[] = []
|
|
196
|
+
const unsub = reader.subscribe((entry) => entries.push(entry))
|
|
197
|
+
await vi.waitFor(() => expect(entries.length).toBe(1))
|
|
198
|
+
expect(entries[0].id).toBe("100-1")
|
|
199
|
+
expect(invalidateSpy).toHaveBeenCalledWith("daily-report:ids")
|
|
200
|
+
unsub()
|
|
201
|
+
await reader.destroy()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('stop → start で前回位置 (_lastId) を保持し、"0-0" へは決して戻らない', async () => {
|
|
205
|
+
const first = new FakeBlockingClient([[{ id: "100-1", message: { data: JSON.stringify({ type: "report-create" }) } }]])
|
|
206
|
+
const second = new FakeBlockingClient([])
|
|
207
|
+
const third = new FakeBlockingClient([])
|
|
208
|
+
const { redis } = makeRedis([first, second, third], "50-3")
|
|
209
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
210
|
+
|
|
211
|
+
// 1 回目の購読: 末尾アンカーから開始し、"100-1" を配信して位置を進める
|
|
212
|
+
const entries: StreamEntry[] = []
|
|
213
|
+
const unsub1 = reader.subscribe((entry) => entries.push(entry))
|
|
214
|
+
await vi.waitFor(() => expect(entries.length).toBe(1))
|
|
215
|
+
unsub1() // 最後の購読者解除 → ループ停止
|
|
216
|
+
|
|
217
|
+
// 2 回目の購読: 前回位置 "100-1" から再開する (停止中の実イベントを追える)
|
|
218
|
+
const unsub2 = reader.subscribe(() => {})
|
|
219
|
+
await vi.waitFor(() => expect(second.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
220
|
+
expect(second.requestedIds[0]).toBe("100-1")
|
|
221
|
+
unsub2()
|
|
222
|
+
|
|
223
|
+
// destroy 後の再購読はテスト専用リセット ("$") を経て、再び末尾アンカーへ解決される
|
|
224
|
+
await reader.destroy()
|
|
225
|
+
const unsub3 = reader.subscribe(() => {})
|
|
226
|
+
await vi.waitFor(() => expect(third.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
227
|
+
expect(third.requestedIds[0]).toBe("50-3")
|
|
228
|
+
unsub3()
|
|
229
|
+
await reader.destroy()
|
|
230
|
+
|
|
231
|
+
// 全ライフサイクルを通じて "0-0" (全履歴再生) を一度も要求していないこと
|
|
232
|
+
const allIds = [first, second, third].flatMap((c) => c.requestedIds)
|
|
233
|
+
expect(allIds).not.toContain("0-0")
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("停止待ちの間に購読者が全解除されたら起動しない (購読者 0 の孤児ループ防止)", async () => {
|
|
237
|
+
const first = new FakeBlockingClient([])
|
|
238
|
+
const second = new FakeBlockingClient([])
|
|
239
|
+
const { redis, created } = makeRedis([first, second], "50-3")
|
|
240
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger: silentLogger })
|
|
241
|
+
|
|
242
|
+
// 購読 → ループ稼働
|
|
243
|
+
const unsub1 = reader.subscribe(() => {})
|
|
244
|
+
await vi.waitFor(() => expect(created.length).toBe(1))
|
|
245
|
+
// 解除 (stopping 開始) → stopping 中に再購読 (起動待ち) → 即解除 (購読者 0)
|
|
246
|
+
unsub1()
|
|
247
|
+
const unsub2 = reader.subscribe(() => {})
|
|
248
|
+
unsub2()
|
|
249
|
+
|
|
250
|
+
// 停止が完了しても 2 本目のクライアントは生成されないこと (旧実装は購読者 0 でも起動した)
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
252
|
+
expect(created.length).toBe(1)
|
|
253
|
+
expect((reader as unknown as { _state: string })._state).toBe("idle")
|
|
254
|
+
await reader.destroy()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('アンカー解決の失敗は warn して "$" のまま縮退し、ループは通常稼働する ("0-0" へは落ちない)', async () => {
|
|
258
|
+
const client = new FakeBlockingClient([])
|
|
259
|
+
const { redis } = makeRedis([client], "reject")
|
|
260
|
+
const warns: unknown[][] = []
|
|
261
|
+
const logger = { debug() {}, info() {}, warn: (...args: unknown[]) => warns.push(args), error() {} }
|
|
262
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger })
|
|
263
|
+
|
|
264
|
+
const unsub = reader.subscribe(() => {})
|
|
265
|
+
await vi.waitFor(() => expect(client.requestedIds.length).toBeGreaterThanOrEqual(1))
|
|
266
|
+
// 失敗時は "$" 縮退 (rethrow で state-stuck したり "0-0" の全履歴再生へ落ちたりしない)
|
|
267
|
+
expect(client.requestedIds[0]).toBe("$")
|
|
268
|
+
expect(warns.length).toBeGreaterThanOrEqual(1)
|
|
269
|
+
expect(String(warns[0][0])).toContain("tail anchor")
|
|
270
|
+
unsub()
|
|
271
|
+
await reader.destroy()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it("onError 省略の購読者しか居なくてもエラーはプロセスを落とさず logger.error へ落ちる", async () => {
|
|
275
|
+
// リスナー 0 の "error" emit は EventEmitter 仕様で throw し、void 呼びの
|
|
276
|
+
// _startLoop 内では unhandledRejection = プロセス危険になる。_emitError の
|
|
277
|
+
// ガードが素の emit へ退行していないことを固定する
|
|
278
|
+
let attempts = 0
|
|
279
|
+
const redis: DailyReportRedisProvider = {
|
|
280
|
+
getClient: async () => undefined,
|
|
281
|
+
createClient: async () => {
|
|
282
|
+
attempts++
|
|
283
|
+
throw new Error("connection refused")
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
const errors: unknown[][] = []
|
|
287
|
+
const logger = { debug() {}, info() {}, warn() {}, error: (...args: unknown[]) => errors.push(args) }
|
|
288
|
+
const reader = new DailyReportSseReader({ redis, streamKey: "test:stream", cache: makeCache(), logger })
|
|
289
|
+
|
|
290
|
+
// onError を渡さない購読 → createClient 失敗 → ガード済みフォールバックが発火する
|
|
291
|
+
const unsub1 = reader.subscribe(() => {})
|
|
292
|
+
await vi.waitFor(() => expect(errors.length).toBeGreaterThanOrEqual(1))
|
|
293
|
+
expect(String(errors[0][0])).toContain("no error listeners")
|
|
294
|
+
unsub1()
|
|
295
|
+
|
|
296
|
+
// state-stuck していないこと (再購読で再試行される)
|
|
297
|
+
const unsub2 = reader.subscribe(() => {})
|
|
298
|
+
await vi.waitFor(() => expect(attempts).toBeGreaterThanOrEqual(2))
|
|
299
|
+
unsub2()
|
|
300
|
+
await reader.destroy()
|
|
301
|
+
})
|
|
302
|
+
})
|
package/src/server/sse-reader.ts
CHANGED
|
@@ -53,7 +53,14 @@ export class DailyReportSseReader {
|
|
|
53
53
|
private _emitter = new EventEmitter()
|
|
54
54
|
private _state: "idle" | "running" | "stopping" = "idle"
|
|
55
55
|
private _refCount = 0
|
|
56
|
-
|
|
56
|
+
// "$" = 「購読開始以降の新着のみ」の番兵。ループ開始時に一度だけ具体 ID (ストリーム末尾) へ
|
|
57
|
+
// 解決される (_startLoop 参照)。❗ "0-0" を初期値にしてはならない: 初回購読でストリーム全履歴を
|
|
58
|
+
// 再生し、過去イベントの invalidatePrefix が初回コールドフェッチの書き込みガードを不成立にして
|
|
59
|
+
// キャッシュ保存を毒殺する (実測: 履歴 10,006 件の再生で ids バケットが保存されず、次のロードも
|
|
60
|
+
// 全量コールド)。起動直後のプロセスのキャッシュは空で、過去イベントの無効化は無意味。
|
|
61
|
+
// 過去分の取りこぼし補償は接続単位の catch-up (Last-Event-ID + xRange) の責務。
|
|
62
|
+
// 通常の stop/start では従来どおり前回位置を保持し、停止中の実イベントを再開時に追う
|
|
63
|
+
private _lastId = "$"
|
|
57
64
|
private _client: DailyReportRedisBlockingClient | undefined
|
|
58
65
|
private readonly _logger: DailyReportLogger
|
|
59
66
|
|
|
@@ -105,7 +112,7 @@ export class DailyReportSseReader {
|
|
|
105
112
|
this._refCount = 0
|
|
106
113
|
await this._stopLoop()
|
|
107
114
|
this._emitter.removeAllListeners()
|
|
108
|
-
this._lastId = "
|
|
115
|
+
this._lastId = "$"
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
/**
|
|
@@ -118,6 +125,9 @@ export class DailyReportSseReader {
|
|
|
118
125
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
119
126
|
}
|
|
120
127
|
if (this._state === "running") return
|
|
128
|
+
// 停止完了を待つ間に最後の購読者が解除されていたら起動しない
|
|
129
|
+
// (購読者 0 の孤児ループが専用 Redis 接続を握り続けるのを防ぐ)
|
|
130
|
+
if (this._refCount === 0) return
|
|
121
131
|
|
|
122
132
|
this._state = "running"
|
|
123
133
|
|
|
@@ -127,12 +137,12 @@ export class DailyReportSseReader {
|
|
|
127
137
|
this._client = await this.config.redis?.createClient()
|
|
128
138
|
} catch (err) {
|
|
129
139
|
this._state = "idle"
|
|
130
|
-
this.
|
|
140
|
+
this._emitError(err instanceof Error ? err : new Error("Failed to create Redis client for SSE reader"))
|
|
131
141
|
return
|
|
132
142
|
}
|
|
133
143
|
if (!this._client) {
|
|
134
144
|
this._state = "idle"
|
|
135
|
-
this.
|
|
145
|
+
this._emitError(new Error("Failed to create Redis client for SSE reader"))
|
|
136
146
|
return
|
|
137
147
|
}
|
|
138
148
|
|
|
@@ -141,6 +151,23 @@ export class DailyReportSseReader {
|
|
|
141
151
|
this._logger.error("Redis client error:", err)
|
|
142
152
|
})
|
|
143
153
|
|
|
154
|
+
// ❗ "$" は xRead ごとにサーバー側で「その時点の末尾」へ再解決されるため、BLOCK タイムアウトで
|
|
155
|
+
// 再武装する合間に届いたエントリを永続的に取りこぼす ("$" が有効なのは最初の 1 回だけ、が
|
|
156
|
+
// Redis の規約)。ループへ入る前に一度だけ具体 ID へ固定する。空ストリームの "0-0" は
|
|
157
|
+
// 履歴が存在しないので安全 (毒殺は「履歴があるのに 0-0」の場合のみ)。
|
|
158
|
+
// 解決に失敗したら "$" のまま縮退する (取りこぼし窓が残るだけで、履歴再生よりは安全)
|
|
159
|
+
if (this._lastId === "$") {
|
|
160
|
+
try {
|
|
161
|
+
const shared = await this.config.redis?.getClient()
|
|
162
|
+
if (shared) {
|
|
163
|
+
const latest = await shared.xRevRange(this.config.streamKey, "+", "-", { COUNT: 1 })
|
|
164
|
+
this._lastId = latest.length > 0 ? latest[0].id : "0-0"
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this._logger.warn("Failed to resolve stream tail anchor; falling back to $:", err)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
try {
|
|
145
172
|
while (this._state === "running" && this._client?.isOpen) {
|
|
146
173
|
const results = await this._client.xRead([{ key: this.config.streamKey, id: this._lastId }], { BLOCK: 5000, COUNT: 100 })
|
|
@@ -177,7 +204,7 @@ export class DailyReportSseReader {
|
|
|
177
204
|
const isClientClosed = (err as Error)?.constructor?.name === "ClientClosedError"
|
|
178
205
|
if (!isClientClosed && this._state === "running") {
|
|
179
206
|
this._logger.error("xRead loop error:", err)
|
|
180
|
-
this.
|
|
207
|
+
this._emitError(err instanceof Error ? err : new Error(String(err)))
|
|
181
208
|
}
|
|
182
209
|
} finally {
|
|
183
210
|
// クライアント切断
|
|
@@ -214,10 +241,27 @@ export class DailyReportSseReader {
|
|
|
214
241
|
}
|
|
215
242
|
}
|
|
216
243
|
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
244
|
+
// 自分が仕掛けた停止フェーズの完了だけを待つ。"idle" 限定で待つと、停止直後の同期的な
|
|
245
|
+
// 自動再起動 (finally 内の idle → running 遷移には await が挟まらない) に追い越された場合に
|
|
246
|
+
// このポーラが永遠に "idle" を観測できず、50ms タイマーが恒久的に残留する。
|
|
247
|
+
// "stopping" を抜けたら (idle でも running でも) 停止処理としての責務は完了している。
|
|
248
|
+
// _state は別の非同期ループが書き換えるが、TS は await を跨いだクラスフィールドの変化を
|
|
249
|
+
// 追えず "stopping" に過剰 narrowing するため、宣言型へキャストして比較する。
|
|
250
|
+
while ((this._state as "idle" | "running" | "stopping") === "stopping") {
|
|
220
251
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
221
252
|
}
|
|
222
253
|
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Emits an "error" event only when a listener exists (bare "error" emit throws).
|
|
257
|
+
* リスナーが居るときだけ "error" イベントを emit する処理 (リスナー 0 の "error" emit は
|
|
258
|
+
* EventEmitter 仕様で throw し、非同期文脈では unhandled rejection = プロセス危険になるため)。
|
|
259
|
+
*/
|
|
260
|
+
private _emitError(err: Error): void {
|
|
261
|
+
if (this._emitter.listenerCount("error") > 0) {
|
|
262
|
+
this._emitter.emit("error", err)
|
|
263
|
+
} else {
|
|
264
|
+
this._logger.error("SSE reader error (no error listeners):", err)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
223
267
|
}
|