@@ -172,6 +172,7 @@ defmodule Durable.Executor do
172172 |> Ecto.Changeset . change (
173173 context: new_context ,
174174 status: :pending ,
175+ scheduled_at: nil ,
175176 locked_by: nil ,
176177 locked_at: nil
177178 )
@@ -458,7 +459,7 @@ defmodule Durable.Executor do
458459
459460 { :ok , execution } =
460461 execution
461- |> Ecto.Changeset . change ( status: :waiting )
462+ |> Ecto.Changeset . change ( status: :waiting , scheduled_at: nil )
462463 |> Repo . update ( config )
463464
464465 { :waiting , execution }
@@ -768,7 +769,8 @@ defmodule Durable.Executor do
768769 exec
769770 |> Ecto.Changeset . change (
770771 context: Map . merge ( exec . context || % { } , parallel_context ) ,
771- status: :waiting
772+ status: :waiting ,
773+ scheduled_at: nil
772774 )
773775 |> Repo . update ( config )
774776
@@ -1150,12 +1152,36 @@ defmodule Durable.Executor do
11501152 process_ctx
11511153 |> Map . merge ( data )
11521154 |> sanitize_for_json ( )
1155+ |> drop_consumed_sleep_marker ( execution . current_step )
11531156
11541157 execution
11551158 |> Ecto.Changeset . change ( context: merged )
11561159 |> Repo . update ( config )
11571160 end
11581161
1162+ # The SleepWaker stamps `__sleep_satisfied__ => <step_name>` into context so
1163+ # the woken step's `sleep/1`/`schedule_at/1` returns instead of re-throwing.
1164+ # Once that step completes we drop the marker — otherwise it lives in context
1165+ # forever and any later re-entry of the SAME step name (a loop/`each` body, or
1166+ # a `decision` goto that revisits it) would see the stale marker and silently
1167+ # skip its sleep. The marker is therefore a strict one-shot keyed to the step
1168+ # that consumed it. (Handles both atom and string key shapes since context can
1169+ # arrive atomized from the process dict or string-keyed from a step return.)
1170+ defp drop_consumed_sleep_marker ( context , current_step ) when is_binary ( current_step ) do
1171+ context
1172+ |> drop_marker_if_step ( :__sleep_satisfied__ , current_step )
1173+ |> drop_marker_if_step ( "__sleep_satisfied__" , current_step )
1174+ end
1175+
1176+ defp drop_consumed_sleep_marker ( context , _current_step ) , do: context
1177+
1178+ defp drop_marker_if_step ( context , key , current_step ) do
1179+ case Map . get ( context , key ) do
1180+ ^ current_step -> Map . delete ( context , key )
1181+ _ -> context
1182+ end
1183+ end
1184+
11591185 defp mark_completed ( config , execution , final_data ) do
11601186 # Sanitize before persisting — the final step may return data containing
11611187 # raw tuples (e.g., child executions in a parallel block complete with
@@ -1169,7 +1195,7 @@ defmodule Durable.Executor do
11691195 completed_at: DateTime . utc_now ( ) ,
11701196 current_step: nil
11711197 } )
1172- |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil )
1198+ |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil , scheduled_at: nil )
11731199 |> Repo . update ( config )
11741200
11751201 DurablePubSub . broadcast_workflow ( config , :workflow_completed , workflow_event ( execution ) )
@@ -1194,7 +1220,7 @@ defmodule Durable.Executor do
11941220 error: safe_error ,
11951221 completed_at: DateTime . utc_now ( )
11961222 } )
1197- |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil )
1223+ |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil , scheduled_at: nil )
11981224 |> Repo . update ( config )
11991225 rescue
12001226 e ->
@@ -1210,7 +1236,7 @@ defmodule Durable.Executor do
12101236 error: fallback ,
12111237 completed_at: DateTime . utc_now ( )
12121238 } )
1213- |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil )
1239+ |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil , scheduled_at: nil )
12141240 |> Repo . update ( config )
12151241 end
12161242
@@ -1285,7 +1311,13 @@ defmodule Durable.Executor do
12851311 where:
12861312 p . workflow_id == ^ execution . parent_workflow_id and
12871313 p . event_name == ^ parallel_event_name and
1288- p . status == :pending
1314+ p . status == :pending ,
1315+ # `limit: 1` keeps `Repo.one` from RAISING if a duplicate parallel
1316+ # event ever exists (e.g. a re-run of fan_out before context
1317+ # committed). One pending event per child is the norm; defending the
1318+ # claim path against the pathological case avoids crashing the child's
1319+ # whole completion handler.
1320+ limit: 1
12891321 )
12901322 )
12911323
@@ -1296,36 +1328,89 @@ defmodule Durable.Executor do
12961328 end
12971329 end
12981330
1299- # Notify parent via WaitGroup (parallel child completion)
1331+ # Notify parent via WaitGroup (parallel child completion).
1332+ #
1333+ # Three updates run inside one transaction:
1334+ #
1335+ # 1. Mark the child's PendingEvent as :received.
1336+ # 2. Lock the WaitGroup row (FOR UPDATE), merge the event into
1337+ # received_events, flip to :completed when the wait condition is
1338+ # satisfied. The lock is what closes the lost-update race that
1339+ # previously allowed concurrent siblings to overwrite each other.
1340+ # 3. If the group just transitioned to :completed, flip the parent
1341+ # :waiting -> :pending so the queue poller picks it up.
1342+ #
1343+ # If any stage fails the whole transaction rolls back, so the system
1344+ # never observes "event :received but wait group not updated" or "wait
1345+ # group complete but parent still :waiting" — the states zombie
1346+ # detection later misreads as a crash.
13001347 defp notify_parallel_parent ( config , execution , pending_event , status , data ) do
13011348 payload = Durable.Orchestration . build_result_payload ( status , data )
1349+ parent_id = execution . parent_workflow_id
13021350
1303- # Fulfill the pending event
1304- { :ok , _ } =
1305- pending_event
1306- |> PendingEvent . receive_changeset ( payload )
1307- |> Repo . update ( config )
1351+ multi =
1352+ Ecto.Multi . new ( )
1353+ |> Ecto.Multi . update ( :event , PendingEvent . receive_changeset ( pending_event , payload ) )
1354+ |> Ecto.Multi . run ( :wait_group , fn repo , _ ->
1355+ WaitGroup . add_event_locked (
1356+ repo ,
1357+ pending_event . wait_group_id ,
1358+ pending_event . event_name ,
1359+ payload
1360+ )
1361+ end )
1362+ |> Ecto.Multi . run ( :parent , fn repo , % { wait_group: wg_result } ->
1363+ if wg_result . just_completed do
1364+ resume_parent_in_multi ( repo , parent_id , wg_result . wait_group . received_events )
1365+ else
1366+ { :ok , % { no_op: true } }
1367+ end
1368+ end )
1369+
1370+ case Repo . transaction ( config , multi ) do
1371+ { :ok , _ } ->
1372+ :ok
13081373
1309- # Update the WaitGroup and resume parent if all children are done
1310- maybe_complete_wait_group ( config , pending_event , payload , execution . parent_workflow_id )
1374+ { :error , stage , reason , _ } ->
1375+ Logger . error (
1376+ "[Durable] failed to atomically fulfill parallel child event + wait group + resume parent: " <>
1377+ "stage=#{ stage } reason=#{ inspect ( reason ) } parent=#{ parent_id } "
1378+ )
13111379
1312- :ok
1380+ { :error , reason }
1381+ end
13131382 end
13141383
1315- defp maybe_complete_wait_group ( _config , % { wait_group_id: nil } , _payload , _parent_id ) , do: :ok
1384+ # Resume a parent (or any waiting workflow) inside an Ecto.Multi.run.
1385+ # Mirrors the body of `resume_workflow/3` minus the standalone Repo.update,
1386+ # so the resume composes into a transaction with the upstream event /
1387+ # wait group updates.
1388+ @ doc false
1389+ def resume_parent_in_multi ( repo , workflow_id , additional_context ) do
1390+ safe_context = sanitize_for_json ( additional_context )
13161391
1317- defp maybe_complete_wait_group ( config , pending_event , payload , parent_id ) do
1318- wait_group = Repo . get ( config , WaitGroup , pending_event . wait_group_id )
1392+ case repo . get ( WorkflowExecution , workflow_id ) do
1393+ nil ->
1394+ { :error , :workflow_not_found }
13191395
1320- if wait_group && wait_group . status == :pending do
1321- { :ok , updated_group } =
1322- wait_group
1323- |> WaitGroup . add_event_changeset ( pending_event . event_name , payload )
1324- |> Repo . update ( config )
1396+ % WorkflowExecution { status: :waiting } = exec ->
1397+ new_context = Map . merge ( exec . context || % { } , safe_context )
13251398
1326- if updated_group . status == :completed do
1327- resume_workflow ( parent_id )
1328- end
1399+ exec
1400+ |> Ecto.Changeset . change (
1401+ context: new_context ,
1402+ status: :pending ,
1403+ scheduled_at: nil ,
1404+ locked_by: nil ,
1405+ locked_at: nil
1406+ )
1407+ |> repo . update ( )
1408+
1409+ % WorkflowExecution { status: status } ->
1410+ # Already moved on (cancelled, completed, etc.). Tolerate; the
1411+ # upstream event/wait group state is still committed by the
1412+ # surrounding Multi.
1413+ { :ok , % { status: status , no_op: true } }
13291414 end
13301415 end
13311416
@@ -1390,6 +1475,7 @@ defmodule Durable.Executor do
13901475 |> Ecto.Changeset . change (
13911476 context: new_context ,
13921477 status: :pending ,
1478+ scheduled_at: nil ,
13931479 locked_by: nil ,
13941480 locked_at: nil
13951481 )
@@ -1536,7 +1622,7 @@ defmodule Durable.Executor do
15361622 { :ok , _exec } =
15371623 exec
15381624 |> WorkflowExecution . compensation_failed_changeset ( results , original_error )
1539- |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil )
1625+ |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil , scheduled_at: nil )
15401626 |> Repo . update ( config )
15411627
15421628 { :error , original_error }
@@ -1545,7 +1631,12 @@ defmodule Durable.Executor do
15451631 { :ok , _exec } =
15461632 exec
15471633 |> WorkflowExecution . compensated_changeset ( results )
1548- |> Ecto.Changeset . change ( locked_by: nil , locked_at: nil , error: original_error )
1634+ |> Ecto.Changeset . change (
1635+ locked_by: nil ,
1636+ locked_at: nil ,
1637+ scheduled_at: nil ,
1638+ error: original_error
1639+ )
15491640 |> Repo . update ( config )
15501641
15511642 { :error , original_error }
@@ -1558,9 +1649,19 @@ defmodule Durable.Executor do
15581649 defp handle_sleep ( config , execution , opts ) do
15591650 wake_at = calculate_wake_time ( opts )
15601651
1652+ # Clear the lock alongside the :waiting + scheduled_at update. The
1653+ # SleepWaker eventually flips this row back to :pending so the queue
1654+ # poller can re-claim it; if the lock weren't cleared here it would
1655+ # sit stale on the row until either stale-lock recovery (which only
1656+ # acts on :running) or the (already-fixed) zombie sweeper got to it.
15611657 { :ok , execution } =
15621658 execution
1563- |> Ecto.Changeset . change ( status: :waiting , scheduled_at: wake_at )
1659+ |> Ecto.Changeset . change (
1660+ status: :waiting ,
1661+ scheduled_at: wake_at ,
1662+ locked_by: nil ,
1663+ locked_at: nil
1664+ )
15641665 |> Repo . update ( config )
15651666
15661667 { :waiting , execution }
@@ -1587,7 +1688,7 @@ defmodule Durable.Executor do
15871688
15881689 { :ok , execution } =
15891690 execution
1590- |> Ecto.Changeset . change ( status: :waiting )
1691+ |> Ecto.Changeset . change ( status: :waiting , scheduled_at: nil )
15911692 |> Repo . update ( config )
15921693
15931694 { :waiting , execution }
@@ -1618,7 +1719,7 @@ defmodule Durable.Executor do
16181719
16191720 { :ok , execution } =
16201721 execution
1621- |> Ecto.Changeset . change ( status: :waiting )
1722+ |> Ecto.Changeset . change ( status: :waiting , scheduled_at: nil )
16221723 |> Repo . update ( config )
16231724
16241725 DurablePubSub . broadcast_workflow ( config , :workflow_waiting , workflow_event ( execution ) )
@@ -1674,7 +1775,7 @@ defmodule Durable.Executor do
16741775
16751776 { :ok , execution } =
16761777 execution
1677- |> Ecto.Changeset . change ( status: :waiting )
1778+ |> Ecto.Changeset . change ( status: :waiting , scheduled_at: nil )
16781779 |> Repo . update ( config )
16791780
16801781 { :waiting , execution }
0 commit comments