UNPKG

@mastra/core

Version:
309 lines (308 loc) • 9.53 kB
const require_logger = require("./logger-BPclhj7J.cjs"); const require_base = require("./base-B6soWsYg.cjs"); const require_cron = require("./cron-CrmzJKFJ.cjs"); //#region src/workflows/scheduler/scheduler.ts const TOPIC_WORKFLOWS = "workflows"; const TOPIC_AGENT_SCHEDULES = "agent-schedules"; const DEFAULT_TICK_INTERVAL_MS = 1e4; const DEFAULT_BATCH_SIZE = 100; const DEFAULT_MISSES_BEFORE_DELETE = 3; /** * Drives cron-based workflow triggers. * * On each tick the scheduler: * 1. Loads schedules whose `nextFireAt <= now` from storage. * 2. Computes the next fire time from the cron expression. * 3. Atomically advances `nextFireAt` via compare-and-swap. Only one * instance across many polling the same storage can claim a fire. * 4. Publishes a `workflow.start` event on the `workflows` pubsub topic. * 5. Records the trigger in the schedule's history. * * The scheduler does **not** execute workflows. The existing * `WorkflowEventProcessor` consumes `workflow.start` events and runs them. */ var Scheduler = class extends require_base.MastraBase { #schedulesStore; #pubsub; #config; #intervalHandle; #inflightTick; #started = false; #stopping = false; /** * Per-schedule count of consecutive ticks where the target workflow was * not registered with the host Mastra instance. Reset when the workflow * resolves or the schedule is deleted. Used to ride out deploy/startup * ordering races before reclaiming a ghost row. */ #missingWorkflowCounts = /* @__PURE__ */ new Map(); constructor({ schedulesStore, pubsub, config }) { super({ component: require_logger.RegisteredLogger.WORKFLOW, name: "Scheduler" }); this.#schedulesStore = schedulesStore; this.#pubsub = pubsub; this.#config = { ...config, tickIntervalMs: config?.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS, batchSize: config?.batchSize ?? DEFAULT_BATCH_SIZE }; } /** Start the periodic tick loop. Runs an immediate tick first. */ async start() { if (this.#started) return; this.#started = true; this.#stopping = false; this.#missingWorkflowCounts.clear(); try { await this.#runTick(); if (this.#stopping || !this.#started) return; this.#intervalHandle = setInterval(() => { this.#runTick().catch((err) => { this.logger.error("Scheduler tick crashed", { error: err }); }); }, this.#config.tickIntervalMs); this.#intervalHandle.unref?.(); } catch (err) { this.#started = false; this.#stopping = false; throw err; } } /** Stop the tick loop and wait for any in-flight tick to finish. */ async stop() { if (!this.#started) return; this.#stopping = true; if (this.#intervalHandle) { clearInterval(this.#intervalHandle); this.#intervalHandle = void 0; } if (this.#inflightTick) try { await this.#inflightTick; } catch {} this.#started = false; this.#stopping = false; } /** True when the scheduler is currently running its tick loop. */ get isRunning() { return this.#started; } /** * Run a single tick. Public for tests; production callers should rely * on the interval started by `start()`. */ async tick() { await this.#runTick(); } async #runTick() { if (this.#stopping || this.#inflightTick) return; const promise = this.#processTick().finally(() => { this.#inflightTick = void 0; }); this.#inflightTick = promise; await promise; } async #processTick() { let due; try { due = await this.#schedulesStore.listDueSchedules(Date.now(), this.#config.batchSize); } catch (err) { this.logger.error("Failed to list due schedules", { error: err }); return; } for (const schedule of due) { if (this.#stopping) break; await this.#fireSchedule(schedule); } } /** * Check whether a schedule's target is registered with the host * Mastra instance. Returns `true` if no predicate is configured (we can't * verify, so assume the consumer will reject) or if the target resolves. * * When the target is missing, we increment an in-memory counter and * delete the schedule after `missesBeforeDelete` consecutive misses. The * grace window protects against deploy/startup ordering races where the * scheduler ticks before workflows/agents finish registering on a fresh * process. Returns `false` to tell `#fireSchedule` to skip publishing for * this tick. */ async #ensureTargetReady(schedule) { const predicate = this.#config.isTargetReady; if (!predicate) return true; if (predicate(schedule.target)) { this.#missingWorkflowCounts.delete(schedule.id); return true; } const targetSummary = schedule.target.type === "workflow" ? { workflowId: schedule.target.workflowId } : { agentId: schedule.target.agentId }; const limit = this.#config.missesBeforeDelete ?? DEFAULT_MISSES_BEFORE_DELETE; const prev = this.#missingWorkflowCounts.get(schedule.id) ?? 0; const next = prev + 1; if (next < limit) { this.#missingWorkflowCounts.set(schedule.id, next); if (prev === 0) this.logger.warn("Schedule target is not registered; skipping until it appears", { scheduleId: schedule.id, targetType: schedule.target.type, ...targetSummary, missesBeforeDelete: limit }); return false; } this.logger.error("Deleting schedule whose target has not been registered", { scheduleId: schedule.id, targetType: schedule.target.type, ...targetSummary, consecutiveMisses: next }); try { await this.#schedulesStore.deleteSchedule(schedule.id); } catch (err) { this.logger.error("Failed to delete ghost schedule", { scheduleId: schedule.id, targetType: schedule.target.type, ...targetSummary, error: err }); return false; } this.#missingWorkflowCounts.delete(schedule.id); return false; } async #fireSchedule(schedule) { if (!await this.#ensureTargetReady(schedule)) return; const actualFireAt = Date.now(); let newNextFireAt; try { newNextFireAt = require_cron.computeNextFireAt(schedule.cron, { timezone: schedule.timezone, after: actualFireAt }); } catch (err) { this.logger.error("Failed to compute next fire time for schedule", { scheduleId: schedule.id, cron: schedule.cron, error: err }); this.#notifyError(err, schedule.id); return; } const runId = `sched_${schedule.id}_${schedule.nextFireAt}`; let claimed = false; try { claimed = await this.#schedulesStore.updateScheduleNextFire(schedule.id, schedule.nextFireAt, newNextFireAt, actualFireAt, runId); } catch (err) { this.logger.error("Failed to claim due schedule fire", { scheduleId: schedule.id, runId, error: err }); this.#notifyError(err, schedule.id); return; } if (!claimed) return; let triggerStatus = "published"; let triggerError; try { await this.#publishTargetStart(schedule, runId); } catch (err) { triggerStatus = "failed"; triggerError = err instanceof Error ? err.message : String(err); this.logger.error("Failed to publish target.start for schedule", { scheduleId: schedule.id, runId, targetType: schedule.target.type, error: err }); this.#notifyError(err, schedule.id); } if (schedule.target.type === "workflow" || triggerStatus === "failed") try { await this.#schedulesStore.recordTrigger({ scheduleId: schedule.id, runId, scheduledFireAt: schedule.nextFireAt, actualFireAt, outcome: triggerStatus, error: triggerError, triggerKind: "schedule-fire" }); } catch (err) { this.logger.error("Failed to record schedule trigger", { scheduleId: schedule.id, runId, error: err }); } } /** * Invoke the user-supplied onError hook in isolation. A throwing hook * must not abort the scheduler tick loop, so we swallow + log any error * the callback itself raises. */ #notifyError(error, scheduleId) { if (!this.#config.onError) return; try { this.#config.onError(error, { scheduleId }); } catch (callbackError) { this.logger.error("Scheduler onError handler threw", { scheduleId, error: callbackError }); } } async #publishTargetStart(schedule, claimId) { switch (schedule.target.type) { case "workflow": { const { workflowId, inputData, initialState, requestContext } = schedule.target; await this.#pubsub.publish(TOPIC_WORKFLOWS, { type: "workflow.start", runId: claimId, data: { workflowId, runId: claimId, prevResult: { status: "success", output: inputData ?? {} }, requestContext: requestContext ?? {}, initialState: initialState ?? {} } }); return; } case "agent": await this.#pubsub.publish(TOPIC_AGENT_SCHEDULES, { type: "agent-schedule.fire", runId: claimId, data: { scheduleId: schedule.id, claimId, scheduledFireAt: schedule.nextFireAt, target: schedule.target } }); return; default: throw new Error(`Unsupported schedule target type: ${schedule.target.type}`); } } }; /** * @deprecated Renamed to {@link Scheduler}. The scheduler now drives both * workflow and agent schedules, so the `Workflow`-prefixed name is no longer * accurate. This alias will be removed in a future major release. */ const WorkflowScheduler = Scheduler; //#endregion Object.defineProperty(exports, "Scheduler", { enumerable: true, get: function() { return Scheduler; } }); Object.defineProperty(exports, "WorkflowScheduler", { enumerable: true, get: function() { return WorkflowScheduler; } }); //# sourceMappingURL=scheduler-D9Mqp8B5.cjs.map