@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/index.js
CHANGED
|
@@ -1,12 +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
4
|
//#region lib/deps.ts
|
|
5
5
|
/**
|
|
6
6
|
* `globalThis.WebSocket` の現在の値を返す。
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `beforeEach`
|
|
7
|
+
* `createFlumeDefaultDeps()` が返す deps の `WebSocket` は getter でここへ委譲するため、
|
|
8
|
+
* `new Flume()` 後に `globalThis.WebSocket` を差し込む (jsdom / happy-dom / vitest の
|
|
9
|
+
* `beforeEach` パッチ) テスト戦略がそのまま機能する — 参照は常にアクセス時点の最新値。
|
|
10
10
|
* `WebSocket` が無い環境 (Node の素の global など) では `null` を返す。
|
|
11
11
|
*/
|
|
12
12
|
function resolveCurrentWebSocket() {
|
|
@@ -27,7 +27,9 @@ function resolveCurrentWebSocket() {
|
|
|
27
27
|
function createFlumeDefaultDeps() {
|
|
28
28
|
return {
|
|
29
29
|
fetch: (url, init) => globalThis.fetch(url, init),
|
|
30
|
-
WebSocket
|
|
30
|
+
get WebSocket() {
|
|
31
|
+
return resolveCurrentWebSocket();
|
|
32
|
+
},
|
|
31
33
|
now: () => Date.now(),
|
|
32
34
|
random: () => Math.random(),
|
|
33
35
|
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
@@ -38,20 +40,25 @@ function createFlumeDefaultDeps() {
|
|
|
38
40
|
}
|
|
39
41
|
//#endregion
|
|
40
42
|
//#region lib/flume-stream.ts
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
function doneResult() {
|
|
44
|
+
return {
|
|
45
|
+
value: void 0,
|
|
46
|
+
done: true
|
|
47
|
+
};
|
|
48
|
+
}
|
|
45
49
|
/**
|
|
46
50
|
* push (`FlumeStreamHub.publish`) を pull (`for await`) に変換する async iterator。
|
|
47
51
|
* consumer が待っていれば即 resolve、いなければ buffer に積み、溢れたら onOverflow に従う。
|
|
48
|
-
*
|
|
52
|
+
* hub.close() ではバッファ済み item を吐き切ってから done へ落ちる (graceful tail drain)。
|
|
53
|
+
* consumer 側の `return()` (break / 例外) はバッファを破棄して即 done になる (iterator 仕様)。
|
|
54
|
+
* drop の観測は onDrop 経由 — drop 通知自体が firehose に還流して再帰しないよう初回のみ発火
|
|
49
55
|
*/
|
|
50
56
|
var FlumeStream = class {
|
|
51
57
|
props;
|
|
52
58
|
items = [];
|
|
53
59
|
resolvers = [];
|
|
54
60
|
closed = false;
|
|
61
|
+
droppedCount = 0;
|
|
55
62
|
constructor(props) {
|
|
56
63
|
this.props = props;
|
|
57
64
|
}
|
|
@@ -66,6 +73,7 @@ var FlumeStream = class {
|
|
|
66
73
|
return;
|
|
67
74
|
}
|
|
68
75
|
if (this.items.length >= this.props.buffer) {
|
|
76
|
+
this.recordDrop();
|
|
69
77
|
if (this.props.onOverflow === "drop-newest") return;
|
|
70
78
|
this.items.shift();
|
|
71
79
|
}
|
|
@@ -76,51 +84,102 @@ var FlumeStream = class {
|
|
|
76
84
|
this.closed = true;
|
|
77
85
|
while (this.resolvers.length > 0) {
|
|
78
86
|
const resolver = this.resolvers.shift();
|
|
79
|
-
if (resolver) resolver(
|
|
87
|
+
if (resolver) resolver(doneResult());
|
|
80
88
|
}
|
|
81
89
|
}
|
|
90
|
+
get dropped() {
|
|
91
|
+
return this.droppedCount;
|
|
92
|
+
}
|
|
82
93
|
next() {
|
|
83
94
|
const item = this.items.shift();
|
|
84
95
|
if (item !== void 0) return Promise.resolve({
|
|
85
96
|
value: item,
|
|
86
97
|
done: false
|
|
87
98
|
});
|
|
88
|
-
if (this.closed) return Promise.resolve(
|
|
99
|
+
if (this.closed) return Promise.resolve(doneResult());
|
|
89
100
|
return new Promise((resolve) => this.resolvers.push(resolve));
|
|
90
101
|
}
|
|
91
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);
|
|
92
110
|
this.close();
|
|
93
111
|
this.props.onClose();
|
|
94
|
-
return Promise.
|
|
112
|
+
return Promise.reject(error);
|
|
95
113
|
}
|
|
96
114
|
[Symbol.asyncIterator]() {
|
|
97
115
|
return this;
|
|
98
116
|
}
|
|
117
|
+
recordDrop() {
|
|
118
|
+
this.droppedCount++;
|
|
119
|
+
if (this.droppedCount > 1) return;
|
|
120
|
+
this.props.onDrop?.({ dropped: 1 });
|
|
121
|
+
}
|
|
99
122
|
};
|
|
100
123
|
//#endregion
|
|
101
124
|
//#region lib/flume-stream-hub.ts
|
|
102
125
|
const DEFAULT_BUFFER = 1e3;
|
|
103
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
|
+
/**
|
|
104
136
|
* firehose (`onEvent` / `stream()`) の item を複数の pull consumer へ fan-out する内部ハブ。
|
|
105
137
|
* subscriber が居なければ publish は実質 no-op。Flume 停止時に close() で全 stream を終端する
|
|
106
138
|
*/
|
|
107
139
|
var FlumeStreamHub = class {
|
|
140
|
+
props;
|
|
108
141
|
streams = /* @__PURE__ */ new Set();
|
|
142
|
+
startupItems = [];
|
|
143
|
+
startupDropNotified = false;
|
|
144
|
+
hasSubscribed = false;
|
|
109
145
|
closed = false;
|
|
146
|
+
constructor(props = {}) {
|
|
147
|
+
this.props = props;
|
|
148
|
+
}
|
|
149
|
+
get isClosed() {
|
|
150
|
+
return this.closed;
|
|
151
|
+
}
|
|
110
152
|
publish(item) {
|
|
111
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
|
+
}
|
|
112
165
|
for (const stream of this.streams) stream.push(item);
|
|
113
166
|
}
|
|
114
167
|
subscribe(options) {
|
|
115
168
|
const stream = new FlumeStream({
|
|
116
|
-
buffer: options?.buffer
|
|
169
|
+
buffer: sanitizeBufferSize(options?.buffer),
|
|
117
170
|
onOverflow: options?.onOverflow ?? "drop-oldest",
|
|
118
|
-
onClose: () => this.streams.delete(stream)
|
|
171
|
+
onClose: () => this.streams.delete(stream),
|
|
172
|
+
onDrop: this.props.onDrop
|
|
119
173
|
});
|
|
120
174
|
if (this.closed) {
|
|
121
175
|
stream.close();
|
|
122
176
|
return stream;
|
|
123
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
|
+
}
|
|
124
183
|
this.streams.add(stream);
|
|
125
184
|
return stream;
|
|
126
185
|
}
|
|
@@ -129,6 +188,7 @@ var FlumeStreamHub = class {
|
|
|
129
188
|
this.closed = true;
|
|
130
189
|
for (const stream of this.streams) stream.close();
|
|
131
190
|
this.streams.clear();
|
|
191
|
+
this.startupItems.splice(0);
|
|
132
192
|
}
|
|
133
193
|
};
|
|
134
194
|
//#endregion
|
|
@@ -139,14 +199,16 @@ var FlumeStreamHub = class {
|
|
|
139
199
|
* きれいに close したか / どれが失敗したか」を直接判定できる
|
|
140
200
|
*/
|
|
141
201
|
var FlumeClosed = class {
|
|
142
|
-
props;
|
|
143
202
|
kind = "closed";
|
|
203
|
+
finalStatuses;
|
|
204
|
+
closeErrors;
|
|
144
205
|
constructor(props) {
|
|
145
|
-
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 })));
|
|
146
208
|
Object.freeze(this);
|
|
147
209
|
}
|
|
148
210
|
statuses() {
|
|
149
|
-
return this.
|
|
211
|
+
return this.finalStatuses;
|
|
150
212
|
}
|
|
151
213
|
/**
|
|
152
214
|
* `runClose` 中に `source.stop()` が rejected で settle した source の名前と
|
|
@@ -154,7 +216,7 @@ var FlumeClosed = class {
|
|
|
154
216
|
* 全 source が clean close した場合は空配列。
|
|
155
217
|
*/
|
|
156
218
|
errors() {
|
|
157
|
-
return this.
|
|
219
|
+
return this.closeErrors;
|
|
158
220
|
}
|
|
159
221
|
};
|
|
160
222
|
//#endregion
|
|
@@ -218,9 +280,10 @@ var FlumeRunning = class {
|
|
|
218
280
|
return this.props.hub.subscribe(options);
|
|
219
281
|
}
|
|
220
282
|
/**
|
|
221
|
-
*
|
|
283
|
+
* `Flume({ signal })` に渡された AbortSignal をそのまま公開する。
|
|
222
284
|
* 直接の controller を持っていない呼び出し元が `running.signal?.aborted`
|
|
223
|
-
* で abort
|
|
285
|
+
* で abort 状態を確認できる。`FlumeConfluence` 経由で開かれたグループでは
|
|
286
|
+
* host の signal ではなく confluence 内部の timeout controller の signal になる点に注意
|
|
224
287
|
*/
|
|
225
288
|
get signal() {
|
|
226
289
|
return this.props.signal;
|
|
@@ -233,10 +296,11 @@ var FlumeRunning = class {
|
|
|
233
296
|
message: `closing ${this.props.sources.length} source(s)`
|
|
234
297
|
});
|
|
235
298
|
const settled = await Promise.allSettled(this.props.sources.map((source) => Promise.resolve().then(() => source.stop())));
|
|
236
|
-
for (const [index, result] of settled.entries())
|
|
299
|
+
for (const [index, result] of settled.entries()) {
|
|
237
300
|
const source = this.props.sources[index];
|
|
238
301
|
const name = source ? this.sourceName(source) : "?";
|
|
239
|
-
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;
|
|
240
304
|
closeErrors.push({
|
|
241
305
|
source: name,
|
|
242
306
|
error
|
|
@@ -260,10 +324,12 @@ var FlumeRunning = class {
|
|
|
260
324
|
});
|
|
261
325
|
}
|
|
262
326
|
}
|
|
327
|
+
await this.props.callbackQueue.drain();
|
|
263
328
|
this.props.log.info({
|
|
264
329
|
action: "flume.close.complete",
|
|
265
330
|
message: "all sources closed"
|
|
266
331
|
});
|
|
332
|
+
await this.props.callbackQueue.drain();
|
|
267
333
|
this.props.hub.close();
|
|
268
334
|
return new FlumeClosed({
|
|
269
335
|
finalStatuses: this.snapshotStatuses(),
|
|
@@ -320,12 +386,42 @@ const DEFAULTS = {
|
|
|
320
386
|
baseDelay: 1e3,
|
|
321
387
|
maxDelay: 3e4
|
|
322
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
|
+
*/
|
|
323
417
|
function resolveFlumeReconnectConfig(input) {
|
|
324
418
|
if (input === false || input === void 0) return null;
|
|
325
419
|
if (input === true) return { ...DEFAULTS };
|
|
420
|
+
const baseDelay = sanitizeBaseDelay(input.baseDelay);
|
|
326
421
|
return {
|
|
327
|
-
|
|
328
|
-
|
|
422
|
+
maxAttempts: sanitizeMaxAttempts(input.maxAttempts),
|
|
423
|
+
baseDelay,
|
|
424
|
+
maxDelay: sanitizeMaxDelay(input.maxDelay, baseDelay)
|
|
329
425
|
};
|
|
330
426
|
}
|
|
331
427
|
//#endregion
|
|
@@ -334,8 +430,8 @@ function resolveFlumeReconnectConfig(input) {
|
|
|
334
430
|
* 起動前の Flume。`open()` で `FlumeRunning` へ遷移する。
|
|
335
431
|
* コンストラクタは単一オブジェクト `{ sources, ...options }` を受け取る (`sources` のみ必須)。
|
|
336
432
|
* events も全ログも 1 本の firehose (`onEvent` push / `stream()` pull) に流れ、購読側が filter する。
|
|
337
|
-
* いずれかの source
|
|
338
|
-
*
|
|
433
|
+
* いずれかの source 失敗時は全 source を `stop()` してロールバックし `FlumeStartError` を返す
|
|
434
|
+
* (失敗した source も半接続状態のリソースを持ち得るため、成功分だけでなく全数を stop する)。
|
|
339
435
|
* `source.start()` / `source.stop()` の sync throw も `Promise.resolve().then` 経由で
|
|
340
436
|
* Promise rejection に正規化して `allSettled` で捕捉する (`open()` は決して reject しない)
|
|
341
437
|
*/
|
|
@@ -346,11 +442,13 @@ var Flume = class {
|
|
|
346
442
|
deps;
|
|
347
443
|
sources;
|
|
348
444
|
sourceEventHandler;
|
|
349
|
-
hub
|
|
445
|
+
hub;
|
|
446
|
+
callbackQueue = new FlumeSerialQueue();
|
|
350
447
|
constructor(options) {
|
|
351
448
|
this.options = options;
|
|
352
|
-
this.sources = options.sources;
|
|
449
|
+
this.sources = [...options.sources];
|
|
353
450
|
this.deps = options.deps ?? createFlumeDefaultDeps();
|
|
451
|
+
this.hub = new FlumeStreamHub({ onDrop: () => this.notifyStreamOverflow() });
|
|
354
452
|
this.log = new FlumeLogger({
|
|
355
453
|
source: "flume",
|
|
356
454
|
handler: this.buildLogHandler(),
|
|
@@ -361,6 +459,25 @@ var Flume = class {
|
|
|
361
459
|
event
|
|
362
460
|
});
|
|
363
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
|
+
}
|
|
364
481
|
/** source が受信したログを firehose へ流す handler。error は onError にも分岐する */
|
|
365
482
|
buildLogHandler() {
|
|
366
483
|
return (log) => {
|
|
@@ -377,16 +494,21 @@ var Flume = class {
|
|
|
377
494
|
}
|
|
378
495
|
/**
|
|
379
496
|
* firehose の単一 sink: pull の hub と push の onEvent の両方へ item を配る。
|
|
497
|
+
* close 後の遅延 emit (stop 中の straggler) は push 側にも流さない (pull 側と対称にする)。
|
|
380
498
|
* onEvent への転送は this.log を経由しない (経由すると log item 経路で再帰する) ため
|
|
381
499
|
* 例外をここで握り潰す
|
|
382
500
|
*/
|
|
383
501
|
emitItem(item) {
|
|
502
|
+
if (this.hub.isClosed) return Promise.resolve();
|
|
384
503
|
this.hub.publish(item);
|
|
504
|
+
return this.enqueueCallback(item);
|
|
505
|
+
}
|
|
506
|
+
enqueueCallback(item) {
|
|
385
507
|
const onEvent = this.options.onEvent;
|
|
386
|
-
if (!onEvent) return;
|
|
387
|
-
|
|
388
|
-
Promise.resolve(onEvent(item))
|
|
389
|
-
}
|
|
508
|
+
if (!onEvent) return Promise.resolve();
|
|
509
|
+
return this.callbackQueue.add(async () => {
|
|
510
|
+
await attempt(() => Promise.resolve(onEvent(item)));
|
|
511
|
+
});
|
|
390
512
|
}
|
|
391
513
|
async open() {
|
|
392
514
|
const guard = this.guardOpen();
|
|
@@ -397,10 +519,9 @@ var Flume = class {
|
|
|
397
519
|
message: `opening ${this.sources.length} source(s)`,
|
|
398
520
|
detail: { count: this.sources.length }
|
|
399
521
|
});
|
|
400
|
-
const reconnect =
|
|
522
|
+
const reconnect = this.resolveReconnect();
|
|
401
523
|
const settled = await Promise.allSettled(this.sources.map((source) => this.safeStart(source, reconnect)));
|
|
402
524
|
const failures = [];
|
|
403
|
-
const started = [];
|
|
404
525
|
for (const [index, result] of settled.entries()) {
|
|
405
526
|
const source = this.sources[index];
|
|
406
527
|
if (source === void 0) continue;
|
|
@@ -412,14 +533,10 @@ var Flume = class {
|
|
|
412
533
|
});
|
|
413
534
|
continue;
|
|
414
535
|
}
|
|
415
|
-
if (result.value instanceof Error) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
});
|
|
420
|
-
continue;
|
|
421
|
-
}
|
|
422
|
-
started.push(source);
|
|
536
|
+
if (result.value instanceof Error) failures.push({
|
|
537
|
+
name,
|
|
538
|
+
error: result.value
|
|
539
|
+
});
|
|
423
540
|
}
|
|
424
541
|
if (failures.length > 0) {
|
|
425
542
|
for (const failure of failures) this.log.error({
|
|
@@ -428,7 +545,7 @@ var Flume = class {
|
|
|
428
545
|
error: failure.error,
|
|
429
546
|
detail: { source: failure.name }
|
|
430
547
|
});
|
|
431
|
-
await this.rollback(
|
|
548
|
+
await this.rollback(this.sources);
|
|
432
549
|
const detail = failures.map((f) => `${f.name}: ${safeErrorMessage({ error: f.error })}`).join("; ");
|
|
433
550
|
const error = new FlumeStartError(`Flume.open: ${failures.length} source(s) failed: ${detail}`);
|
|
434
551
|
this.log.error({
|
|
@@ -436,6 +553,7 @@ var Flume = class {
|
|
|
436
553
|
message: safeErrorMessage({ error }),
|
|
437
554
|
error
|
|
438
555
|
});
|
|
556
|
+
await this.callbackQueue.drain();
|
|
439
557
|
return error;
|
|
440
558
|
}
|
|
441
559
|
if (this.isSignalAborted()) {
|
|
@@ -446,18 +564,42 @@ var Flume = class {
|
|
|
446
564
|
message: safeErrorMessage({ error }),
|
|
447
565
|
error
|
|
448
566
|
});
|
|
567
|
+
await this.callbackQueue.drain();
|
|
449
568
|
return error;
|
|
450
569
|
}
|
|
451
570
|
this.log.info({
|
|
452
571
|
action: "flume.open.complete",
|
|
453
572
|
message: "all sources opened"
|
|
454
573
|
});
|
|
455
|
-
|
|
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({
|
|
456
587
|
sources: this.sources,
|
|
457
588
|
signal: this.options.signal,
|
|
458
589
|
log: this.log,
|
|
459
|
-
hub: this.hub
|
|
590
|
+
hub: this.hub,
|
|
591
|
+
callbackQueue: this.callbackQueue
|
|
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
|
|
460
600
|
});
|
|
601
|
+
await this.callbackQueue.drain();
|
|
602
|
+
return error;
|
|
461
603
|
}
|
|
462
604
|
guardOpen() {
|
|
463
605
|
if (this.consumed) {
|
|
@@ -480,6 +622,19 @@ var Flume = class {
|
|
|
480
622
|
}
|
|
481
623
|
return null;
|
|
482
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
|
+
}
|
|
483
638
|
isSignalAborted() {
|
|
484
639
|
const signal = this.options.signal;
|
|
485
640
|
if (!signal) return false;
|
|
@@ -505,61 +660,142 @@ var Flume = class {
|
|
|
505
660
|
}
|
|
506
661
|
async rollback(sources) {
|
|
507
662
|
const settled = await Promise.allSettled(sources.map((source) => Promise.resolve().then(() => source.stop())));
|
|
508
|
-
for (const [index, result] of settled.entries())
|
|
663
|
+
for (const [index, result] of settled.entries()) {
|
|
509
664
|
const source = sources[index];
|
|
510
665
|
const name = source ? this.sourceName(source) : "?";
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
});
|
|
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);
|
|
518
672
|
}
|
|
519
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
|
+
}
|
|
520
682
|
};
|
|
521
683
|
//#endregion
|
|
522
684
|
//#region lib/flume-confluence.ts
|
|
685
|
+
const DEFAULT_REPLACE_TIMEOUT_MS = 1e4;
|
|
523
686
|
/**
|
|
524
687
|
* 複数の `Flume` を束ねて動的に増減させる上位レイヤー。各 Flume は immutable のまま、
|
|
525
688
|
* `add()` で新しいグループを起動し `remove()` で個別に停止する。全グループの firehose は
|
|
526
|
-
* `onEvent` 1
|
|
689
|
+
* `onEvent` 1 本に合流し、各 item には発信元グループ id が `groupId` としてスタンプされる。
|
|
690
|
+
* Flume 本体の FSM / rollback / reconnect はそのまま再利用される。
|
|
527
691
|
*
|
|
528
|
-
* id
|
|
529
|
-
*
|
|
692
|
+
* `replace(id, sources)` は同じ id のグループを差し替える。新グループを先に起動し、
|
|
693
|
+
* 起動成功時にのみ旧グループを停止するので連続稼働を維持できる (token rotation 用途)。
|
|
694
|
+
* 起動失敗時は旧グループはそのまま走り続ける。
|
|
695
|
+
* 注意: 失敗した replace / add に渡した source インスタンスは consumed になるため、
|
|
696
|
+
* リトライには新しいインスタンスを構築する必要がある。
|
|
697
|
+
*
|
|
698
|
+
* `closeAll()` は終端操作。以後の `add()` / `replace()` は拒否され、closeAll と並行して
|
|
699
|
+
* 起動中だったグループも abort して完了を待つ (シャットダウン後に誰にも止められない
|
|
700
|
+
* グループが残らない)。
|
|
701
|
+
*
|
|
702
|
+
* throw しない流儀に従い `add()` / `replace()` は `Error | null` を返す
|
|
530
703
|
*/
|
|
531
704
|
var FlumeConfluence = class {
|
|
532
705
|
props;
|
|
533
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;
|
|
712
|
+
deps;
|
|
534
713
|
constructor(props = {}) {
|
|
535
714
|
this.props = props;
|
|
715
|
+
this.deps = props.deps ?? createFlumeDefaultDeps();
|
|
716
|
+
}
|
|
717
|
+
get isClosed() {
|
|
718
|
+
return this.isClosedFlag;
|
|
536
719
|
}
|
|
537
720
|
/** sources を 1 グループとして起動。id 重複や起動失敗は `Error` で返す (throw しない) */
|
|
538
721
|
async add(id, sources) {
|
|
539
|
-
if (this.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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();
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* 既存グループを新しい sources で差し替える。新グループを先に起動し、成功時のみ旧を停止する。
|
|
753
|
+
* 起動失敗 / replaceTimeoutMs (既定 10s) 経過時は新グループを破棄し旧を走らせたまま返す。
|
|
754
|
+
* 旧グループが存在しない場合は `Error` を返す (replace は add と違ってグループの存在を前提とする)
|
|
755
|
+
*/
|
|
756
|
+
async replace(id, sources, options) {
|
|
757
|
+
if (this.isClosedFlag) return new FlumeStartError(`FlumeConfluence: already closed: ${id}`);
|
|
758
|
+
const previous = this.running.get(id);
|
|
759
|
+
if (!previous) return new FlumeStartError(`FlumeConfluence: id not running: ${id}`);
|
|
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();
|
|
551
780
|
}
|
|
552
|
-
this.running.set(id, running);
|
|
553
|
-
return null;
|
|
554
781
|
}
|
|
555
|
-
/**
|
|
782
|
+
/**
|
|
783
|
+
* 指定グループだけ close。他グループは無停止。未知の id は no-op。
|
|
784
|
+
* 起動中 (add が open を await 中) の id は commit 時点で破棄されるよう予約する
|
|
785
|
+
*/
|
|
556
786
|
async remove(id) {
|
|
787
|
+
if (this.pendingIds.has(id)) this.removedWhilePending.add(id);
|
|
557
788
|
const running = this.running.get(id);
|
|
558
789
|
if (!running) return;
|
|
559
790
|
this.running.delete(id);
|
|
560
791
|
await running.close();
|
|
561
792
|
}
|
|
793
|
+
/** 終端操作。全グループを close し、以後の add / replace を拒否する */
|
|
562
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));
|
|
563
799
|
const ids = [...this.running.keys()];
|
|
564
800
|
await Promise.all(ids.map((id) => this.remove(id)));
|
|
565
801
|
}
|
|
@@ -569,6 +805,56 @@ var FlumeConfluence = class {
|
|
|
569
805
|
ids() {
|
|
570
806
|
return [...this.running.keys()];
|
|
571
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* 1 グループ分の Flume を開いて FlumeRunning を返す。timeoutMs を指定すると AbortSignal を
|
|
810
|
+
* ctx.signal として各 source へ注入し、超過時に abort して進行中の connect ごと中止する
|
|
811
|
+
* (source 側は base クラスが signal を購読して stop() を発火する)。
|
|
812
|
+
* 失敗時の rollback は Flume 本体に任せる
|
|
813
|
+
*/
|
|
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({
|
|
824
|
+
sources,
|
|
825
|
+
onEvent: this.wrapOnEvent(id),
|
|
826
|
+
onError: this.props.onError,
|
|
827
|
+
deps: this.props.deps,
|
|
828
|
+
reconnect: this.props.reconnect,
|
|
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));
|
|
834
|
+
if (result instanceof Error) {
|
|
835
|
+
if (controller.signal.aborted && timeoutMs !== void 0) return new FlumeStartError(`FlumeConfluence: open of "${id}" timed out after ${timeoutMs}ms`, { cause: result });
|
|
836
|
+
return result;
|
|
837
|
+
}
|
|
838
|
+
return result;
|
|
839
|
+
}
|
|
840
|
+
createPendingOpen() {
|
|
841
|
+
const completion = Promise.withResolvers();
|
|
842
|
+
return {
|
|
843
|
+
controller: new AbortController(),
|
|
844
|
+
done: completion.promise,
|
|
845
|
+
finish: completion.resolve
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
wrapOnEvent(id) {
|
|
849
|
+
const onEvent = this.props.onEvent;
|
|
850
|
+
if (!onEvent) return void 0;
|
|
851
|
+
return (item) => {
|
|
852
|
+
return onEvent({
|
|
853
|
+
...item,
|
|
854
|
+
groupId: id
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
}
|
|
572
858
|
};
|
|
573
859
|
//#endregion
|
|
574
860
|
export { Flume, FlumeClosed, FlumeConfluence, FlumeConnectionError, FlumeHttpError, FlumeParseError, FlumeRunning, FlumeSource, FlumeStartError, createFlumeDefaultDeps };
|