Skip to content

Commit 6b56c35

Browse files
committed
feat(engine): harden durable execution — sleep/wait, fencing, retries, child workflows
Lock fencing + ownership guards on job claim, durable retry/backoff with step timeout, child-workflow linkage, a dedicated sleep waker, and the lock-fencing + child-workflow-link schema migrations.
1 parent 2cb04b7 commit 6b56c35

19 files changed

Lines changed: 1045 additions & 186 deletions

durable/lib/durable/config.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ defmodule Durable.Config do
4545
heartbeat_interval: pos_integer(),
4646
scheduled_modules: [module()],
4747
scheduler_interval: pos_integer(),
48+
sleep_waker_interval: pos_integer(),
49+
sleep_waker_batch_size: pos_integer(),
4850
log_level: false | :debug | :info | :warning | :error,
4951
pubsub: atom() | nil,
5052
owns_pubsub?: boolean()
@@ -60,6 +62,8 @@ defmodule Durable.Config do
6062
:heartbeat_interval,
6163
:scheduled_modules,
6264
:scheduler_interval,
65+
:sleep_waker_interval,
66+
:sleep_waker_batch_size,
6367
:log_level,
6468
:pubsub,
6569
owns_pubsub?: false
@@ -111,6 +115,18 @@ defmodule Durable.Config do
111115
default: 60_000,
112116
doc: "Milliseconds between scheduler polls for due schedules"
113117
],
118+
sleep_waker_interval: [
119+
type: :pos_integer,
120+
default: 1_000,
121+
doc:
122+
"Milliseconds between sleep-waker sweeps that revive workflows " <>
123+
"whose `sleep/1` or `schedule_at/1` wait has elapsed"
124+
],
125+
sleep_waker_batch_size: [
126+
type: :pos_integer,
127+
default: 100,
128+
doc: "Maximum number of sleeping workflows to wake in a single sweep"
129+
],
114130
log_level: [
115131
type: {:in, [false, :debug, :info, :warning, :error]},
116132
default: false,

durable/lib/durable/executor.ex

Lines changed: 133 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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}

durable/lib/durable/executor/backoff.ex

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ defmodule Durable.Executor.Backoff do
1818
}
1919

2020
@default_base 1_000
21-
@default_max_backoff 3_600_000
21+
# Backoff is currently an in-process `Process.sleep/1` that blocks the worker
22+
# and holds the job lock for its duration. A multi-minute (let alone 1-hour)
23+
# default would pin a queue concurrency slot and risk crossing the stale-lock
24+
# timeout. Cap the default well under `stale_lock_timeout` (300s). Callers
25+
# that genuinely want longer, durable backoff should set `max_backoff`
26+
# explicitly — and ideally a future re-enqueue-based backoff will lift this
27+
# ceiling without blocking a worker.
28+
@default_max_backoff 30_000
2229

2330
@doc """
2431
Calculates the delay before the next retry attempt.
@@ -32,7 +39,9 @@ defmodule Durable.Executor.Backoff do
3239
## Options
3340
3441
- `:base` - Base delay in milliseconds (default: 1000)
35-
- `:max_backoff` - Maximum delay in milliseconds (default: 3600000 = 1 hour)
42+
- `:max_backoff` - Maximum delay in milliseconds (default: 30000 = 30s).
43+
Kept low because the backoff blocks the worker in-process; raise it only
44+
if your `stale_lock_timeout` comfortably exceeds the chosen ceiling.
3645
3746
## Examples
3847
@@ -83,15 +92,21 @@ defmodule Durable.Executor.Backoff do
8392
Backoff.calculate_with_jitter(:exponential, 1, %{base: 1000})
8493
8594
"""
86-
@spec calculate_with_jitter(strategy(), pos_integer(), opts()) :: pos_integer()
95+
@spec calculate_with_jitter(strategy(), pos_integer(), opts()) :: non_neg_integer()
8796
def calculate_with_jitter(strategy, attempt, opts \\ %{}) do
8897
base_delay = calculate(strategy, attempt, opts)
89-
jitter_factor = 0.25
90-
jitter_range = trunc(base_delay * jitter_factor)
91-
92-
# Add random jitter between -jitter_range and +jitter_range
93-
jitter = :rand.uniform(jitter_range * 2) - jitter_range
94-
max(0, base_delay + jitter)
98+
jitter_range = trunc(base_delay * 0.25)
99+
100+
# `:rand.uniform/1` requires a positive argument. For small delays (a base
101+
# under ~4 ms, e.g. fast test retries) `jitter_range` truncates to 0 and
102+
# `:rand.uniform(0)` would crash the worker mid-retry. Skip jitter when
103+
# there's no meaningful range to jitter over.
104+
if jitter_range <= 0 do
105+
base_delay
106+
else
107+
jitter = :rand.uniform(jitter_range * 2) - jitter_range
108+
max(0, base_delay + jitter)
109+
end
95110
end
96111

97112
@doc """

0 commit comments

Comments
 (0)