feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,45 @@
// Scheduling owns the bounded Cron expression adapter used by the Cluster cadence.
import type {
LocalCronNextOccurrence,
LocalCronSchedule,
} from '@qinglong/runtime-core/local-scheduler';
interface CronerJob {
nextRun(after: Date): Date | null;
stop(): void;
}
interface CronerConstructor {
new (
expression: string,
options: Readonly<{
timezone: string;
paused: true;
unref: true;
}>,
): CronerJob;
}
export const cronerClusterNextOccurrence: LocalCronNextOccurrence = (
schedule: LocalCronSchedule,
afterMs: number,
): number => {
let job: CronerJob | undefined;
try {
const { Cron } = require('croner') as Readonly<{
Cron: CronerConstructor;
}>;
job = new Cron(schedule.expression, {
timezone: schedule.timezone,
paused: true,
unref: true,
});
const next = job.nextRun(new Date(afterMs));
if (!(next instanceof Date)) {
throw new Error('cron has no next occurrence');
}
return next.getTime();
} finally {
job?.stop();
}
};
@@ -0,0 +1,69 @@
// Scheduling owns recovery and lost-retry ordering inside the shared cadence.
import type {
ClusterControlStartupRecoverySummary,
ClusterRunLostRetryPageResult,
} from '@qinglong/runtime-core';
import type {
ClusterSchedulerCoordinator,
ClusterSchedulerCycleSummary,
} from './scheduler';
export interface ClusterRuntimeSchedulerMaintenanceSummary {
readonly recovery: Readonly<ClusterControlStartupRecoverySummary>;
readonly lostRetry: Readonly<ClusterRunLostRetryPageResult>;
}
/**
* Reuses the scheduler's single non-overlapping cadence for runtime recovery
* and lost retry. It owns no timer, connection, cursor or per-Run state.
*/
export class ClusterRuntimeSchedulerCoordinator {
private inFlight: Promise<ClusterSchedulerCycleSummary> | undefined;
private latestMaintenance:
| Readonly<ClusterRuntimeSchedulerMaintenanceSummary>
| undefined;
constructor(
private readonly recovery: Readonly<{
reconcile(): Promise<ClusterControlStartupRecoverySummary>;
}>,
private readonly lostRetry: Readonly<{
reconcile(): Promise<Readonly<ClusterRunLostRetryPageResult>>;
}>,
private readonly scheduler: Pick<
ClusterSchedulerCoordinator,
'scheduleOnce'
>,
) {
if (
typeof recovery?.reconcile !== 'function' ||
typeof lostRetry?.reconcile !== 'function' ||
typeof scheduler?.scheduleOnce !== 'function'
) {
throw new TypeError('Cluster runtime scheduler coordinator is invalid');
}
}
scheduleOnce(): Promise<ClusterSchedulerCycleSummary> {
if (this.inFlight) return this.inFlight;
const work = this.runCycle().finally(() => {
if (this.inFlight === work) this.inFlight = undefined;
});
this.inFlight = work;
return work;
}
latestMaintenanceSummary():
| Readonly<ClusterRuntimeSchedulerMaintenanceSummary>
| undefined {
return this.latestMaintenance;
}
private async runCycle(): Promise<ClusterSchedulerCycleSummary> {
const recovery = await this.recovery.reconcile();
const lostRetry = await this.lostRetry.reconcile();
this.latestMaintenance = Object.freeze({ recovery, lostRetry });
return this.scheduler.scheduleOnce();
}
}
@@ -0,0 +1,294 @@
// Scheduling owns bounded trigger claiming and the single non-overlapping lifecycle timer.
import { randomUUID } from 'node:crypto';
import {
MAX_CLUSTER_SCHEDULE_CLAIM_LEASE_MS,
MIN_CLUSTER_SCHEDULE_CLAIM_LEASE_MS,
resolveClusterScheduleDecision,
type ClusterScheduleStore,
} from '@qinglong/runtime-core/cluster-scheduler';
import type { LocalCronNextOccurrence } from '@qinglong/runtime-core/local-scheduler';
import { cronerClusterNextOccurrence } from './cronerSchedule';
export const MAX_CLUSTER_SCHEDULE_CLAIMS_PER_CYCLE = 256;
export interface ClusterSchedulerCoordinatorOptions {
readonly ownerId: string;
readonly claimLeaseMs?: number;
readonly maxClaimsPerCycle?: number;
readonly misfireGraceMs?: number;
readonly createId?: () => string;
readonly nextOccurrence?: LocalCronNextOccurrence;
readonly onAdmitted?: (
runId: string,
attemptId: string,
) => void | Promise<void>;
}
export interface ClusterSchedulerCycleSummary {
readonly firstClaimAcquiredAtMs: number | null;
readonly lastClaimAcquiredAtMs: number | null;
readonly claimed: number;
readonly initialized: number;
readonly skipped: number;
readonly admitted: number;
readonly raced: number;
readonly saturated: boolean;
}
const OWNER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const COORDINATOR_OPTION_KEYS = new Set([
'claimLeaseMs',
'createId',
'maxClaimsPerCycle',
'misfireGraceMs',
'nextOccurrence',
'onAdmitted',
'ownerId',
]);
export class ClusterSchedulerCoordinator {
private readonly ownerId: string;
private readonly claimLeaseMs: number;
private readonly maxClaimsPerCycle: number;
private readonly misfireGraceMs: number;
private readonly createId: () => string;
private readonly nextOccurrence: LocalCronNextOccurrence;
private readonly onAdmitted?: ClusterSchedulerCoordinatorOptions['onAdmitted'];
constructor(
private readonly schedules: ClusterScheduleStore,
options: ClusterSchedulerCoordinatorOptions,
) {
this.ownerId = options?.ownerId ?? '';
this.claimLeaseMs = options?.claimLeaseMs ?? 30_000;
this.maxClaimsPerCycle = options?.maxClaimsPerCycle ?? 16;
this.misfireGraceMs = options?.misfireGraceMs ?? 30_000;
this.createId = options?.createId ?? randomUUID;
this.nextOccurrence =
options?.nextOccurrence ?? cronerClusterNextOccurrence;
this.onAdmitted = options?.onAdmitted;
if (
!schedules ||
typeof schedules.claimNextClusterSchedule !== 'function' ||
typeof schedules.commitClusterScheduleDecision !== 'function' ||
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some((key) => !COORDINATOR_OPTION_KEYS.has(key)) ||
!OWNER_PATTERN.test(this.ownerId) ||
!Number.isSafeInteger(this.claimLeaseMs) ||
this.claimLeaseMs < MIN_CLUSTER_SCHEDULE_CLAIM_LEASE_MS ||
this.claimLeaseMs > MAX_CLUSTER_SCHEDULE_CLAIM_LEASE_MS ||
!Number.isSafeInteger(this.maxClaimsPerCycle) ||
this.maxClaimsPerCycle < 1 ||
this.maxClaimsPerCycle > MAX_CLUSTER_SCHEDULE_CLAIMS_PER_CYCLE ||
!Number.isSafeInteger(this.misfireGraceMs) ||
this.misfireGraceMs < 0 ||
this.misfireGraceMs > 5 * 60_000 ||
typeof this.createId !== 'function' ||
typeof this.nextOccurrence !== 'function' ||
(this.onAdmitted !== undefined && typeof this.onAdmitted !== 'function')
) {
throw new TypeError('Cluster scheduler coordinator options are invalid');
}
}
async scheduleOnce(): Promise<ClusterSchedulerCycleSummary> {
const stats: {
firstClaimAcquiredAtMs: number | null;
lastClaimAcquiredAtMs: number | null;
claimed: number;
initialized: number;
skipped: number;
admitted: number;
raced: number;
saturated: boolean;
} = {
firstClaimAcquiredAtMs: null,
lastClaimAcquiredAtMs: null,
claimed: 0,
initialized: 0,
skipped: 0,
admitted: 0,
raced: 0,
saturated: false,
};
while (stats.claimed < this.maxClaimsPerCycle) {
const claimToken = this.createId();
const claimed = await this.schedules.claimNextClusterSchedule({
ownerId: this.ownerId,
claimToken,
leaseMs: this.claimLeaseMs,
});
if (!claimed) break;
if (
claimed.claimOwner !== this.ownerId ||
claimed.claimToken !== claimToken ||
claimed.claimExpiresAtMs !==
claimed.claimAcquiredAtMs + this.claimLeaseMs
) {
throw new TypeError('Cluster scheduler store returned a foreign claim');
}
stats.claimed += 1;
stats.firstClaimAcquiredAtMs ??= claimed.claimAcquiredAtMs;
stats.lastClaimAcquiredAtMs = claimed.claimAcquiredAtMs;
const decision = resolveClusterScheduleDecision(
claimed,
this.misfireGraceMs,
this.nextOccurrence,
);
const admitted = decision.disposition === 'admit';
const result = await this.schedules.commitClusterScheduleDecision({
claim: claimed,
decision,
...(admitted
? {
runId: this.createId(),
attemptId: this.createId(),
createdEventId: this.createId(),
queuedEventId: this.createId(),
}
: {}),
});
if (result.status === 'raced') {
stats.raced += 1;
continue;
}
if (result.disposition === 'initialize') stats.initialized += 1;
if (result.disposition === 'skip') stats.skipped += 1;
if (result.status === 'admitted') {
stats.admitted += 1;
await this.onAdmitted?.(result.runId, result.attemptId);
}
}
stats.saturated = stats.claimed === this.maxClaimsPerCycle;
return Object.freeze(stats);
}
}
export interface ClusterSchedulerLifecycleOptions {
readonly intervalMs: number;
readonly stopTimeoutMs: number;
readonly onDiagnostic?: (
error: unknown,
summary?: ClusterSchedulerCycleSummary,
) => void | Promise<void>;
}
export interface ClusterSchedulerLifecycleStopSummary {
readonly status: 'stopped' | 'timed_out';
}
export class ClusterSchedulerLifecycle {
private timer: NodeJS.Timeout | undefined;
private inFlight: Promise<ClusterSchedulerCycleSummary> | undefined;
private stopPromise:
| Promise<ClusterSchedulerLifecycleStopSummary>
| undefined;
private running = false;
private stopping = false;
constructor(
private readonly scheduler: Pick<
ClusterSchedulerCoordinator,
'scheduleOnce'
>,
private readonly options: ClusterSchedulerLifecycleOptions,
) {
if (
!scheduler ||
typeof scheduler.scheduleOnce !== 'function' ||
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
!Number.isSafeInteger(options.intervalMs) ||
options.intervalMs < 250 ||
options.intervalMs > 60 * 60_000 ||
!Number.isSafeInteger(options.stopTimeoutMs) ||
options.stopTimeoutMs < 100 ||
options.stopTimeoutMs > 30_000 ||
(options.onDiagnostic !== undefined &&
typeof options.onDiagnostic !== 'function')
) {
throw new TypeError('Cluster scheduler lifecycle options are invalid');
}
}
start(): 'started' {
if (!this.running && !this.stopping) {
this.running = true;
this.schedule();
}
return 'started';
}
runOnce(): Promise<ClusterSchedulerCycleSummary> {
if (this.stopping) {
return Promise.reject(
new Error('Cluster scheduler lifecycle is stopping'),
);
}
if (this.inFlight) return this.inFlight;
const work = this.scheduler.scheduleOnce().finally(() => {
if (this.inFlight === work) this.inFlight = undefined;
});
this.inFlight = work;
return work;
}
stopAndDrain(): Promise<ClusterSchedulerLifecycleStopSummary> {
if (this.stopPromise) return this.stopPromise;
this.stopping = true;
this.running = false;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
this.stopPromise = (async () => {
const work = this.inFlight;
if (!work) return Object.freeze({ status: 'stopped' as const });
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
work.then(
() => Object.freeze({ status: 'stopped' as const }),
() => Object.freeze({ status: 'stopped' as const }),
),
new Promise<ClusterSchedulerLifecycleStopSummary>((resolve) => {
timeout = setTimeout(
() => resolve(Object.freeze({ status: 'timed_out' as const })),
this.options.stopTimeoutMs,
);
timeout.unref?.();
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
})();
return this.stopPromise;
}
private schedule(): void {
if (!this.running || this.timer) return;
this.timer = setTimeout(() => {
this.timer = undefined;
if (!this.running) return;
void this.runOnce()
.then((summary) => this.diagnostic(undefined, summary))
.catch((error) => this.diagnostic(error))
.finally(() => this.schedule());
}, this.options.intervalMs);
this.timer.unref?.();
}
private async diagnostic(
error: unknown,
summary?: ClusterSchedulerCycleSummary,
): Promise<void> {
if (this.stopping) return;
try {
await this.options.onDiagnostic?.(error, summary);
} catch {
// Diagnostics cannot own or stop scheduling.
}
}
}
@@ -0,0 +1,252 @@
// Scheduling owns Workflow frontier and Task Attempt admission on the shared cadence.
import type {
PluginPackageWorkflowFrontierCursor,
PluginPackageWorkflowFrontierRepository,
} from '@qinglong/runtime-core/plugin-package-workflow-frontier';
import type {
PluginPackageWorkflowTaskAttemptAdmissionCursor,
PluginPackageWorkflowTaskAttemptAdmissionRepository,
} from '@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission';
import type {
ClusterSchedulerCoordinator,
ClusterSchedulerCycleSummary,
} from './scheduler';
export interface ClusterWorkflowSchedulerOptions {
readonly frontierPageSize: number;
readonly frontierMaxPages: number;
readonly taskAttemptPageSize: number;
readonly taskAttemptMaxPages: number;
}
export interface ClusterWorkflowSchedulerCycleSummary {
readonly frontierPages: number;
readonly frontierScanned: number;
readonly frontierAdvanced: number;
readonly frontierTruncated: boolean;
readonly taskAttemptPages: number;
readonly taskAttemptsScanned: number;
readonly taskAttemptsCreated: number;
readonly taskAttemptsExisting: number;
readonly taskAttemptsTruncated: boolean;
}
function bounded(
label: string,
value: number,
maximum: number,
): number {
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
throw new RangeError(`${label} must be between 1 and ${maximum}`);
}
return value;
}
function nextFrontierCursor(
current: PluginPackageWorkflowFrontierCursor | undefined,
next: PluginPackageWorkflowFrontierCursor | undefined,
): PluginPackageWorkflowFrontierCursor {
if (
!next ||
(current !== undefined &&
(next.admittedAtMs < current.admittedAtMs ||
(next.admittedAtMs === current.admittedAtMs &&
next.planDigest <= current.planDigest)))
) {
throw new TypeError(
'Cluster Workflow frontier continuation did not advance',
);
}
return next;
}
function nextTaskAttemptCursor(
current: PluginPackageWorkflowTaskAttemptAdmissionCursor | undefined,
next: PluginPackageWorkflowTaskAttemptAdmissionCursor | undefined,
): PluginPackageWorkflowTaskAttemptAdmissionCursor {
if (
!next ||
(current !== undefined &&
(next.readyAtMs < current.readyAtMs ||
(next.readyAtMs === current.readyAtMs &&
next.stepRunId <= current.stepRunId)))
) {
throw new TypeError(
'Cluster Workflow Task Attempt continuation did not advance',
);
}
return next;
}
/**
* Extends the existing Cluster Scheduler cadence with Workflow frontier and
* Task Attempt admission. It owns no timer, connection, watcher, or
* per-Workflow state.
*/
export class ClusterWorkflowSchedulerCoordinator {
private readonly frontierPageSize: number;
private readonly frontierMaxPages: number;
private readonly taskAttemptPageSize: number;
private readonly taskAttemptMaxPages: number;
private inFlight: Promise<ClusterSchedulerCycleSummary> | undefined;
private latestWorkflow:
| Readonly<ClusterWorkflowSchedulerCycleSummary>
| undefined;
constructor(
private readonly scheduler: Pick<
ClusterSchedulerCoordinator,
'scheduleOnce'
>,
private readonly frontier: PluginPackageWorkflowFrontierRepository,
private readonly taskAttempts: PluginPackageWorkflowTaskAttemptAdmissionRepository,
options: ClusterWorkflowSchedulerOptions,
) {
if (
typeof scheduler?.scheduleOnce !== 'function' ||
typeof frontier?.listCandidates !== 'function' ||
typeof frontier?.advance !== 'function' ||
typeof taskAttempts?.listCandidates !== 'function' ||
typeof taskAttempts?.admit !== 'function' ||
!options ||
typeof options !== 'object' ||
Array.isArray(options)
) {
throw new TypeError('Cluster Workflow scheduler is invalid');
}
this.frontierPageSize = bounded(
'Cluster Workflow frontier page size',
options.frontierPageSize,
64,
);
this.frontierMaxPages = bounded(
'Cluster Workflow frontier page limit',
options.frontierMaxPages,
16,
);
this.taskAttemptPageSize = bounded(
'Cluster Workflow Task Attempt page size',
options.taskAttemptPageSize,
64,
);
this.taskAttemptMaxPages = bounded(
'Cluster Workflow Task Attempt page limit',
options.taskAttemptMaxPages,
16,
);
}
scheduleOnce(): Promise<ClusterSchedulerCycleSummary> {
if (this.inFlight) return this.inFlight;
const work = this.runCycle().finally(() => {
if (this.inFlight === work) this.inFlight = undefined;
});
this.inFlight = work;
return work;
}
latestWorkflowSummary():
| Readonly<ClusterWorkflowSchedulerCycleSummary>
| undefined {
return this.latestWorkflow;
}
private async runCycle(): Promise<ClusterSchedulerCycleSummary> {
const scheduler = await this.scheduler.scheduleOnce();
const frontier = await this.advanceFrontier();
const taskAttempts = await this.admitTaskAttempts();
this.latestWorkflow = Object.freeze({
...frontier,
...taskAttempts,
});
return scheduler;
}
private async advanceFrontier(): Promise<Readonly<{
frontierPages: number;
frontierScanned: number;
frontierAdvanced: number;
frontierTruncated: boolean;
}>> {
let frontierPages = 0;
let frontierScanned = 0;
let frontierAdvanced = 0;
let frontierTruncated = false;
let after: PluginPackageWorkflowFrontierCursor | undefined;
for (let index = 0; index < this.frontierMaxPages; index += 1) {
const page = await this.frontier.listCandidates({
limit: this.frontierPageSize,
...(after === undefined ? {} : { after }),
});
if (page.candidates.length > this.frontierPageSize) {
throw new RangeError(
'Cluster Workflow frontier exceeded its page size',
);
}
frontierPages += 1;
frontierScanned += page.candidates.length;
for (const candidate of page.candidates) {
await this.frontier.advance(candidate.runId);
frontierAdvanced += 1;
}
frontierTruncated = page.truncated;
if (!page.truncated) break;
after = nextFrontierCursor(after, page.next);
}
return Object.freeze({
frontierPages,
frontierScanned,
frontierAdvanced,
frontierTruncated,
});
}
private async admitTaskAttempts(): Promise<Readonly<{
taskAttemptPages: number;
taskAttemptsScanned: number;
taskAttemptsCreated: number;
taskAttemptsExisting: number;
taskAttemptsTruncated: boolean;
}>> {
let taskAttemptPages = 0;
let taskAttemptsScanned = 0;
let taskAttemptsCreated = 0;
let taskAttemptsExisting = 0;
let taskAttemptsTruncated = false;
let after:
| PluginPackageWorkflowTaskAttemptAdmissionCursor
| undefined;
for (let index = 0; index < this.taskAttemptMaxPages; index += 1) {
const page = await this.taskAttempts.listCandidates({
limit: this.taskAttemptPageSize,
...(after === undefined ? {} : { after }),
});
if (page.candidates.length > this.taskAttemptPageSize) {
throw new RangeError(
'Cluster Workflow Task Attempt source exceeded its page size',
);
}
taskAttemptPages += 1;
taskAttemptsScanned += page.candidates.length;
for (const candidate of page.candidates) {
const admitted = await this.taskAttempts.admit(
candidate.runId,
candidate.stepRunId,
);
if (admitted.status === 'created') taskAttemptsCreated += 1;
else taskAttemptsExisting += 1;
}
taskAttemptsTruncated = page.truncated;
if (!page.truncated) break;
after = nextTaskAttemptCursor(after, page.next);
}
return Object.freeze({
taskAttemptPages,
taskAttemptsScanned,
taskAttemptsCreated,
taskAttemptsExisting,
taskAttemptsTruncated,
});
}
}