refactor progress
This commit is contained in:
205
packages/worker/src/runtime/WorkerFactory.tsx
Normal file
205
packages/worker/src/runtime/WorkerFactory.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import {setWorkerDependencies} from '@fluxer/worker/src/context/WorkerContext';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
import type {WorkerTaskHandler} from '@fluxer/worker/src/contracts/WorkerTask';
|
||||
import type {
|
||||
LeasedQueueJob,
|
||||
TracingInterface,
|
||||
WorkerConfig,
|
||||
WorkerQueueConfig,
|
||||
WorkerRuntimeConfig,
|
||||
} from '@fluxer/worker/src/contracts/WorkerTypes';
|
||||
import type {IQueueProvider} from '@fluxer/worker/src/providers/IQueueProvider';
|
||||
import {WorkerRunner} from '@fluxer/worker/src/runtime/WorkerRunner';
|
||||
import {WorkerTaskRegistry} from '@fluxer/worker/src/runtime/WorkerTaskRegistry';
|
||||
import {WorkerService} from '@fluxer/worker/src/services/WorkerService';
|
||||
|
||||
export interface CreateWorkerOptions {
|
||||
queue: WorkerQueueOptions;
|
||||
runtime?: WorkerRuntimeConfig | undefined;
|
||||
logger: LoggerInterface;
|
||||
dependencies?: unknown;
|
||||
taskRegistry?: WorkerTaskRegistry | undefined;
|
||||
tracing?: TracingInterface | undefined;
|
||||
}
|
||||
|
||||
export interface CreateWorkerLegacyOptions {
|
||||
config: WorkerConfig;
|
||||
queueProvider?: IQueueProvider | undefined;
|
||||
logger: LoggerInterface;
|
||||
dependencies?: unknown;
|
||||
taskRegistry?: WorkerTaskRegistry | undefined;
|
||||
tracing?: TracingInterface | undefined;
|
||||
}
|
||||
|
||||
export interface WorkerQueueOptions {
|
||||
queueProvider?: IQueueProvider | undefined;
|
||||
queueBaseUrl?: string | undefined;
|
||||
requestTimeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
interface ResolvedWorkerFactoryOptions {
|
||||
queue: WorkerQueueOptions;
|
||||
runtime: WorkerRuntimeConfig;
|
||||
logger: LoggerInterface;
|
||||
dependencies?: unknown;
|
||||
taskRegistry?: WorkerTaskRegistry | undefined;
|
||||
tracing?: TracingInterface | undefined;
|
||||
}
|
||||
|
||||
export interface WorkerResult {
|
||||
start: () => Promise<void>;
|
||||
shutdown: () => Promise<void>;
|
||||
processTask: (job: LeasedQueueJob) => Promise<void>;
|
||||
getRunner: () => WorkerRunner;
|
||||
getWorkerService: () => IWorkerService;
|
||||
registerTask: <TPayload = Record<string, unknown>>(name: string, handler: WorkerTaskHandler<TPayload>) => void;
|
||||
registerTasks: (tasks: Record<string, WorkerTaskHandler>) => void;
|
||||
}
|
||||
|
||||
type WorkerFactoryOptions = CreateWorkerOptions | CreateWorkerLegacyOptions;
|
||||
|
||||
function isLegacyCreateWorkerOptions(options: WorkerFactoryOptions): options is CreateWorkerLegacyOptions {
|
||||
return 'config' in options;
|
||||
}
|
||||
|
||||
function resolveLegacyQueueOptions(config: WorkerQueueConfig, queueProvider?: IQueueProvider): WorkerQueueOptions {
|
||||
return {
|
||||
queueProvider,
|
||||
queueBaseUrl: config.queueBaseUrl,
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWorkerFactoryOptions(options: WorkerFactoryOptions): ResolvedWorkerFactoryOptions {
|
||||
if (isLegacyCreateWorkerOptions(options)) {
|
||||
return {
|
||||
queue: resolveLegacyQueueOptions(options.config, options.queueProvider),
|
||||
runtime: {
|
||||
workerId: options.config.workerId,
|
||||
taskTypes: options.config.taskTypes,
|
||||
concurrency: options.config.concurrency,
|
||||
},
|
||||
logger: options.logger,
|
||||
dependencies: options.dependencies,
|
||||
taskRegistry: options.taskRegistry,
|
||||
tracing: options.tracing,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
queue: options.queue,
|
||||
runtime: options.runtime ?? {},
|
||||
logger: options.logger,
|
||||
dependencies: options.dependencies,
|
||||
taskRegistry: options.taskRegistry,
|
||||
tracing: options.tracing,
|
||||
};
|
||||
}
|
||||
|
||||
function assertTaskRegistryMutable(runner: WorkerRunner | null): void {
|
||||
if (runner?.isRunning()) {
|
||||
throw new Error('Cannot register tasks after worker start. Register tasks before starting the worker.');
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorker(options: WorkerFactoryOptions): WorkerResult {
|
||||
const resolvedOptions = resolveWorkerFactoryOptions(options);
|
||||
const {queue, runtime, logger, dependencies, taskRegistry: providedRegistry, tracing} = resolvedOptions;
|
||||
|
||||
if (dependencies !== undefined) {
|
||||
setWorkerDependencies(dependencies);
|
||||
}
|
||||
|
||||
const taskRegistry = providedRegistry ?? new WorkerTaskRegistry();
|
||||
|
||||
let runner: WorkerRunner | null = null;
|
||||
let workerService: WorkerService | null = null;
|
||||
|
||||
function ensureRunner(): WorkerRunner {
|
||||
if (!runner) {
|
||||
runner = new WorkerRunner({
|
||||
tasks: taskRegistry.getTasks(),
|
||||
queueBaseUrl: queue.queueBaseUrl,
|
||||
queueProvider: queue.queueProvider,
|
||||
logger,
|
||||
workerId: runtime.workerId,
|
||||
taskTypes: runtime.taskTypes,
|
||||
concurrency: runtime.concurrency,
|
||||
tracing,
|
||||
requestTimeoutMs: queue.requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
return runner;
|
||||
}
|
||||
|
||||
function ensureWorkerService(): WorkerService {
|
||||
if (!workerService) {
|
||||
workerService = new WorkerService({
|
||||
queueBaseUrl: queue.queueBaseUrl,
|
||||
queueProvider: queue.queueProvider,
|
||||
logger,
|
||||
tracing,
|
||||
timeoutMs: queue.requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
return workerService;
|
||||
}
|
||||
|
||||
return {
|
||||
async start() {
|
||||
const r = ensureRunner();
|
||||
await r.start();
|
||||
},
|
||||
|
||||
async shutdown() {
|
||||
if (runner) {
|
||||
await runner.stop();
|
||||
}
|
||||
},
|
||||
|
||||
async processTask(job: LeasedQueueJob) {
|
||||
const r = ensureRunner();
|
||||
await r.processJob(job);
|
||||
},
|
||||
|
||||
getRunner() {
|
||||
return ensureRunner();
|
||||
},
|
||||
|
||||
getWorkerService() {
|
||||
return ensureWorkerService();
|
||||
},
|
||||
|
||||
registerTask<TPayload = Record<string, unknown>>(name: string, handler: WorkerTaskHandler<TPayload>) {
|
||||
assertTaskRegistryMutable(runner);
|
||||
taskRegistry.register(name, handler);
|
||||
runner = null;
|
||||
},
|
||||
|
||||
registerTasks(tasks: Record<string, WorkerTaskHandler>) {
|
||||
assertTaskRegistryMutable(runner);
|
||||
taskRegistry.registerAll(tasks);
|
||||
runner = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
224
packages/worker/src/runtime/WorkerRunner.tsx
Normal file
224
packages/worker/src/runtime/WorkerRunner.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {WorkerTaskHandler} from '@fluxer/worker/src/contracts/WorkerTask';
|
||||
import type {LeasedQueueJob, TracingInterface} from '@fluxer/worker/src/contracts/WorkerTypes';
|
||||
import type {IQueueProvider} from '@fluxer/worker/src/providers/IQueueProvider';
|
||||
import {createQueueProvider} from '@fluxer/worker/src/providers/QueueProviderFactory';
|
||||
import {WorkerService} from '@fluxer/worker/src/services/WorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export interface WorkerRunnerOptions {
|
||||
tasks: Record<string, WorkerTaskHandler>;
|
||||
queueBaseUrl?: string | undefined;
|
||||
queueProvider?: IQueueProvider | undefined;
|
||||
logger: LoggerInterface;
|
||||
workerId?: string | undefined;
|
||||
taskTypes?: Array<string> | undefined;
|
||||
concurrency?: number | undefined;
|
||||
tracing?: TracingInterface | undefined;
|
||||
requestTimeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
export class WorkerRunner {
|
||||
private readonly tasks: Record<string, WorkerTaskHandler>;
|
||||
private readonly workerId: string;
|
||||
private readonly taskTypes: Array<string>;
|
||||
private readonly concurrency: number;
|
||||
private readonly queue: IQueueProvider;
|
||||
private readonly workerService: WorkerService;
|
||||
private readonly logger: LoggerInterface;
|
||||
private readonly tracing: TracingInterface | undefined;
|
||||
private running = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private workerLoopPromises: Array<Promise<void>> = [];
|
||||
|
||||
constructor(options: WorkerRunnerOptions) {
|
||||
this.tasks = options.tasks;
|
||||
this.workerId = options.workerId ?? `worker-${randomUUID()}`;
|
||||
this.taskTypes = options.taskTypes ?? Object.keys(options.tasks);
|
||||
this.concurrency = options.concurrency ?? 1;
|
||||
this.logger = options.logger;
|
||||
this.tracing = options.tracing;
|
||||
|
||||
this.queue = createQueueProvider({
|
||||
queueProvider: options.queueProvider,
|
||||
queueBaseUrl: options.queueBaseUrl,
|
||||
timeoutMs: options.requestTimeoutMs,
|
||||
tracing: options.tracing,
|
||||
});
|
||||
|
||||
this.workerService = new WorkerService({
|
||||
queueProvider: this.queue,
|
||||
logger: options.logger,
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.logger.warn({workerId: this.workerId}, 'Worker already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this.logger.info(
|
||||
{workerId: this.workerId, taskTypes: this.taskTypes, concurrency: this.concurrency},
|
||||
'Worker starting',
|
||||
);
|
||||
|
||||
this.workerLoopPromises = Array.from({length: this.concurrency}, (_, i) =>
|
||||
this.workerLoop(i, this.abortController!.signal),
|
||||
);
|
||||
|
||||
Promise.all(this.workerLoopPromises).catch((error) => {
|
||||
this.logger.error({workerId: this.workerId, error}, 'Worker loop failed unexpectedly');
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
this.abortController?.abort();
|
||||
|
||||
const stopTimeout = new Promise<void>((resolve) => setTimeout(resolve, ms('2 seconds')));
|
||||
await Promise.race([Promise.all(this.workerLoopPromises), stopTimeout]);
|
||||
|
||||
this.workerLoopPromises = [];
|
||||
this.logger.info({workerId: this.workerId}, 'Worker stopped');
|
||||
}
|
||||
|
||||
async processJob(leasedJob: LeasedQueueJob): Promise<void> {
|
||||
await this.executeJob(leasedJob);
|
||||
}
|
||||
|
||||
getWorkerService(): WorkerService {
|
||||
return this.workerService;
|
||||
}
|
||||
|
||||
getQueue(): IQueueProvider {
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private async workerLoop(workerIndex: number, signal: AbortSignal): Promise<void> {
|
||||
this.logger.info({workerId: this.workerId, workerIndex}, 'Worker loop started');
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const leasedJobs = await this.queue.dequeue(this.taskTypes, 1);
|
||||
|
||||
if (!leasedJobs || leasedJobs.length === 0) {
|
||||
await this.sleep(100);
|
||||
continue;
|
||||
}
|
||||
|
||||
const leasedJob = leasedJobs[0]!;
|
||||
const job = leasedJob.job;
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
workerId: this.workerId,
|
||||
workerIndex,
|
||||
jobId: job.id,
|
||||
taskType: job.task_type,
|
||||
attempts: job.attempts,
|
||||
receipt: leasedJob.receipt,
|
||||
},
|
||||
'Processing job',
|
||||
);
|
||||
|
||||
await this.executeJob(leasedJob);
|
||||
|
||||
this.logger.info({workerId: this.workerId, workerIndex, jobId: job.id}, 'Job completed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error({workerId: this.workerId, workerIndex, error}, 'Worker loop error');
|
||||
|
||||
await this.sleep(ms('1 second'));
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info({workerId: this.workerId, workerIndex}, 'Worker loop stopped');
|
||||
}
|
||||
|
||||
private async executeJob(leasedJob: LeasedQueueJob): Promise<void> {
|
||||
const execute = async () => {
|
||||
const task = this.tasks[leasedJob.job.task_type];
|
||||
if (!task) {
|
||||
throw new Error(`Unknown task: ${leasedJob.job.task_type}`);
|
||||
}
|
||||
|
||||
this.tracing?.addSpanEvent('job.execution.start');
|
||||
|
||||
try {
|
||||
await task(leasedJob.job.payload as never, {
|
||||
logger: this.logger.child({jobId: leasedJob.job.id}),
|
||||
addJob: this.workerService.addJob.bind(this.workerService),
|
||||
});
|
||||
|
||||
this.tracing?.addSpanEvent('job.execution.success');
|
||||
this.tracing?.setSpanAttributes({'job.status': 'success'});
|
||||
|
||||
await this.queue.complete(leasedJob.receipt);
|
||||
} catch (error) {
|
||||
this.logger.error({jobId: leasedJob.job.id, error}, 'Job failed');
|
||||
|
||||
this.tracing?.setSpanAttributes({
|
||||
'job.status': 'failed',
|
||||
'job.error': error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
this.tracing?.addSpanEvent('job.execution.failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
await this.queue.fail(leasedJob.receipt, String(error));
|
||||
}
|
||||
};
|
||||
|
||||
if (this.tracing) {
|
||||
await this.tracing.withSpan(
|
||||
{
|
||||
name: 'worker.process_job',
|
||||
attributes: {
|
||||
'worker.id': this.workerId,
|
||||
'job.id': leasedJob.job.id,
|
||||
'job.task_type': leasedJob.job.task_type,
|
||||
'job.attempts': leasedJob.job.attempts,
|
||||
},
|
||||
},
|
||||
execute,
|
||||
);
|
||||
} else {
|
||||
await execute();
|
||||
}
|
||||
}
|
||||
|
||||
private async sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
60
packages/worker/src/runtime/WorkerTaskRegistry.tsx
Normal file
60
packages/worker/src/runtime/WorkerTaskRegistry.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {WorkerTaskHandler} from '@fluxer/worker/src/contracts/WorkerTask';
|
||||
|
||||
export class WorkerTaskRegistry {
|
||||
private readonly tasks: Map<string, WorkerTaskHandler> = new Map();
|
||||
|
||||
register<TPayload = Record<string, unknown>>(name: string, handler: WorkerTaskHandler<TPayload>): this {
|
||||
this.tasks.set(name, handler as WorkerTaskHandler);
|
||||
return this;
|
||||
}
|
||||
|
||||
registerAll(tasks: Record<string, WorkerTaskHandler>): this {
|
||||
for (const [name, handler] of Object.entries(tasks)) {
|
||||
this.tasks.set(name, handler);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
get(name: string): WorkerTaskHandler | undefined {
|
||||
return this.tasks.get(name);
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.tasks.has(name);
|
||||
}
|
||||
|
||||
getTaskNames(): Array<string> {
|
||||
return Array.from(this.tasks.keys());
|
||||
}
|
||||
|
||||
getTasks(): Record<string, WorkerTaskHandler> {
|
||||
return Object.fromEntries(this.tasks);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.tasks.size;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTaskRegistry(): WorkerTaskRegistry {
|
||||
return new WorkerTaskRegistry();
|
||||
}
|
||||
Reference in New Issue
Block a user