@balena/pinejs 17.1.0-build-joshbwlng-tasks-e8a81b1d884e0039e67661a25a5cb81d46966640-1 → 17.1.0-build-model-based-typings-86a981c89f828217571c93fa010174529a5345f9-1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. package/.pinejs-cache.json +1 -1
  2. package/.versionbot/CHANGELOG.yml +220 -7
  3. package/CHANGELOG.md +65 -2
  4. package/out/config-loader/env.d.ts +0 -4
  5. package/out/config-loader/env.js +1 -5
  6. package/out/config-loader/env.js.map +1 -1
  7. package/out/data-server/sbvr-server.js +3 -2
  8. package/out/data-server/sbvr-server.js.map +1 -1
  9. package/out/database-layer/db.d.ts +0 -3
  10. package/out/database-layer/db.js +0 -17
  11. package/out/database-layer/db.js.map +1 -1
  12. package/out/migrator/migrations.d.ts +113 -0
  13. package/out/migrator/migrations.js +3 -0
  14. package/out/migrator/migrations.js.map +1 -0
  15. package/out/migrator/sync.d.ts +17 -0
  16. package/out/migrator/sync.js +39 -40
  17. package/out/migrator/sync.js.map +1 -1
  18. package/out/sbvr-api/dev.d.ts +41 -0
  19. package/out/sbvr-api/dev.js +3 -0
  20. package/out/sbvr-api/dev.js.map +1 -0
  21. package/out/sbvr-api/hooks.d.ts +26 -26
  22. package/out/sbvr-api/hooks.js.map +1 -1
  23. package/out/sbvr-api/permissions.d.ts +26 -2
  24. package/out/sbvr-api/permissions.js +39 -40
  25. package/out/sbvr-api/permissions.js.map +1 -1
  26. package/out/sbvr-api/sbvr-utils.d.ts +46 -6
  27. package/out/sbvr-api/sbvr-utils.js +44 -44
  28. package/out/sbvr-api/sbvr-utils.js.map +1 -1
  29. package/out/sbvr-api/user.d.ts +467 -0
  30. package/out/sbvr-api/user.js +3 -0
  31. package/out/sbvr-api/user.js.map +1 -0
  32. package/out/server-glue/module.d.ts +0 -1
  33. package/out/server-glue/module.js +1 -4
  34. package/out/server-glue/module.js.map +1 -1
  35. package/package.json +18 -19
  36. package/src/config-loader/env.ts +1 -6
  37. package/src/data-server/sbvr-server.js +3 -2
  38. package/src/database-layer/db.ts +0 -25
  39. package/src/migrator/migrations.ts +120 -0
  40. package/src/migrator/sync.ts +46 -41
  41. package/src/sbvr-api/dev.ts +44 -0
  42. package/src/sbvr-api/hooks.ts +21 -18
  43. package/src/sbvr-api/permissions.ts +50 -44
  44. package/src/sbvr-api/sbvr-utils.ts +90 -53
  45. package/src/sbvr-api/user.ts +421 -0
  46. package/src/server-glue/module.ts +0 -3
  47. package/out/tasks/common.d.ts +0 -4
  48. package/out/tasks/common.js +0 -13
  49. package/out/tasks/common.js.map +0 -1
  50. package/out/tasks/index.d.ts +0 -8
  51. package/out/tasks/index.js +0 -142
  52. package/out/tasks/index.js.map +0 -1
  53. package/out/tasks/tasks.sbvr +0 -60
  54. package/out/tasks/types.d.ts +0 -38
  55. package/out/tasks/types.js +0 -10
  56. package/out/tasks/types.js.map +0 -1
  57. package/out/tasks/worker.d.ts +0 -16
  58. package/out/tasks/worker.js +0 -228
  59. package/out/tasks/worker.js.map +0 -1
  60. package/src/tasks/common.ts +0 -14
  61. package/src/tasks/index.ts +0 -158
  62. package/src/tasks/tasks.sbvr +0 -60
  63. package/src/tasks/types.ts +0 -58
  64. package/src/tasks/worker.ts +0 -281
@@ -1,158 +0,0 @@
1
- import type { Schema } from 'ajv';
2
- import * as cronParser from 'cron-parser';
3
- import { tasks as tasksEnv } from '../config-loader/env';
4
- import type * as Db from '../database-layer/db';
5
- import { BadRequestError } from '../sbvr-api/errors';
6
- import { addPureHook } from '../sbvr-api/hooks';
7
- import * as sbvrUtils from '../sbvr-api/sbvr-utils';
8
- import type { ConfigLoader } from '../server-glue/module';
9
- import { ajv, apiRoot, channel } from './common';
10
- import type { TaskHandler } from './types';
11
- import { Worker } from './worker';
12
-
13
- export * from './types';
14
-
15
- // eslint-disable-next-line @typescript-eslint/no-var-requires
16
- const modelText: string = require('./tasks.sbvr');
17
-
18
- // Create trigger for handling new tasks
19
- // Create index for polling tasks table
20
- const initSql = `
21
- CREATE OR REPLACE FUNCTION notify_task_insert()
22
- RETURNS TRIGGER AS $$
23
- BEGIN
24
- PERFORM pg_notify('${channel}', NEW.id::text);
25
- RETURN NEW;
26
- END;
27
- $$ LANGUAGE plpgsql;
28
-
29
- CREATE OR REPLACE TRIGGER task_insert_trigger
30
- AFTER INSERT ON task
31
- FOR EACH ROW WHEN (NEW.status = 'queued' AND NEW."is scheduled to execute on-time" IS NULL)
32
- EXECUTE FUNCTION notify_task_insert();
33
-
34
- CREATE INDEX IF NOT EXISTS idx_task_poll ON task USING btree (
35
- "is executed by-handler",
36
- "is scheduled to execute on-time" ASC,
37
- "priority" DESC,
38
- "id" ASC
39
- ) WHERE status = 'queued';
40
- `;
41
-
42
- export const config: ConfigLoader.Config = {
43
- models: [
44
- {
45
- modelName: apiRoot,
46
- apiRoot,
47
- modelText,
48
- customServerCode: exports,
49
- initSql,
50
- },
51
- ],
52
- };
53
-
54
- let worker: Worker | null = null;
55
- export async function setup(db: Db.Database): Promise<void> {
56
- // Async task functionality is only supported on Postgres
57
- if (db.engine !== 'postgres') {
58
- console.warn('Skipping task setup as database not supported');
59
- return;
60
- }
61
-
62
- const client = sbvrUtils.api[apiRoot];
63
- worker = new Worker(client);
64
-
65
- // Add resource hooks
66
- addPureHook('POST', apiRoot, 'task', {
67
- POSTPARSE: async ({ req, request }) => {
68
- // Set the actor
69
- request.values.is_created_by__actor =
70
- req.user?.actor ?? req.apiKey?.actor;
71
- if (request.values.is_created_by__actor == null) {
72
- throw new BadRequestError(
73
- 'Creating tasks with missing actor on req is not allowed',
74
- );
75
- }
76
-
77
- // Set defaults
78
- request.values.status = 'queued';
79
- request.values.attempt_count = 0;
80
- request.values.attempt_limit ??= 1;
81
- // TODO: Implement a balancer to better enqueue tasks based on actor usage
82
- request.values.priority ??= 1;
83
-
84
- // Set scheduled start time using cron expression if provided
85
- if (
86
- request.values.is_scheduled_with__cron_expression != null &&
87
- request.values.is_scheduled_to_execute_on__time == null
88
- ) {
89
- try {
90
- request.values.is_scheduled_to_execute_on__time = cronParser
91
- .parseExpression(request.values.is_scheduled_with__cron_expression)
92
- .next()
93
- .toDate()
94
- .toISOString();
95
- } catch {
96
- throw new BadRequestError(
97
- `Invalid cron expression: ${request.values.is_scheduled_with__cron_expression}`,
98
- );
99
- }
100
- }
101
-
102
- // Assert that the provided start time is far enough in the future
103
- if (request.values.is_scheduled_to_execute_on__time != null) {
104
- const now = new Date(Date.now() + tasksEnv.queueIntervalMS);
105
- const startTime = new Date(
106
- request.values.is_scheduled_to_execute_on__time,
107
- );
108
- if (startTime < now) {
109
- throw new BadRequestError(
110
- `Task scheduled start time must be greater than ${tasksEnv.queueIntervalMS} milliseconds in the future`,
111
- );
112
- }
113
- }
114
-
115
- // Assert that the requested handler exists
116
- const handlerName = request.values.is_executed_by__handler;
117
- if (handlerName == null) {
118
- throw new BadRequestError(`Must specify a task handler to execute`);
119
- }
120
- const handler = worker?.handlers[handlerName];
121
- if (handler == null) {
122
- throw new BadRequestError(
123
- `No task handler with name '${handlerName}' registered`,
124
- );
125
- }
126
-
127
- // Assert that the provided parameter set is valid
128
- if (handler.validate != null) {
129
- if (!handler.validate(request.values.is_executed_with__parameter_set)) {
130
- throw new BadRequestError(
131
- `Invalid parameter set: ${ajv.errorsText(handler.validate.errors)}`,
132
- );
133
- }
134
- }
135
- },
136
- });
137
- worker.start();
138
- }
139
-
140
- // Register a task handler
141
- export function addTaskHandler(
142
- name: string,
143
- fn: TaskHandler['fn'],
144
- schema?: Schema,
145
- ): void {
146
- if (worker == null) {
147
- throw new Error('Database does not support tasks');
148
- }
149
-
150
- if (worker.handlers[name] != null) {
151
- throw new Error(`Task handler with name '${name}' already registered`);
152
- }
153
- worker.handlers[name] = {
154
- name,
155
- fn,
156
- validate: schema != null ? ajv.compile(schema) : undefined,
157
- };
158
- }
@@ -1,60 +0,0 @@
1
- Vocabulary: tasks
2
-
3
- Term: id
4
- Concept Type: Big Serial (Type)
5
- Term: actor
6
- Concept Type: Integer (Type)
7
- Term: attempt count
8
- Concept Type: Integer (Type)
9
- Term: attempt limit
10
- Concept Type: Integer (Type)
11
- Term: cron expression
12
- Concept Type: Short Text (Type)
13
- Term: error message
14
- Concept Type: Short Text (Type)
15
- Term: handler
16
- Concept Type: Short Text (Type)
17
- Term: key
18
- Concept Type: Short Text (Type)
19
- Term: parameter set
20
- Concept Type: JSON (Type)
21
- Term: priority
22
- Concept Type: Integer (Type)
23
- Term: status
24
- Concept Type: Short Text (Type)
25
- Term: time
26
- Concept Type: Date Time (Type)
27
-
28
- Term: task
29
- Fact type: task has id
30
- Necessity: each task has exactly one id
31
- Fact type: task has key
32
- Necessity: each task has at most one key
33
- Fact type: task is created by actor
34
- Necessity: each task is created by exactly one actor
35
- Fact type: task is executed by handler
36
- Necessity: each task is executed by exactly one handler
37
- Fact type: task is executed with parameter set
38
- Necessity: each task is executed with at most one parameter set
39
- Fact type: task has priority
40
- Necessity: each task has exactly one priority
41
- Necessity: each task has a priority that is greater than or equal to 0
42
- Fact type: task is scheduled with cron expression
43
- Necessity: each task is scheduled with at most one cron expression
44
- Fact type: task is scheduled to execute on time
45
- Necessity: each task is scheduled to execute on at most one time
46
- Fact type: task has status
47
- Necessity: each task has exactly one status
48
- Definition: "queued" or "cancelled" or "succeeded" or "failed"
49
- Fact type: task started on time
50
- Necessity: each task started on at most one time
51
- Fact type: task ended on time
52
- Necessity: each task ended on at most one time
53
- Fact type: task has error message
54
- Necessity: each task has at most one error message
55
- Fact type: task has attempt count
56
- Necessity: each task has exactly one attempt count
57
- Fact type: task has attempt limit
58
- Necessity: each task has exactly one attempt limit
59
- Necessity: each task has an attempt limit that is greater than or equal to 1
60
-
@@ -1,58 +0,0 @@
1
- import type { ValidateFunction } from 'ajv';
2
- import type { AnyObject } from 'pinejs-client-core';
3
- import type * as Db from '../database-layer/db';
4
- import type { PinejsClient } from '../sbvr-api/sbvr-utils';
5
-
6
- export const taskStatuses = [
7
- 'queued',
8
- 'cancelled',
9
- 'succeeded',
10
- 'failed',
11
- ] as const;
12
- export type TaskStatus = (typeof taskStatuses)[number];
13
- export interface Task {
14
- id: number;
15
- created_at: Date;
16
- modified_at: Date;
17
- is_created_by__actor: number;
18
- is_executed_by__handler: string;
19
- is_executed_with__parameter_set: object | null;
20
- is_scheduled_with__cron_expression: string | null;
21
- is_scheduled_to_execute_on__time: Date | null;
22
- priority: number;
23
- status: TaskStatus;
24
- started_on__time: Date | null;
25
- ended_on__time: Date | null;
26
- error_message: string | null;
27
- attempt_count: number;
28
- attempt_limit: number;
29
- }
30
-
31
- export type PartialTask = Pick<
32
- Task,
33
- | 'id'
34
- | 'is_created_by__actor'
35
- | 'is_executed_by__handler'
36
- | 'is_executed_with__parameter_set'
37
- | 'is_scheduled_with__cron_expression'
38
- | 'priority'
39
- | 'attempt_count'
40
- | 'attempt_limit'
41
- >;
42
-
43
- export interface TaskArgs {
44
- api: PinejsClient;
45
- params: AnyObject;
46
- tx: Db.Tx;
47
- }
48
-
49
- export type TaskResponse = Promise<{
50
- status: TaskStatus;
51
- error?: string;
52
- }>;
53
-
54
- export interface TaskHandler {
55
- name: string;
56
- fn: (options: TaskArgs) => TaskResponse;
57
- validate?: ValidateFunction;
58
- }
@@ -1,281 +0,0 @@
1
- import { setTimeout } from 'node:timers/promises';
2
- import type { AnyObject } from 'pinejs-client-core';
3
- import { tasks as tasksEnv } from '../config-loader/env';
4
- import type * as Db from '../database-layer/db';
5
- import { TransactionClosedError } from '../database-layer/db';
6
- import * as permissions from '../sbvr-api/permissions';
7
- import { PinejsClient } from '../sbvr-api/sbvr-utils';
8
- import { sbvrUtils } from '../server-glue/module';
9
- import { ajv, channel } from './common';
10
- import type { PartialTask, TaskHandler, TaskStatus } from './types';
11
-
12
- // Map of column names with SBVR names used in SELECT queries
13
- const selectColumns = Object.entries({
14
- id: 'id',
15
- 'is executed by-handler': 'is_executed_by__handler',
16
- 'is executed with-parameter set': 'is_executed_with__parameter_set',
17
- 'is scheduled with-cron expression': 'is_scheduled_with__cron_expression',
18
- 'attempt count': 'attempt_count',
19
- 'attempt limit': 'attempt_limit',
20
- priority: 'priority',
21
- 'is created by-actor': 'is_created_by__actor',
22
- })
23
- .map(([key, value]) => `t."${key}" AS "${value}"`)
24
- .join(', ');
25
-
26
- // The worker is responsible for executing tasks in the queue. It listens for
27
- // notifications and polls the database for tasks to execute. It will execute
28
- // tasks in parallel up to a certain concurrency limit.
29
- export class Worker {
30
- public handlers: Record<string, TaskHandler> = {};
31
- private readonly concurrency: number;
32
- private readonly interval: number;
33
- private client: PinejsClient;
34
- private executing = 0;
35
-
36
- constructor(client: PinejsClient) {
37
- this.client = client;
38
- this.concurrency = tasksEnv.queueConcurrency;
39
- this.interval = tasksEnv.queueIntervalMS;
40
- }
41
-
42
- // Check if instance can execute more tasks
43
- private canExecute(): boolean {
44
- return (
45
- this.executing < this.concurrency && Object.keys(this.handlers).length > 0
46
- );
47
- }
48
-
49
- // Execute a task
50
- private async execute(tx: Db.Tx, task: PartialTask): Promise<void> {
51
- this.executing++;
52
- try {
53
- // Get specified handler
54
- const handler = this.handlers[task.is_executed_by__handler];
55
- const startedOnTime = new Date();
56
- if (handler == null) {
57
- await this.finalize(
58
- tx,
59
- task,
60
- startedOnTime,
61
- 'failed',
62
- 'Matching task handler not found',
63
- );
64
- return;
65
- }
66
-
67
- // Validate parameters before execution so we can fail early if
68
- // the parameter set is invalid. This can happen if the handler
69
- // definition changes after a task is added to the queue.
70
- if (
71
- handler.validate != null &&
72
- !handler.validate(task.is_executed_with__parameter_set)
73
- ) {
74
- await this.finalize(
75
- tx,
76
- task,
77
- startedOnTime,
78
- 'failed',
79
- `Invalid parameter set: ${ajv.errorsText(handler.validate.errors)}`,
80
- );
81
- return;
82
- }
83
-
84
- // Execute handler
85
- let status: TaskStatus = 'queued';
86
- let error: string | undefined;
87
- try {
88
- await sbvrUtils.db.transaction(async (handlerTx) => {
89
- const results = await handler.fn({
90
- api: new PinejsClient({
91
- passthrough: {
92
- tx: handlerTx,
93
- },
94
- }),
95
- params: task.is_executed_with__parameter_set ?? {},
96
- tx: handlerTx,
97
- });
98
- status = results.status;
99
- error = results.error;
100
- if (results.status !== 'succeeded' && !handlerTx.isClosed()) {
101
- await handlerTx.rollback();
102
- }
103
- });
104
- } catch (err) {
105
- // Ignore closed/rollback errors
106
- if (!(err instanceof TransactionClosedError)) {
107
- throw err;
108
- }
109
- } finally {
110
- // Update task with results
111
- await this.finalize(tx, task, startedOnTime, status, error);
112
- }
113
- } catch (err) {
114
- // This shouldn't happen, but if it does we want to log and kill the process
115
- console.error(
116
- `Failed to execute task ${task.id} with handler ${task.is_executed_by__handler}:`,
117
- err,
118
- );
119
- process.exit(1);
120
- } finally {
121
- this.executing--;
122
- }
123
- }
124
-
125
- // Update task and schedule next attempt if needed
126
- private async finalize(
127
- tx: Db.Tx,
128
- task: PartialTask,
129
- startedOnTime: Date,
130
- status: TaskStatus,
131
- errorMessage?: string,
132
- ): Promise<void> {
133
- const attemptCount = task.attempt_count + 1;
134
- const body: AnyObject = {
135
- started_on__time: startedOnTime,
136
- ended_on__time: new Date(),
137
- status,
138
- attempt_count: attemptCount,
139
- ...(errorMessage != null && { error_message: errorMessage }),
140
- };
141
-
142
- // Re-enqueue if the task failed but has retries left, remember that
143
- // attemptCount includes the initial attempt while attempt_limit does not
144
- if (status === 'failed' && attemptCount < task.attempt_limit) {
145
- body.status = 'queued';
146
-
147
- // Schedule next attempt using exponential backoff
148
- body.is_scheduled_to_execute_on__time =
149
- this.getNextAttemptTime(attemptCount);
150
- }
151
-
152
- // Patch current task
153
- await this.client.patch({
154
- resource: 'task',
155
- passthrough: {
156
- tx,
157
- req: permissions.root,
158
- },
159
- id: task.id,
160
- body,
161
- });
162
-
163
- // Create new task with same configuration if previous
164
- // iteration completed and has a cron expression
165
- if (
166
- ['failed', 'succeeded'].includes(body.status) &&
167
- task.is_scheduled_with__cron_expression != null
168
- ) {
169
- await this.client.post({
170
- resource: 'task',
171
- passthrough: {
172
- tx,
173
- req: permissions.root,
174
- },
175
- options: {
176
- returnResource: false,
177
- },
178
- body: {
179
- attempt_limit: task.attempt_limit,
180
- is_created_by__actor: task.is_created_by__actor,
181
- is_executed_by__handler: task.is_executed_by__handler,
182
- is_executed_with__parameter_set: task.is_executed_with__parameter_set,
183
- is_scheduled_with__cron_expression:
184
- task.is_scheduled_with__cron_expression,
185
- priority: task.priority,
186
- },
187
- });
188
- }
189
- }
190
-
191
- // Calculate next attempt time using exponential backoff
192
- private getNextAttemptTime(attempt: number): Date | null {
193
- const delay = Math.ceil(Math.exp(Math.min(10, attempt)));
194
- return new Date(Date.now() + delay);
195
- }
196
-
197
- // Poll for tasks to execute
198
- private poll(): void {
199
- let executed = false;
200
- void (async () => {
201
- try {
202
- const handlerNames = Object.keys(this.handlers);
203
- const binds = handlerNames
204
- .map((_, index) => `$${index + 1}`)
205
- .join(', ');
206
- if (!this.canExecute()) {
207
- return;
208
- }
209
- await sbvrUtils.db.transaction(async (tx) => {
210
- const result = await tx.executeSql(
211
- `SELECT ${selectColumns}
212
- FROM task AS t
213
- WHERE
214
- t."is executed by-handler" IN (${binds}) AND
215
- t."status" = 'queued' AND
216
- t."attempt count" <= t."attempt limit" AND
217
- (
218
- t."is scheduled to execute on-time" IS NULL OR
219
- t."is scheduled to execute on-time" <= CURRENT_TIMESTAMP + INTERVAL '${Math.ceil(this.interval / 1000)} second'
220
- )
221
- ORDER BY
222
- t."is scheduled to execute on-time" ASC,
223
- t."priority" DESC,
224
- t."id" ASC
225
- LIMIT ${Math.max(this.concurrency - this.executing, 0)}
226
- FOR UPDATE SKIP LOCKED`,
227
- handlerNames,
228
- );
229
- if (result.rows.length === 0) {
230
- return;
231
- }
232
-
233
- // Tasks found, execute them in parallel
234
- await Promise.all(
235
- result.rows.map(async (row) => {
236
- await this.execute(tx, row as PartialTask);
237
- }),
238
- );
239
- executed = true;
240
- });
241
- } catch (err) {
242
- console.error('Failed polling for tasks:', err);
243
- } finally {
244
- if (!executed) {
245
- await setTimeout(this.interval);
246
- }
247
- this.poll();
248
- }
249
- })();
250
- }
251
-
252
- // Start listening and polling for tasks
253
- public start(): void {
254
- // Tasks only support postgres for now
255
- if (sbvrUtils.db.engine !== 'postgres' || sbvrUtils.db.on == null) {
256
- throw new Error(
257
- 'Database does not support tasks, giving up on starting worker',
258
- );
259
- }
260
- sbvrUtils.db.on(
261
- 'notification',
262
- async (msg) => {
263
- if (this.canExecute()) {
264
- await sbvrUtils.db.transaction(async (tx) => {
265
- const result = await tx.executeSql(
266
- `SELECT ${selectColumns} FROM task AS t WHERE id = $1 FOR UPDATE SKIP LOCKED`,
267
- [msg.payload],
268
- );
269
- if (result.rows.length > 0) {
270
- await this.execute(tx, result.rows[0] as PartialTask);
271
- }
272
- });
273
- }
274
- },
275
- {
276
- channel,
277
- },
278
- );
279
- this.poll();
280
- }
281
- }