@quave/agenda 1.0.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/LICENSE.md +25 -0
- package/README.md +380 -0
- package/dist/Job.d.ts +190 -0
- package/dist/Job.js +689 -0
- package/dist/Job.js.map +1 -0
- package/dist/JobProcessingQueue.d.ts +47 -0
- package/dist/JobProcessingQueue.js +107 -0
- package/dist/JobProcessingQueue.js.map +1 -0
- package/dist/JobProcessor.d.ts +72 -0
- package/dist/JobProcessor.js +512 -0
- package/dist/JobProcessor.js.map +1 -0
- package/dist/backends/index.d.ts +18 -0
- package/dist/backends/index.js +20 -0
- package/dist/backends/index.js.map +1 -0
- package/dist/cjs/Job.d.ts +190 -0
- package/dist/cjs/Job.js +696 -0
- package/dist/cjs/Job.js.map +1 -0
- package/dist/cjs/JobProcessingQueue.d.ts +47 -0
- package/dist/cjs/JobProcessingQueue.js +111 -0
- package/dist/cjs/JobProcessingQueue.js.map +1 -0
- package/dist/cjs/JobProcessor.d.ts +72 -0
- package/dist/cjs/JobProcessor.js +519 -0
- package/dist/cjs/JobProcessor.js.map +1 -0
- package/dist/cjs/backends/index.d.ts +17 -0
- package/dist/cjs/backends/index.js +20 -0
- package/dist/cjs/backends/index.js.map +1 -0
- package/dist/cjs/decorators/Define.d.ts +33 -0
- package/dist/cjs/decorators/Define.js +47 -0
- package/dist/cjs/decorators/Define.js.map +1 -0
- package/dist/cjs/decorators/Every.d.ts +43 -0
- package/dist/cjs/decorators/Every.js +58 -0
- package/dist/cjs/decorators/Every.js.map +1 -0
- package/dist/cjs/decorators/JobsController.d.ts +43 -0
- package/dist/cjs/decorators/JobsController.js +40 -0
- package/dist/cjs/decorators/JobsController.js.map +1 -0
- package/dist/cjs/decorators/index.d.ts +41 -0
- package/dist/cjs/decorators/index.js +57 -0
- package/dist/cjs/decorators/index.js.map +1 -0
- package/dist/cjs/decorators/metadata.d.ts +50 -0
- package/dist/cjs/decorators/metadata.js +41 -0
- package/dist/cjs/decorators/metadata.js.map +1 -0
- package/dist/cjs/decorators/register.d.ts +52 -0
- package/dist/cjs/decorators/register.js +145 -0
- package/dist/cjs/decorators/register.js.map +1 -0
- package/dist/cjs/index.d.ts +498 -0
- package/dist/cjs/index.js +776 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/notifications/BaseNotificationChannel.d.ts +47 -0
- package/dist/cjs/notifications/BaseNotificationChannel.js +112 -0
- package/dist/cjs/notifications/BaseNotificationChannel.js.map +1 -0
- package/dist/cjs/notifications/InMemoryNotificationChannel.d.ts +13 -0
- package/dist/cjs/notifications/InMemoryNotificationChannel.js +37 -0
- package/dist/cjs/notifications/InMemoryNotificationChannel.js.map +1 -0
- package/dist/cjs/notifications/index.d.ts +2 -0
- package/dist/cjs/notifications/index.js +8 -0
- package/dist/cjs/notifications/index.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/types/AgendaBackend.d.ts +61 -0
- package/dist/cjs/types/AgendaBackend.js +3 -0
- package/dist/cjs/types/AgendaBackend.js.map +1 -0
- package/dist/cjs/types/AgendaConfig.d.ts +10 -0
- package/dist/cjs/types/AgendaConfig.js +3 -0
- package/dist/cjs/types/AgendaConfig.js.map +1 -0
- package/dist/cjs/types/AgendaStatus.d.ts +32 -0
- package/dist/cjs/types/AgendaStatus.js +3 -0
- package/dist/cjs/types/AgendaStatus.js.map +1 -0
- package/dist/cjs/types/DbOptions.d.ts +4 -0
- package/dist/cjs/types/DbOptions.js +3 -0
- package/dist/cjs/types/DbOptions.js.map +1 -0
- package/dist/cjs/types/DrainOptions.d.ts +37 -0
- package/dist/cjs/types/DrainOptions.js +3 -0
- package/dist/cjs/types/DrainOptions.js.map +1 -0
- package/dist/cjs/types/JobDefinition.d.ts +49 -0
- package/dist/cjs/types/JobDefinition.js +3 -0
- package/dist/cjs/types/JobDefinition.js.map +1 -0
- package/dist/cjs/types/JobLogger.d.ts +111 -0
- package/dist/cjs/types/JobLogger.js +3 -0
- package/dist/cjs/types/JobLogger.js.map +1 -0
- package/dist/cjs/types/JobParameters.d.ts +106 -0
- package/dist/cjs/types/JobParameters.js +21 -0
- package/dist/cjs/types/JobParameters.js.map +1 -0
- package/dist/cjs/types/JobQuery.d.ts +79 -0
- package/dist/cjs/types/JobQuery.js +36 -0
- package/dist/cjs/types/JobQuery.js.map +1 -0
- package/dist/cjs/types/JobRepository.d.ts +101 -0
- package/dist/cjs/types/JobRepository.js +3 -0
- package/dist/cjs/types/JobRepository.js.map +1 -0
- package/dist/cjs/types/NotificationChannel.d.ts +120 -0
- package/dist/cjs/types/NotificationChannel.js +3 -0
- package/dist/cjs/types/NotificationChannel.js.map +1 -0
- package/dist/cjs/utils/backoff.d.ts +152 -0
- package/dist/cjs/utils/backoff.js +175 -0
- package/dist/cjs/utils/backoff.js.map +1 -0
- package/dist/cjs/utils/dateConstraints.d.ts +48 -0
- package/dist/cjs/utils/dateConstraints.js +127 -0
- package/dist/cjs/utils/dateConstraints.js.map +1 -0
- package/dist/cjs/utils/isValidDate.d.ts +1 -0
- package/dist/cjs/utils/isValidDate.js +8 -0
- package/dist/cjs/utils/isValidDate.js.map +1 -0
- package/dist/cjs/utils/nextRunAt.d.ts +10 -0
- package/dist/cjs/utils/nextRunAt.js +119 -0
- package/dist/cjs/utils/nextRunAt.js.map +1 -0
- package/dist/cjs/utils/priority.d.ts +13 -0
- package/dist/cjs/utils/priority.js +23 -0
- package/dist/cjs/utils/priority.js.map +1 -0
- package/dist/cjs/utils/processEvery.d.ts +1 -0
- package/dist/cjs/utils/processEvery.js +13 -0
- package/dist/cjs/utils/processEvery.js.map +1 -0
- package/dist/cjs/utils/stack.d.ts +1 -0
- package/dist/cjs/utils/stack.js +21 -0
- package/dist/cjs/utils/stack.js.map +1 -0
- package/dist/cjs/version.d.ts +9 -0
- package/dist/cjs/version.js +13 -0
- package/dist/cjs/version.js.map +1 -0
- package/dist/decorators/Define.d.ts +33 -0
- package/dist/decorators/Define.js +44 -0
- package/dist/decorators/Define.js.map +1 -0
- package/dist/decorators/Every.d.ts +43 -0
- package/dist/decorators/Every.js +55 -0
- package/dist/decorators/Every.js.map +1 -0
- package/dist/decorators/JobsController.d.ts +43 -0
- package/dist/decorators/JobsController.js +37 -0
- package/dist/decorators/JobsController.js.map +1 -0
- package/dist/decorators/index.d.ts +41 -0
- package/dist/decorators/index.js +46 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/decorators/metadata.d.ts +50 -0
- package/dist/decorators/metadata.js +35 -0
- package/dist/decorators/metadata.js.map +1 -0
- package/dist/decorators/register.d.ts +52 -0
- package/dist/decorators/register.js +139 -0
- package/dist/decorators/register.js.map +1 -0
- package/dist/index.d.ts +498 -0
- package/dist/index.js +744 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications/BaseNotificationChannel.d.ts +47 -0
- package/dist/notifications/BaseNotificationChannel.js +108 -0
- package/dist/notifications/BaseNotificationChannel.js.map +1 -0
- package/dist/notifications/InMemoryNotificationChannel.d.ts +13 -0
- package/dist/notifications/InMemoryNotificationChannel.js +33 -0
- package/dist/notifications/InMemoryNotificationChannel.js.map +1 -0
- package/dist/notifications/index.d.ts +2 -0
- package/dist/notifications/index.js +3 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/types/AgendaBackend.d.ts +61 -0
- package/dist/types/AgendaBackend.js +2 -0
- package/dist/types/AgendaBackend.js.map +1 -0
- package/dist/types/AgendaConfig.d.ts +10 -0
- package/dist/types/AgendaConfig.js +2 -0
- package/dist/types/AgendaConfig.js.map +1 -0
- package/dist/types/AgendaStatus.d.ts +32 -0
- package/dist/types/AgendaStatus.js +2 -0
- package/dist/types/AgendaStatus.js.map +1 -0
- package/dist/types/DbOptions.d.ts +4 -0
- package/dist/types/DbOptions.js +2 -0
- package/dist/types/DbOptions.js.map +1 -0
- package/dist/types/DrainOptions.d.ts +37 -0
- package/dist/types/DrainOptions.js +2 -0
- package/dist/types/DrainOptions.js.map +1 -0
- package/dist/types/JobDefinition.d.ts +49 -0
- package/dist/types/JobDefinition.js +2 -0
- package/dist/types/JobDefinition.js.map +1 -0
- package/dist/types/JobLogger.d.ts +111 -0
- package/dist/types/JobLogger.js +2 -0
- package/dist/types/JobLogger.js.map +1 -0
- package/dist/types/JobParameters.d.ts +106 -0
- package/dist/types/JobParameters.js +17 -0
- package/dist/types/JobParameters.js.map +1 -0
- package/dist/types/JobQuery.d.ts +79 -0
- package/dist/types/JobQuery.js +33 -0
- package/dist/types/JobQuery.js.map +1 -0
- package/dist/types/JobRepository.d.ts +101 -0
- package/dist/types/JobRepository.js +2 -0
- package/dist/types/JobRepository.js.map +1 -0
- package/dist/types/NotificationChannel.d.ts +120 -0
- package/dist/types/NotificationChannel.js +2 -0
- package/dist/types/NotificationChannel.js.map +1 -0
- package/dist/utils/backoff.d.ts +152 -0
- package/dist/utils/backoff.js +167 -0
- package/dist/utils/backoff.js.map +1 -0
- package/dist/utils/dateConstraints.d.ts +48 -0
- package/dist/utils/dateConstraints.js +117 -0
- package/dist/utils/dateConstraints.js.map +1 -0
- package/dist/utils/isValidDate.d.ts +1 -0
- package/dist/utils/isValidDate.js +5 -0
- package/dist/utils/isValidDate.js.map +1 -0
- package/dist/utils/nextRunAt.d.ts +10 -0
- package/dist/utils/nextRunAt.js +110 -0
- package/dist/utils/nextRunAt.js.map +1 -0
- package/dist/utils/priority.d.ts +13 -0
- package/dist/utils/priority.js +20 -0
- package/dist/utils/priority.js.map +1 -0
- package/dist/utils/processEvery.d.ts +1 -0
- package/dist/utils/processEvery.js +7 -0
- package/dist/utils/processEvery.js.map +1 -0
- package/dist/utils/stack.d.ts +1 -0
- package/dist/utils/stack.js +18 -0
- package/dist/utils/stack.js.map +1 -0
- package/dist/version.d.ts +9 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/package.json +79 -0
- package/test/fixtures/forkHelper.js +40 -0
- package/test/fixtures/someJobDefinition.ts +12 -0
- package/test/shared/agenda-test-suite.ts +1876 -0
- package/test/shared/backoff-test-suite.ts +408 -0
- package/test/shared/debounce-test-suite.ts +377 -0
- package/test/shared/full-test-suite.ts +161 -0
- package/test/shared/index.ts +50 -0
- package/test/shared/joblogger-test-suite.ts +305 -0
- package/test/shared/jobprocessor-test-suite.ts +266 -0
- package/test/shared/notification-channel-test-suite.ts +471 -0
- package/test/shared/removeoncomplete-test-suite.ts +228 -0
- package/test/shared/repository-test-suite.ts +708 -0
- package/test/shared/retry-test-suite.ts +174 -0
- package/test/shared/test-utils.ts +100 -0
|
@@ -0,0 +1,1876 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import type { ForkOptions } from 'child_process';
|
|
3
|
+
import type { AgendaBackend, NotificationChannel } from '../../src/index.js';
|
|
4
|
+
import { Agenda } from '../../src/index.js';
|
|
5
|
+
import { Job } from '../../src/Job.js';
|
|
6
|
+
import { delay, waitFor, waitForEvent, waitForEvents, runJobAndWait } from './test-utils.js';
|
|
7
|
+
import someJobDefinition from '../fixtures/someJobDefinition.js';
|
|
8
|
+
|
|
9
|
+
export interface ForkHelperConfig {
|
|
10
|
+
/** Path to the fork helper script (relative to cwd) */
|
|
11
|
+
path: string;
|
|
12
|
+
/** Fork options (e.g., execArgv for tsx loader, env for environment variables) */
|
|
13
|
+
options?: ForkOptions;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AgendaTestConfig {
|
|
17
|
+
/** Name for the test suite */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Factory to create a fresh backend instance */
|
|
20
|
+
createBackend: () => Promise<AgendaBackend>;
|
|
21
|
+
/** Cleanup function called after tests */
|
|
22
|
+
cleanupBackend: (backend: AgendaBackend) => Promise<void>;
|
|
23
|
+
/** Clear all jobs between tests */
|
|
24
|
+
clearJobs: (backend: AgendaBackend) => Promise<void>;
|
|
25
|
+
/** Optional notification channel factory */
|
|
26
|
+
createNotificationChannel?: () => Promise<NotificationChannel>;
|
|
27
|
+
/** Cleanup notification channel */
|
|
28
|
+
cleanupNotificationChannel?: (channel: NotificationChannel) => Promise<void>;
|
|
29
|
+
/** Fork mode configuration (required if forkMode tests are enabled) */
|
|
30
|
+
forkHelper?: ForkHelperConfig;
|
|
31
|
+
/** Skip specific tests if not supported */
|
|
32
|
+
skip?: {
|
|
33
|
+
forkMode?: boolean; // Skip fork mode tests (require specific file paths)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function agendaTestSuite(config: AgendaTestConfig): void {
|
|
38
|
+
describe(`${config.name} - Agenda Integration`, () => {
|
|
39
|
+
let backend: AgendaBackend;
|
|
40
|
+
let agenda: Agenda;
|
|
41
|
+
|
|
42
|
+
const jobProcessor = () => {};
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
backend = await config.createBackend();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await config.cleanupBackend(backend);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
await config.clearJobs(backend);
|
|
54
|
+
agenda = new Agenda({
|
|
55
|
+
backend,
|
|
56
|
+
processEvery: 100
|
|
57
|
+
});
|
|
58
|
+
await agenda.ready;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
if (agenda) {
|
|
63
|
+
await agenda.stop();
|
|
64
|
+
}
|
|
65
|
+
await config.clearJobs(backend);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('every()', () => {
|
|
69
|
+
it('should schedule a job to run at an interval', async () => {
|
|
70
|
+
await agenda.every('5 minutes', 'test-interval-job');
|
|
71
|
+
|
|
72
|
+
const result = await agenda.queryJobs({ name: 'test-interval-job' });
|
|
73
|
+
expect(result.jobs.length).toBe(1);
|
|
74
|
+
expect(result.jobs[0].repeatInterval).toBe('5 minutes');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should schedule a job to run at a cron expression', async () => {
|
|
78
|
+
await agenda.every('0 6 * * *', 'test-cron-job');
|
|
79
|
+
|
|
80
|
+
const result = await agenda.queryJobs({ name: 'test-cron-job' });
|
|
81
|
+
expect(result.jobs.length).toBe(1);
|
|
82
|
+
expect(result.jobs[0].repeatInterval).toBe('0 6 * * *');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should update a job that was previously scheduled', async () => {
|
|
86
|
+
await agenda.every('1 minute', 'update-interval-job');
|
|
87
|
+
await agenda.every('5 minutes', 'update-interval-job');
|
|
88
|
+
|
|
89
|
+
const result = await agenda.queryJobs({ name: 'update-interval-job' });
|
|
90
|
+
expect(result.jobs.length).toBe(1);
|
|
91
|
+
expect(result.jobs[0].repeatInterval).toBe('5 minutes');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should accept options object with timezone', async () => {
|
|
95
|
+
agenda.define('timezoneJob', jobProcessor);
|
|
96
|
+
const job = await agenda.every('5 minutes', 'timezoneJob', {}, { timezone: 'America/New_York' });
|
|
97
|
+
expect(job.attrs.repeatTimezone).toBe('America/New_York');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should set skipImmediate option', async () => {
|
|
101
|
+
agenda.define('skipImmediateJob', jobProcessor);
|
|
102
|
+
agenda.define('noSkipJob', jobProcessor);
|
|
103
|
+
// Without skipImmediate, job should run immediately (nextRunAt ~ now)
|
|
104
|
+
const jobWithoutSkip = await agenda.every('5 minutes', 'noSkipJob', {});
|
|
105
|
+
const immediateDiff = Math.abs(
|
|
106
|
+
(jobWithoutSkip.attrs.nextRunAt?.getTime() || 0) - Date.now()
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// With skipImmediate, job should run in ~5 minutes (nextRunAt > now + 4 minutes)
|
|
110
|
+
const jobWithSkip = await agenda.every('5 minutes', 'skipImmediateJob', {}, { skipImmediate: true });
|
|
111
|
+
const skipDiff = (jobWithSkip.attrs.nextRunAt?.getTime() || 0) - Date.now();
|
|
112
|
+
|
|
113
|
+
// Without skip, should run very soon (within 1 second)
|
|
114
|
+
expect(immediateDiff).toBeLessThan(1000);
|
|
115
|
+
// With skip, should run in ~5 minutes (at least 4 minutes from now)
|
|
116
|
+
expect(skipDiff).toBeGreaterThan(4 * 60 * 1000);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should create multiple jobs with array of names', async () => {
|
|
120
|
+
agenda.define('arrayJob1', jobProcessor);
|
|
121
|
+
agenda.define('arrayJob2', jobProcessor);
|
|
122
|
+
const jobs = await agenda.every('5 minutes', ['arrayJob1', 'arrayJob2']) as Job[];
|
|
123
|
+
expect(jobs).toHaveLength(2);
|
|
124
|
+
expect(jobs.map(j => j.attrs.name)).toContain('arrayJob1');
|
|
125
|
+
expect(jobs.map(j => j.attrs.name)).toContain('arrayJob2');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('schedule()', () => {
|
|
130
|
+
it('should schedule a job for a specific time', async () => {
|
|
131
|
+
const when = new Date(Date.now() + 60000);
|
|
132
|
+
const job = await agenda.schedule(when, 'test-schedule-job');
|
|
133
|
+
|
|
134
|
+
expect(job.attrs.nextRunAt?.getTime()).toBe(when.getTime());
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should schedule a job with human-readable time', async () => {
|
|
138
|
+
const job = await agenda.schedule('in 1 hour', 'test-human-schedule');
|
|
139
|
+
|
|
140
|
+
expect(job.attrs.nextRunAt).toBeDefined();
|
|
141
|
+
expect(job.attrs.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should create multiple jobs with array of names', async () => {
|
|
145
|
+
agenda.define('scheduleArrayJob1', jobProcessor);
|
|
146
|
+
agenda.define('scheduleArrayJob2', jobProcessor);
|
|
147
|
+
const when = new Date(Date.now() + 60000);
|
|
148
|
+
const jobs = await agenda.schedule(when, ['scheduleArrayJob1', 'scheduleArrayJob2']) as Job[];
|
|
149
|
+
expect(jobs).toHaveLength(2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('now()', () => {
|
|
154
|
+
it('should schedule a job to run immediately', async () => {
|
|
155
|
+
const job = await agenda.now('test-now-job');
|
|
156
|
+
|
|
157
|
+
expect(job.attrs.nextRunAt).toBeDefined();
|
|
158
|
+
expect(job.attrs.nextRunAt!.getTime()).toBeLessThanOrEqual(Date.now() + 1000);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should schedule a job with data', async () => {
|
|
162
|
+
const job = await agenda.now('test-now-data', { key: 'value' });
|
|
163
|
+
|
|
164
|
+
expect(job.attrs.data).toEqual({ key: 'value' });
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('queryJobs()', () => {
|
|
169
|
+
it('should return all jobs matching query', async () => {
|
|
170
|
+
await agenda.now('query-job-1');
|
|
171
|
+
await agenda.now('query-job-2');
|
|
172
|
+
await agenda.now('query-job-1');
|
|
173
|
+
|
|
174
|
+
const result = await agenda.queryJobs({ name: 'query-job-1' });
|
|
175
|
+
expect(result.jobs.length).toBe(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should return empty array when no jobs match', async () => {
|
|
179
|
+
const result = await agenda.queryJobs({ name: 'non-existent' });
|
|
180
|
+
expect(result.jobs).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should limit results', async () => {
|
|
184
|
+
await agenda.now('limit-test');
|
|
185
|
+
await agenda.now('limit-test');
|
|
186
|
+
await agenda.now('limit-test');
|
|
187
|
+
|
|
188
|
+
const result = await agenda.queryJobs({ name: 'limit-test', limit: 2 });
|
|
189
|
+
expect(result.jobs.length).toBe(2);
|
|
190
|
+
expect(result.total).toBe(3);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should skip results', async () => {
|
|
194
|
+
await agenda.now('skip-test');
|
|
195
|
+
await agenda.now('skip-test');
|
|
196
|
+
await agenda.now('skip-test');
|
|
197
|
+
|
|
198
|
+
const result = await agenda.queryJobs({ name: 'skip-test', skip: 2 });
|
|
199
|
+
expect(result.jobs.length).toBe(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should combine limit and skip', async () => {
|
|
203
|
+
await agenda.now('limit-skip-test');
|
|
204
|
+
await agenda.now('limit-skip-test');
|
|
205
|
+
await agenda.now('limit-skip-test');
|
|
206
|
+
await agenda.now('limit-skip-test');
|
|
207
|
+
await agenda.now('limit-skip-test');
|
|
208
|
+
|
|
209
|
+
const result = await agenda.queryJobs({ name: 'limit-skip-test', limit: 2, skip: 2 });
|
|
210
|
+
expect(result.jobs.length).toBe(2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should sort results', async () => {
|
|
214
|
+
const j1 = await agenda.now('sort-test', { order: 3 });
|
|
215
|
+
await j1.priority(3).save();
|
|
216
|
+
const j2 = await agenda.now('sort-test', { order: 1 });
|
|
217
|
+
await j2.priority(1).save();
|
|
218
|
+
const j3 = await agenda.now('sort-test', { order: 2 });
|
|
219
|
+
await j3.priority(2).save();
|
|
220
|
+
|
|
221
|
+
const result = await agenda.queryJobs({
|
|
222
|
+
name: 'sort-test',
|
|
223
|
+
sort: { priority: 'desc' }
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result.jobs.length).toBe(3);
|
|
227
|
+
expect(result.jobs[0].priority).toBe(3);
|
|
228
|
+
expect(result.jobs[1].priority).toBe(2);
|
|
229
|
+
expect(result.jobs[2].priority).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('cancel()', () => {
|
|
234
|
+
it('should cancel jobs matching query', async () => {
|
|
235
|
+
await agenda.now('cancel-test');
|
|
236
|
+
await agenda.now('cancel-test');
|
|
237
|
+
await agenda.now('keep-test');
|
|
238
|
+
|
|
239
|
+
const cancelled = await agenda.cancel({ name: 'cancel-test' });
|
|
240
|
+
expect(cancelled).toBe(2);
|
|
241
|
+
|
|
242
|
+
const result = await agenda.queryJobs({});
|
|
243
|
+
expect(result.jobs.length).toBe(1);
|
|
244
|
+
expect(result.jobs[0].name).toBe('keep-test');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should cancel only jobs matching both name and data filter', async () => {
|
|
248
|
+
await agenda.now('cancel-data-test', { userId: 1 });
|
|
249
|
+
await agenda.now('cancel-data-test', { userId: 2 });
|
|
250
|
+
await agenda.now('cancel-data-test', { userId: 3 });
|
|
251
|
+
|
|
252
|
+
const cancelled = await agenda.cancel({ name: 'cancel-data-test', data: { userId: 2 } });
|
|
253
|
+
expect(cancelled).toBe(1);
|
|
254
|
+
|
|
255
|
+
const result = await agenda.queryJobs({ name: 'cancel-data-test' });
|
|
256
|
+
expect(result.jobs.length).toBe(2);
|
|
257
|
+
const remaining = result.jobs.map(j => (j.data as { userId: number }).userId).sort();
|
|
258
|
+
expect(remaining).toEqual([1, 3]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should cancel jobs matching data filter with nested objects', async () => {
|
|
262
|
+
await agenda.now('cancel-nested-test', { org: { id: 'abc' }, type: 'sync' });
|
|
263
|
+
await agenda.now('cancel-nested-test', { org: { id: 'def' }, type: 'sync' });
|
|
264
|
+
await agenda.now('cancel-nested-test', { org: { id: 'abc' }, type: 'backup' });
|
|
265
|
+
|
|
266
|
+
const cancelled = await agenda.cancel({ name: 'cancel-nested-test', data: { org: { id: 'abc' } } });
|
|
267
|
+
expect(cancelled).toBe(2);
|
|
268
|
+
|
|
269
|
+
const result = await agenda.queryJobs({ name: 'cancel-nested-test' });
|
|
270
|
+
expect(result.jobs.length).toBe(1);
|
|
271
|
+
expect((result.jobs[0].data as { org: { id: string } }).org.id).toBe('def');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('job disable/enable', () => {
|
|
276
|
+
it('should disable a job via Job.disable()', async () => {
|
|
277
|
+
const job = await agenda.now('disable-test');
|
|
278
|
+
await job.disable().save();
|
|
279
|
+
|
|
280
|
+
const result = await agenda.queryJobs({ name: 'disable-test' });
|
|
281
|
+
expect(result.jobs[0].disabled).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should enable a disabled job via Job.enable()', async () => {
|
|
285
|
+
const job = await agenda.now('enable-test');
|
|
286
|
+
await job.disable().save();
|
|
287
|
+
|
|
288
|
+
// Re-fetch and enable
|
|
289
|
+
const jobs = (await agenda.queryJobs({ name: 'enable-test' })).jobs;
|
|
290
|
+
const jobToEnable = agenda.create(jobs[0].name, jobs[0].data);
|
|
291
|
+
Object.assign(jobToEnable.attrs, jobs[0]);
|
|
292
|
+
await jobToEnable.enable().save();
|
|
293
|
+
|
|
294
|
+
const result = await agenda.queryJobs({ name: 'enable-test' });
|
|
295
|
+
expect(result.jobs[0].disabled).toBeFalsy();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should not run disabled jobs', async () => {
|
|
299
|
+
let processed = false;
|
|
300
|
+
agenda.define('disabled-process-test', async () => {
|
|
301
|
+
processed = true;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const job = await agenda.now('disabled-process-test');
|
|
305
|
+
await job.disable().save();
|
|
306
|
+
await agenda.start();
|
|
307
|
+
|
|
308
|
+
// Wait a bit to ensure job would have been picked up
|
|
309
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
310
|
+
expect(processed).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should disable jobs matching query via agenda.disable()', async () => {
|
|
314
|
+
await agenda.now('bulk-disable-test');
|
|
315
|
+
await agenda.now('bulk-disable-test');
|
|
316
|
+
|
|
317
|
+
const disabled = await agenda.disable({ name: 'bulk-disable-test' });
|
|
318
|
+
expect(disabled).toBe(2);
|
|
319
|
+
|
|
320
|
+
const result = await agenda.queryJobs({ name: 'bulk-disable-test' });
|
|
321
|
+
result.jobs.forEach(job => expect(job.disabled).toBe(true));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should enable disabled jobs via agenda.enable()', async () => {
|
|
325
|
+
const job = await agenda.now('bulk-enable-test');
|
|
326
|
+
await job.disable().save();
|
|
327
|
+
|
|
328
|
+
const enabled = await agenda.enable({ name: 'bulk-enable-test' });
|
|
329
|
+
expect(enabled).toBe(1);
|
|
330
|
+
|
|
331
|
+
const result = await agenda.queryJobs({ name: 'bulk-enable-test' });
|
|
332
|
+
expect(result.jobs[0].disabled).toBeFalsy();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should return 0 when no jobs match disable query', async () => {
|
|
336
|
+
const disabled = await agenda.disable({ name: 'non-existent-job' });
|
|
337
|
+
expect(disabled).toBe(0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should return 0 when no options provided to disable', async () => {
|
|
341
|
+
const disabled = await agenda.disable({});
|
|
342
|
+
expect(disabled).toBe(0);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe('job processing', () => {
|
|
347
|
+
it('should process a job', async () => {
|
|
348
|
+
let processed = false;
|
|
349
|
+
agenda.define('process-test', async () => {
|
|
350
|
+
processed = true;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await agenda.start();
|
|
354
|
+
await runJobAndWait(agenda, 'process-test', 'complete:process-test');
|
|
355
|
+
expect(processed).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should pass job instance to processor', async () => {
|
|
359
|
+
let receivedJob: Job | undefined;
|
|
360
|
+
agenda.define('job-instance-test', async (job: Job) => {
|
|
361
|
+
receivedJob = job;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await agenda.start();
|
|
365
|
+
await runJobAndWait(agenda, 'job-instance-test', 'complete:job-instance-test', { testData: 123 });
|
|
366
|
+
expect(receivedJob).toBeDefined();
|
|
367
|
+
expect(receivedJob?.attrs.name).toBe('job-instance-test');
|
|
368
|
+
expect(receivedJob?.attrs.data).toEqual({ testData: 123 });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should emit success event on completion', async () => {
|
|
372
|
+
let successEmitted = false;
|
|
373
|
+
agenda.define('success-event-test', async () => {});
|
|
374
|
+
|
|
375
|
+
agenda.on('success:success-event-test', () => {
|
|
376
|
+
successEmitted = true;
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await agenda.start();
|
|
380
|
+
await runJobAndWait(agenda, 'success-event-test', 'complete:success-event-test');
|
|
381
|
+
expect(successEmitted).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should emit fail event on error', async () => {
|
|
385
|
+
let failEmitted = false;
|
|
386
|
+
let failError: Error | undefined;
|
|
387
|
+
agenda.define('fail-event-test', async () => {
|
|
388
|
+
throw new Error('Test error');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
agenda.on('fail:fail-event-test', (err: Error) => {
|
|
392
|
+
failEmitted = true;
|
|
393
|
+
failError = err;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await agenda.start();
|
|
397
|
+
await runJobAndWait(agenda, 'fail-event-test', 'complete:fail-event-test');
|
|
398
|
+
expect(failEmitted).toBe(true);
|
|
399
|
+
expect(failError?.message).toBe('Test error');
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe('job locking', () => {
|
|
404
|
+
it('should lock jobs during processing', async () => {
|
|
405
|
+
let lockedDuringProcessing = false;
|
|
406
|
+
agenda.define('lock-test', async () => {
|
|
407
|
+
const result = await agenda.queryJobs({ name: 'lock-test' });
|
|
408
|
+
lockedDuringProcessing =
|
|
409
|
+
result.jobs[0].lockedAt !== null && result.jobs[0].lockedAt !== undefined;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await agenda.start();
|
|
413
|
+
await runJobAndWait(agenda, 'lock-test', 'complete:lock-test');
|
|
414
|
+
expect(lockedDuringProcessing).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should clear locks on stop', async () => {
|
|
418
|
+
agenda.define('clear-lock-test', async () => {
|
|
419
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await agenda.start();
|
|
423
|
+
// Wait for job to start (set up listener before scheduling to avoid race)
|
|
424
|
+
await runJobAndWait(agenda, 'clear-lock-test', 'start:clear-lock-test');
|
|
425
|
+
await agenda.stop();
|
|
426
|
+
|
|
427
|
+
const result = await agenda.queryJobs({ name: 'clear-lock-test' });
|
|
428
|
+
expect(result.jobs[0].lockedAt).toBeFalsy();
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('concurrency', () => {
|
|
433
|
+
it('should respect maxConcurrency', async () => {
|
|
434
|
+
let running = 0;
|
|
435
|
+
let maxRunning = 0;
|
|
436
|
+
|
|
437
|
+
agenda.maxConcurrency(2);
|
|
438
|
+
agenda.define('concurrency-test', async () => {
|
|
439
|
+
running++;
|
|
440
|
+
maxRunning = Math.max(maxRunning, running);
|
|
441
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
442
|
+
running--;
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await agenda.start();
|
|
446
|
+
await Promise.all([
|
|
447
|
+
agenda.now('concurrency-test'),
|
|
448
|
+
agenda.now('concurrency-test'),
|
|
449
|
+
agenda.now('concurrency-test'),
|
|
450
|
+
agenda.now('concurrency-test')
|
|
451
|
+
]);
|
|
452
|
+
|
|
453
|
+
await waitForEvents(agenda, 'complete:concurrency-test', 4);
|
|
454
|
+
expect(maxRunning).toBeLessThanOrEqual(2);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should respect per-job concurrency', async () => {
|
|
458
|
+
let running = 0;
|
|
459
|
+
let maxRunning = 0;
|
|
460
|
+
|
|
461
|
+
agenda.define(
|
|
462
|
+
'per-job-concurrency',
|
|
463
|
+
async () => {
|
|
464
|
+
running++;
|
|
465
|
+
maxRunning = Math.max(maxRunning, running);
|
|
466
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
467
|
+
running--;
|
|
468
|
+
},
|
|
469
|
+
{ concurrency: 1 }
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
await agenda.start();
|
|
473
|
+
await Promise.all([
|
|
474
|
+
agenda.now('per-job-concurrency'),
|
|
475
|
+
agenda.now('per-job-concurrency'),
|
|
476
|
+
agenda.now('per-job-concurrency')
|
|
477
|
+
]);
|
|
478
|
+
|
|
479
|
+
await waitForEvents(agenda, 'complete:per-job-concurrency', 3);
|
|
480
|
+
expect(maxRunning).toBe(1);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe('priority', () => {
|
|
485
|
+
it('should process higher priority jobs first', async () => {
|
|
486
|
+
const processOrder: number[] = [];
|
|
487
|
+
|
|
488
|
+
agenda.define('priority-test', async (job: Job) => {
|
|
489
|
+
processOrder.push(job.attrs.priority || 0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Use the same nextRunAt for all jobs so priority is the deciding factor
|
|
493
|
+
const sameTime = new Date(Date.now() - 1000); // 1 second in the past
|
|
494
|
+
|
|
495
|
+
// Create jobs with same nextRunAt but different priorities
|
|
496
|
+
const j1 = agenda.create('priority-test');
|
|
497
|
+
j1.attrs.nextRunAt = sameTime;
|
|
498
|
+
await j1.priority(-10).save();
|
|
499
|
+
|
|
500
|
+
const j2 = agenda.create('priority-test');
|
|
501
|
+
j2.attrs.nextRunAt = sameTime;
|
|
502
|
+
await j2.priority(10).save();
|
|
503
|
+
|
|
504
|
+
const j3 = agenda.create('priority-test');
|
|
505
|
+
j3.attrs.nextRunAt = sameTime;
|
|
506
|
+
await j3.priority(0).save();
|
|
507
|
+
|
|
508
|
+
agenda.maxConcurrency(1); // Process one at a time
|
|
509
|
+
await agenda.start();
|
|
510
|
+
|
|
511
|
+
await waitForEvents(agenda, 'complete:priority-test', 3);
|
|
512
|
+
|
|
513
|
+
// Higher priority (10) should be processed first
|
|
514
|
+
expect(processOrder[0]).toBe(10);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
describe('lock limits', () => {
|
|
519
|
+
it('should not lock more than agenda lockLimit jobs', async () => {
|
|
520
|
+
agenda.lockLimit(1);
|
|
521
|
+
agenda.processEvery(100);
|
|
522
|
+
|
|
523
|
+
agenda.define('lock-limit-job', (_job, _cb) => {
|
|
524
|
+
// This job never finishes
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
await agenda.start();
|
|
528
|
+
|
|
529
|
+
await Promise.all([
|
|
530
|
+
agenda.now('lock-limit-job', { i: 1 }),
|
|
531
|
+
agenda.now('lock-limit-job', { i: 2 })
|
|
532
|
+
]);
|
|
533
|
+
|
|
534
|
+
// Wait for the process interval to pick up the jobs
|
|
535
|
+
await delay(500);
|
|
536
|
+
|
|
537
|
+
expect((await agenda.getRunningStats()).lockedJobs).toBe(1);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('should not lock more mixed jobs than agenda lockLimit', async () => {
|
|
541
|
+
agenda.lockLimit(1);
|
|
542
|
+
agenda.processEvery(100);
|
|
543
|
+
|
|
544
|
+
agenda.define('lock-mixed-1', (_job, _cb) => {});
|
|
545
|
+
agenda.define('lock-mixed-2', (_job, _cb) => {});
|
|
546
|
+
agenda.define('lock-mixed-3', (_job, _cb) => {});
|
|
547
|
+
|
|
548
|
+
await agenda.start();
|
|
549
|
+
|
|
550
|
+
await Promise.all([
|
|
551
|
+
agenda.now('lock-mixed-1', { i: 1 }),
|
|
552
|
+
agenda.now('lock-mixed-2', { i: 2 }),
|
|
553
|
+
agenda.now('lock-mixed-3', { i: 3 })
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
await delay(500);
|
|
557
|
+
expect((await agenda.getRunningStats()).lockedJobs).toBe(1);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('should not lock more than definition lockLimit jobs', async () => {
|
|
561
|
+
agenda.processEvery(100);
|
|
562
|
+
agenda.define('def-lock-limit', (_job, _cb) => {}, { lockLimit: 1 });
|
|
563
|
+
|
|
564
|
+
await agenda.start();
|
|
565
|
+
|
|
566
|
+
await Promise.all([
|
|
567
|
+
agenda.now('def-lock-limit', { i: 1 }),
|
|
568
|
+
agenda.now('def-lock-limit', { i: 2 })
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
await delay(500);
|
|
572
|
+
expect((await agenda.getRunningStats()).lockedJobs).toBe(1);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('should not lock more than agenda lockLimit during processing interval', async () => {
|
|
576
|
+
agenda.lockLimit(1);
|
|
577
|
+
agenda.processEvery(200);
|
|
578
|
+
|
|
579
|
+
agenda.define('lock-interval', (_job, _cb) => {});
|
|
580
|
+
|
|
581
|
+
await agenda.start();
|
|
582
|
+
|
|
583
|
+
const when = new Date(Date.now() + 300);
|
|
584
|
+
|
|
585
|
+
await Promise.all([
|
|
586
|
+
agenda.schedule(when, 'lock-interval', { i: 1 }),
|
|
587
|
+
agenda.schedule(when, 'lock-interval', { i: 2 })
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
// Wait until the first job gets locked, then let further processing
|
|
591
|
+
// cycles run and verify the lock limit is still respected.
|
|
592
|
+
const lockedCount = async () => {
|
|
593
|
+
const { lockedJobs } = await agenda.getRunningStats();
|
|
594
|
+
return Array.isArray(lockedJobs) ? lockedJobs.length : lockedJobs;
|
|
595
|
+
};
|
|
596
|
+
expect(await waitFor(async () => (await lockedCount()) >= 1)).toBe(true);
|
|
597
|
+
await delay(500);
|
|
598
|
+
expect(await lockedCount()).toBe(1);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('should not lock more than definition lockLimit during processing interval', async () => {
|
|
602
|
+
agenda.processEvery(200);
|
|
603
|
+
agenda.define('def-lock-interval', (_job, _cb) => {}, { lockLimit: 1 });
|
|
604
|
+
|
|
605
|
+
await agenda.start();
|
|
606
|
+
|
|
607
|
+
const when = new Date(Date.now() + 300);
|
|
608
|
+
|
|
609
|
+
await Promise.all([
|
|
610
|
+
agenda.schedule(when, 'def-lock-interval', { i: 1 }),
|
|
611
|
+
agenda.schedule(when, 'def-lock-interval', { i: 2 })
|
|
612
|
+
]);
|
|
613
|
+
|
|
614
|
+
// Wait until the first job gets locked, then let further processing
|
|
615
|
+
// cycles run and verify the lock limit is still respected.
|
|
616
|
+
const lockedCount = async () => {
|
|
617
|
+
const { lockedJobs } = await agenda.getRunningStats();
|
|
618
|
+
return Array.isArray(lockedJobs) ? lockedJobs.length : lockedJobs;
|
|
619
|
+
};
|
|
620
|
+
expect(await waitFor(async () => (await lockedCount()) >= 1)).toBe(true);
|
|
621
|
+
await delay(500);
|
|
622
|
+
expect(await lockedCount()).toBe(1);
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Note: getRunningStats tests are in jobprocessor-test-suite.ts (more comprehensive)
|
|
627
|
+
|
|
628
|
+
describe('purge', () => {
|
|
629
|
+
it('should remove orphaned jobs (jobs without definitions)', async () => {
|
|
630
|
+
// Define and create jobs that should be KEPT
|
|
631
|
+
agenda.define('defined-job', jobProcessor);
|
|
632
|
+
await agenda.now('defined-job');
|
|
633
|
+
|
|
634
|
+
// Create orphaned jobs directly in DB (no definition)
|
|
635
|
+
// We need to temporarily define them to create, then "orphan" them
|
|
636
|
+
agenda.define('orphan-1', jobProcessor);
|
|
637
|
+
agenda.define('orphan-2', jobProcessor);
|
|
638
|
+
await agenda.now('orphan-1');
|
|
639
|
+
await agenda.now('orphan-2');
|
|
640
|
+
|
|
641
|
+
// Remove the definitions to make them orphans
|
|
642
|
+
delete agenda.definitions['orphan-1'];
|
|
643
|
+
delete agenda.definitions['orphan-2'];
|
|
644
|
+
|
|
645
|
+
const beforePurge = await agenda.queryJobs({});
|
|
646
|
+
expect(beforePurge.total).toBe(3);
|
|
647
|
+
|
|
648
|
+
// Purge should only remove orphaned jobs (not defined)
|
|
649
|
+
const purged = await agenda.purge();
|
|
650
|
+
expect(purged).toBe(2);
|
|
651
|
+
|
|
652
|
+
const afterPurge = await agenda.queryJobs({});
|
|
653
|
+
expect(afterPurge.total).toBe(1);
|
|
654
|
+
expect(afterPurge.jobs[0].name).toBe('defined-job');
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
describe('cancelAll()', () => {
|
|
659
|
+
it('should remove all jobs from the database', async () => {
|
|
660
|
+
await agenda.now('cancelAll-test-1');
|
|
661
|
+
await agenda.now('cancelAll-test-2');
|
|
662
|
+
await agenda.now('cancelAll-test-3');
|
|
663
|
+
|
|
664
|
+
const before = await agenda.queryJobs({});
|
|
665
|
+
expect(before.total).toBe(3);
|
|
666
|
+
|
|
667
|
+
const removed = await agenda.cancelAll();
|
|
668
|
+
expect(removed).toBe(3);
|
|
669
|
+
|
|
670
|
+
const after = await agenda.queryJobs({});
|
|
671
|
+
expect(after.total).toBe(0);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('should return 0 when no jobs exist', async () => {
|
|
675
|
+
const removed = await agenda.cancelAll();
|
|
676
|
+
expect(removed).toBe(0);
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
describe('unique constraint', () => {
|
|
681
|
+
it('should modify one job when unique matches', async () => {
|
|
682
|
+
const job1 = await agenda
|
|
683
|
+
.create('unique-job', {
|
|
684
|
+
type: 'active',
|
|
685
|
+
userId: '123',
|
|
686
|
+
other: true
|
|
687
|
+
})
|
|
688
|
+
.unique({
|
|
689
|
+
'data.type': 'active',
|
|
690
|
+
'data.userId': '123'
|
|
691
|
+
})
|
|
692
|
+
.schedule('now')
|
|
693
|
+
.save();
|
|
694
|
+
|
|
695
|
+
const job2 = await agenda
|
|
696
|
+
.create('unique-job', {
|
|
697
|
+
type: 'active',
|
|
698
|
+
userId: '123',
|
|
699
|
+
other: false
|
|
700
|
+
})
|
|
701
|
+
.unique({
|
|
702
|
+
'data.type': 'active',
|
|
703
|
+
'data.userId': '123'
|
|
704
|
+
})
|
|
705
|
+
.schedule('now')
|
|
706
|
+
.save();
|
|
707
|
+
|
|
708
|
+
// Both should have the same ID (upserted)
|
|
709
|
+
expect(job1.attrs._id?.toString()).toBe(job2.attrs._id?.toString());
|
|
710
|
+
|
|
711
|
+
const result = await agenda.queryJobs({ name: 'unique-job' });
|
|
712
|
+
expect(result.jobs.length).toBe(1);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('should not modify job when unique matches and insertOnly is set', async () => {
|
|
716
|
+
const job1 = await agenda
|
|
717
|
+
.create('unique-insert-only', {
|
|
718
|
+
type: 'active',
|
|
719
|
+
userId: '123',
|
|
720
|
+
value: 'first'
|
|
721
|
+
})
|
|
722
|
+
.unique(
|
|
723
|
+
{
|
|
724
|
+
'data.type': 'active',
|
|
725
|
+
'data.userId': '123'
|
|
726
|
+
},
|
|
727
|
+
{ insertOnly: true }
|
|
728
|
+
)
|
|
729
|
+
.schedule('now')
|
|
730
|
+
.save();
|
|
731
|
+
|
|
732
|
+
const job2 = await agenda
|
|
733
|
+
.create('unique-insert-only', {
|
|
734
|
+
type: 'active',
|
|
735
|
+
userId: '123',
|
|
736
|
+
value: 'second'
|
|
737
|
+
})
|
|
738
|
+
.unique(
|
|
739
|
+
{
|
|
740
|
+
'data.type': 'active',
|
|
741
|
+
'data.userId': '123'
|
|
742
|
+
},
|
|
743
|
+
{ insertOnly: true }
|
|
744
|
+
)
|
|
745
|
+
.schedule('now')
|
|
746
|
+
.save();
|
|
747
|
+
|
|
748
|
+
// Same ID but data should not be updated
|
|
749
|
+
expect(job1.attrs._id?.toString()).toBe(job2.attrs._id?.toString());
|
|
750
|
+
|
|
751
|
+
const result = await agenda.queryJobs({ name: 'unique-insert-only' });
|
|
752
|
+
expect(result.jobs.length).toBe(1);
|
|
753
|
+
// Original value should be preserved
|
|
754
|
+
expect((result.jobs[0].data as { value: string }).value).toBe('first');
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('should create two jobs when unique does not match', async () => {
|
|
758
|
+
await agenda
|
|
759
|
+
.create('unique-no-match', {
|
|
760
|
+
type: 'active',
|
|
761
|
+
userId: '123'
|
|
762
|
+
})
|
|
763
|
+
.unique({
|
|
764
|
+
'data.type': 'active',
|
|
765
|
+
'data.userId': '123'
|
|
766
|
+
})
|
|
767
|
+
.schedule('now')
|
|
768
|
+
.save();
|
|
769
|
+
|
|
770
|
+
await agenda
|
|
771
|
+
.create('unique-no-match', {
|
|
772
|
+
type: 'active',
|
|
773
|
+
userId: '456' // Different userId
|
|
774
|
+
})
|
|
775
|
+
.unique({
|
|
776
|
+
'data.type': 'active',
|
|
777
|
+
'data.userId': '456'
|
|
778
|
+
})
|
|
779
|
+
.schedule('now')
|
|
780
|
+
.save();
|
|
781
|
+
|
|
782
|
+
const result = await agenda.queryJobs({ name: 'unique-no-match' });
|
|
783
|
+
expect(result.jobs.length).toBe(2);
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
describe('Job class', () => {
|
|
788
|
+
describe('toJson', () => {
|
|
789
|
+
it('should return job attributes as JSON', async () => {
|
|
790
|
+
const job = await agenda.now('json-test', { foo: 'bar' });
|
|
791
|
+
const json = job.toJson();
|
|
792
|
+
|
|
793
|
+
expect(json.name).toBe('json-test');
|
|
794
|
+
expect(json.data).toEqual({ foo: 'bar' });
|
|
795
|
+
expect(json._id).toBeDefined();
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
describe('repeatEvery', () => {
|
|
800
|
+
it('should set repeatInterval', async () => {
|
|
801
|
+
const job = agenda.create('repeat-every-test');
|
|
802
|
+
job.repeatEvery('5 minutes');
|
|
803
|
+
|
|
804
|
+
expect(job.attrs.repeatInterval).toBe('5 minutes');
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('should accept cron expression', async () => {
|
|
808
|
+
const job = agenda.create('repeat-cron-test');
|
|
809
|
+
job.repeatEvery('0 6 * * *');
|
|
810
|
+
|
|
811
|
+
expect(job.attrs.repeatInterval).toBe('0 6 * * *');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('should clear repeatInterval when set to null', async () => {
|
|
815
|
+
agenda.define('repeat-clear-test', jobProcessor);
|
|
816
|
+
const job = await agenda.create('repeat-clear-test');
|
|
817
|
+
job.repeatEvery('5 minutes');
|
|
818
|
+
await job.save();
|
|
819
|
+
expect(job.attrs.repeatInterval).toBe('5 minutes');
|
|
820
|
+
|
|
821
|
+
job.repeatEvery('');
|
|
822
|
+
await job.save();
|
|
823
|
+
expect(job.attrs.repeatInterval).toBe('');
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
describe('repeatAt', () => {
|
|
828
|
+
it('should set repeatAt time', async () => {
|
|
829
|
+
const job = agenda.create('repeat-at-test');
|
|
830
|
+
job.repeatAt('3:30pm');
|
|
831
|
+
|
|
832
|
+
expect(job.attrs.repeatAt).toBe('3:30pm');
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
describe('schedule', () => {
|
|
837
|
+
it('should set nextRunAt from Date', async () => {
|
|
838
|
+
const job = agenda.create('schedule-date-test');
|
|
839
|
+
const when = new Date(Date.now() + 60000);
|
|
840
|
+
job.schedule(when);
|
|
841
|
+
|
|
842
|
+
expect(job.attrs.nextRunAt?.getTime()).toBe(when.getTime());
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('should set nextRunAt from string', async () => {
|
|
846
|
+
const job = agenda.create('schedule-string-test');
|
|
847
|
+
job.schedule('in 5 minutes');
|
|
848
|
+
|
|
849
|
+
expect(job.attrs.nextRunAt).toBeDefined();
|
|
850
|
+
expect(job.attrs.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
describe('priority', () => {
|
|
855
|
+
it('should set priority number', async () => {
|
|
856
|
+
const job = agenda.create('priority-num-test');
|
|
857
|
+
job.priority(10);
|
|
858
|
+
|
|
859
|
+
expect(job.attrs.priority).toBe(10);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it('should parse priority string', async () => {
|
|
863
|
+
const job = agenda.create('priority-str-test');
|
|
864
|
+
job.priority('high');
|
|
865
|
+
|
|
866
|
+
expect(job.attrs.priority).toBe(10);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('should support negative priority', async () => {
|
|
870
|
+
const job = agenda.create('priority-neg-test');
|
|
871
|
+
job.priority(-5);
|
|
872
|
+
|
|
873
|
+
expect(job.attrs.priority).toBe(-5);
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
describe('remove', () => {
|
|
878
|
+
it('should remove job from database', async () => {
|
|
879
|
+
const job = await agenda.now('remove-test');
|
|
880
|
+
const id = job.attrs._id;
|
|
881
|
+
|
|
882
|
+
await job.remove();
|
|
883
|
+
|
|
884
|
+
const result = await agenda.queryJobs({ id: id?.toString() });
|
|
885
|
+
expect(result.jobs.length).toBe(0);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
describe('run', () => {
|
|
890
|
+
it('should call the job processor directly', async () => {
|
|
891
|
+
let called = false;
|
|
892
|
+
agenda.define('run-direct-test', async () => {
|
|
893
|
+
called = true;
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
const job = agenda.create('run-direct-test');
|
|
897
|
+
await job.save(); // Save first to get an ID
|
|
898
|
+
await job.run();
|
|
899
|
+
|
|
900
|
+
expect(called).toBe(true);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('should pass the job instance to the processor', async () => {
|
|
904
|
+
let receivedJob: Job | undefined;
|
|
905
|
+
agenda.define('run-job-instance-test', async (j: Job) => {
|
|
906
|
+
receivedJob = j;
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
const job = agenda.create('run-job-instance-test', { foo: 'bar' });
|
|
910
|
+
await job.save(); // Save first to get an ID
|
|
911
|
+
await job.run();
|
|
912
|
+
|
|
913
|
+
expect(receivedJob).toBeDefined();
|
|
914
|
+
expect(receivedJob?.attrs.data).toEqual({ foo: 'bar' });
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
describe('touch', () => {
|
|
919
|
+
it('should update lockedAt', async () => {
|
|
920
|
+
let touchedLockedAt: Date | undefined;
|
|
921
|
+
let initialLockedAt: Date | undefined;
|
|
922
|
+
|
|
923
|
+
agenda.define('touch-test', async (job: Job) => {
|
|
924
|
+
initialLockedAt = job.attrs.lockedAt;
|
|
925
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
926
|
+
await job.touch();
|
|
927
|
+
touchedLockedAt = job.attrs.lockedAt;
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
await agenda.start();
|
|
931
|
+
await runJobAndWait(agenda, 'touch-test', 'complete:touch-test');
|
|
932
|
+
expect(initialLockedAt).toBeDefined();
|
|
933
|
+
expect(touchedLockedAt).toBeDefined();
|
|
934
|
+
expect(touchedLockedAt!.getTime()).toBeGreaterThan(initialLockedAt!.getTime());
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('should persist progress value', async () => {
|
|
938
|
+
let jobId: string | undefined;
|
|
939
|
+
|
|
940
|
+
agenda.define('touch-progress-test', async (job: Job) => {
|
|
941
|
+
jobId = job.attrs._id?.toString();
|
|
942
|
+
await job.touch(50);
|
|
943
|
+
expect(job.attrs.progress).toBe(50);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
await agenda.start();
|
|
947
|
+
await agenda.now('touch-progress-test');
|
|
948
|
+
|
|
949
|
+
await waitForEvent(agenda, 'complete:touch-progress-test');
|
|
950
|
+
expect(jobId).toBeDefined();
|
|
951
|
+
|
|
952
|
+
const result = await agenda.queryJobs({ id: jobId });
|
|
953
|
+
expect(result.jobs[0].progress).toBe(50);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it('should prevent long-running job from being re-locked after touch', async () => {
|
|
957
|
+
const lockLifetime = 500; // 500ms lock lifetime
|
|
958
|
+
let startCount = 0;
|
|
959
|
+
|
|
960
|
+
agenda.define(
|
|
961
|
+
'touch-relock-test',
|
|
962
|
+
async (job: Job) => {
|
|
963
|
+
startCount++;
|
|
964
|
+
// Run for 3x the lockLifetime, touching every 100ms
|
|
965
|
+
const iterations = Math.ceil((lockLifetime * 3) / 100);
|
|
966
|
+
for (let i = 0; i < iterations; i++) {
|
|
967
|
+
await delay(100);
|
|
968
|
+
await job.touch();
|
|
969
|
+
}
|
|
970
|
+
},
|
|
971
|
+
{ lockLifetime }
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
agenda.processEvery(200);
|
|
975
|
+
await agenda.start();
|
|
976
|
+
await agenda.now('touch-relock-test');
|
|
977
|
+
|
|
978
|
+
// Wait for 3x lockLifetime + buffer for the job to finish
|
|
979
|
+
await delay(lockLifetime * 3 + 500);
|
|
980
|
+
await agenda.stop();
|
|
981
|
+
|
|
982
|
+
// The job should have started exactly once — touch() keeps the lock alive
|
|
983
|
+
expect(startCount).toBe(1);
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
describe('fail', () => {
|
|
988
|
+
it('should set failure fields in memory', async () => {
|
|
989
|
+
const job = await agenda.now('fail-mark-test');
|
|
990
|
+
job.fail(new Error('Test failure'));
|
|
991
|
+
|
|
992
|
+
// fail() sets the in-memory attrs
|
|
993
|
+
expect(job.attrs.failReason).toBe('Test failure');
|
|
994
|
+
expect(job.attrs.failedAt).toBeDefined();
|
|
995
|
+
expect(job.attrs.failCount).toBe(1);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('should increment failCount on multiple failures', async () => {
|
|
999
|
+
const job = await agenda.now('fail-count-test');
|
|
1000
|
+
job.fail(new Error('First failure'));
|
|
1001
|
+
job.fail(new Error('Second failure'));
|
|
1002
|
+
|
|
1003
|
+
expect(job.attrs.failCount).toBe(2);
|
|
1004
|
+
expect(job.attrs.failReason).toBe('Second failure');
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
describe('save', () => {
|
|
1009
|
+
it('should persist job to database', async () => {
|
|
1010
|
+
const job = agenda.create('save-test', { value: 42 });
|
|
1011
|
+
await job.save();
|
|
1012
|
+
|
|
1013
|
+
expect(job.attrs._id).toBeDefined();
|
|
1014
|
+
|
|
1015
|
+
const result = await agenda.queryJobs({ name: 'save-test' });
|
|
1016
|
+
expect(result.jobs.length).toBe(1);
|
|
1017
|
+
expect((result.jobs[0].data as { value: number }).value).toBe(42);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it('should update existing job', async () => {
|
|
1021
|
+
const job = await agenda.now('save-update-test', { value: 1 });
|
|
1022
|
+
job.attrs.data = { value: 2 };
|
|
1023
|
+
await job.save();
|
|
1024
|
+
|
|
1025
|
+
const result = await agenda.queryJobs({ name: 'save-update-test' });
|
|
1026
|
+
expect(result.jobs.length).toBe(1);
|
|
1027
|
+
expect((result.jobs[0].data as { value: number }).value).toBe(2);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it('should return the job', async () => {
|
|
1031
|
+
const job = agenda.create('save-return-test');
|
|
1032
|
+
const result = await job.save();
|
|
1033
|
+
expect(result).toBe(job);
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
describe('async/callback job handling', () => {
|
|
1039
|
+
it('should allow async functions', async () => {
|
|
1040
|
+
let finished = false;
|
|
1041
|
+
let successCalled = false;
|
|
1042
|
+
|
|
1043
|
+
agenda.define('async-job', async () => {
|
|
1044
|
+
await delay(5);
|
|
1045
|
+
finished = true;
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
agenda.once('success:async-job', () => {
|
|
1049
|
+
successCalled = true;
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
const job = agenda.create('async-job');
|
|
1053
|
+
await job.save();
|
|
1054
|
+
|
|
1055
|
+
expect(finished).toBe(false);
|
|
1056
|
+
await job.run();
|
|
1057
|
+
expect(successCalled).toBe(true);
|
|
1058
|
+
expect(finished).toBe(true);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('should handle errors from async functions', async () => {
|
|
1062
|
+
let failCalled = false;
|
|
1063
|
+
let failError: Error | undefined;
|
|
1064
|
+
const err = new Error('async failure');
|
|
1065
|
+
|
|
1066
|
+
agenda.define('async-fail-job', async () => {
|
|
1067
|
+
await delay(5);
|
|
1068
|
+
throw err;
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
agenda.once('fail:async-fail-job', (error: Error) => {
|
|
1072
|
+
failCalled = true;
|
|
1073
|
+
failError = error;
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const job = agenda.create('async-fail-job');
|
|
1077
|
+
await job.save();
|
|
1078
|
+
await job.run();
|
|
1079
|
+
|
|
1080
|
+
expect(failCalled).toBe(true);
|
|
1081
|
+
expect(failError?.message).toBe('async failure');
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('should wait for callback even if function is async', async () => {
|
|
1085
|
+
let finishedCb = false;
|
|
1086
|
+
let successCalled = false;
|
|
1087
|
+
|
|
1088
|
+
agenda.define('async-cb-job', async (_job, cb) => {
|
|
1089
|
+
(async () => {
|
|
1090
|
+
await delay(5);
|
|
1091
|
+
finishedCb = true;
|
|
1092
|
+
cb();
|
|
1093
|
+
})();
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
agenda.once('success:async-cb-job', () => {
|
|
1097
|
+
successCalled = true;
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const job = agenda.create('async-cb-job');
|
|
1101
|
+
await job.save();
|
|
1102
|
+
await job.run();
|
|
1103
|
+
|
|
1104
|
+
expect(finishedCb).toBe(true);
|
|
1105
|
+
expect(successCalled).toBe(true);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it('should use callback error if async function did not reject', async () => {
|
|
1109
|
+
let failCalled = false;
|
|
1110
|
+
let failError: Error | undefined;
|
|
1111
|
+
const err = new Error('callback failure');
|
|
1112
|
+
|
|
1113
|
+
agenda.define('async-cb-error-job', async (_job, cb) => {
|
|
1114
|
+
(async () => {
|
|
1115
|
+
await delay(5);
|
|
1116
|
+
cb(err);
|
|
1117
|
+
})();
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
agenda.once('fail:async-cb-error-job', (error: Error) => {
|
|
1121
|
+
failCalled = true;
|
|
1122
|
+
failError = error;
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
const job = agenda.create('async-cb-error-job');
|
|
1126
|
+
await job.save();
|
|
1127
|
+
await job.run();
|
|
1128
|
+
|
|
1129
|
+
expect(failCalled).toBe(true);
|
|
1130
|
+
expect(failError?.message).toBe('callback failure');
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it('should favor async error over callback error if async comes first', async () => {
|
|
1134
|
+
let failCalled = false;
|
|
1135
|
+
let failError: Error | undefined;
|
|
1136
|
+
const fnErr = new Error('functionFailure');
|
|
1137
|
+
const cbErr = new Error('callbackFailure');
|
|
1138
|
+
|
|
1139
|
+
agenda.define('async-first-error-job', async (_job, cb) => {
|
|
1140
|
+
(async () => {
|
|
1141
|
+
await delay(5);
|
|
1142
|
+
cb(cbErr);
|
|
1143
|
+
})();
|
|
1144
|
+
throw fnErr;
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
agenda.on('fail:async-first-error-job', (error: Error) => {
|
|
1148
|
+
failCalled = true;
|
|
1149
|
+
failError = error;
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
const job = agenda.create('async-first-error-job');
|
|
1153
|
+
await job.save();
|
|
1154
|
+
await job.run();
|
|
1155
|
+
|
|
1156
|
+
expect(failCalled).toBe(true);
|
|
1157
|
+
expect(failError?.message).toBe('functionFailure');
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('should favor callback error over async error if callback comes first', async () => {
|
|
1161
|
+
let failCalled = false;
|
|
1162
|
+
let failError: Error | undefined;
|
|
1163
|
+
const fnErr = new Error('functionFailure');
|
|
1164
|
+
const cbErr = new Error('callbackFailure');
|
|
1165
|
+
|
|
1166
|
+
agenda.define('cb-first-error-job', async (_job, cb) => {
|
|
1167
|
+
cb(cbErr);
|
|
1168
|
+
await delay(5);
|
|
1169
|
+
throw fnErr;
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
agenda.on('fail:cb-first-error-job', (error: Error) => {
|
|
1173
|
+
failCalled = true;
|
|
1174
|
+
failError = error;
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
const job = agenda.create('cb-first-error-job');
|
|
1178
|
+
await job.save();
|
|
1179
|
+
await job.run();
|
|
1180
|
+
|
|
1181
|
+
expect(failCalled).toBe(true);
|
|
1182
|
+
expect(failError?.message).toBe('callbackFailure');
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
describe('events', () => {
|
|
1187
|
+
it('should emit start event when job begins', async () => {
|
|
1188
|
+
let startEmitted = false;
|
|
1189
|
+
agenda.define('start-event-test', async () => {});
|
|
1190
|
+
|
|
1191
|
+
agenda.on('start:start-event-test', () => {
|
|
1192
|
+
startEmitted = true;
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
await agenda.start();
|
|
1196
|
+
await runJobAndWait(agenda, 'start-event-test', 'complete:start-event-test');
|
|
1197
|
+
expect(startEmitted).toBe(true);
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
it('should emit complete event when job finishes', async () => {
|
|
1201
|
+
let completeEmitted = false;
|
|
1202
|
+
agenda.define('complete-event-test', async () => {});
|
|
1203
|
+
|
|
1204
|
+
agenda.on('complete:complete-event-test', () => {
|
|
1205
|
+
completeEmitted = true;
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
await agenda.start();
|
|
1209
|
+
await runJobAndWait(agenda, 'complete-event-test', 'complete:complete-event-test');
|
|
1210
|
+
expect(completeEmitted).toBe(true);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it('should emit generic start event', async () => {
|
|
1214
|
+
let genericStartEmitted = false;
|
|
1215
|
+
agenda.define('generic-start-test', async () => {});
|
|
1216
|
+
|
|
1217
|
+
agenda.on('start', () => {
|
|
1218
|
+
genericStartEmitted = true;
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
await agenda.start();
|
|
1222
|
+
await runJobAndWait(agenda, 'generic-start-test', 'complete:generic-start-test');
|
|
1223
|
+
expect(genericStartEmitted).toBe(true);
|
|
1224
|
+
});
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
describe('drain', () => {
|
|
1228
|
+
it('should wait for all running jobs to complete', async () => {
|
|
1229
|
+
const completedJobs: string[] = [];
|
|
1230
|
+
|
|
1231
|
+
agenda.define('drain-test', async (job: Job) => {
|
|
1232
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1233
|
+
completedJobs.push(job.attrs._id?.toString() || 'unknown');
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
await agenda.start();
|
|
1237
|
+
|
|
1238
|
+
// Set up listener before scheduling to avoid race condition
|
|
1239
|
+
const startEvent = waitForEvent(agenda, 'start:drain-test');
|
|
1240
|
+
|
|
1241
|
+
// Schedule multiple jobs
|
|
1242
|
+
await Promise.all([
|
|
1243
|
+
agenda.now('drain-test'),
|
|
1244
|
+
agenda.now('drain-test'),
|
|
1245
|
+
agenda.now('drain-test')
|
|
1246
|
+
]);
|
|
1247
|
+
|
|
1248
|
+
// Wait for at least one job to start
|
|
1249
|
+
await startEvent;
|
|
1250
|
+
|
|
1251
|
+
// Drain should wait for all to complete
|
|
1252
|
+
const result = await agenda.drain();
|
|
1253
|
+
|
|
1254
|
+
expect(completedJobs.length).toBe(3);
|
|
1255
|
+
expect(result.completed).toBe(3);
|
|
1256
|
+
expect(result.running).toBe(0);
|
|
1257
|
+
expect(result.timedOut).toBe(false);
|
|
1258
|
+
expect(result.aborted).toBe(false);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it('should wait for running job to finish', async () => {
|
|
1262
|
+
let jobStarted = false;
|
|
1263
|
+
let jobFinished = false;
|
|
1264
|
+
|
|
1265
|
+
agenda.define('drainWaitJob', async () => {
|
|
1266
|
+
jobStarted = true;
|
|
1267
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1268
|
+
jobFinished = true;
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
await agenda.start();
|
|
1272
|
+
// Set up listener before scheduling to avoid race condition
|
|
1273
|
+
await runJobAndWait(agenda, 'drainWaitJob', 'start:drainWaitJob');
|
|
1274
|
+
|
|
1275
|
+
expect(jobStarted).toBe(true);
|
|
1276
|
+
expect(jobFinished).toBe(false);
|
|
1277
|
+
|
|
1278
|
+
// Call drain - should wait for job to complete
|
|
1279
|
+
const result = await agenda.drain();
|
|
1280
|
+
expect(jobFinished).toBe(true);
|
|
1281
|
+
expect(result.completed).toBe(1);
|
|
1282
|
+
expect(result.running).toBe(0);
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
it('should resolve immediately if no jobs are running', async () => {
|
|
1286
|
+
await agenda.start();
|
|
1287
|
+
// No jobs scheduled, drain should resolve immediately
|
|
1288
|
+
const result = await agenda.drain();
|
|
1289
|
+
expect(result.completed).toBe(0);
|
|
1290
|
+
expect(result.running).toBe(0);
|
|
1291
|
+
expect(result.timedOut).toBe(false);
|
|
1292
|
+
expect(result.aborted).toBe(false);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it('should timeout and return running jobs count', async () => {
|
|
1296
|
+
let jobStarted = false;
|
|
1297
|
+
|
|
1298
|
+
agenda.define('drain-timeout-test', async () => {
|
|
1299
|
+
jobStarted = true;
|
|
1300
|
+
// Job takes longer than timeout
|
|
1301
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
await agenda.start();
|
|
1305
|
+
await agenda.now('drain-timeout-test');
|
|
1306
|
+
|
|
1307
|
+
// Wait for job to start
|
|
1308
|
+
await waitForEvent(agenda, 'start:drain-timeout-test');
|
|
1309
|
+
expect(jobStarted).toBe(true);
|
|
1310
|
+
|
|
1311
|
+
// Drain with short timeout
|
|
1312
|
+
const result = await agenda.drain(100);
|
|
1313
|
+
|
|
1314
|
+
expect(result.timedOut).toBe(true);
|
|
1315
|
+
expect(result.aborted).toBe(false);
|
|
1316
|
+
expect(result.running).toBe(1);
|
|
1317
|
+
expect(result.completed).toBe(0);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it('should accept timeout as options object', async () => {
|
|
1321
|
+
agenda.define('drain-timeout-opts-test', async () => {
|
|
1322
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
await agenda.start();
|
|
1326
|
+
await agenda.now('drain-timeout-opts-test');
|
|
1327
|
+
|
|
1328
|
+
await waitForEvent(agenda, 'start:drain-timeout-opts-test');
|
|
1329
|
+
|
|
1330
|
+
const result = await agenda.drain({ timeout: 100 });
|
|
1331
|
+
|
|
1332
|
+
expect(result.timedOut).toBe(true);
|
|
1333
|
+
expect(result.running).toBe(1);
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
it('should abort when signal is triggered', async () => {
|
|
1337
|
+
let jobStarted = false;
|
|
1338
|
+
|
|
1339
|
+
agenda.define('drain-abort-test', async () => {
|
|
1340
|
+
jobStarted = true;
|
|
1341
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
await agenda.start();
|
|
1345
|
+
await agenda.now('drain-abort-test');
|
|
1346
|
+
|
|
1347
|
+
await waitForEvent(agenda, 'start:drain-abort-test');
|
|
1348
|
+
expect(jobStarted).toBe(true);
|
|
1349
|
+
|
|
1350
|
+
const controller = new AbortController();
|
|
1351
|
+
|
|
1352
|
+
// Abort after 100ms
|
|
1353
|
+
setTimeout(() => controller.abort(), 100);
|
|
1354
|
+
|
|
1355
|
+
const result = await agenda.drain({ signal: controller.signal });
|
|
1356
|
+
|
|
1357
|
+
expect(result.aborted).toBe(true);
|
|
1358
|
+
expect(result.timedOut).toBe(false);
|
|
1359
|
+
expect(result.running).toBe(1);
|
|
1360
|
+
expect(result.completed).toBe(0);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it('should resolve immediately if signal is already aborted', async () => {
|
|
1364
|
+
agenda.define('drain-pre-aborted-test', async () => {
|
|
1365
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
await agenda.start();
|
|
1369
|
+
await agenda.now('drain-pre-aborted-test');
|
|
1370
|
+
|
|
1371
|
+
await waitForEvent(agenda, 'start:drain-pre-aborted-test');
|
|
1372
|
+
|
|
1373
|
+
const controller = new AbortController();
|
|
1374
|
+
controller.abort(); // Abort before calling drain
|
|
1375
|
+
|
|
1376
|
+
const result = await agenda.drain({ signal: controller.signal });
|
|
1377
|
+
|
|
1378
|
+
expect(result.aborted).toBe(true);
|
|
1379
|
+
expect(result.running).toBe(1);
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it('should support both timeout and signal together', async () => {
|
|
1383
|
+
agenda.define('drain-both-test', async () => {
|
|
1384
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
await agenda.start();
|
|
1388
|
+
await agenda.now('drain-both-test');
|
|
1389
|
+
|
|
1390
|
+
await waitForEvent(agenda, 'start:drain-both-test');
|
|
1391
|
+
|
|
1392
|
+
const controller = new AbortController();
|
|
1393
|
+
|
|
1394
|
+
// Signal aborts first (50ms) before timeout (200ms)
|
|
1395
|
+
setTimeout(() => controller.abort(), 50);
|
|
1396
|
+
|
|
1397
|
+
const result = await agenda.drain({ timeout: 200, signal: controller.signal });
|
|
1398
|
+
|
|
1399
|
+
expect(result.aborted).toBe(true);
|
|
1400
|
+
expect(result.timedOut).toBe(false);
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
describe('stop', () => {
|
|
1405
|
+
it('should stop agenda', async () => {
|
|
1406
|
+
await agenda.start();
|
|
1407
|
+
await agenda.stop();
|
|
1408
|
+
// Agenda should be stopped after stop - if we get here, it worked
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
describe('repeating jobs', () => {
|
|
1413
|
+
it('should reschedule job after completion when using repeatEvery', async () => {
|
|
1414
|
+
let runCount = 0;
|
|
1415
|
+
agenda.define('repeat-test', async () => {
|
|
1416
|
+
runCount++;
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
// Use 1 second interval (humanInterval recognizes "1 second")
|
|
1420
|
+
await agenda.every('1 second', 'repeat-test');
|
|
1421
|
+
await agenda.start();
|
|
1422
|
+
|
|
1423
|
+
// Wait for multiple runs (need >2 seconds for 2+ runs with 1 second interval)
|
|
1424
|
+
await new Promise(resolve => setTimeout(resolve, 2500));
|
|
1425
|
+
|
|
1426
|
+
await agenda.stop();
|
|
1427
|
+
expect(runCount).toBeGreaterThanOrEqual(2);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it('should run job at scheduled cron time', async () => {
|
|
1431
|
+
let processed = false;
|
|
1432
|
+
agenda.define('cron-test', async () => {
|
|
1433
|
+
processed = true;
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
// Schedule to run every second
|
|
1437
|
+
await agenda.every('* * * * * *', 'cron-test');
|
|
1438
|
+
await agenda.start();
|
|
1439
|
+
|
|
1440
|
+
await waitForEvent(agenda, 'complete:cron-test', 3000);
|
|
1441
|
+
expect(processed).toBe(true);
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
describe('error handling', () => {
|
|
1446
|
+
it('should emit error event when job throws', async () => {
|
|
1447
|
+
let errorEmitted = false;
|
|
1448
|
+
let errorJob: Job | undefined;
|
|
1449
|
+
|
|
1450
|
+
agenda.define('error-test', async () => {
|
|
1451
|
+
throw new Error('Job error');
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
agenda.on('fail:error-test', (err: Error, job: Job) => {
|
|
1455
|
+
errorEmitted = true;
|
|
1456
|
+
errorJob = job;
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
await agenda.start();
|
|
1460
|
+
await runJobAndWait(agenda, 'error-test', 'complete:error-test');
|
|
1461
|
+
expect(errorEmitted).toBe(true);
|
|
1462
|
+
expect(errorJob?.attrs.name).toBe('error-test');
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it('should save failReason and failedAt when job fails', async () => {
|
|
1466
|
+
agenda.define('fail-save-test', async () => {
|
|
1467
|
+
throw new Error('Failure reason');
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
await agenda.start();
|
|
1471
|
+
await runJobAndWait(agenda, 'fail-save-test', 'complete:fail-save-test');
|
|
1472
|
+
|
|
1473
|
+
const result = await agenda.queryJobs({ name: 'fail-save-test' });
|
|
1474
|
+
expect(result.jobs[0].failReason).toBe('Failure reason');
|
|
1475
|
+
expect(result.jobs[0].failedAt).toBeDefined();
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it('should increment failCount on repeated failures', async () => {
|
|
1479
|
+
agenda.define('fail-count-test', async () => {
|
|
1480
|
+
throw new Error('Repeated failure');
|
|
1481
|
+
});
|
|
1482
|
+
// The fail event emits (error, job), so second param is the job
|
|
1483
|
+
agenda.on('fail:fail-count-test', (_err, job) => {
|
|
1484
|
+
job.attrs.nextRunAt = new Date();
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
await agenda.start();
|
|
1488
|
+
// Set up listeners before scheduling to avoid race condition
|
|
1489
|
+
const failEvents = waitForEvents(agenda, 'fail:fail-count-test', 2, 10000);
|
|
1490
|
+
const completeEvents = waitForEvents(agenda, 'complete:fail-count-test', 2, 10000);
|
|
1491
|
+
await agenda.now('fail-count-test');
|
|
1492
|
+
|
|
1493
|
+
// Wait for 2 failures (give enough time for job pickup and retries)
|
|
1494
|
+
await Promise.all([
|
|
1495
|
+
failEvents, // fail is called before we save the result to the db
|
|
1496
|
+
completeEvents // therefore we also wait for complete events (emitted after the db commit)
|
|
1497
|
+
]);
|
|
1498
|
+
|
|
1499
|
+
const result = await agenda.queryJobs({ name: 'fail-count-test' });
|
|
1500
|
+
expect(result.jobs[0].failCount).toBeGreaterThanOrEqual(2);
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it('should not run failed jobs again unless rescheduled', async () => {
|
|
1504
|
+
let runCount = 0;
|
|
1505
|
+
|
|
1506
|
+
agenda.define('no-rerun-failed', async () => {
|
|
1507
|
+
runCount++;
|
|
1508
|
+
throw new Error('Intentional failure');
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
await agenda.start();
|
|
1512
|
+
const event = waitForEvent(agenda, 'fail:no-rerun-failed', 10000);
|
|
1513
|
+
await agenda.now('no-rerun-failed');
|
|
1514
|
+
|
|
1515
|
+
await event;
|
|
1516
|
+
// Wait a bit to ensure job is not rerun
|
|
1517
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1518
|
+
|
|
1519
|
+
expect(runCount).toBe(1);
|
|
1520
|
+
|
|
1521
|
+
// Verify job still exists but has no nextRunAt (won't run again)
|
|
1522
|
+
const result = await agenda.queryJobs({ name: 'no-rerun-failed' });
|
|
1523
|
+
expect(result.jobs.length).toBe(1);
|
|
1524
|
+
expect(result.jobs[0].failReason).toBe('Intentional failure');
|
|
1525
|
+
// nextRunAt should be null/undefined after failure (job is done, not rescheduled)
|
|
1526
|
+
expect(result.jobs[0].nextRunAt).toBeFalsy();
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
it('should not cause unhandled promise rejection on job timeout', async () => {
|
|
1530
|
+
const unhandledRejections: Error[] = [];
|
|
1531
|
+
const rejectionHandler = (err: Error) => {
|
|
1532
|
+
console.error('unhandledRejection', err);
|
|
1533
|
+
unhandledRejections.push(err);
|
|
1534
|
+
};
|
|
1535
|
+
process.on('unhandledRejection', rejectionHandler);
|
|
1536
|
+
|
|
1537
|
+
try {
|
|
1538
|
+
agenda.define(
|
|
1539
|
+
'timeout-test',
|
|
1540
|
+
async () => {
|
|
1541
|
+
// Job runs longer than lockLifetime
|
|
1542
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1543
|
+
},
|
|
1544
|
+
{ lockLifetime: 100 } // Very short lock lifetime
|
|
1545
|
+
);
|
|
1546
|
+
|
|
1547
|
+
agenda.on('error', (_err) => {
|
|
1548
|
+
// err handler is required
|
|
1549
|
+
})
|
|
1550
|
+
await agenda.start();
|
|
1551
|
+
// Set up listener before scheduling to avoid race condition
|
|
1552
|
+
await runJobAndWait(agenda, 'timeout-test', 'start:timeout-test');
|
|
1553
|
+
await new Promise(resolve => setTimeout(resolve, 700));
|
|
1554
|
+
|
|
1555
|
+
// Check no unhandled rejections occurred
|
|
1556
|
+
expect(unhandledRejections.length).toBe(0);
|
|
1557
|
+
} finally {
|
|
1558
|
+
process.off('unhandledRejection', rejectionHandler);
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
// Notification channel integration tests - only run if channel is provided
|
|
1564
|
+
if (config.createNotificationChannel) {
|
|
1565
|
+
describe('notification channel integration', () => {
|
|
1566
|
+
let notificationChannel: NotificationChannel;
|
|
1567
|
+
let agendaWithChannel: Agenda;
|
|
1568
|
+
|
|
1569
|
+
beforeEach(async () => {
|
|
1570
|
+
if (config.createNotificationChannel) {
|
|
1571
|
+
notificationChannel = await config.createNotificationChannel();
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
afterEach(async () => {
|
|
1576
|
+
if (agendaWithChannel) {
|
|
1577
|
+
await agendaWithChannel.stop();
|
|
1578
|
+
}
|
|
1579
|
+
if (config.cleanupNotificationChannel && notificationChannel) {
|
|
1580
|
+
await config.cleanupNotificationChannel(notificationChannel);
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
it('should accept notification channel in constructor', async () => {
|
|
1585
|
+
agendaWithChannel = new Agenda({
|
|
1586
|
+
backend,
|
|
1587
|
+
notificationChannel
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
expect(agendaWithChannel.hasNotificationChannel()).toBe(true);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it('should accept notification channel via notifyVia method', async ({ skip }) => {
|
|
1594
|
+
// Skip if backend already provides a notification channel,
|
|
1595
|
+
// since Agenda picks it up automatically
|
|
1596
|
+
if (backend.notificationChannel) {
|
|
1597
|
+
skip();
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
agendaWithChannel = new Agenda({ backend });
|
|
1602
|
+
expect(agendaWithChannel.hasNotificationChannel()).toBe(false);
|
|
1603
|
+
|
|
1604
|
+
agendaWithChannel.notifyVia(notificationChannel);
|
|
1605
|
+
expect(agendaWithChannel.hasNotificationChannel()).toBe(true);
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
it('should throw when setting notification channel after start', async () => {
|
|
1609
|
+
agendaWithChannel = new Agenda({ backend });
|
|
1610
|
+
agendaWithChannel.define('test', async () => {});
|
|
1611
|
+
await agendaWithChannel.start();
|
|
1612
|
+
|
|
1613
|
+
expect(() => {
|
|
1614
|
+
agendaWithChannel.notifyVia(notificationChannel);
|
|
1615
|
+
}).toThrow(/already running/i);
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
it('should connect and disconnect notification channel on start/stop', async () => {
|
|
1619
|
+
agendaWithChannel = new Agenda({
|
|
1620
|
+
backend,
|
|
1621
|
+
notificationChannel
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
expect(notificationChannel.state).toBe('disconnected');
|
|
1625
|
+
|
|
1626
|
+
agendaWithChannel.define('test', async () => {});
|
|
1627
|
+
await agendaWithChannel.start();
|
|
1628
|
+
|
|
1629
|
+
expect(notificationChannel.state).toBe('connected');
|
|
1630
|
+
|
|
1631
|
+
await agendaWithChannel.stop();
|
|
1632
|
+
|
|
1633
|
+
expect(notificationChannel.state).toBe('disconnected');
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it('should process jobs faster with notification channel', async () => {
|
|
1637
|
+
let jobProcessed = false;
|
|
1638
|
+
|
|
1639
|
+
// Create agenda with long processEvery but with notification channel
|
|
1640
|
+
agendaWithChannel = new Agenda({
|
|
1641
|
+
backend,
|
|
1642
|
+
processEvery: 10000, // 10 seconds - way longer than our test
|
|
1643
|
+
notificationChannel
|
|
1644
|
+
});
|
|
1645
|
+
await agendaWithChannel.ready;
|
|
1646
|
+
|
|
1647
|
+
agendaWithChannel.define('fast-job', async () => {
|
|
1648
|
+
jobProcessed = true;
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
await agendaWithChannel.start();
|
|
1652
|
+
|
|
1653
|
+
// Schedule a job - notification should trigger immediate processing
|
|
1654
|
+
await agendaWithChannel.now('fast-job');
|
|
1655
|
+
|
|
1656
|
+
// Wait a short time for notification-based processing
|
|
1657
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1658
|
+
|
|
1659
|
+
expect(jobProcessed).toBe(true);
|
|
1660
|
+
});
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// Backend-provided notification channel tests
|
|
1665
|
+
// Runs automatically when the backend provides a built-in notification channel
|
|
1666
|
+
// (e.g., PostgresBackend with LISTEN/NOTIFY, RedisBackend with Pub/Sub)
|
|
1667
|
+
describe('backend notification channel integration', () => {
|
|
1668
|
+
let agendaWithNotify: Agenda;
|
|
1669
|
+
|
|
1670
|
+
afterEach(async () => {
|
|
1671
|
+
if (agendaWithNotify) {
|
|
1672
|
+
await agendaWithNotify.stop();
|
|
1673
|
+
agendaWithNotify = undefined!;
|
|
1674
|
+
}
|
|
1675
|
+
await config.clearJobs(backend);
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
it('should automatically use the backend notification channel', async ({ skip }) => {
|
|
1679
|
+
if (!backend.notificationChannel) {
|
|
1680
|
+
skip();
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
agendaWithNotify = new Agenda({ backend });
|
|
1685
|
+
await agendaWithNotify.ready;
|
|
1686
|
+
|
|
1687
|
+
expect(agendaWithNotify.hasNotificationChannel()).toBe(true);
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
it('should process job immediately via backend notification channel with long processEvery', async ({ skip }) => {
|
|
1691
|
+
if (!backend.notificationChannel) {
|
|
1692
|
+
skip();
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Simulate a real-world setup: 5 minute polling interval
|
|
1697
|
+
// The notification channel should bypass the interval entirely
|
|
1698
|
+
agendaWithNotify = new Agenda({
|
|
1699
|
+
backend,
|
|
1700
|
+
processEvery: 5 * 60 * 1000 // 5 minutes
|
|
1701
|
+
});
|
|
1702
|
+
await agendaWithNotify.ready;
|
|
1703
|
+
|
|
1704
|
+
let jobRan = false;
|
|
1705
|
+
agendaWithNotify.define('notify-test-job', async () => {
|
|
1706
|
+
jobRan = true;
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
await agendaWithNotify.start();
|
|
1710
|
+
|
|
1711
|
+
// Wait for the initial process() scan to complete so the processor is idle
|
|
1712
|
+
await delay(200);
|
|
1713
|
+
|
|
1714
|
+
// Now schedule a job — the notification channel should trigger immediate pickup
|
|
1715
|
+
const successPromise = waitForEvent(agendaWithNotify, 'success:notify-test-job', 5000);
|
|
1716
|
+
await agendaWithNotify.now('notify-test-job');
|
|
1717
|
+
await successPromise;
|
|
1718
|
+
|
|
1719
|
+
expect(jobRan).toBe(true);
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
it('should process job scheduled from a separate agenda instance via notification', async ({ skip }) => {
|
|
1723
|
+
if (!backend.notificationChannel) {
|
|
1724
|
+
skip();
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Simulates the publisher/worker pattern with separate backends
|
|
1729
|
+
// (as in a real multi-process deployment)
|
|
1730
|
+
const publisherBackend = await config.createBackend();
|
|
1731
|
+
|
|
1732
|
+
const worker = new Agenda({
|
|
1733
|
+
backend,
|
|
1734
|
+
processEvery: 5 * 60 * 1000 // 5 minutes
|
|
1735
|
+
});
|
|
1736
|
+
await worker.ready;
|
|
1737
|
+
|
|
1738
|
+
let jobRan = false;
|
|
1739
|
+
worker.define('cross-instance-job', async () => {
|
|
1740
|
+
jobRan = true;
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
await worker.start();
|
|
1744
|
+
|
|
1745
|
+
// Wait for initial scan
|
|
1746
|
+
await delay(200);
|
|
1747
|
+
|
|
1748
|
+
// Separate publisher instance — only schedules jobs, does NOT process them.
|
|
1749
|
+
// This mirrors a common pattern: API server schedules, worker processes.
|
|
1750
|
+
// The publisher does NOT define the job, so it won't try to lock/run it.
|
|
1751
|
+
const publisher = new Agenda({ backend: publisherBackend });
|
|
1752
|
+
await publisher.ready;
|
|
1753
|
+
|
|
1754
|
+
// Start publisher to connect its notification channel
|
|
1755
|
+
await publisher.start();
|
|
1756
|
+
|
|
1757
|
+
// Schedule from the publisher — worker should pick it up via notification
|
|
1758
|
+
const successPromise = waitForEvent(worker, 'success:cross-instance-job', 5000);
|
|
1759
|
+
await publisher.now('cross-instance-job');
|
|
1760
|
+
await successPromise;
|
|
1761
|
+
|
|
1762
|
+
expect(jobRan).toBe(true);
|
|
1763
|
+
|
|
1764
|
+
await publisher.stop();
|
|
1765
|
+
await config.cleanupBackend(publisherBackend);
|
|
1766
|
+
agendaWithNotify = worker; // let afterEach clean up
|
|
1767
|
+
});
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
// Fork mode tests - skip if configured to skip or if forkHelper is not provided
|
|
1771
|
+
if (!config.skip?.forkMode && config.forkHelper) {
|
|
1772
|
+
describe('fork mode', () => {
|
|
1773
|
+
it('should run a job in fork mode', async () => {
|
|
1774
|
+
const agendaFork = new Agenda({
|
|
1775
|
+
backend,
|
|
1776
|
+
forkHelper: config.forkHelper
|
|
1777
|
+
});
|
|
1778
|
+
await agendaFork.ready;
|
|
1779
|
+
|
|
1780
|
+
expect(agendaFork.forkHelper?.path).toBe(config.forkHelper!.path);
|
|
1781
|
+
|
|
1782
|
+
const job = agendaFork.create('some job');
|
|
1783
|
+
job.forkMode(true);
|
|
1784
|
+
job.schedule('now');
|
|
1785
|
+
await job.save();
|
|
1786
|
+
|
|
1787
|
+
const jobData = await backend.repository.getJobById(job.attrs._id!);
|
|
1788
|
+
expect(jobData).toBeDefined();
|
|
1789
|
+
expect(jobData?.fork).toBe(true);
|
|
1790
|
+
|
|
1791
|
+
// Initialize job definition
|
|
1792
|
+
someJobDefinition(agendaFork);
|
|
1793
|
+
|
|
1794
|
+
await agendaFork.start();
|
|
1795
|
+
|
|
1796
|
+
do {
|
|
1797
|
+
await delay(50);
|
|
1798
|
+
} while (await job.isRunning());
|
|
1799
|
+
|
|
1800
|
+
const jobDataFinished = await backend.repository.getJobById(job.attrs._id!);
|
|
1801
|
+
expect(jobDataFinished?.lastFinishedAt).toBeDefined();
|
|
1802
|
+
expect(jobDataFinished?.failReason).toBeUndefined();
|
|
1803
|
+
expect(jobDataFinished?.failCount).toBeUndefined();
|
|
1804
|
+
|
|
1805
|
+
await agendaFork.stop();
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
it('should handle job failure in fork mode', async () => {
|
|
1809
|
+
const agendaFork = new Agenda({
|
|
1810
|
+
backend,
|
|
1811
|
+
forkHelper: config.forkHelper
|
|
1812
|
+
});
|
|
1813
|
+
await agendaFork.ready;
|
|
1814
|
+
|
|
1815
|
+
const job = agendaFork.create('some job', { failIt: 'error' });
|
|
1816
|
+
job.forkMode(true);
|
|
1817
|
+
job.schedule('now');
|
|
1818
|
+
await job.save();
|
|
1819
|
+
|
|
1820
|
+
const jobData = await backend.repository.getJobById(job.attrs._id!);
|
|
1821
|
+
expect(jobData).toBeDefined();
|
|
1822
|
+
expect(jobData?.fork).toBe(true);
|
|
1823
|
+
|
|
1824
|
+
// Initialize job definition
|
|
1825
|
+
someJobDefinition(agendaFork);
|
|
1826
|
+
|
|
1827
|
+
await agendaFork.start();
|
|
1828
|
+
|
|
1829
|
+
do {
|
|
1830
|
+
await delay(50);
|
|
1831
|
+
} while (await job.isRunning());
|
|
1832
|
+
|
|
1833
|
+
const jobDataFinished = await backend.repository.getJobById(job.attrs._id!);
|
|
1834
|
+
expect(jobDataFinished?.lastFinishedAt).toBeDefined();
|
|
1835
|
+
expect(jobDataFinished?.failReason).not.toBeUndefined();
|
|
1836
|
+
expect(jobDataFinished?.failCount).toBe(1);
|
|
1837
|
+
|
|
1838
|
+
await agendaFork.stop();
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
it('should handle process exit in fork mode', async () => {
|
|
1842
|
+
const agendaFork = new Agenda({
|
|
1843
|
+
backend,
|
|
1844
|
+
forkHelper: config.forkHelper
|
|
1845
|
+
});
|
|
1846
|
+
await agendaFork.ready;
|
|
1847
|
+
|
|
1848
|
+
const job = agendaFork.create('some job', { failIt: 'die' });
|
|
1849
|
+
job.forkMode(true);
|
|
1850
|
+
job.schedule('now');
|
|
1851
|
+
await job.save();
|
|
1852
|
+
|
|
1853
|
+
const jobData = await backend.repository.getJobById(job.attrs._id!);
|
|
1854
|
+
expect(jobData).toBeDefined();
|
|
1855
|
+
expect(jobData?.fork).toBe(true);
|
|
1856
|
+
|
|
1857
|
+
// Initialize job definition
|
|
1858
|
+
someJobDefinition(agendaFork);
|
|
1859
|
+
|
|
1860
|
+
await agendaFork.start();
|
|
1861
|
+
|
|
1862
|
+
do {
|
|
1863
|
+
await delay(50);
|
|
1864
|
+
} while (await job.isRunning());
|
|
1865
|
+
|
|
1866
|
+
const jobDataFinished = await backend.repository.getJobById(job.attrs._id!);
|
|
1867
|
+
expect(jobDataFinished?.lastFinishedAt).toBeDefined();
|
|
1868
|
+
expect(jobDataFinished?.failReason).not.toBeUndefined();
|
|
1869
|
+
expect(jobDataFinished?.failCount).toBe(1);
|
|
1870
|
+
|
|
1871
|
+
await agendaFork.stop();
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
}
|