ti2 1.0.94 → 1.0.96
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/.github/workflows/tests.yml +7 -3
- package/api.yml +159 -0
- package/cli.js +106 -0
- package/controllers/README.md +70 -1
- package/controllers/__tests__/admin.js +1 -1
- package/controllers/__tests__/allotments.js +1 -2
- package/controllers/__tests__/app.js +1 -1
- package/controllers/__tests__/bookings-searchProducts.js +533 -0
- package/controllers/__tests__/bookings.js +29 -267
- package/controllers/__tests__/cronjobs.js +394 -0
- package/controllers/__tests__/ping.js +1 -1
- package/controllers/__tests__/productsSearch-background.js +1 -1
- package/controllers/__tests__/user.js +1 -1
- package/controllers/app.js +280 -14
- package/controllers/bookings.js +188 -108
- package/index.js +2 -3
- package/migrations/20240130000000-add-token-to-cronjobs.js +12 -0
- package/migrations/20250528_create_apiCronjobs.js +61 -0
- package/models/apiCronJobs.js +96 -0
- package/package.json +2 -2
- package/test/utils.js +54 -26
- package/worker/__tests__/job-callback.js +0 -22
- package/worker/__tests__/job-plugin.js +207 -0
- package/worker/index.js +191 -108
- package/worker/queue.js +70 -5
|
@@ -45,28 +45,6 @@ describe('worker: Callback job handling', () => {
|
|
|
45
45
|
}));
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
it('should include the correct structure for callback jobs', () => {
|
|
49
|
-
// This test verifies the worker/index.js handles callback jobs correctly
|
|
50
|
-
// by checking the code structure without actually running it
|
|
51
|
-
|
|
52
|
-
// Read the worker code
|
|
53
|
-
const workerCode = require('fs').readFileSync(require('path').resolve(__dirname, '../index.js'), 'utf8');
|
|
54
|
-
|
|
55
|
-
// Verify the worker code includes handling for callback jobs
|
|
56
|
-
expect(workerCode).toContain('if (type === \'callback\')');
|
|
57
|
-
|
|
58
|
-
// Verify it extracts the necessary parameters
|
|
59
|
-
expect(workerCode).toContain('callbackUrl,');
|
|
60
|
-
expect(workerCode).toContain('request,');
|
|
61
|
-
expect(workerCode).toContain('result,');
|
|
62
|
-
|
|
63
|
-
// Verify it calls the sendCallback function
|
|
64
|
-
expect(workerCode).toContain('sendCallback');
|
|
65
|
-
|
|
66
|
-
// Verify it returns the expected result structure
|
|
67
|
-
expect(workerCode).toContain('return { success: true }');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
48
|
it('should use the callback module to send callbacks', async () => {
|
|
71
49
|
// Get a reference to the mocked axios.post function
|
|
72
50
|
const axios = require('axios');
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Mock throng to directly execute the worker function
|
|
2
|
+
jest.mock('throng', () => jest.fn(config => {
|
|
3
|
+
if (typeof config.worker === 'function') {
|
|
4
|
+
config.worker('test-worker-id-plugin', jest.fn());
|
|
5
|
+
} else {
|
|
6
|
+
console.error('Mocked throng (plugin test): config.worker is not a function or not provided.');
|
|
7
|
+
}
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Mock the queue to avoid actual Redis operations and capture the handler
|
|
11
|
+
jest.mock('../queue', () => {
|
|
12
|
+
const original = jest.requireActual('../queue');
|
|
13
|
+
return {
|
|
14
|
+
...original,
|
|
15
|
+
queue: {
|
|
16
|
+
...original.queue,
|
|
17
|
+
process: jest.fn(), // Used to capture the job handler
|
|
18
|
+
},
|
|
19
|
+
addJob: jest.fn().mockResolvedValue('test-plugin-job-id'), // Mock addJob
|
|
20
|
+
saveResult: jest.fn().mockResolvedValue(undefined), // Mock saveResult
|
|
21
|
+
// listJobs, jobStatus etc. can be original or mocked if needed by other tests
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Mock bookingsControllerFactory to provide a mock for postProcess actions
|
|
26
|
+
const mockBookingsPostProcessAction = jest.fn();
|
|
27
|
+
jest.mock('../../controllers/bookings', () => {
|
|
28
|
+
return jest.fn(() => ({
|
|
29
|
+
// This action name must match what's in job.data.postProcess.action
|
|
30
|
+
$updateProductSearchCache: mockBookingsPostProcessAction,
|
|
31
|
+
// Add other methods if the worker's initialization of bookingsCtrl calls them
|
|
32
|
+
}));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Import the mocked queue and the actual worker module
|
|
36
|
+
const { queue, addJob, saveResult } = require('../queue');
|
|
37
|
+
const actualWorkerModule = require('../index'); // Actual worker module
|
|
38
|
+
const testUtils = require('../../test/utils'); // For setting up plugins
|
|
39
|
+
|
|
40
|
+
describe('worker: Plugin job handling', () => {
|
|
41
|
+
let instantiatedPlugins; // Array of instantiated mock plugins from testUtils
|
|
42
|
+
let actualJobHandler; // The job processing function from the worker
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
46
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
const utils = await testUtils({
|
|
50
|
+
plugins: ['mockPluginForWorker'], // Provide a name for our mock plugin
|
|
51
|
+
startServer: false, // Worker tests usually don't need a live server
|
|
52
|
+
worker: false,
|
|
53
|
+
});
|
|
54
|
+
instantiatedPlugins = utils.plugins; // These are already instantiated mock plugins
|
|
55
|
+
|
|
56
|
+
// Capture the job handler from queue.process
|
|
57
|
+
queue.process.mockImplementation((concurrency, handler) => {
|
|
58
|
+
if (typeof handler === 'function') {
|
|
59
|
+
actualJobHandler = handler;
|
|
60
|
+
} else if (typeof concurrency === 'function') { // Handle case where concurrency is omitted
|
|
61
|
+
actualJobHandler = concurrency;
|
|
62
|
+
} else {
|
|
63
|
+
throw new Error('queue.process mock in plugin test could not capture handler');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Initialize the worker module. This should cause queue.process to be called.
|
|
68
|
+
await actualWorkerModule({ plugins: instantiatedPlugins });
|
|
69
|
+
|
|
70
|
+
if (!actualJobHandler) {
|
|
71
|
+
throw new Error('Job handler was not captured in plugin test. Worker might not have initialized queue processing correctly.');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
jest.clearAllMocks(); // Clears all mocks, including plugin method spies and postProcess action
|
|
77
|
+
// Reset specific mocks if needed, e.g., return values for plugin methods
|
|
78
|
+
if (instantiatedPlugins && instantiatedPlugins.length > 0 && instantiatedPlugins[0].searchProducts) {
|
|
79
|
+
instantiatedPlugins[0].searchProducts.mockClear();
|
|
80
|
+
}
|
|
81
|
+
mockBookingsPostProcessAction.mockClear();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(() => {
|
|
85
|
+
jest.restoreAllMocks();
|
|
86
|
+
actualJobHandler = null;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should call postProcess action after successful plugin method execution', async () => {
|
|
90
|
+
const mockPluginName = 'mockPluginForWorker'; // Must match plugin name used in testUtils setup
|
|
91
|
+
const mockPluginMethod = 'searchProducts'; // Method to be called on the plugin
|
|
92
|
+
const mockPluginPayload = { query: 'test query' };
|
|
93
|
+
const mockPluginToken = { apiKey: 'test-api-key-for-plugin' };
|
|
94
|
+
const mockPluginResult = { products: [{ id: 'prod123', name: 'Test Product' }] };
|
|
95
|
+
const jobId = 'plugin-job-with-pp-001';
|
|
96
|
+
|
|
97
|
+
// Ensure the target mock plugin and its method exist
|
|
98
|
+
const targetPlugin = instantiatedPlugins.find(p => p.name === mockPluginName);
|
|
99
|
+
if (!targetPlugin || typeof targetPlugin[mockPluginMethod] !== 'function') {
|
|
100
|
+
throw new Error(`Mock plugin ${mockPluginName} or method ${mockPluginMethod} not found or not a function.`);
|
|
101
|
+
}
|
|
102
|
+
// Mock the plugin method's return value for this specific test
|
|
103
|
+
targetPlugin[mockPluginMethod].mockResolvedValue(mockPluginResult);
|
|
104
|
+
|
|
105
|
+
const jobDataWithPostProcess = {
|
|
106
|
+
type: 'plugin',
|
|
107
|
+
pluginName: mockPluginName,
|
|
108
|
+
method: mockPluginMethod,
|
|
109
|
+
payload: mockPluginPayload, // This is job.data.payload
|
|
110
|
+
token: mockPluginToken, // This is job.data.token
|
|
111
|
+
inTesting: true,
|
|
112
|
+
postProcess: {
|
|
113
|
+
controller: 'bookings',
|
|
114
|
+
action: '$updateProductSearchCache', // Must match the mocked action name in bookings mock
|
|
115
|
+
args: { staticArg1: 'value1', appKey: mockPluginName }, // Static args for postProcess
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const mockJob = {
|
|
120
|
+
data: jobDataWithPostProcess,
|
|
121
|
+
id: jobId,
|
|
122
|
+
log: jest.fn(), // Bull's job.log
|
|
123
|
+
progress: jest.fn(), // Bull's job.progress
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
await actualJobHandler(mockJob);
|
|
127
|
+
|
|
128
|
+
// 1. Verify the plugin method was called correctly
|
|
129
|
+
expect(targetPlugin[mockPluginMethod]).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(targetPlugin[mockPluginMethod]).toHaveBeenCalledWith(expect.objectContaining({
|
|
131
|
+
...mockPluginPayload, // job.data.payload is spread
|
|
132
|
+
token: mockPluginToken, // job.data.token is passed as 'token'
|
|
133
|
+
requestId: jobId,
|
|
134
|
+
// axios and typeDefsAndQueries are also injected by the worker
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
// 2. Verify the postProcess action was called correctly
|
|
138
|
+
expect(mockBookingsPostProcessAction).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(mockBookingsPostProcessAction).toHaveBeenCalledWith(expect.objectContaining({
|
|
140
|
+
staticArg1: 'value1', // From postProcess.args
|
|
141
|
+
appKey: mockPluginName, // From postProcess.args
|
|
142
|
+
pluginResult: mockPluginResult, // The result from the plugin method
|
|
143
|
+
requestId: jobId,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
// 3. Verify saveResult was called (implicitly tests success path)
|
|
147
|
+
expect(saveResult).toHaveBeenCalledWith({ id: jobId, resultValue: mockPluginResult });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should still save result even if postProcess is not defined', async () => {
|
|
151
|
+
const mockPluginName = 'mockPluginForWorker';
|
|
152
|
+
const mockPluginMethod = 'searchProducts';
|
|
153
|
+
const mockPluginResult = { products: [{ id: 'prod456' }] };
|
|
154
|
+
const jobId = 'plugin-job-no-pp-002';
|
|
155
|
+
|
|
156
|
+
const targetPlugin = instantiatedPlugins.find(p => p.name === mockPluginName);
|
|
157
|
+
targetPlugin[mockPluginMethod].mockResolvedValue(mockPluginResult);
|
|
158
|
+
|
|
159
|
+
const jobDataNoPostProcess = {
|
|
160
|
+
type: 'plugin',
|
|
161
|
+
pluginName: mockPluginName,
|
|
162
|
+
method: mockPluginMethod,
|
|
163
|
+
payload: { query: 'another query' },
|
|
164
|
+
token: { apiKey: 'key-for-no-pp' },
|
|
165
|
+
inTesting: true,
|
|
166
|
+
// No postProcess field
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const mockJob = { data: jobDataNoPostProcess, id: jobId, log: jest.fn(), progress: jest.fn() };
|
|
170
|
+
await actualJobHandler(mockJob);
|
|
171
|
+
|
|
172
|
+
expect(targetPlugin[mockPluginMethod]).toHaveBeenCalledTimes(1);
|
|
173
|
+
expect(mockBookingsPostProcessAction).not.toHaveBeenCalled();
|
|
174
|
+
expect(saveResult).toHaveBeenCalledWith({ id: jobId, resultValue: mockPluginResult });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should throw error and not call postProcess if plugin method fails', async () => {
|
|
178
|
+
const mockPluginName = 'mockPluginForWorker';
|
|
179
|
+
const mockPluginMethod = 'searchProducts';
|
|
180
|
+
const jobId = 'plugin-job-fail-003';
|
|
181
|
+
const pluginError = new Error('Plugin method failed');
|
|
182
|
+
|
|
183
|
+
const targetPlugin = instantiatedPlugins.find(p => p.name === mockPluginName);
|
|
184
|
+
targetPlugin[mockPluginMethod].mockRejectedValue(pluginError);
|
|
185
|
+
|
|
186
|
+
const jobDataWithPpForFailure = {
|
|
187
|
+
type: 'plugin',
|
|
188
|
+
pluginName: mockPluginName,
|
|
189
|
+
method: mockPluginMethod,
|
|
190
|
+
payload: {},
|
|
191
|
+
token: {},
|
|
192
|
+
inTesting: true,
|
|
193
|
+
postProcess: {
|
|
194
|
+
controller: 'bookings',
|
|
195
|
+
action: '$updateProductSearchCache',
|
|
196
|
+
args: { appKey: mockPluginName },
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const mockJob = { data: jobDataWithPpForFailure, id: jobId, log: jest.fn(), progress: jest.fn() };
|
|
200
|
+
|
|
201
|
+
await expect(actualJobHandler(mockJob)).rejects.toThrow(pluginError.message);
|
|
202
|
+
|
|
203
|
+
expect(targetPlugin[mockPluginMethod]).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(mockBookingsPostProcessAction).not.toHaveBeenCalled();
|
|
205
|
+
expect(saveResult).not.toHaveBeenCalled(); // saveResult should not be called on failure
|
|
206
|
+
});
|
|
207
|
+
});
|
package/worker/index.js
CHANGED
|
@@ -2,126 +2,209 @@ const throng = require('throng');
|
|
|
2
2
|
require('util').inspect.defaultOptions.depth = null;
|
|
3
3
|
const R = require('ramda');
|
|
4
4
|
const { queue, saveResult } = require('./queue');
|
|
5
|
+
const bookingsControllerFactory = require('../controllers/bookings');
|
|
6
|
+
const mainAxios = require('axios'); // Generic axios instance for direct calls
|
|
5
7
|
|
|
6
8
|
const workers = process.env.WEB_CONCURRENCY || 2;
|
|
7
9
|
const maxJobsPerWorker = process.env.MAX_JOBS_PER_WORKER || 1;
|
|
8
10
|
|
|
9
|
-
const worker = ({ plugins: pluginsParam }) =>
|
|
10
|
-
|
|
11
|
+
const worker = ({ plugins: pluginsParam }) => { // pluginsParam are instantiated plugins
|
|
12
|
+
// Initialize bookingsCtrl once per worker instance using the passed instantiated plugins.
|
|
13
|
+
const bookingsCtrl = bookingsControllerFactory(pluginsParam);
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
console.log(`
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
return (id, disconnect) => {
|
|
16
|
+
console.log(`Started worker ${id}`);
|
|
17
|
+
|
|
18
|
+
const bye = () => {
|
|
19
|
+
console.log(`Worker ${id} exiting`);
|
|
20
|
+
disconnect();
|
|
21
|
+
// Graceful shutdown of the queue connection
|
|
22
|
+
queue.close().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
23
|
+
};
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const {
|
|
24
|
-
data: {
|
|
25
|
-
type = 'plugin',
|
|
26
|
-
pluginName,
|
|
27
|
-
method,
|
|
28
|
-
userId,
|
|
29
|
-
inTesting,
|
|
30
|
-
payload = {}
|
|
31
|
-
},
|
|
32
|
-
id: jobId,
|
|
33
|
-
data: params,
|
|
34
|
-
} = job;
|
|
35
|
-
if (type === 'api') {
|
|
25
|
+
process.on('SIGTERM', bye);
|
|
26
|
+
process.on('SIGUSR2', bye);
|
|
27
|
+
process.on('SIGINT', bye); // Good for local development
|
|
28
|
+
|
|
29
|
+
queue.process(maxJobsPerWorker, async job => {
|
|
36
30
|
const {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
data, // Full job.data object
|
|
32
|
+
id: jobId,
|
|
33
|
+
} = job;
|
|
34
|
+
|
|
35
|
+
// Destructure from data for clarity and to match existing patterns
|
|
36
|
+
const {
|
|
37
|
+
type = 'plugin', // Default job type if not specified
|
|
38
|
+
pluginName, // Name of the plugin context for the job
|
|
39
|
+
inTesting, // Flag if running in a testing environment
|
|
40
|
+
payload: jobDataPayload, // This is job.data.payload (e.g., { methodName: '...', args: {...} } or other plugin args)
|
|
41
|
+
method: jobDataMethod, // This is job.data.method (e.g., 'searchProducts' for generic plugin calls)
|
|
42
|
+
// Note: 'token' can also be a top-level property in data (data.token)
|
|
43
|
+
} = data;
|
|
44
|
+
|
|
45
|
+
let resultValue; // This will be set by all job type handlers
|
|
46
|
+
const jobLog = msg => console.log(`job ${jobId} (type: ${type}, plugin: ${pluginName || 'N/A'}) > `, msg);
|
|
47
|
+
const jobErr = msg => console.error(`job ${jobId} (type: ${type}, plugin: ${pluginName || 'N/A'}) > `, msg);
|
|
48
|
+
jobLog(`started`);
|
|
49
|
+
|
|
46
50
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
51
|
+
if (type === 'api') {
|
|
52
|
+
// params is job.data, so data.method, data.url etc. are correct here
|
|
53
|
+
jobLog(`running method ${data.method} ${data.url}`);
|
|
54
|
+
let code, result, server;
|
|
55
|
+
const http = require('http');
|
|
56
|
+
const axios = require('axios'); // Local axios for API call type
|
|
57
|
+
try {
|
|
58
|
+
const app = await require('../index')({
|
|
59
|
+
pluginsInstantiated: pluginsParam, // Pass the already instantiated plugins
|
|
60
|
+
startServer: false,
|
|
61
|
+
worker: false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
server = http.createServer(app);
|
|
65
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
66
|
+
const { address, port } = server.address();
|
|
67
|
+
const baseURL = `http://${address}:${port}`;
|
|
68
|
+
jobLog(`Worker internal server for job ${jobId} listening on: ${baseURL}`);
|
|
69
|
+
|
|
70
|
+
// data.payload is the payload for the API call job type
|
|
71
|
+
const { token: jwtTokenForHeader, ...requestBodyForWorker } = data.payload || {};
|
|
72
|
+
|
|
73
|
+
const requestHeaders = {
|
|
74
|
+
...R.omit(['content-length', 'host', 'connection', 'accept-encoding'], data.headers),
|
|
75
|
+
...(jwtTokenForHeader ? { Authorization: `Bearer ${jwtTokenForHeader}` } : {})
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const response = await axios({
|
|
79
|
+
method: data.method.toLowerCase(),
|
|
80
|
+
url: `${baseURL}${data.url}`,
|
|
81
|
+
data: R.omit(['backgroundJob'], requestBodyForWorker),
|
|
82
|
+
headers: requestHeaders,
|
|
83
|
+
validateStatus: () => true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
code = response.status;
|
|
87
|
+
result = response.data;
|
|
88
|
+
jobLog(`Worker internal request for job ${jobId} completed with code ${code}`);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
jobErr(`Worker internal request error for job ${jobId}: ${error.message}`);
|
|
91
|
+
code = R.pathOr(500, ['response', 'status'], error);
|
|
92
|
+
result = R.pathOr({ message: error.message }, ['response', 'data'], error);
|
|
93
|
+
} finally {
|
|
94
|
+
if (server) {
|
|
95
|
+
await new Promise(resolve => server.close(resolve));
|
|
96
|
+
jobLog(`Worker internal server for job ${jobId} closed.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
resultValue = { code, result, success: code >= 200 && code < 300 };
|
|
100
|
+
|
|
101
|
+
} else if (type === 'callback') {
|
|
102
|
+
const { callbackUrl, request, result } = jobDataPayload; // jobDataPayload is job.data.payload
|
|
103
|
+
jobLog(`sending callback to ${callbackUrl}`);
|
|
104
|
+
await require('../lib/callback').sendCallback({
|
|
105
|
+
callbackUrl,
|
|
106
|
+
request,
|
|
107
|
+
result,
|
|
108
|
+
});
|
|
109
|
+
resultValue = { success: true };
|
|
110
|
+
|
|
111
|
+
} else if (type === 'plugin') {
|
|
112
|
+
// jobDataPayload is job.data.payload
|
|
113
|
+
// jobDataMethod is job.data.method (for generic plugin calls)
|
|
114
|
+
|
|
115
|
+
// Check for our specific controller-level background task
|
|
116
|
+
if (jobDataPayload && jobDataPayload.methodName === '$bookingsProductSearchInternal') {
|
|
117
|
+
jobLog(`Processing via bookingsCtrl.$bookingsProductSearchInternal`);
|
|
118
|
+
|
|
119
|
+
const controllerArgs = jobDataPayload.args;
|
|
120
|
+
if (!controllerArgs) {
|
|
121
|
+
throw new Error('$bookingsProductSearchInternal job is missing "args" in its payload');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Ensure appKey from args matches pluginName from job data if pluginName is used for context
|
|
125
|
+
if (pluginName && controllerArgs.appKey !== pluginName) {
|
|
126
|
+
jobErr(`Job data pluginName (${pluginName}) mismatch with args.appKey (${controllerArgs.appKey}). Using args.appKey for plugin context.`);
|
|
127
|
+
}
|
|
128
|
+
// If this job type implies a forced refresh, `forceRefresh: true` should be part of `controllerArgs`.
|
|
129
|
+
// The `backgroundJob` flag is removed from $bookingsProductSearch.
|
|
130
|
+
const executionPayload = {
|
|
131
|
+
...controllerArgs, // Contains appKey, userId, hint, payload (for $bps), headers
|
|
132
|
+
// Potentially `forceRefresh: true` if needed for this job type.
|
|
133
|
+
axios: mainAxios, // Provide the worker's generic axios instance
|
|
134
|
+
requestId: jobId, // Use job ID for tracing
|
|
135
|
+
// plugins are already available to bookingsCtrl via its factory initialization
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
resultValue = await bookingsCtrl.$bookingsProductSearch(executionPayload);
|
|
139
|
+
jobLog(`bookingsCtrl.$bookingsProductSearchInternal completed.`);
|
|
140
|
+
|
|
141
|
+
} else {
|
|
142
|
+
// Handle generic plugin method call
|
|
143
|
+
jobLog(`Processing plugin method: ${jobDataMethod} for plugin ${pluginName}`);
|
|
144
|
+
|
|
145
|
+
const currentJobPlugins = await (async () => {
|
|
146
|
+
if (inTesting) {
|
|
147
|
+
const fakePlugin = require('../test/plugin');
|
|
148
|
+
const FakePluginClass = fakePlugin.default || fakePlugin;
|
|
149
|
+
return [...pluginsParam, new FakePluginClass({ name: pluginName })];
|
|
150
|
+
}
|
|
151
|
+
return pluginsParam;
|
|
152
|
+
})();
|
|
153
|
+
|
|
154
|
+
const thePlugin = currentJobPlugins.find(({ name }) => name === pluginName);
|
|
155
|
+
|
|
156
|
+
if (!thePlugin) {
|
|
157
|
+
throw new Error(`Plugin ${pluginName} not found for job ${jobId}.`);
|
|
158
|
+
}
|
|
159
|
+
if (!jobDataMethod || typeof thePlugin[jobDataMethod] !== 'function') {
|
|
160
|
+
throw new Error(`Method ${jobDataMethod} not found or not a function on plugin ${pluginName} for job ${jobId}.`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Execute the plugin method
|
|
164
|
+
const pluginMethodResult = await thePlugin[jobDataMethod]({
|
|
165
|
+
...(jobDataPayload || {}), // Spread job.data.payload (e.g., contains inner 'payload' and 'userId')
|
|
166
|
+
token: data.token, // Pass job.data.token
|
|
167
|
+
plugins: currentJobPlugins,
|
|
168
|
+
typeDefsAndQueries: bookingsControllerFactory.typeDefsAndQueries, // As per existing pattern
|
|
169
|
+
axios: mainAxios, // Inject worker's axios instance
|
|
170
|
+
requestId: jobId, // Inject job ID as requestId
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
jobLog(`Plugin method ${jobDataMethod} for ${pluginName} completed.`);
|
|
174
|
+
resultValue = pluginMethodResult; // The primary result of the job is the plugin's output
|
|
175
|
+
|
|
176
|
+
// Post-processing step
|
|
177
|
+
if (data.postProcess) {
|
|
178
|
+
jobLog(`Initiating post-processing step: ${data.postProcess.controller}.${data.postProcess.action}`);
|
|
179
|
+
if (data.postProcess.controller === 'bookings' && bookingsCtrl[data.postProcess.action]) {
|
|
180
|
+
await bookingsCtrl[data.postProcess.action]({
|
|
181
|
+
...(data.postProcess.args || {}), // Spread static args from job data
|
|
182
|
+
pluginResult: pluginMethodResult, // Pass the dynamic result from the plugin
|
|
183
|
+
requestId: jobId, // Pass job ID for tracing in post-process
|
|
184
|
+
// 'plugins' are already part of bookingsCtrl via its factory
|
|
185
|
+
});
|
|
186
|
+
jobLog(`Post-processing step ${data.postProcess.action} completed for job ${jobId}.`);
|
|
187
|
+
} else {
|
|
188
|
+
jobErr(`Post-processing action ${data.postProcess.action} on controller ${data.postProcess.controller} not found or controller not supported for job ${jobId}.`);
|
|
189
|
+
// Depending on requirements, this could throw an error to fail the job
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
jobErr(`Unknown job type: ${type} for job ${jobId}`);
|
|
195
|
+
throw new Error(`Unknown job type: ${type} for job ${jobId}`);
|
|
83
196
|
}
|
|
84
|
-
}
|
|
85
197
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const {
|
|
94
|
-
callbackUrl,
|
|
95
|
-
request,
|
|
96
|
-
result,
|
|
97
|
-
} = payload;
|
|
98
|
-
|
|
99
|
-
console.log(`job ${jobId} > sending callback to ${callbackUrl}`);
|
|
100
|
-
await require('../lib/callback').sendCallback({
|
|
101
|
-
callbackUrl,
|
|
102
|
-
request,
|
|
103
|
-
result,
|
|
104
|
-
});
|
|
105
|
-
return { success: true };
|
|
106
|
-
}
|
|
107
|
-
console.log(`job ${jobId} > running ${pluginName}:${method} for ${userId}`);
|
|
108
|
-
const plugins = await (async () => {
|
|
109
|
-
if (inTesting) {
|
|
110
|
-
const fakePlugin = require('../test/plugin');
|
|
111
|
-
return [...pluginsParam, await new fakePlugin({ name: pluginName })];
|
|
198
|
+
jobLog(`completed`);
|
|
199
|
+
await saveResult({ id: jobId, resultValue });
|
|
200
|
+
|
|
201
|
+
} catch (error) {
|
|
202
|
+
jobErr(`Processing failed: ${error.message}`);
|
|
203
|
+
if (error.stack) jobErr(error.stack); // Log full stack for debugging
|
|
204
|
+
throw new Error(error.message) // Re-throw to mark job as failed in Bull (use a summarized mesage)
|
|
112
205
|
}
|
|
113
|
-
return pluginsParam;
|
|
114
|
-
})();
|
|
115
|
-
const thePlugin = plugins.find(({ name }) => name === pluginName);
|
|
116
|
-
const resultValue = await thePlugin[method]({
|
|
117
|
-
...params.payload,
|
|
118
|
-
token: params.token,
|
|
119
|
-
plugins,
|
|
120
|
-
typeDefsAndQueries: require('../controllers/bookings').typeDefsAndQueries,
|
|
121
206
|
});
|
|
122
|
-
|
|
123
|
-
await saveResult({ id: jobId, resultValue });
|
|
124
|
-
});
|
|
207
|
+
};
|
|
125
208
|
};
|
|
126
209
|
|
|
127
210
|
module.exports = async args => throng({
|
package/worker/queue.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const Queue = require('bull');
|
|
2
|
-
const Redis = require('ioredis');
|
|
3
2
|
const R = require('ramda');
|
|
4
|
-
|
|
3
|
+
const Redis = require('ioredis');
|
|
4
|
+
const sqldb = require('../models');
|
|
5
|
+
const { env: { redisHost, redisPort } } = process;
|
|
5
6
|
const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
|
|
6
7
|
const itemsTTL = 3 * 60 * 60; // 3 hours
|
|
7
8
|
|
|
@@ -32,13 +33,15 @@ queue.on('failed', (job, err) => {
|
|
|
32
33
|
const addJob = async (payload, paramsParam) => {
|
|
33
34
|
const params = paramsParam || {};
|
|
34
35
|
const inTesting = Boolean(process.env.JEST_WORKER_ID);
|
|
35
|
-
const
|
|
36
|
+
const job = await queue.add({
|
|
36
37
|
...payload,
|
|
37
38
|
inTesting,
|
|
38
39
|
}, {
|
|
39
40
|
removeOnComplete: true,
|
|
40
41
|
...params,
|
|
41
|
-
})
|
|
42
|
+
});
|
|
43
|
+
// For repeat jobs, we need to store the full repeat key
|
|
44
|
+
const id = job.opts.repeat ? job.opts.jobId : job.id;
|
|
42
45
|
return id;
|
|
43
46
|
};
|
|
44
47
|
|
|
@@ -46,6 +49,67 @@ const saveResult = async ({ id, resultValue }) => {
|
|
|
46
49
|
await redisResults.set(id, JSON.stringify(resultValue), 'EX', itemsTTL);
|
|
47
50
|
};
|
|
48
51
|
|
|
52
|
+
const removeJob = async (jobId) => {
|
|
53
|
+
// For repeat jobs, the ID is in the format 'repeat:jobId:timestamp'
|
|
54
|
+
const jobIdParts = jobId.split(':');
|
|
55
|
+
const isRepeatJob = jobIdParts[0] === 'repeat';
|
|
56
|
+
|
|
57
|
+
if (isRepeatJob) {
|
|
58
|
+
// Get all repeatable jobs
|
|
59
|
+
const repeatableJobs = await queue.getRepeatableJobs();
|
|
60
|
+
|
|
61
|
+
// Find the job with matching cron pattern
|
|
62
|
+
const cronJob = await sqldb.CronJobs.findOne({
|
|
63
|
+
where: {
|
|
64
|
+
bullJobId: jobId,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (cronJob) {
|
|
69
|
+
// First remove all repeatable jobs with matching cron pattern
|
|
70
|
+
for (const repeatableJob of repeatableJobs) {
|
|
71
|
+
if (repeatableJob.cron === cronJob.cron) {
|
|
72
|
+
await queue.removeRepeatableByKey(repeatableJob.key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Then remove all jobs with this pattern
|
|
77
|
+
const jobs = await queue.getJobs(['active', 'wait', 'delayed']);
|
|
78
|
+
for (const job of jobs) {
|
|
79
|
+
if (job.opts.repeat && job.opts.jobId === jobId) {
|
|
80
|
+
await job.remove();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Wait for the queue to process the removals
|
|
85
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
86
|
+
|
|
87
|
+
// Clean up any remaining jobs
|
|
88
|
+
const remainingJobs = await queue.getJobs(['active', 'wait', 'delayed']);
|
|
89
|
+
for (const job of remainingJobs) {
|
|
90
|
+
if (job.opts.repeat && job.opts.jobId === jobId) {
|
|
91
|
+
await job.remove();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await redisResults.del(jobId);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// If we couldn't find the repeatable job, just delete from the database
|
|
100
|
+
// This can happen if the job was already removed from Bull but still exists in our database
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If not a repeatable job, try to remove as a regular job
|
|
105
|
+
const job = await queue.getJob(jobId);
|
|
106
|
+
if (!job) {
|
|
107
|
+
throw new Error('Job not found');
|
|
108
|
+
}
|
|
109
|
+
await job.remove();
|
|
110
|
+
await redisResults.del(jobId);
|
|
111
|
+
};
|
|
112
|
+
|
|
49
113
|
const jobStatus = async ({ jobId }) => {
|
|
50
114
|
try {
|
|
51
115
|
const job = await queue.getJob(jobId);
|
|
@@ -71,13 +135,13 @@ const jobStatus = async ({ jobId }) => {
|
|
|
71
135
|
status: state.toLowerCase(),
|
|
72
136
|
};
|
|
73
137
|
} catch (err) {
|
|
74
|
-
console.log(`could not get job status for ${jobId}`, err);
|
|
75
138
|
return { // 500
|
|
76
139
|
jobId,
|
|
77
140
|
status: 'failed',
|
|
78
141
|
};
|
|
79
142
|
}
|
|
80
143
|
};
|
|
144
|
+
|
|
81
145
|
module.exports = {
|
|
82
146
|
addJob,
|
|
83
147
|
allDone,
|
|
@@ -85,4 +149,5 @@ module.exports = {
|
|
|
85
149
|
queue,
|
|
86
150
|
saveResult,
|
|
87
151
|
redisResults,
|
|
152
|
+
removeJob,
|
|
88
153
|
};
|