629 lines
20 KiB
TypeScript
629 lines
20 KiB
TypeScript
import type {
|
|
IOPacket,
|
|
RunMetadataChangeOperation,
|
|
UpdateMetadataRequestBody,
|
|
} from "@trigger.dev/core/v3";
|
|
import { applyMetadataOperations, parsePacket } from "@trigger.dev/core/v3";
|
|
import type { PrismaClientOrTransaction } from "~/db.server";
|
|
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
|
import { handleMetadataPacket, MetadataTooLargeError } from "~/utils/packets";
|
|
import { ServiceValidationError } from "~/v3/services/common.server";
|
|
import { Effect, Schedule, Duration, Fiber } from "effect";
|
|
import { type RuntimeFiber } from "effect/Fiber";
|
|
import { setTimeout } from "timers/promises";
|
|
import type { LogLevel } from "@trigger.dev/core/logger";
|
|
import { Logger } from "@trigger.dev/core/logger";
|
|
import type { RunStore } from "@internal/run-store";
|
|
|
|
const RUN_UPDATABLE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
type BufferedRunMetadataChangeOperation = {
|
|
runId: string;
|
|
timestamp: number;
|
|
operation: RunMetadataChangeOperation;
|
|
};
|
|
|
|
export type UpdateMetadataServiceOptions = {
|
|
prisma: PrismaClientOrTransaction;
|
|
runStore: RunStore;
|
|
flushIntervalMs?: number;
|
|
flushEnabled?: boolean;
|
|
flushLoggingEnabled?: boolean;
|
|
maximumSize?: number;
|
|
logger?: Logger;
|
|
logLevel?: LogLevel;
|
|
/** Called after the batched flusher writes a run's buffered operations, with everything
|
|
* a realtime change record needs — buffered (parent/root) updates otherwise never wake
|
|
* live feeds. */
|
|
onRunFlushed?: (run: {
|
|
runId: string;
|
|
environmentId: string;
|
|
tags: string[];
|
|
batchId: string | null;
|
|
updatedAtMs: number;
|
|
}) => void;
|
|
// Testing hooks
|
|
onBeforeUpdate?: (runId: string) => Promise<void>;
|
|
onAfterRead?: (runId: string, metadataVersion: number) => Promise<void>;
|
|
};
|
|
|
|
export class UpdateMetadataService {
|
|
private _bufferedOperations: Map<string, BufferedRunMetadataChangeOperation[]> = new Map();
|
|
private _flushFiber: RuntimeFiber<void> | null = null;
|
|
private readonly _prisma: PrismaClientOrTransaction;
|
|
private readonly _runStore: RunStore;
|
|
private readonly flushIntervalMs: number;
|
|
private readonly flushEnabled: boolean;
|
|
private readonly flushLoggingEnabled: boolean;
|
|
private readonly maximumSize: number;
|
|
private readonly logger: Logger;
|
|
|
|
constructor(private readonly options: UpdateMetadataServiceOptions) {
|
|
this._prisma = options.prisma;
|
|
this._runStore = options.runStore;
|
|
this.flushIntervalMs = options.flushIntervalMs ?? 5000;
|
|
this.flushEnabled = options.flushEnabled ?? true;
|
|
this.flushLoggingEnabled = options.flushLoggingEnabled ?? true;
|
|
this.maximumSize = options.maximumSize ?? 1024 * 1024 * 1; // 1MB
|
|
this.logger = options.logger ?? new Logger("UpdateMetadataService", options.logLevel ?? "info");
|
|
|
|
this._startFlushing();
|
|
}
|
|
|
|
// Start a loop that periodically flushes buffered operations
|
|
private _startFlushing() {
|
|
if (!this.flushEnabled) {
|
|
this.logger.info("[UpdateMetadataService] 🚽 Flushing disabled");
|
|
|
|
return;
|
|
}
|
|
|
|
this.logger.info("[UpdateMetadataService] 🚽 Flushing started");
|
|
|
|
// Create a program that sleeps, then processes buffered ops
|
|
const program = Effect.gen(this, function* (_) {
|
|
while (true) {
|
|
// Wait for flushIntervalMs before flushing again
|
|
yield* _(Effect.sleep(Duration.millis(this.flushIntervalMs)));
|
|
|
|
// Atomically get and clear current operations
|
|
const currentOperations = new Map(this._bufferedOperations);
|
|
this._bufferedOperations.clear();
|
|
|
|
yield* Effect.sync(() => {
|
|
if (this.flushLoggingEnabled) {
|
|
this.logger.debug(`[UpdateMetadataService] Flushing operations`, {
|
|
operations: Object.fromEntries(currentOperations),
|
|
});
|
|
}
|
|
});
|
|
|
|
// If we have operations, process them
|
|
if (currentOperations.size > 0) {
|
|
yield* _(this._processBufferedOperations(currentOperations));
|
|
}
|
|
}
|
|
}).pipe(
|
|
// Handle any unexpected errors, ensuring program does not fail
|
|
Effect.catchAll((error) =>
|
|
Effect.sync(() => {
|
|
this.logger.error("Error in flushing program:", { error });
|
|
})
|
|
)
|
|
);
|
|
|
|
// Fork the program so it runs in the background
|
|
this._flushFiber = Effect.runFork(program as Effect.Effect<void, never, never>);
|
|
}
|
|
|
|
stopFlushing() {
|
|
if (this._flushFiber) {
|
|
Effect.runFork(Fiber.interrupt(this._flushFiber));
|
|
}
|
|
}
|
|
|
|
private _processBufferedOperations = (
|
|
operations: Map<string, BufferedRunMetadataChangeOperation[]>
|
|
) => {
|
|
return Effect.gen(this, function* (_) {
|
|
for (const [runId, ops] of operations) {
|
|
// Process and cull operations
|
|
const processedOps = this._cullOperations(ops);
|
|
|
|
// If there are no operations to process, skip
|
|
if (processedOps.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
yield* Effect.sync(() => {
|
|
if (this.flushLoggingEnabled) {
|
|
this.logger.debug(`[UpdateMetadataService] Processing operations for run`, {
|
|
runId,
|
|
operationsCount: processedOps.length,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Update run with retry
|
|
yield* _(
|
|
this._updateRunWithOperations(runId, processedOps).pipe(
|
|
// Catch MetadataTooLargeError before retry logic
|
|
Effect.catchIf(
|
|
(error) => error instanceof MetadataTooLargeError,
|
|
(error) =>
|
|
Effect.sync(() => {
|
|
// Log the error but don't return operations to buffer
|
|
console.error(
|
|
`[UpdateMetadataService] Dropping operations for run ${runId} due to metadata size limit:`,
|
|
error.message
|
|
);
|
|
if (this.flushLoggingEnabled) {
|
|
this.logger.warn(`[UpdateMetadataService] Metadata too large for run`, {
|
|
runId,
|
|
operationsCount: processedOps.length,
|
|
error: error.message,
|
|
});
|
|
}
|
|
})
|
|
),
|
|
Effect.retry(Schedule.exponential(Duration.millis(100), 1.4)),
|
|
Effect.catchAll((error) =>
|
|
Effect.sync(() => {
|
|
// On complete failure, return ops to buffer
|
|
const existingOps = this._bufferedOperations.get(runId) ?? [];
|
|
this._bufferedOperations.set(runId, [...existingOps, ...ops]);
|
|
console.error(`Failed to process run ${runId}:`, error);
|
|
})
|
|
)
|
|
)
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
private _updateRunWithOperations = (
|
|
runId: string,
|
|
operations: BufferedRunMetadataChangeOperation[]
|
|
) => {
|
|
return Effect.gen(this, function* (_) {
|
|
// Fetch current run (+ the realtime membership keys, so a flush can publish)
|
|
const run = yield* _(
|
|
Effect.tryPromise(() =>
|
|
this._runStore.findRun(
|
|
{ id: runId },
|
|
{
|
|
select: {
|
|
id: true,
|
|
metadata: true,
|
|
metadataType: true,
|
|
metadataVersion: true,
|
|
runtimeEnvironmentId: true,
|
|
runTags: true,
|
|
batchId: true,
|
|
},
|
|
},
|
|
this._prisma
|
|
)
|
|
)
|
|
);
|
|
|
|
if (!run) {
|
|
return yield* _(Effect.fail(new Error(`Run ${runId} not found`)));
|
|
}
|
|
|
|
// Testing hook after read
|
|
if (this.options.onAfterRead) {
|
|
yield* _(Effect.tryPromise(() => this.options.onAfterRead!(runId, run.metadataVersion)));
|
|
}
|
|
|
|
const metadata = yield* _(
|
|
Effect.tryPromise(() =>
|
|
run.metadata
|
|
? parsePacket({ data: run.metadata, dataType: run.metadataType })
|
|
: Promise.resolve({})
|
|
)
|
|
);
|
|
|
|
// Apply operations and update
|
|
const applyResult = applyMetadataOperations(
|
|
metadata,
|
|
operations.map((op) => op.operation)
|
|
);
|
|
|
|
if (applyResult.unappliedOperations.length === operations.length) {
|
|
this.logger.warn(`No operations applied for run ${runId}`);
|
|
// If no operations were applied, return
|
|
return;
|
|
}
|
|
|
|
// Stringify the metadata
|
|
const newMetadataPacket = yield* _(
|
|
Effect.try(() =>
|
|
handleMetadataPacket(applyResult.newMetadata, run.metadataType, this.maximumSize)
|
|
).pipe(
|
|
Effect.mapError((error) => {
|
|
// Preserve the original error if it's MetadataTooLargeError
|
|
if ("cause" in error && error.cause instanceof MetadataTooLargeError) {
|
|
return error.cause;
|
|
}
|
|
return error;
|
|
})
|
|
)
|
|
);
|
|
|
|
if (!newMetadataPacket) {
|
|
// Log and skip if metadata is invalid
|
|
this.logger.warn(`Invalid metadata after operations, skipping update`);
|
|
return;
|
|
}
|
|
|
|
// Testing hook before update
|
|
if (this.options.onBeforeUpdate) {
|
|
yield* _(Effect.tryPromise(() => this.options.onBeforeUpdate!(runId)));
|
|
}
|
|
|
|
// Stamp updatedAt explicitly so the realtime publish can carry the exact committed
|
|
// value without a follow-up read (updateMany can't RETURNING).
|
|
const writeTime = new Date();
|
|
const result = yield* _(
|
|
Effect.tryPromise(() =>
|
|
this._runStore.updateMetadata(
|
|
runId,
|
|
{
|
|
metadata: newMetadataPacket.data!,
|
|
metadataVersion: { increment: 1 },
|
|
updatedAt: writeTime,
|
|
},
|
|
{ expectedMetadataVersion: run.metadataVersion },
|
|
this._prisma
|
|
)
|
|
)
|
|
);
|
|
|
|
if (result.count === 0) {
|
|
yield* Effect.sync(() => {
|
|
this.logger.warn(`Optimistic lock failed for run ${runId}`, {
|
|
metadataVersion: run.metadataVersion,
|
|
});
|
|
});
|
|
|
|
return yield* _(Effect.fail(new Error("Optimistic lock failed")));
|
|
}
|
|
|
|
yield* Effect.sync(() => {
|
|
this.options.onRunFlushed?.({
|
|
runId,
|
|
environmentId: run.runtimeEnvironmentId,
|
|
tags: run.runTags,
|
|
batchId: run.batchId,
|
|
updatedAtMs: writeTime.getTime(),
|
|
});
|
|
});
|
|
|
|
return result;
|
|
});
|
|
};
|
|
|
|
private _cullOperations(
|
|
operations: BufferedRunMetadataChangeOperation[]
|
|
): BufferedRunMetadataChangeOperation[] {
|
|
// Sort by timestamp
|
|
const sortedOps = [...operations].sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
// Track latest set operations by key
|
|
const latestSetOps = new Map<string, BufferedRunMetadataChangeOperation>();
|
|
const resultOps: BufferedRunMetadataChangeOperation[] = [];
|
|
|
|
for (const op of sortedOps) {
|
|
if (op.operation.type === "set") {
|
|
latestSetOps.set(op.operation.key, op);
|
|
} else {
|
|
resultOps.push(op);
|
|
}
|
|
}
|
|
|
|
// Add winning set operations
|
|
resultOps.push(...latestSetOps.values());
|
|
|
|
return resultOps;
|
|
}
|
|
|
|
public async call(
|
|
runId: string,
|
|
body: UpdateMetadataRequestBody,
|
|
environment?: AuthenticatedEnvironment
|
|
) {
|
|
const runIdType = runId.startsWith("run_") ? "friendly" : "internal";
|
|
|
|
const taskRun = await this._runStore.findRun(
|
|
environment
|
|
? {
|
|
runtimeEnvironmentId: environment.id,
|
|
...(runIdType === "internal" ? { id: runId } : { friendlyId: runId }),
|
|
}
|
|
: {
|
|
...(runIdType === "internal" ? { id: runId } : { friendlyId: runId }),
|
|
},
|
|
{
|
|
select: {
|
|
id: true,
|
|
batchId: true,
|
|
runTags: true,
|
|
completedAt: true,
|
|
status: true,
|
|
metadata: true,
|
|
metadataType: true,
|
|
metadataVersion: true,
|
|
parentTaskRun: {
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
},
|
|
},
|
|
rootTaskRun: {
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
this._prisma
|
|
);
|
|
|
|
if (!taskRun) {
|
|
return;
|
|
}
|
|
|
|
if (!this.#isRunUpdatable(taskRun)) {
|
|
throw new ServiceValidationError("Cannot update metadata for a completed run");
|
|
}
|
|
|
|
if (body.parentOperations && body.parentOperations.length > 0) {
|
|
this.#ingestRunOperations(taskRun.parentTaskRun?.id ?? taskRun.id, body.parentOperations);
|
|
}
|
|
|
|
if (body.rootOperations && body.rootOperations.length > 0) {
|
|
this.#ingestRunOperations(taskRun.rootTaskRun?.id ?? taskRun.id, body.rootOperations);
|
|
}
|
|
|
|
const result = await this.#updateRunMetadata({
|
|
runId: taskRun.id,
|
|
body,
|
|
existingMetadata: {
|
|
data: taskRun.metadata ?? undefined,
|
|
dataType: taskRun.metadataType,
|
|
},
|
|
});
|
|
|
|
return {
|
|
metadata: result?.metadata,
|
|
// Internal id + membership keys, so callers can publish full realtime records the router routes by index.
|
|
runId: taskRun.id,
|
|
batchId: taskRun.batchId,
|
|
runTags: taskRun.runTags,
|
|
// The committed row's updatedAt — the realtime watermark. Undefined when nothing was
|
|
// written here (no-op, or buffered for the flusher, which publishes itself).
|
|
updatedAtMs: result?.updatedAtMs,
|
|
};
|
|
}
|
|
|
|
async #updateRunMetadata({
|
|
runId,
|
|
body,
|
|
existingMetadata,
|
|
}: {
|
|
runId: string;
|
|
body: UpdateMetadataRequestBody;
|
|
existingMetadata: IOPacket;
|
|
}): Promise<{ metadata: Record<string, unknown> | undefined; updatedAtMs?: number }> {
|
|
if (Array.isArray(body.operations)) {
|
|
return this.#updateRunMetadataWithOperations(runId, body.operations);
|
|
} else {
|
|
return this.#updateRunMetadataDirectly(runId, body, existingMetadata);
|
|
}
|
|
}
|
|
|
|
async #updateRunMetadataWithOperations(
|
|
runId: string,
|
|
operations: RunMetadataChangeOperation[]
|
|
): Promise<{ metadata: Record<string, unknown> | undefined; updatedAtMs?: number }> {
|
|
const MAX_RETRIES = 3;
|
|
let attempts = 0;
|
|
|
|
while (attempts <= MAX_RETRIES) {
|
|
// Fetch the latest run data
|
|
const run = await this._runStore.findRun(
|
|
{ id: runId },
|
|
{
|
|
select: { metadata: true, metadataType: true, metadataVersion: true },
|
|
},
|
|
this._prisma
|
|
);
|
|
|
|
if (!run) {
|
|
throw new Error(`Run ${runId} not found`);
|
|
}
|
|
|
|
// Testing hook after read
|
|
if (this.options.onAfterRead) {
|
|
await this.options.onAfterRead(runId, run.metadataVersion);
|
|
}
|
|
|
|
// Parse the current metadata
|
|
const currentMetadata = await (run.metadata
|
|
? parsePacket({ data: run.metadata, dataType: run.metadataType })
|
|
: Promise.resolve({}));
|
|
|
|
// Apply operations to the current metadata
|
|
const applyResults = applyMetadataOperations(currentMetadata, operations);
|
|
|
|
// If no operations were applied, return the current metadata (nothing written)
|
|
if (applyResults.unappliedOperations.length === operations.length) {
|
|
return { metadata: currentMetadata };
|
|
}
|
|
|
|
const newMetadataPacket = handleMetadataPacket(
|
|
applyResults.newMetadata,
|
|
run.metadataType,
|
|
this.maximumSize
|
|
);
|
|
|
|
if (!newMetadataPacket) {
|
|
throw new ServiceValidationError("Unable to update metadata");
|
|
}
|
|
|
|
// Testing hook before update
|
|
if (this.options.onBeforeUpdate) {
|
|
await this.options.onBeforeUpdate(runId);
|
|
}
|
|
|
|
// Update with optimistic locking; updatedAt stamped explicitly so the caller can
|
|
// publish the exact committed watermark without a follow-up read.
|
|
const writeTime = new Date();
|
|
const result = await this._runStore.updateMetadata(
|
|
runId,
|
|
{
|
|
metadata: newMetadataPacket.data!,
|
|
metadataType: newMetadataPacket.dataType,
|
|
metadataVersion: {
|
|
increment: 1,
|
|
},
|
|
updatedAt: writeTime,
|
|
},
|
|
{ expectedMetadataVersion: run.metadataVersion },
|
|
this._prisma
|
|
);
|
|
|
|
if (result.count === 0) {
|
|
if (this.flushLoggingEnabled) {
|
|
this.logger.debug(
|
|
`[updateRunMetadataWithOperations] Optimistic lock failed for run ${runId}`,
|
|
{
|
|
metadataVersion: run.metadataVersion,
|
|
}
|
|
);
|
|
}
|
|
|
|
// If this was our last attempt, buffer the operations and return optimistically
|
|
// (no watermark — the flusher writes later and publishes itself).
|
|
if (attempts === MAX_RETRIES) {
|
|
this.#ingestRunOperations(runId, operations);
|
|
return { metadata: applyResults.newMetadata };
|
|
}
|
|
|
|
// Otherwise sleep and try again
|
|
await setTimeout(100 * Math.pow(1.4, attempts));
|
|
attempts++;
|
|
continue;
|
|
}
|
|
|
|
if (this.flushLoggingEnabled) {
|
|
this.logger.debug(`[updateRunMetadataWithOperations] Updated metadata for run`, {
|
|
metadata: applyResults.newMetadata,
|
|
operations: operations,
|
|
runId,
|
|
});
|
|
}
|
|
|
|
// Success! Return the new metadata
|
|
return { metadata: applyResults.newMetadata, updatedAtMs: writeTime.getTime() };
|
|
}
|
|
|
|
return { metadata: undefined };
|
|
}
|
|
|
|
// Checks to see if a run is updatable
|
|
// if there is no completedAt, the run is updatable
|
|
// if the run is completed, but the completedAt is within the last 10 minutes, the run is updatable
|
|
#isRunUpdatable(run: { completedAt: Date | null }) {
|
|
if (!run.completedAt) {
|
|
return true;
|
|
}
|
|
|
|
return run.completedAt.getTime() > Date.now() - RUN_UPDATABLE_WINDOW_MS;
|
|
}
|
|
|
|
async #updateRunMetadataDirectly(
|
|
runId: string,
|
|
body: UpdateMetadataRequestBody,
|
|
existingMetadata: IOPacket
|
|
): Promise<{ metadata: Record<string, unknown> | undefined; updatedAtMs?: number }> {
|
|
const metadataPacket = handleMetadataPacket(
|
|
body.metadata,
|
|
"application/json",
|
|
this.maximumSize
|
|
);
|
|
|
|
if (!metadataPacket) {
|
|
return { metadata: {} };
|
|
}
|
|
|
|
let updatedAtMs: number | undefined;
|
|
|
|
if (
|
|
metadataPacket.data !== "{}" ||
|
|
(existingMetadata.data && metadataPacket.data !== existingMetadata.data)
|
|
) {
|
|
if (this.flushLoggingEnabled) {
|
|
this.logger.debug(`[updateRunMetadataDirectly] Updating metadata directly for run`, {
|
|
metadata: metadataPacket.data,
|
|
runId,
|
|
});
|
|
}
|
|
|
|
// Update the metadata without version check; updatedAt stamped explicitly so the
|
|
// caller can publish the exact committed watermark.
|
|
const writeTime = new Date();
|
|
await this._runStore.updateMetadata(
|
|
runId,
|
|
{
|
|
metadata: metadataPacket?.data!,
|
|
metadataType: metadataPacket?.dataType,
|
|
metadataVersion: {
|
|
increment: 1,
|
|
},
|
|
updatedAt: writeTime,
|
|
},
|
|
{},
|
|
this._prisma
|
|
);
|
|
updatedAtMs = writeTime.getTime();
|
|
}
|
|
|
|
const newMetadata = await parsePacket(metadataPacket);
|
|
return { metadata: newMetadata, updatedAtMs };
|
|
}
|
|
|
|
#ingestRunOperations(runId: string, operations: RunMetadataChangeOperation[]) {
|
|
const bufferedOperations: BufferedRunMetadataChangeOperation[] = operations.map((operation) => {
|
|
return {
|
|
runId,
|
|
timestamp: Date.now(),
|
|
operation,
|
|
};
|
|
});
|
|
|
|
if (this.flushLoggingEnabled) {
|
|
this.logger.debug(`[ingestRunOperations] Ingesting operations for run`, {
|
|
runId,
|
|
bufferedOperations,
|
|
});
|
|
}
|
|
|
|
const existingBufferedOperations = this._bufferedOperations.get(runId) ?? [];
|
|
|
|
this._bufferedOperations.set(runId, [...existingBufferedOperations, ...bufferedOperations]);
|
|
}
|
|
|
|
// Testing method to manually trigger flush
|
|
async flushOperations() {
|
|
const currentOperations = new Map(this._bufferedOperations);
|
|
this._bufferedOperations.clear();
|
|
|
|
if (currentOperations.size > 0) {
|
|
await Effect.runPromise(this._processBufferedOperations(currentOperations));
|
|
}
|
|
}
|
|
}
|