abxbus 2.4.16 → 2.4.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/dist/cjs/event_bus.js +1 -1
- package/dist/cjs/event_bus.js.map +2 -2
- package/dist/cjs/event_handler.d.ts +1 -0
- package/dist/cjs/event_handler.js +14 -1
- package/dist/cjs/event_handler.js.map +2 -2
- package/dist/cjs/middleware_otel_tracing.js +3 -3
- package/dist/cjs/middleware_otel_tracing.js.map +2 -2
- package/dist/cjs/retry.js +39 -0
- package/dist/cjs/retry.js.map +2 -2
- package/dist/esm/event_bus.js +1 -1
- package/dist/esm/event_bus.js.map +2 -2
- package/dist/esm/event_handler.js +14 -1
- package/dist/esm/event_handler.js.map +2 -2
- package/dist/esm/middleware_otel_tracing.js +4 -4
- package/dist/esm/middleware_otel_tracing.js.map +2 -2
- package/dist/esm/retry.js +39 -0
- package/dist/esm/retry.js.map +2 -2
- package/dist/types/event_handler.d.ts +1 -0
- package/package.json +5 -1
- package/src/async_context.ts +70 -0
- package/src/base_event.ts +1201 -0
- package/src/bridge_jsonl.ts +174 -0
- package/src/bridge_nats.ts +104 -0
- package/src/bridge_postgres.ts +277 -0
- package/src/bridge_redis.ts +194 -0
- package/src/bridge_sqlite.ts +289 -0
- package/src/bridges.ts +376 -0
- package/src/event_bus.ts +1263 -0
- package/src/event_handler.ts +379 -0
- package/src/event_history.ts +247 -0
- package/src/event_result.ts +483 -0
- package/src/events_suck.ts +96 -0
- package/src/helpers.ts +65 -0
- package/src/index.ts +37 -0
- package/src/lock_manager.ts +401 -0
- package/src/logging.ts +261 -0
- package/src/middleware_otel_tracing.ts +201 -0
- package/src/middlewares.ts +16 -0
- package/src/optional_deps.ts +52 -0
- package/src/retry.ts +578 -0
- package/src/timing.ts +52 -0
- package/src/types.ts +132 -0
package/src/retry.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { createAsyncLocalStorage, type AsyncLocalStorageLike } from './async_context.js'
|
|
2
|
+
import { isNodeRuntime } from './optional_deps.js'
|
|
3
|
+
|
|
4
|
+
type SemaphoreScope = 'multiprocess' | 'global' | 'class' | 'instance'
|
|
5
|
+
|
|
6
|
+
type MultiprocessLockHandle = {
|
|
7
|
+
release: () => Promise<void>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MULTIPROCESS_SEMAPHORE_DIRNAME = 'browser_use_semaphores'
|
|
11
|
+
const MULTIPROCESS_STALE_LOCK_MS = 5 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
let multiprocess_fallback_reason_logged: string | null = null
|
|
14
|
+
|
|
15
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface RetryOptions {
|
|
18
|
+
/** Total number of attempts including the initial call (1 = no retry, 3 = up to 2 retries). Default: 1 */
|
|
19
|
+
max_attempts?: number
|
|
20
|
+
|
|
21
|
+
/** Seconds to wait between retries. Default: 0 */
|
|
22
|
+
retry_after?: number
|
|
23
|
+
|
|
24
|
+
/** Multiplier applied to retry_after after each attempt for exponential backoff. Default: 1.0 (constant delay) */
|
|
25
|
+
retry_backoff_factor?: number
|
|
26
|
+
|
|
27
|
+
/** Only retry when the thrown error matches one of these matchers. Accepts error class constructors,
|
|
28
|
+
* string error names (matched against error.name), or RegExp patterns (tested against String(error)).
|
|
29
|
+
* Default: undefined (retry on any error) */
|
|
30
|
+
retry_on_errors?: Array<(new (...args: any[]) => Error) | string | RegExp>
|
|
31
|
+
|
|
32
|
+
/** Per-attempt timeout in seconds. Default: undefined (no per-attempt timeout) */
|
|
33
|
+
timeout?: number | null
|
|
34
|
+
|
|
35
|
+
/** Maximum concurrent executions sharing this semaphore. Default: undefined (no concurrency limit) */
|
|
36
|
+
semaphore_limit?: number | null
|
|
37
|
+
|
|
38
|
+
/** Semaphore identifier. Functions with the same name share the same concurrency slot pool. Default: function name.
|
|
39
|
+
* If a function is provided, it receives the same arguments as the wrapped function. */
|
|
40
|
+
semaphore_name?: string | ((...args: any[]) => string) | null
|
|
41
|
+
|
|
42
|
+
/** If true, proceed without concurrency limit when semaphore acquisition times out. Default: true */
|
|
43
|
+
semaphore_lax?: boolean
|
|
44
|
+
|
|
45
|
+
/** Semaphore scoping strategy. Default: 'global'
|
|
46
|
+
* - 'multiprocess': all processes on the machine share one semaphore (Node.js only)
|
|
47
|
+
* - 'global': all calls share one semaphore (keyed by semaphore_name)
|
|
48
|
+
* - 'class': all instances of the same class share one semaphore (keyed by className.semaphore_name)
|
|
49
|
+
* - 'instance': each object instance gets its own semaphore (keyed by instanceId.semaphore_name)
|
|
50
|
+
* 'class' and 'instance' require `this` to be an object; they fall back to 'global' for standalone calls. */
|
|
51
|
+
semaphore_scope?: SemaphoreScope
|
|
52
|
+
|
|
53
|
+
/** Maximum seconds to wait for semaphore acquisition. Default: undefined → timeout * max(1, limit - 1) */
|
|
54
|
+
semaphore_timeout?: number | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Errors ──────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Thrown when a single attempt exceeds the per-attempt timeout. */
|
|
60
|
+
export class RetryTimeoutError extends Error {
|
|
61
|
+
timeout_seconds: number
|
|
62
|
+
attempt: number
|
|
63
|
+
|
|
64
|
+
constructor(message: string, params: { timeout_seconds: number; attempt: number }) {
|
|
65
|
+
super(message)
|
|
66
|
+
this.name = 'RetryTimeoutError'
|
|
67
|
+
this.timeout_seconds = params.timeout_seconds
|
|
68
|
+
this.attempt = params.attempt
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Thrown (when semaphore_lax=false) if the semaphore cannot be acquired within the timeout. */
|
|
73
|
+
export class SemaphoreTimeoutError extends Error {
|
|
74
|
+
semaphore_name: string
|
|
75
|
+
semaphore_limit: number
|
|
76
|
+
timeout_seconds: number
|
|
77
|
+
|
|
78
|
+
constructor(message: string, params: { semaphore_name: string; semaphore_limit: number; timeout_seconds: number }) {
|
|
79
|
+
super(message)
|
|
80
|
+
this.name = 'SemaphoreTimeoutError'
|
|
81
|
+
this.semaphore_name = params.semaphore_name
|
|
82
|
+
this.semaphore_limit = params.semaphore_limit
|
|
83
|
+
this.timeout_seconds = params.timeout_seconds
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Re-entrancy tracking via AsyncLocalStorage ──────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// Prevents deadlocks when a retry()-wrapped function calls another retry()-wrapped
|
|
90
|
+
// function that shares the same semaphore (or calls itself recursively).
|
|
91
|
+
//
|
|
92
|
+
// Each async call stack tracks which semaphore names it currently holds. When a
|
|
93
|
+
// nested call encounters a semaphore it already holds, it skips acquisition and
|
|
94
|
+
// runs directly within the parent's slot.
|
|
95
|
+
//
|
|
96
|
+
// Uses the same AsyncLocalStorage polyfill as the rest of abxbus (see async_context.ts)
|
|
97
|
+
// so it works in Node.js and gracefully degrades to a no-op in browsers.
|
|
98
|
+
|
|
99
|
+
type ReentrantStore = Set<string>
|
|
100
|
+
|
|
101
|
+
// Separate AsyncLocalStorage instance for retry re-entrancy tracking.
|
|
102
|
+
// Created via the shared factory in async_context.ts (returns null in browsers).
|
|
103
|
+
const retry_context_storage: AsyncLocalStorageLike | null = createAsyncLocalStorage()
|
|
104
|
+
|
|
105
|
+
function getHeldSemaphores(): ReentrantStore {
|
|
106
|
+
return (retry_context_storage?.getStore() as ReentrantStore | undefined) ?? new Set()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function runWithHeldSemaphores<T>(held: ReentrantStore, fn: () => T): T {
|
|
110
|
+
if (!retry_context_storage) return fn()
|
|
111
|
+
return retry_context_storage.run(held, fn)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Semaphore scope helpers ─────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
let _next_instance_id = 1
|
|
117
|
+
const _instance_ids = new WeakMap<object, number>()
|
|
118
|
+
|
|
119
|
+
function scopedSemaphoreKey(base_name: string, scope: SemaphoreScope, context: unknown): string {
|
|
120
|
+
if (scope === 'class' && context && typeof context === 'object') {
|
|
121
|
+
return `${(context as object).constructor?.name ?? 'Object'}.${base_name}`
|
|
122
|
+
}
|
|
123
|
+
if (scope === 'instance' && context && typeof context === 'object') {
|
|
124
|
+
let id = _instance_ids.get(context as object)
|
|
125
|
+
if (id === undefined) {
|
|
126
|
+
id = _next_instance_id++
|
|
127
|
+
_instance_ids.set(context as object, id)
|
|
128
|
+
}
|
|
129
|
+
return `${id}.${base_name}`
|
|
130
|
+
}
|
|
131
|
+
return base_name
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Global semaphore registry ───────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
class RetrySemaphore {
|
|
137
|
+
readonly size: number
|
|
138
|
+
private inUse: number
|
|
139
|
+
private waiters: Array<() => void>
|
|
140
|
+
|
|
141
|
+
constructor(size: number) {
|
|
142
|
+
this.size = size
|
|
143
|
+
this.inUse = 0
|
|
144
|
+
this.waiters = []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async acquire(): Promise<void> {
|
|
148
|
+
if (this.size === Infinity) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if (this.inUse < this.size) {
|
|
152
|
+
this.inUse += 1
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
await new Promise<void>((resolve) => {
|
|
156
|
+
this.waiters.push(resolve)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
release(): void {
|
|
161
|
+
if (this.size === Infinity) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const next = this.waiters.shift()
|
|
165
|
+
if (next) {
|
|
166
|
+
// Handoff: keep the permit accounted for and transfer it directly to the waiter.
|
|
167
|
+
next()
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
this.inUse = Math.max(0, this.inUse - 1)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const SEMAPHORE_REGISTRY = new Map<string, RetrySemaphore>()
|
|
175
|
+
|
|
176
|
+
function getOrCreateSemaphore(name: string, limit: number): RetrySemaphore {
|
|
177
|
+
const existing = SEMAPHORE_REGISTRY.get(name)
|
|
178
|
+
if (existing && existing.size === limit) return existing
|
|
179
|
+
const sem = new RetrySemaphore(limit)
|
|
180
|
+
SEMAPHORE_REGISTRY.set(name, sem)
|
|
181
|
+
return sem
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Reset the global semaphore registry. Useful in tests. */
|
|
185
|
+
export function clearSemaphoreRegistry(): void {
|
|
186
|
+
SEMAPHORE_REGISTRY.clear()
|
|
187
|
+
multiprocess_fallback_reason_logged = null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── retry() decorator / higher-order wrapper ────────────────────────────────
|
|
191
|
+
//
|
|
192
|
+
// Usage as a higher-order function (works on any async function):
|
|
193
|
+
//
|
|
194
|
+
// const fetchWithRetry = retry({ max_attempts: 3, retry_after: 1 })(async (url: string) => {
|
|
195
|
+
// return await fetch(url)
|
|
196
|
+
// })
|
|
197
|
+
//
|
|
198
|
+
// Usage as a TC39 Stage 3 decorator on class methods (TS 5.0+):
|
|
199
|
+
//
|
|
200
|
+
// class ApiClient {
|
|
201
|
+
// @retry({ max_attempts: 3, retry_after: 1 })
|
|
202
|
+
// async fetchData(): Promise<Data> { ... }
|
|
203
|
+
// }
|
|
204
|
+
//
|
|
205
|
+
// Usage on event bus handlers:
|
|
206
|
+
//
|
|
207
|
+
// bus.on(MyEvent, retry({ max_attempts: 3 })(async (event) => {
|
|
208
|
+
// await riskyOperation(event.data)
|
|
209
|
+
// }))
|
|
210
|
+
|
|
211
|
+
export function retry(options: RetryOptions = {}) {
|
|
212
|
+
const {
|
|
213
|
+
max_attempts = 1,
|
|
214
|
+
retry_after = 0,
|
|
215
|
+
retry_backoff_factor = 1.0,
|
|
216
|
+
retry_on_errors,
|
|
217
|
+
timeout,
|
|
218
|
+
semaphore_limit,
|
|
219
|
+
semaphore_name: semaphore_name_option,
|
|
220
|
+
semaphore_lax = true,
|
|
221
|
+
semaphore_scope = 'global',
|
|
222
|
+
semaphore_timeout,
|
|
223
|
+
} = options
|
|
224
|
+
|
|
225
|
+
return function decorator<T extends (...args: any[]) => any>(target: T, _context?: ClassMethodDecoratorContext): T {
|
|
226
|
+
const fn_name = target.name || (_context?.name as string) || 'anonymous'
|
|
227
|
+
const effective_max_attempts = Math.max(1, max_attempts)
|
|
228
|
+
const effective_retry_after = Math.max(0, retry_after)
|
|
229
|
+
|
|
230
|
+
async function retryWrapper(this: any, ...args: any[]): Promise<any> {
|
|
231
|
+
const base_name = typeof semaphore_name_option === 'function' ? semaphore_name_option(...args) : (semaphore_name_option ?? fn_name)
|
|
232
|
+
const sem_name = typeof base_name === 'string' ? base_name : String(base_name)
|
|
233
|
+
// ── Resolve scoped semaphore key at call time (uses `this` for class/instance scopes) ──
|
|
234
|
+
const scoped_key = scopedSemaphoreKey(sem_name, semaphore_scope, this)
|
|
235
|
+
|
|
236
|
+
// ── Check re-entrancy: skip semaphore if we already hold it in this async context ──
|
|
237
|
+
const held = getHeldSemaphores()
|
|
238
|
+
const needs_semaphore = semaphore_limit != null && semaphore_limit > 0
|
|
239
|
+
const is_reentrant = needs_semaphore && held.has(scoped_key)
|
|
240
|
+
|
|
241
|
+
// ── Semaphore acquisition (held across all retry attempts, skipped if re-entrant) ──
|
|
242
|
+
let semaphore: RetrySemaphore | null = null
|
|
243
|
+
let multiprocess_lock: MultiprocessLockHandle | null = null
|
|
244
|
+
let semaphore_acquired = false
|
|
245
|
+
|
|
246
|
+
if (needs_semaphore && !is_reentrant) {
|
|
247
|
+
const effective_sem_timeout =
|
|
248
|
+
semaphore_timeout != null ? semaphore_timeout : timeout != null ? timeout * Math.max(1, semaphore_limit! - 1) : null
|
|
249
|
+
|
|
250
|
+
if (semaphore_scope === 'multiprocess') {
|
|
251
|
+
if (isNodeRuntime()) {
|
|
252
|
+
multiprocess_lock = await acquireMultiprocessSemaphore(scoped_key, semaphore_limit!, effective_sem_timeout, semaphore_lax)
|
|
253
|
+
semaphore_acquired = multiprocess_lock !== null
|
|
254
|
+
} else {
|
|
255
|
+
logMultiprocessFallbackOnce('multiprocess semaphores require a Node.js runtime; falling back to in-process global scope')
|
|
256
|
+
semaphore = getOrCreateSemaphore(scoped_key, semaphore_limit!)
|
|
257
|
+
if (effective_sem_timeout != null && effective_sem_timeout > 0) {
|
|
258
|
+
semaphore_acquired = await acquireWithTimeout(semaphore, effective_sem_timeout * 1000)
|
|
259
|
+
if (!semaphore_acquired) {
|
|
260
|
+
if (!semaphore_lax) {
|
|
261
|
+
throw new SemaphoreTimeoutError(
|
|
262
|
+
`Failed to acquire semaphore "${scoped_key}" within ${effective_sem_timeout}s (limit=${semaphore_limit})`,
|
|
263
|
+
{ semaphore_name: scoped_key, semaphore_limit: semaphore_limit!, timeout_seconds: effective_sem_timeout }
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
await semaphore.acquire()
|
|
269
|
+
semaphore_acquired = true
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
semaphore = getOrCreateSemaphore(scoped_key, semaphore_limit!)
|
|
274
|
+
|
|
275
|
+
if (effective_sem_timeout != null && effective_sem_timeout > 0) {
|
|
276
|
+
semaphore_acquired = await acquireWithTimeout(semaphore, effective_sem_timeout * 1000)
|
|
277
|
+
if (!semaphore_acquired) {
|
|
278
|
+
if (!semaphore_lax) {
|
|
279
|
+
throw new SemaphoreTimeoutError(
|
|
280
|
+
`Failed to acquire semaphore "${scoped_key}" within ${effective_sem_timeout}s (limit=${semaphore_limit})`,
|
|
281
|
+
{ semaphore_name: scoped_key, semaphore_limit: semaphore_limit!, timeout_seconds: effective_sem_timeout }
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
await semaphore.acquire()
|
|
287
|
+
semaphore_acquired = true
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Build the set of held semaphores for nested calls ──
|
|
293
|
+
const new_held = new Set(held)
|
|
294
|
+
if (semaphore_acquired) {
|
|
295
|
+
new_held.add(scoped_key)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Retry loop (runs inside the semaphore and re-entrancy context) ──
|
|
299
|
+
const runRetryLoop = async (): Promise<any> => {
|
|
300
|
+
for (let attempt = 1; attempt <= effective_max_attempts; attempt++) {
|
|
301
|
+
try {
|
|
302
|
+
if (timeout != null && timeout > 0) {
|
|
303
|
+
return await _runWithTimeout(() => Promise.resolve(target.apply(this, args)), timeout * 1000, attempt)
|
|
304
|
+
} else {
|
|
305
|
+
return await Promise.resolve(target.apply(this, args))
|
|
306
|
+
}
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// Check if this error type should trigger a retry
|
|
309
|
+
if (retry_on_errors && retry_on_errors.length > 0) {
|
|
310
|
+
const is_retryable = retry_on_errors.some((matcher) =>
|
|
311
|
+
typeof matcher === 'string'
|
|
312
|
+
? (error as Error)?.name === matcher
|
|
313
|
+
: matcher instanceof RegExp
|
|
314
|
+
? matcher.test(String(error))
|
|
315
|
+
: error instanceof matcher
|
|
316
|
+
)
|
|
317
|
+
if (!is_retryable) throw error
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Last attempt: rethrow
|
|
321
|
+
if (attempt >= effective_max_attempts) throw error
|
|
322
|
+
|
|
323
|
+
// Wait before next attempt with exponential backoff
|
|
324
|
+
const delay_seconds = effective_retry_after * Math.pow(retry_backoff_factor, attempt - 1)
|
|
325
|
+
if (delay_seconds > 0) {
|
|
326
|
+
await sleep(delay_seconds * 1000)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Unreachable, but satisfies the type checker
|
|
332
|
+
throw new Error(`retry(${fn_name}): unexpected end of retry loop`)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
return await runWithHeldSemaphores(new_held, runRetryLoop)
|
|
337
|
+
} finally {
|
|
338
|
+
if (semaphore_acquired && multiprocess_lock) {
|
|
339
|
+
await multiprocess_lock.release()
|
|
340
|
+
} else if (semaphore_acquired && semaphore) {
|
|
341
|
+
semaphore.release()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
Object.defineProperty(retryWrapper, 'name', { value: fn_name, configurable: true })
|
|
347
|
+
if (_context?.kind === 'method' && typeof _context.addInitializer === 'function') {
|
|
348
|
+
_context.addInitializer(function (this: unknown) {
|
|
349
|
+
const owner_name = findDecoratedMethodOwnerName(this, _context, retryWrapper)
|
|
350
|
+
if (owner_name) {
|
|
351
|
+
Object.defineProperty(retryWrapper, 'name', { value: `${owner_name}.${fn_name}`, configurable: true })
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
return retryWrapper as unknown as T
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
function findDecoratedMethodOwnerName(
|
|
362
|
+
context_this: unknown,
|
|
363
|
+
context: ClassMethodDecoratorContext,
|
|
364
|
+
replacement: (...args: any[]) => any
|
|
365
|
+
): string | null {
|
|
366
|
+
const method_name = context.name
|
|
367
|
+
if (typeof method_name !== 'string') {
|
|
368
|
+
return null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (context.static) {
|
|
372
|
+
let ctor = typeof context_this === 'function' ? context_this : null
|
|
373
|
+
while (ctor && ctor !== Function.prototype) {
|
|
374
|
+
const descriptor = Object.getOwnPropertyDescriptor(ctor, method_name)
|
|
375
|
+
if (descriptor?.value === replacement) {
|
|
376
|
+
return ctor.name || null
|
|
377
|
+
}
|
|
378
|
+
const parent = Object.getPrototypeOf(ctor)
|
|
379
|
+
ctor = typeof parent === 'function' ? parent : null
|
|
380
|
+
}
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if ((typeof context_this !== 'object' && typeof context_this !== 'function') || context_this === null) {
|
|
385
|
+
return null
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let prototype = Object.getPrototypeOf(context_this)
|
|
389
|
+
while (prototype && prototype !== Object.prototype) {
|
|
390
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, method_name)
|
|
391
|
+
if (descriptor?.value === replacement) {
|
|
392
|
+
const ctor_name = (prototype as { constructor?: { name?: string } }).constructor?.name
|
|
393
|
+
return ctor_name || null
|
|
394
|
+
}
|
|
395
|
+
prototype = Object.getPrototypeOf(prototype)
|
|
396
|
+
}
|
|
397
|
+
return null
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Try to acquire a semaphore within a timeout. Returns true if acquired, false if timed out.
|
|
402
|
+
* If the semaphore is acquired after the timeout (due to the waiter remaining queued),
|
|
403
|
+
* it is immediately released to avoid leaking slots.
|
|
404
|
+
*/
|
|
405
|
+
async function acquireWithTimeout(semaphore: RetrySemaphore, timeout_ms: number): Promise<boolean> {
|
|
406
|
+
return new Promise<boolean>((resolve) => {
|
|
407
|
+
let settled = false
|
|
408
|
+
|
|
409
|
+
const timer = setTimeout(() => {
|
|
410
|
+
if (!settled) {
|
|
411
|
+
settled = true
|
|
412
|
+
resolve(false)
|
|
413
|
+
}
|
|
414
|
+
}, timeout_ms)
|
|
415
|
+
|
|
416
|
+
semaphore.acquire().then(() => {
|
|
417
|
+
if (!settled) {
|
|
418
|
+
settled = true
|
|
419
|
+
clearTimeout(timer)
|
|
420
|
+
resolve(true)
|
|
421
|
+
} else {
|
|
422
|
+
// Acquired after timeout fired — release immediately to avoid slot leak
|
|
423
|
+
semaphore.release()
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function logMultiprocessFallbackOnce(reason: string): void {
|
|
430
|
+
if (multiprocess_fallback_reason_logged === reason) return
|
|
431
|
+
multiprocess_fallback_reason_logged = reason
|
|
432
|
+
console.warn(`[abxbus.retry] ${reason}`)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function importNodeModule(specifier: string): Promise<any> {
|
|
436
|
+
const dynamic_import = Function('module_name', 'return import(module_name)') as (module_name: string) => Promise<unknown>
|
|
437
|
+
return dynamic_import(specifier) as Promise<any>
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function acquireMultiprocessSemaphore(
|
|
441
|
+
scoped_key: string,
|
|
442
|
+
semaphore_limit: number,
|
|
443
|
+
semaphore_timeout_seconds: number | null,
|
|
444
|
+
semaphore_lax: boolean
|
|
445
|
+
): Promise<MultiprocessLockHandle | null> {
|
|
446
|
+
const [crypto, fs, os, path] = await Promise.all([
|
|
447
|
+
importNodeModule('node:crypto'),
|
|
448
|
+
importNodeModule('node:fs'),
|
|
449
|
+
importNodeModule('node:os'),
|
|
450
|
+
importNodeModule('node:path'),
|
|
451
|
+
])
|
|
452
|
+
const semaphore_directory = path.join(os.tmpdir(), MULTIPROCESS_SEMAPHORE_DIRNAME)
|
|
453
|
+
const lock_prefix = crypto.createHash('sha256').update(scoped_key).digest('hex').slice(0, 40)
|
|
454
|
+
fs.mkdirSync(semaphore_directory, { recursive: true })
|
|
455
|
+
|
|
456
|
+
const start = Date.now()
|
|
457
|
+
let retry_delay_ms = 100
|
|
458
|
+
|
|
459
|
+
while (true) {
|
|
460
|
+
const elapsed_ms = Date.now() - start
|
|
461
|
+
const remaining_ms =
|
|
462
|
+
semaphore_timeout_seconds != null && semaphore_timeout_seconds > 0 ? semaphore_timeout_seconds * 1000 - elapsed_ms : null
|
|
463
|
+
|
|
464
|
+
if (remaining_ms != null && remaining_ms <= 0) {
|
|
465
|
+
break
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (let slot = 0; slot < semaphore_limit; slot++) {
|
|
469
|
+
const slot_file = path.join(semaphore_directory, `${lock_prefix}.${String(slot).padStart(2, '0')}.lock`)
|
|
470
|
+
const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const fd = fs.openSync(slot_file, 'wx', 0o600)
|
|
474
|
+
try {
|
|
475
|
+
fs.writeFileSync(
|
|
476
|
+
fd,
|
|
477
|
+
JSON.stringify({
|
|
478
|
+
token,
|
|
479
|
+
pid: process.pid,
|
|
480
|
+
semaphore_name: scoped_key,
|
|
481
|
+
created_at_ms: Date.now(),
|
|
482
|
+
}),
|
|
483
|
+
'utf8'
|
|
484
|
+
)
|
|
485
|
+
} finally {
|
|
486
|
+
fs.closeSync(fd)
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
release: async () => {
|
|
490
|
+
try {
|
|
491
|
+
const raw = String(fs.readFileSync(slot_file, 'utf8') || '').trim()
|
|
492
|
+
const current_owner = raw ? (JSON.parse(raw) as { token?: unknown }) : null
|
|
493
|
+
if (current_owner?.token === token) {
|
|
494
|
+
fs.unlinkSync(slot_file)
|
|
495
|
+
}
|
|
496
|
+
} catch {}
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
501
|
+
throw error
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
const raw = String(fs.readFileSync(slot_file, 'utf8') || '').trim()
|
|
506
|
+
const current_owner = raw ? (JSON.parse(raw) as { pid?: unknown }) : null
|
|
507
|
+
const current_pid = typeof current_owner?.pid === 'number' ? current_owner.pid : null
|
|
508
|
+
if (current_pid != null) {
|
|
509
|
+
try {
|
|
510
|
+
process.kill(current_pid, 0)
|
|
511
|
+
continue
|
|
512
|
+
} catch {}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const slot_age_ms = Date.now() - fs.statSync(slot_file).mtimeMs
|
|
516
|
+
if (current_pid != null || slot_age_ms >= MULTIPROCESS_STALE_LOCK_MS) {
|
|
517
|
+
fs.unlinkSync(slot_file)
|
|
518
|
+
}
|
|
519
|
+
} catch {}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const sleep_ms = Math.min(retry_delay_ms, remaining_ms ?? retry_delay_ms)
|
|
524
|
+
if (sleep_ms > 0) {
|
|
525
|
+
await sleep(sleep_ms)
|
|
526
|
+
}
|
|
527
|
+
retry_delay_ms = Math.min(retry_delay_ms * 2, 1000)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!semaphore_lax) {
|
|
531
|
+
throw new SemaphoreTimeoutError(
|
|
532
|
+
`Failed to acquire semaphore "${scoped_key}" within ${semaphore_timeout_seconds}s (limit=${semaphore_limit})`,
|
|
533
|
+
{ semaphore_name: scoped_key, semaphore_limit, timeout_seconds: semaphore_timeout_seconds ?? 0 }
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return null
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Run fn() with a timeout. Rejects with RetryTimeoutError if the timeout fires first. */
|
|
541
|
+
async function _runWithTimeout<T>(fn: () => Promise<T>, timeout_ms: number, attempt: number): Promise<T> {
|
|
542
|
+
return new Promise<T>((resolve, reject) => {
|
|
543
|
+
let settled = false
|
|
544
|
+
|
|
545
|
+
const timer = setTimeout(() => {
|
|
546
|
+
if (!settled) {
|
|
547
|
+
settled = true
|
|
548
|
+
reject(
|
|
549
|
+
new RetryTimeoutError(`Timed out after ${timeout_ms / 1000}s (attempt ${attempt})`, {
|
|
550
|
+
timeout_seconds: timeout_ms / 1000,
|
|
551
|
+
attempt,
|
|
552
|
+
})
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
}, timeout_ms)
|
|
556
|
+
|
|
557
|
+
fn().then(
|
|
558
|
+
(value) => {
|
|
559
|
+
if (!settled) {
|
|
560
|
+
settled = true
|
|
561
|
+
clearTimeout(timer)
|
|
562
|
+
resolve(value)
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
(error) => {
|
|
566
|
+
if (!settled) {
|
|
567
|
+
settled = true
|
|
568
|
+
clearTimeout(timer)
|
|
569
|
+
reject(error)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
)
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function sleep(ms: number): Promise<void> {
|
|
577
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
578
|
+
}
|
package/src/timing.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export async function _runWithTimeout<T>(timeout_seconds: number | null, on_timeout: () => Error, fn: () => Promise<T>): Promise<T> {
|
|
2
|
+
const task = Promise.resolve().then(fn)
|
|
3
|
+
if (timeout_seconds === null) {
|
|
4
|
+
return await task
|
|
5
|
+
}
|
|
6
|
+
const timeout_ms = timeout_seconds * 1000
|
|
7
|
+
return await new Promise<T>((resolve, reject) => {
|
|
8
|
+
let settled = false
|
|
9
|
+
const finishResolve = (value: T) => {
|
|
10
|
+
if (settled) {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
settled = true
|
|
14
|
+
clearTimeout(timer)
|
|
15
|
+
resolve(value)
|
|
16
|
+
}
|
|
17
|
+
const finishReject = (error: unknown) => {
|
|
18
|
+
if (settled) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
settled = true
|
|
22
|
+
clearTimeout(timer)
|
|
23
|
+
reject(error)
|
|
24
|
+
}
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
if (settled) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
settled = true
|
|
30
|
+
reject(on_timeout())
|
|
31
|
+
void task.catch(() => undefined)
|
|
32
|
+
}, timeout_ms)
|
|
33
|
+
task.then(finishResolve).catch(finishReject)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function _runWithSlowMonitor<T>(slow_timer: ReturnType<typeof setTimeout> | null, fn: () => Promise<T>): Promise<T> {
|
|
38
|
+
try {
|
|
39
|
+
return await fn()
|
|
40
|
+
} finally {
|
|
41
|
+
if (slow_timer) {
|
|
42
|
+
clearTimeout(slow_timer)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function _runWithAbortMonitor<T>(fn: () => T | Promise<T>, abort_signal: Promise<never>): Promise<T> {
|
|
48
|
+
const task = Promise.resolve().then(fn)
|
|
49
|
+
const raced = Promise.race([task, abort_signal])
|
|
50
|
+
void task.catch(() => undefined)
|
|
51
|
+
return await raced
|
|
52
|
+
}
|