@interactive-inc/flume 0.9.4 → 0.10.1
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 +3 -2
- package/dist/discord.d.ts +15 -1
- package/dist/discord.js +302 -33
- package/dist/flume-source.d.ts +95 -12
- package/dist/flume-source.js +141 -68
- package/dist/github.d.ts +5 -1
- package/dist/github.js +211 -61
- package/dist/http-error.js +4 -0
- package/dist/index.d.ts +138 -23
- package/dist/index.js +358 -72
- package/dist/safe-json-parse.js +1 -1
- package/dist/safe-read-text.js +6 -3
- package/dist/safe-stringify.js +64 -28
- package/dist/slack.d.ts +8 -1
- package/dist/slack.js +227 -30
- package/dist/time.d.ts +46 -4
- package/dist/time.js +342 -40
- package/package.json +1 -1
package/dist/time.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as safeNow, c as attempt, i as FlumeLogger, l as safeNormalizeError, o as FlumeStartError, r as safeInvokeCallback, s as FlumeParseError, t as FlumeSource, u as safeErrorMessage } from "./flume-source.js";
|
|
2
2
|
import { t as isRecord } from "./is-record.js";
|
|
3
3
|
//#region lib/time/parse-cron-field.ts
|
|
4
4
|
/**
|
|
5
5
|
* 単一 cron フィールド (minute など) の spec を許可値の Set に展開する。
|
|
6
|
-
* 対応: `*` / `* /n` / `a` / `a-b` / `a-b/n` とそれらのカンマ区切り。名前 (JAN, MON)
|
|
6
|
+
* 対応: `*` / `* /n` / `a` / `a-b` / `a-b/n` とそれらのカンマ区切り。名前 (JAN, MON) は非対応。
|
|
7
|
+
* 空トークン (`"5,"` / `"-5"` / `"/5"` など) は `Number("") === 0` の暗黙変換で
|
|
8
|
+
* 0 に化けるため、数値が期待される位置の空文字列は明示的に拒否する
|
|
7
9
|
*/
|
|
8
10
|
function parseCronField(spec, min, max) {
|
|
9
11
|
const values = /* @__PURE__ */ new Set();
|
|
10
12
|
for (const part of spec.split(",")) {
|
|
13
|
+
if (part === "") return new FlumeParseError(`empty cron list segment: "${spec}"`);
|
|
11
14
|
const expanded = expandCronPart(part, min, max);
|
|
12
15
|
if (expanded instanceof FlumeParseError) return expanded;
|
|
13
16
|
for (const value of expanded) values.add(value);
|
|
@@ -16,15 +19,12 @@ function parseCronField(spec, min, max) {
|
|
|
16
19
|
return values;
|
|
17
20
|
}
|
|
18
21
|
function expandCronPart(part, min, max) {
|
|
19
|
-
let range = part;
|
|
20
|
-
let step = 1;
|
|
21
22
|
const slash = part.indexOf("/");
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
23
|
+
const range = slash === -1 ? part : part.slice(0, slash);
|
|
24
|
+
const stepToken = slash === -1 ? null : part.slice(slash + 1);
|
|
25
|
+
if (stepToken === "") return new FlumeParseError(`invalid cron step: "${part}"`);
|
|
26
|
+
const step = stepToken === null ? 1 : Number(stepToken);
|
|
27
|
+
if (!Number.isInteger(step) || step <= 0) return new FlumeParseError(`invalid cron step: "${part}"`);
|
|
28
28
|
const bounds = resolveBounds(range, min, max);
|
|
29
29
|
if (bounds instanceof FlumeParseError) return bounds;
|
|
30
30
|
const numbers = [];
|
|
@@ -37,8 +37,11 @@ function resolveBounds(range, min, max) {
|
|
|
37
37
|
hi: max
|
|
38
38
|
};
|
|
39
39
|
const dash = range.indexOf("-");
|
|
40
|
-
const
|
|
41
|
-
const
|
|
40
|
+
const loToken = dash === -1 ? range : range.slice(0, dash);
|
|
41
|
+
const hiToken = dash === -1 ? loToken : range.slice(dash + 1);
|
|
42
|
+
if (loToken === "" || hiToken === "") return new FlumeParseError(`invalid cron range: "${range}"`);
|
|
43
|
+
const lo = Number(loToken);
|
|
44
|
+
const hi = Number(hiToken);
|
|
42
45
|
if (!Number.isInteger(lo) || !Number.isInteger(hi)) return new FlumeParseError(`invalid cron range: "${range}"`);
|
|
43
46
|
if (lo < min || hi > max || lo > hi) return new FlumeParseError(`cron value out of range [${min}-${max}]: "${range}"`);
|
|
44
47
|
return {
|
|
@@ -74,20 +77,20 @@ function parseCron(expression) {
|
|
|
74
77
|
daysOfMonth,
|
|
75
78
|
months,
|
|
76
79
|
daysOfWeek,
|
|
77
|
-
domRestricted: fields[2]
|
|
78
|
-
dowRestricted: fields[4]
|
|
80
|
+
domRestricted: !fields[2]?.includes("*"),
|
|
81
|
+
dowRestricted: !fields[4]?.includes("*")
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
//#endregion
|
|
82
85
|
//#region lib/time/cron-next.ts
|
|
83
|
-
const MINUTE_MS = 6e4;
|
|
86
|
+
const MINUTE_MS$1 = 6e4;
|
|
84
87
|
const MAX_ITERATIONS = 5e5;
|
|
85
88
|
/**
|
|
86
89
|
* `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
|
|
87
90
|
* 到達不能なら FlumeParseError を返す
|
|
88
91
|
*/
|
|
89
92
|
function flumeCronNext(cron, afterMs) {
|
|
90
|
-
let candidate = Math.floor(afterMs / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
|
|
93
|
+
let candidate = Math.floor(afterMs / MINUTE_MS$1) * MINUTE_MS$1 + MINUTE_MS$1;
|
|
91
94
|
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
|
92
95
|
const date = new Date(candidate);
|
|
93
96
|
if (!cron.months.has(date.getMonth() + 1)) {
|
|
@@ -103,7 +106,7 @@ function flumeCronNext(cron, afterMs) {
|
|
|
103
106
|
continue;
|
|
104
107
|
}
|
|
105
108
|
if (!cron.minutes.has(date.getMinutes())) {
|
|
106
|
-
candidate += MINUTE_MS;
|
|
109
|
+
candidate += MINUTE_MS$1;
|
|
107
110
|
continue;
|
|
108
111
|
}
|
|
109
112
|
return candidate;
|
|
@@ -114,17 +117,48 @@ function matchesDay(cron, date) {
|
|
|
114
117
|
const domMatch = cron.daysOfMonth.has(date.getDate());
|
|
115
118
|
const dowMatch = cron.daysOfWeek.has(date.getDay());
|
|
116
119
|
if (cron.domRestricted && cron.dowRestricted) return domMatch || dowMatch;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
return domMatch && dowMatch;
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region lib/time/is-dst-duplicate-fire.ts
|
|
124
|
+
const DEDUP_WINDOW_MS = 7200 * 1e3;
|
|
125
|
+
/**
|
|
126
|
+
* DST fall-back では同一壁時計分 (y/m/d/h/min) が 2 つの epoch に存在し、
|
|
127
|
+
* 分単位の epoch ウォークが両方にマッチして二重発火する。fire 直後に計算した
|
|
128
|
+
* 次ターゲットが直近 2 時間の fire のいずれかと同じ壁時計分なら重複と判定する。
|
|
129
|
+
* 複数分にマッチする cron では巻き戻し後の最初の時刻と直前 fire の分が異なるため、
|
|
130
|
+
* 直前 1 件でなく履歴を受け取る
|
|
131
|
+
*/
|
|
132
|
+
function isDstDuplicateFire(firedTimes, nextTarget) {
|
|
133
|
+
for (let index = firedTimes.length - 1; index >= 0; index--) {
|
|
134
|
+
const firedAt = firedTimes[index];
|
|
135
|
+
if (firedAt === void 0) continue;
|
|
136
|
+
if (nextTarget <= firedAt) continue;
|
|
137
|
+
if (nextTarget - firedAt > DEDUP_WINDOW_MS) return false;
|
|
138
|
+
if (hasSameLocalMinute(firedAt, nextTarget)) return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
function hasSameLocalMinute(firedAt, nextTarget) {
|
|
143
|
+
const fired = new Date(firedAt);
|
|
144
|
+
const next = new Date(nextTarget);
|
|
145
|
+
if (fired.getFullYear() !== next.getFullYear()) return false;
|
|
146
|
+
if (fired.getMonth() !== next.getMonth()) return false;
|
|
147
|
+
if (fired.getDate() !== next.getDate()) return false;
|
|
148
|
+
if (fired.getHours() !== next.getHours()) return false;
|
|
149
|
+
return fired.getMinutes() === next.getMinutes();
|
|
120
150
|
}
|
|
121
151
|
//#endregion
|
|
122
152
|
//#region lib/time/time-scheduler.ts
|
|
123
153
|
const MAX_TIMEOUT_MS = 2e9;
|
|
124
154
|
const FIRE_TOLERANCE_MS = 1e3;
|
|
155
|
+
const DST_HISTORY_WINDOW_MS = 7200 * 1e3;
|
|
156
|
+
const MAX_DST_SKIPS = 180;
|
|
125
157
|
/**
|
|
126
158
|
* cron に従って `onTick` を駆動するタイマーループ。外部接続を持たないため reconnect 不要。
|
|
127
|
-
* IO 境界は全て `attempt`
|
|
159
|
+
* IO 境界は全て `attempt` 経由で扱い、停止後はコールバックを発火しない。
|
|
160
|
+
* sleep-wake で複数回分を取り逃した場合は 1 回だけ発火して now まで早送りする
|
|
161
|
+
* (取り逃しは `scheduler.skipped` info で観測可能)
|
|
128
162
|
*/
|
|
129
163
|
var FlumeTimeScheduler = class {
|
|
130
164
|
props;
|
|
@@ -132,6 +166,7 @@ var FlumeTimeScheduler = class {
|
|
|
132
166
|
isStoppedFlag = false;
|
|
133
167
|
timer = null;
|
|
134
168
|
target = 0;
|
|
169
|
+
recentFires = [];
|
|
135
170
|
constructor(props) {
|
|
136
171
|
this.props = props;
|
|
137
172
|
this.log = new FlumeLogger({
|
|
@@ -143,8 +178,13 @@ var FlumeTimeScheduler = class {
|
|
|
143
178
|
get isStopped() {
|
|
144
179
|
return this.isStoppedFlag;
|
|
145
180
|
}
|
|
146
|
-
|
|
147
|
-
|
|
181
|
+
/**
|
|
182
|
+
* `fromMs` より後の最初のマッチを狙う。catchup と同じ基準時刻を共有できるよう
|
|
183
|
+
* 呼び出し側から 1 つのタイムスタンプを渡す (未指定なら now)
|
|
184
|
+
*/
|
|
185
|
+
start(fromMs) {
|
|
186
|
+
const basis = fromMs ?? safeNow({ deps: this.props.deps });
|
|
187
|
+
const next = flumeCronNext(this.props.cron, basis);
|
|
148
188
|
if (next instanceof FlumeParseError) {
|
|
149
189
|
this.log.error({
|
|
150
190
|
action: "cron.no-next",
|
|
@@ -159,14 +199,14 @@ var FlumeTimeScheduler = class {
|
|
|
159
199
|
message: `next fire at ${new Date(next).toISOString()}`,
|
|
160
200
|
detail: { target: next }
|
|
161
201
|
});
|
|
162
|
-
this.arm();
|
|
163
|
-
return null;
|
|
202
|
+
return this.arm();
|
|
164
203
|
}
|
|
165
204
|
stop() {
|
|
166
205
|
this.isStoppedFlag = true;
|
|
167
206
|
this.clearTimer();
|
|
168
207
|
}
|
|
169
208
|
arm() {
|
|
209
|
+
if (this.isStoppedFlag) return null;
|
|
170
210
|
this.clearTimer();
|
|
171
211
|
const delay = Math.max(0, this.target - safeNow({ deps: this.props.deps }));
|
|
172
212
|
const capped = Math.min(delay, MAX_TIMEOUT_MS);
|
|
@@ -178,18 +218,22 @@ var FlumeTimeScheduler = class {
|
|
|
178
218
|
error: result
|
|
179
219
|
});
|
|
180
220
|
this.timer = null;
|
|
181
|
-
|
|
221
|
+
this.isStoppedFlag = true;
|
|
222
|
+
return result;
|
|
182
223
|
}
|
|
183
224
|
this.timer = result;
|
|
225
|
+
return null;
|
|
184
226
|
}
|
|
185
227
|
onWake() {
|
|
186
228
|
this.timer = null;
|
|
187
229
|
if (this.isStoppedFlag) return;
|
|
188
230
|
if (this.target - safeNow({ deps: this.props.deps }) > FIRE_TOLERANCE_MS) {
|
|
189
|
-
this.arm();
|
|
231
|
+
const error = this.arm();
|
|
232
|
+
if (error instanceof Error) this.halt(error);
|
|
190
233
|
return;
|
|
191
234
|
}
|
|
192
235
|
const firedAt = this.target;
|
|
236
|
+
this.rememberFire(firedAt);
|
|
193
237
|
safeInvokeCallback({
|
|
194
238
|
fn: () => this.props.onTick(firedAt),
|
|
195
239
|
onError: (error) => {
|
|
@@ -200,17 +244,78 @@ var FlumeTimeScheduler = class {
|
|
|
200
244
|
});
|
|
201
245
|
}
|
|
202
246
|
});
|
|
203
|
-
|
|
247
|
+
if (this.isStoppedFlag) return;
|
|
248
|
+
this.advanceAfterFire(firedAt);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* fire 後の次ターゲット計算。sleep 明けで firedAt が過去に沈んでいる場合は
|
|
252
|
+
* now まで早送りして取り逃し分の replay burst を防ぐ (catchup は opt-in の別機構)
|
|
253
|
+
*/
|
|
254
|
+
advanceAfterFire(firedAt) {
|
|
255
|
+
const nowMs = safeNow({ deps: this.props.deps });
|
|
256
|
+
const firstAfterFired = flumeCronNext(this.props.cron, firedAt);
|
|
257
|
+
if (firstAfterFired instanceof FlumeParseError) {
|
|
258
|
+
this.halt(firstAfterFired);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (firstAfterFired <= nowMs) this.log.info({
|
|
262
|
+
action: "scheduler.skipped",
|
|
263
|
+
message: `late wake: skipped occurrence(s) between ${new Date(firedAt).toISOString()} and now`,
|
|
264
|
+
detail: {
|
|
265
|
+
latenessMs: nowMs - firedAt,
|
|
266
|
+
firedAt
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
const next = firstAfterFired > nowMs ? firstAfterFired : flumeCronNext(this.props.cron, nowMs);
|
|
204
270
|
if (next instanceof FlumeParseError) {
|
|
205
|
-
this.
|
|
206
|
-
action: "cron.no-next",
|
|
207
|
-
message: next.message,
|
|
208
|
-
error: next
|
|
209
|
-
});
|
|
271
|
+
this.halt(next);
|
|
210
272
|
return;
|
|
211
273
|
}
|
|
212
|
-
|
|
213
|
-
|
|
274
|
+
const deduped = this.skipDstDuplicates(next);
|
|
275
|
+
if (deduped instanceof FlumeParseError) {
|
|
276
|
+
this.halt(deduped);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.target = deduped;
|
|
280
|
+
const armError = this.arm();
|
|
281
|
+
if (armError instanceof Error) this.halt(armError);
|
|
282
|
+
}
|
|
283
|
+
/** cron エラーによる恒久停止。source が接続済みのまま沈黙しないよう onHalt で通知する */
|
|
284
|
+
halt(error) {
|
|
285
|
+
this.isStoppedFlag = true;
|
|
286
|
+
this.clearTimer();
|
|
287
|
+
this.log.error({
|
|
288
|
+
action: "scheduler.halted",
|
|
289
|
+
message: error.message,
|
|
290
|
+
error
|
|
291
|
+
});
|
|
292
|
+
const onHalt = this.props.onHalt;
|
|
293
|
+
if (!onHalt) return;
|
|
294
|
+
safeInvokeCallback({
|
|
295
|
+
fn: () => onHalt(),
|
|
296
|
+
onError: (haltError) => {
|
|
297
|
+
this.log.error({
|
|
298
|
+
action: "scheduler.halt.error",
|
|
299
|
+
message: safeErrorMessage({ error: haltError }),
|
|
300
|
+
error: haltError
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
rememberFire(firedAt) {
|
|
306
|
+
this.recentFires.push(firedAt);
|
|
307
|
+
const cutoff = firedAt - DST_HISTORY_WINDOW_MS;
|
|
308
|
+
while (this.recentFires[0] !== void 0 && this.recentFires[0] < cutoff) this.recentFires.shift();
|
|
309
|
+
}
|
|
310
|
+
skipDstDuplicates(initialTarget) {
|
|
311
|
+
let target = initialTarget;
|
|
312
|
+
for (let iteration = 0; iteration < MAX_DST_SKIPS; iteration++) {
|
|
313
|
+
if (!isDstDuplicateFire(this.recentFires, target)) return target;
|
|
314
|
+
const next = flumeCronNext(this.props.cron, target);
|
|
315
|
+
if (next instanceof FlumeParseError) return next;
|
|
316
|
+
target = next;
|
|
317
|
+
}
|
|
318
|
+
return new FlumeParseError(`cron "${this.props.cron.source}" exceeded DST duplicate bound`);
|
|
214
319
|
}
|
|
215
320
|
clearTimer() {
|
|
216
321
|
if (this.timer === null) return;
|
|
@@ -225,11 +330,120 @@ var FlumeTimeScheduler = class {
|
|
|
225
330
|
}
|
|
226
331
|
};
|
|
227
332
|
//#endregion
|
|
333
|
+
//#region lib/time/time-catchup.ts
|
|
334
|
+
const DEFAULT_MISSED_WINDOW_MS = 1440 * 60 * 1e3;
|
|
335
|
+
const MAX_CATCHUP_MATCHES = 1e4;
|
|
336
|
+
const MINUTE_MS = 6e4;
|
|
337
|
+
/**
|
|
338
|
+
* `lastFiredAt` から `now` までに過ぎ去った cron マッチを policy に従って列挙する。
|
|
339
|
+
*
|
|
340
|
+
* - policy.mode === "off" : 常に空
|
|
341
|
+
* - policy.mode === "lastOnly" : 過ぎ去ったマッチの中で最も新しいもの 1 件 (件数上限なし・O(1) メモリ)
|
|
342
|
+
* - policy.mode === "missed" : maxWindowMs (既定 24h) 以内に過ぎ去ったすべてのマッチ。
|
|
343
|
+
* window の起点は `max(lastFiredAt, now - maxWindowMs)`。
|
|
344
|
+
* 10,000 件を超えた場合は古い方を捨てて新しい 10,000 件を返し
|
|
345
|
+
* truncated: true で通知する
|
|
346
|
+
*
|
|
347
|
+
* 到達不能 cron や catastrophic な policy ミス指定の場合は FlumeParseError を返す
|
|
348
|
+
* (catchup 列挙だけで失敗させる。source 本体の起動は別判断)
|
|
349
|
+
*/
|
|
350
|
+
function flumeCollectCatchupMatches(props) {
|
|
351
|
+
const policy = props.policy;
|
|
352
|
+
if (policy.mode === "off") return {
|
|
353
|
+
matches: [],
|
|
354
|
+
truncated: false
|
|
355
|
+
};
|
|
356
|
+
if (props.lastFiredAt >= props.now) return {
|
|
357
|
+
matches: [],
|
|
358
|
+
truncated: false
|
|
359
|
+
};
|
|
360
|
+
if (policy.mode === "lastOnly") return collectLastOnly({
|
|
361
|
+
cron: props.cron,
|
|
362
|
+
windowStart: props.lastFiredAt,
|
|
363
|
+
now: props.now
|
|
364
|
+
});
|
|
365
|
+
const windowStart = Math.max(props.lastFiredAt, props.now - (policy.maxWindowMs ?? DEFAULT_MISSED_WINDOW_MS));
|
|
366
|
+
return collectMissed({
|
|
367
|
+
cron: props.cron,
|
|
368
|
+
windowStart,
|
|
369
|
+
now: props.now
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function collectLastOnly(props) {
|
|
373
|
+
let lookbackMs = MINUTE_MS;
|
|
374
|
+
while (true) {
|
|
375
|
+
const recentStart = Math.max(props.windowStart, props.now - lookbackMs);
|
|
376
|
+
const walked = walkMatches({
|
|
377
|
+
...props,
|
|
378
|
+
windowStart: recentStart
|
|
379
|
+
});
|
|
380
|
+
if (walked instanceof FlumeParseError) return walked;
|
|
381
|
+
const latest = walked[walked.length - 1];
|
|
382
|
+
if (latest !== void 0) return {
|
|
383
|
+
matches: [latest],
|
|
384
|
+
truncated: false
|
|
385
|
+
};
|
|
386
|
+
if (recentStart === props.windowStart) return {
|
|
387
|
+
matches: [],
|
|
388
|
+
truncated: false
|
|
389
|
+
};
|
|
390
|
+
lookbackMs *= 2;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function collectMissed(props) {
|
|
394
|
+
let lookbackMs = MAX_CATCHUP_MATCHES * MINUTE_MS;
|
|
395
|
+
while (true) {
|
|
396
|
+
const recentStart = Math.max(props.windowStart, props.now - lookbackMs);
|
|
397
|
+
const walked = walkMatches({
|
|
398
|
+
...props,
|
|
399
|
+
windowStart: recentStart
|
|
400
|
+
});
|
|
401
|
+
if (walked instanceof FlumeParseError) return walked;
|
|
402
|
+
if (walked.length >= MAX_CATCHUP_MATCHES || recentStart === props.windowStart) {
|
|
403
|
+
const matches = walked.slice(Math.max(0, walked.length - MAX_CATCHUP_MATCHES));
|
|
404
|
+
const older = hasMatchBefore(props.cron, props.windowStart, recentStart);
|
|
405
|
+
if (older instanceof FlumeParseError) return older;
|
|
406
|
+
return {
|
|
407
|
+
matches,
|
|
408
|
+
truncated: walked.length > MAX_CATCHUP_MATCHES || older
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
lookbackMs *= 2;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function walkMatches(props) {
|
|
415
|
+
const matches = [];
|
|
416
|
+
let cursor = props.windowStart;
|
|
417
|
+
while (true) {
|
|
418
|
+
const next = flumeCronNext(props.cron, cursor);
|
|
419
|
+
if (next instanceof FlumeParseError) return next;
|
|
420
|
+
if (next > props.now) return matches;
|
|
421
|
+
if (!isDstDuplicateFire(matches, next)) matches.push(next);
|
|
422
|
+
cursor = next;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function hasMatchBefore(cron, windowStart, recentStart) {
|
|
426
|
+
if (recentStart === windowStart) return false;
|
|
427
|
+
const first = flumeCronNext(cron, windowStart);
|
|
428
|
+
if (first instanceof FlumeParseError) return first;
|
|
429
|
+
return first <= recentStart;
|
|
430
|
+
}
|
|
431
|
+
//#endregion
|
|
228
432
|
//#region lib/time/time-source.ts
|
|
229
433
|
/**
|
|
230
434
|
* cron スケジュールで tick を emit する Source。外部接続を持たないため
|
|
231
435
|
* 起動成功と同時に `connected` になり reconnect の対象外。
|
|
232
|
-
*
|
|
436
|
+
*
|
|
437
|
+
* options.statePersister + options.catchupPolicy を渡すと:
|
|
438
|
+
* 1. 起動時に lastFiredAt を読み出す
|
|
439
|
+
* 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
|
|
440
|
+
* 3. 各 tick 後に lastFiredAt を保存する (best-effort, ブロックしない)
|
|
441
|
+
*
|
|
442
|
+
* 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)。
|
|
443
|
+
*
|
|
444
|
+
* DST 制限: fall-back (時計の巻き戻し) の二重発火は dedup で防ぐが、spring-forward
|
|
445
|
+
* (時計の飛び越し) でスキップされた壁時計時刻 (例: 02:30 が存在しない日) にスケジュール
|
|
446
|
+
* された job はその日は実行されない。cron は壁時計 (local time) 基準のため仕様とする
|
|
233
447
|
*/
|
|
234
448
|
var FlumeTimeSource = class extends FlumeSource {
|
|
235
449
|
options;
|
|
@@ -252,26 +466,41 @@ var FlumeTimeSource = class extends FlumeSource {
|
|
|
252
466
|
this.setStatus("disconnected", error.message);
|
|
253
467
|
return error;
|
|
254
468
|
}
|
|
469
|
+
const persister = this.options.statePersister ?? null;
|
|
470
|
+
const lastFiredAt = persister === null ? null : await this.loadLastFiredAt(ctx, persister);
|
|
255
471
|
this.scheduler = new FlumeTimeScheduler({
|
|
256
472
|
cron,
|
|
257
473
|
onLog: ctx.log.handler,
|
|
258
474
|
deps: ctx.deps,
|
|
259
|
-
onTick: (firedAt) => this.handleTick(ctx, firedAt)
|
|
475
|
+
onTick: (firedAt) => this.handleTick(ctx, firedAt, persister),
|
|
476
|
+
onHalt: () => this.handleSchedulerHalt(ctx)
|
|
260
477
|
});
|
|
261
|
-
const
|
|
478
|
+
const startedAt = safeNow({ deps: ctx.deps });
|
|
479
|
+
const result = this.scheduler.start(startedAt);
|
|
262
480
|
if (result instanceof Error) {
|
|
263
481
|
const error = new FlumeStartError(`Time source: ${safeErrorMessage({ error: result })}`);
|
|
264
482
|
this.setStatus("disconnected", error.message);
|
|
265
483
|
return error;
|
|
266
484
|
}
|
|
267
485
|
this.setStatus("connected");
|
|
486
|
+
if (lastFiredAt !== null && persister !== null) this.runCatchup({
|
|
487
|
+
ctx,
|
|
488
|
+
cron,
|
|
489
|
+
lastFiredAt,
|
|
490
|
+
persister,
|
|
491
|
+
now: startedAt
|
|
492
|
+
});
|
|
268
493
|
return null;
|
|
269
494
|
}
|
|
270
495
|
disconnect() {
|
|
271
496
|
this.scheduler?.stop();
|
|
272
497
|
this.scheduler = null;
|
|
273
498
|
}
|
|
274
|
-
handleTick(ctx, firedAt) {
|
|
499
|
+
handleTick(ctx, firedAt, persister) {
|
|
500
|
+
this.emitTick(ctx, firedAt);
|
|
501
|
+
if (persister !== null) this.saveLastFiredAt(ctx, persister, firedAt);
|
|
502
|
+
}
|
|
503
|
+
emitTick(ctx, firedAt) {
|
|
275
504
|
const tick = {
|
|
276
505
|
firedAt,
|
|
277
506
|
cron: this.options.cron
|
|
@@ -288,6 +517,79 @@ var FlumeTimeSource = class extends FlumeSource {
|
|
|
288
517
|
receivedAt: safeNow({ deps: ctx.deps })
|
|
289
518
|
});
|
|
290
519
|
}
|
|
520
|
+
runCatchup(props) {
|
|
521
|
+
const policy = this.options.catchupPolicy ?? { mode: "off" };
|
|
522
|
+
if (policy.mode === "off") return;
|
|
523
|
+
const collected = flumeCollectCatchupMatches({
|
|
524
|
+
cron: props.cron,
|
|
525
|
+
lastFiredAt: props.lastFiredAt,
|
|
526
|
+
now: props.now,
|
|
527
|
+
policy
|
|
528
|
+
});
|
|
529
|
+
if (collected instanceof FlumeParseError) {
|
|
530
|
+
props.ctx.log.warn({
|
|
531
|
+
action: "time.catchup.failed",
|
|
532
|
+
message: collected.message,
|
|
533
|
+
error: collected
|
|
534
|
+
});
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (collected.truncated) props.ctx.log.warn({
|
|
538
|
+
action: "time.catchup.truncated",
|
|
539
|
+
message: "catchup exceeded the match cap; oldest missed tick(s) were dropped",
|
|
540
|
+
detail: {
|
|
541
|
+
kept: collected.matches.length,
|
|
542
|
+
policy: policy.mode
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
const matches = collected.matches;
|
|
546
|
+
if (matches.length === 0) return;
|
|
547
|
+
props.ctx.log.info({
|
|
548
|
+
action: "time.catchup.fired",
|
|
549
|
+
message: `catchup ${matches.length} missed tick(s) since ${new Date(props.lastFiredAt).toISOString()}`,
|
|
550
|
+
detail: {
|
|
551
|
+
count: matches.length,
|
|
552
|
+
policy: policy.mode
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
for (const firedAt of matches) this.emitTick(props.ctx, firedAt);
|
|
556
|
+
const last = matches[matches.length - 1];
|
|
557
|
+
if (last !== void 0) this.saveLastFiredAt(props.ctx, props.persister, last);
|
|
558
|
+
}
|
|
559
|
+
/** スケジューラが cron エラーで恒久停止した (dead-but-green を防ぐため接続状態を落とす) */
|
|
560
|
+
handleSchedulerHalt(ctx) {
|
|
561
|
+
ctx.log.error({
|
|
562
|
+
action: "time.scheduler.halted",
|
|
563
|
+
message: "scheduler halted due to cron error; time source will not tick again"
|
|
564
|
+
});
|
|
565
|
+
this.setStatus("disconnected", "scheduler halted");
|
|
566
|
+
}
|
|
567
|
+
async loadLastFiredAt(ctx, persister) {
|
|
568
|
+
const result = await attempt(() => persister.load());
|
|
569
|
+
if (result instanceof Error) {
|
|
570
|
+
ctx.log.warn({
|
|
571
|
+
action: "time.state.load.error",
|
|
572
|
+
message: safeErrorMessage({ error: result }),
|
|
573
|
+
error: result
|
|
574
|
+
});
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
if (result === null) return null;
|
|
578
|
+
if (typeof result.lastFiredAt !== "number" || !Number.isFinite(result.lastFiredAt)) return null;
|
|
579
|
+
return result.lastFiredAt;
|
|
580
|
+
}
|
|
581
|
+
saveLastFiredAt(ctx, persister, lastFiredAt) {
|
|
582
|
+
safeInvokeCallback({
|
|
583
|
+
fn: () => persister.save({ lastFiredAt }),
|
|
584
|
+
onError: (error) => {
|
|
585
|
+
ctx.log.warn({
|
|
586
|
+
action: "time.state.save.error",
|
|
587
|
+
message: safeErrorMessage({ error: safeNormalizeError({ value: error }) }),
|
|
588
|
+
error
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
291
593
|
safeMessage(ctx, tick) {
|
|
292
594
|
const message = this.options.message;
|
|
293
595
|
if (!message) return {};
|
|
@@ -316,4 +618,4 @@ var FlumeTimeSource = class extends FlumeSource {
|
|
|
316
618
|
}
|
|
317
619
|
};
|
|
318
620
|
//#endregion
|
|
319
|
-
export { FlumeTimeSource, flumeCronNext, parseCron };
|
|
621
|
+
export { FlumeTimeSource, flumeCollectCatchupMatches, flumeCronNext, parseCron };
|
package/package.json
CHANGED