@balena/pinejs 17.1.0-build-joshbwlng-tasks-e3dcb0e73ea9c960af553c67cbf7121650f8d1ea-1 → 17.1.0-build-model-based-typings-c276ef4fb8482a246c25940d617d76b76847eff8-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 +227 -13
  3. package/CHANGELOG.md +70 -4
  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 +58 -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 +22 -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 +28 -28
  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 +236 -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 +20 -21
  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 +64 -0
  40. package/src/migrator/sync.ts +46 -41
  41. package/src/sbvr-api/dev.ts +26 -0
  42. package/src/sbvr-api/hooks.ts +19 -18
  43. package/src/sbvr-api/permissions.ts +54 -48
  44. package/src/sbvr-api/sbvr-utils.ts +93 -53
  45. package/src/sbvr-api/user.ts +216 -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 -11
  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 -140
  52. package/out/tasks/index.js.map +0 -1
  53. package/out/tasks/tasks.sbvr +0 -55
  54. package/out/tasks/types.d.ts +0 -37
  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 -226
  59. package/out/tasks/worker.js.map +0 -1
  60. package/src/tasks/common.ts +0 -9
  61. package/src/tasks/index.ts +0 -155
  62. package/src/tasks/tasks.sbvr +0 -55
  63. package/src/tasks/types.ts +0 -56
  64. package/src/tasks/worker.ts +0 -276
@@ -1,276 +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
- 'is created by-actor': 'is_created_by__actor',
21
- })
22
- .map(([key, value]) => `t."${key}" AS "${value}"`)
23
- .join(', ');
24
-
25
- // The worker is responsible for executing tasks in the queue. It listens for
26
- // notifications and polls the database for tasks to execute. It will execute
27
- // tasks in parallel up to a certain concurrency limit.
28
- export class Worker {
29
- public handlers: Record<string, TaskHandler> = {};
30
- private readonly concurrency: number;
31
- private readonly interval: number;
32
- private executing = 0;
33
-
34
- constructor(private readonly client: PinejsClient) {
35
- this.concurrency = tasksEnv.queueConcurrency;
36
- this.interval = tasksEnv.queueIntervalMS;
37
- }
38
-
39
- // Check if instance can execute more tasks
40
- private canExecute(): boolean {
41
- return (
42
- this.executing < this.concurrency && Object.keys(this.handlers).length > 0
43
- );
44
- }
45
-
46
- private async execute(task: PartialTask, tx: Db.Tx): Promise<void> {
47
- this.executing++;
48
- try {
49
- // Get specified handler
50
- const handler = this.handlers[task.is_executed_by__handler];
51
- const startedOnTime = new Date();
52
- if (handler == null) {
53
- await this.finalize(
54
- tx,
55
- task,
56
- startedOnTime,
57
- 'failed',
58
- 'Matching task handler not found',
59
- );
60
- return;
61
- }
62
-
63
- // Validate parameters before execution so we can fail early if
64
- // the parameter set is invalid. This can happen if the handler
65
- // definition changes after a task is added to the queue.
66
- if (
67
- handler.validate != null &&
68
- !handler.validate(task.is_executed_with__parameter_set)
69
- ) {
70
- await this.finalize(
71
- tx,
72
- task,
73
- startedOnTime,
74
- 'failed',
75
- `Invalid parameter set: ${ajv.errorsText(handler.validate.errors)}`,
76
- );
77
- return;
78
- }
79
-
80
- // Execute handler
81
- let status: TaskStatus = 'queued';
82
- let error: string | undefined;
83
- try {
84
- await sbvrUtils.db.transaction(async (handlerTx) => {
85
- const results = await handler.fn({
86
- api: new PinejsClient({
87
- passthrough: {
88
- tx: handlerTx,
89
- },
90
- }),
91
- params: task.is_executed_with__parameter_set ?? {},
92
- tx: handlerTx,
93
- });
94
- status = results.status;
95
- error = results.error;
96
- if (results.status !== 'succeeded' && !handlerTx.isClosed()) {
97
- await handlerTx.rollback();
98
- }
99
- });
100
- } catch (err) {
101
- // Ignore closed/rollback errors
102
- if (!(err instanceof TransactionClosedError)) {
103
- throw err;
104
- }
105
- } finally {
106
- // Update task with results
107
- await this.finalize(tx, task, startedOnTime, status, error);
108
- }
109
- } catch (err) {
110
- // This shouldn't happen, but if it does we want to log and kill the process
111
- console.error(
112
- `Failed to execute task ${task.id} with handler ${task.is_executed_by__handler}:`,
113
- err,
114
- );
115
- process.exit(1);
116
- } finally {
117
- this.executing--;
118
- }
119
- }
120
-
121
- // Update task and schedule next attempt if needed
122
- private async finalize(
123
- tx: Db.Tx,
124
- task: PartialTask,
125
- startedOnTime: Date,
126
- status: TaskStatus,
127
- errorMessage?: string,
128
- ): Promise<void> {
129
- const attemptCount = task.attempt_count + 1;
130
- const body: AnyObject = {
131
- started_on__time: startedOnTime,
132
- ended_on__time: new Date(),
133
- status,
134
- attempt_count: attemptCount,
135
- ...(errorMessage != null && { error_message: errorMessage }),
136
- };
137
-
138
- // Re-enqueue if the task failed but has retries left, remember that
139
- // attemptCount includes the initial attempt while attempt_limit does not
140
- if (status === 'failed' && attemptCount < task.attempt_limit) {
141
- body.status = 'queued';
142
-
143
- // Schedule next attempt using exponential backoff
144
- body.is_scheduled_to_execute_on__time =
145
- this.getNextAttemptTime(attemptCount);
146
- }
147
-
148
- // Patch current task
149
- await this.client.patch({
150
- resource: 'task',
151
- passthrough: {
152
- tx,
153
- req: permissions.root,
154
- },
155
- id: task.id,
156
- body,
157
- });
158
-
159
- // Create new task with same configuration if previous
160
- // iteration completed and has a cron expression
161
- if (
162
- ['failed', 'succeeded'].includes(body.status) &&
163
- task.is_scheduled_with__cron_expression != null
164
- ) {
165
- await this.client.post({
166
- resource: 'task',
167
- passthrough: {
168
- tx,
169
- req: permissions.root,
170
- },
171
- options: {
172
- returnResource: false,
173
- },
174
- body: {
175
- attempt_limit: task.attempt_limit,
176
- is_created_by__actor: task.is_created_by__actor,
177
- is_executed_by__handler: task.is_executed_by__handler,
178
- is_executed_with__parameter_set: task.is_executed_with__parameter_set,
179
- is_scheduled_with__cron_expression:
180
- task.is_scheduled_with__cron_expression,
181
- },
182
- });
183
- }
184
- }
185
-
186
- // Calculate next attempt time using exponential backoff
187
- private getNextAttemptTime(attempt: number): Date | null {
188
- const delay = Math.ceil(Math.exp(Math.min(10, attempt)));
189
- return new Date(Date.now() + delay);
190
- }
191
-
192
- // Poll for tasks to execute
193
- private poll(): void {
194
- let executed = false;
195
- void (async () => {
196
- try {
197
- if (!this.canExecute()) {
198
- return;
199
- }
200
- const handlerNames = Object.keys(this.handlers);
201
- await sbvrUtils.db.transaction(async (tx) => {
202
- const result = await tx.executeSql(
203
- `SELECT ${selectColumns}
204
- FROM task AS t
205
- WHERE
206
- t."is executed by-handler" IN (${handlerNames.map((_, index) => `$${index + 1}`).join(', ')}) AND
207
- t."status" = 'queued' AND
208
- t."attempt count" <= t."attempt limit" AND
209
- (
210
- t."is scheduled to execute on-time" IS NULL OR
211
- t."is scheduled to execute on-time" <= CURRENT_TIMESTAMP + $${handlerNames.length + 1} * INTERVAL '1 SECOND'
212
- )
213
- ORDER BY
214
- t."is scheduled to execute on-time" ASC,
215
- t."id" ASC
216
- LIMIT $${handlerNames.length + 2}
217
- FOR UPDATE SKIP LOCKED`,
218
- [
219
- ...handlerNames,
220
- Math.ceil(this.interval / 1000),
221
- Math.max(this.concurrency - this.executing, 0),
222
- ],
223
- );
224
- if (result.rows.length === 0) {
225
- return;
226
- }
227
-
228
- // Tasks found, execute them in parallel
229
- await Promise.all(
230
- result.rows.map(async (row) => {
231
- await this.execute(row as PartialTask, tx);
232
- }),
233
- );
234
- executed = true;
235
- });
236
- } catch (err) {
237
- console.error('Failed polling for tasks:', err);
238
- } finally {
239
- if (!executed) {
240
- await setTimeout(this.interval);
241
- }
242
- this.poll();
243
- }
244
- })();
245
- }
246
-
247
- // Start listening and polling for tasks
248
- public start(): void {
249
- // Tasks only support postgres for now
250
- if (sbvrUtils.db.engine !== 'postgres' || sbvrUtils.db.on == null) {
251
- throw new Error(
252
- 'Database does not support tasks, giving up on starting worker',
253
- );
254
- }
255
- sbvrUtils.db.on(
256
- 'notification',
257
- async (msg) => {
258
- if (this.canExecute()) {
259
- await sbvrUtils.db.transaction(async (tx) => {
260
- const result = await tx.executeSql(
261
- `SELECT ${selectColumns} FROM task AS t WHERE id = $1 FOR UPDATE SKIP LOCKED`,
262
- [msg.payload],
263
- );
264
- if (result.rows.length > 0) {
265
- await this.execute(result.rows[0] as PartialTask, tx);
266
- }
267
- });
268
- }
269
- },
270
- {
271
- channel,
272
- },
273
- );
274
- this.poll();
275
- }
276
- }