Delayed / Scheduled Actions
Overview
Many workflows need to "wait" before doing the next thing — send a follow-up message three days after a purchase, or watch whether the customer clicks a link within 24 hours and only resend if they do not.
Rather than relying on the broker's delayed messages, the system records the work into the
scheduled_action table and has a cron sweep for due rows every minute. This approach is
inspectable, editable, and survives worker restarts.
Business Flow
Scheduling
- When
TriggerService.executeActionfindsactionConfig.delayset, it callsScheduleActioninstead of dispatching the action immediately. execute_atis computed per mode:- in the normal mode, it is the current time plus
delay.seconds - in
waitForTrackingmode,execute_atis set to the expiry time (fromexpiresSecondsordelaySeconds) andexpires_atis recorded alongside it, expressing "wait until the deadline unless someone clicks first"
- in the normal mode, it is the current time plus
- If
delay.reEvaluateConditions = true, thesourceConfigis stored as well so the conditions can be re-evaluated when the action actually comes due — the customer's situation may have changed by then. - Before writing the row, Redis is checked for a tracking click that already arrived, guarding against a race condition.
Processing due actions
This runs every minute on two profiles: trigger-worker via
ScheduledActionService.ProcessDueActions, and cron-scheduler via
ScheduledActionScannerService.Run, which publishes onto the queue instead.
recoverStaleProcessingrecovers rows stuck inprocessingfor more than five minutes, which happens when a worker dies mid-flight.claimBatchatomically claims 500 rows at a time usingFOR UPDATE SKIP LOCKED, so multiple replicas can work concurrently without colliding.- Each action is handled by
processAction:- load the current
line_user - if a
sourceConfigwas stored, callEvaluateConditionsagain; if it no longer passes, cancel the action - if it passes, build a synthetic
TriggerRuleand call the trigger engine'sExecuteAction - retry up to three times (
maxRetries) before marking the action failed
- load the current
handleExpireddeals with actions past theirexpires_atthat were never unlocked, following whatever the configuration specifies — typically taking the "did not click" branch.- Loop back to
claimBatchuntil no work remains.
Early unlock
If the user clicks the link while the system is still waiting, the tracking consumer calls
checkAndCompleteDelayWait, which makes that workflow's action run immediately.
Key Files & Functions
internal/scheduledaction/scheduledaction.goScheduledActionService.ScheduleAction(ctx, params)— writes intoscheduled_actionProcessDueActions(ctx)— the per-minute cron on thetrigger-workerprofileclaimBatch()(usingFOR UPDATE SKIP LOCKED),processAction(),recoverStaleProcessing(),handleExpired(),updateStatus(),getLineUser()- the constants
batchSize = 500,maxRetries = 3,staleProcessingMinute = 5 - the
TriggerExecutorinterface (EvaluateConditions,ExecuteAction), which breaks the circular dependency
internal/actionexecute/scheduler.go—ActionSchedulerService.ScheduleAction(), a lightweight variant used on themainprofile that only inserts and runs no croninternal/cronscheduler/scheduled_action_scanner.go—ScheduledActionScannerService.Run()on thecron-schedulerprofile, which claims rows and publishes them toscheduled_action_fireinternal/trigger/service_events.go—ProcessScheduledActionFire()andhandleScheduledActionExpired()cmd/worker/main.go—runTriggerWorker(), which cross-wires the two services- Queue:
scheduled_action_fire, consumed ontrigger-workerand published fromcron-scheduler
Connections to Other Services
- The
scheduled_actiontable — key columns areexecute_at,expires_at,status(pending, processing, done, failed, cancelled),retry_count,action_type,action_config,source_config,workflow_id, andworkflow_node_path - Other tables:
trigger_rule(the originating rule) andline_user(for re-evaluating conditions) - Redis — checks for tracking clicks that arrive ahead of the deadline
- RabbitMQ — the
scheduled_action_firequeue, withaction_executeas the final destination - Directly connected to the Trigger / Workflow Automation Engine and Workflow Action Execution