Add ability to update parent and root run metadata from children (#1563)
* Increase the number of active streams from 2 to 5 * WIP parent metadata updates * Fix noop metadata manager * Implement run metadata updates from ancestor tasks * Add changeset * Add ability to stream into parent and root task runs * Don't duplicate exporting run metadata types * Add ability to fetch streams through metadata * Couple of fixes from CodeRabbit * Fix metadata tests * Make sure streams are subscribed in the "background" * Move the stream subscription stuff to the API client, expose it through `runs.fetchStream` * Fixed run stream tests
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Increase the number of active streams from 2 to 5, total streams from 5 to 10
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@trigger.dev/react-hooks": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Adding ability to update parent run metadata from child runs/tasks
|
||||
@@ -253,13 +253,14 @@ const EnvironmentSchema = z.object({
|
||||
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
|
||||
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
|
||||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(4_096), // 4KB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
|
||||
|
||||
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
BATCH_METADATA_OPERATIONS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -1,94 +1,29 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { parsePacket, UpdateMetadataRequestBody } from "@trigger.dev/core/v3";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { UpdateMetadataRequestBody } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { handleMetadataPacket } from "~/utils/packets";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { updateMetadataService } from "~/services/metadata/updateMetadata.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Ensure this is a PUT request
|
||||
if (request.method.toUpperCase() !== "PUT") {
|
||||
return json({ error: "Method not allowed" }, { status: 405, headers: { Allow: "PUT" } });
|
||||
}
|
||||
const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
body: UpdateMetadataRequestBody,
|
||||
maxContentLength: 1024 * 1024, // 1MB
|
||||
method: "PUT",
|
||||
},
|
||||
async ({ authentication, body, params }) => {
|
||||
const result = await updateMetadataService.call(authentication.environment, params.runId, body);
|
||||
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
if (!parsedParams.success) {
|
||||
return json(
|
||||
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const anyBody = await request.json();
|
||||
|
||||
const body = UpdateMetadataRequestBody.safeParse(anyBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const metadataPacket = handleMetadataPacket(
|
||||
body.data.metadata,
|
||||
body.data.metadataType ?? "application/json"
|
||||
);
|
||||
|
||||
if (!metadataPacket) {
|
||||
return json({ error: "Invalid metadata" }, { status: 400 });
|
||||
}
|
||||
|
||||
const taskRun = await prisma.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: parsedParams.data.runId,
|
||||
runtimeEnvironmentId: authenticationResult.environment.id,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
if (!result) {
|
||||
return json({ error: "Task Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (isFinalRunStatus(taskRun.status)) {
|
||||
return json({ error: "Cannot update metadata for a completed run" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.taskRun.update({
|
||||
where: {
|
||||
friendlyId: parsedParams.data.runId,
|
||||
runtimeEnvironmentId: authenticationResult.environment.id,
|
||||
},
|
||||
data: {
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
},
|
||||
});
|
||||
|
||||
const parsedPacket = await parsePacket(metadataPacket);
|
||||
|
||||
return json({ metadata: parsedPacket }, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: error.status ?? 422 });
|
||||
} else {
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return json(result, { status: 200 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { action };
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
BatchTriggerTaskResponse,
|
||||
BatchTriggerTaskV2RequestBody,
|
||||
BatchTriggerTaskV2Response,
|
||||
generateJWT,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { env } from "~/env.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import {
|
||||
BatchProcessingStrategy,
|
||||
BatchTriggerV2Service,
|
||||
} from "~/v3/services/batchTriggerV2.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { z } from "zod";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { relayRealtimeStreams } from "~/services/realtime/relayRealtimeStreams.server";
|
||||
import { v1RealtimeStreams } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { relayRealtimeStreams } from "~/services/realtime/relayRealtimeStreams.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
target: z.enum(["self", "parent", "root"]),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
},
|
||||
async ({ request, params, authentication }) => {
|
||||
if (!request.body) {
|
||||
return new Response("No body provided", { status: 400 });
|
||||
}
|
||||
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
parentTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
const targetId =
|
||||
params.target === "self"
|
||||
? run.friendlyId
|
||||
: params.target === "parent"
|
||||
? run.parentTaskRun?.friendlyId
|
||||
: run.rootTaskRun?.friendlyId;
|
||||
|
||||
if (!targetId) {
|
||||
return new Response("Target not found", { status: 404 });
|
||||
}
|
||||
|
||||
return relayRealtimeStreams.ingestData(request.body, targetId, params.streamId);
|
||||
}
|
||||
);
|
||||
|
||||
export { action };
|
||||
@@ -0,0 +1,414 @@
|
||||
import {
|
||||
applyMetadataOperations,
|
||||
IOPacket,
|
||||
parsePacket,
|
||||
RunMetadataChangeOperation,
|
||||
UpdateMetadataRequestBody,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { prisma, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { handleMetadataPacket } from "~/utils/packets";
|
||||
import { BaseService, ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
|
||||
import { Effect, Schedule, Duration } from "effect";
|
||||
import { type RuntimeFiber } from "effect/Fiber";
|
||||
import { logger } from "../logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { env } from "~/env.server";
|
||||
import { setTimeout } from "timers/promises";
|
||||
|
||||
type BufferedRunMetadataChangeOperation = {
|
||||
runId: string;
|
||||
timestamp: number;
|
||||
operation: RunMetadataChangeOperation;
|
||||
};
|
||||
|
||||
export class UpdateMetadataService extends BaseService {
|
||||
private _bufferedOperations: Map<string, BufferedRunMetadataChangeOperation[]> = new Map();
|
||||
private _flushFiber: RuntimeFiber<void> | null = null;
|
||||
|
||||
constructor(
|
||||
protected readonly _prisma: PrismaClientOrTransaction = prisma,
|
||||
private readonly flushIntervalMs: number = 5000
|
||||
) {
|
||||
super();
|
||||
|
||||
this._startFlushing();
|
||||
}
|
||||
|
||||
// Start a loop that periodically flushes buffered operations
|
||||
private _startFlushing() {
|
||||
// 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(() => {
|
||||
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(() => {
|
||||
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>);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
logger.debug(`[UpdateMetadataService] Processing operations for run`, {
|
||||
runId,
|
||||
operationsCount: processedOps.length,
|
||||
});
|
||||
});
|
||||
|
||||
// Update run with retry
|
||||
yield* _(
|
||||
this._updateRunWithOperations(runId, processedOps).pipe(
|
||||
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
|
||||
const run = yield* _(
|
||||
Effect.tryPromise(() =>
|
||||
this._prisma.taskRun.findFirst({
|
||||
where: { id: runId },
|
||||
select: { id: true, metadata: true, metadataType: true, metadataVersion: true },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
return yield* _(Effect.fail(new Error(`Run ${runId} not found`)));
|
||||
}
|
||||
|
||||
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) {
|
||||
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))
|
||||
);
|
||||
|
||||
if (!newMetadataPacket) {
|
||||
// Log and skip if metadata is invalid
|
||||
logger.warn(`Invalid metadata after operations, skipping update`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = yield* _(
|
||||
Effect.tryPromise(() =>
|
||||
this._prisma.taskRun.updateMany({
|
||||
where: {
|
||||
id: runId,
|
||||
metadataVersion: run.metadataVersion,
|
||||
},
|
||||
data: {
|
||||
metadata: newMetadataPacket.data,
|
||||
metadataVersion: { increment: 1 },
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (result.count === 0) {
|
||||
yield* Effect.sync(() => {
|
||||
logger.warn(`Optimistic lock failed for run ${runId}`, {
|
||||
metadataVersion: run.metadataVersion,
|
||||
});
|
||||
});
|
||||
|
||||
return yield* _(Effect.fail(new Error("Optimistic lock failed")));
|
||||
}
|
||||
|
||||
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(
|
||||
environment: AuthenticatedEnvironment,
|
||||
runId: string,
|
||||
body: UpdateMetadataRequestBody
|
||||
) {
|
||||
const runIdType = runId.startsWith("run_") ? "friendly" : "internal";
|
||||
|
||||
const taskRun = await this._prisma.taskRun.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
...(runIdType === "internal" ? { id: runId } : { friendlyId: runId }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
metadataType: true,
|
||||
metadataVersion: true,
|
||||
parentTaskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFinalRunStatus(taskRun.status)) {
|
||||
throw new ServiceValidationError("Cannot update metadata for a completed run");
|
||||
}
|
||||
|
||||
if (body.parentOperations && body.parentOperations.length > 0 && taskRun.parentTaskRun) {
|
||||
this.#ingestRunOperations(taskRun.parentTaskRun.id, body.parentOperations);
|
||||
}
|
||||
|
||||
if (body.rootOperations && body.rootOperations.length > 0 && taskRun.rootTaskRun) {
|
||||
this.#ingestRunOperations(taskRun.rootTaskRun.id, body.rootOperations);
|
||||
}
|
||||
|
||||
const newMetadata = await this.#updateRunMetadata({
|
||||
runId: taskRun.id,
|
||||
body,
|
||||
existingMetadata: {
|
||||
data: taskRun.metadata ?? undefined,
|
||||
dataType: taskRun.metadataType,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
metadata: newMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
async #updateRunMetadata({
|
||||
runId,
|
||||
body,
|
||||
existingMetadata,
|
||||
}: {
|
||||
runId: string;
|
||||
body: UpdateMetadataRequestBody;
|
||||
existingMetadata: IOPacket;
|
||||
}) {
|
||||
if (Array.isArray(body.operations)) {
|
||||
return this.#updateRunMetadataWithOperations(runId, body.operations);
|
||||
} else {
|
||||
return this.#updateRunMetadataDirectly(runId, body, existingMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
async #updateRunMetadataWithOperations(runId: string, operations: RunMetadataChangeOperation[]) {
|
||||
const MAX_RETRIES = 3;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts <= MAX_RETRIES) {
|
||||
// Fetch the latest run data
|
||||
const run = await this._prisma.taskRun.findFirst({
|
||||
where: { id: runId },
|
||||
select: { metadata: true, metadataType: true, metadataVersion: true },
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
throw new Error(`Run ${runId} not found`);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (applyResults.unappliedOperations.length === operations.length) {
|
||||
return currentMetadata;
|
||||
}
|
||||
|
||||
// Update with optimistic locking
|
||||
const result = await this._prisma.taskRun.updateMany({
|
||||
where: {
|
||||
id: runId,
|
||||
metadataVersion: run.metadataVersion,
|
||||
},
|
||||
data: {
|
||||
metadata: JSON.stringify(applyResults.newMetadata),
|
||||
metadataType: run.metadataType,
|
||||
metadataVersion: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
// If this was our last attempt, buffer the operations and return optimistically
|
||||
if (attempts === MAX_RETRIES) {
|
||||
this.#ingestRunOperations(runId, operations);
|
||||
return applyResults.newMetadata;
|
||||
}
|
||||
|
||||
// Otherwise sleep and try again
|
||||
await setTimeout(100 * Math.pow(1.4, attempts));
|
||||
attempts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Success! Return the new metadata
|
||||
return applyResults.newMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
async #updateRunMetadataDirectly(
|
||||
runId: string,
|
||||
body: UpdateMetadataRequestBody,
|
||||
existingMetadata: IOPacket
|
||||
) {
|
||||
const metadataPacket = handleMetadataPacket(body.metadata, "application/json");
|
||||
|
||||
if (!metadataPacket) {
|
||||
throw new ServiceValidationError("Invalid metadata");
|
||||
}
|
||||
|
||||
if (
|
||||
metadataPacket.data !== "{}" ||
|
||||
(existingMetadata.data && metadataPacket.data !== existingMetadata.data)
|
||||
) {
|
||||
logger.debug(`Updating metadata directly for run`, {
|
||||
metadata: metadataPacket.data,
|
||||
runId,
|
||||
});
|
||||
|
||||
// Update the metadata without version check
|
||||
await this._prisma.taskRun.update({
|
||||
where: {
|
||||
id: runId,
|
||||
},
|
||||
data: {
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
metadataVersion: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const newMetadata = await parsePacket(metadataPacket);
|
||||
return newMetadata;
|
||||
}
|
||||
|
||||
#ingestRunOperations(runId: string, operations: RunMetadataChangeOperation[]) {
|
||||
const bufferedOperations: BufferedRunMetadataChangeOperation[] = operations.map((operation) => {
|
||||
return {
|
||||
runId,
|
||||
timestamp: Date.now(),
|
||||
operation,
|
||||
};
|
||||
});
|
||||
|
||||
const existingBufferedOperations = this._bufferedOperations.get(runId) ?? [];
|
||||
|
||||
this._bufferedOperations.set(runId, [...existingBufferedOperations, ...bufferedOperations]);
|
||||
}
|
||||
}
|
||||
|
||||
export const updateMetadataService = singleton(
|
||||
"update-metadata-service",
|
||||
() => new UpdateMetadataService(prisma, env.BATCH_METADATA_OPERATIONS_FLUSH_INTERVAL_MS)
|
||||
);
|
||||
@@ -384,6 +384,7 @@ type ApiKeyActionRouteBuilderOptions<
|
||||
headers?: THeadersSchema;
|
||||
allowJWT?: boolean;
|
||||
corsStrategy?: "all" | "none";
|
||||
method?: "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (
|
||||
@@ -455,6 +456,19 @@ export function createActionApiRoute<
|
||||
}
|
||||
|
||||
async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (options.method) {
|
||||
if (request.method.toUpperCase() !== options.method) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Method not allowed" },
|
||||
{ status: 405, headers: { Allow: options.method } }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
|
||||
|
||||
@@ -613,6 +627,19 @@ export function createActionApiRoute<
|
||||
if (error instanceof Response) {
|
||||
return await wrapResponse(request, error, corsStrategy !== "none");
|
||||
}
|
||||
|
||||
logger.error("Error in action", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: String(error),
|
||||
url: request.url,
|
||||
});
|
||||
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
|
||||
@@ -12,22 +12,23 @@ import {
|
||||
shouldRetryError,
|
||||
taskRunErrorEnhancer,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
|
||||
import { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { safeJsonParse } from "~/utils/json";
|
||||
import { createExceptionPropertiesFromError, eventRepository } from "../eventRepository.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { createExceptionPropertiesFromError, eventRepository } from "../eventRepository.server";
|
||||
import { FailedTaskRunRetryHelper } from "../failedTaskRun.server";
|
||||
import { FAILED_RUN_STATUSES, isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { CancelAttemptService } from "./cancelAttempt.server";
|
||||
import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
|
||||
import { CreateCheckpointService } from "./createCheckpoint.server";
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { RetryAttemptService } from "./retryAttempt.server";
|
||||
import { FAILED_RUN_STATUSES, isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
|
||||
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
|
||||
import { env } from "~/env.server";
|
||||
import { FailedTaskRunRetryHelper } from "../failedTaskRun.server";
|
||||
import { RetryAttemptService } from "./retryAttempt.server";
|
||||
import { updateMetadataService } from "~/services/metadata/updateMetadata.server";
|
||||
|
||||
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
|
||||
|
||||
@@ -94,6 +95,8 @@ export class CompleteAttemptService extends BaseService {
|
||||
code: TaskRunErrorCodes.TASK_EXECUTION_FAILED,
|
||||
message: "Tried to complete attempt but it doesn't exist",
|
||||
},
|
||||
metadata: completion.metadata,
|
||||
env,
|
||||
});
|
||||
|
||||
// No attempt, so there's no message to ACK
|
||||
@@ -153,6 +156,8 @@ export class CompleteAttemptService extends BaseService {
|
||||
id: taskRunAttempt.taskRunId,
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
completedAt: new Date(),
|
||||
metadata: completion.metadata,
|
||||
env,
|
||||
});
|
||||
|
||||
// Now we need to "complete" the task run event/span
|
||||
@@ -306,6 +311,8 @@ export class CompleteAttemptService extends BaseService {
|
||||
id: taskRunAttempt.taskRunId,
|
||||
status,
|
||||
completedAt: failedAt,
|
||||
metadata: completion.metadata,
|
||||
env,
|
||||
});
|
||||
|
||||
if (status !== "CRASHED" && status !== "SYSTEM_FAILURE") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sanitizeError, TaskRunError } from "@trigger.dev/core/v3";
|
||||
import { FlushedRunMetadata, sanitizeError, TaskRunError } from "@trigger.dev/core/v3";
|
||||
import { type Prisma, type TaskRun } from "@trigger.dev/database";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server";
|
||||
@@ -15,6 +15,8 @@ import { ResumeDependentParentsService } from "./resumeDependentParents.server";
|
||||
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
|
||||
import { socketIo } from "../handleSocketIo.server";
|
||||
import { ResumeBatchRunService } from "./resumeBatchRun.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { updateMetadataService } from "~/services/metadata/updateMetadata.server";
|
||||
|
||||
type BaseInput = {
|
||||
id: string;
|
||||
@@ -23,6 +25,8 @@ type BaseInput = {
|
||||
completedAt?: Date;
|
||||
attemptStatus?: FINAL_ATTEMPT_STATUSES;
|
||||
error?: TaskRunError;
|
||||
metadata?: FlushedRunMetadata;
|
||||
env?: AuthenticatedEnvironment;
|
||||
};
|
||||
|
||||
type InputWithInclude<T extends Prisma.TaskRunInclude> = BaseInput & {
|
||||
@@ -46,6 +50,8 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
include,
|
||||
attemptStatus,
|
||||
error,
|
||||
metadata,
|
||||
env,
|
||||
}: T extends Prisma.TaskRunInclude ? InputWithInclude<T> : InputWithoutInclude): Promise<
|
||||
Output<T>
|
||||
> {
|
||||
@@ -64,6 +70,24 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
completedAt,
|
||||
});
|
||||
|
||||
if (env && metadata) {
|
||||
try {
|
||||
await updateMetadataService.call(env, id, metadata);
|
||||
} catch (e) {
|
||||
logger.error("[FinalizeTaskRunService] Failed to update metadata", {
|
||||
taskRun: id,
|
||||
error:
|
||||
e instanceof Error
|
||||
? {
|
||||
name: e.name,
|
||||
message: e.message,
|
||||
stack: e.stack,
|
||||
}
|
||||
: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// I moved the error update here for two reasons:
|
||||
// - A single update is more efficient than two
|
||||
// - If the status updates to a final status, realtime will receive that status and then shut down the stream
|
||||
|
||||
@@ -21,28 +21,43 @@ type Output =
|
||||
error: string;
|
||||
};
|
||||
|
||||
type Dependency = Prisma.TaskRunDependencyGetPayload<{
|
||||
include: {
|
||||
taskRun: true;
|
||||
dependentAttempt: true;
|
||||
dependentBatchRun: true;
|
||||
};
|
||||
}>;
|
||||
const taskRunDependencySelect = {
|
||||
select: {
|
||||
id: true,
|
||||
taskRunId: true,
|
||||
taskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
friendlyId: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependentAttempt: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
dependentBatchRun: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
type Dependency = Prisma.TaskRunDependencyGetPayload<typeof taskRunDependencySelect>;
|
||||
|
||||
/** This will resume a dependent (parent) run if there is one and it makes sense. */
|
||||
export class ResumeDependentParentsService extends BaseService {
|
||||
public async call({ id }: { id: string }): Promise<Output> {
|
||||
try {
|
||||
const dependency = await this._prisma.taskRunDependency.findFirst({
|
||||
include: {
|
||||
taskRun: {
|
||||
include: {
|
||||
runtimeEnvironment: true,
|
||||
},
|
||||
},
|
||||
dependentAttempt: true,
|
||||
dependentBatchRun: true,
|
||||
},
|
||||
...taskRunDependencySelect,
|
||||
where: {
|
||||
taskRunId: id,
|
||||
},
|
||||
@@ -65,6 +80,13 @@ export class ResumeDependentParentsService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
if (dependency.taskRun.runtimeEnvironment.type === "DEVELOPMENT") {
|
||||
return {
|
||||
success: true,
|
||||
action: "dev",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isFinalRunStatus(dependency.taskRun.status)) {
|
||||
logger.debug(
|
||||
"ResumeDependentParentsService: run not finished yet, can't resume parent yet",
|
||||
@@ -81,18 +103,6 @@ export class ResumeDependentParentsService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
if (dependency.taskRun.runtimeEnvironment.type === "DEVELOPMENT") {
|
||||
logger.debug("ResumeDependentParentsService: runs are resumed on device for DEV runs.", {
|
||||
runId: id,
|
||||
dependency,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "dev",
|
||||
};
|
||||
}
|
||||
|
||||
if (dependency.dependentAttempt) {
|
||||
return this.#singleRunDependency(dependency);
|
||||
} else if (dependency.dependentBatchRun) {
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"cuid": "^2.1.8",
|
||||
"dotenv": "^16.4.5",
|
||||
"effect": "^3.11.7",
|
||||
"emails": "workspace:*",
|
||||
"evt": "^2.4.13",
|
||||
"express": "^4.18.1",
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "metadataVersion" INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -1778,8 +1778,9 @@ model TaskRun {
|
||||
seedMetadataType String @default("application/json")
|
||||
|
||||
/// Run metadata
|
||||
metadata String?
|
||||
metadataType String @default("application/json")
|
||||
metadata String?
|
||||
metadataType String @default("application/json")
|
||||
metadataVersion Int @default(1)
|
||||
|
||||
/// Run output
|
||||
output String?
|
||||
|
||||
@@ -363,6 +363,7 @@ const zodIpc = new ZodIpcConnection({
|
||||
durationMs: usageSample.cpuTime,
|
||||
},
|
||||
taskIdentifier: execution.task.id,
|
||||
metadata: runMetadataManager.stopAndReturnLastFlush(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -387,6 +388,7 @@ const zodIpc = new ZodIpcConnection({
|
||||
durationMs: usageSample.cpuTime,
|
||||
},
|
||||
taskIdentifier: execution.task.id,
|
||||
metadata: runMetadataManager.stopAndReturnLastFlush(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -410,6 +412,7 @@ const zodIpc = new ZodIpcConnection({
|
||||
durationMs: 0,
|
||||
},
|
||||
taskIdentifier: execution.task.id,
|
||||
metadata: runMetadataManager.stopAndReturnLastFlush(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,6 +330,7 @@ const zodIpc = new ZodIpcConnection({
|
||||
durationMs: usageSample.cpuTime,
|
||||
},
|
||||
taskIdentifier: execution.task.id,
|
||||
metadata: runMetadataManager.stopAndReturnLastFlush(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -354,6 +355,7 @@ const zodIpc = new ZodIpcConnection({
|
||||
durationMs: usageSample.cpuTime,
|
||||
},
|
||||
taskIdentifier: execution.task.id,
|
||||
metadata: runMetadataManager.stopAndReturnLastFlush(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
RunSubscription,
|
||||
TaskRunShape,
|
||||
runShapeStream,
|
||||
SSEStreamSubscriptionFactory,
|
||||
} from "./runStream.js";
|
||||
import {
|
||||
CreateEnvironmentVariableParams,
|
||||
@@ -59,12 +60,14 @@ import {
|
||||
SubscribeToRunsQueryParams,
|
||||
UpdateEnvironmentVariableParams,
|
||||
} from "./types.js";
|
||||
import type { AsyncIterableStream } from "./stream.js";
|
||||
|
||||
export type {
|
||||
CreateEnvironmentVariableParams,
|
||||
ImportEnvironmentVariablesParams,
|
||||
SubscribeToRunsQueryParams,
|
||||
UpdateEnvironmentVariableParams,
|
||||
AsyncIterableStream,
|
||||
};
|
||||
|
||||
export type ClientTriggerOptions = {
|
||||
@@ -623,9 +626,25 @@ export class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
subscribeToRun<TRunTypes extends AnyRunTypes>(runId: string, options?: { signal?: AbortSignal }) {
|
||||
getRunMetadata(runId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
UpdateMetadataResponseBody,
|
||||
`${this.baseUrl}/api/v1/runs/${runId}/metadata`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
subscribeToRun<TRunTypes extends AnyRunTypes>(
|
||||
runId: string,
|
||||
options?: { signal?: AbortSignal; closeOnComplete?: boolean }
|
||||
) {
|
||||
return runShapeStream<TRunTypes>(`${this.baseUrl}/realtime/v1/runs/${runId}`, {
|
||||
closeOnComplete: true,
|
||||
closeOnComplete:
|
||||
typeof options?.closeOnComplete === "boolean" ? options.closeOnComplete : true,
|
||||
headers: this.#getRealtimeHeaders(),
|
||||
client: this,
|
||||
signal: options?.signal,
|
||||
@@ -663,6 +682,23 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchStream<T>(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
options?: { signal?: AbortSignal; baseUrl?: string }
|
||||
): Promise<AsyncIterableStream<T>> {
|
||||
const streamFactory = new SSEStreamSubscriptionFactory(options?.baseUrl ?? this.baseUrl, {
|
||||
headers: this.getHeaders(),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
const subscription = streamFactory.createSubscription(runId, streamKey);
|
||||
|
||||
const stream = await subscription.subscribe();
|
||||
|
||||
return stream as AsyncIterableStream<T>;
|
||||
}
|
||||
|
||||
async generateJWTClaims(requestOptions?: ZodFetchOptions): Promise<Record<string, any>> {
|
||||
return zodfetch(
|
||||
z.record(z.any()),
|
||||
|
||||
@@ -89,15 +89,7 @@ export function runShapeStream<TRunTypes extends AnyRunTypes>(
|
||||
): RunSubscription<TRunTypes> {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const version1 = new SSEStreamSubscriptionFactory(
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
{
|
||||
headers: options?.headers,
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
|
||||
const version2 = new ElectricStreamSubscriptionFactory(
|
||||
const streamFactory = new SSEStreamSubscriptionFactory(
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
{
|
||||
headers: options?.headers,
|
||||
@@ -123,8 +115,8 @@ export function runShapeStream<TRunTypes extends AnyRunTypes>(
|
||||
|
||||
const $options: RunSubscriptionOptions = {
|
||||
runShapeStream: runStreamInstance.stream,
|
||||
stopRunShapeStream: runStreamInstance.stop,
|
||||
streamFactory: new VersionedStreamSubscriptionFactory(version1, version2),
|
||||
stopRunShapeStream: () => runStreamInstance.stop(30 * 1000),
|
||||
streamFactory: streamFactory,
|
||||
abortController,
|
||||
...options,
|
||||
};
|
||||
@@ -138,12 +130,7 @@ export interface StreamSubscription {
|
||||
}
|
||||
|
||||
export interface StreamSubscriptionFactory {
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
baseUrl?: string
|
||||
): StreamSubscription;
|
||||
createSubscription(runId: string, streamKey: string, baseUrl?: string): StreamSubscription;
|
||||
}
|
||||
|
||||
// Real implementation for production
|
||||
@@ -194,12 +181,7 @@ export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
private options: { headers?: Record<string, string>; signal?: AbortSignal }
|
||||
) {}
|
||||
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
baseUrl?: string
|
||||
): StreamSubscription {
|
||||
createSubscription(runId: string, streamKey: string, baseUrl?: string): StreamSubscription {
|
||||
if (!runId || !streamKey) {
|
||||
throw new Error("runId and streamKey are required");
|
||||
}
|
||||
@@ -238,63 +220,6 @@ export class ElectricStreamSubscription implements StreamSubscription {
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectricStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private options: { headers?: Record<string, string>; signal?: AbortSignal }
|
||||
) {}
|
||||
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
baseUrl?: string
|
||||
): StreamSubscription {
|
||||
if (!runId || !streamKey) {
|
||||
throw new Error("runId and streamKey are required");
|
||||
}
|
||||
|
||||
return new ElectricStreamSubscription(
|
||||
`${baseUrl ?? this.baseUrl}/realtime/v2/streams/${runId}/${streamKey}`,
|
||||
this.options
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class VersionedStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
constructor(
|
||||
private version1: StreamSubscriptionFactory,
|
||||
private version2: StreamSubscriptionFactory
|
||||
) {}
|
||||
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
baseUrl?: string
|
||||
): StreamSubscription {
|
||||
if (!runId || !streamKey) {
|
||||
throw new Error("runId and streamKey are required");
|
||||
}
|
||||
|
||||
const version =
|
||||
typeof metadata.$$streamsVersion === "string" ? metadata.$$streamsVersion : "v1";
|
||||
|
||||
const $baseUrl =
|
||||
typeof metadata.$$streamsBaseUrl === "string" ? metadata.$$streamsBaseUrl : baseUrl;
|
||||
|
||||
if (version === "v1") {
|
||||
return this.version1.createSubscription(metadata, runId, streamKey, $baseUrl);
|
||||
}
|
||||
|
||||
if (version === "v2") {
|
||||
return this.version2.createSubscription(metadata, runId, streamKey, $baseUrl);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown stream version: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunShapeProvider {
|
||||
onShape(callback: (shape: SubscribeRunRawShape) => Promise<void>): Promise<() => void>;
|
||||
}
|
||||
@@ -324,6 +249,7 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
|
||||
controller.enqueue(run);
|
||||
|
||||
// only set the run to complete when finishedAt is set
|
||||
this._isRunComplete = !!run.finishedAt;
|
||||
|
||||
if (
|
||||
@@ -384,36 +310,40 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
activeStreams.add(streamKey);
|
||||
|
||||
const subscription = this.options.streamFactory.createSubscription(
|
||||
run.metadata,
|
||||
run.id,
|
||||
streamKey,
|
||||
this.options.client?.baseUrl
|
||||
);
|
||||
|
||||
const stream = await subscription.subscribe();
|
||||
|
||||
// Create the pipeline and start it
|
||||
stream
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue({
|
||||
type: streamKey,
|
||||
chunk: chunk as TStreams[typeof streamKey],
|
||||
run,
|
||||
} as StreamPartResult<RunShape<TRunTypes>, TStreams>);
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
)
|
||||
// Start stream processing in the background
|
||||
subscription
|
||||
.subscribe()
|
||||
.then((stream) => {
|
||||
stream
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue({
|
||||
type: streamKey,
|
||||
chunk: chunk as TStreams[typeof streamKey],
|
||||
run,
|
||||
});
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(`Error in stream ${streamKey}:`, error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Error in stream ${streamKey}:`, error);
|
||||
console.error(`Error subscribing to stream ${streamKey}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export type ZodShapeStreamOptions = {
|
||||
|
||||
export type ZodShapeStreamInstance<TShapeSchema extends z.ZodTypeAny> = {
|
||||
stream: AsyncIterableStream<z.output<TShapeSchema>>;
|
||||
stop: () => void;
|
||||
stop: (delay?: number) => void;
|
||||
};
|
||||
|
||||
export function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
|
||||
@@ -64,8 +64,16 @@ export function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
|
||||
|
||||
return {
|
||||
stream: stream as AsyncIterableStream<z.output<TShapeSchema>>,
|
||||
stop: () => {
|
||||
abortController.abort();
|
||||
stop: (delay?: number) => {
|
||||
if (delay) {
|
||||
setTimeout(() => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
abortController.abort();
|
||||
}, delay);
|
||||
} else {
|
||||
abortController.abort();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
import { RunMetadataAPI } from "./runMetadata/index.js";
|
||||
|
||||
export const runMetadata = RunMetadataAPI.getInstance();
|
||||
|
||||
export * from "./runMetadata/types.js";
|
||||
export * from "./runMetadata/operations.js";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { DeserializedJson } from "../../schemas/json.js";
|
||||
import { AsyncIterableStream } from "../apiClient/stream.js";
|
||||
import { getGlobal, registerGlobal } from "../utils/globals.js";
|
||||
import { ApiRequestOptions } from "../zodfetch.js";
|
||||
import { NoopRunMetadataManager } from "./noopManager.js";
|
||||
import { RunMetadataManager } from "./types.js";
|
||||
import { RunMetadataManager, RunMetadataUpdater } from "./types.js";
|
||||
|
||||
const API_NAME = "run-metadata";
|
||||
|
||||
@@ -41,32 +42,39 @@ export class RunMetadataAPI implements RunMetadataManager {
|
||||
return this.#getManager().getKey(key);
|
||||
}
|
||||
|
||||
public setKey(key: string, value: DeserializedJson) {
|
||||
return this.#getManager().setKey(key, value);
|
||||
public set(key: string, value: DeserializedJson) {
|
||||
this.#getManager().set(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public deleteKey(key: string) {
|
||||
return this.#getManager().deleteKey(key);
|
||||
public del(key: string) {
|
||||
this.#getManager().del(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
public incrementKey(key: string, value: number): void {
|
||||
return this.#getManager().incrementKey(key, value);
|
||||
public increment(key: string, value: number) {
|
||||
this.#getManager().increment(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
decrementKey(key: string, value: number): void {
|
||||
return this.#getManager().decrementKey(key, value);
|
||||
decrement(key: string, value: number) {
|
||||
this.#getManager().decrement(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
appendKey(key: string, value: DeserializedJson): void {
|
||||
this.#getManager().appendKey(key, value);
|
||||
append(key: string, value: DeserializedJson) {
|
||||
this.#getManager().append(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
removeFromKey(key: string, value: DeserializedJson): void {
|
||||
this.#getManager().removeFromKey(key, value);
|
||||
remove(key: string, value: DeserializedJson) {
|
||||
this.#getManager().remove(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public update(metadata: Record<string, DeserializedJson>): void {
|
||||
return this.#getManager().update(metadata);
|
||||
public update(metadata: Record<string, DeserializedJson>) {
|
||||
this.#getManager().update(metadata);
|
||||
return this;
|
||||
}
|
||||
|
||||
public stream<T>(
|
||||
@@ -77,7 +85,23 @@ export class RunMetadataAPI implements RunMetadataManager {
|
||||
return this.#getManager().stream(key, value, signal);
|
||||
}
|
||||
|
||||
public fetchStream<T>(key: string, signal?: AbortSignal): Promise<AsyncIterableStream<T>> {
|
||||
return this.#getManager().fetchStream(key, signal);
|
||||
}
|
||||
|
||||
flush(requestOptions?: ApiRequestOptions): Promise<void> {
|
||||
return this.#getManager().flush(requestOptions);
|
||||
}
|
||||
|
||||
refresh(requestOptions?: ApiRequestOptions): Promise<void> {
|
||||
return this.#getManager().refresh(requestOptions);
|
||||
}
|
||||
|
||||
get parent(): RunMetadataUpdater {
|
||||
return this.#getManager().parent;
|
||||
}
|
||||
|
||||
get root(): RunMetadataUpdater {
|
||||
return this.#getManager().root;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { JSONHeroPath } from "@jsonhero/path";
|
||||
import { dequal } from "dequal/lite";
|
||||
import { DeserializedJson } from "../../schemas/json.js";
|
||||
import { ApiRequestOptions } from "../zodfetch.js";
|
||||
import { RunMetadataManager } from "./types.js";
|
||||
import { MetadataStream } from "./metadataStream.js";
|
||||
import { ApiClient } from "../apiClient/index.js";
|
||||
import { AsyncIterableStream } from "../apiClient/stream.js";
|
||||
import { FlushedRunMetadata, RunMetadataChangeOperation } from "../schemas/common.js";
|
||||
import { ApiRequestOptions } from "../zodfetch.js";
|
||||
import { MetadataStream } from "./metadataStream.js";
|
||||
import { applyMetadataOperations } from "./operations.js";
|
||||
import { RunMetadataManager, RunMetadataUpdater } from "./types.js";
|
||||
|
||||
const MAXIMUM_ACTIVE_STREAMS = 2;
|
||||
const MAXIMUM_TOTAL_STREAMS = 5;
|
||||
const MAXIMUM_ACTIVE_STREAMS = 5;
|
||||
const MAXIMUM_TOTAL_STREAMS = 10;
|
||||
|
||||
export class StandardMetadataManager implements RunMetadataManager {
|
||||
private flushTimeoutId: NodeJS.Timeout | null = null;
|
||||
private hasChanges: boolean = false;
|
||||
private isFlushing: boolean = false;
|
||||
private store: Record<string, DeserializedJson> | undefined;
|
||||
// Add a Map to track active streams
|
||||
private activeStreams = new Map<string, MetadataStream<any>>();
|
||||
|
||||
private queuedOperations: Set<RunMetadataChangeOperation> = new Set();
|
||||
private queuedParentOperations: Set<RunMetadataChangeOperation> = new Set();
|
||||
private queuedRootOperations: Set<RunMetadataChangeOperation> = new Set();
|
||||
|
||||
public runId: string | undefined;
|
||||
|
||||
constructor(
|
||||
@@ -24,6 +30,74 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
private streamsVersion: "v1" | "v2" = "v1"
|
||||
) {}
|
||||
|
||||
get parent(): RunMetadataUpdater {
|
||||
return {
|
||||
set: (key, value) => {
|
||||
this.queuedParentOperations.add({ type: "set", key, value });
|
||||
return this.parent;
|
||||
},
|
||||
del: (key) => {
|
||||
this.queuedParentOperations.add({ type: "delete", key });
|
||||
return this.parent;
|
||||
},
|
||||
append: (key, value) => {
|
||||
this.queuedParentOperations.add({ type: "append", key, value });
|
||||
return this.parent;
|
||||
},
|
||||
remove: (key, value) => {
|
||||
this.queuedParentOperations.add({ type: "remove", key, value });
|
||||
return this.parent;
|
||||
},
|
||||
increment: (key, value) => {
|
||||
this.queuedParentOperations.add({ type: "increment", key, value });
|
||||
return this.parent;
|
||||
},
|
||||
decrement: (key, value) => {
|
||||
this.queuedParentOperations.add({ type: "increment", key, value: -Math.abs(value) });
|
||||
return this.parent;
|
||||
},
|
||||
update: (value) => {
|
||||
this.queuedParentOperations.add({ type: "update", value });
|
||||
return this.parent;
|
||||
},
|
||||
stream: (key, value, signal) => this.doStream(key, value, "parent", this.parent, signal),
|
||||
};
|
||||
}
|
||||
|
||||
get root(): RunMetadataUpdater {
|
||||
return {
|
||||
set: (key, value) => {
|
||||
this.queuedRootOperations.add({ type: "set", key, value });
|
||||
return this.root;
|
||||
},
|
||||
del: (key) => {
|
||||
this.queuedRootOperations.add({ type: "delete", key });
|
||||
return this.root;
|
||||
},
|
||||
append: (key, value) => {
|
||||
this.queuedRootOperations.add({ type: "append", key, value });
|
||||
return this.root;
|
||||
},
|
||||
remove: (key, value) => {
|
||||
this.queuedRootOperations.add({ type: "remove", key, value });
|
||||
return this.root;
|
||||
},
|
||||
increment: (key, value) => {
|
||||
this.queuedRootOperations.add({ type: "increment", key, value });
|
||||
return this.root;
|
||||
},
|
||||
decrement: (key, value) => {
|
||||
this.queuedRootOperations.add({ type: "increment", key, value: -Math.abs(value) });
|
||||
return this.root;
|
||||
},
|
||||
update: (value) => {
|
||||
this.queuedRootOperations.add({ type: "update", value });
|
||||
return this.root;
|
||||
},
|
||||
stream: (key, value, signal) => this.doStream(key, value, "root", this.root, signal),
|
||||
};
|
||||
}
|
||||
|
||||
public enterWithMetadata(metadata: Record<string, DeserializedJson>): void {
|
||||
this.store = metadata ?? {};
|
||||
}
|
||||
@@ -36,174 +110,111 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
return this.store?.[key];
|
||||
}
|
||||
|
||||
public setKey(key: string, value: DeserializedJson) {
|
||||
if (!this.runId) {
|
||||
private enqueueOperation(operation: RunMetadataChangeOperation) {
|
||||
const applyResults = applyMetadataOperations(this.store ?? {}, operation);
|
||||
|
||||
if (applyResults.unappliedOperations.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextStore: Record<string, DeserializedJson> | undefined = this.store
|
||||
? structuredClone(this.store)
|
||||
: undefined;
|
||||
|
||||
if (key.startsWith("$.")) {
|
||||
const path = new JSONHeroPath(key);
|
||||
path.set(nextStore, value);
|
||||
} else {
|
||||
nextStore = {
|
||||
...(nextStore ?? {}),
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
|
||||
if (!nextStore) {
|
||||
if (dequal(this.store, applyResults.newMetadata)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dequal(this.store, nextStore)) {
|
||||
this.hasChanges = true;
|
||||
}
|
||||
|
||||
this.store = nextStore;
|
||||
this.queuedOperations.add(operation);
|
||||
this.store = applyResults.newMetadata as Record<string, DeserializedJson>;
|
||||
}
|
||||
|
||||
public deleteKey(key: string) {
|
||||
public set(key: string, value: DeserializedJson) {
|
||||
if (!this.runId) {
|
||||
return;
|
||||
return this;
|
||||
}
|
||||
|
||||
const nextStore = { ...(this.store ?? {}) };
|
||||
delete nextStore[key];
|
||||
this.enqueueOperation({ type: "set", key, value });
|
||||
|
||||
if (!dequal(this.store, nextStore)) {
|
||||
this.hasChanges = true;
|
||||
}
|
||||
|
||||
this.store = nextStore;
|
||||
return this;
|
||||
}
|
||||
|
||||
public appendKey(key: string, value: DeserializedJson) {
|
||||
public del(key: string) {
|
||||
if (!this.runId) {
|
||||
return;
|
||||
return this;
|
||||
}
|
||||
|
||||
let nextStore: Record<string, DeserializedJson> | undefined = this.store
|
||||
? structuredClone(this.store)
|
||||
: {};
|
||||
this.enqueueOperation({ type: "delete", key });
|
||||
|
||||
if (key.startsWith("$.")) {
|
||||
const path = new JSONHeroPath(key);
|
||||
const currentValue = path.first(nextStore);
|
||||
|
||||
if (currentValue === undefined) {
|
||||
// Initialize as array with single item
|
||||
path.set(nextStore, [value]);
|
||||
} else if (Array.isArray(currentValue)) {
|
||||
// Append to existing array
|
||||
path.set(nextStore, [...currentValue, value]);
|
||||
} else {
|
||||
// Convert to array if not already
|
||||
path.set(nextStore, [currentValue, value]);
|
||||
}
|
||||
} else {
|
||||
const currentValue = nextStore[key];
|
||||
|
||||
if (currentValue === undefined) {
|
||||
// Initialize as array with single item
|
||||
nextStore[key] = [value];
|
||||
} else if (Array.isArray(currentValue)) {
|
||||
// Append to existing array
|
||||
nextStore[key] = [...currentValue, value];
|
||||
} else {
|
||||
// Convert to array if not already
|
||||
nextStore[key] = [currentValue, value];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dequal(this.store, nextStore)) {
|
||||
this.hasChanges = true;
|
||||
}
|
||||
|
||||
this.store = nextStore;
|
||||
return this;
|
||||
}
|
||||
|
||||
public removeFromKey(key: string, value: DeserializedJson) {
|
||||
public append(key: string, value: DeserializedJson) {
|
||||
if (!this.runId) {
|
||||
return;
|
||||
return this;
|
||||
}
|
||||
|
||||
let nextStore: Record<string, DeserializedJson> | undefined = this.store
|
||||
? structuredClone(this.store)
|
||||
: {};
|
||||
this.enqueueOperation({ type: "append", key, value });
|
||||
|
||||
if (key.startsWith("$.")) {
|
||||
const path = new JSONHeroPath(key);
|
||||
const currentValue = path.first(nextStore);
|
||||
|
||||
if (Array.isArray(currentValue)) {
|
||||
// Remove the value from array using deep equality check
|
||||
const newArray = currentValue.filter((item) => !dequal(item, value));
|
||||
path.set(nextStore, newArray);
|
||||
}
|
||||
} else {
|
||||
const currentValue = nextStore[key];
|
||||
|
||||
if (Array.isArray(currentValue)) {
|
||||
// Remove the value from array using deep equality check
|
||||
nextStore[key] = currentValue.filter((item) => !dequal(item, value));
|
||||
}
|
||||
}
|
||||
|
||||
if (!dequal(this.store, nextStore)) {
|
||||
this.hasChanges = true;
|
||||
}
|
||||
|
||||
this.store = nextStore;
|
||||
return this;
|
||||
}
|
||||
|
||||
public incrementKey(key: string, increment: number = 1) {
|
||||
public remove(key: string, value: DeserializedJson) {
|
||||
if (!this.runId) {
|
||||
return;
|
||||
return this;
|
||||
}
|
||||
|
||||
let nextStore = this.store ? structuredClone(this.store) : {};
|
||||
let currentValue = key.startsWith("$.")
|
||||
? new JSONHeroPath(key).first(nextStore)
|
||||
: nextStore[key];
|
||||
this.enqueueOperation({ type: "remove", key, value });
|
||||
|
||||
const newValue = (typeof currentValue === "number" ? currentValue : 0) + increment;
|
||||
|
||||
if (key.startsWith("$.")) {
|
||||
new JSONHeroPath(key).set(nextStore, newValue);
|
||||
} else {
|
||||
nextStore[key] = newValue;
|
||||
}
|
||||
|
||||
if (!dequal(this.store, nextStore)) {
|
||||
this.hasChanges = true;
|
||||
this.store = nextStore;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public decrementKey(key: string, decrement: number = 1) {
|
||||
this.incrementKey(key, -decrement);
|
||||
public increment(key: string, increment: number = 1) {
|
||||
if (!this.runId) {
|
||||
return this;
|
||||
}
|
||||
|
||||
this.enqueueOperation({ type: "increment", key, value: increment });
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public update(metadata: Record<string, DeserializedJson>): void {
|
||||
public decrement(key: string, decrement: number = 1) {
|
||||
return this.increment(key, -decrement);
|
||||
}
|
||||
|
||||
public update(metadata: Record<string, DeserializedJson>) {
|
||||
if (!this.runId) {
|
||||
return;
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!dequal(this.store, metadata)) {
|
||||
this.hasChanges = true;
|
||||
}
|
||||
this.enqueueOperation({ type: "update", value: metadata });
|
||||
|
||||
this.store = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
public async stream<T>(
|
||||
key: string,
|
||||
value: AsyncIterable<T> | ReadableStream<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AsyncIterable<T>> {
|
||||
return this.doStream(key, value, "self", this, signal);
|
||||
}
|
||||
|
||||
public async fetchStream<T>(key: string, signal?: AbortSignal): Promise<AsyncIterableStream<T>> {
|
||||
if (!this.runId) {
|
||||
throw new Error("Run ID is required to fetch metadata streams.");
|
||||
}
|
||||
|
||||
const baseUrl = this.getKey("$$streamsBaseUrl");
|
||||
|
||||
const $baseUrl = typeof baseUrl === "string" ? baseUrl : this.streamsBaseUrl;
|
||||
|
||||
return this.apiClient.fetchStream<T>(this.runId, key, { baseUrl: $baseUrl, signal });
|
||||
}
|
||||
|
||||
private async doStream<T>(
|
||||
key: string,
|
||||
value: AsyncIterable<T> | ReadableStream<T>,
|
||||
target: "self" | "parent" | "root",
|
||||
updater: RunMetadataUpdater = this,
|
||||
signal?: AbortSignal
|
||||
): Promise<AsyncIterable<T>> {
|
||||
const $value = value as AsyncIterable<T>;
|
||||
|
||||
@@ -238,6 +249,7 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
headers: this.apiClient.getHeaders(),
|
||||
signal,
|
||||
version: this.streamsVersion,
|
||||
target,
|
||||
});
|
||||
|
||||
this.activeStreams.set(key, streamInstance);
|
||||
@@ -246,16 +258,17 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
streamInstance.wait().finally(() => this.activeStreams.delete(key));
|
||||
|
||||
// Add the key to the special stream metadata object
|
||||
this.appendKey(`$$streams`, key);
|
||||
this.setKey("$$streamsVersion", this.streamsVersion);
|
||||
this.setKey("$$streamsBaseUrl", this.streamsBaseUrl);
|
||||
updater
|
||||
.append(`$$streams`, key)
|
||||
.set("$$streamsVersion", this.streamsVersion)
|
||||
.set("$$streamsBaseUrl", this.streamsBaseUrl);
|
||||
|
||||
await this.flush();
|
||||
|
||||
return streamInstance;
|
||||
} catch (error) {
|
||||
// Clean up metadata key if stream creation fails
|
||||
this.removeFromKey(`$$streams`, key);
|
||||
updater.remove(`$$streams`, key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -289,36 +302,72 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
}
|
||||
}
|
||||
|
||||
public async refresh(requestOptions?: ApiRequestOptions): Promise<void> {
|
||||
if (!this.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await this.apiClient.getRunMetadata(this.runId, requestOptions);
|
||||
this.store = metadata.metadata;
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh metadata", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async flush(requestOptions?: ApiRequestOptions): Promise<void> {
|
||||
if (!this.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.store) {
|
||||
if (!this.#needsFlush()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.hasChanges) {
|
||||
if (this.isFlushing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isFlushing = true;
|
||||
|
||||
const operations = Array.from(this.queuedOperations);
|
||||
this.queuedOperations.clear();
|
||||
|
||||
const parentOperations = Array.from(this.queuedParentOperations);
|
||||
this.queuedParentOperations.clear();
|
||||
|
||||
const rootOperations = Array.from(this.queuedRootOperations);
|
||||
this.queuedRootOperations.clear();
|
||||
|
||||
try {
|
||||
this.hasChanges = false;
|
||||
await this.apiClient.updateRunMetadata(this.runId, { metadata: this.store }, requestOptions);
|
||||
const response = await this.apiClient.updateRunMetadata(
|
||||
this.runId,
|
||||
{ operations, parentOperations, rootOperations },
|
||||
requestOptions
|
||||
);
|
||||
|
||||
this.store = response.metadata;
|
||||
} catch (error) {
|
||||
this.hasChanges = true;
|
||||
throw error;
|
||||
console.error("Failed to flush metadata", error);
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public startPeriodicFlush(intervalMs: number = 1000) {
|
||||
const periodicFlush = async (intervalMs: number) => {
|
||||
if (this.isFlushing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.flush();
|
||||
} catch (error) {
|
||||
console.error("Failed to flush metadata", error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
@@ -336,4 +385,27 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
this.flushTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
stopAndReturnLastFlush(): FlushedRunMetadata | undefined {
|
||||
this.stopPeriodicFlush();
|
||||
this.isFlushing = true;
|
||||
|
||||
if (!this.#needsFlush()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
operations: Array.from(this.queuedOperations),
|
||||
parentOperations: Array.from(this.queuedParentOperations),
|
||||
rootOperations: Array.from(this.queuedRootOperations),
|
||||
};
|
||||
}
|
||||
|
||||
#needsFlush(): boolean {
|
||||
return (
|
||||
this.queuedOperations.size > 0 ||
|
||||
this.queuedParentOperations.size > 0 ||
|
||||
this.queuedRootOperations.size > 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type MetadataOptions<T> = {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
version?: "v1" | "v2";
|
||||
target?: "self" | "parent" | "root";
|
||||
};
|
||||
|
||||
export class MetadataStream<T> {
|
||||
@@ -45,19 +46,14 @@ export class MetadataStream<T> {
|
||||
})
|
||||
);
|
||||
|
||||
return fetch(
|
||||
`${this.options.baseUrl}/realtime/${this.options.version ?? "v1"}/streams/${
|
||||
this.options.runId
|
||||
}/${this.options.key}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.options.headers ?? {},
|
||||
body: serverStream,
|
||||
// @ts-expect-error
|
||||
duplex: "half",
|
||||
signal: this.controller.signal,
|
||||
}
|
||||
);
|
||||
return fetch(this.buildUrl(), {
|
||||
method: "POST",
|
||||
headers: this.options.headers ?? {},
|
||||
body: serverStream,
|
||||
signal: this.controller.signal,
|
||||
// @ts-expect-error
|
||||
duplex: "half",
|
||||
});
|
||||
}
|
||||
|
||||
public async wait(): Promise<void> {
|
||||
@@ -67,6 +63,19 @@ export class MetadataStream<T> {
|
||||
public [Symbol.asyncIterator]() {
|
||||
return streamToAsyncIterator(this.consumerStream);
|
||||
}
|
||||
|
||||
private buildUrl(): string {
|
||||
switch (this.options.version ?? "v1") {
|
||||
case "v1": {
|
||||
return `${this.options.baseUrl}/realtime/v1/streams/${this.options.runId}/${
|
||||
this.options.target ?? "self"
|
||||
}/${this.options.key}`;
|
||||
}
|
||||
case "v2": {
|
||||
return `${this.options.baseUrl}/realtime/v2/streams/${this.options.runId}/${this.options.key}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* streamToAsyncIterator<T>(stream: ReadableStream<T>): AsyncIterableIterator<T> {
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { DeserializedJson } from "../../schemas/json.js";
|
||||
import { AsyncIterableStream } from "../apiClient/stream.js";
|
||||
import { ApiRequestOptions } from "../zodfetch.js";
|
||||
import type { RunMetadataManager } from "./types.js";
|
||||
import type { RunMetadataManager, RunMetadataUpdater } from "./types.js";
|
||||
|
||||
export class NoopRunMetadataManager implements RunMetadataManager {
|
||||
appendKey(key: string, value: DeserializedJson): void {
|
||||
append(key: string, value: DeserializedJson): this {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
removeFromKey(key: string, value: DeserializedJson): void {
|
||||
remove(key: string, value: DeserializedJson): this {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
incrementKey(key: string, value: number): void {
|
||||
increment(key: string, value: number): this {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
decrementKey(key: string, value: number): void {
|
||||
decrement(key: string, value: number): this {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
stream<T>(key: string, value: AsyncIterable<T>): Promise<AsyncIterable<T>> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
fetchStream<T>(key: string, signal?: AbortSignal): Promise<AsyncIterableStream<T>> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
flush(requestOptions?: ApiRequestOptions): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
refresh(requestOptions?: ApiRequestOptions): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
enterWithMetadata(metadata: Record<string, DeserializedJson>): void {}
|
||||
current(): Record<string, DeserializedJson> | undefined {
|
||||
throw new Error("Method not implemented.");
|
||||
@@ -28,13 +35,49 @@ export class NoopRunMetadataManager implements RunMetadataManager {
|
||||
getKey(key: string): DeserializedJson | undefined {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
setKey(key: string, value: DeserializedJson): void {
|
||||
set(key: string, value: DeserializedJson): this {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
deleteKey(key: string): void {
|
||||
del(key: string): this {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
update(metadata: Record<string, DeserializedJson>): void {
|
||||
update(metadata: Record<string, DeserializedJson>): this {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
get parent(): RunMetadataUpdater {
|
||||
return {
|
||||
append: () => this.parent,
|
||||
set: () => this.parent,
|
||||
del: () => this.parent,
|
||||
increment: () => this.parent,
|
||||
decrement: () => this.parent,
|
||||
remove: () => this.parent,
|
||||
stream: () =>
|
||||
Promise.resolve({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => Promise.resolve({ done: true, value: undefined }),
|
||||
}),
|
||||
}),
|
||||
update: () => this.parent,
|
||||
};
|
||||
}
|
||||
|
||||
get root(): RunMetadataUpdater {
|
||||
return {
|
||||
append: () => this.root,
|
||||
set: () => this.root,
|
||||
del: () => this.root,
|
||||
increment: () => this.root,
|
||||
decrement: () => this.root,
|
||||
remove: () => this.root,
|
||||
stream: () =>
|
||||
Promise.resolve({
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => Promise.resolve({ done: true, value: undefined }),
|
||||
}),
|
||||
}),
|
||||
update: () => this.root,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { JSONHeroPath } from "@jsonhero/path";
|
||||
import { RunMetadataChangeOperation } from "../schemas/common.js";
|
||||
import { dequal } from "dequal";
|
||||
|
||||
export type ApplyOperationResult = {
|
||||
newMetadata: Record<string, unknown>;
|
||||
unappliedOperations: RunMetadataChangeOperation[];
|
||||
};
|
||||
|
||||
export function applyMetadataOperations(
|
||||
currentMetadata: Record<string, unknown>,
|
||||
operations: RunMetadataChangeOperation | RunMetadataChangeOperation[]
|
||||
): ApplyOperationResult {
|
||||
const unappliedOperations: RunMetadataChangeOperation[] = [];
|
||||
// Start with a mutable copy of the current metadata
|
||||
let newMetadata: Record<string, unknown> = structuredClone(currentMetadata);
|
||||
|
||||
for (const operation of Array.isArray(operations) ? operations : [operations]) {
|
||||
switch (operation.type) {
|
||||
case "set": {
|
||||
if (operation.key.startsWith("$.")) {
|
||||
const path = new JSONHeroPath(operation.key);
|
||||
path.set(newMetadata, operation.value);
|
||||
} else {
|
||||
// Set the value directly
|
||||
newMetadata[operation.key] = operation.value;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
// Safely delete the key if it exists
|
||||
if (operation.key in newMetadata) {
|
||||
delete newMetadata[operation.key];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "append": {
|
||||
if (operation.key.startsWith("$.")) {
|
||||
const path = new JSONHeroPath(operation.key);
|
||||
const currentValue = path.first(newMetadata);
|
||||
|
||||
if (currentValue === undefined) {
|
||||
// Initialize as array with single item
|
||||
path.set(newMetadata, [operation.value]);
|
||||
} else if (Array.isArray(currentValue)) {
|
||||
// Append to existing array
|
||||
path.set(newMetadata, [...currentValue, operation.value]);
|
||||
} else {
|
||||
// Convert to array if not already
|
||||
path.set(newMetadata, [currentValue, operation.value]);
|
||||
}
|
||||
} else {
|
||||
// Ensure the value at key is an array or initialize as an array
|
||||
const existingValue = newMetadata[operation.key];
|
||||
if (Array.isArray(existingValue)) {
|
||||
existingValue.push(operation.value);
|
||||
} else if (existingValue === undefined) {
|
||||
newMetadata[operation.key] = [operation.value];
|
||||
} else {
|
||||
// Convert to array if not already
|
||||
newMetadata[operation.key] = [existingValue, operation.value];
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
if (operation.key.startsWith("$.")) {
|
||||
const path = new JSONHeroPath(operation.key);
|
||||
const currentValue = path.first(newMetadata);
|
||||
|
||||
if (Array.isArray(currentValue)) {
|
||||
// Remove the value from array using deep equality check
|
||||
const newArray = currentValue.filter((item) => !dequal(item, operation.value));
|
||||
path.set(newMetadata, newArray);
|
||||
} else {
|
||||
unappliedOperations.push(operation);
|
||||
}
|
||||
} else {
|
||||
// Remove matching values if the key points to an array
|
||||
const existingValue = newMetadata[operation.key];
|
||||
|
||||
if (Array.isArray(existingValue)) {
|
||||
newMetadata[operation.key] = existingValue.filter(
|
||||
(item) => !dequal(item, operation.value)
|
||||
);
|
||||
} else {
|
||||
unappliedOperations.push(operation);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "increment": {
|
||||
let currentValue = operation.key.startsWith("$.")
|
||||
? new JSONHeroPath(operation.key).first(newMetadata)
|
||||
: newMetadata[operation.key];
|
||||
|
||||
const newValue = (typeof currentValue === "number" ? currentValue : 0) + operation.value;
|
||||
|
||||
if (operation.key.startsWith("$.")) {
|
||||
new JSONHeroPath(operation.key).set(newMetadata, newValue);
|
||||
} else {
|
||||
newMetadata[operation.key] = newValue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "update": {
|
||||
// Update the metadata object with the new object
|
||||
newMetadata = operation.value;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Log unsupported operation type
|
||||
unappliedOperations.push(operation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { newMetadata, unappliedOperations };
|
||||
}
|
||||
@@ -1,22 +1,31 @@
|
||||
import { DeserializedJson } from "../../schemas/json.js";
|
||||
import { AsyncIterableStream } from "../apiClient/stream.js";
|
||||
import { ApiRequestOptions } from "../zodfetch.js";
|
||||
|
||||
export interface RunMetadataManager {
|
||||
// Instance Methods
|
||||
enterWithMetadata(metadata: Record<string, DeserializedJson>): void;
|
||||
current(): Record<string, DeserializedJson> | undefined;
|
||||
getKey(key: string): DeserializedJson | undefined;
|
||||
setKey(key: string, value: DeserializedJson): void;
|
||||
deleteKey(key: string): void;
|
||||
appendKey(key: string, value: DeserializedJson): void;
|
||||
removeFromKey(key: string, value: DeserializedJson): void;
|
||||
incrementKey(key: string, value: number): void;
|
||||
decrementKey(key: string, value: number): void;
|
||||
update(metadata: Record<string, DeserializedJson>): void;
|
||||
flush(requestOptions?: ApiRequestOptions): Promise<void>;
|
||||
export interface RunMetadataUpdater {
|
||||
set(key: string, value: DeserializedJson): this;
|
||||
del(key: string): this;
|
||||
append(key: string, value: DeserializedJson): this;
|
||||
remove(key: string, value: DeserializedJson): this;
|
||||
increment(key: string, value: number): this;
|
||||
decrement(key: string, value: number): this;
|
||||
update(metadata: Record<string, DeserializedJson>): this;
|
||||
stream<T>(
|
||||
key: string,
|
||||
value: AsyncIterable<T> | ReadableStream<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AsyncIterable<T>>;
|
||||
}
|
||||
|
||||
export interface RunMetadataManager extends RunMetadataUpdater {
|
||||
// Instance Methods
|
||||
enterWithMetadata(metadata: Record<string, DeserializedJson>): void;
|
||||
current(): Record<string, DeserializedJson> | undefined;
|
||||
getKey(key: string): DeserializedJson | undefined;
|
||||
flush(requestOptions?: ApiRequestOptions): Promise<void>;
|
||||
refresh(requestOptions?: ApiRequestOptions): Promise<void>;
|
||||
fetchStream<T>(key: string, signal?: AbortSignal): Promise<AsyncIterableStream<T>>;
|
||||
|
||||
get parent(): RunMetadataUpdater;
|
||||
get root(): RunMetadataUpdater;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runMetadata } from "../run-metadata-api.js";
|
||||
import {
|
||||
BatchTaskRunExecutionResult,
|
||||
TaskRunContext,
|
||||
@@ -41,6 +42,8 @@ export class DevRuntimeManager implements RuntimeManager {
|
||||
this._taskWaits.set(params.id, { resolve });
|
||||
});
|
||||
|
||||
await this.#tryFlushMetadata();
|
||||
|
||||
return await promise;
|
||||
}
|
||||
|
||||
@@ -71,6 +74,8 @@ export class DevRuntimeManager implements RuntimeManager {
|
||||
})
|
||||
);
|
||||
|
||||
await this.#tryFlushMetadata();
|
||||
|
||||
const results = await promise;
|
||||
|
||||
return {
|
||||
@@ -93,4 +98,10 @@ export class DevRuntimeManager implements RuntimeManager {
|
||||
|
||||
this._taskWaits.delete(runId);
|
||||
}
|
||||
|
||||
async #tryFlushMetadata() {
|
||||
try {
|
||||
await runMetadata.flush();
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { DeserializedJsonSchema } from "../../schemas/json.js";
|
||||
import { SerializedError, TaskRunError } from "./common.js";
|
||||
import {
|
||||
FlushedRunMetadata,
|
||||
RunMetadataChangeOperation,
|
||||
SerializedError,
|
||||
TaskRunError,
|
||||
} from "./common.js";
|
||||
import { BackgroundWorkerMetadata } from "./resources.js";
|
||||
import { QueueOptions } from "./schemas.js";
|
||||
|
||||
@@ -669,10 +674,7 @@ export const EnvironmentVariables = z.array(EnvironmentVariable);
|
||||
|
||||
export type EnvironmentVariables = z.infer<typeof EnvironmentVariables>;
|
||||
|
||||
export const UpdateMetadataRequestBody = z.object({
|
||||
metadata: z.record(DeserializedJsonSchema),
|
||||
metadataType: z.string().optional(),
|
||||
});
|
||||
export const UpdateMetadataRequestBody = FlushedRunMetadata;
|
||||
|
||||
export type UpdateMetadataRequestBody = z.infer<typeof UpdateMetadataRequestBody>;
|
||||
|
||||
|
||||
@@ -1,6 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import { DeserializedJsonSchema } from "../../schemas/json.js";
|
||||
|
||||
export const RunMetadataUpdateOperation = z.object({
|
||||
type: z.literal("update"),
|
||||
value: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type RunMetadataUpdateOperation = z.infer<typeof RunMetadataUpdateOperation>;
|
||||
|
||||
export const RunMetadataSetKeyOperation = z.object({
|
||||
type: z.literal("set"),
|
||||
key: z.string(),
|
||||
value: DeserializedJsonSchema,
|
||||
});
|
||||
|
||||
export type RunMetadataSetKeyOperation = z.infer<typeof RunMetadataSetKeyOperation>;
|
||||
|
||||
export const RunMetadataDeleteKeyOperation = z.object({
|
||||
type: z.literal("delete"),
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export type RunMetadataDeleteKeyOperation = z.infer<typeof RunMetadataDeleteKeyOperation>;
|
||||
|
||||
export const RunMetadataAppendKeyOperation = z.object({
|
||||
type: z.literal("append"),
|
||||
key: z.string(),
|
||||
value: DeserializedJsonSchema,
|
||||
});
|
||||
|
||||
export type RunMetadataAppendKeyOperation = z.infer<typeof RunMetadataAppendKeyOperation>;
|
||||
|
||||
export const RunMetadataRemoveFromKeyOperation = z.object({
|
||||
type: z.literal("remove"),
|
||||
key: z.string(),
|
||||
value: DeserializedJsonSchema,
|
||||
});
|
||||
|
||||
export type RunMetadataRemoveFromKeyOperation = z.infer<typeof RunMetadataRemoveFromKeyOperation>;
|
||||
|
||||
export const RunMetadataIncrementKeyOperation = z.object({
|
||||
type: z.literal("increment"),
|
||||
key: z.string(),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
export type RunMetadataIncrementKeyOperation = z.infer<typeof RunMetadataIncrementKeyOperation>;
|
||||
|
||||
export const RunMetadataChangeOperation = z.discriminatedUnion("type", [
|
||||
RunMetadataUpdateOperation,
|
||||
RunMetadataSetKeyOperation,
|
||||
RunMetadataDeleteKeyOperation,
|
||||
RunMetadataAppendKeyOperation,
|
||||
RunMetadataRemoveFromKeyOperation,
|
||||
RunMetadataIncrementKeyOperation,
|
||||
]);
|
||||
|
||||
export type RunMetadataChangeOperation = z.infer<typeof RunMetadataChangeOperation>;
|
||||
|
||||
export const FlushedRunMetadata = z.object({
|
||||
metadata: z.record(DeserializedJsonSchema).optional(),
|
||||
operations: z.array(RunMetadataChangeOperation).optional(),
|
||||
parentOperations: z.array(RunMetadataChangeOperation).optional(),
|
||||
rootOperations: z.array(RunMetadataChangeOperation).optional(),
|
||||
});
|
||||
|
||||
export type FlushedRunMetadata = z.infer<typeof FlushedRunMetadata>;
|
||||
|
||||
// Defaults to 0.5
|
||||
export const MachineCpu = z.union([
|
||||
z.literal(0.25),
|
||||
@@ -253,6 +319,7 @@ export const TaskRunFailedExecutionResult = z.object({
|
||||
usage: TaskRunExecutionUsage.optional(),
|
||||
// Optional for now for backwards compatibility
|
||||
taskIdentifier: z.string().optional(),
|
||||
metadata: FlushedRunMetadata.optional(),
|
||||
});
|
||||
|
||||
export type TaskRunFailedExecutionResult = z.infer<typeof TaskRunFailedExecutionResult>;
|
||||
@@ -265,6 +332,7 @@ export const TaskRunSuccessfulExecutionResult = z.object({
|
||||
usage: TaskRunExecutionUsage.optional(),
|
||||
// Optional for now for backwards compatibility
|
||||
taskIdentifier: z.string().optional(),
|
||||
metadata: FlushedRunMetadata.optional(),
|
||||
});
|
||||
|
||||
export type TaskRunSuccessfulExecutionResult = z.infer<typeof TaskRunSuccessfulExecutionResult>;
|
||||
|
||||
@@ -31,11 +31,7 @@ class TestStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
this.streams.set(`${runId}:${streamKey}`, chunks);
|
||||
}
|
||||
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string
|
||||
): StreamSubscription {
|
||||
createSubscription(runId: string, streamKey: string): StreamSubscription {
|
||||
const chunks = this.streams.get(`${runId}:${streamKey}`) ?? [];
|
||||
return new TestStreamSubscription(chunks);
|
||||
}
|
||||
@@ -285,13 +281,9 @@ describe("RunSubscription", () => {
|
||||
|
||||
// Override createSubscription to count calls
|
||||
const originalCreate = streamFactory.createSubscription.bind(streamFactory);
|
||||
streamFactory.createSubscription = (
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string
|
||||
) => {
|
||||
streamFactory.createSubscription = (runId: string, streamKey: string) => {
|
||||
streamCreationCount++;
|
||||
return originalCreate(metadata, runId, streamKey);
|
||||
return originalCreate(runId, streamKey);
|
||||
};
|
||||
|
||||
// Set up test chunks
|
||||
|
||||
@@ -2,22 +2,30 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createTestHttpServer } from "@epic-web/test-server/http";
|
||||
import { StandardMetadataManager } from "../src/v3/runMetadata/manager.js";
|
||||
import { ApiClient } from "../src/v3/apiClient/index.js";
|
||||
import { UpdateMetadataRequestBody } from "../src/v3/schemas/index.js";
|
||||
import { applyMetadataOperations } from "../src/v3/runMetadata/operations.js";
|
||||
|
||||
describe("StandardMetadataManager", () => {
|
||||
const runId = "test-run-id";
|
||||
let server: Awaited<ReturnType<typeof createTestHttpServer>>;
|
||||
let metadataUpdates: Array<Record<string, any>> = [];
|
||||
let metadataUpdates: Array<UpdateMetadataRequestBody> = [];
|
||||
let manager: StandardMetadataManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
metadataUpdates = [];
|
||||
const store = {};
|
||||
|
||||
server = await createTestHttpServer({
|
||||
defineRoutes(router) {
|
||||
router.put("/api/v1/runs/:runId/metadata", async ({ req }) => {
|
||||
const body = await req.json();
|
||||
metadataUpdates.push(body);
|
||||
return Response.json({ metadata: body.metadata });
|
||||
const parsedBody = UpdateMetadataRequestBody.parse(body);
|
||||
|
||||
metadataUpdates.push(parsedBody);
|
||||
|
||||
const { newMetadata } = applyMetadataOperations(store, parsedBody.operations ?? []);
|
||||
|
||||
return Response.json({ metadata: newMetadata });
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -37,13 +45,13 @@ describe("StandardMetadataManager", () => {
|
||||
});
|
||||
|
||||
test("should set and get simple keys", () => {
|
||||
manager.setKey("test", "value");
|
||||
manager.set("test", "value");
|
||||
expect(manager.getKey("test")).toBe("value");
|
||||
});
|
||||
|
||||
test("should handle JSON path keys", () => {
|
||||
manager.setKey("nested", { foo: "bar" });
|
||||
manager.setKey("$.nested.path", "value");
|
||||
manager.set("nested", { foo: "bar" });
|
||||
manager.set("$.nested.path", "value");
|
||||
expect(manager.current()).toEqual({
|
||||
nested: {
|
||||
foo: "bar",
|
||||
@@ -53,31 +61,26 @@ describe("StandardMetadataManager", () => {
|
||||
});
|
||||
|
||||
test("should flush changes to server", async () => {
|
||||
manager.setKey("test", "value");
|
||||
manager.set("test", "value");
|
||||
await manager.flush();
|
||||
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
expect(metadataUpdates[0]).toEqual({
|
||||
metadata: {
|
||||
test: "value",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should only flush to server when data has actually changed", async () => {
|
||||
// Initial set and flush
|
||||
manager.setKey("test", "value");
|
||||
manager.set("test", "value");
|
||||
await manager.flush();
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
|
||||
// Same value set again
|
||||
manager.setKey("test", "value");
|
||||
manager.set("test", "value");
|
||||
await manager.flush();
|
||||
// Should not trigger another update since value hasn't changed
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
|
||||
// Different value set
|
||||
manager.setKey("test", "new value");
|
||||
manager.set("test", "new value");
|
||||
await manager.flush();
|
||||
// Should trigger new update
|
||||
expect(metadataUpdates).toHaveLength(2);
|
||||
@@ -85,18 +88,18 @@ describe("StandardMetadataManager", () => {
|
||||
|
||||
test("should only flush to server when nested data has actually changed", async () => {
|
||||
// Initial nested object
|
||||
manager.setKey("nested", { foo: "bar" });
|
||||
manager.set("nested", { foo: "bar" });
|
||||
await manager.flush();
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
|
||||
// Same nested value
|
||||
manager.setKey("nested", { foo: "bar" });
|
||||
manager.set("nested", { foo: "bar" });
|
||||
await manager.flush();
|
||||
// Should not trigger another update
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
|
||||
// Different nested value
|
||||
manager.setKey("nested", { foo: "baz" });
|
||||
manager.set("nested", { foo: "baz" });
|
||||
await manager.flush();
|
||||
// Should trigger new update
|
||||
expect(metadataUpdates).toHaveLength(2);
|
||||
@@ -104,20 +107,20 @@ describe("StandardMetadataManager", () => {
|
||||
|
||||
test("should append to list with simple key", () => {
|
||||
// First append creates the array
|
||||
manager.appendKey("myList", "first");
|
||||
manager.append("myList", "first");
|
||||
expect(manager.getKey("myList")).toEqual(["first"]);
|
||||
|
||||
// Second append adds to existing array
|
||||
manager.appendKey("myList", "second");
|
||||
manager.append("myList", "second");
|
||||
expect(manager.getKey("myList")).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
test("should append to list with JSON path", () => {
|
||||
// First create nested structure
|
||||
manager.setKey("nested", { items: [] });
|
||||
manager.set("nested", { items: [] });
|
||||
|
||||
// Append to empty array
|
||||
manager.appendKey("$.nested.items", "first");
|
||||
manager.append("$.nested.items", "first");
|
||||
expect(manager.current()).toEqual({
|
||||
nested: {
|
||||
items: ["first"],
|
||||
@@ -125,7 +128,7 @@ describe("StandardMetadataManager", () => {
|
||||
});
|
||||
|
||||
// Append another item
|
||||
manager.appendKey("$.nested.items", "second");
|
||||
manager.append("$.nested.items", "second");
|
||||
expect(manager.current()).toEqual({
|
||||
nested: {
|
||||
items: ["first", "second"],
|
||||
@@ -135,19 +138,19 @@ describe("StandardMetadataManager", () => {
|
||||
|
||||
test("should convert non-array values to array when appending", () => {
|
||||
// Set initial non-array value
|
||||
manager.setKey("value", "initial");
|
||||
manager.set("value", "initial");
|
||||
|
||||
// Append should convert to array
|
||||
manager.appendKey("value", "second");
|
||||
manager.append("value", "second");
|
||||
expect(manager.getKey("value")).toEqual(["initial", "second"]);
|
||||
});
|
||||
|
||||
test("should convert non-array values to array when appending with JSON path", () => {
|
||||
// Set initial nested non-array value
|
||||
manager.setKey("nested", { value: "initial" });
|
||||
manager.set("nested", { value: "initial" });
|
||||
|
||||
// Append should convert to array
|
||||
manager.appendKey("$.nested.value", "second");
|
||||
manager.append("$.nested.value", "second");
|
||||
expect(manager.current()).toEqual({
|
||||
nested: {
|
||||
value: ["initial", "second"],
|
||||
@@ -156,35 +159,25 @@ describe("StandardMetadataManager", () => {
|
||||
});
|
||||
|
||||
test("should trigger server update when appending to list", async () => {
|
||||
manager.appendKey("myList", "first");
|
||||
manager.append("myList", "first");
|
||||
await manager.flush();
|
||||
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
expect(metadataUpdates[0]).toEqual({
|
||||
metadata: {
|
||||
myList: ["first"],
|
||||
},
|
||||
});
|
||||
|
||||
manager.appendKey("myList", "second");
|
||||
manager.append("myList", "second");
|
||||
await manager.flush();
|
||||
|
||||
expect(metadataUpdates).toHaveLength(2);
|
||||
expect(metadataUpdates[1]).toEqual({
|
||||
metadata: {
|
||||
myList: ["first", "second"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should not trigger server update when appending same value", async () => {
|
||||
manager.appendKey("myList", "first");
|
||||
manager.append("myList", "first");
|
||||
await manager.flush();
|
||||
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
|
||||
// Append same value
|
||||
manager.appendKey("myList", "first");
|
||||
manager.append("myList", "first");
|
||||
await manager.flush();
|
||||
|
||||
// Should still be only one update
|
||||
@@ -192,34 +185,34 @@ describe("StandardMetadataManager", () => {
|
||||
});
|
||||
|
||||
test("should increment and decrement keys", () => {
|
||||
manager.incrementKey("counter");
|
||||
manager.increment("counter");
|
||||
expect(manager.getKey("counter")).toBe(1);
|
||||
|
||||
manager.incrementKey("counter", 5);
|
||||
manager.increment("counter", 5);
|
||||
expect(manager.getKey("counter")).toBe(6);
|
||||
|
||||
manager.decrementKey("counter");
|
||||
manager.decrement("counter");
|
||||
expect(manager.getKey("counter")).toBe(5);
|
||||
|
||||
manager.decrementKey("counter", 3);
|
||||
manager.decrement("counter", 3);
|
||||
expect(manager.getKey("counter")).toBe(2);
|
||||
});
|
||||
|
||||
test("should remove value from array with simple key", () => {
|
||||
// Setup initial array
|
||||
manager.setKey("myList", ["first", "second", "third"]);
|
||||
manager.set("myList", ["first", "second", "third"]);
|
||||
|
||||
// Remove a value
|
||||
manager.removeFromKey("myList", "second");
|
||||
manager.remove("myList", "second");
|
||||
expect(manager.getKey("myList")).toEqual(["first", "third"]);
|
||||
});
|
||||
|
||||
test("should remove value from array with JSON path", () => {
|
||||
// Setup initial nested array
|
||||
manager.setKey("nested", { items: ["first", "second", "third"] });
|
||||
manager.set("nested", { items: ["first", "second", "third"] });
|
||||
|
||||
// Remove a value
|
||||
manager.removeFromKey("$.nested.items", "second");
|
||||
manager.remove("$.nested.items", "second");
|
||||
expect(manager.current()).toEqual({
|
||||
nested: {
|
||||
items: ["first", "third"],
|
||||
@@ -229,32 +222,32 @@ describe("StandardMetadataManager", () => {
|
||||
|
||||
test("should handle removing non-existent value", () => {
|
||||
// Setup initial array
|
||||
manager.setKey("myList", ["first", "second"]);
|
||||
manager.set("myList", ["first", "second"]);
|
||||
|
||||
// Try to remove non-existent value
|
||||
manager.removeFromKey("myList", "third");
|
||||
manager.remove("myList", "third");
|
||||
expect(manager.getKey("myList")).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
test("should handle removing from non-array values", () => {
|
||||
// Setup non-array value
|
||||
manager.setKey("value", "string");
|
||||
manager.set("value", "string");
|
||||
|
||||
// Try to remove from non-array
|
||||
manager.removeFromKey("value", "something");
|
||||
manager.remove("value", "something");
|
||||
expect(manager.getKey("value")).toBe("string");
|
||||
});
|
||||
|
||||
test("should remove object from array using deep equality", () => {
|
||||
// Setup array with objects
|
||||
manager.setKey("objects", [
|
||||
manager.set("objects", [
|
||||
{ id: 1, name: "first" },
|
||||
{ id: 2, name: "second" },
|
||||
{ id: 3, name: "third" },
|
||||
]);
|
||||
|
||||
// Remove object
|
||||
manager.removeFromKey("objects", { id: 2, name: "second" });
|
||||
manager.remove("objects", { id: 2, name: "second" });
|
||||
expect(manager.getKey("objects")).toEqual([
|
||||
{ id: 1, name: "first" },
|
||||
{ id: 3, name: "third" },
|
||||
@@ -263,30 +256,25 @@ describe("StandardMetadataManager", () => {
|
||||
|
||||
test("should trigger server update when removing from array", async () => {
|
||||
// Setup initial array
|
||||
manager.setKey("myList", ["first", "second", "third"]);
|
||||
manager.set("myList", ["first", "second", "third"]);
|
||||
await manager.flush();
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
|
||||
// Remove value
|
||||
manager.removeFromKey("myList", "second");
|
||||
manager.remove("myList", "second");
|
||||
await manager.flush();
|
||||
|
||||
expect(metadataUpdates).toHaveLength(2);
|
||||
expect(metadataUpdates[1]).toEqual({
|
||||
metadata: {
|
||||
myList: ["first", "third"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should not trigger server update when removing non-existent value", async () => {
|
||||
// Setup initial array
|
||||
manager.setKey("myList", ["first", "second"]);
|
||||
manager.set("myList", ["first", "second"]);
|
||||
await manager.flush();
|
||||
expect(metadataUpdates).toHaveLength(1);
|
||||
|
||||
// Try to remove non-existent value
|
||||
manager.removeFromKey("myList", "third");
|
||||
manager.remove("myList", "third");
|
||||
await manager.flush();
|
||||
|
||||
// Should not trigger new update since nothing changed
|
||||
|
||||
@@ -74,4 +74,4 @@
|
||||
"main": "./dist/commonjs/index.js",
|
||||
"types": "./dist/commonjs/index.d.ts",
|
||||
"module": "./dist/esm/index.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@ export type UseRealtimeSingleRunOptions<TTask extends AnyTask = AnyTask> = UseRe
|
||||
* @param {Error} [err] - The error that occurred
|
||||
*/
|
||||
onComplete?: (run: RealtimeRun<TTask>, err?: Error) => void;
|
||||
|
||||
/**
|
||||
* Whether to stop the subscription when the run completes
|
||||
*
|
||||
* @default true
|
||||
*
|
||||
* Set this to false if you are making updates to the run metadata after completion through child runs
|
||||
*/
|
||||
stopOnCompletion?: boolean;
|
||||
};
|
||||
|
||||
export type UseRealtimeRunInstance<TTask extends AnyTask = AnyTask> = {
|
||||
@@ -90,7 +99,13 @@ export function useRealtimeRun<TTask extends AnyTask>(
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
await processRealtimeRun(runId, apiClient, mutateRun, abortControllerRef);
|
||||
await processRealtimeRun(
|
||||
runId,
|
||||
apiClient,
|
||||
mutateRun,
|
||||
abortControllerRef,
|
||||
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore abort errors as they are expected.
|
||||
if ((err as any).name === "AbortError") {
|
||||
@@ -244,6 +259,7 @@ export function useRealtimeRunWithStreams<
|
||||
mutateStreams,
|
||||
streamsRef,
|
||||
abortControllerRef,
|
||||
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true,
|
||||
options?.experimental_throttleInMs
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -567,10 +583,12 @@ async function processRealtimeRunWithStreams<
|
||||
mutateStreamData: KeyedMutator<StreamResults<TStreams>>,
|
||||
existingDataRef: React.MutableRefObject<StreamResults<TStreams>>,
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
||||
stopOnCompletion: boolean = true,
|
||||
throttleInMs?: number
|
||||
) {
|
||||
const subscription = apiClient.subscribeToRun<InferRunTypes<TTask>>(runId, {
|
||||
signal: abortControllerRef.current?.signal,
|
||||
closeOnComplete: stopOnCompletion,
|
||||
});
|
||||
|
||||
type StreamUpdate = {
|
||||
@@ -619,10 +637,12 @@ async function processRealtimeRun<TTask extends AnyTask = AnyTask>(
|
||||
runId: string,
|
||||
apiClient: ApiClient,
|
||||
mutateRunData: KeyedMutator<RealtimeRun<TTask>>,
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
||||
stopOnCompletion: boolean = true
|
||||
) {
|
||||
const subscription = apiClient.subscribeToRun<InferRunTypes<TTask>>(runId, {
|
||||
signal: abortControllerRef.current?.signal,
|
||||
closeOnComplete: stopOnCompletion,
|
||||
});
|
||||
|
||||
for await (const part of subscription) {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { DeserializedJson } from "@trigger.dev/core";
|
||||
import {
|
||||
ApiRequestOptions,
|
||||
flattenAttributes,
|
||||
mergeRequestOptions,
|
||||
runMetadata,
|
||||
type RunMetadataUpdater,
|
||||
type AsyncIterableStream,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { tracer } from "./tracer.js";
|
||||
|
||||
const parentMetadataUpdater: RunMetadataUpdater = runMetadata.parent;
|
||||
const rootMetadataUpdater: RunMetadataUpdater = runMetadata.root;
|
||||
|
||||
/**
|
||||
* Provides access to run metadata operations.
|
||||
* @namespace
|
||||
@@ -17,19 +21,27 @@ import { tracer } from "./tracer.js";
|
||||
* @property {Function} save - Update the entire metadata object for the current run.
|
||||
*/
|
||||
|
||||
export const metadata = {
|
||||
current: currentMetadata,
|
||||
get: getMetadataKey,
|
||||
const metadataUpdater = {
|
||||
set: setMetadataKey,
|
||||
del: deleteMetadataKey,
|
||||
save: saveMetadata,
|
||||
replace: replaceMetadata,
|
||||
flush: flushMetadata,
|
||||
stream: stream,
|
||||
append: appendMetadataKey,
|
||||
remove: removeMetadataKey,
|
||||
increment: incrementMetadataKey,
|
||||
decrement: decrementMetadataKey,
|
||||
flush: flushMetadata,
|
||||
};
|
||||
|
||||
export const metadata = {
|
||||
current: currentMetadata,
|
||||
get: getMetadataKey,
|
||||
save: saveMetadata,
|
||||
replace: replaceMetadata,
|
||||
stream: stream,
|
||||
fetchStream: fetchStream,
|
||||
parent: parentMetadataUpdater,
|
||||
root: rootMetadataUpdater,
|
||||
refresh: refreshMetadata,
|
||||
...metadataUpdater,
|
||||
};
|
||||
|
||||
export type RunMetadata = Record<string, DeserializedJson>;
|
||||
@@ -74,7 +86,9 @@ function getMetadataKey(key: string): DeserializedJson | undefined {
|
||||
* metadata.set("progress", 0.5);
|
||||
*/
|
||||
function setMetadataKey(key: string, value: DeserializedJson) {
|
||||
runMetadata.setKey(key, value);
|
||||
runMetadata.set(key, value);
|
||||
|
||||
return metadataUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +100,8 @@ function setMetadataKey(key: string, value: DeserializedJson) {
|
||||
* metadata.del("progress");
|
||||
*/
|
||||
function deleteMetadataKey(key: string) {
|
||||
runMetadata.deleteKey(key);
|
||||
runMetadata.del(key);
|
||||
return metadataUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,14 +114,14 @@ function deleteMetadataKey(key: string) {
|
||||
* @example
|
||||
* metadata.replace({ progress: 0.6, user: { name: "Alice", id: "user_5678" } });
|
||||
*/
|
||||
function replaceMetadata(metadata: RunMetadata): void {
|
||||
function replaceMetadata(metadata: RunMetadata) {
|
||||
runMetadata.update(metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `metadata.replace()` instead.
|
||||
*/
|
||||
function saveMetadata(metadata: RunMetadata): void {
|
||||
function saveMetadata(metadata: RunMetadata) {
|
||||
runMetadata.update(metadata);
|
||||
}
|
||||
|
||||
@@ -122,7 +137,8 @@ function saveMetadata(metadata: RunMetadata): void {
|
||||
* metadata.increment("score", 10); // Increments score by 10
|
||||
*/
|
||||
function incrementMetadataKey(key: string, value: number = 1) {
|
||||
runMetadata.incrementKey(key, value);
|
||||
runMetadata.increment(key, value);
|
||||
return metadataUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,7 +153,8 @@ function incrementMetadataKey(key: string, value: number = 1) {
|
||||
* metadata.decrement("score", 5); // Decrements score by 5
|
||||
*/
|
||||
function decrementMetadataKey(key: string, value: number = 1) {
|
||||
runMetadata.decrementKey(key, value);
|
||||
runMetadata.decrement(key, value);
|
||||
return metadataUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +170,8 @@ function decrementMetadataKey(key: string, value: number = 1) {
|
||||
* metadata.append("events", { type: "click", timestamp: Date.now() });
|
||||
*/
|
||||
function appendMetadataKey(key: string, value: DeserializedJson) {
|
||||
runMetadata.appendKey(key, value);
|
||||
runMetadata.append(key, value);
|
||||
return metadataUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +186,8 @@ function appendMetadataKey(key: string, value: DeserializedJson) {
|
||||
* metadata.remove("events", { type: "click", timestamp: Date.now() });
|
||||
*/
|
||||
function removeMetadataKey(key: string, value: DeserializedJson) {
|
||||
runMetadata.removeFromKey(key, value);
|
||||
runMetadata.remove(key, value);
|
||||
return metadataUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +209,25 @@ async function flushMetadata(requestOptions?: ApiRequestOptions): Promise<void>
|
||||
await runMetadata.flush($requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes metadata from the Trigger.dev instance
|
||||
*
|
||||
* @param {ApiRequestOptions} [requestOptions] - Optional request options to customize the API request.
|
||||
* @returns {Promise<void>} A promise that resolves when the metadata refresh operation is complete.
|
||||
*/
|
||||
async function refreshMetadata(requestOptions?: ApiRequestOptions): Promise<void> {
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "metadata.refresh()",
|
||||
icon: "code-plus",
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
await runMetadata.refresh($requestOptions);
|
||||
}
|
||||
|
||||
async function stream<T>(
|
||||
key: string,
|
||||
value: AsyncIterable<T> | ReadableStream<T>,
|
||||
@@ -197,3 +235,7 @@ async function stream<T>(
|
||||
): Promise<AsyncIterable<T>> {
|
||||
return runMetadata.stream(key, value, signal);
|
||||
}
|
||||
|
||||
async function fetchStream<T>(key: string, signal?: AbortSignal): Promise<AsyncIterableStream<T>> {
|
||||
return runMetadata.fetchStream<T>(key, signal);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
RunSubscription,
|
||||
TaskRunShape,
|
||||
AnyBatchedRunHandle,
|
||||
AsyncIterableStream,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import {
|
||||
ApiPromise,
|
||||
@@ -51,6 +52,7 @@ export const runs = {
|
||||
subscribeToRun,
|
||||
subscribeToRunsWithTag,
|
||||
subscribeToBatch: subscribeToRunsInBatch,
|
||||
fetchStream,
|
||||
};
|
||||
|
||||
export type ListRunsItem = ListRunResponseItem;
|
||||
@@ -335,6 +337,17 @@ async function poll<TRunId extends AnyRunHandle | AnyTask | string>(
|
||||
);
|
||||
}
|
||||
|
||||
export type SubscribeToRunOptions = {
|
||||
/**
|
||||
* Whether to close the subscription when the run completes
|
||||
*
|
||||
* @default true
|
||||
*
|
||||
* Set this to false if you are making updates to the run metadata after completion through child runs
|
||||
*/
|
||||
stopOnCompletion?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes to real-time updates for a specific run.
|
||||
*
|
||||
@@ -345,6 +358,8 @@ async function poll<TRunId extends AnyRunHandle | AnyTask | string>(
|
||||
*
|
||||
* @template TRunId - The type parameter extending AnyRunHandle, AnyTask, or string
|
||||
* @param {RunId<TRunId>} runId - The ID of the run to subscribe to. Can be a string ID, RunHandle, or Task
|
||||
* @param {SubscribeToRunOptions} [options] - Optional configuration for the subscription
|
||||
* @param {boolean} [options.stopOnCompletion=true] - Whether to close the subscription when the run completes
|
||||
* @returns {RunSubscription<InferRunTypes<TRunId>>} An async iterator that yields updated run objects
|
||||
*
|
||||
* @example
|
||||
@@ -365,13 +380,17 @@ async function poll<TRunId extends AnyRunHandle | AnyTask | string>(
|
||||
* ```
|
||||
*/
|
||||
function subscribeToRun<TRunId extends AnyRunHandle | AnyTask | string>(
|
||||
runId: RunId<TRunId>
|
||||
runId: RunId<TRunId>,
|
||||
options?: SubscribeToRunOptions
|
||||
): RunSubscription<InferRunTypes<TRunId>> {
|
||||
const $runId = typeof runId === "string" ? runId : runId.id;
|
||||
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
return apiClient.subscribeToRun($runId);
|
||||
return apiClient.subscribeToRun($runId, {
|
||||
closeOnComplete:
|
||||
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -448,3 +467,12 @@ function subscribeToRunsInBatch<TTasks extends AnyTask>(
|
||||
|
||||
return apiClient.subscribeToBatch<InferRunTypes<TTasks>>(batchId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a stream of data from a run's stream key.
|
||||
*/
|
||||
async function fetchStream<T>(runId: string, streamKey: string): Promise<AsyncIterableStream<T>> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
return await apiClient.fetchStream(runId, streamKey);
|
||||
}
|
||||
|
||||
Generated
+121
-7
@@ -447,6 +447,9 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.4.5
|
||||
effect:
|
||||
specifier: ^3.11.7
|
||||
version: 3.11.7
|
||||
emails:
|
||||
specifier: workspace:*
|
||||
version: link:../../internal-packages/emails
|
||||
@@ -1616,12 +1619,18 @@ importers:
|
||||
'@fal-ai/serverless-client':
|
||||
specifier: ^0.15.0
|
||||
version: 0.15.0
|
||||
'@fast-csv/parse':
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1)
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(react@18.3.1)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1)
|
||||
@@ -1712,7 +1721,7 @@ importers:
|
||||
dependencies:
|
||||
'@effect/schema':
|
||||
specifier: ^0.75.5
|
||||
version: 0.75.5(effect@3.9.2)
|
||||
version: 0.75.5(effect@3.11.7)
|
||||
'@infisical/sdk':
|
||||
specifier: ^2.1.9
|
||||
version: 2.3.5
|
||||
@@ -5585,12 +5594,12 @@ packages:
|
||||
fast-check: 3.22.0
|
||||
dev: false
|
||||
|
||||
/@effect/schema@0.75.5(effect@3.9.2):
|
||||
/@effect/schema@0.75.5(effect@3.11.7):
|
||||
resolution: {integrity: sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw==}
|
||||
peerDependencies:
|
||||
effect: ^3.9.2
|
||||
dependencies:
|
||||
effect: 3.9.2
|
||||
effect: 3.11.7
|
||||
fast-check: 3.22.0
|
||||
dev: false
|
||||
|
||||
@@ -7129,6 +7138,17 @@ packages:
|
||||
robot3: 0.4.1
|
||||
dev: false
|
||||
|
||||
/@fast-csv/parse@5.0.2:
|
||||
resolution: {integrity: sha512-gMu1Btmm99TP+wc0tZnlH30E/F1Gw1Tah3oMDBHNPe9W8S68ixVHjt89Wg5lh7d9RuQMtwN+sGl5kxR891+fzw==}
|
||||
dependencies:
|
||||
lodash.escaperegexp: 4.1.2
|
||||
lodash.groupby: 4.6.0
|
||||
lodash.isfunction: 3.0.9
|
||||
lodash.isnil: 4.0.0
|
||||
lodash.isundefined: 3.0.1
|
||||
lodash.uniq: 4.5.0
|
||||
dev: false
|
||||
|
||||
/@fastify/busboy@2.0.0:
|
||||
resolution: {integrity: sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -9608,6 +9628,19 @@ packages:
|
||||
react: 18.3.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-compose-refs@1.1.1(@types/react@18.3.1)(react@18.3.1):
|
||||
resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.3.1
|
||||
react: 18.3.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-context@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
|
||||
peerDependencies:
|
||||
@@ -10500,6 +10533,47 @@ packages:
|
||||
react-dom: 18.2.0(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-primitive@2.0.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1):
|
||||
resolution: {integrity: sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.1.1(@types/react@18.3.1)(react@18.3.1)
|
||||
'@types/react': 18.3.1
|
||||
'@types/react-dom': 18.2.7
|
||||
react: 18.3.1
|
||||
react-dom: 18.2.0(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-progress@1.1.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1):
|
||||
resolution: {integrity: sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.1(@types/react@18.3.1)(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1)
|
||||
'@types/react': 18.3.1
|
||||
'@types/react-dom': 18.2.7
|
||||
react: 18.3.1
|
||||
react-dom: 18.2.0(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==}
|
||||
peerDependencies:
|
||||
@@ -10800,6 +10874,20 @@ packages:
|
||||
react: 18.3.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-slot@1.1.1(@types/react@18.3.1)(react@18.3.1):
|
||||
resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.1)(react@18.3.1)
|
||||
'@types/react': 18.3.1
|
||||
react: 18.3.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==}
|
||||
peerDependencies:
|
||||
@@ -20374,12 +20462,14 @@ packages:
|
||||
/ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
/effect@3.7.2:
|
||||
resolution: {integrity: sha512-pV7l1+LSZFvVObj4zuy4nYiBaC7qZOfrKV6s/Ef4p3KueiQwZFgamazklwyZ+x7Nyj2etRDFvHE/xkThTfQD1w==}
|
||||
/effect@3.11.7:
|
||||
resolution: {integrity: sha512-laj+TCxWGn0eOv6jNmS9vavMO01Z4vvRr7v5airaOUfE7Zr5PrHiECpiI5HRvOewxa1im/4EcOvRodOZ1S2Y7Q==}
|
||||
dependencies:
|
||||
fast-check: 3.22.0
|
||||
dev: false
|
||||
|
||||
/effect@3.9.2:
|
||||
resolution: {integrity: sha512-1sx/v1HTWHTodXfzWxAFg+SCF+ACgpJVruaAMIh/NmDVvrUsf0x9PzpXvkgJUbQ1fMdmKYK//FqxeHSQ+Zxv/Q==}
|
||||
/effect@3.7.2:
|
||||
resolution: {integrity: sha512-pV7l1+LSZFvVObj4zuy4nYiBaC7qZOfrKV6s/Ef4p3KueiQwZFgamazklwyZ+x7Nyj2etRDFvHE/xkThTfQD1w==}
|
||||
dev: false
|
||||
|
||||
/electron-to-chromium@1.4.433:
|
||||
@@ -24035,10 +24125,18 @@ packages:
|
||||
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
|
||||
dev: true
|
||||
|
||||
/lodash.escaperegexp@4.1.2:
|
||||
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||
dev: false
|
||||
|
||||
/lodash.flatten@4.4.0:
|
||||
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
|
||||
dev: true
|
||||
|
||||
/lodash.groupby@4.6.0:
|
||||
resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==}
|
||||
dev: false
|
||||
|
||||
/lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
dev: false
|
||||
@@ -24047,10 +24145,22 @@ packages:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
dev: false
|
||||
|
||||
/lodash.isfunction@3.0.9:
|
||||
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
|
||||
dev: false
|
||||
|
||||
/lodash.isnil@4.0.0:
|
||||
resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==}
|
||||
dev: false
|
||||
|
||||
/lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
dev: true
|
||||
|
||||
/lodash.isundefined@3.0.1:
|
||||
resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==}
|
||||
dev: false
|
||||
|
||||
/lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -24066,6 +24176,10 @@ packages:
|
||||
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
|
||||
dev: true
|
||||
|
||||
/lodash.uniq@4.5.0:
|
||||
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
|
||||
dev: false
|
||||
|
||||
/lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.0.1",
|
||||
"@fal-ai/serverless-client": "^0.15.0",
|
||||
"@fast-csv/parse": "^5.0.2",
|
||||
"@radix-ui/react-dialog": "^1.0.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "crypto";
|
||||
import { createUploadthing, type FileRouter } from "uploadthing/next";
|
||||
import { UploadThingError } from "uploadthing/server";
|
||||
import type { handleUpload } from "@/trigger/images";
|
||||
import type { handleCSVUpload } from "@/trigger/csv";
|
||||
import { auth, tasks } from "@trigger.dev/sdk/v3";
|
||||
|
||||
const f = createUploadthing();
|
||||
@@ -46,6 +47,13 @@ export const ourFileRouter = {
|
||||
// !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback
|
||||
return { uploadedBy: metadata.userId, publicAccessToken, fileId: file.key };
|
||||
}),
|
||||
csvUploader: f({ blob: { maxFileSize: "4MB" } }).onUploadComplete(async ({ metadata, file }) => {
|
||||
console.log("file", file);
|
||||
|
||||
const handle = await tasks.trigger<typeof handleCSVUpload>("handle-csv-upload", file);
|
||||
|
||||
return handle;
|
||||
}),
|
||||
} satisfies FileRouter;
|
||||
|
||||
export type OurFileRouter = typeof ourFileRouter;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { handleCSVUpload } from "@/trigger/csv";
|
||||
import { CSVUploadMetadataSchema } from "@/trigger/schemas";
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
import { Terminal } from "lucide-react";
|
||||
|
||||
type UseCSVUploadInstance = {
|
||||
status: "loading" | "queued" | "fetching" | "parsing" | "processing" | "complete" | "error";
|
||||
filename?: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
totalRows?: number;
|
||||
inProgressRows?: number;
|
||||
processedRows?: number;
|
||||
};
|
||||
|
||||
function useCSVUpload(runId: string, accessToken: string): UseCSVUploadInstance {
|
||||
const instance = useRealtimeRun<typeof handleCSVUpload>(runId, {
|
||||
accessToken,
|
||||
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
|
||||
onComplete: (run) => {
|
||||
console.log("CSV Upload complete", run);
|
||||
},
|
||||
stopOnCompletion: false,
|
||||
});
|
||||
|
||||
if (!instance.run) {
|
||||
return { status: "loading", progress: 0, message: "Loading..." };
|
||||
}
|
||||
|
||||
console.log("CSV Upload", instance.run);
|
||||
|
||||
if (!instance.run.metadata) {
|
||||
return {
|
||||
status: "queued",
|
||||
progress: 0.05,
|
||||
message: "Queued...",
|
||||
filename: instance.run.payload.name,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedMetadata = CSVUploadMetadataSchema.safeParse(instance.run.metadata);
|
||||
|
||||
if (!parsedMetadata.success) {
|
||||
return {
|
||||
status: "error",
|
||||
progress: 0,
|
||||
message: "Failed to parse metadata",
|
||||
filename: instance.run.payload.name,
|
||||
};
|
||||
}
|
||||
|
||||
switch (parsedMetadata.data.status) {
|
||||
case "fetching": {
|
||||
return {
|
||||
status: "fetching",
|
||||
progress: 0.1,
|
||||
message: "Fetching CSV file...",
|
||||
filename: instance.run.payload.name,
|
||||
};
|
||||
}
|
||||
case "parsing": {
|
||||
return {
|
||||
status: "parsing",
|
||||
progress: 0.2,
|
||||
message: "Parsing CSV file...",
|
||||
filename: instance.run.payload.name,
|
||||
};
|
||||
}
|
||||
case "processing": {
|
||||
// progress will be some number between 0.3 and 0.95
|
||||
// depending on the totalRows and processedRows
|
||||
|
||||
const progress =
|
||||
typeof parsedMetadata.data.processedRows === "number" &&
|
||||
typeof parsedMetadata.data.totalRows === "number"
|
||||
? 0.3 + (parsedMetadata.data.processedRows / parsedMetadata.data.totalRows) * 0.65
|
||||
: 0.3;
|
||||
|
||||
return {
|
||||
status: "processing",
|
||||
progress: progress,
|
||||
message: "Processing CSV file...",
|
||||
totalRows: parsedMetadata.data.totalRows,
|
||||
inProgressRows: parsedMetadata.data.inProgressRows,
|
||||
processedRows: parsedMetadata.data.processedRows,
|
||||
filename: instance.run.payload.name,
|
||||
};
|
||||
}
|
||||
case "complete": {
|
||||
return {
|
||||
status: "complete",
|
||||
progress: 1,
|
||||
message: "CSV processing complete",
|
||||
totalRows: parsedMetadata.data.totalRows,
|
||||
inProgressRows: parsedMetadata.data.inProgressRows,
|
||||
processedRows: parsedMetadata.data.processedRows,
|
||||
filename: instance.run.payload.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function RealtimeCSVRun({
|
||||
runId,
|
||||
accessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
accessToken: string;
|
||||
}) {
|
||||
const csvRun = useCSVUpload(runId, accessToken);
|
||||
|
||||
const progress = Math.round(csvRun.progress * 100);
|
||||
const isComplete = csvRun.status === "complete";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground p-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Terminal className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">CSV Email Validation</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>{csvRun.filename ?? "n/a"}</CardTitle>
|
||||
<CardDescription>
|
||||
{csvRun.totalRows ? `Processing ${csvRun.totalRows} rows` : "Processing CSV file"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className={isComplete ? "bg-green-500/10" : "bg-primary/10"}>
|
||||
{isComplete ? "Completed" : "Running"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Overall Progress</span>
|
||||
<span className="font-mono">{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{typeof csvRun.processedRows === "number" ? csvRun.processedRows : "N/A"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Emails Processed</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">
|
||||
{typeof csvRun.totalRows === "number"
|
||||
? csvRun.totalRows - (csvRun.processedRows ?? 0)
|
||||
: "N/A"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Remaining</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import RealtimeCSVRun from "./RealtimeCSVRun";
|
||||
|
||||
export default function CSVProcessor({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { id: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
if (typeof searchParams.publicAccessToken !== "string") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <RealtimeCSVRun runId={params.id} accessToken={searchParams.publicAccessToken} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CSVUploadDropzone } from "@/components/ImageUploadButton";
|
||||
|
||||
export default async function CSVPage() {
|
||||
return (
|
||||
<main className="grid grid-rows-[1fr_auto] min-h-screen items-center justify-center w-full bg-gray-900">
|
||||
<div className="flex flex-col space-y-8">
|
||||
<h1 className="text-gray-200 text-4xl max-w-xl text-center font-bold">
|
||||
Trigger.dev Realtime + UploadThing + CSV Import
|
||||
</h1>
|
||||
<CSVUploadDropzone />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -51,3 +51,28 @@ export function ImageUploadDropzone() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CSVUploadDropzone() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<UploadDropzone
|
||||
endpoint="csvUploader"
|
||||
onClientUploadComplete={(res) => {
|
||||
// Do something with the response
|
||||
console.log("Files: ", res);
|
||||
|
||||
const firstFile = res[0];
|
||||
|
||||
router.push(
|
||||
`/csv/${firstFile.serverData.id}?publicAccessToken=${firstFile.serverData.publicAccessToken}`
|
||||
);
|
||||
}}
|
||||
onUploadError={(error: Error) => {
|
||||
// Do something with the error.
|
||||
console.error(`ERROR! ${error.message}`);
|
||||
}}
|
||||
className="border-gray-600"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,106 @@
|
||||
import { UploadedFileData } from "@/utils/schemas";
|
||||
import { parse } from "@fast-csv/parse";
|
||||
import { batch, logger, metadata, schemaTask } from "@trigger.dev/sdk/v3";
|
||||
import { setTimeout } from "timers/promises";
|
||||
import { CSVRow } from "./schemas";
|
||||
|
||||
export const handleCSVUpload = schemaTask({
|
||||
id: "handle-csv-upload",
|
||||
schema: UploadedFileData,
|
||||
run: async (file, { ctx }) => {
|
||||
logger.info("Handling uploaded file", { file });
|
||||
|
||||
metadata.set("status", "fetching");
|
||||
|
||||
const response = await fetch(file.url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
metadata.set("status", "parsing");
|
||||
|
||||
const rows = await new Promise<Array<CSVRow>>((resolve, reject) => {
|
||||
const rows: Array<CSVRow> = [];
|
||||
|
||||
const parser = parse({ headers: true });
|
||||
|
||||
parser.on("data", (row) => {
|
||||
logger.info("Row", { row });
|
||||
|
||||
const parsedRow = CSVRow.safeParse(row);
|
||||
|
||||
if (parsedRow.success) {
|
||||
rows.push(parsedRow.data);
|
||||
} else {
|
||||
logger.warn("Failed to parse row", { row, errors: parsedRow.error });
|
||||
}
|
||||
});
|
||||
|
||||
parser.on("end", () => {
|
||||
logger.info("CSV parsing complete");
|
||||
|
||||
resolve(rows);
|
||||
});
|
||||
|
||||
parser.on("error", reject);
|
||||
|
||||
parser.write(body);
|
||||
parser.end();
|
||||
});
|
||||
|
||||
metadata.set("status", "processing").set("totalRows", rows.length);
|
||||
|
||||
const results = await batch.triggerAndWait<typeof handleCSVRow>(
|
||||
rows.map((row) => ({ id: "handle-csv-row", payload: row }))
|
||||
);
|
||||
|
||||
metadata.set("status", "complete");
|
||||
|
||||
const successfulRows = results.runs.filter((r) => r.ok);
|
||||
const failedRows = results.runs.filter((r) => !r.ok);
|
||||
|
||||
const firstSuccessfulRow = successfulRows[0];
|
||||
|
||||
if (firstSuccessfulRow) {
|
||||
const stream = await metadata.fetchStream<string>(firstSuccessfulRow.id);
|
||||
|
||||
for await (const value of stream) {
|
||||
logger.info(`Stream value from ${firstSuccessfulRow.id}`, { value });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file,
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
successCount: successfulRows.length,
|
||||
failedCount: failedRows.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const handleCSVRow = schemaTask({
|
||||
id: "handle-csv-row",
|
||||
schema: CSVRow,
|
||||
run: async (row, { ctx }) => {
|
||||
logger.info("Handling CSV row", { row });
|
||||
|
||||
// Simulate processing time
|
||||
await setTimeout(200 + Math.random() * 1000); // 200ms - 1.2s
|
||||
|
||||
metadata.parent.increment("processedRows", 1).append("rowRuns", ctx.run.id);
|
||||
|
||||
await metadata.parent.stream(
|
||||
ctx.run.id,
|
||||
(async function* () {
|
||||
yield "hello";
|
||||
yield "world";
|
||||
})()
|
||||
);
|
||||
|
||||
return row;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CSVRow = z.object({
|
||||
Date: z.string(),
|
||||
Impressions: z.coerce.number(),
|
||||
Likes: z.coerce.number(),
|
||||
Engagements: z.coerce.number(),
|
||||
Bookmarks: z.coerce.number(),
|
||||
Shares: z.coerce.number(),
|
||||
"New follows": z.coerce.number(),
|
||||
Unfollows: z.coerce.number(),
|
||||
Replies: z.coerce.number(),
|
||||
Reposts: z.coerce.number(),
|
||||
"Profile visits": z.coerce.number(),
|
||||
"Create Post": z.coerce.number(),
|
||||
"Video views": z.coerce.number(),
|
||||
"Media views": z.coerce.number(),
|
||||
});
|
||||
|
||||
export type CSVRow = z.infer<typeof CSVRow>;
|
||||
|
||||
// Status schema for progress updates
|
||||
export const CSVStatus = z.enum(["fetching", "parsing", "processing", "complete"]);
|
||||
|
||||
export type CSVStatus = z.infer<typeof CSVStatus>;
|
||||
|
||||
// The full metadata schema that encompasses all possible metadata fields
|
||||
export const CSVUploadMetadataSchema = z.object({
|
||||
status: CSVStatus,
|
||||
totalRows: z.number().int().nonnegative().optional(),
|
||||
inProgressRows: z.number().int().nonnegative().optional(),
|
||||
processedRows: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export type CSVUploadMetadata = z.infer<typeof CSVUploadMetadataSchema>;
|
||||
@@ -3,6 +3,8 @@ import { logger, task, metadata, AbortTaskRunError } from "@trigger.dev/sdk/v3";
|
||||
export const runMetadataTask = task({
|
||||
id: "run-metadata-task",
|
||||
run: async (payload: any) => {
|
||||
metadata.set("numberOfChildren", 2);
|
||||
|
||||
await runMetadataChildTask.triggerAndWait(payload, {
|
||||
metadata: {
|
||||
hello: "world",
|
||||
@@ -19,6 +21,9 @@ export const runMetadataTask = task({
|
||||
export const runMetadataChildTask = task({
|
||||
id: "run-metadata-child-task",
|
||||
run: async (payload: any, { ctx }) => {
|
||||
metadata.parent.increment("numberOfChildren", 1);
|
||||
metadata.root.increment("numberOfChildren", 1);
|
||||
|
||||
logger.info("metadata", { metadata: metadata.current() });
|
||||
|
||||
metadata.set("child", "task");
|
||||
@@ -58,7 +63,7 @@ export const runMetadataChildTask = task({
|
||||
export const runMetadataChildTask2 = task({
|
||||
id: "run-metadata-child-task-2",
|
||||
run: async (payload: any, { ctx }) => {
|
||||
throw new AbortTaskRunError("aborting");
|
||||
metadata.root.increment("numberOfChildren", 1);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user