@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/index.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as safeNow, c as attempt, i as FlumeLogger, l as safeNormalizeError, n as FlumeSerialQueue, o as FlumeStartError, r as safeInvokeCallback, s as FlumeParseError, t as FlumeSource, u as safeErrorMessage } from "./flume-source.js";
|
|
2
2
|
import { t as FlumeConnectionError } from "./connection-error.js";
|
|
3
3
|
import { t as FlumeHttpError } from "./http-error.js";
|
|
4
|
-
import { n as flumeCollectCatchupMatches, t as FlumeTimeSource } from "./time-source.js";
|
|
5
4
|
//#region lib/deps.ts
|
|
6
5
|
/**
|
|
7
6
|
* `globalThis.WebSocket` の現在の値を返す。
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `beforeEach`
|
|
7
|
+
* `createFlumeDefaultDeps()` が返す deps の `WebSocket` は getter でここへ委譲するため、
|
|
8
|
+
* `new Flume()` 後に `globalThis.WebSocket` を差し込む (jsdom / happy-dom / vitest の
|
|
9
|
+
* `beforeEach` パッチ) テスト戦略がそのまま機能する — 参照は常にアクセス時点の最新値。
|
|
11
10
|
* `WebSocket` が無い環境 (Node の素の global など) では `null` を返す。
|
|
12
11
|
*/
|
|
13
12
|
function resolveCurrentWebSocket() {
|
|
@@ -28,7 +27,9 @@ function resolveCurrentWebSocket() {
|
|
|
28
27
|
function createFlumeDefaultDeps() {
|
|
29
28
|
return {
|
|
30
29
|
fetch: (url, init) => globalThis.fetch(url, init),
|
|
31
|
-
WebSocket
|
|
30
|
+
get WebSocket() {
|
|
31
|
+
return resolveCurrentWebSocket();
|
|
32
|
+
},
|
|
32
33
|
now: () => Date.now(),
|
|
33
34
|
random: () => Math.random(),
|
|
34
35
|
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
@@ -39,20 +40,25 @@ function createFlumeDefaultDeps() {
|
|
|
39
40
|
}
|
|
40
41
|
//#endregion
|
|
41
42
|
//#region lib/flume-stream.ts
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
function doneResult() {
|
|
44
|
+
return {
|
|
45
|
+
value: void 0,
|
|
46
|
+
done: true
|
|
47
|
+
};
|
|
48
|
+
}
|
|
46
49
|
/**
|
|
47
50
|
* push (`FlumeStreamHub.publish`) を pull (`for await`) に変換する async iterator。
|
|
48
51
|
* consumer が待っていれば即 resolve、いなければ buffer に積み、溢れたら onOverflow に従う。
|
|
49
|
-
*
|
|
52
|
+
* hub.close() ではバッファ済み item を吐き切ってから done へ落ちる (graceful tail drain)。
|
|
53
|
+
* consumer 側の `return()` (break / 例外) はバッファを破棄して即 done になる (iterator 仕様)。
|
|
54
|
+
* drop の観測は onDrop 経由 — drop 通知自体が firehose に還流して再帰しないよう初回のみ発火
|
|
50
55
|
*/
|
|
51
56
|
var FlumeStream = class {
|
|
52
57
|
props;
|
|
53
58
|
items = [];
|
|
54
59
|
resolvers = [];
|
|
55
60
|
closed = false;
|
|
61
|
+
droppedCount = 0;
|
|
56
62
|
constructor(props) {
|
|
57
63
|
this.props = props;
|
|
58
64
|
}
|
|
@@ -67,6 +73,7 @@ var FlumeStream = class {
|
|
|
67
73
|
return;
|
|
68
74
|
}
|
|
69
75
|
if (this.items.length >= this.props.buffer) {
|
|
76
|
+
this.recordDrop();
|
|
70
77
|
if (this.props.onOverflow === "drop-newest") return;
|
|
71
78
|
this.items.shift();
|
|
72
79
|
}
|
|
@@ -77,51 +84,102 @@ var FlumeStream = class {
|
|
|
77
84
|
this.closed = true;
|
|
78
85
|
while (this.resolvers.length > 0) {
|
|
79
86
|
const resolver = this.resolvers.shift();
|
|
80
|
-
if (resolver) resolver(
|
|
87
|
+
if (resolver) resolver(doneResult());
|
|
81
88
|
}
|
|
82
89
|
}
|
|
90
|
+
get dropped() {
|
|
91
|
+
return this.droppedCount;
|
|
92
|
+
}
|
|
83
93
|
next() {
|
|
84
94
|
const item = this.items.shift();
|
|
85
95
|
if (item !== void 0) return Promise.resolve({
|
|
86
96
|
value: item,
|
|
87
97
|
done: false
|
|
88
98
|
});
|
|
89
|
-
if (this.closed) return Promise.resolve(
|
|
99
|
+
if (this.closed) return Promise.resolve(doneResult());
|
|
90
100
|
return new Promise((resolve) => this.resolvers.push(resolve));
|
|
91
101
|
}
|
|
92
102
|
return() {
|
|
103
|
+
this.items.splice(0);
|
|
104
|
+
this.close();
|
|
105
|
+
this.props.onClose();
|
|
106
|
+
return Promise.resolve(doneResult());
|
|
107
|
+
}
|
|
108
|
+
throw(error) {
|
|
109
|
+
this.items.splice(0);
|
|
93
110
|
this.close();
|
|
94
111
|
this.props.onClose();
|
|
95
|
-
return Promise.
|
|
112
|
+
return Promise.reject(error);
|
|
96
113
|
}
|
|
97
114
|
[Symbol.asyncIterator]() {
|
|
98
115
|
return this;
|
|
99
116
|
}
|
|
117
|
+
recordDrop() {
|
|
118
|
+
this.droppedCount++;
|
|
119
|
+
if (this.droppedCount > 1) return;
|
|
120
|
+
this.props.onDrop?.({ dropped: 1 });
|
|
121
|
+
}
|
|
100
122
|
};
|
|
101
123
|
//#endregion
|
|
102
124
|
//#region lib/flume-stream-hub.ts
|
|
103
125
|
const DEFAULT_BUFFER = 1e3;
|
|
104
126
|
/**
|
|
127
|
+
* NaN / Infinity / 0 以下を弾いて必ず 1 以上の有限整数にする。
|
|
128
|
+
* 不正値で backpressure 上限が実質無効化される (比較が常に false → 無制限成長) のを防ぐ
|
|
129
|
+
*/
|
|
130
|
+
function sanitizeBufferSize(value) {
|
|
131
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_BUFFER;
|
|
132
|
+
if (value < 1) return 1;
|
|
133
|
+
return Math.floor(value);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
105
136
|
* firehose (`onEvent` / `stream()`) の item を複数の pull consumer へ fan-out する内部ハブ。
|
|
106
137
|
* subscriber が居なければ publish は実質 no-op。Flume 停止時に close() で全 stream を終端する
|
|
107
138
|
*/
|
|
108
139
|
var FlumeStreamHub = class {
|
|
140
|
+
props;
|
|
109
141
|
streams = /* @__PURE__ */ new Set();
|
|
142
|
+
startupItems = [];
|
|
143
|
+
startupDropNotified = false;
|
|
144
|
+
hasSubscribed = false;
|
|
110
145
|
closed = false;
|
|
146
|
+
constructor(props = {}) {
|
|
147
|
+
this.props = props;
|
|
148
|
+
}
|
|
149
|
+
get isClosed() {
|
|
150
|
+
return this.closed;
|
|
151
|
+
}
|
|
111
152
|
publish(item) {
|
|
112
153
|
if (this.closed) return;
|
|
154
|
+
if (!this.hasSubscribed) {
|
|
155
|
+
if (this.startupItems.length >= DEFAULT_BUFFER) {
|
|
156
|
+
this.startupItems.shift();
|
|
157
|
+
if (!this.startupDropNotified) {
|
|
158
|
+
this.startupDropNotified = true;
|
|
159
|
+
this.props.onDrop?.({ dropped: 1 });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.startupItems.push(item);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
113
165
|
for (const stream of this.streams) stream.push(item);
|
|
114
166
|
}
|
|
115
167
|
subscribe(options) {
|
|
116
168
|
const stream = new FlumeStream({
|
|
117
|
-
buffer: options?.buffer
|
|
169
|
+
buffer: sanitizeBufferSize(options?.buffer),
|
|
118
170
|
onOverflow: options?.onOverflow ?? "drop-oldest",
|
|
119
|
-
onClose: () => this.streams.delete(stream)
|
|
171
|
+
onClose: () => this.streams.delete(stream),
|
|
172
|
+
onDrop: this.props.onDrop
|
|
120
173
|
});
|
|
121
174
|
if (this.closed) {
|
|
122
175
|
stream.close();
|
|
123
176
|
return stream;
|
|
124
177
|
}
|
|
178
|
+
if (!this.hasSubscribed) {
|
|
179
|
+
this.hasSubscribed = true;
|
|
180
|
+
for (const item of this.startupItems) stream.push(item);
|
|
181
|
+
this.startupItems.splice(0);
|
|
182
|
+
}
|
|
125
183
|
this.streams.add(stream);
|
|
126
184
|
return stream;
|
|
127
185
|
}
|
|
@@ -130,6 +188,7 @@ var FlumeStreamHub = class {
|
|
|
130
188
|
this.closed = true;
|
|
131
189
|
for (const stream of this.streams) stream.close();
|
|
132
190
|
this.streams.clear();
|
|
191
|
+
this.startupItems.splice(0);
|
|
133
192
|
}
|
|
134
193
|
};
|
|
135
194
|
//#endregion
|
|
@@ -140,14 +199,16 @@ var FlumeStreamHub = class {
|
|
|
140
199
|
* きれいに close したか / どれが失敗したか」を直接判定できる
|
|
141
200
|
*/
|
|
142
201
|
var FlumeClosed = class {
|
|
143
|
-
props;
|
|
144
202
|
kind = "closed";
|
|
203
|
+
finalStatuses;
|
|
204
|
+
closeErrors;
|
|
145
205
|
constructor(props) {
|
|
146
|
-
this.
|
|
206
|
+
this.finalStatuses = Object.freeze(props.finalStatuses.map((status) => Object.freeze({ ...status })));
|
|
207
|
+
this.closeErrors = Object.freeze(props.closeErrors.map((closeError) => Object.freeze({ ...closeError })));
|
|
147
208
|
Object.freeze(this);
|
|
148
209
|
}
|
|
149
210
|
statuses() {
|
|
150
|
-
return this.
|
|
211
|
+
return this.finalStatuses;
|
|
151
212
|
}
|
|
152
213
|
/**
|
|
153
214
|
* `runClose` 中に `source.stop()` が rejected で settle した source の名前と
|
|
@@ -155,7 +216,7 @@ var FlumeClosed = class {
|
|
|
155
216
|
* 全 source が clean close した場合は空配列。
|
|
156
217
|
*/
|
|
157
218
|
errors() {
|
|
158
|
-
return this.
|
|
219
|
+
return this.closeErrors;
|
|
159
220
|
}
|
|
160
221
|
};
|
|
161
222
|
//#endregion
|
|
@@ -219,9 +280,10 @@ var FlumeRunning = class {
|
|
|
219
280
|
return this.props.hub.subscribe(options);
|
|
220
281
|
}
|
|
221
282
|
/**
|
|
222
|
-
*
|
|
283
|
+
* `Flume({ signal })` に渡された AbortSignal をそのまま公開する。
|
|
223
284
|
* 直接の controller を持っていない呼び出し元が `running.signal?.aborted`
|
|
224
|
-
* で abort
|
|
285
|
+
* で abort 状態を確認できる。`FlumeConfluence` 経由で開かれたグループでは
|
|
286
|
+
* host の signal ではなく confluence 内部の timeout controller の signal になる点に注意
|
|
225
287
|
*/
|
|
226
288
|
get signal() {
|
|
227
289
|
return this.props.signal;
|
|
@@ -234,10 +296,11 @@ var FlumeRunning = class {
|
|
|
234
296
|
message: `closing ${this.props.sources.length} source(s)`
|
|
235
297
|
});
|
|
236
298
|
const settled = await Promise.allSettled(this.props.sources.map((source) => Promise.resolve().then(() => source.stop())));
|
|
237
|
-
for (const [index, result] of settled.entries())
|
|
299
|
+
for (const [index, result] of settled.entries()) {
|
|
238
300
|
const source = this.props.sources[index];
|
|
239
301
|
const name = source ? this.sourceName(source) : "?";
|
|
240
|
-
const error = safeNormalizeError({ value: result.reason });
|
|
302
|
+
const error = result.status === "rejected" ? safeNormalizeError({ value: result.reason }) : result.value instanceof Error ? result.value : null;
|
|
303
|
+
if (error === null) continue;
|
|
241
304
|
closeErrors.push({
|
|
242
305
|
source: name,
|
|
243
306
|
error
|
|
@@ -261,10 +324,12 @@ var FlumeRunning = class {
|
|
|
261
324
|
});
|
|
262
325
|
}
|
|
263
326
|
}
|
|
327
|
+
await this.props.callbackQueue.drain();
|
|
264
328
|
this.props.log.info({
|
|
265
329
|
action: "flume.close.complete",
|
|
266
330
|
message: "all sources closed"
|
|
267
331
|
});
|
|
332
|
+
await this.props.callbackQueue.drain();
|
|
268
333
|
this.props.hub.close();
|
|
269
334
|
return new FlumeClosed({
|
|
270
335
|
finalStatuses: this.snapshotStatuses(),
|
|
@@ -321,12 +386,42 @@ const DEFAULTS = {
|
|
|
321
386
|
baseDelay: 1e3,
|
|
322
387
|
maxDelay: 3e4
|
|
323
388
|
};
|
|
389
|
+
/**
|
|
390
|
+
* `baseDelay` として受理できる値のみ透過する。NaN / Infinity / 1ms 未満 /
|
|
391
|
+
* 非数値 / 明示的 `undefined` (spread でデフォルトを潰すケース) は既定値へ落とす。
|
|
392
|
+
* 0 や負値を許すと `maxAttempts: Infinity` と組み合わさって 0ms 再接続ホットループになる
|
|
393
|
+
*/
|
|
394
|
+
function sanitizeBaseDelay(value) {
|
|
395
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULTS.baseDelay;
|
|
396
|
+
if (value < 1) return DEFAULTS.baseDelay;
|
|
397
|
+
return value;
|
|
398
|
+
}
|
|
399
|
+
function sanitizeMaxDelay(value, baseDelay) {
|
|
400
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return Math.max(DEFAULTS.maxDelay, baseDelay);
|
|
401
|
+
if (value < baseDelay) return baseDelay;
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
/** 1 以上の整数 or Infinity のみ透過。NaN / 0 / 負値 / 小数は既定 (Infinity) へ */
|
|
405
|
+
function sanitizeMaxAttempts(value) {
|
|
406
|
+
if (typeof value !== "number" || Number.isNaN(value)) return DEFAULTS.maxAttempts;
|
|
407
|
+
if (value === Infinity) return Infinity;
|
|
408
|
+
if (!Number.isInteger(value) || value < 1) return DEFAULTS.maxAttempts;
|
|
409
|
+
return value;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* ユーザー入力の reconnect 指定を検証済み `FlumeReconnectConfig` へ解決する。
|
|
413
|
+
* `false` / `undefined` は再接続無効 (null)。`true` は既定値。
|
|
414
|
+
* オブジェクトはフィールドごとに検証し、不正値 (NaN / 負値 / Infinity delay 等) は
|
|
415
|
+
* 既定値へフォールバックする — throw しない
|
|
416
|
+
*/
|
|
324
417
|
function resolveFlumeReconnectConfig(input) {
|
|
325
418
|
if (input === false || input === void 0) return null;
|
|
326
419
|
if (input === true) return { ...DEFAULTS };
|
|
420
|
+
const baseDelay = sanitizeBaseDelay(input.baseDelay);
|
|
327
421
|
return {
|
|
328
|
-
|
|
329
|
-
|
|
422
|
+
maxAttempts: sanitizeMaxAttempts(input.maxAttempts),
|
|
423
|
+
baseDelay,
|
|
424
|
+
maxDelay: sanitizeMaxDelay(input.maxDelay, baseDelay)
|
|
330
425
|
};
|
|
331
426
|
}
|
|
332
427
|
//#endregion
|
|
@@ -335,8 +430,8 @@ function resolveFlumeReconnectConfig(input) {
|
|
|
335
430
|
* 起動前の Flume。`open()` で `FlumeRunning` へ遷移する。
|
|
336
431
|
* コンストラクタは単一オブジェクト `{ sources, ...options }` を受け取る (`sources` のみ必須)。
|
|
337
432
|
* events も全ログも 1 本の firehose (`onEvent` push / `stream()` pull) に流れ、購読側が filter する。
|
|
338
|
-
* いずれかの source
|
|
339
|
-
*
|
|
433
|
+
* いずれかの source 失敗時は全 source を `stop()` してロールバックし `FlumeStartError` を返す
|
|
434
|
+
* (失敗した source も半接続状態のリソースを持ち得るため、成功分だけでなく全数を stop する)。
|
|
340
435
|
* `source.start()` / `source.stop()` の sync throw も `Promise.resolve().then` 経由で
|
|
341
436
|
* Promise rejection に正規化して `allSettled` で捕捉する (`open()` は決して reject しない)
|
|
342
437
|
*/
|
|
@@ -347,11 +442,13 @@ var Flume = class {
|
|
|
347
442
|
deps;
|
|
348
443
|
sources;
|
|
349
444
|
sourceEventHandler;
|
|
350
|
-
hub
|
|
445
|
+
hub;
|
|
446
|
+
callbackQueue = new FlumeSerialQueue();
|
|
351
447
|
constructor(options) {
|
|
352
448
|
this.options = options;
|
|
353
|
-
this.sources = options.sources;
|
|
449
|
+
this.sources = [...options.sources];
|
|
354
450
|
this.deps = options.deps ?? createFlumeDefaultDeps();
|
|
451
|
+
this.hub = new FlumeStreamHub({ onDrop: () => this.notifyStreamOverflow() });
|
|
355
452
|
this.log = new FlumeLogger({
|
|
356
453
|
source: "flume",
|
|
357
454
|
handler: this.buildLogHandler(),
|
|
@@ -362,6 +459,25 @@ var Flume = class {
|
|
|
362
459
|
event
|
|
363
460
|
});
|
|
364
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* stream の buffer 溢れ通知 (stream ごとに初回 1 回)。
|
|
464
|
+
* firehose (hub) には流さない — 溢れている stream 自身に還流して実イベントを
|
|
465
|
+
* さらに追い出す自己破壊になるため、push の `onEvent` にだけ warn log として届ける
|
|
466
|
+
*/
|
|
467
|
+
notifyStreamOverflow() {
|
|
468
|
+
if (!this.options.onEvent) return;
|
|
469
|
+
const log = {
|
|
470
|
+
level: "warn",
|
|
471
|
+
source: "flume",
|
|
472
|
+
action: "stream.overflow",
|
|
473
|
+
message: "stream buffer overflowed, dropping items (notified once per stream; see FlumeStreamOptions.buffer)",
|
|
474
|
+
timestamp: safeNow({ deps: this.deps })
|
|
475
|
+
};
|
|
476
|
+
this.enqueueCallback({
|
|
477
|
+
kind: "log",
|
|
478
|
+
log
|
|
479
|
+
});
|
|
480
|
+
}
|
|
365
481
|
/** source が受信したログを firehose へ流す handler。error は onError にも分岐する */
|
|
366
482
|
buildLogHandler() {
|
|
367
483
|
return (log) => {
|
|
@@ -378,16 +494,21 @@ var Flume = class {
|
|
|
378
494
|
}
|
|
379
495
|
/**
|
|
380
496
|
* firehose の単一 sink: pull の hub と push の onEvent の両方へ item を配る。
|
|
497
|
+
* close 後の遅延 emit (stop 中の straggler) は push 側にも流さない (pull 側と対称にする)。
|
|
381
498
|
* onEvent への転送は this.log を経由しない (経由すると log item 経路で再帰する) ため
|
|
382
499
|
* 例外をここで握り潰す
|
|
383
500
|
*/
|
|
384
501
|
emitItem(item) {
|
|
502
|
+
if (this.hub.isClosed) return Promise.resolve();
|
|
385
503
|
this.hub.publish(item);
|
|
504
|
+
return this.enqueueCallback(item);
|
|
505
|
+
}
|
|
506
|
+
enqueueCallback(item) {
|
|
386
507
|
const onEvent = this.options.onEvent;
|
|
387
|
-
if (!onEvent) return;
|
|
388
|
-
|
|
389
|
-
Promise.resolve(onEvent(item))
|
|
390
|
-
}
|
|
508
|
+
if (!onEvent) return Promise.resolve();
|
|
509
|
+
return this.callbackQueue.add(async () => {
|
|
510
|
+
await attempt(() => Promise.resolve(onEvent(item)));
|
|
511
|
+
});
|
|
391
512
|
}
|
|
392
513
|
async open() {
|
|
393
514
|
const guard = this.guardOpen();
|
|
@@ -398,10 +519,9 @@ var Flume = class {
|
|
|
398
519
|
message: `opening ${this.sources.length} source(s)`,
|
|
399
520
|
detail: { count: this.sources.length }
|
|
400
521
|
});
|
|
401
|
-
const reconnect =
|
|
522
|
+
const reconnect = this.resolveReconnect();
|
|
402
523
|
const settled = await Promise.allSettled(this.sources.map((source) => this.safeStart(source, reconnect)));
|
|
403
524
|
const failures = [];
|
|
404
|
-
const started = [];
|
|
405
525
|
for (const [index, result] of settled.entries()) {
|
|
406
526
|
const source = this.sources[index];
|
|
407
527
|
if (source === void 0) continue;
|
|
@@ -413,14 +533,10 @@ var Flume = class {
|
|
|
413
533
|
});
|
|
414
534
|
continue;
|
|
415
535
|
}
|
|
416
|
-
if (result.value instanceof Error) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
});
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
started.push(source);
|
|
536
|
+
if (result.value instanceof Error) failures.push({
|
|
537
|
+
name,
|
|
538
|
+
error: result.value
|
|
539
|
+
});
|
|
424
540
|
}
|
|
425
541
|
if (failures.length > 0) {
|
|
426
542
|
for (const failure of failures) this.log.error({
|
|
@@ -429,7 +545,7 @@ var Flume = class {
|
|
|
429
545
|
error: failure.error,
|
|
430
546
|
detail: { source: failure.name }
|
|
431
547
|
});
|
|
432
|
-
await this.rollback(
|
|
548
|
+
await this.rollback(this.sources);
|
|
433
549
|
const detail = failures.map((f) => `${f.name}: ${safeErrorMessage({ error: f.error })}`).join("; ");
|
|
434
550
|
const error = new FlumeStartError(`Flume.open: ${failures.length} source(s) failed: ${detail}`);
|
|
435
551
|
this.log.error({
|
|
@@ -437,6 +553,7 @@ var Flume = class {
|
|
|
437
553
|
message: safeErrorMessage({ error }),
|
|
438
554
|
error
|
|
439
555
|
});
|
|
556
|
+
await this.callbackQueue.drain();
|
|
440
557
|
return error;
|
|
441
558
|
}
|
|
442
559
|
if (this.isSignalAborted()) {
|
|
@@ -447,18 +564,42 @@ var Flume = class {
|
|
|
447
564
|
message: safeErrorMessage({ error }),
|
|
448
565
|
error
|
|
449
566
|
});
|
|
567
|
+
await this.callbackQueue.drain();
|
|
450
568
|
return error;
|
|
451
569
|
}
|
|
452
570
|
this.log.info({
|
|
453
571
|
action: "flume.open.complete",
|
|
454
572
|
message: "all sources opened"
|
|
455
573
|
});
|
|
456
|
-
|
|
574
|
+
await this.callbackQueue.drain();
|
|
575
|
+
if (this.isSignalAborted()) {
|
|
576
|
+
await this.rollback(this.sources);
|
|
577
|
+
const error = new FlumeStartError("Flume.open: aborted during completion");
|
|
578
|
+
this.log.warn({
|
|
579
|
+
action: "flume.open.aborted",
|
|
580
|
+
message: safeErrorMessage({ error }),
|
|
581
|
+
error
|
|
582
|
+
});
|
|
583
|
+
await this.callbackQueue.drain();
|
|
584
|
+
return error;
|
|
585
|
+
}
|
|
586
|
+
const running = new FlumeRunning({
|
|
457
587
|
sources: this.sources,
|
|
458
588
|
signal: this.options.signal,
|
|
459
589
|
log: this.log,
|
|
460
|
-
hub: this.hub
|
|
590
|
+
hub: this.hub,
|
|
591
|
+
callbackQueue: this.callbackQueue
|
|
461
592
|
});
|
|
593
|
+
if (!this.isSignalAborted()) return running;
|
|
594
|
+
await running.close();
|
|
595
|
+
const error = new FlumeStartError("Flume.open: aborted while entering running state");
|
|
596
|
+
this.log.warn({
|
|
597
|
+
action: "flume.open.aborted",
|
|
598
|
+
message: safeErrorMessage({ error }),
|
|
599
|
+
error
|
|
600
|
+
});
|
|
601
|
+
await this.callbackQueue.drain();
|
|
602
|
+
return error;
|
|
462
603
|
}
|
|
463
604
|
guardOpen() {
|
|
464
605
|
if (this.consumed) {
|
|
@@ -481,6 +622,19 @@ var Flume = class {
|
|
|
481
622
|
}
|
|
482
623
|
return null;
|
|
483
624
|
}
|
|
625
|
+
/** reconnect オプションの解決。throwing getter を持つ hostile 入力でも open() を reject させない */
|
|
626
|
+
resolveReconnect() {
|
|
627
|
+
const result = attempt(() => resolveFlumeReconnectConfig(this.options.reconnect));
|
|
628
|
+
if (result instanceof Error) {
|
|
629
|
+
this.log.warn({
|
|
630
|
+
action: "reconnect.config.invalid",
|
|
631
|
+
message: safeErrorMessage({ error: result }),
|
|
632
|
+
error: result
|
|
633
|
+
});
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
return result;
|
|
637
|
+
}
|
|
484
638
|
isSignalAborted() {
|
|
485
639
|
const signal = this.options.signal;
|
|
486
640
|
if (!signal) return false;
|
|
@@ -506,18 +660,25 @@ var Flume = class {
|
|
|
506
660
|
}
|
|
507
661
|
async rollback(sources) {
|
|
508
662
|
const settled = await Promise.allSettled(sources.map((source) => Promise.resolve().then(() => source.stop())));
|
|
509
|
-
for (const [index, result] of settled.entries())
|
|
663
|
+
for (const [index, result] of settled.entries()) {
|
|
510
664
|
const source = sources[index];
|
|
511
665
|
const name = source ? this.sourceName(source) : "?";
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
});
|
|
666
|
+
if (result.status === "rejected") {
|
|
667
|
+
const error = safeNormalizeError({ value: result.reason });
|
|
668
|
+
this.logRollbackFailure(name, error);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (result.value instanceof Error) this.logRollbackFailure(name, result.value);
|
|
519
672
|
}
|
|
520
673
|
}
|
|
674
|
+
logRollbackFailure(name, error) {
|
|
675
|
+
this.log.error({
|
|
676
|
+
action: "flume.rollback.failed",
|
|
677
|
+
message: `${name}: ${safeErrorMessage({ error })}`,
|
|
678
|
+
error,
|
|
679
|
+
detail: { source: name }
|
|
680
|
+
});
|
|
681
|
+
}
|
|
521
682
|
};
|
|
522
683
|
//#endregion
|
|
523
684
|
//#region lib/flume-confluence.ts
|
|
@@ -531,28 +692,61 @@ const DEFAULT_REPLACE_TIMEOUT_MS = 1e4;
|
|
|
531
692
|
* `replace(id, sources)` は同じ id のグループを差し替える。新グループを先に起動し、
|
|
532
693
|
* 起動成功時にのみ旧グループを停止するので連続稼働を維持できる (token rotation 用途)。
|
|
533
694
|
* 起動失敗時は旧グループはそのまま走り続ける。
|
|
695
|
+
* 注意: 失敗した replace / add に渡した source インスタンスは consumed になるため、
|
|
696
|
+
* リトライには新しいインスタンスを構築する必要がある。
|
|
697
|
+
*
|
|
698
|
+
* `closeAll()` は終端操作。以後の `add()` / `replace()` は拒否され、closeAll と並行して
|
|
699
|
+
* 起動中だったグループも abort して完了を待つ (シャットダウン後に誰にも止められない
|
|
700
|
+
* グループが残らない)。
|
|
534
701
|
*
|
|
535
702
|
* throw しない流儀に従い `add()` / `replace()` は `Error | null` を返す
|
|
536
703
|
*/
|
|
537
704
|
var FlumeConfluence = class {
|
|
538
705
|
props;
|
|
539
706
|
running = /* @__PURE__ */ new Map();
|
|
707
|
+
/** open() を await 中でまだ Map に commit されていないグループの id (add 重複と remove 追跡用) */
|
|
708
|
+
pendingIds = /* @__PURE__ */ new Set();
|
|
709
|
+
removedWhilePending = /* @__PURE__ */ new Set();
|
|
710
|
+
pendingOpens = /* @__PURE__ */ new Set();
|
|
711
|
+
isClosedFlag = false;
|
|
540
712
|
deps;
|
|
541
713
|
constructor(props = {}) {
|
|
542
714
|
this.props = props;
|
|
543
715
|
this.deps = props.deps ?? createFlumeDefaultDeps();
|
|
544
716
|
}
|
|
717
|
+
get isClosed() {
|
|
718
|
+
return this.isClosedFlag;
|
|
719
|
+
}
|
|
545
720
|
/** sources を 1 グループとして起動。id 重複や起動失敗は `Error` で返す (throw しない) */
|
|
546
721
|
async add(id, sources) {
|
|
547
|
-
if (this.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
722
|
+
if (this.isClosedFlag) return new FlumeStartError(`FlumeConfluence: already closed: ${id}`);
|
|
723
|
+
if (this.running.has(id) || this.pendingIds.has(id)) return new FlumeStartError(`FlumeConfluence: id already added: ${id}`);
|
|
724
|
+
this.pendingIds.add(id);
|
|
725
|
+
const pending = this.createPendingOpen();
|
|
726
|
+
this.pendingOpens.add(pending);
|
|
727
|
+
try {
|
|
728
|
+
const running = await this.openGroup(id, sources, pending.controller, void 0);
|
|
729
|
+
if (running instanceof Error) return running;
|
|
730
|
+
if (this.isClosedFlag) {
|
|
731
|
+
await running.close();
|
|
732
|
+
return new FlumeStartError(`FlumeConfluence: closed during add: ${id}`);
|
|
733
|
+
}
|
|
734
|
+
if (this.removedWhilePending.has(id)) {
|
|
735
|
+
await running.close();
|
|
736
|
+
return new FlumeStartError(`FlumeConfluence: removed during add: ${id}`);
|
|
737
|
+
}
|
|
738
|
+
if (this.running.has(id)) {
|
|
739
|
+
await running.close();
|
|
740
|
+
return new FlumeStartError(`FlumeConfluence: id already added: ${id}`);
|
|
741
|
+
}
|
|
742
|
+
this.running.set(id, running);
|
|
743
|
+
return null;
|
|
744
|
+
} finally {
|
|
745
|
+
this.pendingIds.delete(id);
|
|
746
|
+
this.removedWhilePending.delete(id);
|
|
747
|
+
this.pendingOpens.delete(pending);
|
|
748
|
+
pending.finish();
|
|
553
749
|
}
|
|
554
|
-
this.running.set(id, running);
|
|
555
|
-
return null;
|
|
556
750
|
}
|
|
557
751
|
/**
|
|
558
752
|
* 既存グループを新しい sources で差し替える。新グループを先に起動し、成功時のみ旧を停止する。
|
|
@@ -560,27 +754,48 @@ var FlumeConfluence = class {
|
|
|
560
754
|
* 旧グループが存在しない場合は `Error` を返す (replace は add と違ってグループの存在を前提とする)
|
|
561
755
|
*/
|
|
562
756
|
async replace(id, sources, options) {
|
|
757
|
+
if (this.isClosedFlag) return new FlumeStartError(`FlumeConfluence: already closed: ${id}`);
|
|
563
758
|
const previous = this.running.get(id);
|
|
564
759
|
if (!previous) return new FlumeStartError(`FlumeConfluence: id not running: ${id}`);
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
await
|
|
570
|
-
|
|
760
|
+
const pending = this.createPendingOpen();
|
|
761
|
+
this.pendingOpens.add(pending);
|
|
762
|
+
try {
|
|
763
|
+
const timeoutMs = options?.replaceTimeoutMs ?? DEFAULT_REPLACE_TIMEOUT_MS;
|
|
764
|
+
const next = await this.openGroup(id, sources, pending.controller, timeoutMs);
|
|
765
|
+
if (next instanceof Error) return next;
|
|
766
|
+
if (this.isClosedFlag) {
|
|
767
|
+
await next.close();
|
|
768
|
+
return new FlumeStartError(`FlumeConfluence: closed during replace: ${id}`);
|
|
769
|
+
}
|
|
770
|
+
if (this.running.get(id) !== previous) {
|
|
771
|
+
await next.close();
|
|
772
|
+
return new FlumeStartError(`FlumeConfluence: ${id} concurrently mutated during replace`);
|
|
773
|
+
}
|
|
774
|
+
this.running.set(id, next);
|
|
775
|
+
await previous.close();
|
|
776
|
+
return null;
|
|
777
|
+
} finally {
|
|
778
|
+
this.pendingOpens.delete(pending);
|
|
779
|
+
pending.finish();
|
|
571
780
|
}
|
|
572
|
-
this.running.set(id, next);
|
|
573
|
-
await previous.close();
|
|
574
|
-
return null;
|
|
575
781
|
}
|
|
576
|
-
/**
|
|
782
|
+
/**
|
|
783
|
+
* 指定グループだけ close。他グループは無停止。未知の id は no-op。
|
|
784
|
+
* 起動中 (add が open を await 中) の id は commit 時点で破棄されるよう予約する
|
|
785
|
+
*/
|
|
577
786
|
async remove(id) {
|
|
787
|
+
if (this.pendingIds.has(id)) this.removedWhilePending.add(id);
|
|
578
788
|
const running = this.running.get(id);
|
|
579
789
|
if (!running) return;
|
|
580
790
|
this.running.delete(id);
|
|
581
791
|
await running.close();
|
|
582
792
|
}
|
|
793
|
+
/** 終端操作。全グループを close し、以後の add / replace を拒否する */
|
|
583
794
|
async closeAll() {
|
|
795
|
+
this.isClosedFlag = true;
|
|
796
|
+
const pending = [...this.pendingOpens];
|
|
797
|
+
for (const operation of pending) attempt(() => operation.controller.abort());
|
|
798
|
+
await Promise.allSettled(pending.map((operation) => operation.done));
|
|
584
799
|
const ids = [...this.running.keys()];
|
|
585
800
|
await Promise.all(ids.map((id) => this.remove(id)));
|
|
586
801
|
}
|
|
@@ -591,33 +806,50 @@ var FlumeConfluence = class {
|
|
|
591
806
|
return [...this.running.keys()];
|
|
592
807
|
}
|
|
593
808
|
/**
|
|
594
|
-
* 1 グループ分の Flume を開いて FlumeRunning を返す。timeoutMs を指定すると
|
|
595
|
-
*
|
|
596
|
-
*
|
|
809
|
+
* 1 グループ分の Flume を開いて FlumeRunning を返す。timeoutMs を指定すると AbortSignal を
|
|
810
|
+
* ctx.signal として各 source へ注入し、超過時に abort して進行中の connect ごと中止する
|
|
811
|
+
* (source 側は base クラスが signal を購読して stop() を発火する)。
|
|
812
|
+
* 失敗時の rollback は Flume 本体に任せる
|
|
597
813
|
*/
|
|
598
|
-
async openGroup(id, sources, timeoutMs) {
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
|
|
814
|
+
async openGroup(id, sources, controller, timeoutMs) {
|
|
815
|
+
const timeoutState = { isArmed: timeoutMs !== void 0 };
|
|
816
|
+
const timeoutResult = timeoutMs === void 0 ? null : attempt(() => this.deps.setTimeout(() => {
|
|
817
|
+
if (timeoutState.isArmed) controller.abort();
|
|
818
|
+
}, timeoutMs));
|
|
819
|
+
if (timeoutResult instanceof Error) {
|
|
820
|
+
attempt(() => controller.abort());
|
|
821
|
+
return new FlumeStartError(`FlumeConfluence: failed to schedule timeout for "${id}"`, { cause: timeoutResult });
|
|
822
|
+
}
|
|
823
|
+
const flume = new Flume({
|
|
602
824
|
sources,
|
|
603
825
|
onEvent: this.wrapOnEvent(id),
|
|
604
826
|
onError: this.props.onError,
|
|
605
827
|
deps: this.props.deps,
|
|
606
828
|
reconnect: this.props.reconnect,
|
|
607
|
-
signal: controller
|
|
608
|
-
})
|
|
609
|
-
|
|
829
|
+
signal: controller.signal
|
|
830
|
+
});
|
|
831
|
+
const result = await attempt(() => flume.open());
|
|
832
|
+
timeoutState.isArmed = false;
|
|
833
|
+
if (timeoutResult !== null) attempt(() => this.deps.clearTimeout(timeoutResult));
|
|
610
834
|
if (result instanceof Error) {
|
|
611
|
-
if (controller !==
|
|
835
|
+
if (controller.signal.aborted && timeoutMs !== void 0) return new FlumeStartError(`FlumeConfluence: open of "${id}" timed out after ${timeoutMs}ms`, { cause: result });
|
|
612
836
|
return result;
|
|
613
837
|
}
|
|
614
838
|
return result;
|
|
615
839
|
}
|
|
840
|
+
createPendingOpen() {
|
|
841
|
+
const completion = Promise.withResolvers();
|
|
842
|
+
return {
|
|
843
|
+
controller: new AbortController(),
|
|
844
|
+
done: completion.promise,
|
|
845
|
+
finish: completion.resolve
|
|
846
|
+
};
|
|
847
|
+
}
|
|
616
848
|
wrapOnEvent(id) {
|
|
617
849
|
const onEvent = this.props.onEvent;
|
|
618
850
|
if (!onEvent) return void 0;
|
|
619
851
|
return (item) => {
|
|
620
|
-
onEvent({
|
|
852
|
+
return onEvent({
|
|
621
853
|
...item,
|
|
622
854
|
groupId: id
|
|
623
855
|
});
|
|
@@ -625,4 +857,4 @@ var FlumeConfluence = class {
|
|
|
625
857
|
}
|
|
626
858
|
};
|
|
627
859
|
//#endregion
|
|
628
|
-
export { Flume, FlumeClosed, FlumeConfluence, FlumeConnectionError, FlumeHttpError, FlumeParseError, FlumeRunning, FlumeSource, FlumeStartError,
|
|
860
|
+
export { Flume, FlumeClosed, FlumeConfluence, FlumeConnectionError, FlumeHttpError, FlumeParseError, FlumeRunning, FlumeSource, FlumeStartError, createFlumeDefaultDeps };
|