@pgflow/edge-worker 0.0.5-prealpha.0
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/.envrc +2 -0
- package/LICENSE.md +660 -0
- package/README.md +46 -0
- package/deno.json +32 -0
- package/deno.lock +369 -0
- package/dist/LICENSE.md +660 -0
- package/dist/README.md +46 -0
- package/dist/index.js +972 -0
- package/dist/index.js.map +7 -0
- package/mod.ts +7 -0
- package/package.json +14 -0
- package/project.json +164 -0
- package/scripts/concatenate-migrations.sh +22 -0
- package/scripts/wait-for-localhost +17 -0
- package/sql/990_active_workers.sql +11 -0
- package/sql/991_inactive_workers.sql +12 -0
- package/sql/992_spawn_worker.sql +68 -0
- package/sql/benchmarks/max_concurrency.sql +32 -0
- package/sql/queries/debug_connections.sql +0 -0
- package/sql/queries/debug_processing_gaps.sql +115 -0
- package/src/EdgeWorker.ts +172 -0
- package/src/core/BatchProcessor.ts +38 -0
- package/src/core/ExecutionController.ts +51 -0
- package/src/core/Heartbeat.ts +23 -0
- package/src/core/Logger.ts +69 -0
- package/src/core/Queries.ts +44 -0
- package/src/core/Worker.ts +102 -0
- package/src/core/WorkerLifecycle.ts +93 -0
- package/src/core/WorkerState.ts +85 -0
- package/src/core/types.ts +47 -0
- package/src/flow/FlowWorkerLifecycle.ts +81 -0
- package/src/flow/StepTaskExecutor.ts +87 -0
- package/src/flow/StepTaskPoller.ts +51 -0
- package/src/flow/createFlowWorker.ts +105 -0
- package/src/flow/types.ts +1 -0
- package/src/index.ts +15 -0
- package/src/queue/MessageExecutor.ts +105 -0
- package/src/queue/Queue.ts +92 -0
- package/src/queue/ReadWithPollPoller.ts +35 -0
- package/src/queue/createQueueWorker.ts +145 -0
- package/src/queue/types.ts +14 -0
- package/src/spawnNewEdgeFunction.ts +33 -0
- package/supabase/call +23 -0
- package/supabase/cli +3 -0
- package/supabase/config.toml +42 -0
- package/supabase/functions/cpu_intensive/index.ts +20 -0
- package/supabase/functions/creating_queue/index.ts +5 -0
- package/supabase/functions/failing_always/index.ts +13 -0
- package/supabase/functions/increment_sequence/index.ts +14 -0
- package/supabase/functions/max_concurrency/index.ts +17 -0
- package/supabase/functions/serial_sleep/index.ts +16 -0
- package/supabase/functions/utils.ts +13 -0
- package/supabase/seed.sql +2 -0
- package/tests/db/compose.yaml +20 -0
- package/tests/db.ts +71 -0
- package/tests/e2e/README.md +54 -0
- package/tests/e2e/_helpers.ts +135 -0
- package/tests/e2e/performance.test.ts +60 -0
- package/tests/e2e/restarts.test.ts +56 -0
- package/tests/helpers.ts +22 -0
- package/tests/integration/_helpers.ts +43 -0
- package/tests/integration/creating_queue.test.ts +32 -0
- package/tests/integration/flow/minimalFlow.test.ts +121 -0
- package/tests/integration/maxConcurrent.test.ts +76 -0
- package/tests/integration/retries.test.ts +78 -0
- package/tests/integration/starting_worker.test.ts +35 -0
- package/tests/sql.ts +46 -0
- package/tests/unit/WorkerState.test.ts +74 -0
- package/tsconfig.lib.json +23 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { Worker } from './core/Worker.ts';
|
|
2
|
+
import spawnNewEdgeFunction from './spawnNewEdgeFunction.ts';
|
|
3
|
+
import type { Json } from './core/types.ts';
|
|
4
|
+
import { getLogger, setupLogger } from './core/Logger.ts';
|
|
5
|
+
import {
|
|
6
|
+
createQueueWorker,
|
|
7
|
+
type QueueWorkerConfig,
|
|
8
|
+
} from './queue/createQueueWorker.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration options for the EdgeWorker.
|
|
12
|
+
*/
|
|
13
|
+
export type EdgeWorkerConfig = QueueWorkerConfig;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* EdgeWorker is the main entry point for creating and starting edge workers.
|
|
17
|
+
*
|
|
18
|
+
* It provides a simple interface for starting a worker that processes messages from a queue.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { EdgeWorker } from '@pgflow/edge-worker';
|
|
23
|
+
*
|
|
24
|
+
* EdgeWorker.start(async (message) => {
|
|
25
|
+
* // Process the message
|
|
26
|
+
* console.log('Processing message:', message);
|
|
27
|
+
* }, {
|
|
28
|
+
* queueName: 'my-queue',
|
|
29
|
+
* maxConcurrent: 5,
|
|
30
|
+
* retryLimit: 3
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export class EdgeWorker {
|
|
35
|
+
private static logger = getLogger('EdgeWorker');
|
|
36
|
+
private static wasCalled = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start the EdgeWorker with the given message handler and configuration.
|
|
40
|
+
*
|
|
41
|
+
* @param handler - Function that processes each message from the queue
|
|
42
|
+
* @param config - Configuration options for the worker
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* EdgeWorker.start(handler, {
|
|
47
|
+
* // name of the queue to poll for messages
|
|
48
|
+
* queueName: 'tasks',
|
|
49
|
+
*
|
|
50
|
+
* // how many tasks are processed at the same time
|
|
51
|
+
* maxConcurrent: 10,
|
|
52
|
+
*
|
|
53
|
+
* // how many connections to the database are opened
|
|
54
|
+
* maxPgConnections: 4,
|
|
55
|
+
*
|
|
56
|
+
* // in-worker polling interval
|
|
57
|
+
* maxPollSeconds: 5,
|
|
58
|
+
*
|
|
59
|
+
* // in-database polling interval
|
|
60
|
+
* pollIntervalMs: 200,
|
|
61
|
+
*
|
|
62
|
+
* // how long to wait before retrying a failed job
|
|
63
|
+
* retryDelay: 5,
|
|
64
|
+
*
|
|
65
|
+
* // how many times to retry a failed job
|
|
66
|
+
* retryLimit: 5,
|
|
67
|
+
*
|
|
68
|
+
* // how long a job is invisible after reading
|
|
69
|
+
* // if not successful, will reappear after this time
|
|
70
|
+
* visibilityTimeout: 3,
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
static start<TPayload extends Json = Json>(
|
|
75
|
+
handler: (message: TPayload) => Promise<void> | void,
|
|
76
|
+
config: EdgeWorkerConfig = {}
|
|
77
|
+
) {
|
|
78
|
+
this.ensureFirstCall();
|
|
79
|
+
|
|
80
|
+
// Get connection string from config or environment
|
|
81
|
+
const connectionString =
|
|
82
|
+
config.connectionString || this.getConnectionString();
|
|
83
|
+
|
|
84
|
+
// Create a complete configuration object with defaults
|
|
85
|
+
const completeConfig: EdgeWorkerConfig = {
|
|
86
|
+
// Pass through any config options first
|
|
87
|
+
...config,
|
|
88
|
+
|
|
89
|
+
// Then override with defaults for missing values
|
|
90
|
+
queueName: config.queueName || 'tasks',
|
|
91
|
+
maxConcurrent: config.maxConcurrent ?? 10,
|
|
92
|
+
maxPgConnections: config.maxPgConnections ?? 4,
|
|
93
|
+
maxPollSeconds: config.maxPollSeconds ?? 5,
|
|
94
|
+
pollIntervalMs: config.pollIntervalMs ?? 200,
|
|
95
|
+
retryDelay: config.retryDelay ?? 5,
|
|
96
|
+
retryLimit: config.retryLimit ?? 5,
|
|
97
|
+
visibilityTimeout: config.visibilityTimeout ?? 3,
|
|
98
|
+
|
|
99
|
+
// Ensure connectionString is always set
|
|
100
|
+
connectionString,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
this.setupRequestHandler(handler, completeConfig);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private static ensureFirstCall() {
|
|
107
|
+
if (this.wasCalled) {
|
|
108
|
+
throw new Error('EdgeWorker.start() can only be called once');
|
|
109
|
+
}
|
|
110
|
+
this.wasCalled = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private static getConnectionString(): string {
|
|
114
|
+
// @ts-ignore - TODO: fix the types
|
|
115
|
+
const connectionString = Deno.env.get('EDGE_WORKER_DB_URL');
|
|
116
|
+
if (!connectionString) {
|
|
117
|
+
const message =
|
|
118
|
+
'EDGE_WORKER_DB_URL is not set!\n' +
|
|
119
|
+
'See https://pgflow.pages.dev/edge-worker/prepare-environment/#prepare-connection-string';
|
|
120
|
+
throw new Error(message);
|
|
121
|
+
}
|
|
122
|
+
return connectionString;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private static setupShutdownHandler(worker: Worker) {
|
|
126
|
+
globalThis.onbeforeunload = async () => {
|
|
127
|
+
if (worker.edgeFunctionName) {
|
|
128
|
+
await spawnNewEdgeFunction(worker.edgeFunctionName);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
worker.stop();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// use waitUntil to prevent the function from exiting
|
|
135
|
+
// @ts-ignore: TODO: fix the types
|
|
136
|
+
EdgeRuntime.waitUntil(new Promise(() => {}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private static setupRequestHandler<TPayload extends Json>(
|
|
140
|
+
handler: (message: TPayload) => Promise<void> | void,
|
|
141
|
+
workerConfig: EdgeWorkerConfig
|
|
142
|
+
) {
|
|
143
|
+
let worker: Worker | null = null;
|
|
144
|
+
|
|
145
|
+
Deno.serve({}, (req) => {
|
|
146
|
+
if (!worker) {
|
|
147
|
+
const edgeFunctionName = this.extractFunctionName(req);
|
|
148
|
+
const sbExecutionId = Deno.env.get('SB_EXECUTION_ID')!;
|
|
149
|
+
setupLogger(sbExecutionId);
|
|
150
|
+
|
|
151
|
+
this.logger.info(`HTTP Request: ${edgeFunctionName}`);
|
|
152
|
+
// Create the worker with all configuration options
|
|
153
|
+
|
|
154
|
+
worker = createQueueWorker(handler, workerConfig);
|
|
155
|
+
worker.startOnlyOnce({
|
|
156
|
+
edgeFunctionName,
|
|
157
|
+
workerId: sbExecutionId,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
this.setupShutdownHandler(worker);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return new Response('ok', {
|
|
164
|
+
headers: { 'Content-Type': 'application/json' },
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private static extractFunctionName(req: Request): string {
|
|
170
|
+
return new URL(req.url).pathname.replace(/^\/+|\/+$/g, '');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ExecutionController } from './ExecutionController.ts';
|
|
2
|
+
import type { IMessage, IPoller } from './types.ts';
|
|
3
|
+
import { getLogger } from './Logger.ts';
|
|
4
|
+
|
|
5
|
+
export class BatchProcessor<TMessage extends IMessage> {
|
|
6
|
+
private logger = getLogger('BatchProcessor');
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
private executionController: ExecutionController<TMessage>,
|
|
10
|
+
private poller: IPoller<TMessage>,
|
|
11
|
+
private signal: AbortSignal
|
|
12
|
+
) {
|
|
13
|
+
this.executionController = executionController;
|
|
14
|
+
this.signal = signal;
|
|
15
|
+
this.poller = poller;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async processBatch() {
|
|
19
|
+
this.logger.debug('Polling for new batch of messages...');
|
|
20
|
+
const messageRecords = await this.poller.poll();
|
|
21
|
+
|
|
22
|
+
if (this.signal.aborted) {
|
|
23
|
+
this.logger.info('Discarding messageRecords because worker is stopping');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.logger.debug(`Starting ${messageRecords.length} messages`);
|
|
28
|
+
|
|
29
|
+
const startPromises = messageRecords.map(
|
|
30
|
+
(message) => this.executionController.start(message)
|
|
31
|
+
);
|
|
32
|
+
await Promise.all(startPromises);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async awaitCompletion() {
|
|
36
|
+
return await this.executionController.awaitCompletion();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { newQueue, type Queue as PromiseQueue } from '@jsr/henrygd__queue';
|
|
2
|
+
import type { IExecutor, IMessage } from './types.ts';
|
|
3
|
+
import { getLogger } from './Logger.ts';
|
|
4
|
+
|
|
5
|
+
export interface ExecutionConfig {
|
|
6
|
+
maxConcurrent: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ExecutionController<TMessage extends IMessage> {
|
|
10
|
+
private logger = getLogger('ExecutionController');
|
|
11
|
+
private promiseQueue: PromiseQueue;
|
|
12
|
+
private signal: AbortSignal;
|
|
13
|
+
private createExecutor: (record: TMessage, signal: AbortSignal) => IExecutor;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
executorFactory: (record: TMessage, signal: AbortSignal) => IExecutor,
|
|
17
|
+
abortSignal: AbortSignal,
|
|
18
|
+
config: ExecutionConfig
|
|
19
|
+
) {
|
|
20
|
+
this.signal = abortSignal;
|
|
21
|
+
this.createExecutor = executorFactory;
|
|
22
|
+
this.promiseQueue = newQueue(config.maxConcurrent);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async start(record: TMessage) {
|
|
26
|
+
const executor = this.createExecutor(record, this.signal);
|
|
27
|
+
|
|
28
|
+
this.logger.info(`Scheduling execution of task ${executor.msgId}`);
|
|
29
|
+
|
|
30
|
+
return await this.promiseQueue.add(async () => {
|
|
31
|
+
try {
|
|
32
|
+
this.logger.debug(`Executing task ${executor.msgId}...`);
|
|
33
|
+
await executor.execute();
|
|
34
|
+
this.logger.debug(`Execution successful for ${executor.msgId}`);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
this.logger.error(`Execution failed for ${executor.msgId}:`, error);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async awaitCompletion() {
|
|
43
|
+
const active = this.promiseQueue.active();
|
|
44
|
+
const all = this.promiseQueue.size();
|
|
45
|
+
|
|
46
|
+
this.logger.debug(
|
|
47
|
+
`Awaiting completion of all tasks... (active/all: ${active}}/${all})`
|
|
48
|
+
);
|
|
49
|
+
await this.promiseQueue.done();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Queries } from './Queries.ts';
|
|
2
|
+
import type { WorkerRow } from './types.ts';
|
|
3
|
+
import { getLogger } from './Logger.ts';
|
|
4
|
+
|
|
5
|
+
export class Heartbeat {
|
|
6
|
+
private logger = getLogger('Heartbeat');
|
|
7
|
+
private lastHeartbeat = 0;
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
private interval: number,
|
|
11
|
+
private queries: Queries,
|
|
12
|
+
private workerRow: WorkerRow
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
async send(): Promise<void> {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
if (now - this.lastHeartbeat >= this.interval) {
|
|
18
|
+
await this.queries.sendHeartbeat(this.workerRow);
|
|
19
|
+
this.logger.debug('OK');
|
|
20
|
+
this.lastHeartbeat = now;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as log from '@jsr/std__log';
|
|
2
|
+
|
|
3
|
+
function getLogLevelFromEnv(): log.LevelName {
|
|
4
|
+
const validLevels = [
|
|
5
|
+
// 'NOTSET',
|
|
6
|
+
'DEBUG',
|
|
7
|
+
'INFO',
|
|
8
|
+
// 'WARNING',
|
|
9
|
+
'ERROR',
|
|
10
|
+
// 'CRITICAL',
|
|
11
|
+
];
|
|
12
|
+
const logLevel = Deno.env.get('EDGE_WORKER_LOG_LEVEL')?.toUpperCase();
|
|
13
|
+
|
|
14
|
+
if (logLevel && !validLevels.includes(logLevel)) {
|
|
15
|
+
console.warn(`Invalid log level "${logLevel}". Using "INFO" instead.`);
|
|
16
|
+
return 'INFO';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (logLevel as log.LevelName) || 'INFO';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultLoggerConfig: log.LoggerConfig = {
|
|
23
|
+
level: 'DEBUG',
|
|
24
|
+
handlers: ['console'],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function setupLogger(workerId: string) {
|
|
28
|
+
log.setup({
|
|
29
|
+
handlers: {
|
|
30
|
+
console: new log.ConsoleHandler(getLogLevelFromEnv(), {
|
|
31
|
+
formatter: (record: {
|
|
32
|
+
loggerName: string;
|
|
33
|
+
msg: string;
|
|
34
|
+
args: unknown[];
|
|
35
|
+
}) => {
|
|
36
|
+
const prefix = `worker_id=${workerId}`;
|
|
37
|
+
const module = record.loggerName;
|
|
38
|
+
const msg = record.msg;
|
|
39
|
+
|
|
40
|
+
// If there are additional args, pretty print them using console.log
|
|
41
|
+
if (record.args.length > 0) {
|
|
42
|
+
return `${prefix} [${module}] ${msg}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `${prefix} [${module}] ${msg}`;
|
|
46
|
+
},
|
|
47
|
+
useColors: true,
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
loggers: {
|
|
52
|
+
BatchProcessor: defaultLoggerConfig,
|
|
53
|
+
EdgeWorker: defaultLoggerConfig,
|
|
54
|
+
ExecutionController: defaultLoggerConfig,
|
|
55
|
+
Heartbeat: defaultLoggerConfig,
|
|
56
|
+
Logger: defaultLoggerConfig,
|
|
57
|
+
MessageExecutor: defaultLoggerConfig,
|
|
58
|
+
Worker: defaultLoggerConfig,
|
|
59
|
+
WorkerLifecycle: defaultLoggerConfig,
|
|
60
|
+
WorkerState: defaultLoggerConfig,
|
|
61
|
+
spawnNewEdgeFunction: defaultLoggerConfig,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Helper function to get logger for specific module
|
|
67
|
+
export function getLogger(module: string) {
|
|
68
|
+
return log.getLogger(module);
|
|
69
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type postgres from 'postgres';
|
|
2
|
+
import type { WorkerRow } from './types.ts';
|
|
3
|
+
|
|
4
|
+
export class Queries {
|
|
5
|
+
constructor(private readonly sql: postgres.Sql) {}
|
|
6
|
+
|
|
7
|
+
async onWorkerStarted({
|
|
8
|
+
queueName,
|
|
9
|
+
workerId,
|
|
10
|
+
edgeFunctionName,
|
|
11
|
+
}: {
|
|
12
|
+
queueName: string;
|
|
13
|
+
workerId: string;
|
|
14
|
+
edgeFunctionName: string;
|
|
15
|
+
}): Promise<WorkerRow> {
|
|
16
|
+
const [worker] = await this.sql<WorkerRow[]>`
|
|
17
|
+
INSERT INTO edge_worker.workers (queue_name, worker_id, function_name)
|
|
18
|
+
VALUES (${queueName}, ${workerId}, ${edgeFunctionName})
|
|
19
|
+
RETURNING *;
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
return worker;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async onWorkerStopped(workerRow: WorkerRow): Promise<WorkerRow> {
|
|
26
|
+
const [worker] = await this.sql<WorkerRow[]>`
|
|
27
|
+
UPDATE edge_worker.workers AS w
|
|
28
|
+
SET stopped_at = clock_timestamp(), last_heartbeat_at = clock_timestamp()
|
|
29
|
+
WHERE w.worker_id = ${workerRow.worker_id}
|
|
30
|
+
RETURNING *;
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
return worker;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async sendHeartbeat(workerRow: WorkerRow): Promise<void> {
|
|
37
|
+
await this.sql<WorkerRow[]>`
|
|
38
|
+
UPDATE edge_worker.workers AS w
|
|
39
|
+
SET last_heartbeat_at = clock_timestamp()
|
|
40
|
+
WHERE w.worker_id = ${workerRow.worker_id}
|
|
41
|
+
RETURNING *;
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type postgres from 'postgres';
|
|
2
|
+
import type { IBatchProcessor, ILifecycle, WorkerBootstrap } from './types.ts';
|
|
3
|
+
import { getLogger, setupLogger } from './Logger.ts';
|
|
4
|
+
|
|
5
|
+
export class Worker {
|
|
6
|
+
private lifecycle: ILifecycle;
|
|
7
|
+
private logger = getLogger('Worker');
|
|
8
|
+
private abortController = new AbortController();
|
|
9
|
+
|
|
10
|
+
private batchProcessor: IBatchProcessor;
|
|
11
|
+
private sql: postgres.Sql;
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
batchProcessor: IBatchProcessor,
|
|
15
|
+
lifecycle: ILifecycle,
|
|
16
|
+
sql: postgres.Sql,
|
|
17
|
+
) {
|
|
18
|
+
this.sql = sql;
|
|
19
|
+
|
|
20
|
+
this.lifecycle = lifecycle;
|
|
21
|
+
|
|
22
|
+
this.batchProcessor = batchProcessor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async startOnlyOnce(workerBootstrap: WorkerBootstrap) {
|
|
26
|
+
if (this.lifecycle.isRunning) {
|
|
27
|
+
this.logger.debug('Worker already running, ignoring start request');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await this.start(workerBootstrap);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async start(workerBootstrap: WorkerBootstrap) {
|
|
35
|
+
setupLogger(workerBootstrap.workerId);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await this.lifecycle.acknowledgeStart(workerBootstrap);
|
|
39
|
+
|
|
40
|
+
while (this.isMainLoopActive) {
|
|
41
|
+
try {
|
|
42
|
+
await this.lifecycle.sendHeartbeat();
|
|
43
|
+
} catch (error: unknown) {
|
|
44
|
+
this.logger.error(`Error sending heartbeat: ${error}`);
|
|
45
|
+
// Continue execution - a failed heartbeat shouldn't stop processing
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await this.batchProcessor.processBatch();
|
|
50
|
+
} catch (error: unknown) {
|
|
51
|
+
this.logger.error(`Error processing batch: ${error}`);
|
|
52
|
+
// Continue to next iteration - failed batch shouldn't stop the worker
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.logger.error(`Error in worker main loop: ${error}`);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async stop() {
|
|
62
|
+
// If the worker is already stopping or stopped, do nothing
|
|
63
|
+
if (this.lifecycle.isStopping || this.lifecycle.isStopped) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.lifecycle.transitionToStopping();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
this.logger.info('-> Stopped accepting new messages');
|
|
71
|
+
this.abortController.abort();
|
|
72
|
+
|
|
73
|
+
this.logger.info('-> Waiting for pending tasks to complete...');
|
|
74
|
+
await this.batchProcessor.awaitCompletion();
|
|
75
|
+
this.logger.info('-> Pending tasks completed!');
|
|
76
|
+
|
|
77
|
+
this.lifecycle.acknowledgeStop();
|
|
78
|
+
|
|
79
|
+
this.logger.info('-> Closing SQL connection...');
|
|
80
|
+
await this.sql.end();
|
|
81
|
+
this.logger.info('-> SQL connection closed!');
|
|
82
|
+
} catch (error) {
|
|
83
|
+
this.logger.info(`Error during worker stop: ${error}`);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get edgeFunctionName() {
|
|
89
|
+
return this.lifecycle.edgeFunctionName;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Returns true if worker state is Running and worker was not stopped
|
|
94
|
+
*/
|
|
95
|
+
private get isMainLoopActive() {
|
|
96
|
+
return this.lifecycle.isRunning && !this.isAborted;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private get isAborted() {
|
|
100
|
+
return this.abortController.signal.aborted;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Heartbeat } from './Heartbeat.ts';
|
|
2
|
+
import { getLogger } from './Logger.ts';
|
|
3
|
+
import type { Queries } from './Queries.ts';
|
|
4
|
+
import type { Queue } from '../queue/Queue.ts';
|
|
5
|
+
import type { ILifecycle, Json, WorkerBootstrap, WorkerRow } from './types.ts';
|
|
6
|
+
import { States, WorkerState } from './WorkerState.ts';
|
|
7
|
+
|
|
8
|
+
export interface LifecycleConfig {
|
|
9
|
+
queueName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class WorkerLifecycle<IMessage extends Json> implements ILifecycle {
|
|
13
|
+
private workerState: WorkerState = new WorkerState();
|
|
14
|
+
private heartbeat?: Heartbeat;
|
|
15
|
+
private logger = getLogger('WorkerLifecycle');
|
|
16
|
+
private queries: Queries;
|
|
17
|
+
private queue: Queue<IMessage>;
|
|
18
|
+
private workerRow?: WorkerRow;
|
|
19
|
+
|
|
20
|
+
constructor(queries: Queries, queue: Queue<IMessage>) {
|
|
21
|
+
this.queries = queries;
|
|
22
|
+
this.queue = queue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async acknowledgeStart(workerBootstrap: WorkerBootstrap): Promise<void> {
|
|
26
|
+
this.workerState.transitionTo(States.Starting);
|
|
27
|
+
|
|
28
|
+
this.logger.info(`Ensuring queue '${this.queue.queueName}' exists...`);
|
|
29
|
+
await this.queue.safeCreate();
|
|
30
|
+
|
|
31
|
+
this.workerRow = await this.queries.onWorkerStarted({
|
|
32
|
+
queueName: this.queueName,
|
|
33
|
+
...workerBootstrap,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.heartbeat = new Heartbeat(5000, this.queries, this.workerRow);
|
|
37
|
+
|
|
38
|
+
this.workerState.transitionTo(States.Running);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
acknowledgeStop() {
|
|
42
|
+
this.workerState.transitionTo(States.Stopping);
|
|
43
|
+
|
|
44
|
+
if (!this.workerRow) {
|
|
45
|
+
throw new Error('Cannot stop worker: workerRow not set');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
this.logger.debug('Acknowledging worker stop...');
|
|
50
|
+
|
|
51
|
+
// TODO: commented out because we can live without this
|
|
52
|
+
// but it is causing problems with DbHandler - workes does not have
|
|
53
|
+
// enough time to fire this query before hard-terimnated
|
|
54
|
+
// We can always check the heartbeat to see if it is still running
|
|
55
|
+
//
|
|
56
|
+
// await this.queries.onWorkerStopped(this.workerRow);
|
|
57
|
+
|
|
58
|
+
this.workerState.transitionTo(States.Stopped);
|
|
59
|
+
this.logger.debug('Worker stop acknowledged');
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.logger.debug(`Error acknowledging worker stop: ${error}`);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get edgeFunctionName() {
|
|
67
|
+
return this.workerRow?.function_name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get queueName() {
|
|
71
|
+
return this.queue.queueName;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async sendHeartbeat() {
|
|
75
|
+
await this.heartbeat?.send();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get isRunning() {
|
|
79
|
+
return this.workerState.isRunning;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get isStopping() {
|
|
83
|
+
return this.workerState.isStopping;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get isStopped() {
|
|
87
|
+
return this.workerState.isStopped;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
transitionToStopping() {
|
|
91
|
+
this.workerState.transitionTo(States.Stopping);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getLogger } from './Logger.ts';
|
|
2
|
+
|
|
3
|
+
export enum States {
|
|
4
|
+
/** The worker has been created but has not yet started. */
|
|
5
|
+
Created = 'created',
|
|
6
|
+
|
|
7
|
+
/** The worker is starting but has not yet started processing messages. */
|
|
8
|
+
Starting = 'starting',
|
|
9
|
+
|
|
10
|
+
/** The worker is processing messages. */
|
|
11
|
+
Running = 'running',
|
|
12
|
+
|
|
13
|
+
/** The worker stopped processing messages but is still releasing resources. */
|
|
14
|
+
Stopping = 'stopping',
|
|
15
|
+
|
|
16
|
+
/** The worker has stopped processing messages and released resources
|
|
17
|
+
* and can be discarded. */
|
|
18
|
+
Stopped = 'stopped',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const Transitions: Record<States, States[]> = {
|
|
22
|
+
[States.Created]: [States.Starting],
|
|
23
|
+
[States.Starting]: [States.Running],
|
|
24
|
+
[States.Running]: [States.Stopping],
|
|
25
|
+
[States.Stopping]: [States.Stopped],
|
|
26
|
+
[States.Stopped]: [], // Terminal state - no valid transitions from here
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class TransitionError extends Error {
|
|
30
|
+
constructor(options: { from: States; to: States }) {
|
|
31
|
+
super(`Cannot transition from ${options.from} to ${options.to}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Represents the state of a worker and exposes method for doing allowed transitions
|
|
37
|
+
*/
|
|
38
|
+
export class WorkerState {
|
|
39
|
+
private logger = getLogger('WorkerState');
|
|
40
|
+
private state: States = States.Created;
|
|
41
|
+
|
|
42
|
+
get current() {
|
|
43
|
+
return this.state;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get isCreated() {
|
|
47
|
+
return this.state === States.Created;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get isStarting() {
|
|
51
|
+
return this.state === States.Starting;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get isRunning() {
|
|
55
|
+
return this.state === States.Running;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get isStopping() {
|
|
59
|
+
return this.state === States.Stopping;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get isStopped() {
|
|
63
|
+
return this.state === States.Stopped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
transitionTo(state: States) {
|
|
67
|
+
this.logger.debug(
|
|
68
|
+
`[WorkerState] Starting transition to '${state}' (current state: ${this.state})`
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (this.state === state) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Transitions[this.state].includes(state)) {
|
|
76
|
+
this.state = state;
|
|
77
|
+
this.logger.debug(`[WorkerState] Transitioned to '${state}'`);
|
|
78
|
+
} else {
|
|
79
|
+
throw new TransitionError({
|
|
80
|
+
from: this.state,
|
|
81
|
+
to: state,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|