@zintrust/workers 0.4.48 → 0.4.60

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.
@@ -0,0 +1,30 @@
1
+ <svg width="120" height="120" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="zt-g2d" x1="10" y1="50" x2="90" y2="50" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#22c55e" />
5
+ <stop offset="1" stop-color="#38bdf8" />
6
+ </linearGradient>
7
+ </defs>
8
+ <circle cx="50" cy="50" r="34" stroke="rgba(255,255,255,0.16)" stroke-width="4" />
9
+ <ellipse cx="50" cy="50" rx="40" ry="18" stroke="url(#zt-g2d)" stroke-width="4" />
10
+ <ellipse cx="50" cy="50" rx="18" ry="40" stroke="url(#zt-g2d)" stroke-width="4" opacity="0.75" />
11
+ <circle cx="50" cy="50" r="6" fill="url(#zt-g2d)" />
12
+ <path
13
+ d="M40 52C35 52 32 49 32 44C32 39 35 36 40 36H48"
14
+ stroke="white"
15
+ stroke-width="6"
16
+ stroke-linecap="round"
17
+ />
18
+ <path
19
+ d="M60 48C65 48 68 51 68 56C68 61 65 64 60 64H52"
20
+ stroke="white"
21
+ stroke-width="6"
22
+ stroke-linecap="round"
23
+ />
24
+ <path
25
+ d="M44 50H56"
26
+ stroke="rgba(255,255,255,0.22)"
27
+ stroke-width="6"
28
+ stroke-linecap="round"
29
+ />
30
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/workers",
3
- "version": "0.4.48",
3
+ "version": "0.4.60",
4
4
  "description": "Worker orchestration and background job management for ZinTrust.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "node": ">=20.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@zintrust/core": "^0.4.48",
43
+ "@zintrust/core": ">=0.4.0 <0.5.0",
44
44
  "@zintrust/queue-monitor": "*",
45
45
  "@zintrust/queue-redis": "*"
46
46
  },
@@ -9,6 +9,7 @@ import {
9
9
  Cloudflare,
10
10
  createRedisConnection,
11
11
  databaseConfig,
12
+ DatabaseConnectionRegistry,
12
13
  Env,
13
14
  ErrorFactory,
14
15
  generateUuid,
@@ -46,6 +47,7 @@ import { WorkerMetrics } from './WorkerMetrics';
46
47
  import { WorkerRegistry, type WorkerInstance as RegistryWorkerInstance } from './WorkerRegistry';
47
48
  import { WorkerVersioning } from './WorkerVersioning';
48
49
  import { keyPrefix } from './config/workerConfig';
50
+ import { recordQueueMonitorJob } from './queueMonitorHistory';
49
51
  import {
50
52
  DbWorkerStore,
51
53
  InMemoryWorkerStore,
@@ -108,7 +110,7 @@ const resolveRuntimeBridgeUrl = (specifier: string): string | null => {
108
110
  const filePath = path.join(dir, `${safeName}.bridge.mjs`);
109
111
  const exportLines = Object.keys(bridgeModule)
110
112
  .filter((key) => key !== 'default' && isValidBridgeExportName(key))
111
- .sort()
113
+ .sort((a, b) => a.localeCompare(b))
112
114
  .map((key) => `export const ${key} = bridge[${JSON.stringify(key)}];`);
113
115
 
114
116
  const code = [
@@ -2226,20 +2228,27 @@ const resolvePersistenceConfig = (
2226
2228
  };
2227
2229
 
2228
2230
  const resolveDbClientFromEnv = async (connectionName = 'default'): Promise<IDatabase> => {
2229
- const connect = async (): Promise<IDatabase> =>
2230
- await useEnsureDbConnected(undefined, connectionName);
2231
-
2232
- try {
2233
- return await connect();
2234
- } catch (error) {
2235
- Logger.error('Worker persistence failed to resolve database connection', error);
2231
+ // Eagerly populate the registry when the requested connection is not yet
2232
+ // registered. On both the Cloudflare Workers runtime and Node, the registry
2233
+ // may be empty when called from the workers-persistence path because
2234
+ // registerDatabasesFromRuntimeConfig has not yet run for this connection. The
2235
+ // old pattern of connecting first (which always fails) then registering as a
2236
+ // fallback produced spurious [DEBUG] noise on every fresh start.
2237
+ if (DatabaseConnectionRegistry.get(connectionName) === undefined) {
2238
+ try {
2239
+ registerDatabasesFromRuntimeConfig(databaseConfig);
2240
+ } catch (registrationError) {
2241
+ Logger.warn(
2242
+ `[WorkerPersistence] Runtime database registration failed for connection '${connectionName}'`,
2243
+ registrationError
2244
+ );
2245
+ }
2236
2246
  }
2237
2247
 
2238
2248
  try {
2239
- registerDatabasesFromRuntimeConfig(databaseConfig);
2240
- return await connect();
2249
+ return await useEnsureDbConnected(undefined, connectionName);
2241
2250
  } catch (error) {
2242
- Logger.error('Worker persistence failed after registering runtime databases', error);
2251
+ Logger.error('Worker persistence failed to resolve database connection', error);
2243
2252
  throw ErrorFactory.createConfigError(
2244
2253
  `Worker persistence requires a database client. Register connection '${connectionName}' or pass infrastructure.persistence.client.`
2245
2254
  );
@@ -2677,12 +2686,18 @@ const initializeDatacenter = (config: WorkerFactoryConfig): void => {
2677
2686
  const setupWorkerEventListeners = (
2678
2687
  worker: Worker,
2679
2688
  workerName: string,
2689
+ queueName: string,
2680
2690
  workerVersion: string,
2681
2691
  features?: WorkerFactoryConfig['features']
2682
2692
  ): void => {
2683
2693
  worker.on('completed', (job: Job) => {
2684
2694
  try {
2685
2695
  Logger.debug(`Job completed: ${workerName}`, { jobId: job.id });
2696
+ void recordQueueMonitorJob({
2697
+ queueName,
2698
+ status: 'completed',
2699
+ job,
2700
+ });
2686
2701
 
2687
2702
  if (features?.observability === true) {
2688
2703
  Observability.incrementCounter('worker.jobs.completed', 1, {
@@ -2699,6 +2714,14 @@ const setupWorkerEventListeners = (
2699
2714
  worker.on('failed', (job: Job | undefined, error: Error) => {
2700
2715
  try {
2701
2716
  Logger.error(`Job failed: ${workerName}`, { error, jobId: job?.id }, 'workers');
2717
+ if (job) {
2718
+ void recordQueueMonitorJob({
2719
+ queueName,
2720
+ status: 'failed',
2721
+ job,
2722
+ error,
2723
+ });
2724
+ }
2702
2725
 
2703
2726
  if (features?.observability === true) {
2704
2727
  Observability.incrementCounter('worker.jobs.failed', 1, {
@@ -2892,7 +2915,7 @@ export const WorkerFactory = Object.freeze({
2892
2915
  const resolvedOptions = resolveWorkerOptions(config, autoStart);
2893
2916
  const worker = new Worker(queueName, enhancedProcessor, resolvedOptions);
2894
2917
 
2895
- setupWorkerEventListeners(worker, name, workerVersion, features);
2918
+ setupWorkerEventListeners(worker, name, queueName, workerVersion, features);
2896
2919
 
2897
2920
  // Update status to "starting"
2898
2921
  await store.update(name, {
@@ -1,6 +1,7 @@
1
1
  import type { BullMQPayload, QueueMessage } from '@zintrust/core';
2
2
  import * as Core from '@zintrust/core';
3
3
  import { Env, Logger, Queue } from '@zintrust/core';
4
+ import { recordQueueMonitorJob } from './queueMonitorHistory';
4
5
 
5
6
  type QueueApi = Readonly<{
6
7
  enqueue: (queue: string, payload: BullMQPayload, driverName?: string) => Promise<string>;
@@ -320,6 +321,19 @@ const onProcessSuccess = async <TPayload>(input: {
320
321
  }): Promise<boolean> => {
321
322
  await TypedQueue.ack(input.queueName, input.message.id, input.driverName);
322
323
 
324
+ await recordQueueMonitorJob({
325
+ queueName: input.queueName,
326
+ status: 'completed',
327
+ job: {
328
+ id: input.message.id,
329
+ name: `${input.queueName}-job`,
330
+ data: input.message.payload,
331
+ attemptsMade: getAttemptsFromMessage(input.message),
332
+ processedOn: input.startedAtMs,
333
+ finishedOn: Date.now(),
334
+ },
335
+ });
336
+
323
337
  if (typeof input.trackerApi.completed === 'function') {
324
338
  await input.trackerApi.completed({
325
339
  queueName: input.queueName,
@@ -386,6 +400,22 @@ const onProcessFailure = async <TPayload>(input: {
386
400
  }
387
401
 
388
402
  await TypedQueue.ack(input.queueName, input.message.id, input.driverName);
403
+
404
+ await recordQueueMonitorJob({
405
+ queueName: input.queueName,
406
+ status: 'failed',
407
+ job: {
408
+ id: input.message.id,
409
+ name: `${input.queueName}-job`,
410
+ data: input.message.payload,
411
+ attemptsMade: nextAttempts,
412
+ failedReason: failure.message,
413
+ processedOn: Date.now(),
414
+ finishedOn: Date.now(),
415
+ },
416
+ error: failure,
417
+ });
418
+
389
419
  await removeHeartbeatIfSupported(input.queueName, input.message.id);
390
420
 
391
421
  if (typeof input.trackerApi.failed === 'function') {
@@ -591,6 +591,16 @@ async function getRedisQueueData(): Promise<QueueData> {
591
591
  }
592
592
 
593
593
  const monitor = QueueMonitor.create({
594
+ knownQueues: async () => {
595
+ const records = await WorkerFactory.listPersistedRecords();
596
+ return Array.from(
597
+ new Set(
598
+ records
599
+ .map((record) => record.queueName)
600
+ .filter((queueName) => typeof queueName === 'string')
601
+ )
602
+ ).sort((left, right) => left.localeCompare(right));
603
+ },
594
604
  redis: {
595
605
  host: redisConfig.host || 'localhost',
596
606
  port: redisConfig.port || 6379,
@@ -0,0 +1,114 @@
1
+ import { isNonEmptyString, isObject, Logger, queueConfig } from '@zintrust/core';
2
+
3
+ type QueueMonitorStatus = 'completed' | 'failed';
4
+
5
+ type QueueMonitorJobLike = {
6
+ id?: string;
7
+ name?: string;
8
+ data?: unknown;
9
+ attemptsMade?: number;
10
+ failedReason?: string;
11
+ processedOn?: number;
12
+ finishedOn?: number;
13
+ };
14
+
15
+ type QueueMonitorMetrics = {
16
+ recordJob: (
17
+ queueName: string,
18
+ status: QueueMonitorStatus,
19
+ job: QueueMonitorJobLike,
20
+ error?: Error
21
+ ) => Promise<void>;
22
+ };
23
+
24
+ type QueueMonitorModule = {
25
+ createMetrics?: (config: {
26
+ host: string;
27
+ port: number;
28
+ password?: string;
29
+ db: number;
30
+ }) => QueueMonitorMetrics;
31
+ };
32
+
33
+ let queueMonitorMetricsPromise: Promise<QueueMonitorMetrics | null> | null = null;
34
+
35
+ const toFiniteInteger = (value: unknown, fallback: number): number => {
36
+ if (typeof value === 'number' && Number.isFinite(value)) {
37
+ return Math.floor(value);
38
+ }
39
+
40
+ if (typeof value === 'string' && value.trim() !== '') {
41
+ const parsed = Number(value);
42
+ if (Number.isFinite(parsed)) {
43
+ return Math.floor(parsed);
44
+ }
45
+ }
46
+
47
+ return fallback;
48
+ };
49
+
50
+ const resolveQueueMonitorRedisConfig = (): {
51
+ host: string;
52
+ port: number;
53
+ password?: string;
54
+ db: number;
55
+ } | null => {
56
+ const redisConfig = queueConfig?.drivers?.redis;
57
+
58
+ if (!isObject(redisConfig) || redisConfig['driver'] !== 'redis') {
59
+ return null;
60
+ }
61
+
62
+ const host = isNonEmptyString(redisConfig['host']) ? redisConfig['host'].trim() : '127.0.0.1';
63
+ const port = toFiniteInteger(redisConfig['port'], 6379);
64
+ const db = toFiniteInteger(redisConfig['database'], 0);
65
+ const password = isNonEmptyString(redisConfig['password']) ? redisConfig['password'] : undefined;
66
+
67
+ return { host, port, password, db };
68
+ };
69
+
70
+ const loadQueueMonitorMetrics = async (): Promise<QueueMonitorMetrics | null> => {
71
+ const redisConfig = resolveQueueMonitorRedisConfig();
72
+ if (redisConfig === null) {
73
+ return null;
74
+ }
75
+
76
+ try {
77
+ const module = (await import('@zintrust/queue-monitor')) as QueueMonitorModule;
78
+ if (typeof module.createMetrics !== 'function') {
79
+ return null;
80
+ }
81
+
82
+ return module.createMetrics(redisConfig);
83
+ } catch (error) {
84
+ Logger.debug('Queue monitor metrics are unavailable for worker history recording', error);
85
+ return null;
86
+ }
87
+ };
88
+
89
+ const getQueueMonitorMetrics = async (): Promise<QueueMonitorMetrics | null> => {
90
+ queueMonitorMetricsPromise ??= loadQueueMonitorMetrics();
91
+ return queueMonitorMetricsPromise;
92
+ };
93
+
94
+ export const recordQueueMonitorJob = async (input: {
95
+ queueName: string;
96
+ status: QueueMonitorStatus;
97
+ job: QueueMonitorJobLike;
98
+ error?: Error;
99
+ }): Promise<void> => {
100
+ const metrics = await getQueueMonitorMetrics();
101
+ if (metrics === null) {
102
+ return;
103
+ }
104
+
105
+ try {
106
+ await metrics.recordJob(input.queueName, input.status, input.job, input.error);
107
+ } catch (error) {
108
+ Logger.debug('Queue monitor history write failed', {
109
+ queueName: input.queueName,
110
+ status: input.status,
111
+ error,
112
+ });
113
+ }
114
+ };
@@ -1,4 +1,43 @@
1
1
  declare module '@zintrust/queue-monitor' {
2
+ export type JobStatus = 'completed' | 'failed';
3
+
4
+ export type JobSummary = {
5
+ id: string | undefined;
6
+ name: string;
7
+ queue?: string;
8
+ data: unknown;
9
+ attempts: number;
10
+ status?: string;
11
+ failedReason?: string;
12
+ timestamp: number;
13
+ processedOn?: number;
14
+ finishedOn?: number;
15
+ };
16
+
17
+ export type Metrics = {
18
+ recordJob: (
19
+ queue: string,
20
+ status: JobStatus,
21
+ job: {
22
+ id?: string;
23
+ name?: string;
24
+ data?: unknown;
25
+ attemptsMade?: number;
26
+ failedReason?: string;
27
+ processedOn?: number;
28
+ finishedOn?: number;
29
+ },
30
+ error?: Error
31
+ ) => Promise<void>;
32
+ getStats: (
33
+ queue: string,
34
+ minutes?: number
35
+ ) => Promise<Array<{ time: string; completed: number; failed: number }>>;
36
+ getRecentJobs: (queue: string) => Promise<JobSummary[]>;
37
+ getFailedJobs: (queue: string) => Promise<JobSummary[]>;
38
+ close: () => Promise<void>;
39
+ };
40
+
2
41
  export type QueueCounts = {
3
42
  waiting: number;
4
43
  active: number;
@@ -24,6 +63,9 @@ declare module '@zintrust/queue-monitor' {
24
63
  autoRefresh?: boolean;
25
64
  refreshIntervalMs?: number;
26
65
  redis?: Record<string, unknown>;
66
+ knownQueues?:
67
+ | ReadonlyArray<string>
68
+ | (() => Promise<ReadonlyArray<string>> | ReadonlyArray<string>);
27
69
  };
28
70
 
29
71
  export type QueueMonitorApi = {
@@ -31,6 +73,13 @@ declare module '@zintrust/queue-monitor' {
31
73
  getSnapshot: () => Promise<QueueMonitorSnapshot>;
32
74
  };
33
75
 
76
+ export const createMetrics: (config: {
77
+ host: string;
78
+ port: number;
79
+ password?: string;
80
+ db: number;
81
+ }) => Metrics;
82
+
34
83
  export const QueueMonitor: Readonly<{
35
84
  create: (config: QueueMonitorConfig) => QueueMonitorApi;
36
85
  }>;