@blamejs/core 0.7.4 → 0.7.18
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/CHANGELOG.md +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/safe-async.js
CHANGED
|
@@ -1,735 +1,735 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Async resilience + safety primitives.
|
|
4
|
-
*
|
|
5
|
-
* The framework's async surfaces (external-db queries, cluster
|
|
6
|
-
* coordination, queue operations, audit chain writes) all share the
|
|
7
|
-
* same hazards: races between interleaved awaits, unbounded retries
|
|
8
|
-
* masking real failures, hangs from unresponsive backends, and partial
|
|
9
|
-
* results from operator-supplied drivers. This module collects the
|
|
10
|
-
* primitives the framework uses to handle those hazards consistently.
|
|
11
|
-
*
|
|
12
|
-
* Surface includes:
|
|
13
|
-
* - Async coordination: withTimeout, withSignal, sleep, repeating,
|
|
14
|
-
* flushLoop, safeAwait, asyncRetry
|
|
15
|
-
* - Async state objects: Mutex, Semaphore, Once, CircuitBreaker
|
|
16
|
-
* - Sync helpers used by async pipelines: safeInvoke (callback
|
|
17
|
-
* wrapper with optional onError), makeDropCallback (factory for
|
|
18
|
-
* log-stream-style onDrop callbacks), makeScheduledFlush
|
|
19
|
-
* (idempotent setTimeout coalesce-and-flush helper)
|
|
20
|
-
*
|
|
21
|
-
* Design posture:
|
|
22
|
-
*
|
|
23
|
-
* - **AbortSignal everywhere.** Every primitive that takes time
|
|
24
|
-
* accepts an `AbortSignal` and aborts cleanly when the signal
|
|
25
|
-
* fires. This is the modern Node.js convention (Node 18+) and
|
|
26
|
-
* replaces the older "cancellation token" pattern. Operators who
|
|
27
|
-
* don't pass a signal get the legacy non-cancellable behaviour.
|
|
28
|
-
*
|
|
29
|
-
* - **Error.cause preserved.** Wrapper errors set `.cause` to the
|
|
30
|
-
* original failure so debugging traces back to the root. Callers
|
|
31
|
-
* who walk `.cause` chains see the full picture.
|
|
32
|
-
*
|
|
33
|
-
* - **No leaked Promises.** Mutex / Semaphore release on path-out
|
|
34
|
-
* in finally blocks — even cancellation. No pending acquirer
|
|
35
|
-
* stays referenced after its abort.
|
|
36
|
-
*
|
|
37
|
-
* - **Bounded by default.** Semaphore / Queue have explicit limits
|
|
38
|
-
* and reject acquisitions over the limit rather than growing
|
|
39
|
-
* unboundedly. Operators size limits explicitly for their workload.
|
|
40
|
-
*
|
|
41
|
-
* - **Fail loud.** Errors propagate; primitives never silently
|
|
42
|
-
* swallow. safeAwait() opt-in for callers who need {error, value}
|
|
43
|
-
* tuples; everything else throws / rejects.
|
|
44
|
-
*
|
|
45
|
-
* Public API:
|
|
46
|
-
*
|
|
47
|
-
* withTimeout(promise, ms, opts?) promise; rejects on timeout
|
|
48
|
-
* withSignal(promise, signal) promise; rejects on abort
|
|
49
|
-
* withTimeoutSignal(signal, ms) AbortSignal composing user
|
|
50
|
-
* signal + a fresh timeout. Used
|
|
51
|
-
* by I/O primitives that already
|
|
52
|
-
* accept a signal and want to
|
|
53
|
-
* add a wall-clock deadline.
|
|
54
|
-
* sleep(ms, opts?) promise that resolves after ms;
|
|
55
|
-
* opts.signal aborts mid-sleep,
|
|
56
|
-
* timer is unref'd so a pending
|
|
57
|
-
* sleep doesn't keep the process
|
|
58
|
-
* alive
|
|
59
|
-
* safeAwait(promise) [error, value] never throws
|
|
60
|
-
*
|
|
61
|
-
* Mutex class; .runExclusive(fn)
|
|
62
|
-
* Semaphore(limit) class; .runWith(fn)
|
|
63
|
-
* Once(fn) class; .invoke()
|
|
64
|
-
*
|
|
65
|
-
* asyncRetry(fn, opts?) re-export from object-store-retry
|
|
66
|
-
* CircuitBreaker(name, opts?) re-export from object-store-retry
|
|
67
|
-
*
|
|
68
|
-
* SafeAsyncError error class
|
|
69
|
-
*
|
|
70
|
-
* Best-practice notes for callers:
|
|
71
|
-
*
|
|
72
|
-
* - Always pair `withTimeout` with the external-db / network calls
|
|
73
|
-
* where operator-supplied drivers might hang. The framework's
|
|
74
|
-
* external-db wrapper already retries; timeout puts a ceiling on
|
|
75
|
-
* each individual attempt.
|
|
76
|
-
*
|
|
77
|
-
* - Wrap chain-writes with Mutex.runExclusive. Audit chain hashing
|
|
78
|
-
* reads the previous tip and writes a successor; without
|
|
79
|
-
* serialization, concurrent awaiting record() calls can hash
|
|
80
|
-
* against the same prev-tip and produce a forked chain. Mutex
|
|
81
|
-
* prevents this in single-process; for cross-process coordination
|
|
82
|
-
* the cluster module's leader election is the correct primitive.
|
|
83
|
-
*
|
|
84
|
-
* - Use Once for boot-time lazy init (counter primer, schema
|
|
85
|
-
* check). Multiple concurrent first-callers correctly all wait
|
|
86
|
-
* on the same in-flight init Promise rather than each starting
|
|
87
|
-
* their own.
|
|
88
|
-
*
|
|
89
|
-
* - Use safeAwait for fire-and-forget paths (audit hooks in
|
|
90
|
-
* middleware) that previously used try/catch — preserves the
|
|
91
|
-
* "log + continue" pattern without unhandled-rejection warnings.
|
|
92
|
-
*
|
|
93
|
-
* - Prefer Promise.allSettled over Promise.all when partial failure
|
|
94
|
-
* is acceptable (e.g. emitting to multiple log sinks; one sink
|
|
95
|
-
* down shouldn't block the others). The framework's log-stream
|
|
96
|
-
* dispatcher already does this.
|
|
97
|
-
*/
|
|
98
|
-
|
|
99
|
-
var { FrameworkError } = require("./framework-error");
|
|
100
|
-
|
|
101
|
-
class SafeAsyncError extends FrameworkError {
|
|
102
|
-
constructor(message, code, cause) {
|
|
103
|
-
super(message);
|
|
104
|
-
this.name = "SafeAsyncError";
|
|
105
|
-
this.code = code || "async/invalid";
|
|
106
|
-
if (cause !== undefined) this.cause = cause;
|
|
107
|
-
this.isSafeAsyncError = true;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ---- withTimeout ----
|
|
112
|
-
//
|
|
113
|
-
// Race the promise against a timer. On timeout, the wrapper rejects with
|
|
114
|
-
// SafeAsyncError(code=async/timeout). The original promise continues
|
|
115
|
-
// running in the background — the framework cannot cancel an arbitrary
|
|
116
|
-
// async operation; only signal-aware ones can be aborted (see withSignal).
|
|
117
|
-
//
|
|
118
|
-
// opts.signal: AbortSignal — aborting the signal also rejects the wrapper
|
|
119
|
-
// with code=async/aborted.
|
|
120
|
-
// opts.name: diagnostic label included in the timeout message.
|
|
121
|
-
|
|
122
|
-
function withTimeout(promise, ms, opts) {
|
|
123
|
-
opts = opts || {};
|
|
124
|
-
if (typeof ms !== "number" || ms <= 0 || !Number.isFinite(ms)) {
|
|
125
|
-
throw new SafeAsyncError("withTimeout: ms must be a positive finite number", "async/bad-arg");
|
|
126
|
-
}
|
|
127
|
-
return new Promise(function (resolve, reject) {
|
|
128
|
-
var settled = false;
|
|
129
|
-
// The timer is ref'd intentionally: while a withTimeout is pending,
|
|
130
|
-
// the process should stay alive until either the underlying promise
|
|
131
|
-
// settles or the timeout fires. unref'ing here would let Node exit
|
|
132
|
-
// mid-await and the awaited Promise would never resolve.
|
|
133
|
-
var timer = setTimeout(function () {
|
|
134
|
-
if (settled) return;
|
|
135
|
-
settled = true;
|
|
136
|
-
reject(new SafeAsyncError(
|
|
137
|
-
"operation timed out after " + ms + "ms" + (opts.name ? " (" + opts.name + ")" : ""),
|
|
138
|
-
"async/timeout"
|
|
139
|
-
));
|
|
140
|
-
}, ms);
|
|
141
|
-
|
|
142
|
-
function _onAbort() {
|
|
143
|
-
if (settled) return;
|
|
144
|
-
settled = true;
|
|
145
|
-
clearTimeout(timer);
|
|
146
|
-
reject(new SafeAsyncError(
|
|
147
|
-
"operation aborted" + (opts.name ? " (" + opts.name + ")" : ""),
|
|
148
|
-
"async/aborted",
|
|
149
|
-
opts.signal && opts.signal.reason
|
|
150
|
-
));
|
|
151
|
-
}
|
|
152
|
-
if (opts.signal) {
|
|
153
|
-
if (opts.signal.aborted) { _onAbort(); return; }
|
|
154
|
-
opts.signal.addEventListener("abort", _onAbort, { once: true });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
Promise.resolve(promise).then(function (v) {
|
|
158
|
-
if (settled) return;
|
|
159
|
-
settled = true;
|
|
160
|
-
clearTimeout(timer);
|
|
161
|
-
if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
|
|
162
|
-
resolve(v);
|
|
163
|
-
}, function (e) {
|
|
164
|
-
if (settled) return;
|
|
165
|
-
settled = true;
|
|
166
|
-
clearTimeout(timer);
|
|
167
|
-
if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
|
|
168
|
-
reject(e);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ---- withSignal ----
|
|
174
|
-
//
|
|
175
|
-
// Race the promise against an AbortSignal. The original promise continues
|
|
176
|
-
// running in the background; only the wrapper's resolution is short-
|
|
177
|
-
// circuited. Useful for plumbing a single signal through a chain of awaits.
|
|
178
|
-
|
|
179
|
-
function withSignal(promise, signal) {
|
|
180
|
-
if (!signal) return Promise.resolve(promise);
|
|
181
|
-
return new Promise(function (resolve, reject) {
|
|
182
|
-
var settled = false;
|
|
183
|
-
function _onAbort() {
|
|
184
|
-
if (settled) return;
|
|
185
|
-
settled = true;
|
|
186
|
-
reject(new SafeAsyncError(
|
|
187
|
-
"operation aborted",
|
|
188
|
-
"async/aborted",
|
|
189
|
-
signal.reason
|
|
190
|
-
));
|
|
191
|
-
}
|
|
192
|
-
if (signal.aborted) { _onAbort(); return; }
|
|
193
|
-
signal.addEventListener("abort", _onAbort, { once: true });
|
|
194
|
-
Promise.resolve(promise).then(function (v) {
|
|
195
|
-
if (settled) return;
|
|
196
|
-
settled = true;
|
|
197
|
-
signal.removeEventListener("abort", _onAbort);
|
|
198
|
-
resolve(v);
|
|
199
|
-
}, function (e) {
|
|
200
|
-
if (settled) return;
|
|
201
|
-
settled = true;
|
|
202
|
-
signal.removeEventListener("abort", _onAbort);
|
|
203
|
-
reject(e);
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// ---- sleep ----
|
|
209
|
-
//
|
|
210
|
-
// Promise that resolves after `ms` milliseconds. opts.signal aborts the
|
|
211
|
-
// sleep cleanly — the wrapper rejects with SafeAsyncError(async/aborted).
|
|
212
|
-
//
|
|
213
|
-
// opts.unref (default false): if true, the timer is unref'd so a pending
|
|
214
|
-
// sleep does NOT keep the process alive. Use for fire-and-forget /
|
|
215
|
-
// heartbeat-style patterns where the program should be free to exit if
|
|
216
|
-
// nothing else is keeping it busy. The default is ref'd to match the
|
|
217
|
-
// natural meaning of `await sleep(ms)` — "I'm waiting, this IS my work,
|
|
218
|
-
// don't exit out from under me." (An unref'd-only event loop in Node
|
|
219
|
-
// exits even with pending awaits — the unref'd timer is not enough to
|
|
220
|
-
// hold the loop alive, so the awaiting promise never resolves.)
|
|
221
|
-
//
|
|
222
|
-
// ms <= 0 resolves immediately (matches setTimeout's clamp-to-1ms but
|
|
223
|
-
// without the wasted tick). Non-finite ms rejects.
|
|
224
|
-
|
|
225
|
-
function sleep(ms, opts) {
|
|
226
|
-
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
|
227
|
-
return Promise.reject(new SafeAsyncError(
|
|
228
|
-
"sleep: ms must be a finite number", "async/bad-arg"
|
|
229
|
-
));
|
|
230
|
-
}
|
|
231
|
-
if (ms <= 0) return Promise.resolve();
|
|
232
|
-
|
|
233
|
-
var signal = opts && opts.signal;
|
|
234
|
-
if (signal && signal.aborted) {
|
|
235
|
-
return Promise.reject(new SafeAsyncError(
|
|
236
|
-
"sleep aborted before start", "async/aborted", signal.reason
|
|
237
|
-
));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return new Promise(function (resolve, reject) {
|
|
241
|
-
var settled = false;
|
|
242
|
-
var timer = setTimeout(function () {
|
|
243
|
-
if (settled) return;
|
|
244
|
-
settled = true;
|
|
245
|
-
if (signal) signal.removeEventListener("abort", _onAbort);
|
|
246
|
-
resolve();
|
|
247
|
-
}, ms);
|
|
248
|
-
if (opts && opts.unref) timer.unref();
|
|
249
|
-
|
|
250
|
-
function _onAbort() {
|
|
251
|
-
if (settled) return;
|
|
252
|
-
settled = true;
|
|
253
|
-
clearTimeout(timer);
|
|
254
|
-
reject(new SafeAsyncError(
|
|
255
|
-
"sleep aborted", "async/aborted", signal.reason
|
|
256
|
-
));
|
|
257
|
-
}
|
|
258
|
-
if (signal) signal.addEventListener("abort", _onAbort, { once: true });
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ---- withTimeoutSignal ----
|
|
263
|
-
//
|
|
264
|
-
// Returns an AbortSignal that fires when EITHER the input signal aborts
|
|
265
|
-
// OR `ms` milliseconds elapse. Wraps the I/O-primitive composition that
|
|
266
|
-
// http-client was doing inline twice (h1 + h2 paths).
|
|
267
|
-
//
|
|
268
|
-
// var sig = withTimeoutSignal(userSignal, 5000);
|
|
269
|
-
// await fetch(url, { signal: sig });
|
|
270
|
-
//
|
|
271
|
-
// Edge cases:
|
|
272
|
-
// - userSignal == null and ms not positive → returns null
|
|
273
|
-
// (caller's "no signal needed" path)
|
|
274
|
-
// - userSignal == null → returns AbortSignal.timeout(ms)
|
|
275
|
-
// - ms not positive (0/undefined) → returns userSignal unchanged
|
|
276
|
-
// - both → AbortSignal.any([user, timeout])
|
|
277
|
-
//
|
|
278
|
-
// Returning null when neither is requested means the caller can pass
|
|
279
|
-
// the result straight to APIs that treat null as "no abort" (the http
|
|
280
|
-
// `signal` option does), with no special-case branching needed.
|
|
281
|
-
|
|
282
|
-
function withTimeoutSignal(signal, ms) {
|
|
283
|
-
var hasTimeout = typeof ms === "number" && ms > 0 && Number.isFinite(ms);
|
|
284
|
-
if (!signal && !hasTimeout) return null;
|
|
285
|
-
if (!signal) return AbortSignal.timeout(ms);
|
|
286
|
-
if (!hasTimeout) return signal;
|
|
287
|
-
return AbortSignal.any([signal, AbortSignal.timeout(ms)]);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// ---- safeAwait ----
|
|
291
|
-
//
|
|
292
|
-
// Go-style [error, value] tuple. Never throws. Lets callers handle the
|
|
293
|
-
// "expected may fail; log and continue" pattern without try/catch
|
|
294
|
-
// scaffolding.
|
|
295
|
-
//
|
|
296
|
-
// var [err, value] = await safeAwait(somePromise);
|
|
297
|
-
// if (err) { /* log + continue */ }
|
|
298
|
-
|
|
299
|
-
async function safeAwait(promise) {
|
|
300
|
-
try {
|
|
301
|
-
var v = await promise;
|
|
302
|
-
return [null, v];
|
|
303
|
-
} catch (e) {
|
|
304
|
-
return [e, null];
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// safeInvoke — call an operator-supplied callback with one payload arg.
|
|
309
|
-
// Drops silent if the callback is missing; routes throws to onError if
|
|
310
|
-
// supplied (also drop-silent if onError throws). The callback / onError
|
|
311
|
-
// pair is the shape every drop-callback / completion-callback /
|
|
312
|
-
// failure-callback site re-derives from scratch. Centralizing here so
|
|
313
|
-
// the contract is one definition.
|
|
314
|
-
//
|
|
315
|
-
// safeInvoke(opts.onDrop, { reason: "buffer-full", batch: rows },
|
|
316
|
-
// function (e) { log.warn("onDrop threw: " + e.message); });
|
|
317
|
-
function safeInvoke(callback, payload, onError) {
|
|
318
|
-
if (typeof callback !== "function") return;
|
|
319
|
-
try { callback(payload); }
|
|
320
|
-
catch (e) {
|
|
321
|
-
if (typeof onError === "function") {
|
|
322
|
-
try { onError(e); } catch (_e2) { /* nested error handler must not bubble */ }
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// makeDropCallback — every log-stream sink (cloudwatch / otlp-grpc /
|
|
328
|
-
// otlp-http / syslog / webhook) previously defined its own
|
|
329
|
-
// `_emitDrop(reason, batch, err)` 3-line wrapper. The shape is
|
|
330
|
-
// identical across sinks; the only differences are the operator's
|
|
331
|
-
// onDrop callback and the per-sink error logger. Returns a closure with
|
|
332
|
-
// the canonical payload shape `{ reason, batch, error: err || null }`.
|
|
333
|
-
//
|
|
334
|
-
// var _emitDrop = safeAsync.makeDropCallback(onDrop,
|
|
335
|
-
// function (e) { log.warn("onDrop-callback-failed: " + e.message); });
|
|
336
|
-
// _emitDrop("buffer-full", batch, err);
|
|
337
|
-
function makeDropCallback(onDrop, onError) {
|
|
338
|
-
return function (reason, batch, err) {
|
|
339
|
-
safeInvoke(onDrop, { reason: reason, batch: batch, error: err || null }, onError);
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// makeScheduledFlush — idempotent setTimeout scheduler used by every
|
|
344
|
-
// log-stream sink (cloudwatch / otlp-grpc / otlp-http / syslog /
|
|
345
|
-
// webhook) to coalesce buffered writes. Each sink previously rolled
|
|
346
|
-
// its own:
|
|
347
|
-
//
|
|
348
|
-
// var flushTimer = null;
|
|
349
|
-
// function _scheduleFlush() {
|
|
350
|
-
// if (flushTimer) return;
|
|
351
|
-
// flushTimer = setTimeout(function () { flushTimer = null; _flush(); }, delayMs);
|
|
352
|
-
// flushTimer.unref();
|
|
353
|
-
// }
|
|
354
|
-
//
|
|
355
|
-
// Returns { schedule, cancel, isPending }. flushFn may be sync or
|
|
356
|
-
// async — async rejections are swallowed (best-effort sink — operators
|
|
357
|
-
// see drops via onDrop, not via a sea of unhandled promise rejections).
|
|
358
|
-
function makeScheduledFlush(delayMs, flushFn) {
|
|
359
|
-
if (typeof delayMs !== "number" || !isFinite(delayMs) || delayMs < 0) {
|
|
360
|
-
throw new TypeError("safeAsync.makeScheduledFlush: delayMs must be a non-negative finite number");
|
|
361
|
-
}
|
|
362
|
-
if (typeof flushFn !== "function") {
|
|
363
|
-
throw new TypeError("safeAsync.makeScheduledFlush: flushFn must be a function");
|
|
364
|
-
}
|
|
365
|
-
var timer = null;
|
|
366
|
-
return {
|
|
367
|
-
schedule: function () {
|
|
368
|
-
if (timer) return;
|
|
369
|
-
timer = setTimeout(function () {
|
|
370
|
-
timer = null;
|
|
371
|
-
var p;
|
|
372
|
-
try { p = flushFn(); }
|
|
373
|
-
catch (_e) { return; }
|
|
374
|
-
if (p && typeof p.catch === "function") {
|
|
375
|
-
p.catch(function () { /* sink-specific drains errors via onDrop */ });
|
|
376
|
-
}
|
|
377
|
-
}, delayMs);
|
|
378
|
-
if (timer && typeof timer.unref === "function") timer.unref();
|
|
379
|
-
},
|
|
380
|
-
cancel: function () {
|
|
381
|
-
if (timer) { clearTimeout(timer); timer = null; }
|
|
382
|
-
},
|
|
383
|
-
isPending: function () { return timer !== null; },
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// ---- Mutex ----
|
|
388
|
-
//
|
|
389
|
-
// Async mutex — only one async region holds the lock at a time. Acquirers
|
|
390
|
-
// queue in arrival order. .runExclusive(fn) is the recommended call form
|
|
391
|
-
// (lock release is automatic via finally even if fn throws); .acquire()
|
|
392
|
-
// + .release() are exposed for callers needing finer control.
|
|
393
|
-
//
|
|
394
|
-
// Implementation note: a queued acquirer that's never released (operator
|
|
395
|
-
// bug) blocks the entire mutex. We don't add a hard-coded timeout because
|
|
396
|
-
// timeouts mask bugs and the real fix is releasing properly. If a caller
|
|
397
|
-
// wants a deadline, wrap the runExclusive call with withTimeout.
|
|
398
|
-
|
|
399
|
-
class Mutex {
|
|
400
|
-
constructor() {
|
|
401
|
-
this._waiters = []; // [{ resolve, reject, signal, onAbort }]
|
|
402
|
-
this._held = false;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
|
|
406
|
-
// When the signal fires, the waiter is removed from the queue and
|
|
407
|
-
// its Promise rejects with async/aborted. The slot it WOULD have
|
|
408
|
-
// taken stays free for the next waiter. If the mutex isn't held,
|
|
409
|
-
// acquire returns immediately regardless of signal state.
|
|
410
|
-
acquire(opts) {
|
|
411
|
-
var self = this;
|
|
412
|
-
var signal = opts && opts.signal;
|
|
413
|
-
if (!self._held) {
|
|
414
|
-
self._held = true;
|
|
415
|
-
return Promise.resolve();
|
|
416
|
-
}
|
|
417
|
-
if (signal && signal.aborted) {
|
|
418
|
-
return Promise.reject(new SafeAsyncError(
|
|
419
|
-
"Mutex.acquire aborted", "async/aborted", signal.reason
|
|
420
|
-
));
|
|
421
|
-
}
|
|
422
|
-
return new Promise(function (resolve, reject) {
|
|
423
|
-
var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
|
|
424
|
-
if (signal) {
|
|
425
|
-
entry.onAbort = function () {
|
|
426
|
-
var idx = self._waiters.indexOf(entry);
|
|
427
|
-
if (idx === -1) return; // already taken or already aborted
|
|
428
|
-
self._waiters.splice(idx, 1);
|
|
429
|
-
reject(new SafeAsyncError(
|
|
430
|
-
"Mutex.acquire aborted while waiting", "async/aborted", signal.reason
|
|
431
|
-
));
|
|
432
|
-
};
|
|
433
|
-
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
434
|
-
}
|
|
435
|
-
self._waiters.push(entry);
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
release() {
|
|
440
|
-
if (!this._held) {
|
|
441
|
-
throw new SafeAsyncError("release on unheld Mutex", "async/bad-release");
|
|
442
|
-
}
|
|
443
|
-
if (this._waiters.length > 0) {
|
|
444
|
-
var next = this._waiters.shift();
|
|
445
|
-
if (next.signal && next.onAbort) {
|
|
446
|
-
next.signal.removeEventListener("abort", next.onAbort);
|
|
447
|
-
}
|
|
448
|
-
next.resolve();
|
|
449
|
-
} else {
|
|
450
|
-
this._held = false;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
async runExclusive(fn, opts) {
|
|
455
|
-
await this.acquire(opts);
|
|
456
|
-
try {
|
|
457
|
-
return await fn();
|
|
458
|
-
} finally {
|
|
459
|
-
this.release();
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
isHeld() { return this._held; }
|
|
464
|
-
pendingCount() { return this._waiters.length; }
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// ---- Semaphore ----
|
|
468
|
-
//
|
|
469
|
-
// Bounded concurrency: at most `limit` simultaneous holders. Acquirers
|
|
470
|
-
// over the limit wait their turn. Use cases: limit external-db query
|
|
471
|
-
// concurrency, throttle outbound webhook fan-out, cap parallel file I/O.
|
|
472
|
-
//
|
|
473
|
-
// .runWith(fn) is the recommended form (release on finally); .acquire/
|
|
474
|
-
// .release are exposed for finer control.
|
|
475
|
-
|
|
476
|
-
class Semaphore {
|
|
477
|
-
constructor(limit) {
|
|
478
|
-
if (typeof limit !== "number" || limit < 1 || !Number.isInteger(limit)) {
|
|
479
|
-
throw new SafeAsyncError("Semaphore limit must be a positive integer", "async/bad-arg");
|
|
480
|
-
}
|
|
481
|
-
this._limit = limit;
|
|
482
|
-
this._inFlight = 0;
|
|
483
|
-
this._waiters = []; // [{ resolve, reject, signal, onAbort }]
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
|
|
487
|
-
acquire(opts) {
|
|
488
|
-
var self = this;
|
|
489
|
-
var signal = opts && opts.signal;
|
|
490
|
-
if (self._inFlight < self._limit) {
|
|
491
|
-
self._inFlight += 1;
|
|
492
|
-
return Promise.resolve();
|
|
493
|
-
}
|
|
494
|
-
if (signal && signal.aborted) {
|
|
495
|
-
return Promise.reject(new SafeAsyncError(
|
|
496
|
-
"Semaphore.acquire aborted", "async/aborted", signal.reason
|
|
497
|
-
));
|
|
498
|
-
}
|
|
499
|
-
return new Promise(function (resolve, reject) {
|
|
500
|
-
var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
|
|
501
|
-
if (signal) {
|
|
502
|
-
entry.onAbort = function () {
|
|
503
|
-
var idx = self._waiters.indexOf(entry);
|
|
504
|
-
if (idx === -1) return;
|
|
505
|
-
self._waiters.splice(idx, 1);
|
|
506
|
-
reject(new SafeAsyncError(
|
|
507
|
-
"Semaphore.acquire aborted while waiting", "async/aborted", signal.reason
|
|
508
|
-
));
|
|
509
|
-
};
|
|
510
|
-
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
511
|
-
}
|
|
512
|
-
self._waiters.push(entry);
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
release() {
|
|
517
|
-
if (this._inFlight === 0) {
|
|
518
|
-
throw new SafeAsyncError("release on idle Semaphore", "async/bad-release");
|
|
519
|
-
}
|
|
520
|
-
if (this._waiters.length > 0) {
|
|
521
|
-
var next = this._waiters.shift();
|
|
522
|
-
if (next.signal && next.onAbort) {
|
|
523
|
-
next.signal.removeEventListener("abort", next.onAbort);
|
|
524
|
-
}
|
|
525
|
-
next.resolve();
|
|
526
|
-
} else {
|
|
527
|
-
this._inFlight -= 1;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
async runWith(fn, opts) {
|
|
532
|
-
await this.acquire(opts);
|
|
533
|
-
try {
|
|
534
|
-
return await fn();
|
|
535
|
-
} finally {
|
|
536
|
-
this.release();
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
inFlight() { return this._inFlight; }
|
|
541
|
-
pendingCount() { return this._waiters.length; }
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// ---- Once ----
|
|
545
|
-
//
|
|
546
|
-
// Single-flight memoization. The first .invoke() call runs the function;
|
|
547
|
-
// subsequent calls (concurrent or later) await the same in-flight Promise
|
|
548
|
-
// and receive the same result. If the first invocation throws, the cached
|
|
549
|
-
// result is the rejected Promise — re-invocation will NOT retry.
|
|
550
|
-
//
|
|
551
|
-
// Use case: lazy boot-time init where multiple call sites might first-
|
|
552
|
-
// touch concurrently (counter primer, schema-check, key load). Without
|
|
553
|
-
// Once, the second concurrent caller would start its own init and produce
|
|
554
|
-
// double-initialization.
|
|
555
|
-
//
|
|
556
|
-
// .reset() clears the cached Promise so the next .invoke() runs fresh.
|
|
557
|
-
// Useful for tests, hot-reload, and operator-driven re-init after a
|
|
558
|
-
// transient init failure. To make resets explicit (avoid silent stale
|
|
559
|
-
// state), reset() does NOT cancel an in-flight first invocation —
|
|
560
|
-
// callers awaiting the prior invoke continue to see its result.
|
|
561
|
-
|
|
562
|
-
class Once {
|
|
563
|
-
constructor(fn) {
|
|
564
|
-
if (typeof fn !== "function") {
|
|
565
|
-
throw new SafeAsyncError("Once: argument must be a function", "async/bad-arg");
|
|
566
|
-
}
|
|
567
|
-
this._fn = fn;
|
|
568
|
-
this._promise = null;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
invoke() {
|
|
572
|
-
if (this._promise === null) {
|
|
573
|
-
this._promise = Promise.resolve().then(this._fn);
|
|
574
|
-
}
|
|
575
|
-
return this._promise;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
reset() {
|
|
579
|
-
this._promise = null;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
hasInvoked() { return this._promise !== null; }
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// ---- repeating ----
|
|
586
|
-
//
|
|
587
|
-
// Bounded-cadence interval timer with consistent unref + cancel semantics.
|
|
588
|
-
// Replaces the scattered setInterval ceremony where each caller hand-rolled
|
|
589
|
-
// `var t = setInterval(...); t.unref();` and a corresponding clearInterval
|
|
590
|
-
// in shutdown — easy to forget the unref and silently block process exit.
|
|
591
|
-
//
|
|
592
|
-
// var sweep = b.safeAsync.repeating(function () {
|
|
593
|
-
// return cleanup();
|
|
594
|
-
// }, b.constants.TIME.seconds(30), { name: "cache-sweep" });
|
|
595
|
-
// ...
|
|
596
|
-
// sweep.stop();
|
|
597
|
-
//
|
|
598
|
-
// fn may be sync or async. If fn returns a Promise, the next tick fires
|
|
599
|
-
// `intervalMs` after the prior fn() *started* (matching setInterval's
|
|
600
|
-
// fixed-rate semantics, not after-completion). Promise rejections are
|
|
601
|
-
// captured by the optional onError callback; if none provided, they're
|
|
602
|
-
// silently dropped — a repeating timer is by definition fire-and-forget,
|
|
603
|
-
// and an unhandled rejection here would crash the process.
|
|
604
|
-
//
|
|
605
|
-
// opts.unref defaults true: most repeating timers are background sweepers
|
|
606
|
-
// that should NOT keep the process alive. Cluster heartbeat etc. set
|
|
607
|
-
// `unref: false` so the lease keeps the leader from exiting silently.
|
|
608
|
-
|
|
609
|
-
function repeating(fn, intervalMs, opts) {
|
|
610
|
-
if (typeof fn !== "function") {
|
|
611
|
-
throw new SafeAsyncError("repeating: fn must be a function", "async/bad-arg");
|
|
612
|
-
}
|
|
613
|
-
if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
614
|
-
throw new SafeAsyncError("repeating: intervalMs must be a positive finite number, got " + intervalMs,
|
|
615
|
-
"async/bad-arg");
|
|
616
|
-
}
|
|
617
|
-
opts = opts || {};
|
|
618
|
-
var unref = opts.unref !== false; // default true
|
|
619
|
-
var onError = typeof opts.onError === "function" ? opts.onError : null;
|
|
620
|
-
|
|
621
|
-
var stopped = false;
|
|
622
|
-
function _tick() {
|
|
623
|
-
if (stopped) return;
|
|
624
|
-
var result;
|
|
625
|
-
try { result = fn(); } catch (e) {
|
|
626
|
-
if (onError) { try { onError(e); } catch (_e) { /* swallow */ } } return;
|
|
627
|
-
}
|
|
628
|
-
if (result && typeof result.then === "function") {
|
|
629
|
-
result.then(null, function (e) {
|
|
630
|
-
if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
var timer = setInterval(_tick, intervalMs);
|
|
635
|
-
if (unref && typeof timer.unref === "function") timer.unref();
|
|
636
|
-
|
|
637
|
-
return {
|
|
638
|
-
stop: function () {
|
|
639
|
-
if (stopped) return;
|
|
640
|
-
stopped = true;
|
|
641
|
-
clearInterval(timer);
|
|
642
|
-
},
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// ---- flushLoop ----
|
|
647
|
-
//
|
|
648
|
-
// Schedule fn(), wait for it to settle (resolve or reject), then schedule
|
|
649
|
-
// the next fn() `intervalMs` later. Differs from `repeating` (fixed-rate,
|
|
650
|
-
// fire-and-forget) — flushLoop is the after-completion pattern most
|
|
651
|
-
// background flushers want: never overlap two flushes, and don't accumulate
|
|
652
|
-
// backlog if a flush is slow.
|
|
653
|
-
//
|
|
654
|
-
// var loop = b.safeAsync.flushLoop(function () {
|
|
655
|
-
// return otelExporter.flush();
|
|
656
|
-
// }, b.constants.TIME.seconds(15), { name: "otel-flush" });
|
|
657
|
-
// ...
|
|
658
|
-
// loop.stop();
|
|
659
|
-
//
|
|
660
|
-
// Always unref'd — a pending flush should never keep the process alive
|
|
661
|
-
// (the operator's b.appShutdown drives the final drain explicitly).
|
|
662
|
-
// onError catches rejections; without one, they're silently dropped.
|
|
663
|
-
|
|
664
|
-
function flushLoop(fn, intervalMs, opts) {
|
|
665
|
-
if (typeof fn !== "function") {
|
|
666
|
-
throw new SafeAsyncError("flushLoop: fn must be a function", "async/bad-arg");
|
|
667
|
-
}
|
|
668
|
-
if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
669
|
-
throw new SafeAsyncError("flushLoop: intervalMs must be a positive finite number, got " + intervalMs,
|
|
670
|
-
"async/bad-arg");
|
|
671
|
-
}
|
|
672
|
-
opts = opts || {};
|
|
673
|
-
var onError = typeof opts.onError === "function" ? opts.onError : null;
|
|
674
|
-
|
|
675
|
-
var stopped = false;
|
|
676
|
-
var timer = null;
|
|
677
|
-
|
|
678
|
-
function _schedule() {
|
|
679
|
-
if (stopped) return;
|
|
680
|
-
timer = setTimeout(function () {
|
|
681
|
-
timer = null;
|
|
682
|
-
if (stopped) return;
|
|
683
|
-
var settled;
|
|
684
|
-
try { settled = Promise.resolve(fn()); }
|
|
685
|
-
catch (e) {
|
|
686
|
-
if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
|
|
687
|
-
_schedule();
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
settled.then(null, function (e) {
|
|
691
|
-
if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
|
|
692
|
-
}).then(_schedule);
|
|
693
|
-
}, intervalMs);
|
|
694
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
695
|
-
}
|
|
696
|
-
_schedule();
|
|
697
|
-
|
|
698
|
-
return {
|
|
699
|
-
stop: function () {
|
|
700
|
-
if (stopped) return;
|
|
701
|
-
stopped = true;
|
|
702
|
-
if (timer) { clearTimeout(timer); timer = null; }
|
|
703
|
-
},
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// ---- Re-exports of resilience primitives from lib/retry.js ----
|
|
708
|
-
//
|
|
709
|
-
// withRetry + CircuitBreaker live in lib/retry.js (the canonical home).
|
|
710
|
-
// We re-export them here under safe-async-shaped names so call sites
|
|
711
|
-
// reaching for async safety primitives find them in one place.
|
|
712
|
-
|
|
713
|
-
var retryHelper = require("./retry");
|
|
714
|
-
|
|
715
|
-
var asyncRetry = retryHelper.withRetry;
|
|
716
|
-
var CircuitBreaker = retryHelper.CircuitBreaker;
|
|
717
|
-
|
|
718
|
-
module.exports = {
|
|
719
|
-
withTimeout: withTimeout,
|
|
720
|
-
withSignal: withSignal,
|
|
721
|
-
withTimeoutSignal: withTimeoutSignal,
|
|
722
|
-
sleep: sleep,
|
|
723
|
-
repeating: repeating,
|
|
724
|
-
flushLoop: flushLoop,
|
|
725
|
-
safeAwait: safeAwait,
|
|
726
|
-
safeInvoke: safeInvoke,
|
|
727
|
-
makeDropCallback: makeDropCallback,
|
|
728
|
-
makeScheduledFlush: makeScheduledFlush,
|
|
729
|
-
Mutex: Mutex,
|
|
730
|
-
Semaphore: Semaphore,
|
|
731
|
-
Once: Once,
|
|
732
|
-
asyncRetry: asyncRetry,
|
|
733
|
-
CircuitBreaker: CircuitBreaker,
|
|
734
|
-
SafeAsyncError: SafeAsyncError,
|
|
735
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Async resilience + safety primitives.
|
|
4
|
+
*
|
|
5
|
+
* The framework's async surfaces (external-db queries, cluster
|
|
6
|
+
* coordination, queue operations, audit chain writes) all share the
|
|
7
|
+
* same hazards: races between interleaved awaits, unbounded retries
|
|
8
|
+
* masking real failures, hangs from unresponsive backends, and partial
|
|
9
|
+
* results from operator-supplied drivers. This module collects the
|
|
10
|
+
* primitives the framework uses to handle those hazards consistently.
|
|
11
|
+
*
|
|
12
|
+
* Surface includes:
|
|
13
|
+
* - Async coordination: withTimeout, withSignal, sleep, repeating,
|
|
14
|
+
* flushLoop, safeAwait, asyncRetry
|
|
15
|
+
* - Async state objects: Mutex, Semaphore, Once, CircuitBreaker
|
|
16
|
+
* - Sync helpers used by async pipelines: safeInvoke (callback
|
|
17
|
+
* wrapper with optional onError), makeDropCallback (factory for
|
|
18
|
+
* log-stream-style onDrop callbacks), makeScheduledFlush
|
|
19
|
+
* (idempotent setTimeout coalesce-and-flush helper)
|
|
20
|
+
*
|
|
21
|
+
* Design posture:
|
|
22
|
+
*
|
|
23
|
+
* - **AbortSignal everywhere.** Every primitive that takes time
|
|
24
|
+
* accepts an `AbortSignal` and aborts cleanly when the signal
|
|
25
|
+
* fires. This is the modern Node.js convention (Node 18+) and
|
|
26
|
+
* replaces the older "cancellation token" pattern. Operators who
|
|
27
|
+
* don't pass a signal get the legacy non-cancellable behaviour.
|
|
28
|
+
*
|
|
29
|
+
* - **Error.cause preserved.** Wrapper errors set `.cause` to the
|
|
30
|
+
* original failure so debugging traces back to the root. Callers
|
|
31
|
+
* who walk `.cause` chains see the full picture.
|
|
32
|
+
*
|
|
33
|
+
* - **No leaked Promises.** Mutex / Semaphore release on path-out
|
|
34
|
+
* in finally blocks — even cancellation. No pending acquirer
|
|
35
|
+
* stays referenced after its abort.
|
|
36
|
+
*
|
|
37
|
+
* - **Bounded by default.** Semaphore / Queue have explicit limits
|
|
38
|
+
* and reject acquisitions over the limit rather than growing
|
|
39
|
+
* unboundedly. Operators size limits explicitly for their workload.
|
|
40
|
+
*
|
|
41
|
+
* - **Fail loud.** Errors propagate; primitives never silently
|
|
42
|
+
* swallow. safeAwait() opt-in for callers who need {error, value}
|
|
43
|
+
* tuples; everything else throws / rejects.
|
|
44
|
+
*
|
|
45
|
+
* Public API:
|
|
46
|
+
*
|
|
47
|
+
* withTimeout(promise, ms, opts?) promise; rejects on timeout
|
|
48
|
+
* withSignal(promise, signal) promise; rejects on abort
|
|
49
|
+
* withTimeoutSignal(signal, ms) AbortSignal composing user
|
|
50
|
+
* signal + a fresh timeout. Used
|
|
51
|
+
* by I/O primitives that already
|
|
52
|
+
* accept a signal and want to
|
|
53
|
+
* add a wall-clock deadline.
|
|
54
|
+
* sleep(ms, opts?) promise that resolves after ms;
|
|
55
|
+
* opts.signal aborts mid-sleep,
|
|
56
|
+
* timer is unref'd so a pending
|
|
57
|
+
* sleep doesn't keep the process
|
|
58
|
+
* alive
|
|
59
|
+
* safeAwait(promise) [error, value] never throws
|
|
60
|
+
*
|
|
61
|
+
* Mutex class; .runExclusive(fn)
|
|
62
|
+
* Semaphore(limit) class; .runWith(fn)
|
|
63
|
+
* Once(fn) class; .invoke()
|
|
64
|
+
*
|
|
65
|
+
* asyncRetry(fn, opts?) re-export from object-store-retry
|
|
66
|
+
* CircuitBreaker(name, opts?) re-export from object-store-retry
|
|
67
|
+
*
|
|
68
|
+
* SafeAsyncError error class
|
|
69
|
+
*
|
|
70
|
+
* Best-practice notes for callers:
|
|
71
|
+
*
|
|
72
|
+
* - Always pair `withTimeout` with the external-db / network calls
|
|
73
|
+
* where operator-supplied drivers might hang. The framework's
|
|
74
|
+
* external-db wrapper already retries; timeout puts a ceiling on
|
|
75
|
+
* each individual attempt.
|
|
76
|
+
*
|
|
77
|
+
* - Wrap chain-writes with Mutex.runExclusive. Audit chain hashing
|
|
78
|
+
* reads the previous tip and writes a successor; without
|
|
79
|
+
* serialization, concurrent awaiting record() calls can hash
|
|
80
|
+
* against the same prev-tip and produce a forked chain. Mutex
|
|
81
|
+
* prevents this in single-process; for cross-process coordination
|
|
82
|
+
* the cluster module's leader election is the correct primitive.
|
|
83
|
+
*
|
|
84
|
+
* - Use Once for boot-time lazy init (counter primer, schema
|
|
85
|
+
* check). Multiple concurrent first-callers correctly all wait
|
|
86
|
+
* on the same in-flight init Promise rather than each starting
|
|
87
|
+
* their own.
|
|
88
|
+
*
|
|
89
|
+
* - Use safeAwait for fire-and-forget paths (audit hooks in
|
|
90
|
+
* middleware) that previously used try/catch — preserves the
|
|
91
|
+
* "log + continue" pattern without unhandled-rejection warnings.
|
|
92
|
+
*
|
|
93
|
+
* - Prefer Promise.allSettled over Promise.all when partial failure
|
|
94
|
+
* is acceptable (e.g. emitting to multiple log sinks; one sink
|
|
95
|
+
* down shouldn't block the others). The framework's log-stream
|
|
96
|
+
* dispatcher already does this.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
var { FrameworkError } = require("./framework-error");
|
|
100
|
+
|
|
101
|
+
class SafeAsyncError extends FrameworkError {
|
|
102
|
+
constructor(message, code, cause) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.name = "SafeAsyncError";
|
|
105
|
+
this.code = code || "async/invalid";
|
|
106
|
+
if (cause !== undefined) this.cause = cause;
|
|
107
|
+
this.isSafeAsyncError = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- withTimeout ----
|
|
112
|
+
//
|
|
113
|
+
// Race the promise against a timer. On timeout, the wrapper rejects with
|
|
114
|
+
// SafeAsyncError(code=async/timeout). The original promise continues
|
|
115
|
+
// running in the background — the framework cannot cancel an arbitrary
|
|
116
|
+
// async operation; only signal-aware ones can be aborted (see withSignal).
|
|
117
|
+
//
|
|
118
|
+
// opts.signal: AbortSignal — aborting the signal also rejects the wrapper
|
|
119
|
+
// with code=async/aborted.
|
|
120
|
+
// opts.name: diagnostic label included in the timeout message.
|
|
121
|
+
|
|
122
|
+
function withTimeout(promise, ms, opts) {
|
|
123
|
+
opts = opts || {};
|
|
124
|
+
if (typeof ms !== "number" || ms <= 0 || !Number.isFinite(ms)) {
|
|
125
|
+
throw new SafeAsyncError("withTimeout: ms must be a positive finite number", "async/bad-arg");
|
|
126
|
+
}
|
|
127
|
+
return new Promise(function (resolve, reject) {
|
|
128
|
+
var settled = false;
|
|
129
|
+
// The timer is ref'd intentionally: while a withTimeout is pending,
|
|
130
|
+
// the process should stay alive until either the underlying promise
|
|
131
|
+
// settles or the timeout fires. unref'ing here would let Node exit
|
|
132
|
+
// mid-await and the awaited Promise would never resolve.
|
|
133
|
+
var timer = setTimeout(function () {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
settled = true;
|
|
136
|
+
reject(new SafeAsyncError(
|
|
137
|
+
"operation timed out after " + ms + "ms" + (opts.name ? " (" + opts.name + ")" : ""),
|
|
138
|
+
"async/timeout"
|
|
139
|
+
));
|
|
140
|
+
}, ms);
|
|
141
|
+
|
|
142
|
+
function _onAbort() {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
reject(new SafeAsyncError(
|
|
147
|
+
"operation aborted" + (opts.name ? " (" + opts.name + ")" : ""),
|
|
148
|
+
"async/aborted",
|
|
149
|
+
opts.signal && opts.signal.reason
|
|
150
|
+
));
|
|
151
|
+
}
|
|
152
|
+
if (opts.signal) {
|
|
153
|
+
if (opts.signal.aborted) { _onAbort(); return; }
|
|
154
|
+
opts.signal.addEventListener("abort", _onAbort, { once: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
Promise.resolve(promise).then(function (v) {
|
|
158
|
+
if (settled) return;
|
|
159
|
+
settled = true;
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
|
|
162
|
+
resolve(v);
|
|
163
|
+
}, function (e) {
|
|
164
|
+
if (settled) return;
|
|
165
|
+
settled = true;
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
if (opts.signal) opts.signal.removeEventListener("abort", _onAbort);
|
|
168
|
+
reject(e);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- withSignal ----
|
|
174
|
+
//
|
|
175
|
+
// Race the promise against an AbortSignal. The original promise continues
|
|
176
|
+
// running in the background; only the wrapper's resolution is short-
|
|
177
|
+
// circuited. Useful for plumbing a single signal through a chain of awaits.
|
|
178
|
+
|
|
179
|
+
function withSignal(promise, signal) {
|
|
180
|
+
if (!signal) return Promise.resolve(promise);
|
|
181
|
+
return new Promise(function (resolve, reject) {
|
|
182
|
+
var settled = false;
|
|
183
|
+
function _onAbort() {
|
|
184
|
+
if (settled) return;
|
|
185
|
+
settled = true;
|
|
186
|
+
reject(new SafeAsyncError(
|
|
187
|
+
"operation aborted",
|
|
188
|
+
"async/aborted",
|
|
189
|
+
signal.reason
|
|
190
|
+
));
|
|
191
|
+
}
|
|
192
|
+
if (signal.aborted) { _onAbort(); return; }
|
|
193
|
+
signal.addEventListener("abort", _onAbort, { once: true });
|
|
194
|
+
Promise.resolve(promise).then(function (v) {
|
|
195
|
+
if (settled) return;
|
|
196
|
+
settled = true;
|
|
197
|
+
signal.removeEventListener("abort", _onAbort);
|
|
198
|
+
resolve(v);
|
|
199
|
+
}, function (e) {
|
|
200
|
+
if (settled) return;
|
|
201
|
+
settled = true;
|
|
202
|
+
signal.removeEventListener("abort", _onAbort);
|
|
203
|
+
reject(e);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- sleep ----
|
|
209
|
+
//
|
|
210
|
+
// Promise that resolves after `ms` milliseconds. opts.signal aborts the
|
|
211
|
+
// sleep cleanly — the wrapper rejects with SafeAsyncError(async/aborted).
|
|
212
|
+
//
|
|
213
|
+
// opts.unref (default false): if true, the timer is unref'd so a pending
|
|
214
|
+
// sleep does NOT keep the process alive. Use for fire-and-forget /
|
|
215
|
+
// heartbeat-style patterns where the program should be free to exit if
|
|
216
|
+
// nothing else is keeping it busy. The default is ref'd to match the
|
|
217
|
+
// natural meaning of `await sleep(ms)` — "I'm waiting, this IS my work,
|
|
218
|
+
// don't exit out from under me." (An unref'd-only event loop in Node
|
|
219
|
+
// exits even with pending awaits — the unref'd timer is not enough to
|
|
220
|
+
// hold the loop alive, so the awaiting promise never resolves.)
|
|
221
|
+
//
|
|
222
|
+
// ms <= 0 resolves immediately (matches setTimeout's clamp-to-1ms but
|
|
223
|
+
// without the wasted tick). Non-finite ms rejects.
|
|
224
|
+
|
|
225
|
+
function sleep(ms, opts) {
|
|
226
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
|
227
|
+
return Promise.reject(new SafeAsyncError(
|
|
228
|
+
"sleep: ms must be a finite number", "async/bad-arg"
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
if (ms <= 0) return Promise.resolve();
|
|
232
|
+
|
|
233
|
+
var signal = opts && opts.signal;
|
|
234
|
+
if (signal && signal.aborted) {
|
|
235
|
+
return Promise.reject(new SafeAsyncError(
|
|
236
|
+
"sleep aborted before start", "async/aborted", signal.reason
|
|
237
|
+
));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return new Promise(function (resolve, reject) {
|
|
241
|
+
var settled = false;
|
|
242
|
+
var timer = setTimeout(function () {
|
|
243
|
+
if (settled) return;
|
|
244
|
+
settled = true;
|
|
245
|
+
if (signal) signal.removeEventListener("abort", _onAbort);
|
|
246
|
+
resolve();
|
|
247
|
+
}, ms);
|
|
248
|
+
if (opts && opts.unref) timer.unref();
|
|
249
|
+
|
|
250
|
+
function _onAbort() {
|
|
251
|
+
if (settled) return;
|
|
252
|
+
settled = true;
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
reject(new SafeAsyncError(
|
|
255
|
+
"sleep aborted", "async/aborted", signal.reason
|
|
256
|
+
));
|
|
257
|
+
}
|
|
258
|
+
if (signal) signal.addEventListener("abort", _onAbort, { once: true });
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- withTimeoutSignal ----
|
|
263
|
+
//
|
|
264
|
+
// Returns an AbortSignal that fires when EITHER the input signal aborts
|
|
265
|
+
// OR `ms` milliseconds elapse. Wraps the I/O-primitive composition that
|
|
266
|
+
// http-client was doing inline twice (h1 + h2 paths).
|
|
267
|
+
//
|
|
268
|
+
// var sig = withTimeoutSignal(userSignal, 5000);
|
|
269
|
+
// await fetch(url, { signal: sig });
|
|
270
|
+
//
|
|
271
|
+
// Edge cases:
|
|
272
|
+
// - userSignal == null and ms not positive → returns null
|
|
273
|
+
// (caller's "no signal needed" path)
|
|
274
|
+
// - userSignal == null → returns AbortSignal.timeout(ms)
|
|
275
|
+
// - ms not positive (0/undefined) → returns userSignal unchanged
|
|
276
|
+
// - both → AbortSignal.any([user, timeout])
|
|
277
|
+
//
|
|
278
|
+
// Returning null when neither is requested means the caller can pass
|
|
279
|
+
// the result straight to APIs that treat null as "no abort" (the http
|
|
280
|
+
// `signal` option does), with no special-case branching needed.
|
|
281
|
+
|
|
282
|
+
function withTimeoutSignal(signal, ms) {
|
|
283
|
+
var hasTimeout = typeof ms === "number" && ms > 0 && Number.isFinite(ms);
|
|
284
|
+
if (!signal && !hasTimeout) return null;
|
|
285
|
+
if (!signal) return AbortSignal.timeout(ms);
|
|
286
|
+
if (!hasTimeout) return signal;
|
|
287
|
+
return AbortSignal.any([signal, AbortSignal.timeout(ms)]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- safeAwait ----
|
|
291
|
+
//
|
|
292
|
+
// Go-style [error, value] tuple. Never throws. Lets callers handle the
|
|
293
|
+
// "expected may fail; log and continue" pattern without try/catch
|
|
294
|
+
// scaffolding.
|
|
295
|
+
//
|
|
296
|
+
// var [err, value] = await safeAwait(somePromise);
|
|
297
|
+
// if (err) { /* log + continue */ }
|
|
298
|
+
|
|
299
|
+
async function safeAwait(promise) {
|
|
300
|
+
try {
|
|
301
|
+
var v = await promise;
|
|
302
|
+
return [null, v];
|
|
303
|
+
} catch (e) {
|
|
304
|
+
return [e, null];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// safeInvoke — call an operator-supplied callback with one payload arg.
|
|
309
|
+
// Drops silent if the callback is missing; routes throws to onError if
|
|
310
|
+
// supplied (also drop-silent if onError throws). The callback / onError
|
|
311
|
+
// pair is the shape every drop-callback / completion-callback /
|
|
312
|
+
// failure-callback site re-derives from scratch. Centralizing here so
|
|
313
|
+
// the contract is one definition.
|
|
314
|
+
//
|
|
315
|
+
// safeInvoke(opts.onDrop, { reason: "buffer-full", batch: rows },
|
|
316
|
+
// function (e) { log.warn("onDrop threw: " + e.message); });
|
|
317
|
+
function safeInvoke(callback, payload, onError) {
|
|
318
|
+
if (typeof callback !== "function") return;
|
|
319
|
+
try { callback(payload); }
|
|
320
|
+
catch (e) {
|
|
321
|
+
if (typeof onError === "function") {
|
|
322
|
+
try { onError(e); } catch (_e2) { /* nested error handler must not bubble */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// makeDropCallback — every log-stream sink (cloudwatch / otlp-grpc /
|
|
328
|
+
// otlp-http / syslog / webhook) previously defined its own
|
|
329
|
+
// `_emitDrop(reason, batch, err)` 3-line wrapper. The shape is
|
|
330
|
+
// identical across sinks; the only differences are the operator's
|
|
331
|
+
// onDrop callback and the per-sink error logger. Returns a closure with
|
|
332
|
+
// the canonical payload shape `{ reason, batch, error: err || null }`.
|
|
333
|
+
//
|
|
334
|
+
// var _emitDrop = safeAsync.makeDropCallback(onDrop,
|
|
335
|
+
// function (e) { log.warn("onDrop-callback-failed: " + e.message); });
|
|
336
|
+
// _emitDrop("buffer-full", batch, err);
|
|
337
|
+
function makeDropCallback(onDrop, onError) {
|
|
338
|
+
return function (reason, batch, err) {
|
|
339
|
+
safeInvoke(onDrop, { reason: reason, batch: batch, error: err || null }, onError);
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// makeScheduledFlush — idempotent setTimeout scheduler used by every
|
|
344
|
+
// log-stream sink (cloudwatch / otlp-grpc / otlp-http / syslog /
|
|
345
|
+
// webhook) to coalesce buffered writes. Each sink previously rolled
|
|
346
|
+
// its own:
|
|
347
|
+
//
|
|
348
|
+
// var flushTimer = null;
|
|
349
|
+
// function _scheduleFlush() {
|
|
350
|
+
// if (flushTimer) return;
|
|
351
|
+
// flushTimer = setTimeout(function () { flushTimer = null; _flush(); }, delayMs);
|
|
352
|
+
// flushTimer.unref();
|
|
353
|
+
// }
|
|
354
|
+
//
|
|
355
|
+
// Returns { schedule, cancel, isPending }. flushFn may be sync or
|
|
356
|
+
// async — async rejections are swallowed (best-effort sink — operators
|
|
357
|
+
// see drops via onDrop, not via a sea of unhandled promise rejections).
|
|
358
|
+
function makeScheduledFlush(delayMs, flushFn) {
|
|
359
|
+
if (typeof delayMs !== "number" || !isFinite(delayMs) || delayMs < 0) {
|
|
360
|
+
throw new TypeError("safeAsync.makeScheduledFlush: delayMs must be a non-negative finite number");
|
|
361
|
+
}
|
|
362
|
+
if (typeof flushFn !== "function") {
|
|
363
|
+
throw new TypeError("safeAsync.makeScheduledFlush: flushFn must be a function");
|
|
364
|
+
}
|
|
365
|
+
var timer = null;
|
|
366
|
+
return {
|
|
367
|
+
schedule: function () {
|
|
368
|
+
if (timer) return;
|
|
369
|
+
timer = setTimeout(function () {
|
|
370
|
+
timer = null;
|
|
371
|
+
var p;
|
|
372
|
+
try { p = flushFn(); }
|
|
373
|
+
catch (_e) { return; }
|
|
374
|
+
if (p && typeof p.catch === "function") {
|
|
375
|
+
p.catch(function () { /* sink-specific drains errors via onDrop */ });
|
|
376
|
+
}
|
|
377
|
+
}, delayMs);
|
|
378
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
379
|
+
},
|
|
380
|
+
cancel: function () {
|
|
381
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
382
|
+
},
|
|
383
|
+
isPending: function () { return timer !== null; },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---- Mutex ----
|
|
388
|
+
//
|
|
389
|
+
// Async mutex — only one async region holds the lock at a time. Acquirers
|
|
390
|
+
// queue in arrival order. .runExclusive(fn) is the recommended call form
|
|
391
|
+
// (lock release is automatic via finally even if fn throws); .acquire()
|
|
392
|
+
// + .release() are exposed for callers needing finer control.
|
|
393
|
+
//
|
|
394
|
+
// Implementation note: a queued acquirer that's never released (operator
|
|
395
|
+
// bug) blocks the entire mutex. We don't add a hard-coded timeout because
|
|
396
|
+
// timeouts mask bugs and the real fix is releasing properly. If a caller
|
|
397
|
+
// wants a deadline, wrap the runExclusive call with withTimeout.
|
|
398
|
+
|
|
399
|
+
class Mutex {
|
|
400
|
+
constructor() {
|
|
401
|
+
this._waiters = []; // [{ resolve, reject, signal, onAbort }]
|
|
402
|
+
this._held = false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
|
|
406
|
+
// When the signal fires, the waiter is removed from the queue and
|
|
407
|
+
// its Promise rejects with async/aborted. The slot it WOULD have
|
|
408
|
+
// taken stays free for the next waiter. If the mutex isn't held,
|
|
409
|
+
// acquire returns immediately regardless of signal state.
|
|
410
|
+
acquire(opts) {
|
|
411
|
+
var self = this;
|
|
412
|
+
var signal = opts && opts.signal;
|
|
413
|
+
if (!self._held) {
|
|
414
|
+
self._held = true;
|
|
415
|
+
return Promise.resolve();
|
|
416
|
+
}
|
|
417
|
+
if (signal && signal.aborted) {
|
|
418
|
+
return Promise.reject(new SafeAsyncError(
|
|
419
|
+
"Mutex.acquire aborted", "async/aborted", signal.reason
|
|
420
|
+
));
|
|
421
|
+
}
|
|
422
|
+
return new Promise(function (resolve, reject) {
|
|
423
|
+
var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
|
|
424
|
+
if (signal) {
|
|
425
|
+
entry.onAbort = function () {
|
|
426
|
+
var idx = self._waiters.indexOf(entry);
|
|
427
|
+
if (idx === -1) return; // already taken or already aborted
|
|
428
|
+
self._waiters.splice(idx, 1);
|
|
429
|
+
reject(new SafeAsyncError(
|
|
430
|
+
"Mutex.acquire aborted while waiting", "async/aborted", signal.reason
|
|
431
|
+
));
|
|
432
|
+
};
|
|
433
|
+
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
434
|
+
}
|
|
435
|
+
self._waiters.push(entry);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
release() {
|
|
440
|
+
if (!this._held) {
|
|
441
|
+
throw new SafeAsyncError("release on unheld Mutex", "async/bad-release");
|
|
442
|
+
}
|
|
443
|
+
if (this._waiters.length > 0) {
|
|
444
|
+
var next = this._waiters.shift();
|
|
445
|
+
if (next.signal && next.onAbort) {
|
|
446
|
+
next.signal.removeEventListener("abort", next.onAbort);
|
|
447
|
+
}
|
|
448
|
+
next.resolve();
|
|
449
|
+
} else {
|
|
450
|
+
this._held = false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async runExclusive(fn, opts) {
|
|
455
|
+
await this.acquire(opts);
|
|
456
|
+
try {
|
|
457
|
+
return await fn();
|
|
458
|
+
} finally {
|
|
459
|
+
this.release();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
isHeld() { return this._held; }
|
|
464
|
+
pendingCount() { return this._waiters.length; }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---- Semaphore ----
|
|
468
|
+
//
|
|
469
|
+
// Bounded concurrency: at most `limit` simultaneous holders. Acquirers
|
|
470
|
+
// over the limit wait their turn. Use cases: limit external-db query
|
|
471
|
+
// concurrency, throttle outbound webhook fan-out, cap parallel file I/O.
|
|
472
|
+
//
|
|
473
|
+
// .runWith(fn) is the recommended form (release on finally); .acquire/
|
|
474
|
+
// .release are exposed for finer control.
|
|
475
|
+
|
|
476
|
+
class Semaphore {
|
|
477
|
+
constructor(limit) {
|
|
478
|
+
if (typeof limit !== "number" || limit < 1 || !Number.isInteger(limit)) {
|
|
479
|
+
throw new SafeAsyncError("Semaphore limit must be a positive integer", "async/bad-arg");
|
|
480
|
+
}
|
|
481
|
+
this._limit = limit;
|
|
482
|
+
this._inFlight = 0;
|
|
483
|
+
this._waiters = []; // [{ resolve, reject, signal, onAbort }]
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// acquire(opts?) — opts.signal aborts a waiting acquirer cleanly.
|
|
487
|
+
acquire(opts) {
|
|
488
|
+
var self = this;
|
|
489
|
+
var signal = opts && opts.signal;
|
|
490
|
+
if (self._inFlight < self._limit) {
|
|
491
|
+
self._inFlight += 1;
|
|
492
|
+
return Promise.resolve();
|
|
493
|
+
}
|
|
494
|
+
if (signal && signal.aborted) {
|
|
495
|
+
return Promise.reject(new SafeAsyncError(
|
|
496
|
+
"Semaphore.acquire aborted", "async/aborted", signal.reason
|
|
497
|
+
));
|
|
498
|
+
}
|
|
499
|
+
return new Promise(function (resolve, reject) {
|
|
500
|
+
var entry = { resolve: resolve, reject: reject, signal: signal, onAbort: null };
|
|
501
|
+
if (signal) {
|
|
502
|
+
entry.onAbort = function () {
|
|
503
|
+
var idx = self._waiters.indexOf(entry);
|
|
504
|
+
if (idx === -1) return;
|
|
505
|
+
self._waiters.splice(idx, 1);
|
|
506
|
+
reject(new SafeAsyncError(
|
|
507
|
+
"Semaphore.acquire aborted while waiting", "async/aborted", signal.reason
|
|
508
|
+
));
|
|
509
|
+
};
|
|
510
|
+
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
511
|
+
}
|
|
512
|
+
self._waiters.push(entry);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
release() {
|
|
517
|
+
if (this._inFlight === 0) {
|
|
518
|
+
throw new SafeAsyncError("release on idle Semaphore", "async/bad-release");
|
|
519
|
+
}
|
|
520
|
+
if (this._waiters.length > 0) {
|
|
521
|
+
var next = this._waiters.shift();
|
|
522
|
+
if (next.signal && next.onAbort) {
|
|
523
|
+
next.signal.removeEventListener("abort", next.onAbort);
|
|
524
|
+
}
|
|
525
|
+
next.resolve();
|
|
526
|
+
} else {
|
|
527
|
+
this._inFlight -= 1;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async runWith(fn, opts) {
|
|
532
|
+
await this.acquire(opts);
|
|
533
|
+
try {
|
|
534
|
+
return await fn();
|
|
535
|
+
} finally {
|
|
536
|
+
this.release();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
inFlight() { return this._inFlight; }
|
|
541
|
+
pendingCount() { return this._waiters.length; }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---- Once ----
|
|
545
|
+
//
|
|
546
|
+
// Single-flight memoization. The first .invoke() call runs the function;
|
|
547
|
+
// subsequent calls (concurrent or later) await the same in-flight Promise
|
|
548
|
+
// and receive the same result. If the first invocation throws, the cached
|
|
549
|
+
// result is the rejected Promise — re-invocation will NOT retry.
|
|
550
|
+
//
|
|
551
|
+
// Use case: lazy boot-time init where multiple call sites might first-
|
|
552
|
+
// touch concurrently (counter primer, schema-check, key load). Without
|
|
553
|
+
// Once, the second concurrent caller would start its own init and produce
|
|
554
|
+
// double-initialization.
|
|
555
|
+
//
|
|
556
|
+
// .reset() clears the cached Promise so the next .invoke() runs fresh.
|
|
557
|
+
// Useful for tests, hot-reload, and operator-driven re-init after a
|
|
558
|
+
// transient init failure. To make resets explicit (avoid silent stale
|
|
559
|
+
// state), reset() does NOT cancel an in-flight first invocation —
|
|
560
|
+
// callers awaiting the prior invoke continue to see its result.
|
|
561
|
+
|
|
562
|
+
class Once {
|
|
563
|
+
constructor(fn) {
|
|
564
|
+
if (typeof fn !== "function") {
|
|
565
|
+
throw new SafeAsyncError("Once: argument must be a function", "async/bad-arg");
|
|
566
|
+
}
|
|
567
|
+
this._fn = fn;
|
|
568
|
+
this._promise = null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
invoke() {
|
|
572
|
+
if (this._promise === null) {
|
|
573
|
+
this._promise = Promise.resolve().then(this._fn);
|
|
574
|
+
}
|
|
575
|
+
return this._promise;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
reset() {
|
|
579
|
+
this._promise = null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
hasInvoked() { return this._promise !== null; }
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---- repeating ----
|
|
586
|
+
//
|
|
587
|
+
// Bounded-cadence interval timer with consistent unref + cancel semantics.
|
|
588
|
+
// Replaces the scattered setInterval ceremony where each caller hand-rolled
|
|
589
|
+
// `var t = setInterval(...); t.unref();` and a corresponding clearInterval
|
|
590
|
+
// in shutdown — easy to forget the unref and silently block process exit.
|
|
591
|
+
//
|
|
592
|
+
// var sweep = b.safeAsync.repeating(function () {
|
|
593
|
+
// return cleanup();
|
|
594
|
+
// }, b.constants.TIME.seconds(30), { name: "cache-sweep" });
|
|
595
|
+
// ...
|
|
596
|
+
// sweep.stop();
|
|
597
|
+
//
|
|
598
|
+
// fn may be sync or async. If fn returns a Promise, the next tick fires
|
|
599
|
+
// `intervalMs` after the prior fn() *started* (matching setInterval's
|
|
600
|
+
// fixed-rate semantics, not after-completion). Promise rejections are
|
|
601
|
+
// captured by the optional onError callback; if none provided, they're
|
|
602
|
+
// silently dropped — a repeating timer is by definition fire-and-forget,
|
|
603
|
+
// and an unhandled rejection here would crash the process.
|
|
604
|
+
//
|
|
605
|
+
// opts.unref defaults true: most repeating timers are background sweepers
|
|
606
|
+
// that should NOT keep the process alive. Cluster heartbeat etc. set
|
|
607
|
+
// `unref: false` so the lease keeps the leader from exiting silently.
|
|
608
|
+
|
|
609
|
+
function repeating(fn, intervalMs, opts) {
|
|
610
|
+
if (typeof fn !== "function") {
|
|
611
|
+
throw new SafeAsyncError("repeating: fn must be a function", "async/bad-arg");
|
|
612
|
+
}
|
|
613
|
+
if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
614
|
+
throw new SafeAsyncError("repeating: intervalMs must be a positive finite number, got " + intervalMs,
|
|
615
|
+
"async/bad-arg");
|
|
616
|
+
}
|
|
617
|
+
opts = opts || {};
|
|
618
|
+
var unref = opts.unref !== false; // default true
|
|
619
|
+
var onError = typeof opts.onError === "function" ? opts.onError : null;
|
|
620
|
+
|
|
621
|
+
var stopped = false;
|
|
622
|
+
function _tick() {
|
|
623
|
+
if (stopped) return;
|
|
624
|
+
var result;
|
|
625
|
+
try { result = fn(); } catch (e) {
|
|
626
|
+
if (onError) { try { onError(e); } catch (_e) { /* swallow */ } } return;
|
|
627
|
+
}
|
|
628
|
+
if (result && typeof result.then === "function") {
|
|
629
|
+
result.then(null, function (e) {
|
|
630
|
+
if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
var timer = setInterval(_tick, intervalMs);
|
|
635
|
+
if (unref && typeof timer.unref === "function") timer.unref();
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
stop: function () {
|
|
639
|
+
if (stopped) return;
|
|
640
|
+
stopped = true;
|
|
641
|
+
clearInterval(timer);
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ---- flushLoop ----
|
|
647
|
+
//
|
|
648
|
+
// Schedule fn(), wait for it to settle (resolve or reject), then schedule
|
|
649
|
+
// the next fn() `intervalMs` later. Differs from `repeating` (fixed-rate,
|
|
650
|
+
// fire-and-forget) — flushLoop is the after-completion pattern most
|
|
651
|
+
// background flushers want: never overlap two flushes, and don't accumulate
|
|
652
|
+
// backlog if a flush is slow.
|
|
653
|
+
//
|
|
654
|
+
// var loop = b.safeAsync.flushLoop(function () {
|
|
655
|
+
// return otelExporter.flush();
|
|
656
|
+
// }, b.constants.TIME.seconds(15), { name: "otel-flush" });
|
|
657
|
+
// ...
|
|
658
|
+
// loop.stop();
|
|
659
|
+
//
|
|
660
|
+
// Always unref'd — a pending flush should never keep the process alive
|
|
661
|
+
// (the operator's b.appShutdown drives the final drain explicitly).
|
|
662
|
+
// onError catches rejections; without one, they're silently dropped.
|
|
663
|
+
|
|
664
|
+
function flushLoop(fn, intervalMs, opts) {
|
|
665
|
+
if (typeof fn !== "function") {
|
|
666
|
+
throw new SafeAsyncError("flushLoop: fn must be a function", "async/bad-arg");
|
|
667
|
+
}
|
|
668
|
+
if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
669
|
+
throw new SafeAsyncError("flushLoop: intervalMs must be a positive finite number, got " + intervalMs,
|
|
670
|
+
"async/bad-arg");
|
|
671
|
+
}
|
|
672
|
+
opts = opts || {};
|
|
673
|
+
var onError = typeof opts.onError === "function" ? opts.onError : null;
|
|
674
|
+
|
|
675
|
+
var stopped = false;
|
|
676
|
+
var timer = null;
|
|
677
|
+
|
|
678
|
+
function _schedule() {
|
|
679
|
+
if (stopped) return;
|
|
680
|
+
timer = setTimeout(function () {
|
|
681
|
+
timer = null;
|
|
682
|
+
if (stopped) return;
|
|
683
|
+
var settled;
|
|
684
|
+
try { settled = Promise.resolve(fn()); }
|
|
685
|
+
catch (e) {
|
|
686
|
+
if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
|
|
687
|
+
_schedule();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
settled.then(null, function (e) {
|
|
691
|
+
if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
|
|
692
|
+
}).then(_schedule);
|
|
693
|
+
}, intervalMs);
|
|
694
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
695
|
+
}
|
|
696
|
+
_schedule();
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
stop: function () {
|
|
700
|
+
if (stopped) return;
|
|
701
|
+
stopped = true;
|
|
702
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---- Re-exports of resilience primitives from lib/retry.js ----
|
|
708
|
+
//
|
|
709
|
+
// withRetry + CircuitBreaker live in lib/retry.js (the canonical home).
|
|
710
|
+
// We re-export them here under safe-async-shaped names so call sites
|
|
711
|
+
// reaching for async safety primitives find them in one place.
|
|
712
|
+
|
|
713
|
+
var retryHelper = require("./retry");
|
|
714
|
+
|
|
715
|
+
var asyncRetry = retryHelper.withRetry;
|
|
716
|
+
var CircuitBreaker = retryHelper.CircuitBreaker;
|
|
717
|
+
|
|
718
|
+
module.exports = {
|
|
719
|
+
withTimeout: withTimeout,
|
|
720
|
+
withSignal: withSignal,
|
|
721
|
+
withTimeoutSignal: withTimeoutSignal,
|
|
722
|
+
sleep: sleep,
|
|
723
|
+
repeating: repeating,
|
|
724
|
+
flushLoop: flushLoop,
|
|
725
|
+
safeAwait: safeAwait,
|
|
726
|
+
safeInvoke: safeInvoke,
|
|
727
|
+
makeDropCallback: makeDropCallback,
|
|
728
|
+
makeScheduledFlush: makeScheduledFlush,
|
|
729
|
+
Mutex: Mutex,
|
|
730
|
+
Semaphore: Semaphore,
|
|
731
|
+
Once: Once,
|
|
732
|
+
asyncRetry: asyncRetry,
|
|
733
|
+
CircuitBreaker: CircuitBreaker,
|
|
734
|
+
SafeAsyncError: SafeAsyncError,
|
|
735
|
+
};
|