mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user