@rvoh/psychic-workers 2.3.0 → 2.4.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.
@@ -7,6 +7,7 @@ import ActivatingBackgroundWorkersWithoutDefaultWorkerConnection from '../error/
7
7
  import ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection from '../error/background/ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection.js';
8
8
  import DefaultBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection from '../error/background/DefaultBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection.js';
9
9
  import NamedBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection from '../error/background/NamedBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection.js';
10
+ import NoClassForSpecifiedGlobalName from '../error/background/NoClassForSpecifiedGlobalName.js';
10
11
  import NoQueueForSpecifiedQueueName from '../error/background/NoQueueForSpecifiedQueueName.js';
11
12
  import NoQueueForSpecifiedWorkstream from '../error/background/NoQueueForSpecifiedWorkstream.js';
12
13
  import EnvInternal from '../helpers/EnvInternal.js';
@@ -128,10 +129,66 @@ export class Background {
128
129
  get workers() {
129
130
  return [...this._workers];
130
131
  }
132
+ /**
133
+ * @internal
134
+ *
135
+ * the maximum number of milliseconds graceful shutdown is allowed
136
+ * to run before the process exits anyway, so that a hung hook,
137
+ * db close, or worker close can never keep a dying process alive
138
+ */
139
+ static SHUTDOWN_TIMEOUT_MS = 15000;
140
+ /**
141
+ * @internal
142
+ *
143
+ * set once a fatal error has begun graceful cleanup, so that a
144
+ * second fatal error during cleanup exits immediately instead of
145
+ * attempting cleanup again
146
+ */
147
+ fatalErrorShutdownBegun = false;
131
148
  async shutdownAndExit() {
132
- await this.shutdown();
149
+ let exitCode = 0;
150
+ try {
151
+ await this.shutdownWithTimeout();
152
+ }
153
+ catch (error) {
154
+ PsychicApp.logWithLevel('error', '[psychic-workers] error during graceful shutdown:', error);
155
+ exitCode = 1;
156
+ }
133
157
  // https://docs.bullmq.io/guide/going-to-production#gracefully-shut-down-workers
134
- process.exit(0);
158
+ process.exit(exitCode);
159
+ }
160
+ /**
161
+ * @internal
162
+ *
163
+ * after an uncaught exception or unhandled rejection, attempt
164
+ * best-effort graceful cleanup (bounded by SHUTDOWN_TIMEOUT_MS),
165
+ * then exit nonzero so orchestrators know to restart the process
166
+ */
167
+ async shutdownAndExitAfterFatalError() {
168
+ if (!this.fatalErrorShutdownBegun) {
169
+ this.fatalErrorShutdownBegun = true;
170
+ try {
171
+ await this.shutdownWithTimeout();
172
+ }
173
+ catch (error) {
174
+ PsychicApp.logWithLevel('error', '[psychic-workers] error during graceful shutdown after fatal error:', error);
175
+ }
176
+ }
177
+ process.exit(1);
178
+ }
179
+ /**
180
+ * @internal
181
+ *
182
+ * runs {@link Background.shutdown}, rejecting if it has not settled
183
+ * within SHUTDOWN_TIMEOUT_MS
184
+ */
185
+ async shutdownWithTimeout() {
186
+ await Promise.race([
187
+ this.shutdown(),
188
+ new Promise((_, reject) => {
189
+ setTimeout(() => reject(new Error(`[psychic-workers] graceful shutdown timed out after ${Background.SHUTDOWN_TIMEOUT_MS}ms`)), Background.SHUTDOWN_TIMEOUT_MS).unref();
190
+ }),
191
+ ]);
135
192
  }
136
193
  /**
137
194
  * Shuts down workers, closes all redis connections
@@ -153,7 +210,13 @@ export class Background {
153
210
  if (!EnvInternal.isTest)
154
211
  PsychicApp.log(`[psychic-workers] closeAllRedisConnections`);
155
212
  for (const worker of this.workers) {
156
- await worker.close();
213
+ try {
214
+ await worker.close();
215
+ }
216
+ catch (error) {
217
+ if (!EnvInternal.isTest)
218
+ PsychicApp.logWithLevel('error', `[psychic-workers] error closing worker:`, error);
219
+ }
157
220
  }
158
221
  for (const connection of this.redisConnections) {
159
222
  try {
@@ -379,24 +442,22 @@ export class Background {
379
442
  */
380
443
  work() {
381
444
  process.on('uncaughtException', (error) => {
382
- PsychicApp.log('[psychic-workers] uncaughtException:', error);
445
+ PsychicApp.logWithLevel('error', '[psychic-workers] uncaughtException:', error);
446
+ void this.shutdownAndExitAfterFatalError();
383
447
  });
384
- process.on('unhandledRejection', (error) => {
385
- PsychicApp.log('[psychic-workers] unhandledRejection:', error);
448
+ process.on('unhandledRejection', (reason) => {
449
+ PsychicApp.logWithLevel('error', '[psychic-workers] unhandledRejection:', reason);
450
+ void this.shutdownAndExitAfterFatalError();
386
451
  });
387
452
  process.on('SIGTERM', () => {
388
453
  if (!EnvInternal.isTest)
389
454
  PsychicApp.log('[psychic-workers] handle SIGTERM');
390
- void this.shutdownAndExit()
391
- .then(() => { })
392
- .catch(() => { });
455
+ void this.shutdownAndExit();
393
456
  });
394
457
  process.on('SIGINT', () => {
395
458
  if (!EnvInternal.isTest)
396
459
  PsychicApp.log('[psychic-workers] handle SIGINT');
397
- void this.shutdownAndExit()
398
- .then(() => { })
399
- .catch(() => { });
460
+ void this.shutdownAndExit();
400
461
  });
401
462
  this.connect({ activateWorkers: true });
402
463
  }
@@ -617,22 +678,23 @@ export class Background {
617
678
  objectClass = PsychicApp.lookupClassByGlobalName(globalName);
618
679
  }
619
680
  if (!objectClass)
620
- return;
681
+ throw new NoClassForSpecifiedGlobalName(globalName);
621
682
  // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
622
683
  await objectClass[method](...args, job);
623
684
  break;
624
- case 'BackgroundJobQueueModelInstanceJob':
685
+ case 'BackgroundJobQueueModelInstanceJob': {
625
686
  if (globalName) {
626
687
  dreamClass = PsychicApp.lookupClassByGlobalName(globalName);
627
688
  }
628
- if (dreamClass) {
629
- const modelInstance = await dreamClass.connection('primary').find(id);
630
- if (!modelInstance)
631
- return;
632
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
633
- await modelInstance[method](...args, job);
634
- }
689
+ if (!dreamClass)
690
+ throw new NoClassForSpecifiedGlobalName(globalName);
691
+ const modelInstance = await dreamClass.connection('primary').find(id);
692
+ if (!modelInstance)
693
+ return;
694
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
695
+ await modelInstance[method](...args, job);
635
696
  break;
697
+ }
636
698
  }
637
699
  }
638
700
  }
@@ -0,0 +1,15 @@
1
+ export default class NoClassForSpecifiedGlobalName extends Error {
2
+ globalName;
3
+ constructor(globalName) {
4
+ super();
5
+ this.globalName = globalName;
6
+ }
7
+ get message() {
8
+ return `Error processing background job
9
+ No class found for globalName "${this.globalName}"
10
+
11
+ This usually means the class was renamed or removed after this job
12
+ (or its repeating job scheduler) was enqueued.
13
+ `;
14
+ }
15
+ }
@@ -1,2 +1,3 @@
1
+ export { default as NoClassForSpecifiedGlobalName } from '../error/background/NoClassForSpecifiedGlobalName.js';
1
2
  export { default as NoQueueForSpecifiedQueueName } from '../error/background/NoQueueForSpecifiedQueueName.js';
2
3
  export { default as NoQueueForSpecifiedWorkstream } from '../error/background/NoQueueForSpecifiedWorkstream.js';
@@ -7,6 +7,7 @@ import ActivatingBackgroundWorkersWithoutDefaultWorkerConnection from '../error/
7
7
  import ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection from '../error/background/ActivatingNamedQueueBackgroundWorkersWithoutWorkerConnection.js';
8
8
  import DefaultBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection from '../error/background/DefaultBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection.js';
9
9
  import NamedBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection from '../error/background/NamedBullMQNativeOptionsMissingQueueConnectionAndDefaultQueueConnection.js';
10
+ import NoClassForSpecifiedGlobalName from '../error/background/NoClassForSpecifiedGlobalName.js';
10
11
  import NoQueueForSpecifiedQueueName from '../error/background/NoQueueForSpecifiedQueueName.js';
11
12
  import NoQueueForSpecifiedWorkstream from '../error/background/NoQueueForSpecifiedWorkstream.js';
12
13
  import EnvInternal from '../helpers/EnvInternal.js';
@@ -128,10 +129,66 @@ export class Background {
128
129
  get workers() {
129
130
  return [...this._workers];
130
131
  }
132
+ /**
133
+ * @internal
134
+ *
135
+ * the maximum number of milliseconds graceful shutdown is allowed
136
+ * to run before the process exits anyway, so that a hung hook,
137
+ * db close, or worker close can never keep a dying process alive
138
+ */
139
+ static SHUTDOWN_TIMEOUT_MS = 15000;
140
+ /**
141
+ * @internal
142
+ *
143
+ * set once a fatal error has begun graceful cleanup, so that a
144
+ * second fatal error during cleanup exits immediately instead of
145
+ * attempting cleanup again
146
+ */
147
+ fatalErrorShutdownBegun = false;
131
148
  async shutdownAndExit() {
132
- await this.shutdown();
149
+ let exitCode = 0;
150
+ try {
151
+ await this.shutdownWithTimeout();
152
+ }
153
+ catch (error) {
154
+ PsychicApp.logWithLevel('error', '[psychic-workers] error during graceful shutdown:', error);
155
+ exitCode = 1;
156
+ }
133
157
  // https://docs.bullmq.io/guide/going-to-production#gracefully-shut-down-workers
134
- process.exit(0);
158
+ process.exit(exitCode);
159
+ }
160
+ /**
161
+ * @internal
162
+ *
163
+ * after an uncaught exception or unhandled rejection, attempt
164
+ * best-effort graceful cleanup (bounded by SHUTDOWN_TIMEOUT_MS),
165
+ * then exit nonzero so orchestrators know to restart the process
166
+ */
167
+ async shutdownAndExitAfterFatalError() {
168
+ if (!this.fatalErrorShutdownBegun) {
169
+ this.fatalErrorShutdownBegun = true;
170
+ try {
171
+ await this.shutdownWithTimeout();
172
+ }
173
+ catch (error) {
174
+ PsychicApp.logWithLevel('error', '[psychic-workers] error during graceful shutdown after fatal error:', error);
175
+ }
176
+ }
177
+ process.exit(1);
178
+ }
179
+ /**
180
+ * @internal
181
+ *
182
+ * runs {@link Background.shutdown}, rejecting if it has not settled
183
+ * within SHUTDOWN_TIMEOUT_MS
184
+ */
185
+ async shutdownWithTimeout() {
186
+ await Promise.race([
187
+ this.shutdown(),
188
+ new Promise((_, reject) => {
189
+ setTimeout(() => reject(new Error(`[psychic-workers] graceful shutdown timed out after ${Background.SHUTDOWN_TIMEOUT_MS}ms`)), Background.SHUTDOWN_TIMEOUT_MS).unref();
190
+ }),
191
+ ]);
135
192
  }
136
193
  /**
137
194
  * Shuts down workers, closes all redis connections
@@ -153,7 +210,13 @@ export class Background {
153
210
  if (!EnvInternal.isTest)
154
211
  PsychicApp.log(`[psychic-workers] closeAllRedisConnections`);
155
212
  for (const worker of this.workers) {
156
- await worker.close();
213
+ try {
214
+ await worker.close();
215
+ }
216
+ catch (error) {
217
+ if (!EnvInternal.isTest)
218
+ PsychicApp.logWithLevel('error', `[psychic-workers] error closing worker:`, error);
219
+ }
157
220
  }
158
221
  for (const connection of this.redisConnections) {
159
222
  try {
@@ -379,24 +442,22 @@ export class Background {
379
442
  */
380
443
  work() {
381
444
  process.on('uncaughtException', (error) => {
382
- PsychicApp.log('[psychic-workers] uncaughtException:', error);
445
+ PsychicApp.logWithLevel('error', '[psychic-workers] uncaughtException:', error);
446
+ void this.shutdownAndExitAfterFatalError();
383
447
  });
384
- process.on('unhandledRejection', (error) => {
385
- PsychicApp.log('[psychic-workers] unhandledRejection:', error);
448
+ process.on('unhandledRejection', (reason) => {
449
+ PsychicApp.logWithLevel('error', '[psychic-workers] unhandledRejection:', reason);
450
+ void this.shutdownAndExitAfterFatalError();
386
451
  });
387
452
  process.on('SIGTERM', () => {
388
453
  if (!EnvInternal.isTest)
389
454
  PsychicApp.log('[psychic-workers] handle SIGTERM');
390
- void this.shutdownAndExit()
391
- .then(() => { })
392
- .catch(() => { });
455
+ void this.shutdownAndExit();
393
456
  });
394
457
  process.on('SIGINT', () => {
395
458
  if (!EnvInternal.isTest)
396
459
  PsychicApp.log('[psychic-workers] handle SIGINT');
397
- void this.shutdownAndExit()
398
- .then(() => { })
399
- .catch(() => { });
460
+ void this.shutdownAndExit();
400
461
  });
401
462
  this.connect({ activateWorkers: true });
402
463
  }
@@ -617,22 +678,23 @@ export class Background {
617
678
  objectClass = PsychicApp.lookupClassByGlobalName(globalName);
618
679
  }
619
680
  if (!objectClass)
620
- return;
681
+ throw new NoClassForSpecifiedGlobalName(globalName);
621
682
  // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
622
683
  await objectClass[method](...args, job);
623
684
  break;
624
- case 'BackgroundJobQueueModelInstanceJob':
685
+ case 'BackgroundJobQueueModelInstanceJob': {
625
686
  if (globalName) {
626
687
  dreamClass = PsychicApp.lookupClassByGlobalName(globalName);
627
688
  }
628
- if (dreamClass) {
629
- const modelInstance = await dreamClass.connection('primary').find(id);
630
- if (!modelInstance)
631
- return;
632
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
633
- await modelInstance[method](...args, job);
634
- }
689
+ if (!dreamClass)
690
+ throw new NoClassForSpecifiedGlobalName(globalName);
691
+ const modelInstance = await dreamClass.connection('primary').find(id);
692
+ if (!modelInstance)
693
+ return;
694
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
695
+ await modelInstance[method](...args, job);
635
696
  break;
697
+ }
636
698
  }
637
699
  }
638
700
  }
@@ -0,0 +1,15 @@
1
+ export default class NoClassForSpecifiedGlobalName extends Error {
2
+ globalName;
3
+ constructor(globalName) {
4
+ super();
5
+ this.globalName = globalName;
6
+ }
7
+ get message() {
8
+ return `Error processing background job
9
+ No class found for globalName "${this.globalName}"
10
+
11
+ This usually means the class was renamed or removed after this job
12
+ (or its repeating job scheduler) was enqueued.
13
+ `;
14
+ }
15
+ }
@@ -1,2 +1,3 @@
1
+ export { default as NoClassForSpecifiedGlobalName } from '../error/background/NoClassForSpecifiedGlobalName.js';
1
2
  export { default as NoQueueForSpecifiedQueueName } from '../error/background/NoQueueForSpecifiedQueueName.js';
2
3
  export { default as NoQueueForSpecifiedWorkstream } from '../error/background/NoQueueForSpecifiedWorkstream.js';
@@ -88,7 +88,38 @@ export declare class Background {
88
88
  * Returns all the workers in your application
89
89
  */
90
90
  get workers(): Worker<any, any, string>[];
91
+ /**
92
+ * @internal
93
+ *
94
+ * the maximum number of milliseconds graceful shutdown is allowed
95
+ * to run before the process exits anyway, so that a hung hook,
96
+ * db close, or worker close can never keep a dying process alive
97
+ */
98
+ static readonly SHUTDOWN_TIMEOUT_MS = 15000;
99
+ /**
100
+ * @internal
101
+ *
102
+ * set once a fatal error has begun graceful cleanup, so that a
103
+ * second fatal error during cleanup exits immediately instead of
104
+ * attempting cleanup again
105
+ */
106
+ private fatalErrorShutdownBegun;
91
107
  private shutdownAndExit;
108
+ /**
109
+ * @internal
110
+ *
111
+ * after an uncaught exception or unhandled rejection, attempt
112
+ * best-effort graceful cleanup (bounded by SHUTDOWN_TIMEOUT_MS),
113
+ * then exit nonzero so orchestrators know to restart the process
114
+ */
115
+ private shutdownAndExitAfterFatalError;
116
+ /**
117
+ * @internal
118
+ *
119
+ * runs {@link Background.shutdown}, rejecting if it has not settled
120
+ * within SHUTDOWN_TIMEOUT_MS
121
+ */
122
+ private shutdownWithTimeout;
92
123
  /**
93
124
  * Shuts down workers, closes all redis connections
94
125
  */
@@ -0,0 +1,5 @@
1
+ export default class NoClassForSpecifiedGlobalName extends Error {
2
+ private globalName;
3
+ constructor(globalName: string | undefined);
4
+ get message(): string;
5
+ }
@@ -1,2 +1,3 @@
1
+ export { default as NoClassForSpecifiedGlobalName } from '../error/background/NoClassForSpecifiedGlobalName.js';
1
2
  export { default as NoQueueForSpecifiedQueueName } from '../error/background/NoQueueForSpecifiedQueueName.js';
2
3
  export { default as NoQueueForSpecifiedWorkstream } from '../error/background/NoQueueForSpecifiedWorkstream.js';
package/package.json CHANGED
@@ -2,8 +2,11 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic-workers",
4
4
  "description": "Background job system for Psychic applications",
5
- "version": "2.3.0",
5
+ "version": "2.4.0",
6
6
  "author": "RVO Health",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
7
10
  "repository": {
8
11
  "type": "git",
9
12
  "url": "git+https://github.com/rvohealth/psychic-workers.git"
@@ -41,8 +44,8 @@
41
44
  "psy": "NODE_ENV=${NODE_ENV:-test} pnpm psy:ts",
42
45
  "psy:js": "node ./dist/test-app/src/cli/index.js",
43
46
  "psy:ts": "tsx ./test-app/src/cli/index.ts",
44
- "build": "echo \"building cjs...\" && rm -rf dist && npx tsc -p ./tsconfig.cjs.build.json && echo \"building esm...\" && npx tsc -p ./tsconfig.esm.build.json",
45
- "build:test-app": "rm -rf dist && echo \"building test app to esm...\" && npx tsc -p ./tsconfig.esm.build.test-app.json && echo \"building test app to cjs...\" && npx tsc -p ./tsconfig.cjs.build.test-app.json",
47
+ "build": "echo \"building cjs...\" && rm -rf dist && tsc -p ./tsconfig.cjs.build.json && echo \"building esm...\" && tsc -p ./tsconfig.esm.build.json",
48
+ "build:test-app": "rm -rf dist && echo \"building test app to esm...\" && tsc -p ./tsconfig.esm.build.test-app.json && echo \"building test app to cjs...\" && tsc -p ./tsconfig.cjs.build.test-app.json",
46
49
  "uspec": "vitest --config ./spec/unit/vite.config.ts",
47
50
  "format": "pnpm prettier . --write",
48
51
  "lint": "pnpm eslint --no-warn-ignored \"src/**/*.ts\" && pnpm prettier . --check",
@@ -96,7 +99,7 @@
96
99
  "typescript-eslint": "^8.48.1",
97
100
  "vitest": "^4.1.0"
98
101
  },
99
- "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
102
+ "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
100
103
  "dependencies": {
101
104
  "commander": "^14.0.2",
102
105
  "dotenv": "^17.2.3",