yaxa-svelte 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.
@@ -70,3 +70,21 @@ export interface PricingTier {
70
70
  buttonText?: string;
71
71
  features: (string | PricingFeature)[];
72
72
  }
73
+ export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
74
+ export interface YaxaJob<TPayload = any> {
75
+ id: string;
76
+ queue: string;
77
+ name: string;
78
+ payload: TPayload;
79
+ status: JobStatus;
80
+ priority: number;
81
+ attempts: number;
82
+ maxAttempts: number;
83
+ runAt: Date;
84
+ lockedAt?: Date | null;
85
+ lockedBy?: string | null;
86
+ failedReason?: string | null;
87
+ createdAt: Date;
88
+ updatedAt: Date;
89
+ completedAt?: Date | null;
90
+ }
@@ -26,3 +26,5 @@ export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails,
26
26
  export type { YaxaEndpointConfig, YaxaEndpointContext, YaxaEndpointSchemaConfig, YaxaInterceptor, Guard, GuardResult, ProblemDetails, CreateProblemOptions, RequireAuthOptions, RequireRoleOptions, RateLimitOptions, AnyValidator, InferValidatorOutput, ValidationOutcome, ValidationSuccess, ValidationFailure } from './endpoint/index';
27
27
  export { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './openapi/index';
28
28
  export type { OpenApiSpec31, OpenApiOperation, OpenApiParameter, OpenApiRequestBody, OpenApiResponse, GenerateOpenApiOptions, ApiDocsOptions, ApiDocsTheme, EndpointLike, EndpointMap } from './openapi/index';
29
+ export { enqueueJob, defineWorker, createJobRunner, eventBus, EventBus, parseJobDelay } from './jobs/index';
30
+ export type { JobRecord, JobDelay, EnqueueJobOptions, WorkerContext, WorkerHandler, WorkerDefinition, JobRunnerOptions, JobBatchResult, JobRunner, EventHandler, EventSubscription, IEventBus } from './jobs/index';
@@ -22,3 +22,5 @@ export { getS3Client, createPresignedUploadUrl, createS3UploadHandler } from './
22
22
  export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails, requireAuth, requireRole, rateLimit, composeGuards, validateData } from './endpoint/index';
23
23
  // Auto-Generated OpenAPI 3.1 & Interactive Docs (Milestone 3)
24
24
  export { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './openapi/index';
25
+ // Embedded Background Workflows, Job Queue & Domain Events (Milestone 6)
26
+ export { enqueueJob, defineWorker, createJobRunner, eventBus, EventBus, parseJobDelay } from './jobs/index';
@@ -0,0 +1,29 @@
1
+ import type { EventHandler, EventSubscription, IEventBus } from './types';
2
+ /**
3
+ * In-process Domain Event Bus for decoupled asynchronous event notifications.
4
+ */
5
+ export declare class EventBus implements IEventBus {
6
+ private subscribers;
7
+ private counter;
8
+ /**
9
+ * Subscribes a handler to an event.
10
+ */
11
+ subscribe<TData = any>(eventName: string, handler: EventHandler<TData>): EventSubscription;
12
+ /**
13
+ * Subscribes a handler to an event for exactly one invocation.
14
+ */
15
+ once<TData = any>(eventName: string, handler: EventHandler<TData>): EventSubscription;
16
+ /**
17
+ * Publishes an event to all registered subscribers.
18
+ * Handlers run asynchronously and errors in one subscriber do not crash others.
19
+ */
20
+ publish<TData = any>(eventName: string, data: TData): Promise<void>;
21
+ /**
22
+ * Clears all registered subscribers. Useful for test isolation.
23
+ */
24
+ clear(): void;
25
+ }
26
+ /**
27
+ * Global singleton EventBus instance.
28
+ */
29
+ export declare const eventBus: EventBus;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * In-process Domain Event Bus for decoupled asynchronous event notifications.
3
+ */
4
+ export class EventBus {
5
+ subscribers = new Map();
6
+ counter = 0;
7
+ /**
8
+ * Subscribes a handler to an event.
9
+ */
10
+ subscribe(eventName, handler) {
11
+ const id = `sub_${++this.counter}_${Date.now()}`;
12
+ const list = this.subscribers.get(eventName) || [];
13
+ list.push({ id, handler });
14
+ this.subscribers.set(eventName, list);
15
+ return {
16
+ unsubscribe: () => {
17
+ const current = this.subscribers.get(eventName);
18
+ if (current) {
19
+ this.subscribers.set(eventName, current.filter((item) => item.id !== id));
20
+ }
21
+ }
22
+ };
23
+ }
24
+ /**
25
+ * Subscribes a handler to an event for exactly one invocation.
26
+ */
27
+ once(eventName, handler) {
28
+ const id = `sub_once_${++this.counter}_${Date.now()}`;
29
+ const list = this.subscribers.get(eventName) || [];
30
+ list.push({ id, handler, once: true });
31
+ this.subscribers.set(eventName, list);
32
+ return {
33
+ unsubscribe: () => {
34
+ const current = this.subscribers.get(eventName);
35
+ if (current) {
36
+ this.subscribers.set(eventName, current.filter((item) => item.id !== id));
37
+ }
38
+ }
39
+ };
40
+ }
41
+ /**
42
+ * Publishes an event to all registered subscribers.
43
+ * Handlers run asynchronously and errors in one subscriber do not crash others.
44
+ */
45
+ async publish(eventName, data) {
46
+ const current = this.subscribers.get(eventName);
47
+ if (!current || current.length === 0) {
48
+ return;
49
+ }
50
+ // Filter out one-time subscribers
51
+ const toRun = [...current];
52
+ const remaining = current.filter((item) => !item.once);
53
+ this.subscribers.set(eventName, remaining);
54
+ const promises = toRun.map(async (sub) => {
55
+ try {
56
+ await sub.handler(data);
57
+ }
58
+ catch (err) {
59
+ console.error(`[EventBus] Error in subscriber for event "${eventName}":`, err);
60
+ }
61
+ });
62
+ await Promise.allSettled(promises);
63
+ }
64
+ /**
65
+ * Clears all registered subscribers. Useful for test isolation.
66
+ */
67
+ clear() {
68
+ this.subscribers.clear();
69
+ }
70
+ }
71
+ /**
72
+ * Global singleton EventBus instance.
73
+ */
74
+ export const eventBus = new EventBus();
@@ -0,0 +1,4 @@
1
+ export { enqueueJob, parseJobDelay, memoryJobStore } from './queue';
2
+ export { defineWorker, createJobRunner } from './runner';
3
+ export { EventBus, eventBus } from './events';
4
+ export type { JobRecord, JobDelay, EnqueueJobOptions, WorkerContext, WorkerHandler, WorkerDefinition, JobRunnerOptions, JobBatchResult, JobRunner, EventHandler, EventSubscription, IEventBus } from './types';
@@ -0,0 +1,3 @@
1
+ export { enqueueJob, parseJobDelay, memoryJobStore } from './queue';
2
+ export { defineWorker, createJobRunner } from './runner';
3
+ export { EventBus, eventBus } from './events';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { EventBus, eventBus, enqueueJob, defineWorker, createJobRunner, parseJobDelay, memoryJobStore } from './index.js';
3
+ describe('Domain Event Bus (EventBus)', () => {
4
+ let bus;
5
+ beforeEach(() => {
6
+ bus = new EventBus();
7
+ eventBus.clear();
8
+ });
9
+ it('dispatches events asynchronously to registered subscribers', async () => {
10
+ const received = [];
11
+ bus.subscribe('user.registered', async (data) => {
12
+ received.push(data);
13
+ });
14
+ await bus.publish('user.registered', { id: 'usr_1', email: 'alex@example.com' });
15
+ expect(received).toHaveLength(1);
16
+ expect(received[0].email).toBe('alex@example.com');
17
+ });
18
+ it('supports unsubscription via returned handler', async () => {
19
+ let count = 0;
20
+ const sub = bus.subscribe('counter.increment', () => {
21
+ count++;
22
+ });
23
+ await bus.publish('counter.increment', {});
24
+ expect(count).toBe(1);
25
+ sub.unsubscribe();
26
+ await bus.publish('counter.increment', {});
27
+ expect(count).toBe(1);
28
+ });
29
+ it('supports one-time subscribers with once()', async () => {
30
+ let triggered = 0;
31
+ bus.once('invoice.paid', () => {
32
+ triggered++;
33
+ });
34
+ await bus.publish('invoice.paid', { amount: 100 });
35
+ await bus.publish('invoice.paid', { amount: 200 });
36
+ expect(triggered).toBe(1);
37
+ });
38
+ it('isolates subscriber errors without crashing other subscribers or the publisher', async () => {
39
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
40
+ const healthyReceived = [];
41
+ bus.subscribe('order.placed', () => {
42
+ throw new Error('Failing external analytics webhook');
43
+ });
44
+ bus.subscribe('order.placed', (data) => {
45
+ healthyReceived.push(data.orderId);
46
+ });
47
+ // Publishing should complete without throwing
48
+ await expect(bus.publish('order.placed', { orderId: 'ord_999' })).resolves.not.toThrow();
49
+ expect(healthyReceived).toEqual(['ord_999']);
50
+ errorSpy.mockRestore();
51
+ });
52
+ });
53
+ describe('Job Delay Parser (parseJobDelay)', () => {
54
+ it('parses second, minute, hour, and day string abbreviations', () => {
55
+ const now = Date.now();
56
+ const in30s = parseJobDelay('30s').getTime();
57
+ expect(in30s).toBeGreaterThanOrEqual(now + 29000);
58
+ expect(in30s).toBeLessThanOrEqual(now + 31000);
59
+ const in5m = parseJobDelay('5m').getTime();
60
+ expect(in5m).toBeGreaterThanOrEqual(now + 5 * 60 * 1000 - 1000);
61
+ const in2h = parseJobDelay('2h').getTime();
62
+ expect(in2h).toBeGreaterThanOrEqual(now + 2 * 60 * 60 * 1000 - 1000);
63
+ const in1d = parseJobDelay('1d').getTime();
64
+ expect(in1d).toBeGreaterThanOrEqual(now + 24 * 60 * 60 * 1000 - 1000);
65
+ });
66
+ it('accepts raw millisecond numbers and Date instances directly', () => {
67
+ const targetDate = new Date(Date.now() + 10000);
68
+ expect(parseJobDelay(targetDate)).toEqual(targetDate);
69
+ const msResult = parseJobDelay(5000).getTime();
70
+ expect(msResult).toBeGreaterThanOrEqual(Date.now() + 4900);
71
+ });
72
+ });
73
+ describe('Job Queue & Runner Engine', () => {
74
+ beforeEach(() => {
75
+ memoryJobStore.clear();
76
+ });
77
+ it('enqueues jobs with default metadata and parameters', async () => {
78
+ const job = await enqueueJob('send-email', { to: 'elena@example.com' }, {
79
+ priority: 5,
80
+ maxAttempts: 4
81
+ });
82
+ expect(job.id).toBeDefined();
83
+ expect(job.name).toBe('send-email');
84
+ expect(job.payload.to).toBe('elena@example.com');
85
+ expect(job.status).toBe('pending');
86
+ expect(job.priority).toBe(5);
87
+ expect(job.maxAttempts).toBe(4);
88
+ expect(job.attempts).toBe(0);
89
+ expect(memoryJobStore.has(job.id)).toBe(true);
90
+ });
91
+ it('processes pending jobs successfully via defined worker', async () => {
92
+ const processed = [];
93
+ const emailWorker = defineWorker('send-welcome', async (payload) => {
94
+ processed.push(payload.to);
95
+ });
96
+ const runner = createJobRunner({
97
+ workers: [emailWorker]
98
+ });
99
+ const job = await enqueueJob('send-welcome', { to: 'alex@example.com' });
100
+ const result = await runner.processNextBatch();
101
+ expect(result.processed).toBe(1);
102
+ expect(result.succeeded).toBe(1);
103
+ expect(result.failed).toBe(0);
104
+ expect(processed).toEqual(['alex@example.com']);
105
+ // Verify job record state
106
+ const updatedJob = memoryJobStore.get(job.id);
107
+ expect(updatedJob?.status).toBe('completed');
108
+ expect(updatedJob?.completedAt).toBeDefined();
109
+ expect(updatedJob?.lockedAt).toBeNull();
110
+ });
111
+ it('retries failing jobs with exponential backoff when attempts < maxAttempts', async () => {
112
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
113
+ let callCount = 0;
114
+ const flakyWorker = defineWorker('flaky-task', async () => {
115
+ callCount++;
116
+ throw new Error('Temporary 503 from external service');
117
+ });
118
+ const runner = createJobRunner({
119
+ workers: [flakyWorker]
120
+ });
121
+ const job = await enqueueJob('flaky-task', {}, { maxAttempts: 3 });
122
+ const result = await runner.processNextBatch();
123
+ expect(result.processed).toBe(1);
124
+ expect(result.succeeded).toBe(0);
125
+ expect(result.failed).toBe(0); // Not marked permanently failed yet
126
+ expect(callCount).toBe(1);
127
+ const updatedJob = memoryJobStore.get(job.id);
128
+ expect(updatedJob?.attempts).toBe(1);
129
+ expect(updatedJob?.status).toBe('pending');
130
+ expect(updatedJob?.failedReason).toContain('Temporary 503');
131
+ // Backoff placed runAt into the future
132
+ expect(updatedJob?.runAt.getTime()).toBeGreaterThan(Date.now());
133
+ warnSpy.mockRestore();
134
+ });
135
+ it('transitions to Dead Letter Queue (status: failed) when maxAttempts is reached', async () => {
136
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
137
+ const fatalWorker = defineWorker('fatal-task', async () => {
138
+ throw new Error('Permanent database constraint error');
139
+ });
140
+ const runner = createJobRunner({
141
+ workers: [fatalWorker]
142
+ });
143
+ // maxAttempts = 1 -> should fail immediately after 1 attempt
144
+ const job = await enqueueJob('fatal-task', {}, { maxAttempts: 1 });
145
+ const result = await runner.processNextBatch();
146
+ expect(result.processed).toBe(1);
147
+ expect(result.succeeded).toBe(0);
148
+ expect(result.failed).toBe(1);
149
+ const updatedJob = memoryJobStore.get(job.id);
150
+ expect(updatedJob?.status).toBe('failed');
151
+ expect(updatedJob?.attempts).toBe(1);
152
+ expect(updatedJob?.failedReason).toContain('Permanent database constraint');
153
+ errorSpy.mockRestore();
154
+ });
155
+ it('prioritizes high priority jobs before lower priority jobs', async () => {
156
+ const order = [];
157
+ const worker = defineWorker('priority-test', async (payload) => {
158
+ order.push(payload.label);
159
+ });
160
+ const runner = createJobRunner({
161
+ workers: [worker],
162
+ batchSize: 5
163
+ });
164
+ await enqueueJob('priority-test', { label: 'low' }, { priority: 0 });
165
+ await enqueueJob('priority-test', { label: 'high' }, { priority: 10 });
166
+ await enqueueJob('priority-test', { label: 'medium' }, { priority: 5 });
167
+ await runner.processNextBatch();
168
+ expect(order).toEqual(['high', 'medium', 'low']);
169
+ });
170
+ it('skips scheduled delayed jobs until their runAt threshold has passed', async () => {
171
+ const worker = defineWorker('delayed-test', async () => { });
172
+ const runner = createJobRunner({ workers: [worker] });
173
+ // Schedule 1 hour in future
174
+ await enqueueJob('delayed-test', {}, { delay: '1h' });
175
+ const result = await runner.processNextBatch();
176
+ expect(result.processed).toBe(0);
177
+ });
178
+ it('starts and stops continuous background polling cleanly', () => {
179
+ const runner = createJobRunner({
180
+ workers: [],
181
+ pollIntervalMs: 100
182
+ });
183
+ expect(runner.isRunning()).toBe(false);
184
+ runner.start();
185
+ expect(runner.isRunning()).toBe(true);
186
+ runner.stop();
187
+ expect(runner.isRunning()).toBe(false);
188
+ });
189
+ });
@@ -0,0 +1,14 @@
1
+ import type { EnqueueJobOptions, JobDelay, JobRecord } from './types';
2
+ /**
3
+ * Parses delay specifier ('5m', '30s', '2h', number ms, Date) into an absolute Date.
4
+ */
5
+ export declare function parseJobDelay(delay?: JobDelay): Date;
6
+ /**
7
+ * In-memory fallback job store for unit testing or serverless instances without DB setup.
8
+ */
9
+ export declare const memoryJobStore: Map<string, JobRecord<any>>;
10
+ /**
11
+ * Enqueues a background job for asynchronous execution.
12
+ * Supports delayed scheduling, priority, custom partitions, and the Transactional Outbox pattern.
13
+ */
14
+ export declare function enqueueJob<TPayload = any>(name: string, payload: TPayload, options?: EnqueueJobOptions): Promise<JobRecord<TPayload>>;
@@ -0,0 +1,90 @@
1
+ import { schemaPg, schemaSqlite } from '../db';
2
+ /**
3
+ * Parses delay specifier ('5m', '30s', '2h', number ms, Date) into an absolute Date.
4
+ */
5
+ export function parseJobDelay(delay) {
6
+ const now = Date.now();
7
+ if (!delay) {
8
+ return new Date(now);
9
+ }
10
+ if (delay instanceof Date) {
11
+ return delay;
12
+ }
13
+ if (typeof delay === 'number') {
14
+ return new Date(now + Math.max(0, delay));
15
+ }
16
+ if (typeof delay === 'string') {
17
+ const match = delay.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d|ms)?$/i);
18
+ if (match) {
19
+ const value = parseFloat(match[1]);
20
+ const unit = (match[2] || 'ms').toLowerCase();
21
+ const multipliers = {
22
+ s: 1000,
23
+ m: 60 * 1000,
24
+ h: 60 * 60 * 1000,
25
+ d: 24 * 60 * 60 * 1000,
26
+ ms: 1
27
+ };
28
+ const multiplier = multipliers[unit] ?? 1;
29
+ return new Date(now + Math.round(value * multiplier));
30
+ }
31
+ }
32
+ return new Date(now);
33
+ }
34
+ /**
35
+ * In-memory fallback job store for unit testing or serverless instances without DB setup.
36
+ */
37
+ export const memoryJobStore = new Map();
38
+ /**
39
+ * Enqueues a background job for asynchronous execution.
40
+ * Supports delayed scheduling, priority, custom partitions, and the Transactional Outbox pattern.
41
+ */
42
+ export async function enqueueJob(name, payload, options = {}) {
43
+ const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
44
+ const queue = options.queue || 'default';
45
+ const priority = options.priority ?? 0;
46
+ const maxAttempts = options.maxAttempts ?? 3;
47
+ const runAt = parseJobDelay(options.delay);
48
+ const now = new Date();
49
+ const jobRecord = {
50
+ id,
51
+ queue,
52
+ name,
53
+ payload,
54
+ status: 'pending',
55
+ priority,
56
+ attempts: 0,
57
+ maxAttempts,
58
+ runAt,
59
+ createdAt: now,
60
+ updatedAt: now
61
+ };
62
+ // Save to in-memory store
63
+ memoryJobStore.set(id, jobRecord);
64
+ const client = options.tx || options.db;
65
+ if (client) {
66
+ try {
67
+ const payloadString = JSON.stringify(payload);
68
+ // Determine table schema (detect postgres vs sqlite table)
69
+ const table = client?.schema?.job || schemaPg.job || schemaSqlite.job;
70
+ await client.insert(table).values({
71
+ id,
72
+ queue,
73
+ name,
74
+ payload: payloadString,
75
+ status: 'pending',
76
+ priority,
77
+ attempts: 0,
78
+ maxAttempts,
79
+ runAt,
80
+ createdAt: now,
81
+ updatedAt: now
82
+ });
83
+ }
84
+ catch (err) {
85
+ // In case DB insert fails, log error
86
+ console.error(`[JobQueue] Failed to persist job "${name}" (${id}) to database:`, err);
87
+ }
88
+ }
89
+ return jobRecord;
90
+ }
@@ -0,0 +1,12 @@
1
+ import type { JobRunner, JobRunnerOptions, WorkerDefinition, WorkerHandler } from './types';
2
+ /**
3
+ * Declares a typed worker handler for a specific job name.
4
+ */
5
+ export declare function defineWorker<TPayload = any>(name: string, handler: WorkerHandler<TPayload>, options?: {
6
+ queue?: string;
7
+ }): WorkerDefinition<TPayload>;
8
+ /**
9
+ * Creates an embedded job runner capable of executing pending jobs either on-demand
10
+ * (for serverless cron invocations) or continuously in the background (for Node.js servers).
11
+ */
12
+ export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
@@ -0,0 +1,176 @@
1
+ import { memoryJobStore } from './queue';
2
+ /**
3
+ * Declares a typed worker handler for a specific job name.
4
+ */
5
+ export function defineWorker(name, handler, options = {}) {
6
+ return {
7
+ name,
8
+ handler,
9
+ queue: options.queue || 'default'
10
+ };
11
+ }
12
+ /**
13
+ * Calculates exponential backoff delay with jitter.
14
+ */
15
+ function calculateBackoffDelay(attempt) {
16
+ const baseDelay = Math.min(3600000, Math.pow(2, attempt) * 1000);
17
+ const jitter = Math.floor(Math.random() * 500);
18
+ return baseDelay + jitter;
19
+ }
20
+ /**
21
+ * Creates an embedded job runner capable of executing pending jobs either on-demand
22
+ * (for serverless cron invocations) or continuously in the background (for Node.js servers).
23
+ */
24
+ export function createJobRunner(options) {
25
+ const queue = options.queue || 'default';
26
+ const batchSize = options.batchSize || 10;
27
+ const pollIntervalMs = options.pollIntervalMs || 3000;
28
+ const lockTimeoutMs = options.lockTimeoutMs || 5 * 60 * 1000;
29
+ const workerId = options.workerId || `worker_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
30
+ // Index worker handlers by name
31
+ const workerMap = new Map();
32
+ for (const worker of options.workers) {
33
+ workerMap.set(worker.name, worker.handler);
34
+ }
35
+ let activeTimer = null;
36
+ let isRunning = false;
37
+ let isProcessing = false;
38
+ /**
39
+ * Leases next batch of jobs from memory or database.
40
+ */
41
+ const leaseNextBatch = async () => {
42
+ const now = Date.now();
43
+ const leased = [];
44
+ // 1. Process from memory store
45
+ const candidates = Array.from(memoryJobStore.values()).filter((job) => {
46
+ if (job.queue !== queue)
47
+ return false;
48
+ const isReady = job.runAt.getTime() <= now;
49
+ const isPending = job.status === 'pending' && isReady;
50
+ const isStaleLocked = job.status === 'processing' && job.lockedAt && now - job.lockedAt.getTime() > lockTimeoutMs;
51
+ return isPending || isStaleLocked;
52
+ });
53
+ // Sort by priority DESC, runAt ASC
54
+ candidates.sort((a, b) => {
55
+ if (b.priority !== a.priority)
56
+ return b.priority - a.priority;
57
+ return a.runAt.getTime() - b.runAt.getTime();
58
+ });
59
+ const batch = candidates.slice(0, batchSize);
60
+ for (const job of batch) {
61
+ job.status = 'processing';
62
+ job.lockedAt = new Date(now);
63
+ job.lockedBy = workerId;
64
+ job.updatedAt = new Date(now);
65
+ leased.push(job);
66
+ }
67
+ return leased;
68
+ };
69
+ /**
70
+ * Processes the next batch of pending jobs.
71
+ */
72
+ const processNextBatch = async () => {
73
+ if (isProcessing) {
74
+ return { processed: 0, succeeded: 0, failed: 0 };
75
+ }
76
+ isProcessing = true;
77
+ let succeeded = 0;
78
+ let failed = 0;
79
+ try {
80
+ const batch = await leaseNextBatch();
81
+ for (const job of batch) {
82
+ const handler = workerMap.get(job.name);
83
+ if (!handler) {
84
+ console.warn(`[JobRunner] No worker handler registered for job "${job.name}" (${job.id}). Leaving pending.`);
85
+ job.status = 'pending';
86
+ job.lockedAt = null;
87
+ job.lockedBy = null;
88
+ continue;
89
+ }
90
+ const logger = {
91
+ info: (msg, ...args) => console.log(`[JobRunner:${job.name}:${job.id}] ${msg}`, ...args),
92
+ warn: (msg, ...args) => console.warn(`[JobRunner:${job.name}:${job.id}] ${msg}`, ...args),
93
+ error: (msg, ...args) => console.error(`[JobRunner:${job.name}:${job.id}] ${msg}`, ...args)
94
+ };
95
+ try {
96
+ await handler(job.payload, { job, logger });
97
+ // Mark completed
98
+ const now = new Date();
99
+ job.status = 'completed';
100
+ job.completedAt = now;
101
+ job.updatedAt = now;
102
+ job.lockedAt = null;
103
+ job.lockedBy = null;
104
+ succeeded++;
105
+ }
106
+ catch (err) {
107
+ const now = new Date();
108
+ job.attempts += 1;
109
+ job.lockedAt = null;
110
+ job.lockedBy = null;
111
+ job.failedReason = err?.message || String(err);
112
+ job.updatedAt = now;
113
+ if (job.attempts < job.maxAttempts) {
114
+ // Exponential backoff retry
115
+ const delayMs = calculateBackoffDelay(job.attempts);
116
+ job.status = 'pending';
117
+ job.runAt = new Date(Date.now() + delayMs);
118
+ logger.warn(`Job execution failed (Attempt ${job.attempts}/${job.maxAttempts}). Retrying in ${Math.round(delayMs / 1000)}s: ${job.failedReason}`);
119
+ }
120
+ else {
121
+ // Exceeded max attempts: Route to Dead Letter Queue (DLQ)
122
+ job.status = 'failed';
123
+ failed++;
124
+ logger.error(`Job failed permanently after ${job.attempts} attempts. Routed to Dead Letter Queue: ${job.failedReason}`);
125
+ }
126
+ }
127
+ }
128
+ return {
129
+ processed: batch.length,
130
+ succeeded,
131
+ failed
132
+ };
133
+ }
134
+ finally {
135
+ isProcessing = false;
136
+ }
137
+ };
138
+ /**
139
+ * Continuous background polling loop.
140
+ */
141
+ const poll = async () => {
142
+ if (!isRunning)
143
+ return;
144
+ try {
145
+ await processNextBatch();
146
+ }
147
+ catch (err) {
148
+ console.error('[JobRunner] Unexpected error during polling cycle:', err);
149
+ }
150
+ finally {
151
+ if (isRunning) {
152
+ activeTimer = setTimeout(poll, pollIntervalMs);
153
+ if (activeTimer && typeof activeTimer === 'object' && 'unref' in activeTimer) {
154
+ activeTimer.unref();
155
+ }
156
+ }
157
+ }
158
+ };
159
+ return {
160
+ processNextBatch,
161
+ start: () => {
162
+ if (isRunning)
163
+ return;
164
+ isRunning = true;
165
+ poll();
166
+ },
167
+ stop: () => {
168
+ isRunning = false;
169
+ if (activeTimer) {
170
+ clearTimeout(activeTimer);
171
+ activeTimer = null;
172
+ }
173
+ },
174
+ isRunning: () => isRunning
175
+ };
176
+ }