@balena/pinejs 16.1.0-build-joshbwlng-tasks-82a48d2f7c281892020e59c514b2775690dcabed-1 → 16.1.0-build-partial-unique-index-constraints-c664148d85c67b645954adc929c778a7ce81115f-2

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.
@@ -1,378 +0,0 @@
1
- import Ajv from 'ajv';
2
- import type { Schema, ValidateFunction } from 'ajv';
3
- import * as cronParser from 'cron-parser';
4
- import type { AnyObject } from 'pinejs-client-core';
5
-
6
- import { tasks as tasksEnv } from '../config-loader/env';
7
- import type { Tx } from '../database-layer/db';
8
- import { BadRequestError } from './errors';
9
- import { addPureHook } from './hooks';
10
- import * as permissions from './permissions';
11
- import type { ExecutableModel } from './sbvr-utils';
12
- import { PinejsClient } from './sbvr-utils';
13
- import { sbvrUtils } from '../server-glue/module';
14
-
15
- export const apiRoot = 'tasks';
16
-
17
- // eslint-disable-next-line @typescript-eslint/no-var-requires
18
- const modelText: string = require(`./${apiRoot}.sbvr`);
19
-
20
- const handlers: {
21
- [name: string]: TaskHandler;
22
- } = {};
23
-
24
- export const taskStatuses = ['pending', 'cancelled', 'success', 'failed'];
25
- export interface Task {
26
- id: number;
27
- created_at: Date;
28
- modified_at: Date;
29
- is_created_by__actor: number;
30
- is_executed_by__handler: string;
31
- is_executed_with__parameter_set: object | null;
32
- is_scheduled_with__cron_expression: string | null;
33
- is_scheduled_to_execute_on__time: Date | null;
34
- priority: number;
35
- status: (typeof taskStatuses)[number];
36
- started_on__time: Date | null;
37
- ended_on__time: Date | null;
38
- error_message: string | null;
39
- attempt_count: number;
40
- attempt_limit: number;
41
- }
42
-
43
- type PartialTask = Pick<
44
- Task,
45
- | 'id'
46
- | 'is_created_by__actor'
47
- | 'is_executed_by__handler'
48
- | 'is_executed_with__parameter_set'
49
- | 'is_scheduled_with__cron_expression'
50
- | 'priority'
51
- | 'attempt_count'
52
- | 'attempt_limit'
53
- >;
54
-
55
- interface TaskArgs {
56
- api: PinejsClient;
57
- params: AnyObject;
58
- tx: Tx;
59
- }
60
-
61
- type TaskResponse = Promise<{
62
- status: (typeof taskStatuses)[number];
63
- error?: string;
64
- }>;
65
-
66
- export interface TaskHandler {
67
- name: string;
68
- fn: (options: TaskArgs) => TaskResponse;
69
- validate?: ValidateFunction;
70
- }
71
-
72
- // Parse a cron expression
73
- function parseCron(cron: string): Date {
74
- return cronParser.parseExpression(cron).next().toDate();
75
- }
76
-
77
- export const config = {
78
- models: [
79
- {
80
- apiRoot,
81
- modelText,
82
- customServerCode: exports,
83
- migrations: {},
84
- },
85
- ] as ExecutableModel[],
86
- };
87
-
88
- // Setting inlineRefs=false as without it we run into a
89
- // "Maximum call stack size exceeded" error apprarently caused
90
- // by String.prototype._uncountable_words being set in sbvr-parser?
91
- const ajv = new Ajv({
92
- inlineRefs: false,
93
- });
94
-
95
- export const setup = async () => {
96
- addPureHook('POST', apiRoot, 'task', {
97
- POSTPARSE: async ({ req, request }) => {
98
- // Set the actor
99
- request.values.is_created_by__actor =
100
- req.user?.actor ?? req.apiKey?.actor;
101
- if (request.values.is_created_by__actor == null) {
102
- throw new BadRequestError(
103
- 'Creating tasks with missing actor on req is not allowed',
104
- );
105
- }
106
-
107
- // Set defaults
108
- request.values.status = 'pending';
109
- request.values.attempt_count = 0;
110
- request.values.priority ??= 1;
111
- request.values.attempt_limit ??= 1;
112
-
113
- // Set scheduled start time using cron expression if provided
114
- if (
115
- request.values.is_scheduled_with__cron_expression != null &&
116
- request.values.is_scheduled_to_execute_on__time == null
117
- ) {
118
- try {
119
- request.values.is_scheduled_to_execute_on__time = parseCron(
120
- request.values.is_scheduled_with__cron_expression,
121
- ).toISOString();
122
- } catch (_) {
123
- throw new BadRequestError(
124
- `Invalid cron expression: ${request.values.is_scheduled_with__cron_expression}`,
125
- );
126
- }
127
- }
128
-
129
- // Assert that the provided start time is far enough in the future
130
- if (request.values.is_scheduled_to_execute_on__time != null) {
131
- const now = new Date(new Date().getTime() + tasksEnv.queueIntervalMS);
132
- const startTime = new Date(
133
- request.values.is_scheduled_to_execute_on__time,
134
- );
135
- if (startTime < now) {
136
- throw new BadRequestError(
137
- `Task scheduled start time must be greater than ${tasksEnv.queueIntervalMS} milliseconds in the future`,
138
- );
139
- }
140
- }
141
-
142
- // Assert that the requested handler exists
143
- const handlerName = request.values.is_executed_by__handler;
144
- if (handlerName == null) {
145
- throw new BadRequestError(`Must specify a task handler to execute`);
146
- }
147
- const handler = handlers[handlerName];
148
- if (handler == null) {
149
- throw new BadRequestError(
150
- `No task handler with name '${handlerName}' registered`,
151
- );
152
- }
153
-
154
- // Assert that the provided parameter set is valid
155
- if (handler.validate != null) {
156
- if (!handler.validate(request.values.is_executed_with__parameter_set)) {
157
- throw new BadRequestError(
158
- `Invalid parameter set: ${ajv.errorsText(handler.validate.errors)}`,
159
- );
160
- }
161
- }
162
- },
163
- });
164
-
165
- // Start the worker if possible
166
- if (tasksEnv.queueConcurrency > 0 && tasksEnv.queueIntervalMS >= 1000) {
167
- watch();
168
- }
169
- };
170
-
171
- // Register a task handler
172
- export function addTaskHandler(
173
- name: string,
174
- fn: TaskHandler['fn'],
175
- schema?: Schema,
176
- ): void {
177
- if (handlers[name] != null) {
178
- throw new Error(`Task handler with name '${name}' already registered`);
179
- }
180
- handlers[name] = {
181
- name,
182
- fn,
183
- validate: schema != null ? ajv.compile(schema) : undefined,
184
- };
185
- }
186
-
187
- // Calculate next attempt datetime for a task that has failed using exponential backoff
188
- function getNextAttemptTime(attempt: number): Date | null {
189
- const millisecondsInFuture =
190
- Math.ceil(Math.exp(Math.min(10, attempt))) * 1000;
191
- return new Date(Date.now() + millisecondsInFuture);
192
- }
193
-
194
- // Watch for new tasks to execute
195
- let executing = 0;
196
- function watch(): void {
197
- const client = new PinejsClient({
198
- apiPrefix: `/${apiRoot}/`,
199
- });
200
-
201
- setInterval(async () => {
202
- // Do nothing if there are no handlers or if we are already at the concurrency limit
203
- if (
204
- Object.keys(handlers).length === 0 ||
205
- executing >= tasksEnv.queueConcurrency
206
- ) {
207
- return;
208
- }
209
-
210
- try {
211
- await sbvrUtils.db.transaction(async (tx) => {
212
- const names = Object.keys(handlers);
213
- const binds = names.map((_, index) => `$${index + 1}`).join(', ');
214
- const result = await tx.executeSql(
215
- `
216
- SELECT
217
- t."id",
218
- t."is executed by-handler" AS is_executed_by__handler,
219
- t."is executed with-parameter set" AS is_executed_with__parameter_set,
220
- t."is scheduled with-cron expression" AS is_scheduled_with__cron_expression,
221
- t."attempt count" AS attempt_count,
222
- t."attempt limit" AS attempt_limit,
223
- t."priority" AS priority,
224
- t."is created by-actor" AS is_created_by__actor
225
- FROM
226
- task AS t
227
- WHERE
228
- t."is executed by-handler" IN (${binds}) AND
229
- t."status" = 'pending' AND
230
- t."attempt count" <= t."attempt limit" AND
231
- (
232
- t."is scheduled to execute on-time" IS NULL OR
233
- t."is scheduled to execute on-time" <= CURRENT_TIMESTAMP + INTERVAL '${Math.ceil(tasksEnv.queueIntervalMS / 1000)} second'
234
- )
235
- ORDER BY
236
- t."is scheduled to execute on-time" ASC,
237
- t."priority" DESC,
238
- t."id" ASC
239
- LIMIT 1
240
- FOR UPDATE
241
- SKIP LOCKED
242
- `,
243
- names,
244
- );
245
- if (result.rows.length > 0) {
246
- executing++;
247
- await execute(client, result.rows[0] as PartialTask, tx);
248
- executing--;
249
- }
250
- });
251
- } catch (err: unknown) {
252
- console.error('Failed polling for tasks:', err);
253
- }
254
- }, tasksEnv.queueIntervalMS);
255
- }
256
-
257
- // Execute a task
258
- async function execute(
259
- client: PinejsClient,
260
- task: PartialTask,
261
- tx: Tx,
262
- ): Promise<void> {
263
- try {
264
- // Get the handler
265
- const handler = handlers[task.is_executed_by__handler];
266
- const startedOnTime = new Date();
267
- if (handler == null) {
268
- await update(
269
- client,
270
- tx,
271
- task,
272
- startedOnTime,
273
- 'failed',
274
- 'Matching task handler not found',
275
- );
276
- return;
277
- }
278
-
279
- // Validate parameters before execution so we can fail early if
280
- // the parameter set is invalid. This can happen if the handler
281
- // definition changes after a task is added to the queue.
282
- if (
283
- handler.validate != null &&
284
- !handler.validate(task.is_executed_with__parameter_set)
285
- ) {
286
- await update(
287
- client,
288
- tx,
289
- task,
290
- startedOnTime,
291
- 'failed',
292
- `Invalid parameter set: ${ajv.errorsText(handler.validate.errors)}`,
293
- );
294
- return;
295
- }
296
-
297
- // Execute the handler
298
- const result = await handler.fn({
299
- api: new PinejsClient({
300
- passthrough: {
301
- tx,
302
- },
303
- }),
304
- params: task.is_executed_with__parameter_set ?? {},
305
- tx,
306
- });
307
-
308
- // Update the task with the results
309
- await update(client, tx, task, startedOnTime, result.status, result.error);
310
- } catch (err: unknown) {
311
- // This shouldn't normally happen, but if it does, we want to log it and kill the process
312
- console.error('Task execution failed:', err);
313
- process.exit(1);
314
- }
315
- }
316
-
317
- // Update a task
318
- async function update(
319
- client: PinejsClient,
320
- tx: Tx,
321
- task: PartialTask,
322
- startedOnTime: Date,
323
- status: string,
324
- errorMessage?: string,
325
- ): Promise<void> {
326
- const attemptCount = task.attempt_count + 1;
327
- const body: AnyObject = {
328
- started_on__time: startedOnTime,
329
- ended_on__time: new Date(),
330
- status,
331
- attempt_count: attemptCount,
332
- ...(errorMessage != null && { error_message: errorMessage }),
333
- };
334
-
335
- // Re-enqueue if the task failed but has retries left, remember that
336
- // executionCount includes the initial attempt while retryLimit does not
337
- if (status === 'failed' && attemptCount < task.attempt_limit) {
338
- body.status = 'pending';
339
-
340
- // Schedule next attempt using exponential backoff
341
- body.is_scheduled_to_execute_on__time = getNextAttemptTime(attemptCount);
342
- }
343
-
344
- // Patch current task
345
- await client.patch({
346
- resource: 'task',
347
- passthrough: {
348
- tx,
349
- req: permissions.root,
350
- },
351
- id: task.id,
352
- body,
353
- });
354
-
355
- // Create new task with same configuration if previous
356
- // iteration completed and has a cron expression
357
- if (
358
- ['failed', 'success'].includes(body.status) &&
359
- task.is_scheduled_with__cron_expression != null
360
- ) {
361
- await client.post({
362
- resource: 'task',
363
- passthrough: {
364
- tx,
365
- req: permissions.root,
366
- },
367
- body: {
368
- attempt_limit: task.attempt_limit,
369
- is_created_by__actor: task.is_created_by__actor,
370
- is_executed_by__handler: task.is_executed_by__handler,
371
- is_executed_with__parameter_set: task.is_executed_with__parameter_set,
372
- is_scheduled_with__cron_expression:
373
- task.is_scheduled_with__cron_expression,
374
- priority: task.priority,
375
- },
376
- });
377
- }
378
- }