@rpcbase/worker 0.9.0 → 0.10.0

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/index.js CHANGED
@@ -109,7 +109,28 @@ const queueApi = {
109
109
  };
110
110
  const RETRY_MAXIMUM_DELAY_MS = 3e3;
111
111
  const RETRY_MINIMUM_DELAY_MS = 50;
112
- const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
112
+ const RETRY_DEFAULT_FACTOR = 2;
113
+ const sleep = async (ms, signal) => new Promise((resolve) => {
114
+ if (signal?.aborted) {
115
+ resolve();
116
+ return;
117
+ }
118
+ let timeout = null;
119
+ const cleanup = () => {
120
+ if (timeout) clearTimeout(timeout);
121
+ signal?.removeEventListener("abort", onAbort);
122
+ };
123
+ const onAbort = () => {
124
+ cleanup();
125
+ resolve();
126
+ };
127
+ timeout = setTimeout(() => {
128
+ cleanup();
129
+ resolve();
130
+ }, ms);
131
+ timeout.unref?.();
132
+ signal?.addEventListener("abort", onAbort);
133
+ });
113
134
  const getMongoUrl = () => {
114
135
  const explicit = process.env.MONGODB_URL ?? process.env.MONGO_URL ?? process.env.MONGODB_URI ?? process.env.DB_URL;
115
136
  if (explicit && explicit.trim()) return explicit.trim();
@@ -162,19 +183,66 @@ const dispatchWorkerQueue = async ({
162
183
  const shouldSkipCollection = (collName) => collName.endsWith(".files") || collName.endsWith(".chunks");
163
184
  const INTERNAL_IGNORED_MODEL_NAMES = /* @__PURE__ */ new Set(["RBRtsChange", "RBRtsCounter"]);
164
185
  const INTERNAL_IGNORED_COLLECTION_NAMES = /* @__PURE__ */ new Set(["rtschanges", "rtscounters"]);
186
+ const normalizeRetryDelays = (input) => {
187
+ const minMs = Math.max(0, Math.floor(input?.minMs ?? RETRY_MINIMUM_DELAY_MS));
188
+ const rawMaxMs = Math.max(0, Math.floor(input?.maxMs ?? RETRY_MAXIMUM_DELAY_MS));
189
+ const maxMs = Math.max(minMs, rawMaxMs);
190
+ const factor = Math.max(1, Number.isFinite(input?.factor) ? input?.factor ?? RETRY_DEFAULT_FACTOR : RETRY_DEFAULT_FACTOR);
191
+ return { minMs, maxMs, factor };
192
+ };
193
+ const getRetryDelayMs = (attempt, delays) => Math.min(delays.maxMs, Math.round(delays.minMs * Math.pow(delays.factor, Math.max(0, attempt - 1))));
194
+ const normalizeMaxRetries = (value) => {
195
+ if (value === "infinite") return value;
196
+ const parsed = Math.floor(value);
197
+ return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
198
+ };
165
199
  const registerQueueListener = async (options = {}) => {
166
- const maxRetries = Math.max(1, Math.floor(options.maxRetries ?? 20));
200
+ const maxRetries = normalizeMaxRetries(options.maxRetries ?? "infinite");
201
+ const fatalOnMaxRetries = options.fatalOnMaxRetries ?? false;
202
+ const retryDelays = normalizeRetryDelays(options.retryDelays);
167
203
  const appName = process.env.APP_NAME?.trim();
168
204
  if (!appName) {
169
205
  throw new Error("Missing APP_NAME (required to configure the worker DB change listener)");
170
206
  }
171
207
  const mongoUrl = getMongoUrl();
208
+ const mongoClientOptions = {
209
+ family: 4,
210
+ serverSelectionTimeoutMS: 2e3,
211
+ connectTimeoutMS: 2e3,
212
+ ...options.mongoClientOptions
213
+ };
172
214
  let stopped = false;
173
- let retryCounter = 0;
215
+ const abortController = new AbortController();
174
216
  let client = null;
175
217
  let stream = null;
176
218
  let resumeAfter = null;
177
219
  let processing = Promise.resolve();
220
+ let status = { state: "connecting", attempt: 1 };
221
+ const setStatus = (next) => {
222
+ status = next;
223
+ try {
224
+ options.onStateChange?.(next);
225
+ } catch (err) {
226
+ console.warn("queue listener onStateChange failed", err);
227
+ }
228
+ };
229
+ let readySettled = false;
230
+ let readyResolve = null;
231
+ let readyReject = null;
232
+ const ready = new Promise((resolve, reject) => {
233
+ readyResolve = resolve;
234
+ readyReject = reject;
235
+ });
236
+ const resolveReady = () => {
237
+ if (readySettled) return;
238
+ readySettled = true;
239
+ readyResolve?.();
240
+ };
241
+ const rejectReady = (err) => {
242
+ if (readySettled) return;
243
+ readySettled = true;
244
+ readyReject?.(err);
245
+ };
178
246
  const isChangeStreamHistoryLost = (err) => {
179
247
  const anyErr = err;
180
248
  const code = typeof anyErr?.code === "number" ? anyErr.code : null;
@@ -184,6 +252,24 @@ const registerQueueListener = async (options = {}) => {
184
252
  };
185
253
  const close2 = async () => {
186
254
  stopped = true;
255
+ abortController.abort();
256
+ try {
257
+ stream?.removeAllListeners();
258
+ await stream?.close();
259
+ } catch {
260
+ }
261
+ stream = null;
262
+ try {
263
+ await client?.close();
264
+ } catch {
265
+ }
266
+ client = null;
267
+ if (!readySettled) {
268
+ rejectReady(new Error("queue listener closed before ready"));
269
+ }
270
+ setStatus({ state: "closed" });
271
+ };
272
+ const closeResources = async () => {
187
273
  try {
188
274
  stream?.removeAllListeners();
189
275
  await stream?.close();
@@ -197,7 +283,7 @@ const registerQueueListener = async (options = {}) => {
197
283
  client = null;
198
284
  };
199
285
  const startStream = async () => {
200
- if (stopped) return;
286
+ if (stopped) return { stoppedPromise: Promise.resolve({ reason: "close" }) };
201
287
  if (stream) {
202
288
  try {
203
289
  stream.removeAllListeners();
@@ -213,11 +299,7 @@ const registerQueueListener = async (options = {}) => {
213
299
  }
214
300
  client = null;
215
301
  }
216
- client = new MongoClient(mongoUrl, {
217
- family: 4,
218
- serverSelectionTimeoutMS: 2e3,
219
- connectTimeoutMS: 2e3
220
- });
302
+ client = new MongoClient(mongoUrl, mongoClientOptions);
221
303
  await client.connect();
222
304
  const dbMatch = { "ns.db": { $regex: `^${escapeRegex(appName)}-.*-db$` } };
223
305
  const pipeline = [
@@ -233,6 +315,19 @@ const registerQueueListener = async (options = {}) => {
233
315
  fullDocument: "updateLookup",
234
316
  ...resumeAfter ? { resumeAfter } : {}
235
317
  });
318
+ const stoppedPromise = new Promise((resolve) => {
319
+ let settled = false;
320
+ const onAbort = () => settle({ reason: "close" });
321
+ const settle = (result) => {
322
+ if (settled) return;
323
+ settled = true;
324
+ abortController.signal.removeEventListener("abort", onAbort);
325
+ resolve(result);
326
+ };
327
+ abortController.signal.addEventListener("abort", onAbort);
328
+ stream?.once("close", () => settle({ reason: "close" }));
329
+ stream?.once("error", (err) => settle({ reason: "error", error: err }));
330
+ });
236
331
  stream.on("change", (change) => {
237
332
  const streamRef = stream;
238
333
  processing = processing.then(async () => {
@@ -274,33 +369,88 @@ const registerQueueListener = async (options = {}) => {
274
369
  });
275
370
  });
276
371
  stream.on("error", (err) => {
277
- console.warn("queue listener change stream error", err);
278
372
  if (stopped) return;
279
373
  if (resumeAfter && isChangeStreamHistoryLost(err)) {
280
374
  resumeAfter = null;
281
375
  }
282
376
  try {
283
- void stream?.close();
377
+ void Promise.resolve(stream?.close()).catch(() => {
378
+ });
284
379
  } catch {
285
380
  }
286
381
  });
287
- stream.on("close", () => {
288
- if (stopped) return;
289
- retryCounter += 1;
290
- if (retryCounter > maxRetries) {
291
- console.error("queue listener reached max retries, exiting with failure");
292
- process.exit(1);
382
+ return { stoppedPromise };
383
+ };
384
+ const run = async () => {
385
+ let retryCounter = 0;
386
+ while (!stopped) {
387
+ try {
388
+ setStatus({ state: "connecting", attempt: retryCounter + 1 });
389
+ const { stoppedPromise } = await startStream();
390
+ retryCounter = 0;
391
+ setStatus({ state: "ready" });
392
+ resolveReady();
393
+ const end = await stoppedPromise;
394
+ if (stopped) return;
395
+ retryCounter += 1;
396
+ if (maxRetries !== "infinite" && retryCounter > maxRetries) {
397
+ const err2 = end.reason === "error" ? end.error : new Error("queue listener closed");
398
+ setStatus({ state: "failed", attempt: retryCounter, error: err2 });
399
+ if (fatalOnMaxRetries) {
400
+ try {
401
+ options.onFatal?.(err2);
402
+ } catch (fatalErr) {
403
+ console.warn("queue listener onFatal failed", fatalErr);
404
+ }
405
+ }
406
+ rejectReady(err2);
407
+ abortController.abort();
408
+ await closeResources();
409
+ return;
410
+ }
411
+ const delayMs = getRetryDelayMs(retryCounter, retryDelays);
412
+ const err = end.reason === "error" ? end.error : new Error("queue listener closed");
413
+ console.warn("queue listener not ready, retrying in", delayMs, err);
414
+ setStatus({ state: "error", attempt: retryCounter, error: err, nextRetryInMs: delayMs });
415
+ await closeResources();
416
+ await sleep(delayMs, abortController.signal);
417
+ } catch (err) {
418
+ if (stopped) return;
419
+ retryCounter += 1;
420
+ if (maxRetries !== "infinite" && retryCounter > maxRetries) {
421
+ setStatus({ state: "failed", attempt: retryCounter, error: err });
422
+ if (fatalOnMaxRetries) {
423
+ try {
424
+ options.onFatal?.(err);
425
+ } catch (fatalErr) {
426
+ console.warn("queue listener onFatal failed", fatalErr);
427
+ }
428
+ }
429
+ rejectReady(err);
430
+ abortController.abort();
431
+ await closeResources();
432
+ return;
433
+ }
434
+ const delayMs = getRetryDelayMs(retryCounter, retryDelays);
435
+ console.warn("queue listener not ready, retrying in", delayMs, err);
436
+ setStatus({ state: "error", attempt: retryCounter, error: err, nextRetryInMs: delayMs });
437
+ await closeResources();
438
+ await sleep(delayMs, abortController.signal);
293
439
  }
294
- const timeoutMs = Math.min(
295
- RETRY_MAXIMUM_DELAY_MS,
296
- RETRY_MINIMUM_DELAY_MS + 10 * Math.pow(2, retryCounter)
297
- );
298
- console.log("queue listener closed, retrying in", timeoutMs);
299
- void sleep(timeoutMs).then(() => startStream());
300
- });
440
+ }
441
+ };
442
+ const handle = {
443
+ ready,
444
+ close: close2,
445
+ getStatus: () => status
301
446
  };
302
- await startStream();
303
- return { close: close2 };
447
+ void run().catch(async (err) => {
448
+ if (stopped) return;
449
+ setStatus({ state: "failed", attempt: 0, error: err });
450
+ rejectReady(err);
451
+ await closeResources();
452
+ });
453
+ return handle;
304
454
  };
305
455
  const dbEventTaskName = (op, modelName) => `on-${op}-${modelName}`;
306
456
  export {
@@ -1,9 +1,38 @@
1
- type Closeable = {
1
+ import { MongoClientOptions } from 'mongodb';
2
+ export type QueueListenerRetryDelays = {
3
+ minMs?: number;
4
+ maxMs?: number;
5
+ factor?: number;
6
+ };
7
+ export type QueueListenerStatus = {
8
+ state: "connecting";
9
+ attempt: number;
10
+ } | {
11
+ state: "ready";
12
+ } | {
13
+ state: "error";
14
+ attempt: number;
15
+ error: unknown;
16
+ nextRetryInMs: number;
17
+ } | {
18
+ state: "failed";
19
+ attempt: number;
20
+ error: unknown;
21
+ } | {
22
+ state: "closed";
23
+ };
24
+ export type QueueListenerHandle = {
25
+ ready: Promise<void>;
2
26
  close: () => Promise<void>;
27
+ getStatus: () => QueueListenerStatus;
3
28
  };
4
- type QueueListenerOptions = {
5
- maxRetries?: number;
29
+ export type QueueListenerOptions = {
30
+ maxRetries?: number | "infinite";
31
+ fatalOnMaxRetries?: boolean;
32
+ onStateChange?: (status: QueueListenerStatus) => void;
33
+ onFatal?: (err: unknown) => void;
34
+ retryDelays?: QueueListenerRetryDelays;
35
+ mongoClientOptions?: MongoClientOptions;
6
36
  };
7
- export declare const registerQueueListener: (options?: QueueListenerOptions) => Promise<Closeable>;
8
- export {};
37
+ export declare const registerQueueListener: (options?: QueueListenerOptions) => Promise<QueueListenerHandle>;
9
38
  //# sourceMappingURL=queueListener.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"queueListener.d.ts","sourceRoot":"","sources":["../src/queueListener.ts"],"names":[],"mappings":"AAMA,KAAK,SAAS,GAAG;IACf,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B,CAAA;AAED,KAAK,oBAAoB,GAAG;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AA4FD,eAAO,MAAM,qBAAqB,GAAU,UAAS,oBAAyB,KAAG,OAAO,CAAC,SAAS,CAsLjG,CAAA"}
1
+ {"version":3,"file":"queueListener.d.ts","sourceRoot":"","sources":["../src/queueListener.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiD,KAAK,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAMhG,MAAM,MAAM,wBAAwB,GAAG;IACrC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAC3B;IAAE,KAAK,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACxC;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,GAClB;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,GAC1E;IAAE,KAAK,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACpD;IAAE,KAAK,EAAE,QAAQ,CAAA;CAAE,CAAA;AAEvB,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IACpB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1B,SAAS,EAAE,MAAM,mBAAmB,CAAA;CACrC,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,UAAU,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IAChC,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,IAAI,CAAA;IACrD,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;IAChC,WAAW,CAAC,EAAE,wBAAwB,CAAA;IACtC,kBAAkB,CAAC,EAAE,kBAAkB,CAAA;CACxC,CAAA;AAuID,eAAO,MAAM,qBAAqB,GAAU,UAAS,oBAAyB,KAAG,OAAO,CAAC,mBAAmB,CA4T3G,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpcbase/worker",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"