@interactive-inc/flume 0.10.0 → 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 +303 -34
- package/dist/flume-source.d.ts +53 -13
- 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 +116 -45
- package/dist/index.js +318 -86
- package/dist/parse-error.d.ts +9 -0
- 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 +228 -31
- package/dist/time.d.ts +79 -2
- package/dist/time.js +621 -2
- package/package.json +1 -1
- package/dist/parse-cron.d.ts +0 -55
- package/dist/time-source.js +0 -427
package/dist/time.js
CHANGED
|
@@ -1,2 +1,621 @@
|
|
|
1
|
-
import { i as
|
|
2
|
-
|
|
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
|
+
import { t as isRecord } from "./is-record.js";
|
|
3
|
+
//#region lib/time/parse-cron-field.ts
|
|
4
|
+
/**
|
|
5
|
+
* 単一 cron フィールド (minute など) の spec を許可値の Set に展開する。
|
|
6
|
+
* 対応: `*` / `* /n` / `a` / `a-b` / `a-b/n` とそれらのカンマ区切り。名前 (JAN, MON) は非対応。
|
|
7
|
+
* 空トークン (`"5,"` / `"-5"` / `"/5"` など) は `Number("") === 0` の暗黙変換で
|
|
8
|
+
* 0 に化けるため、数値が期待される位置の空文字列は明示的に拒否する
|
|
9
|
+
*/
|
|
10
|
+
function parseCronField(spec, min, max) {
|
|
11
|
+
const values = /* @__PURE__ */ new Set();
|
|
12
|
+
for (const part of spec.split(",")) {
|
|
13
|
+
if (part === "") return new FlumeParseError(`empty cron list segment: "${spec}"`);
|
|
14
|
+
const expanded = expandCronPart(part, min, max);
|
|
15
|
+
if (expanded instanceof FlumeParseError) return expanded;
|
|
16
|
+
for (const value of expanded) values.add(value);
|
|
17
|
+
}
|
|
18
|
+
if (values.size === 0) return new FlumeParseError(`cron field empty: "${spec}"`);
|
|
19
|
+
return values;
|
|
20
|
+
}
|
|
21
|
+
function expandCronPart(part, min, max) {
|
|
22
|
+
const slash = part.indexOf("/");
|
|
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
|
+
const bounds = resolveBounds(range, min, max);
|
|
29
|
+
if (bounds instanceof FlumeParseError) return bounds;
|
|
30
|
+
const numbers = [];
|
|
31
|
+
for (let value = bounds.lo; value <= bounds.hi; value += step) numbers.push(value);
|
|
32
|
+
return numbers;
|
|
33
|
+
}
|
|
34
|
+
function resolveBounds(range, min, max) {
|
|
35
|
+
if (range === "*") return {
|
|
36
|
+
lo: min,
|
|
37
|
+
hi: max
|
|
38
|
+
};
|
|
39
|
+
const dash = range.indexOf("-");
|
|
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);
|
|
45
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi)) return new FlumeParseError(`invalid cron range: "${range}"`);
|
|
46
|
+
if (lo < min || hi > max || lo > hi) return new FlumeParseError(`cron value out of range [${min}-${max}]: "${range}"`);
|
|
47
|
+
return {
|
|
48
|
+
lo,
|
|
49
|
+
hi
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region lib/time/parse-cron.ts
|
|
54
|
+
/**
|
|
55
|
+
* 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
|
|
56
|
+
*/
|
|
57
|
+
function parseCron(expression) {
|
|
58
|
+
const trimmed = expression.trim();
|
|
59
|
+
const fields = trimmed.split(/\s+/);
|
|
60
|
+
if (fields.length !== 5) return new FlumeParseError(`cron must have 5 fields, got ${fields.length}: "${expression}"`);
|
|
61
|
+
const minutes = parseCronField(fields[0] ?? "", 0, 59);
|
|
62
|
+
if (minutes instanceof FlumeParseError) return minutes;
|
|
63
|
+
const hours = parseCronField(fields[1] ?? "", 0, 23);
|
|
64
|
+
if (hours instanceof FlumeParseError) return hours;
|
|
65
|
+
const daysOfMonth = parseCronField(fields[2] ?? "", 1, 31);
|
|
66
|
+
if (daysOfMonth instanceof FlumeParseError) return daysOfMonth;
|
|
67
|
+
const months = parseCronField(fields[3] ?? "", 1, 12);
|
|
68
|
+
if (months instanceof FlumeParseError) return months;
|
|
69
|
+
const rawDaysOfWeek = parseCronField(fields[4] ?? "", 0, 7);
|
|
70
|
+
if (rawDaysOfWeek instanceof FlumeParseError) return rawDaysOfWeek;
|
|
71
|
+
const daysOfWeek = /* @__PURE__ */ new Set();
|
|
72
|
+
for (const value of rawDaysOfWeek) daysOfWeek.add(value === 7 ? 0 : value);
|
|
73
|
+
return {
|
|
74
|
+
source: trimmed,
|
|
75
|
+
minutes,
|
|
76
|
+
hours,
|
|
77
|
+
daysOfMonth,
|
|
78
|
+
months,
|
|
79
|
+
daysOfWeek,
|
|
80
|
+
domRestricted: !fields[2]?.includes("*"),
|
|
81
|
+
dowRestricted: !fields[4]?.includes("*")
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region lib/time/cron-next.ts
|
|
86
|
+
const MINUTE_MS$1 = 6e4;
|
|
87
|
+
const MAX_ITERATIONS = 5e5;
|
|
88
|
+
/**
|
|
89
|
+
* `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
|
|
90
|
+
* 到達不能なら FlumeParseError を返す
|
|
91
|
+
*/
|
|
92
|
+
function flumeCronNext(cron, afterMs) {
|
|
93
|
+
let candidate = Math.floor(afterMs / MINUTE_MS$1) * MINUTE_MS$1 + MINUTE_MS$1;
|
|
94
|
+
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
|
95
|
+
const date = new Date(candidate);
|
|
96
|
+
if (!cron.months.has(date.getMonth() + 1)) {
|
|
97
|
+
candidate = new Date(date.getFullYear(), date.getMonth() + 1, 1, 0, 0, 0, 0).getTime();
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!matchesDay(cron, date)) {
|
|
101
|
+
candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!cron.hours.has(date.getHours())) {
|
|
105
|
+
candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours() + 1, 0, 0, 0).getTime();
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!cron.minutes.has(date.getMinutes())) {
|
|
109
|
+
candidate += MINUTE_MS$1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
return candidate;
|
|
113
|
+
}
|
|
114
|
+
return new FlumeParseError(`cron "${cron.source}" has no next time within bound`);
|
|
115
|
+
}
|
|
116
|
+
function matchesDay(cron, date) {
|
|
117
|
+
const domMatch = cron.daysOfMonth.has(date.getDate());
|
|
118
|
+
const dowMatch = cron.daysOfWeek.has(date.getDay());
|
|
119
|
+
if (cron.domRestricted && cron.dowRestricted) return domMatch || dowMatch;
|
|
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();
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region lib/time/time-scheduler.ts
|
|
153
|
+
const MAX_TIMEOUT_MS = 2e9;
|
|
154
|
+
const FIRE_TOLERANCE_MS = 1e3;
|
|
155
|
+
const DST_HISTORY_WINDOW_MS = 7200 * 1e3;
|
|
156
|
+
const MAX_DST_SKIPS = 180;
|
|
157
|
+
/**
|
|
158
|
+
* cron に従って `onTick` を駆動するタイマーループ。外部接続を持たないため reconnect 不要。
|
|
159
|
+
* IO 境界は全て `attempt` 経由で扱い、停止後はコールバックを発火しない。
|
|
160
|
+
* sleep-wake で複数回分を取り逃した場合は 1 回だけ発火して now まで早送りする
|
|
161
|
+
* (取り逃しは `scheduler.skipped` info で観測可能)
|
|
162
|
+
*/
|
|
163
|
+
var FlumeTimeScheduler = class {
|
|
164
|
+
props;
|
|
165
|
+
log;
|
|
166
|
+
isStoppedFlag = false;
|
|
167
|
+
timer = null;
|
|
168
|
+
target = 0;
|
|
169
|
+
recentFires = [];
|
|
170
|
+
constructor(props) {
|
|
171
|
+
this.props = props;
|
|
172
|
+
this.log = new FlumeLogger({
|
|
173
|
+
source: "time.scheduler",
|
|
174
|
+
handler: props.onLog,
|
|
175
|
+
deps: props.deps
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
get isStopped() {
|
|
179
|
+
return this.isStoppedFlag;
|
|
180
|
+
}
|
|
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);
|
|
188
|
+
if (next instanceof FlumeParseError) {
|
|
189
|
+
this.log.error({
|
|
190
|
+
action: "cron.no-next",
|
|
191
|
+
message: next.message,
|
|
192
|
+
error: next
|
|
193
|
+
});
|
|
194
|
+
return next;
|
|
195
|
+
}
|
|
196
|
+
this.target = next;
|
|
197
|
+
this.log.info({
|
|
198
|
+
action: "scheduler.start",
|
|
199
|
+
message: `next fire at ${new Date(next).toISOString()}`,
|
|
200
|
+
detail: { target: next }
|
|
201
|
+
});
|
|
202
|
+
return this.arm();
|
|
203
|
+
}
|
|
204
|
+
stop() {
|
|
205
|
+
this.isStoppedFlag = true;
|
|
206
|
+
this.clearTimer();
|
|
207
|
+
}
|
|
208
|
+
arm() {
|
|
209
|
+
if (this.isStoppedFlag) return null;
|
|
210
|
+
this.clearTimer();
|
|
211
|
+
const delay = Math.max(0, this.target - safeNow({ deps: this.props.deps }));
|
|
212
|
+
const capped = Math.min(delay, MAX_TIMEOUT_MS);
|
|
213
|
+
const result = attempt(() => this.props.deps.setTimeout(() => this.onWake(), capped));
|
|
214
|
+
if (result instanceof Error) {
|
|
215
|
+
this.log.error({
|
|
216
|
+
action: "scheduler.arm.error",
|
|
217
|
+
message: safeErrorMessage({ error: result }),
|
|
218
|
+
error: result
|
|
219
|
+
});
|
|
220
|
+
this.timer = null;
|
|
221
|
+
this.isStoppedFlag = true;
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
this.timer = result;
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
onWake() {
|
|
228
|
+
this.timer = null;
|
|
229
|
+
if (this.isStoppedFlag) return;
|
|
230
|
+
if (this.target - safeNow({ deps: this.props.deps }) > FIRE_TOLERANCE_MS) {
|
|
231
|
+
const error = this.arm();
|
|
232
|
+
if (error instanceof Error) this.halt(error);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const firedAt = this.target;
|
|
236
|
+
this.rememberFire(firedAt);
|
|
237
|
+
safeInvokeCallback({
|
|
238
|
+
fn: () => this.props.onTick(firedAt),
|
|
239
|
+
onError: (error) => {
|
|
240
|
+
this.log.error({
|
|
241
|
+
action: "scheduler.tick.error",
|
|
242
|
+
message: safeErrorMessage({ error }),
|
|
243
|
+
error
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
});
|
|
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);
|
|
270
|
+
if (next instanceof FlumeParseError) {
|
|
271
|
+
this.halt(next);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
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`);
|
|
319
|
+
}
|
|
320
|
+
clearTimer() {
|
|
321
|
+
if (this.timer === null) return;
|
|
322
|
+
const handle = this.timer;
|
|
323
|
+
const result = attempt(() => this.props.deps.clearTimeout(handle));
|
|
324
|
+
if (result instanceof Error) this.log.error({
|
|
325
|
+
action: "scheduler.timer.clear.error",
|
|
326
|
+
message: safeErrorMessage({ error: result }),
|
|
327
|
+
error: result
|
|
328
|
+
});
|
|
329
|
+
this.timer = null;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
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
|
|
432
|
+
//#region lib/time/time-source.ts
|
|
433
|
+
/**
|
|
434
|
+
* cron スケジュールで tick を emit する Source。外部接続を持たないため
|
|
435
|
+
* 起動成功と同時に `connected` になり reconnect の対象外。
|
|
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) 基準のため仕様とする
|
|
447
|
+
*/
|
|
448
|
+
var FlumeTimeSource = class extends FlumeSource {
|
|
449
|
+
options;
|
|
450
|
+
name = "time";
|
|
451
|
+
scheduler = null;
|
|
452
|
+
constructor(options) {
|
|
453
|
+
super();
|
|
454
|
+
this.options = options;
|
|
455
|
+
}
|
|
456
|
+
async connect(ctx) {
|
|
457
|
+
this.setStatus("connecting");
|
|
458
|
+
const cron = parseCron(this.options.cron);
|
|
459
|
+
if (cron instanceof FlumeParseError) {
|
|
460
|
+
const error = new FlumeStartError(`Time source: invalid cron "${this.options.cron}": ${cron.message}`);
|
|
461
|
+
ctx.log.error({
|
|
462
|
+
action: "source.start.failed",
|
|
463
|
+
message: safeErrorMessage({ error }),
|
|
464
|
+
error
|
|
465
|
+
});
|
|
466
|
+
this.setStatus("disconnected", error.message);
|
|
467
|
+
return error;
|
|
468
|
+
}
|
|
469
|
+
const persister = this.options.statePersister ?? null;
|
|
470
|
+
const lastFiredAt = persister === null ? null : await this.loadLastFiredAt(ctx, persister);
|
|
471
|
+
this.scheduler = new FlumeTimeScheduler({
|
|
472
|
+
cron,
|
|
473
|
+
onLog: ctx.log.handler,
|
|
474
|
+
deps: ctx.deps,
|
|
475
|
+
onTick: (firedAt) => this.handleTick(ctx, firedAt, persister),
|
|
476
|
+
onHalt: () => this.handleSchedulerHalt(ctx)
|
|
477
|
+
});
|
|
478
|
+
const startedAt = safeNow({ deps: ctx.deps });
|
|
479
|
+
const result = this.scheduler.start(startedAt);
|
|
480
|
+
if (result instanceof Error) {
|
|
481
|
+
const error = new FlumeStartError(`Time source: ${safeErrorMessage({ error: result })}`);
|
|
482
|
+
this.setStatus("disconnected", error.message);
|
|
483
|
+
return error;
|
|
484
|
+
}
|
|
485
|
+
this.setStatus("connected");
|
|
486
|
+
if (lastFiredAt !== null && persister !== null) this.runCatchup({
|
|
487
|
+
ctx,
|
|
488
|
+
cron,
|
|
489
|
+
lastFiredAt,
|
|
490
|
+
persister,
|
|
491
|
+
now: startedAt
|
|
492
|
+
});
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
disconnect() {
|
|
496
|
+
this.scheduler?.stop();
|
|
497
|
+
this.scheduler = null;
|
|
498
|
+
}
|
|
499
|
+
handleTick(ctx, firedAt, persister) {
|
|
500
|
+
this.emitTick(ctx, firedAt);
|
|
501
|
+
if (persister !== null) this.saveLastFiredAt(ctx, persister, firedAt);
|
|
502
|
+
}
|
|
503
|
+
emitTick(ctx, firedAt) {
|
|
504
|
+
const tick = {
|
|
505
|
+
firedAt,
|
|
506
|
+
cron: this.options.cron
|
|
507
|
+
};
|
|
508
|
+
const custom = this.safeMessage(ctx, tick);
|
|
509
|
+
this.emit({
|
|
510
|
+
source: "time",
|
|
511
|
+
type: typeof custom.type === "string" ? custom.type : "tick",
|
|
512
|
+
data: isRecord(custom.data) ? custom.data : {
|
|
513
|
+
firedAt,
|
|
514
|
+
cron: this.options.cron
|
|
515
|
+
},
|
|
516
|
+
meta: this.normalizeMeta(custom.meta, this.options.cron),
|
|
517
|
+
receivedAt: safeNow({ deps: ctx.deps })
|
|
518
|
+
});
|
|
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
|
+
}
|
|
593
|
+
safeMessage(ctx, tick) {
|
|
594
|
+
const message = this.options.message;
|
|
595
|
+
if (!message) return {};
|
|
596
|
+
const result = attempt(() => message(tick));
|
|
597
|
+
if (result instanceof Error) {
|
|
598
|
+
const error = safeNormalizeError({ value: result });
|
|
599
|
+
ctx.log.warn({
|
|
600
|
+
action: "message.error",
|
|
601
|
+
message: safeErrorMessage({ error }),
|
|
602
|
+
error,
|
|
603
|
+
detail: { firedAt: tick.firedAt }
|
|
604
|
+
});
|
|
605
|
+
return {};
|
|
606
|
+
}
|
|
607
|
+
return isRecord(result) ? result : {};
|
|
608
|
+
}
|
|
609
|
+
normalizeMeta(meta, cron) {
|
|
610
|
+
if (!isRecord(meta)) return { cron };
|
|
611
|
+
const normalized = {};
|
|
612
|
+
for (const key of Object.keys(meta)) {
|
|
613
|
+
const value = meta[key];
|
|
614
|
+
if (typeof value === "string") normalized[key] = value;
|
|
615
|
+
}
|
|
616
|
+
if (Object.keys(normalized).length === 0) return { cron };
|
|
617
|
+
return normalized;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
//#endregion
|
|
621
|
+
export { FlumeTimeSource, flumeCollectCatchupMatches, flumeCronNext, parseCron };
|
package/package.json
CHANGED
package/dist/parse-cron.d.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { D as FlumeSourceStartContext, L as FlumeTimeSourceOptions, t as FlumeSource } from "./flume-source.js";
|
|
2
|
-
|
|
3
|
-
//#region lib/errors/parse-error.d.ts
|
|
4
|
-
type Options = {
|
|
5
|
-
cause?: unknown;
|
|
6
|
-
};
|
|
7
|
-
declare class FlumeParseError extends Error {
|
|
8
|
-
constructor(message: string, options?: Options);
|
|
9
|
-
}
|
|
10
|
-
//#endregion
|
|
11
|
-
//#region lib/time/time-source.d.ts
|
|
12
|
-
/**
|
|
13
|
-
* cron スケジュールで tick を emit する Source。外部接続を持たないため
|
|
14
|
-
* 起動成功と同時に `connected` になり reconnect の対象外。
|
|
15
|
-
*
|
|
16
|
-
* options.statePersister + options.catchupPolicy を渡すと:
|
|
17
|
-
* 1. 起動時に lastFiredAt を読み出す
|
|
18
|
-
* 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
|
|
19
|
-
* 3. 各 tick 後に lastFiredAt を保存する (best-effort, ブロックしない)
|
|
20
|
-
*
|
|
21
|
-
* 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)
|
|
22
|
-
*/
|
|
23
|
-
declare class FlumeTimeSource extends FlumeSource {
|
|
24
|
-
private readonly options;
|
|
25
|
-
readonly name: "time";
|
|
26
|
-
private scheduler;
|
|
27
|
-
constructor(options: FlumeTimeSourceOptions);
|
|
28
|
-
protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
|
|
29
|
-
protected disconnect(): void;
|
|
30
|
-
private handleTick;
|
|
31
|
-
private emitTick;
|
|
32
|
-
private runCatchup;
|
|
33
|
-
private loadLastFiredAt;
|
|
34
|
-
private saveLastFiredAt;
|
|
35
|
-
private safeMessage;
|
|
36
|
-
private normalizeMeta;
|
|
37
|
-
}
|
|
38
|
-
//#endregion
|
|
39
|
-
//#region lib/time/parse-cron.d.ts
|
|
40
|
-
type FlumeCron = {
|
|
41
|
-
source: string;
|
|
42
|
-
minutes: ReadonlySet<number>;
|
|
43
|
-
hours: ReadonlySet<number>;
|
|
44
|
-
daysOfMonth: ReadonlySet<number>;
|
|
45
|
-
months: ReadonlySet<number>;
|
|
46
|
-
daysOfWeek: ReadonlySet<number>; /** day-of-month フィールドが `*` 以外か。dow と両方制限時は OR マッチ (標準 cron 準拠) */
|
|
47
|
-
domRestricted: boolean;
|
|
48
|
-
dowRestricted: boolean;
|
|
49
|
-
};
|
|
50
|
-
/**
|
|
51
|
-
* 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
|
|
52
|
-
*/
|
|
53
|
-
declare function parseCron(expression: string): FlumeCron | FlumeParseError;
|
|
54
|
-
//#endregion
|
|
55
|
-
export { FlumeParseError as i, parseCron as n, FlumeTimeSource as r, FlumeCron as t };
|