Fix for delay actions in workflows

This commit is contained in:
Dries Augustyns
2025-12-03 16:22:59 +01:00
parent b3e6d03962
commit 9fadd01a6e
3 changed files with 125 additions and 67 deletions
+8 -7
View File
@@ -1,5 +1,6 @@
import {type Job, Queue} from 'bullmq'; import {type Job, Queue} from 'bullmq';
import type {RedisOptions} from 'ioredis'; import type {RedisOptions} from 'ioredis';
import signale from 'signale';
import {REDIS_URL} from '../app/constants.js'; import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -253,7 +254,7 @@ export class QueueService {
if (job) { if (job) {
await job.remove(); await job.remove();
console.log(`[QUEUE] Cancelled timeout job ${jobId}`); signale.info(`[QUEUE] Cancelled timeout job ${jobId}`);
} }
} }
@@ -435,7 +436,7 @@ export class QueueService {
* This should be called when a project is disabled * This should be called when a project is disabled
*/ */
public static async cancelAllProjectJobs(projectId: string): Promise<void> { public static async cancelAllProjectJobs(projectId: string): Promise<void> {
console.log(`[QUEUE] Cancelling all pending jobs for project ${projectId}`); signale.info(`[QUEUE] Cancelling all pending jobs for project ${projectId}`);
// Cancel all scheduled campaigns for this project // Cancel all scheduled campaigns for this project
const scheduledCampaigns = await scheduledQueue.getJobs(['waiting', 'delayed']); const scheduledCampaigns = await scheduledQueue.getJobs(['waiting', 'delayed']);
@@ -449,7 +450,7 @@ export class QueueService {
if (campaign?.projectId === projectId) { if (campaign?.projectId === projectId) {
await job.remove(); await job.remove();
console.log(`[QUEUE] Removed scheduled campaign job ${job.id}`); signale.info(`[QUEUE] Removed scheduled campaign job ${job.id}`);
} }
} }
@@ -463,7 +464,7 @@ export class QueueService {
if (email?.projectId === projectId) { if (email?.projectId === projectId) {
await job.remove(); await job.remove();
console.log(`[QUEUE] Removed email job ${job.id}`); signale.info(`[QUEUE] Removed email job ${job.id}`);
} }
} }
@@ -477,7 +478,7 @@ export class QueueService {
if (campaign?.projectId === projectId) { if (campaign?.projectId === projectId) {
await job.remove(); await job.remove();
console.log(`[QUEUE] Removed campaign batch job ${job.id}`); signale.info(`[QUEUE] Removed campaign batch job ${job.id}`);
} }
} }
@@ -491,11 +492,11 @@ export class QueueService {
if (execution?.workflow.projectId === projectId) { if (execution?.workflow.projectId === projectId) {
await job.remove(); await job.remove();
console.log(`[QUEUE] Removed workflow step job ${job.id}`); signale.info(`[QUEUE] Removed workflow step job ${job.id}`);
} }
} }
console.log(`[QUEUE] Finished cancelling jobs for project ${projectId}`); signale.info(`[QUEUE] Finished cancelling jobs for project ${projectId}`);
} }
/** /**
+102 -26
View File
@@ -9,6 +9,7 @@ import type {
} from '@plunk/db'; } from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db'; import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared'; import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared';
import signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
@@ -41,7 +42,9 @@ export class WorkflowExecutionService {
* This is the main entry point for executing workflow steps * This is the main entry point for executing workflow steps
*/ */
public static async processStepExecution(executionId: string, stepId: string): Promise<void> { public static async processStepExecution(executionId: string, stepId: string): Promise<void> {
const execution = await prisma.workflowExecution.findUnique({ signale.info(`[WORKFLOW] Processing step execution: ${executionId} -> ${stepId}`);
const initialExecution = await prisma.workflowExecution.findUnique({
where: {id: executionId}, where: {id: executionId},
include: { include: {
workflow: { workflow: {
@@ -64,18 +67,26 @@ export class WorkflowExecutionService {
}, },
}); });
if (!execution) { if (!initialExecution) {
signale.error(`[WORKFLOW] Execution ${executionId} not found`);
throw new HttpException(404, 'Workflow execution not found'); throw new HttpException(404, 'Workflow execution not found');
} }
if (execution.status !== WorkflowExecutionStatus.RUNNING) { signale.info(`[WORKFLOW] Execution ${executionId} status: ${initialExecution.status}`);
// For WAITING executions, check if this is a delayed step that's ready to execute
if (initialExecution.status === WorkflowExecutionStatus.WAITING) {
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
// This is a delayed step - continue with execution
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) {
signale.info(`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`);
return; // Already completed or cancelled return; // Already completed or cancelled
} }
// Check if project is disabled // Check if project is disabled
if (execution.workflow.project.disabled) { if (initialExecution.workflow.project.disabled) {
console.warn( signale.warn(
`[WORKFLOW] Project ${execution.workflow.projectId} (${execution.workflow.project.name}) is disabled, cancelling workflow execution ${executionId}`, `[WORKFLOW] Project ${initialExecution.workflow.projectId} (${initialExecution.workflow.project.name}) is disabled, cancelling workflow execution ${executionId}`,
); );
await prisma.workflowExecution.update({ await prisma.workflowExecution.update({
where: {id: executionId}, where: {id: executionId},
@@ -92,18 +103,70 @@ export class WorkflowExecutionService {
// Note: We allow running executions to complete even if workflow is disabled // Note: We allow running executions to complete even if workflow is disabled
// This prevents disruption to contacts who are already in the workflow // This prevents disruption to contacts who are already in the workflow
// Only NEW executions are prevented when workflow is disabled (see startExecution in WorkflowService) // Only NEW executions are prevented when workflow is disabled (see startExecution in WorkflowService)
if (!execution.workflow.enabled) { if (!initialExecution.workflow.enabled) {
console.info( signale.info(
`[WORKFLOW] Workflow ${execution.workflow.id} (${execution.workflow.name}) is disabled, but allowing execution ${executionId} to continue`, `[WORKFLOW] Workflow ${initialExecution.workflow.id} (${initialExecution.workflow.name}) is disabled, but allowing execution ${executionId} to continue`,
); );
// Allow execution to continue - no action needed // Allow execution to continue - no action needed
} }
const step = execution.workflow.steps.find(s => s.id === stepId); const step = initialExecution.workflow.steps.find(s => s.id === stepId);
if (!step) { if (!step) {
signale.error(`[WORKFLOW] Step ${stepId} not found in workflow ${initialExecution.workflow.id}`);
throw new HttpException(404, 'Step not found in workflow'); throw new HttpException(404, 'Step not found in workflow');
} }
signale.info(`[WORKFLOW] Found step: ${step.name} (${step.type})`);
// Track if this execution was in WAITING state (meaning it's a delayed step)
const isResumingFromDelay = initialExecution.status === WorkflowExecutionStatus.WAITING;
// If workflow execution is WAITING (e.g., from a delay), set it back to RUNNING
let execution = initialExecution;
if (initialExecution.status === WorkflowExecutionStatus.WAITING) {
signale.info(`[WORKFLOW] Setting execution ${executionId} from WAITING to RUNNING`);
await prisma.workflowExecution.update({
where: {id: executionId},
data: {
status: WorkflowExecutionStatus.RUNNING,
currentStepId: stepId,
},
});
// Re-fetch the execution with the updated status
const updatedExecution = await prisma.workflowExecution.findUnique({
where: {id: executionId},
include: {
workflow: {
include: {
steps: {
include: {
template: true,
outgoingTransitions: {
orderBy: {priority: 'asc'},
include: {toStep: true},
},
},
},
project: {
select: {disabled: true, id: true, name: true},
},
},
},
contact: true,
},
});
if (!updatedExecution) {
signale.error(`[WORKFLOW] Execution ${executionId} not found after status update`);
throw new HttpException(404, 'Workflow execution not found');
}
execution = updatedExecution;
signale.info(`[WORKFLOW] Execution ${executionId} status updated to RUNNING`);
}
// Create or get step execution record // Create or get step execution record
let stepExecution = await prisma.workflowStepExecution.findFirst({ let stepExecution = await prisma.workflowStepExecution.findFirst({
where: { where: {
@@ -114,14 +177,21 @@ export class WorkflowExecutionService {
}); });
if (!stepExecution) { if (!stepExecution) {
stepExecution = await prisma.workflowStepExecution.create({ signale.info(`[WORKFLOW] Creating new step execution for ${executionId} -> ${stepId}`);
data: { try {
executionId, stepExecution = await prisma.workflowStepExecution.create({
stepId, data: {
status: StepExecutionStatus.RUNNING, executionId,
startedAt: new Date(), stepId,
}, status: StepExecutionStatus.RUNNING,
}); startedAt: new Date(),
},
});
signale.info(`[WORKFLOW] Created step execution ${stepExecution.id}`);
} catch (error) {
signale.error(`[WORKFLOW] Failed to create step execution for ${executionId} -> ${stepId}:`, error);
throw error;
}
} else { } else {
stepExecution = await prisma.workflowStepExecution.update({ stepExecution = await prisma.workflowStepExecution.update({
where: {id: stepExecution.id}, where: {id: stepExecution.id},
@@ -130,11 +200,14 @@ export class WorkflowExecutionService {
startedAt: stepExecution.startedAt || new Date(), startedAt: stepExecution.startedAt || new Date(),
}, },
}); });
signale.info(`[WORKFLOW] Updated existing step execution ${stepExecution.id} to RUNNING`);
} }
try { try {
// Execute the step based on its type // Execute the step based on its type
signale.info(`[WORKFLOW] Executing step ${stepId} of type ${step.type}`);
const result = await this.executeStep(step, execution, stepExecution); const result = await this.executeStep(step, execution, stepExecution);
signale.info(`[WORKFLOW] Step ${stepId} executed successfully`);
// Check if step is in a waiting state (WAIT_FOR_EVENT steps only) // Check if step is in a waiting state (WAIT_FOR_EVENT steps only)
// DELAY steps now mark themselves as COMPLETED and queue the next step // DELAY steps now mark themselves as COMPLETED and queue the next step
@@ -148,14 +221,17 @@ export class WorkflowExecutionService {
} }
// Check if the workflow execution is now in WAITING state (DELAY steps do this) // Check if the workflow execution is now in WAITING state (DELAY steps do this)
// If so, the step has already been handled and queued - don't process next steps // Only check this if we're NOT resuming from a delay - if we are resuming,
const updatedExecution = await prisma.workflowExecution.findUnique({ // we've already set it to RUNNING and the step just executed
where: {id: execution.id}, if (!isResumingFromDelay) {
}); const updatedExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
if (updatedExecution?.status === WorkflowExecutionStatus.WAITING) { if (updatedExecution?.status === WorkflowExecutionStatus.WAITING) {
// Workflow is waiting (DELAY step has queued the next step) - don't process next steps now // Workflow is waiting (DELAY step has queued the next step) - don't process next steps now
return; return;
}
} }
// Mark step as completed (for normal steps that complete immediately) // Mark step as completed (for normal steps that complete immediately)
@@ -171,7 +247,7 @@ export class WorkflowExecutionService {
// Determine next step(s) based on transitions and conditions // Determine next step(s) based on transitions and conditions
await this.processNextSteps(execution, step, result); await this.processNextSteps(execution, step, result);
} catch (error) { } catch (error) {
console.error(`[WORKFLOW] Error executing step ${step.id}:`, error); signale.error(`[WORKFLOW] Error executing step ${step.id}:`, error);
// Mark step as failed // Mark step as failed
await prisma.workflowStepExecution.update({ await prisma.workflowStepExecution.update({
where: {id: stepExecution.id}, where: {id: stepExecution.id},
@@ -1,6 +1,7 @@
import {useState} from 'react'; import {useState} from 'react';
import { import {
Alert, Alert,
Badge,
Button, Button,
Card, Card,
CardContent, CardContent,
@@ -13,7 +14,6 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
Badge,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -21,7 +21,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@plunk/ui'; } from '@plunk/ui';
import {Trash2, AlertCircle, Loader2} from 'lucide-react'; import {AlertCircle, Loader2, Trash2} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider'; import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
@@ -76,13 +76,12 @@ export function DataManagementSettings() {
); );
// Filter out standard fields and only show custom data fields // Filter out standard fields and only show custom data fields
const customFields = const customFields = fieldsData?.fields.filter(f => f.field.startsWith('data.')) || [];
fieldsData?.fields.filter(f => f.field.startsWith('data.')) || [];
// Filter out system events // Filter out system events
const customEvents = const customEvents =
eventsData?.eventNames.filter( eventsData?.eventNames.filter(
name => !name.startsWith('email.') && !name.startsWith('segment.'), name => !name.startsWith('email.') && !name.startsWith('segment.') && !name.startsWith('contact.'),
) || []; ) || [];
const handleDeleteField = async () => { const handleDeleteField = async () => {
@@ -146,8 +145,8 @@ export function DataManagementSettings() {
<CardHeader> <CardHeader>
<CardTitle>Custom Contact Fields</CardTitle> <CardTitle>Custom Contact Fields</CardTitle>
<CardDescription> <CardDescription>
Manage custom fields stored in your contact data. You can only delete fields that are not Manage custom fields stored in your contact data. You can only delete fields that are not used in any
used in any segments or campaigns. segments or campaigns.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -166,9 +165,7 @@ export function DataManagementSettings() {
<TableBody> <TableBody>
{customFields.map(field => ( {customFields.map(field => (
<TableRow key={field.field}> <TableRow key={field.field}>
<TableCell className="font-mono text-sm"> <TableCell className="font-mono text-sm">{field.field.replace('data.', '')}</TableCell>
{field.field.replace('data.', '')}
</TableCell>
<TableCell> <TableCell>
<Badge variant="secondary">{field.type}</Badge> <Badge variant="secondary">{field.type}</Badge>
</TableCell> </TableCell>
@@ -176,11 +173,7 @@ export function DataManagementSettings() {
<span className="text-sm text-muted-foreground">{field.coverage}%</span> <span className="text-sm text-muted-foreground">{field.coverage}%</span>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button variant="ghost" size="sm" onClick={() => openFieldDeleteDialog(field.field)}>
variant="ghost"
size="sm"
onClick={() => openFieldDeleteDialog(field.field)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TableCell> </TableCell>
@@ -197,8 +190,8 @@ export function DataManagementSettings() {
<CardHeader> <CardHeader>
<CardTitle>Custom Events</CardTitle> <CardTitle>Custom Events</CardTitle>
<CardDescription> <CardDescription>
Manage custom events tracked in your project. You can only delete events that are not used Manage custom events tracked in your project. You can only delete events that are not used in any segments
in any segments or workflows. System events (email.*, segment.*) cannot be deleted. or workflows. System events (email.*, segment.*) cannot be deleted.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -217,11 +210,7 @@ export function DataManagementSettings() {
<TableRow key={eventName}> <TableRow key={eventName}>
<TableCell className="font-mono text-sm">{eventName}</TableCell> <TableCell className="font-mono text-sm">{eventName}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button variant="ghost" size="sm" onClick={() => openEventDeleteDialog(eventName)}>
variant="ghost"
size="sm"
onClick={() => openEventDeleteDialog(eventName)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TableCell> </TableCell>
@@ -237,9 +226,7 @@ export function DataManagementSettings() {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{selectedField ? 'Delete Field' : 'Delete Event'}</DialogTitle>
{selectedField ? 'Delete Field' : 'Delete Event'}
</DialogTitle>
<DialogDescription> <DialogDescription>
{selectedField {selectedField
? `Are you sure you want to delete the field "${selectedField.replace('data.', '')}"?` ? `Are you sure you want to delete the field "${selectedField.replace('data.', '')}"?`
@@ -266,9 +253,7 @@ export function DataManagementSettings() {
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<div className="ml-2"> <div className="ml-2">
<p className="text-sm font-medium">Cannot delete this field</p> <p className="text-sm font-medium">Cannot delete this field</p>
<p className="text-sm"> <p className="text-sm">This field is currently used in:</p>
This field is currently used in:
</p>
{fieldUsage.usedInSegments.length > 0 && ( {fieldUsage.usedInSegments.length > 0 && (
<ul className="mt-2 list-disc list-inside text-sm"> <ul className="mt-2 list-disc list-inside text-sm">
{fieldUsage.usedInSegments.map(segment => ( {fieldUsage.usedInSegments.map(segment => (
@@ -311,9 +296,7 @@ export function DataManagementSettings() {
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<div className="ml-2"> <div className="ml-2">
<p className="text-sm font-medium">Cannot delete this event</p> <p className="text-sm font-medium">Cannot delete this event</p>
<p className="text-sm"> <p className="text-sm">This event is currently used in:</p>
This event is currently used in:
</p>
{eventUsage.usedInSegments.length > 0 && ( {eventUsage.usedInSegments.length > 0 && (
<ul className="mt-2 list-disc list-inside text-sm"> <ul className="mt-2 list-disc list-inside text-sm">
{eventUsage.usedInSegments.map(segment => ( {eventUsage.usedInSegments.map(segment => (
@@ -350,9 +333,7 @@ export function DataManagementSettings() {
variant="destructive" variant="destructive"
onClick={selectedField ? handleDeleteField : handleDeleteEvent} onClick={selectedField ? handleDeleteField : handleDeleteEvent}
disabled={ disabled={
isDeleting || isDeleting || (!!selectedField && !fieldUsage?.canDelete) || (!!selectedEvent && !eventUsage?.canDelete)
(!!selectedField && !fieldUsage?.canDelete) ||
(!!selectedEvent && !eventUsage?.canDelete)
} }
> >
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}