@friggframework/admin-scripts 2.0.0-next.103 → 2.0.0-next.105
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/index.js +14 -0
- package/package.json +6 -6
- package/src/adapters/__tests__/aws-scheduler-adapter.test.js +54 -0
- package/src/adapters/__tests__/scheduler-adapter-factory.test.js +49 -0
- package/src/adapters/aws-scheduler-adapter.js +21 -9
- package/src/adapters/scheduler-adapter-factory.js +40 -0
- package/src/application/__tests__/report-runner.test.js +428 -0
- package/src/application/admin-script-context.js +12 -0
- package/src/application/report-runner.js +273 -0
- package/src/infrastructure/__tests__/report-executor-handler.test.js +314 -0
- package/src/infrastructure/__tests__/report-router.test.js +525 -0
- package/src/infrastructure/bootstrap.js +95 -15
- package/src/infrastructure/report-executor-handler.js +202 -0
- package/src/infrastructure/report-router.js +421 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
const { createAdminScriptContext } = require('./admin-script-context');
|
|
2
|
+
const { validateParams } = require('./validate-script-input');
|
|
3
|
+
|
|
4
|
+
const ARTIFACT_CONTENT_TYPES = {
|
|
5
|
+
csv: 'text/csv',
|
|
6
|
+
json: 'application/json',
|
|
7
|
+
pdf: 'application/pdf',
|
|
8
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
9
|
+
html: 'text/html',
|
|
10
|
+
txt: 'text/plain',
|
|
11
|
+
xml: 'application/xml',
|
|
12
|
+
zip: 'application/zip',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// The run mode decides persistence: live computes inline and records nothing,
|
|
16
|
+
// recorded persists an execution record, snapshot additionally tags the run
|
|
17
|
+
// into a named series. Reports read only through the injected frigg command
|
|
18
|
+
// bundle (exposed as context.commands).
|
|
19
|
+
class ReportRunner {
|
|
20
|
+
constructor(params = {}) {
|
|
21
|
+
if (!params.reportFactory) {
|
|
22
|
+
throw new Error('ReportRunner requires a reportFactory');
|
|
23
|
+
}
|
|
24
|
+
this.reportFactory = params.reportFactory;
|
|
25
|
+
this.reportCommands = params.reportCommands || null;
|
|
26
|
+
this.friggCommands = params.friggCommands || null;
|
|
27
|
+
this.integrationFactory = params.integrationFactory || null;
|
|
28
|
+
this.artifactRepository = params.artifactRepository || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_getArtifactRepository() {
|
|
32
|
+
if (!this.artifactRepository) {
|
|
33
|
+
const {
|
|
34
|
+
createArtifactRepository,
|
|
35
|
+
} = require('@friggframework/core/artifacts/repositories/artifact-repository-factory');
|
|
36
|
+
this.artifactRepository = createArtifactRepository();
|
|
37
|
+
}
|
|
38
|
+
return this.artifactRepository;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async execute(reportName, params = {}, options = {}) {
|
|
42
|
+
const ReportClass = this.reportFactory.get(reportName);
|
|
43
|
+
const definition = ReportClass.Definition;
|
|
44
|
+
|
|
45
|
+
const runModes =
|
|
46
|
+
Array.isArray(definition.runModes) && definition.runModes.length
|
|
47
|
+
? definition.runModes
|
|
48
|
+
: ['live'];
|
|
49
|
+
const mode = options.mode || runModes[0];
|
|
50
|
+
if (!runModes.includes(mode)) {
|
|
51
|
+
const error = new Error(
|
|
52
|
+
`Report "${reportName}" does not support mode "${mode}". Allowed: ${runModes.join(
|
|
53
|
+
', '
|
|
54
|
+
)}`
|
|
55
|
+
);
|
|
56
|
+
error.code = 'INVALID_MODE';
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const validation = validateParams(definition, params);
|
|
61
|
+
if (!validation.valid) {
|
|
62
|
+
const error = new Error(
|
|
63
|
+
`Invalid input: ${validation.errors.join(', ')}`
|
|
64
|
+
);
|
|
65
|
+
error.code = 'INVALID_INPUT';
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const format = definition.output?.format || 'json';
|
|
70
|
+
|
|
71
|
+
if (mode === 'live') {
|
|
72
|
+
// Non-JSON can't be returned inline as a response body; it needs an
|
|
73
|
+
// artifact, so it must run recorded/snapshot.
|
|
74
|
+
if (format !== 'json') {
|
|
75
|
+
const error = new Error(
|
|
76
|
+
`Report "${reportName}" output format "${format}" cannot be returned inline; run it in recorded or snapshot mode`
|
|
77
|
+
);
|
|
78
|
+
error.code = 'ARTIFACT_STORAGE_UNAVAILABLE';
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
return this._runLive(reportName, params);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return this._runRecorded(reportName, definition, params, mode, options);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async _runLive(reportName, params) {
|
|
88
|
+
const startTime = new Date();
|
|
89
|
+
// executionId null: live persists nothing, so record-dependent logging
|
|
90
|
+
// and chaining are intentionally inert.
|
|
91
|
+
const context = createAdminScriptContext({
|
|
92
|
+
executionId: null,
|
|
93
|
+
integrationFactory: this.integrationFactory,
|
|
94
|
+
commands: this.friggCommands,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const report = this.reportFactory.createInstance(reportName, {
|
|
98
|
+
context,
|
|
99
|
+
executionId: null,
|
|
100
|
+
integrationFactory: this.integrationFactory,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const output = await report.execute(context.commands, params, context);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
status: 'COMPLETED',
|
|
107
|
+
reportName,
|
|
108
|
+
mode: 'live',
|
|
109
|
+
output,
|
|
110
|
+
metrics: { durationMs: new Date() - startTime },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Completion is written OUTSIDE the try on success so a persistence failure
|
|
115
|
+
// is never misreported as a report failure.
|
|
116
|
+
async _runRecorded(reportName, definition, params, mode, options = {}) {
|
|
117
|
+
let executionId = options.executionId;
|
|
118
|
+
|
|
119
|
+
if (!executionId) {
|
|
120
|
+
const execution = await this.reportCommands.createExecution({
|
|
121
|
+
reportName,
|
|
122
|
+
reportVersion: definition.version,
|
|
123
|
+
trigger: options.trigger || 'MANUAL',
|
|
124
|
+
mode,
|
|
125
|
+
input: params,
|
|
126
|
+
audit: options.audit,
|
|
127
|
+
seriesName:
|
|
128
|
+
mode === 'snapshot'
|
|
129
|
+
? options.seriesName || reportName
|
|
130
|
+
: undefined,
|
|
131
|
+
parentExecutionId: options.parentExecutionId,
|
|
132
|
+
});
|
|
133
|
+
if (execution.error) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
execution.reason ||
|
|
136
|
+
'Failed to create report execution record'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
executionId = execution.id;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const startTime = new Date();
|
|
143
|
+
const context = createAdminScriptContext({
|
|
144
|
+
executionId,
|
|
145
|
+
integrationFactory: this.integrationFactory,
|
|
146
|
+
commands: this.friggCommands,
|
|
147
|
+
lambdaContext: options.lambdaContext,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const format = definition.output?.format || 'json';
|
|
151
|
+
let output;
|
|
152
|
+
let artifact = null;
|
|
153
|
+
let summary;
|
|
154
|
+
try {
|
|
155
|
+
await this.reportCommands.updateExecutionState(
|
|
156
|
+
executionId,
|
|
157
|
+
'RUNNING'
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const report = this.reportFactory.createInstance(reportName, {
|
|
161
|
+
context,
|
|
162
|
+
executionId,
|
|
163
|
+
integrationFactory: this.integrationFactory,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
output = await report.execute(context.commands, params, context);
|
|
167
|
+
|
|
168
|
+
// Store non-JSON output as an artifact inside the try so a put
|
|
169
|
+
// failure marks the run FAILED (a continuation yield has none yet).
|
|
170
|
+
if (format !== 'json' && !(output && output.__continuation)) {
|
|
171
|
+
const stamp = new Date()
|
|
172
|
+
.toISOString()
|
|
173
|
+
.replace(/[:.]/g, '-');
|
|
174
|
+
const fileType = output.fileType || format;
|
|
175
|
+
const key = `reports/${executionId}/${reportName}-${stamp}.${fileType}`;
|
|
176
|
+
const contentType =
|
|
177
|
+
ARTIFACT_CONTENT_TYPES[fileType] ||
|
|
178
|
+
'application/octet-stream';
|
|
179
|
+
artifact = await this._getArtifactRepository().put(
|
|
180
|
+
key,
|
|
181
|
+
output.file,
|
|
182
|
+
contentType
|
|
183
|
+
);
|
|
184
|
+
summary = output.summary;
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const durationMs = new Date() - startTime;
|
|
188
|
+
await this.reportCommands.completeExecution(executionId, {
|
|
189
|
+
state: 'FAILED',
|
|
190
|
+
error: {
|
|
191
|
+
name: error.name,
|
|
192
|
+
message: error.message,
|
|
193
|
+
stack: error.stack,
|
|
194
|
+
},
|
|
195
|
+
metrics: {
|
|
196
|
+
startTime: startTime.toISOString(),
|
|
197
|
+
endTime: new Date().toISOString(),
|
|
198
|
+
durationMs,
|
|
199
|
+
},
|
|
200
|
+
logs: context.getLogs(),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
executionId,
|
|
205
|
+
status: 'FAILED',
|
|
206
|
+
reportName,
|
|
207
|
+
mode,
|
|
208
|
+
error: { name: error.name, message: error.message },
|
|
209
|
+
metrics: { durationMs },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Opt-in self-requeue: a report yields by returning a truthy
|
|
214
|
+
// `__continuation` (its resume state) instead of a final result. The
|
|
215
|
+
// execution stays RUNNING and the executor re-enqueues the SAME id, so
|
|
216
|
+
// the report resumes from the marker on the next invocation.
|
|
217
|
+
if (output && output.__continuation) {
|
|
218
|
+
const resumeState = output.__continuation;
|
|
219
|
+
if (typeof this.reportCommands.appendExecutionLog === 'function') {
|
|
220
|
+
await this.reportCommands.appendExecutionLog(executionId, {
|
|
221
|
+
level: 'info',
|
|
222
|
+
message: 'report yielded a continuation; re-queueing',
|
|
223
|
+
data: { resumeAt: new Date().toISOString() },
|
|
224
|
+
timestamp: new Date().toISOString(),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
executionId,
|
|
229
|
+
status: 'CONTINUE',
|
|
230
|
+
reportName,
|
|
231
|
+
mode,
|
|
232
|
+
continuation: resumeState,
|
|
233
|
+
metrics: { durationMs: new Date() - startTime },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const durationMs = new Date() - startTime;
|
|
238
|
+
const isArtifact = format !== 'json';
|
|
239
|
+
const result = {
|
|
240
|
+
executionId,
|
|
241
|
+
status: 'COMPLETED',
|
|
242
|
+
reportName,
|
|
243
|
+
mode,
|
|
244
|
+
...(isArtifact ? { summary, artifact } : { output }),
|
|
245
|
+
metrics: { durationMs },
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const completion = await this.reportCommands.completeExecution(
|
|
249
|
+
executionId,
|
|
250
|
+
{
|
|
251
|
+
state: 'COMPLETED',
|
|
252
|
+
...(isArtifact ? { summary, artifact } : { output }),
|
|
253
|
+
metrics: {
|
|
254
|
+
startTime: startTime.toISOString(),
|
|
255
|
+
endTime: new Date().toISOString(),
|
|
256
|
+
durationMs,
|
|
257
|
+
},
|
|
258
|
+
logs: context.getLogs(),
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
if (completion?.error) {
|
|
262
|
+
result.stateUpdateFailed = true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function createReportRunner(params = {}) {
|
|
270
|
+
return new ReportRunner(params);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { ReportRunner, createReportRunner };
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
jest.mock('../bootstrap');
|
|
2
|
+
jest.mock('../../application/report-runner');
|
|
3
|
+
jest.mock('@friggframework/core/application/commands/report-commands');
|
|
4
|
+
jest.mock('@friggframework/core/queues', () => ({
|
|
5
|
+
QueuerUtil: { send: jest.fn().mockResolvedValue({}) },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const { bootstrapAdminScripts } = require('../bootstrap');
|
|
9
|
+
const { createReportRunner } = require('../../application/report-runner');
|
|
10
|
+
const {
|
|
11
|
+
createReportCommands,
|
|
12
|
+
} = require('@friggframework/core/application/commands/report-commands');
|
|
13
|
+
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
14
|
+
const { handler } = require('../report-executor-handler');
|
|
15
|
+
|
|
16
|
+
describe('Report Executor Handler', () => {
|
|
17
|
+
let mockReportFactory;
|
|
18
|
+
let mockIntegrationFactory;
|
|
19
|
+
let mockReportFriggCommands;
|
|
20
|
+
let mockRunner;
|
|
21
|
+
let mockCommands;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockReportFactory = { id: 'report-factory' };
|
|
25
|
+
mockIntegrationFactory = { id: 'integration-factory' };
|
|
26
|
+
mockReportFriggCommands = { id: 'report-frigg-commands' };
|
|
27
|
+
mockRunner = { execute: jest.fn() };
|
|
28
|
+
mockCommands = {
|
|
29
|
+
completeExecution: jest.fn().mockResolvedValue({}),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
bootstrapAdminScripts.mockReturnValue({
|
|
33
|
+
reportFactory: mockReportFactory,
|
|
34
|
+
reportCommands: { id: 'report-commands' },
|
|
35
|
+
reportFriggCommands: mockReportFriggCommands,
|
|
36
|
+
integrationFactory: mockIntegrationFactory,
|
|
37
|
+
});
|
|
38
|
+
createReportRunner.mockReturnValue(mockRunner);
|
|
39
|
+
createReportCommands.mockReturnValue(mockCommands);
|
|
40
|
+
|
|
41
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
42
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
console.log.mockRestore();
|
|
47
|
+
console.error.mockRestore();
|
|
48
|
+
jest.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('EventBridge Scheduler direct invoke (no Records)', () => {
|
|
52
|
+
it('injects the bootstrapped factory into the runner and reports the result', async () => {
|
|
53
|
+
mockRunner.execute.mockResolvedValue({
|
|
54
|
+
status: 'COMPLETED',
|
|
55
|
+
executionId: 'exec-1',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const response = await handler({
|
|
59
|
+
reportName: 'my-report',
|
|
60
|
+
trigger: 'SCHEDULED',
|
|
61
|
+
mode: 'snapshot',
|
|
62
|
+
seriesName: 'daily',
|
|
63
|
+
params: { foo: 'bar' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(createReportRunner).toHaveBeenCalledWith({
|
|
67
|
+
reportFactory: mockReportFactory,
|
|
68
|
+
reportCommands: { id: 'report-commands' },
|
|
69
|
+
friggCommands: mockReportFriggCommands,
|
|
70
|
+
integrationFactory: mockIntegrationFactory,
|
|
71
|
+
});
|
|
72
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
73
|
+
'my-report',
|
|
74
|
+
{ foo: 'bar' },
|
|
75
|
+
expect.objectContaining({
|
|
76
|
+
trigger: 'SCHEDULED',
|
|
77
|
+
mode: 'snapshot',
|
|
78
|
+
seriesName: 'daily',
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(response.statusCode).toBe(200);
|
|
83
|
+
const body = JSON.parse(response.body);
|
|
84
|
+
expect(body.processed).toBe(1);
|
|
85
|
+
expect(body.results[0]).toEqual({
|
|
86
|
+
reportName: 'my-report',
|
|
87
|
+
status: 'COMPLETED',
|
|
88
|
+
executionId: 'exec-1',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('defaults mode to recorded and trigger to QUEUE', async () => {
|
|
93
|
+
mockRunner.execute.mockResolvedValue({
|
|
94
|
+
status: 'COMPLETED',
|
|
95
|
+
executionId: 'exec-d',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await handler({ reportName: 'my-report', params: {} });
|
|
99
|
+
|
|
100
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
101
|
+
'my-report',
|
|
102
|
+
{},
|
|
103
|
+
expect.objectContaining({ mode: 'recorded', trigger: 'QUEUE' })
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('marks the execution FAILED when the runner throws', async () => {
|
|
108
|
+
mockRunner.execute.mockRejectedValue(new Error('boom'));
|
|
109
|
+
|
|
110
|
+
const response = await handler({
|
|
111
|
+
reportName: 'my-report',
|
|
112
|
+
executionId: 'exec-2',
|
|
113
|
+
trigger: 'SCHEDULED',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(response.statusCode).toBe(200);
|
|
117
|
+
const body = JSON.parse(response.body);
|
|
118
|
+
expect(body.results[0].status).toBe('FAILED');
|
|
119
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
120
|
+
'exec-2',
|
|
121
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('SQS batch (Records[])', () => {
|
|
127
|
+
it('injects the bootstrapped factory and processes each record', async () => {
|
|
128
|
+
mockRunner.execute.mockResolvedValue({
|
|
129
|
+
status: 'COMPLETED',
|
|
130
|
+
executionId: 'exec-3',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const response = await handler({
|
|
134
|
+
Records: [
|
|
135
|
+
{
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
reportName: 'my-report',
|
|
138
|
+
executionId: 'exec-3',
|
|
139
|
+
mode: 'recorded',
|
|
140
|
+
params: {},
|
|
141
|
+
}),
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(createReportRunner).toHaveBeenCalledWith({
|
|
147
|
+
reportFactory: mockReportFactory,
|
|
148
|
+
reportCommands: { id: 'report-commands' },
|
|
149
|
+
friggCommands: mockReportFriggCommands,
|
|
150
|
+
integrationFactory: mockIntegrationFactory,
|
|
151
|
+
});
|
|
152
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
153
|
+
'my-report',
|
|
154
|
+
{},
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
trigger: 'QUEUE',
|
|
157
|
+
mode: 'recorded',
|
|
158
|
+
executionId: 'exec-3',
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(response.statusCode).toBe(200);
|
|
163
|
+
const body = JSON.parse(response.body);
|
|
164
|
+
expect(body.processed).toBe(1);
|
|
165
|
+
expect(body.results[0].status).toBe('COMPLETED');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('isolates a bad record without dropping the rest of the batch', async () => {
|
|
169
|
+
mockRunner.execute.mockResolvedValue({
|
|
170
|
+
status: 'COMPLETED',
|
|
171
|
+
executionId: 'exec-4',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const response = await handler({
|
|
175
|
+
Records: [
|
|
176
|
+
// Missing reportName -> runMessage throws before the runner
|
|
177
|
+
{ body: JSON.stringify({ executionId: 'exec-bad' }) },
|
|
178
|
+
{
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
reportName: 'my-report',
|
|
181
|
+
executionId: 'exec-4',
|
|
182
|
+
params: {},
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const body = JSON.parse(response.body);
|
|
189
|
+
expect(body.processed).toBe(2);
|
|
190
|
+
expect(body.results[0].status).toBe('FAILED');
|
|
191
|
+
expect(body.results[1].status).toBe('COMPLETED');
|
|
192
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
193
|
+
'exec-bad',
|
|
194
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('self-requeue on CONTINUE', () => {
|
|
200
|
+
afterEach(() => {
|
|
201
|
+
delete process.env.REPORT_QUEUE_URL;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('passes the Lambda context to the runner', async () => {
|
|
205
|
+
mockRunner.execute.mockResolvedValue({
|
|
206
|
+
status: 'COMPLETED',
|
|
207
|
+
executionId: 'exec-ctx',
|
|
208
|
+
});
|
|
209
|
+
const lambdaContext = {
|
|
210
|
+
getRemainingTimeInMillis: () => 12345,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
await handler(
|
|
214
|
+
{ reportName: 'my-report', executionId: 'exec-ctx', params: {} },
|
|
215
|
+
lambdaContext
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
219
|
+
'my-report',
|
|
220
|
+
{},
|
|
221
|
+
expect.objectContaining({ lambdaContext })
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('re-enqueues the same execution with a resume marker when the runner returns CONTINUE', async () => {
|
|
226
|
+
process.env.REPORT_QUEUE_URL = 'https://sqs.local/report-queue';
|
|
227
|
+
mockRunner.execute.mockResolvedValue({
|
|
228
|
+
status: 'CONTINUE',
|
|
229
|
+
executionId: 'exec-cont',
|
|
230
|
+
continuation: { cursor: 7 },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const response = await handler(
|
|
234
|
+
{
|
|
235
|
+
reportName: 'my-report',
|
|
236
|
+
executionId: 'exec-cont',
|
|
237
|
+
mode: 'recorded',
|
|
238
|
+
params: { since: '2026-01-01' },
|
|
239
|
+
},
|
|
240
|
+
{ getRemainingTimeInMillis: () => 5000 }
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(QueuerUtil.send).toHaveBeenCalledWith(
|
|
244
|
+
expect.objectContaining({
|
|
245
|
+
reportName: 'my-report',
|
|
246
|
+
executionId: 'exec-cont',
|
|
247
|
+
mode: 'recorded',
|
|
248
|
+
trigger: 'QUEUE',
|
|
249
|
+
params: {
|
|
250
|
+
since: '2026-01-01',
|
|
251
|
+
__resume: { cursor: 7 },
|
|
252
|
+
},
|
|
253
|
+
resumeAt: expect.any(String),
|
|
254
|
+
}),
|
|
255
|
+
'https://sqs.local/report-queue'
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const body = JSON.parse(response.body);
|
|
259
|
+
expect(body.results[0].status).toBe('CONTINUE');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('fails the record when a continuation is yielded but REPORT_QUEUE_URL is unset', async () => {
|
|
263
|
+
delete process.env.REPORT_QUEUE_URL;
|
|
264
|
+
mockRunner.execute.mockResolvedValue({
|
|
265
|
+
status: 'CONTINUE',
|
|
266
|
+
executionId: 'exec-noqueue',
|
|
267
|
+
continuation: { cursor: 1 },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const response = await handler({
|
|
271
|
+
reportName: 'my-report',
|
|
272
|
+
executionId: 'exec-noqueue',
|
|
273
|
+
params: {},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(QueuerUtil.send).not.toHaveBeenCalled();
|
|
277
|
+
const body = JSON.parse(response.body);
|
|
278
|
+
expect(body.results[0].status).toBe('FAILED');
|
|
279
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
280
|
+
'exec-noqueue',
|
|
281
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('compensates with the RUNNER-created id on a scheduled first run that cannot re-queue (no inbound executionId)', async () => {
|
|
286
|
+
// A real EventBridge scheduled event carries NO executionId — the
|
|
287
|
+
// runner creates the record. If the continuation can't be re-queued,
|
|
288
|
+
// the record must still be marked FAILED using the runner-created id,
|
|
289
|
+
// not the absent inbound one.
|
|
290
|
+
delete process.env.REPORT_QUEUE_URL;
|
|
291
|
+
mockRunner.execute.mockResolvedValue({
|
|
292
|
+
status: 'CONTINUE',
|
|
293
|
+
executionId: 'runner-created-exec',
|
|
294
|
+
continuation: { cursor: 1 },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const response = await handler({
|
|
298
|
+
reportName: 'my-report',
|
|
299
|
+
trigger: 'SCHEDULED',
|
|
300
|
+
mode: 'snapshot',
|
|
301
|
+
params: {},
|
|
302
|
+
// note: no executionId, matching the scheduler's buildInput
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(QueuerUtil.send).not.toHaveBeenCalled();
|
|
306
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
307
|
+
'runner-created-exec',
|
|
308
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
309
|
+
);
|
|
310
|
+
const body = JSON.parse(response.body);
|
|
311
|
+
expect(body.results[0].status).toBe('FAILED');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|