feat: migrate task jobs to workflow notifications + timeline
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import type { Task, TaskStatus, TaskType } from '@/lib/types';
|
||||
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import type { Task, TaskStage, TaskStageEvent, TaskStatus, TaskType } from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { taskRun } from '@/lib/server/db/schema';
|
||||
import { taskRun, taskStageEvent } from '@/lib/server/db/schema';
|
||||
|
||||
type TaskRow = typeof taskRun.$inferSelect;
|
||||
type TaskStageEventRow = typeof taskStageEvent.$inferSelect;
|
||||
|
||||
type CreateTaskInput = {
|
||||
id: string;
|
||||
@@ -12,14 +13,36 @@ type CreateTaskInput = {
|
||||
payload: Record<string, unknown>;
|
||||
priority: number;
|
||||
max_attempts: number;
|
||||
resource_key?: string | null;
|
||||
};
|
||||
|
||||
type UpdateTaskNotificationStateInput = {
|
||||
read?: boolean;
|
||||
silenced?: boolean;
|
||||
};
|
||||
|
||||
type EventInsertInput = {
|
||||
task_id: string;
|
||||
user_id: string;
|
||||
stage: TaskStage;
|
||||
stage_detail: string | null;
|
||||
status: TaskStatus;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type InsertExecutor = Pick<typeof db, 'insert'>;
|
||||
|
||||
function toTask(row: TaskRow): Task {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
task_type: row.task_type,
|
||||
status: row.status,
|
||||
stage: row.stage as TaskStage,
|
||||
stage_detail: row.stage_detail,
|
||||
resource_key: row.resource_key,
|
||||
notification_read_at: row.notification_read_at,
|
||||
notification_silenced_at: row.notification_silenced_at,
|
||||
priority: row.priority,
|
||||
payload: row.payload,
|
||||
result: row.result,
|
||||
@@ -33,30 +56,84 @@ function toTask(row: TaskRow): Task {
|
||||
};
|
||||
}
|
||||
|
||||
function toTaskStageEvent(row: TaskStageEventRow): TaskStageEvent {
|
||||
return {
|
||||
id: row.id,
|
||||
task_id: row.task_id,
|
||||
user_id: row.user_id,
|
||||
stage: row.stage as TaskStage,
|
||||
stage_detail: row.stage_detail,
|
||||
status: row.status as TaskStatus,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
function statusToStage(status: TaskStatus): TaskStage {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'queued';
|
||||
case 'running':
|
||||
return 'running';
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'failed':
|
||||
return 'failed';
|
||||
default:
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function insertTaskStageEvent(executor: InsertExecutor, input: EventInsertInput) {
|
||||
await executor.insert(taskStageEvent).values({
|
||||
task_id: input.task_id,
|
||||
user_id: input.user_id,
|
||||
stage: input.stage,
|
||||
stage_detail: input.stage_detail,
|
||||
status: input.status,
|
||||
created_at: input.created_at
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTaskRunRecord(input: CreateTaskInput) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const [row] = await db
|
||||
.insert(taskRun)
|
||||
.values({
|
||||
id: input.id,
|
||||
user_id: input.user_id,
|
||||
task_type: input.task_type,
|
||||
status: 'queued',
|
||||
priority: input.priority,
|
||||
payload: input.payload,
|
||||
result: null,
|
||||
error: null,
|
||||
attempts: 0,
|
||||
max_attempts: input.max_attempts,
|
||||
workflow_run_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
finished_at: null
|
||||
})
|
||||
.returning();
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(taskRun)
|
||||
.values({
|
||||
id: input.id,
|
||||
user_id: input.user_id,
|
||||
task_type: input.task_type,
|
||||
status: 'queued',
|
||||
stage: 'queued',
|
||||
stage_detail: null,
|
||||
resource_key: input.resource_key ?? null,
|
||||
notification_read_at: null,
|
||||
notification_silenced_at: null,
|
||||
priority: input.priority,
|
||||
payload: input.payload,
|
||||
result: null,
|
||||
error: null,
|
||||
attempts: 0,
|
||||
max_attempts: input.max_attempts,
|
||||
workflow_run_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
finished_at: null
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toTask(row);
|
||||
await insertTaskStageEvent(tx, {
|
||||
task_id: row.id,
|
||||
user_id: row.user_id,
|
||||
stage: row.stage as TaskStage,
|
||||
stage_detail: row.stage_detail,
|
||||
status: row.status,
|
||||
created_at: now
|
||||
});
|
||||
|
||||
return toTask(row);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTaskWorkflowRunId(taskId: string, workflowRunId: string) {
|
||||
@@ -121,67 +198,268 @@ export async function countTasksByStatus() {
|
||||
return queue;
|
||||
}
|
||||
|
||||
export async function claimQueuedTask(taskId: string) {
|
||||
export async function findInFlightTaskByResourceKey(
|
||||
userId: string,
|
||||
taskType: TaskType,
|
||||
resourceKey: string
|
||||
) {
|
||||
const [row] = await db
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: 'running',
|
||||
attempts: sql`${taskRun.attempts} + 1`,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(taskRun.id, taskId), eq(taskRun.status, 'queued')))
|
||||
.returning();
|
||||
.select()
|
||||
.from(taskRun)
|
||||
.where(and(
|
||||
eq(taskRun.user_id, userId),
|
||||
eq(taskRun.task_type, taskType),
|
||||
eq(taskRun.resource_key, resourceKey),
|
||||
inArray(taskRun.status, ['queued', 'running'])
|
||||
))
|
||||
.orderBy(desc(taskRun.created_at))
|
||||
.limit(1);
|
||||
|
||||
return row ? toTask(row) : null;
|
||||
}
|
||||
|
||||
export async function markTaskRunning(taskId: string) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: 'running',
|
||||
stage: 'running',
|
||||
stage_detail: 'Workflow task is now running',
|
||||
attempts: sql`${taskRun.attempts} + 1`,
|
||||
updated_at: now,
|
||||
finished_at: null
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await insertTaskStageEvent(tx, {
|
||||
task_id: row.id,
|
||||
user_id: row.user_id,
|
||||
stage: row.stage as TaskStage,
|
||||
stage_detail: row.stage_detail,
|
||||
status: row.status,
|
||||
created_at: now
|
||||
});
|
||||
|
||||
return toTask(row);
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTaskStage(taskId: string, stage: TaskStage, detail: string | null = null) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(taskRun)
|
||||
.set({
|
||||
stage,
|
||||
stage_detail: detail,
|
||||
updated_at: now
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await insertTaskStageEvent(tx, {
|
||||
task_id: row.id,
|
||||
user_id: row.user_id,
|
||||
stage: row.stage as TaskStage,
|
||||
stage_detail: row.stage_detail,
|
||||
status: row.status,
|
||||
created_at: now
|
||||
});
|
||||
|
||||
return toTask(row);
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeTask(taskId: string, result: Record<string, unknown>) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: 'completed',
|
||||
stage: 'completed',
|
||||
stage_detail: null,
|
||||
result,
|
||||
error: null,
|
||||
updated_at: now,
|
||||
finished_at: now
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await insertTaskStageEvent(tx, {
|
||||
task_id: row.id,
|
||||
user_id: row.user_id,
|
||||
stage: row.stage as TaskStage,
|
||||
stage_detail: row.stage_detail,
|
||||
status: row.status,
|
||||
created_at: now
|
||||
});
|
||||
|
||||
return toTask(row);
|
||||
});
|
||||
}
|
||||
|
||||
export async function markTaskFailure(taskId: string, reason: string, stage: TaskStage = 'failed') {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: 'failed',
|
||||
stage,
|
||||
stage_detail: null,
|
||||
error: reason,
|
||||
updated_at: now,
|
||||
finished_at: now
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await insertTaskStageEvent(tx, {
|
||||
task_id: row.id,
|
||||
user_id: row.user_id,
|
||||
stage: row.stage as TaskStage,
|
||||
stage_detail: row.stage_detail,
|
||||
status: row.status,
|
||||
created_at: now
|
||||
});
|
||||
|
||||
return toTask(row);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTaskStatusFromWorkflow(
|
||||
taskId: string,
|
||||
status: TaskStatus,
|
||||
error?: string | null
|
||||
) {
|
||||
const isTerminal = status === 'completed' || status === 'failed';
|
||||
const nextStage = statusToStage(status);
|
||||
const nextError = status === 'failed' ? (error ?? 'Workflow run failed') : null;
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(taskRun)
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.limit(1);
|
||||
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasNoStateChange = current.status === status
|
||||
&& current.stage === nextStage
|
||||
&& (current.error ?? null) === nextError
|
||||
&& current.stage_detail === null
|
||||
&& (isTerminal ? current.finished_at !== null : current.finished_at === null);
|
||||
|
||||
if (hasNoStateChange) {
|
||||
return toTask(current);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const [row] = await tx
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status,
|
||||
stage: nextStage,
|
||||
stage_detail: null,
|
||||
error: nextError,
|
||||
updated_at: now,
|
||||
finished_at: isTerminal ? now : null
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await insertTaskStageEvent(tx, {
|
||||
task_id: row.id,
|
||||
user_id: row.user_id,
|
||||
stage: row.stage as TaskStage,
|
||||
stage_detail: row.stage_detail,
|
||||
status: row.status,
|
||||
created_at: now
|
||||
});
|
||||
|
||||
return toTask(row);
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTaskNotificationState(
|
||||
taskId: string,
|
||||
userId: string,
|
||||
input: UpdateTaskNotificationStateInput
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const patch: Partial<typeof taskRun.$inferInsert> = {
|
||||
updated_at: now
|
||||
};
|
||||
|
||||
let hasMutation = false;
|
||||
|
||||
if (typeof input.read === 'boolean') {
|
||||
patch.notification_read_at = input.read ? now : null;
|
||||
hasMutation = true;
|
||||
}
|
||||
|
||||
if (typeof input.silenced === 'boolean') {
|
||||
patch.notification_silenced_at = input.silenced ? now : null;
|
||||
hasMutation = true;
|
||||
|
||||
if (input.silenced) {
|
||||
patch.notification_read_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMutation) {
|
||||
return await getTaskByIdForUser(taskId, userId);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: 'completed',
|
||||
result,
|
||||
error: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString()
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.set(patch)
|
||||
.where(and(eq(taskRun.id, taskId), eq(taskRun.user_id, userId)))
|
||||
.returning();
|
||||
|
||||
return row ? toTask(row) : null;
|
||||
}
|
||||
|
||||
export async function markTaskFailure(taskId: string, reason: string) {
|
||||
const [current] = await db
|
||||
export async function listTaskStageEventsForTask(taskId: string, userId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(taskRun)
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.limit(1);
|
||||
.from(taskStageEvent)
|
||||
.where(and(eq(taskStageEvent.task_id, taskId), eq(taskStageEvent.user_id, userId)))
|
||||
.orderBy(asc(taskStageEvent.created_at), asc(taskStageEvent.id));
|
||||
|
||||
if (!current) {
|
||||
return {
|
||||
task: null,
|
||||
shouldRetry: false
|
||||
};
|
||||
}
|
||||
|
||||
const shouldRetry = current.attempts < current.max_attempts;
|
||||
|
||||
const [updated] = await db
|
||||
.update(taskRun)
|
||||
.set({
|
||||
status: shouldRetry ? 'queued' : 'failed',
|
||||
error: reason,
|
||||
updated_at: new Date().toISOString(),
|
||||
finished_at: shouldRetry ? null : new Date().toISOString()
|
||||
})
|
||||
.where(eq(taskRun.id, taskId))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
task: updated ? toTask(updated) : null,
|
||||
shouldRetry
|
||||
};
|
||||
return rows.map(toTaskStageEvent);
|
||||
}
|
||||
|
||||
export async function getTaskById(taskId: string) {
|
||||
|
||||
Reference in New Issue
Block a user