@mastra/core
Version:
Mastra is a framework for building AI-powered applications and agents with a modern TypeScript stack.
270 lines (268 loc) • 8.47 kB
JavaScript
import { computeNextFireAt } from './chunk-NNKKOMEN.js';
import { MastraBase } from './chunk-WENZPAHS.js';
import { RegisteredLogger } from './chunk-DBBWTK24.js';
// src/workflows/scheduler/scheduler.ts
var TOPIC_WORKFLOWS = "workflows";
var DEFAULT_TICK_INTERVAL_MS = 1e4;
var DEFAULT_BATCH_SIZE = 100;
var DEFAULT_MISSES_BEFORE_DELETE = 3;
var WorkflowScheduler = class extends 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: RegisteredLogger.WORKFLOW, name: "WorkflowScheduler" });
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(() => {
void this.#runTick().catch((err) => {
this.logger.error("WorkflowScheduler tick crashed", { error: err });
});
}, this.#config.tickIntervalMs);
} 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();
}
// -------- Internals --------
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 workflow 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 workflow resolves.
*
* When the workflow 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 finish registering on a fresh process.
* Returns `false` to tell `#fireSchedule` to skip publishing for this tick.
*/
async #ensureWorkflowExists(schedule) {
const predicate = this.#config.isWorkflowRegistered;
if (!predicate) return true;
if (schedule.target.type !== "workflow") return true;
const workflowId = schedule.target.workflowId;
if (predicate(workflowId)) {
this.#missingWorkflowCounts.delete(schedule.id);
return true;
}
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 workflow is not registered; skipping until it appears", {
scheduleId: schedule.id,
workflowId,
missesBeforeDelete: limit
});
}
return false;
}
this.logger.error("Deleting schedule whose target workflow has not been registered", {
scheduleId: schedule.id,
workflowId,
consecutiveMisses: next
});
try {
await this.#schedulesStore.deleteSchedule(schedule.id);
} catch (err) {
this.logger.error("Failed to delete ghost schedule", {
scheduleId: schedule.id,
workflowId,
error: err
});
return false;
}
this.#missingWorkflowCounts.delete(schedule.id);
return false;
}
async #fireSchedule(schedule) {
if (!await this.#ensureWorkflowExists(schedule)) return;
const actualFireAt = Date.now();
let newNextFireAt;
try {
newNextFireAt = 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.#publishWorkflowStart(schedule, runId);
} catch (err) {
triggerStatus = "failed";
triggerError = err instanceof Error ? err.message : String(err);
this.logger.error("Failed to publish workflow.start for schedule", {
scheduleId: schedule.id,
runId,
error: err
});
this.#notifyError(err, schedule.id);
}
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("WorkflowScheduler onError handler threw", {
scheduleId,
error: callbackError
});
}
}
async #publishWorkflowStart(schedule, runId) {
if (schedule.target.type !== "workflow") {
throw new Error(`Unsupported schedule target type: ${schedule.target.type}`);
}
const { workflowId, inputData, initialState, requestContext } = schedule.target;
await this.#pubsub.publish(TOPIC_WORKFLOWS, {
type: "workflow.start",
runId,
data: {
workflowId,
runId,
prevResult: { status: "success", output: inputData ?? {} },
requestContext: requestContext ?? {},
initialState: initialState ?? {}
}
});
}
};
export { WorkflowScheduler };
//# sourceMappingURL=chunk-K3JN5SWJ.js.map
//# sourceMappingURL=chunk-K3JN5SWJ.js.map