Fix for delay actions in workflows
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {type Job, Queue} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
import signale from 'signale';
|
||||
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -253,7 +254,7 @@ export class QueueService {
|
||||
|
||||
if (job) {
|
||||
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
|
||||
*/
|
||||
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
|
||||
const scheduledCampaigns = await scheduledQueue.getJobs(['waiting', 'delayed']);
|
||||
@@ -449,7 +450,7 @@ export class QueueService {
|
||||
|
||||
if (campaign?.projectId === projectId) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@plunk/db';
|
||||
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
@@ -41,7 +42,9 @@ export class WorkflowExecutionService {
|
||||
* This is the main entry point for executing workflow steps
|
||||
*/
|
||||
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},
|
||||
include: {
|
||||
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');
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Check if project is disabled
|
||||
if (execution.workflow.project.disabled) {
|
||||
console.warn(
|
||||
`[WORKFLOW] Project ${execution.workflow.projectId} (${execution.workflow.project.name}) is disabled, cancelling workflow execution ${executionId}`,
|
||||
if (initialExecution.workflow.project.disabled) {
|
||||
signale.warn(
|
||||
`[WORKFLOW] Project ${initialExecution.workflow.projectId} (${initialExecution.workflow.project.name}) is disabled, cancelling workflow execution ${executionId}`,
|
||||
);
|
||||
await prisma.workflowExecution.update({
|
||||
where: {id: executionId},
|
||||
@@ -92,18 +103,70 @@ export class WorkflowExecutionService {
|
||||
// Note: We allow running executions to complete even if workflow is disabled
|
||||
// This prevents disruption to contacts who are already in the workflow
|
||||
// Only NEW executions are prevented when workflow is disabled (see startExecution in WorkflowService)
|
||||
if (!execution.workflow.enabled) {
|
||||
console.info(
|
||||
`[WORKFLOW] Workflow ${execution.workflow.id} (${execution.workflow.name}) is disabled, but allowing execution ${executionId} to continue`,
|
||||
if (!initialExecution.workflow.enabled) {
|
||||
signale.info(
|
||||
`[WORKFLOW] Workflow ${initialExecution.workflow.id} (${initialExecution.workflow.name}) is disabled, but allowing execution ${executionId} to continue`,
|
||||
);
|
||||
// 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) {
|
||||
signale.error(`[WORKFLOW] Step ${stepId} not found in workflow ${initialExecution.workflow.id}`);
|
||||
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
|
||||
let stepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
@@ -114,14 +177,21 @@ export class WorkflowExecutionService {
|
||||
});
|
||||
|
||||
if (!stepExecution) {
|
||||
stepExecution = await prisma.workflowStepExecution.create({
|
||||
data: {
|
||||
executionId,
|
||||
stepId,
|
||||
status: StepExecutionStatus.RUNNING,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
signale.info(`[WORKFLOW] Creating new step execution for ${executionId} -> ${stepId}`);
|
||||
try {
|
||||
stepExecution = await prisma.workflowStepExecution.create({
|
||||
data: {
|
||||
executionId,
|
||||
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 {
|
||||
stepExecution = await prisma.workflowStepExecution.update({
|
||||
where: {id: stepExecution.id},
|
||||
@@ -130,11 +200,14 @@ export class WorkflowExecutionService {
|
||||
startedAt: stepExecution.startedAt || new Date(),
|
||||
},
|
||||
});
|
||||
signale.info(`[WORKFLOW] Updated existing step execution ${stepExecution.id} to RUNNING`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 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);
|
||||
signale.info(`[WORKFLOW] Step ${stepId} executed successfully`);
|
||||
|
||||
// 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
|
||||
@@ -148,14 +221,17 @@ export class WorkflowExecutionService {
|
||||
}
|
||||
|
||||
// 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
|
||||
const updatedExecution = await prisma.workflowExecution.findUnique({
|
||||
where: {id: execution.id},
|
||||
});
|
||||
// Only check this if we're NOT resuming from a delay - if we are resuming,
|
||||
// we've already set it to RUNNING and the step just executed
|
||||
if (!isResumingFromDelay) {
|
||||
const updatedExecution = await prisma.workflowExecution.findUnique({
|
||||
where: {id: execution.id},
|
||||
});
|
||||
|
||||
if (updatedExecution?.status === WorkflowExecutionStatus.WAITING) {
|
||||
// Workflow is waiting (DELAY step has queued the next step) - don't process next steps now
|
||||
return;
|
||||
if (updatedExecution?.status === WorkflowExecutionStatus.WAITING) {
|
||||
// Workflow is waiting (DELAY step has queued the next step) - don't process next steps now
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
await this.processNextSteps(execution, step, result);
|
||||
} catch (error) {
|
||||
console.error(`[WORKFLOW] Error executing step ${step.id}:`, error);
|
||||
signale.error(`[WORKFLOW] Error executing step ${step.id}:`, error);
|
||||
// Mark step as failed
|
||||
await prisma.workflowStepExecution.update({
|
||||
where: {id: stepExecution.id},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Badge,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@plunk/ui';
|
||||
import {Trash2, AlertCircle, Loader2} from 'lucide-react';
|
||||
import {AlertCircle, Loader2, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
@@ -76,13 +76,12 @@ export function DataManagementSettings() {
|
||||
);
|
||||
|
||||
// Filter out standard fields and only show custom data fields
|
||||
const customFields =
|
||||
fieldsData?.fields.filter(f => f.field.startsWith('data.')) || [];
|
||||
const customFields = fieldsData?.fields.filter(f => f.field.startsWith('data.')) || [];
|
||||
|
||||
// Filter out system events
|
||||
const customEvents =
|
||||
eventsData?.eventNames.filter(
|
||||
name => !name.startsWith('email.') && !name.startsWith('segment.'),
|
||||
name => !name.startsWith('email.') && !name.startsWith('segment.') && !name.startsWith('contact.'),
|
||||
) || [];
|
||||
|
||||
const handleDeleteField = async () => {
|
||||
@@ -146,8 +145,8 @@ export function DataManagementSettings() {
|
||||
<CardHeader>
|
||||
<CardTitle>Custom Contact Fields</CardTitle>
|
||||
<CardDescription>
|
||||
Manage custom fields stored in your contact data. You can only delete fields that are not
|
||||
used in any segments or campaigns.
|
||||
Manage custom fields stored in your contact data. You can only delete fields that are not used in any
|
||||
segments or campaigns.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -166,9 +165,7 @@ export function DataManagementSettings() {
|
||||
<TableBody>
|
||||
{customFields.map(field => (
|
||||
<TableRow key={field.field}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{field.field.replace('data.', '')}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{field.field.replace('data.', '')}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{field.type}</Badge>
|
||||
</TableCell>
|
||||
@@ -176,11 +173,7 @@ export function DataManagementSettings() {
|
||||
<span className="text-sm text-muted-foreground">{field.coverage}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openFieldDeleteDialog(field.field)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => openFieldDeleteDialog(field.field)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -197,8 +190,8 @@ export function DataManagementSettings() {
|
||||
<CardHeader>
|
||||
<CardTitle>Custom Events</CardTitle>
|
||||
<CardDescription>
|
||||
Manage custom events tracked in your project. You can only delete events that are not used
|
||||
in any segments or workflows. System events (email.*, segment.*) cannot be deleted.
|
||||
Manage custom events tracked in your project. You can only delete events that are not used in any segments
|
||||
or workflows. System events (email.*, segment.*) cannot be deleted.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -217,11 +210,7 @@ export function DataManagementSettings() {
|
||||
<TableRow key={eventName}>
|
||||
<TableCell className="font-mono text-sm">{eventName}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEventDeleteDialog(eventName)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEventDeleteDialog(eventName)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -237,9 +226,7 @@ export function DataManagementSettings() {
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedField ? 'Delete Field' : 'Delete Event'}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{selectedField ? 'Delete Field' : 'Delete Event'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedField
|
||||
? `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" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-medium">Cannot delete this field</p>
|
||||
<p className="text-sm">
|
||||
This field is currently used in:
|
||||
</p>
|
||||
<p className="text-sm">This field is currently used in:</p>
|
||||
{fieldUsage.usedInSegments.length > 0 && (
|
||||
<ul className="mt-2 list-disc list-inside text-sm">
|
||||
{fieldUsage.usedInSegments.map(segment => (
|
||||
@@ -311,9 +296,7 @@ export function DataManagementSettings() {
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-medium">Cannot delete this event</p>
|
||||
<p className="text-sm">
|
||||
This event is currently used in:
|
||||
</p>
|
||||
<p className="text-sm">This event is currently used in:</p>
|
||||
{eventUsage.usedInSegments.length > 0 && (
|
||||
<ul className="mt-2 list-disc list-inside text-sm">
|
||||
{eventUsage.usedInSegments.map(segment => (
|
||||
@@ -350,9 +333,7 @@ export function DataManagementSettings() {
|
||||
variant="destructive"
|
||||
onClick={selectedField ? handleDeleteField : handleDeleteEvent}
|
||||
disabled={
|
||||
isDeleting ||
|
||||
(!!selectedField && !fieldUsage?.canDelete) ||
|
||||
(!!selectedEvent && !eventUsage?.canDelete)
|
||||
isDeleting || (!!selectedField && !fieldUsage?.canDelete) || (!!selectedEvent && !eventUsage?.canDelete)
|
||||
}
|
||||
>
|
||||
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
|
||||
Reference in New Issue
Block a user