mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:17:47 +08:00
186 lines
5.4 KiB
TypeScript
186 lines
5.4 KiB
TypeScript
import {
|
|
assertWorkerConcurrency,
|
|
assertWorkerId,
|
|
assertWorkerSessionId,
|
|
hashWorkerCapabilities,
|
|
serializeWorkerCapabilities,
|
|
type WorkerRecord,
|
|
} from '../domain/worker';
|
|
import type {
|
|
HeartbeatWorkerRequest,
|
|
RegisterWorkerRequest,
|
|
TransitionWorkerRequest,
|
|
WorkerControlPlaneClient,
|
|
} from '../ports/workerControlPlaneClient';
|
|
import type { WorkerRegistryRepository } from '../ports/workerRegistryRepository';
|
|
|
|
export const MIN_WORKER_LEASE_DURATION_MS = 5_000;
|
|
export const MAX_WORKER_LEASE_DURATION_MS = 10 * 60_000;
|
|
|
|
export interface AuthenticatedWorkerPrincipal {
|
|
workerId: string;
|
|
}
|
|
|
|
export interface WorkerControlServiceOptions {
|
|
leaseDurationMs?: number;
|
|
clock?: { now(): number };
|
|
}
|
|
|
|
export class WorkerPrincipalMismatchError extends Error {
|
|
constructor() {
|
|
super('Authenticated Worker principal does not match the requested worker');
|
|
this.name = 'WorkerPrincipalMismatchError';
|
|
}
|
|
}
|
|
|
|
function assertIntegerBetween(
|
|
name: string,
|
|
value: number,
|
|
minimum: number,
|
|
maximum: number,
|
|
): void {
|
|
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
|
}
|
|
}
|
|
|
|
function safeExpiration(nowMs: number, durationMs: number): number {
|
|
assertIntegerBetween('nowMs', nowMs, 0, Number.MAX_SAFE_INTEGER);
|
|
if (nowMs > Number.MAX_SAFE_INTEGER - durationMs) {
|
|
throw new RangeError('Worker lease expiration exceeds the safe range');
|
|
}
|
|
return nowMs + durationMs;
|
|
}
|
|
|
|
export class WorkerControlService {
|
|
private readonly leaseDurationMs: number;
|
|
private readonly clock: { now(): number };
|
|
|
|
constructor(
|
|
private readonly repository: WorkerRegistryRepository,
|
|
options: WorkerControlServiceOptions = {},
|
|
) {
|
|
this.leaseDurationMs = options.leaseDurationMs ?? 45_000;
|
|
this.clock = options.clock ?? Date;
|
|
assertIntegerBetween(
|
|
'leaseDurationMs',
|
|
this.leaseDurationMs,
|
|
MIN_WORKER_LEASE_DURATION_MS,
|
|
MAX_WORKER_LEASE_DURATION_MS,
|
|
);
|
|
}
|
|
|
|
async register(
|
|
principal: AuthenticatedWorkerPrincipal,
|
|
request: RegisterWorkerRequest,
|
|
): Promise<WorkerRecord> {
|
|
this.assertPrincipal(principal, request.workerId);
|
|
assertWorkerSessionId(request.sessionId);
|
|
assertWorkerConcurrency(request.maxConcurrentRuns, request.availableSlots);
|
|
const capabilitiesJson = serializeWorkerCapabilities(request.capabilities);
|
|
const nowMs = this.clock.now();
|
|
const result = await this.repository.register({
|
|
workerId: request.workerId,
|
|
sessionId: request.sessionId,
|
|
capabilitiesJson,
|
|
capabilitiesHash: hashWorkerCapabilities(capabilitiesJson),
|
|
maxConcurrentRuns: request.maxConcurrentRuns,
|
|
availableSlots: request.availableSlots,
|
|
registeredAtMs: nowMs,
|
|
leaseExpiresAtMs: safeExpiration(nowMs, this.leaseDurationMs),
|
|
});
|
|
return result.worker;
|
|
}
|
|
|
|
async heartbeat(
|
|
principal: AuthenticatedWorkerPrincipal,
|
|
request: HeartbeatWorkerRequest,
|
|
): Promise<WorkerRecord> {
|
|
this.assertPrincipal(principal, request.workerId);
|
|
const nowMs = this.clock.now();
|
|
return this.repository.heartbeat({
|
|
...request,
|
|
heartbeatAtMs: nowMs,
|
|
leaseExpiresAtMs: safeExpiration(nowMs, this.leaseDurationMs),
|
|
});
|
|
}
|
|
|
|
async drain(
|
|
principal: AuthenticatedWorkerPrincipal,
|
|
request: TransitionWorkerRequest,
|
|
): Promise<WorkerRecord> {
|
|
return this.transition(principal, request, 'draining');
|
|
}
|
|
|
|
async disconnect(
|
|
principal: AuthenticatedWorkerPrincipal,
|
|
request: TransitionWorkerRequest,
|
|
): Promise<WorkerRecord> {
|
|
return this.transition(principal, request, 'offline');
|
|
}
|
|
|
|
async listAvailable(
|
|
options: {
|
|
afterWorkerId?: string;
|
|
limit?: number;
|
|
} = {},
|
|
) {
|
|
return this.repository.listAvailable({
|
|
observedAtMs: this.clock.now(),
|
|
...options,
|
|
});
|
|
}
|
|
|
|
private async transition(
|
|
principal: AuthenticatedWorkerPrincipal,
|
|
request: TransitionWorkerRequest,
|
|
status: 'draining' | 'offline',
|
|
): Promise<WorkerRecord> {
|
|
this.assertPrincipal(principal, request.workerId);
|
|
return this.repository.transition({
|
|
...request,
|
|
status,
|
|
transitionedAtMs: this.clock.now(),
|
|
});
|
|
}
|
|
|
|
private assertPrincipal(
|
|
principal: AuthenticatedWorkerPrincipal,
|
|
workerId: string,
|
|
): void {
|
|
assertWorkerId(principal.workerId);
|
|
assertWorkerId(workerId);
|
|
if (principal.workerId !== workerId) {
|
|
throw new WorkerPrincipalMismatchError();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Binds an authenticated transport principal to the application service. A
|
|
* future HTTP/gRPC adapter must establish this principal before constructing
|
|
* the client; request payloads never get to self-authenticate a Worker id.
|
|
*/
|
|
export class BoundWorkerControlPlaneClient implements WorkerControlPlaneClient {
|
|
constructor(
|
|
private readonly service: WorkerControlService,
|
|
private readonly principal: AuthenticatedWorkerPrincipal,
|
|
) {}
|
|
|
|
register(request: RegisterWorkerRequest): Promise<WorkerRecord> {
|
|
return this.service.register(this.principal, request);
|
|
}
|
|
|
|
heartbeat(request: HeartbeatWorkerRequest): Promise<WorkerRecord> {
|
|
return this.service.heartbeat(this.principal, request);
|
|
}
|
|
|
|
drain(request: TransitionWorkerRequest): Promise<WorkerRecord> {
|
|
return this.service.drain(this.principal, request);
|
|
}
|
|
|
|
disconnect(request: TransitionWorkerRequest): Promise<WorkerRecord> {
|
|
return this.service.disconnect(this.principal, request);
|
|
}
|
|
}
|