UNPKG

trigger.dev

Version:

A Command-Line Interface for Trigger.dev projects

306 lines • 12.2 kB
import { tryCatch } from "@trigger.dev/core/utils"; import { assertExhaustive } from "@trigger.dev/core/utils"; export class SnapshotManager { runFriendlyId; runnerId; logger; metadataClient; state; isSuspendable = false; onSnapshotChange; onSuspendable; changeQueue = []; isProcessingQueue = false; // Track seen deprecated snapshots to prevent false positives seenDeprecatedSnapshotIds = []; maxSeenDeprecatedSnapshotIds = 50; constructor(opts) { this.runFriendlyId = opts.runFriendlyId; this.runnerId = opts.runnerId; this.logger = opts.logger; this.metadataClient = opts.metadataClient; this.state = { id: opts.initialSnapshotId, status: opts.initialStatus, }; this.onSnapshotChange = opts.onSnapshotChange; this.onSuspendable = opts.onSuspendable; } get snapshotId() { return this.state.id; } get status() { return this.state.status; } get suspendable() { return this.isSuspendable; } async setSuspendable(suspendable) { if (this.isSuspendable === suspendable) { this.sendDebugLog(`skipping suspendable update, already ${suspendable}`); return; } this.sendDebugLog(`setting suspendable to ${suspendable}`); return this.enqueueSnapshotChange({ id: crypto.randomUUID(), type: "suspendable", value: suspendable, }); } /** * Update the snapshot ID and status without invoking any handlers * * @param snapshotId - The ID of the snapshot to update to * @param status - The status to update to */ updateSnapshot(snapshotId, status) { // Check if this is an old snapshot if (snapshotId < this.state.id) { this.sendDebugLog("skipping update for old snapshot", { incomingId: snapshotId, currentId: this.state.id, }); return; } this.state = { id: snapshotId, status }; } async handleSnapshotChanges(snapshots) { if (!this.statusCheck(snapshots)) { return; } return this.enqueueSnapshotChange({ id: crypto.randomUUID(), type: "snapshot", snapshots, }); } get queueLength() { return this.changeQueue.length; } statusCheck(snapshots) { const latestSnapshot = snapshots[snapshots.length - 1]; if (!latestSnapshot) { this.sendDebugLog("skipping status check for empty snapshots", { snapshots, }); return false; } const { run, snapshot } = latestSnapshot; const statusCheckData = { incomingId: snapshot.friendlyId, incomingStatus: snapshot.executionStatus, currentId: this.state.id, currentStatus: this.state.status, }; // Ensure run ID matches if (run.friendlyId !== this.runFriendlyId) { this.sendDebugLog("skipping update for mismatched run ID", { statusCheckData, }); return false; } // Skip if this is an old snapshot if (snapshot.friendlyId < this.state.id) { this.sendDebugLog("skipping update for old snapshot", { statusCheckData, }); return false; } // Skip if this is the current snapshot if (snapshot.friendlyId === this.state.id) { // DO NOT REMOVE (very noisy, but helpful for debugging) // this.sendDebugLog("skipping update for duplicate snapshot", { // statusCheckData, // }); return false; } return true; } async enqueueSnapshotChange(change) { return new Promise((resolve, reject) => { // For suspendable changes, resolve and remove any pending suspendable changes since only the last one matters if (change.type === "suspendable") { const pendingSuspendable = this.changeQueue.filter((item) => item.change.type === "suspendable"); // Resolve any pending suspendable changes - they're effectively done since we're superseding them for (const item of pendingSuspendable) { item.resolve(); } // Remove the exact items we just resolved const resolvedIds = new Set(pendingSuspendable.map((item) => item.change.id)); this.changeQueue = this.changeQueue.filter((item) => !resolvedIds.has(item.change.id)); } this.changeQueue.push({ change, resolve, reject }); // Sort queue: // 1. Suspendable changes always go to the back // 2. Snapshot changes are ordered by creation time, with the latest snapshot last this.changeQueue.sort((a, b) => { if (a.change.type === "suspendable" && b.change.type === "snapshot") { return 1; // a goes after b } if (a.change.type === "snapshot" && b.change.type === "suspendable") { return -1; // a goes before b } if (a.change.type === "snapshot" && b.change.type === "snapshot") { const snapshotA = a.change.snapshots[a.change.snapshots.length - 1]; const snapshotB = b.change.snapshots[b.change.snapshots.length - 1]; if (!snapshotA || !snapshotB) { return 0; } // Sort snapshot changes by creation time, old -> new return snapshotA.snapshot.createdAt.getTime() - snapshotB.snapshot.createdAt.getTime(); } return 0; // both suspendable, maintain insertion order }); // Start processing if not already running this.processQueue().catch((error) => { this.sendDebugLog("error processing queue", { error: error.message }); }); }); } async processQueue() { if (this.isProcessingQueue) { return; } this.isProcessingQueue = true; try { while (this.queueLength > 0) { // Remove first item from queue const item = this.changeQueue.shift(); if (!item) { break; } const [error] = await tryCatch(this.applyChange(item.change)); // Resolve/reject promise if (error) { item.reject(error); } else { item.resolve(); } } } finally { const hasMoreItems = this.queueLength > 0; this.isProcessingQueue = false; if (hasMoreItems) { this.processQueue().catch((error) => { this.sendDebugLog("error processing queue (finally)", { error: error.message }); }); } } } async applyChange(change) { switch (change.type) { case "snapshot": { const { snapshots } = change; // Double check we should process this snapshot if (!this.statusCheck(snapshots)) { return; } const latestSnapshot = change.snapshots[change.snapshots.length - 1]; if (!latestSnapshot) { return; } // These are the snapshots between the current and the latest one const previousSnapshots = snapshots.slice(0, -1); // Check if any previous snapshot is QUEUED or SUSPENDED const deprecatedStatus = ["QUEUED", "SUSPENDED"]; const deprecatedSnapshots = previousSnapshots.filter((snap) => { const isDeprecated = deprecatedStatus.includes(snap.snapshot.executionStatus); const previouslySeen = this.seenDeprecatedSnapshotIds.some((s) => s === snap.snapshot.friendlyId); return isDeprecated && !previouslySeen; }); let deprecated = false; if (deprecatedSnapshots.length > 0) { const hasBeenRestored = await this.hasBeenRestored(); if (hasBeenRestored) { // It's normal for a restored run to have deprecation markers, e.g. it will have been SUSPENDED deprecated = false; } else { deprecated = true; } // Add the deprecated snapshot IDs to the seen list this.seenDeprecatedSnapshotIds.push(...deprecatedSnapshots.map((s) => s.snapshot.friendlyId)); if (this.seenDeprecatedSnapshotIds.length > this.maxSeenDeprecatedSnapshotIds) { // Only keep the latest maxSeenDeprecatedSnapshotIds this.seenDeprecatedSnapshotIds = this.seenDeprecatedSnapshotIds.slice(-this.maxSeenDeprecatedSnapshotIds); } } const { snapshot } = latestSnapshot; const oldState = { ...this.state }; this.updateSnapshot(snapshot.friendlyId, snapshot.executionStatus); this.sendDebugLog(`status changed to ${snapshot.executionStatus}`, { oldId: oldState.id, newId: snapshot.friendlyId, oldStatus: oldState.status, newStatus: snapshot.executionStatus, deprecated, }); // Execute handler await this.onSnapshotChange(latestSnapshot, deprecated); // Check suspendable state after snapshot change await this.checkSuspendableState(); break; } case "suspendable": { this.isSuspendable = change.value; // Check suspendable state after suspendable change await this.checkSuspendableState(); break; } default: { assertExhaustive(change); } } } async hasBeenRestored() { if (!this.metadataClient) { return false; } const [error, overrides] = await this.metadataClient.getEnvOverrides(); if (error) { return false; } if (!overrides.TRIGGER_RUNNER_ID) { return false; } if (overrides.TRIGGER_RUNNER_ID === this.runnerId) { return false; } this.runnerId = overrides.TRIGGER_RUNNER_ID; return true; } async checkSuspendableState() { if (this.isSuspendable && (this.state.status === "EXECUTING_WITH_WAITPOINTS" || this.state.status === "QUEUED_EXECUTING")) { // DO NOT REMOVE (very noisy, but helpful for debugging) // this.sendDebugLog("run is now suspendable, executing handler"); await this.onSuspendable(this.state); } } stop() { this.sendDebugLog("stop"); // Clear any pending changes for (const item of this.changeQueue) { item.reject(new Error("SnapshotManager stopped")); } this.changeQueue = []; } sendDebugLog(message, properties) { this.logger.sendDebugLog({ runId: this.runFriendlyId, message: `[snapshot] ${message}`, properties: { ...properties, snapshotId: this.state.id, status: this.state.status, suspendable: this.isSuspendable, queueLength: this.queueLength, isProcessingQueue: this.isProcessingQueue, }, }); } } //# sourceMappingURL=snapshot.js.map