Skip to content

Commit 53f7ea5

Browse files
committed
test(engine): DB-backed concurrency, retry, race & query coverage
Real Postgres-backed suites replacing mocks: parallel race, retry/backoff, query API, timeout-worker integration, and the integration/concurrency tests.
1 parent 6b56c35 commit 53f7ea5

9 files changed

Lines changed: 1217 additions & 8 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
defmodule Durable.Integration.ConcurrencyTest do
2+
@moduledoc """
3+
Real-concurrency coverage that the shared SQL Sandbox cannot provide.
4+
5+
Every other DB test runs inside ONE sandboxed connection wrapped in a single
6+
transaction, so two "concurrent" operations actually serialize and the
7+
locking primitives (`FOR UPDATE SKIP LOCKED`, `FOR UPDATE` row locks) are
8+
never contended — a regression that dropped the locks would still pass. These
9+
tests use `Sandbox.unboxed_run/2` to run against REAL, committed connections
10+
on separate backends, so the locks are genuinely exercised.
11+
12+
They commit + truncate, so they're tagged `:integration` and excluded from the
13+
default suite. Run with: `mix test --only integration`.
14+
"""
15+
use ExUnit.Case, async: false
16+
17+
@moduletag :integration
18+
19+
alias Durable.Config
20+
alias Durable.Queue.Adapters.Postgres
21+
alias Durable.Storage.Schemas.{PendingEvent, WaitGroup, WorkflowExecution}
22+
alias Ecto.Adapters.SQL.Sandbox
23+
24+
@repo Durable.TestRepo
25+
26+
setup do
27+
start_supervised!({Durable, repo: @repo, queue_enabled: false, pubsub: :start})
28+
truncate!()
29+
on_exit(&truncate!/0)
30+
%{config: Config.get(Durable)}
31+
end
32+
33+
defp truncate! do
34+
Sandbox.unboxed_run(@repo, fn ->
35+
@repo.query!(
36+
"TRUNCATE durable.workflow_executions, durable.scheduled_workflows RESTART IDENTITY CASCADE"
37+
)
38+
end)
39+
end
40+
41+
defp committed(fun), do: Sandbox.unboxed_run(@repo, fun)
42+
43+
describe "fetch_jobs/4 — FOR UPDATE SKIP LOCKED across real backends" do
44+
test "two workers claiming concurrently never double-claim a job", %{config: config} do
45+
committed(fn ->
46+
for i <- 1..40 do
47+
@repo.insert!(%WorkflowExecution{
48+
workflow_module: "Elixir.IntegrationWorkflow",
49+
workflow_name: "claim_#{i}",
50+
status: :pending,
51+
queue: "default",
52+
priority: 0,
53+
input: %{},
54+
context: %{}
55+
})
56+
end
57+
end)
58+
59+
claim = fn node ->
60+
committed(fn ->
61+
config |> Postgres.fetch_jobs("default", 30, node) |> Enum.map(& &1.id)
62+
end)
63+
end
64+
65+
[a, b] =
66+
[
67+
Task.async(fn -> claim.("node_a") end),
68+
Task.async(fn -> claim.("node_b") end)
69+
]
70+
|> Task.await_many(15_000)
71+
72+
overlap = MapSet.intersection(MapSet.new(a), MapSet.new(b))
73+
74+
assert MapSet.size(overlap) == 0,
75+
"two backends claimed the same job(s): #{inspect(MapSet.to_list(overlap))}"
76+
77+
# Every job is claimed exactly once across the two workers.
78+
assert length(a) + length(b) == 40
79+
assert MapSet.size(MapSet.new(a ++ b)) == 40
80+
end
81+
end
82+
83+
describe "WaitGroup.add_event_locked/4 — FOR UPDATE under real contention" do
84+
test "concurrent sibling completions never lose an event", %{config: _config} do
85+
# A wait group expecting 8 events, plus its pending event rows.
86+
event_names = for i <- 1..8, do: "evt_#{i}"
87+
88+
{wf_id, wg_id} =
89+
committed(fn ->
90+
wf =
91+
@repo.insert!(%WorkflowExecution{
92+
workflow_module: "Elixir.IntegrationWorkflow",
93+
workflow_name: "wg_parent",
94+
status: :waiting,
95+
queue: "default",
96+
priority: 0,
97+
input: %{},
98+
context: %{}
99+
})
100+
101+
wg =
102+
%WaitGroup{}
103+
|> WaitGroup.changeset(%{
104+
workflow_id: wf.id,
105+
step_name: "fan",
106+
wait_type: :all,
107+
event_names: event_names
108+
})
109+
|> @repo.insert!()
110+
111+
for name <- event_names do
112+
%PendingEvent{}
113+
|> PendingEvent.changeset(%{
114+
workflow_id: wf.id,
115+
event_name: name,
116+
step_name: "fan",
117+
wait_group_id: wg.id,
118+
wait_type: :all
119+
})
120+
|> @repo.insert!()
121+
end
122+
123+
{wf.id, wg.id}
124+
end)
125+
126+
_ = wf_id
127+
128+
# 8 backends each merge a distinct event concurrently. Each runs inside a
129+
# transaction (as production does via the executor's Ecto.Multi) so the
130+
# FOR UPDATE row lock is held across the read-modify-write on
131+
# received_events and serializes the siblings — without it, concurrent
132+
# merges overwrite each other and events are lost.
133+
event_names
134+
|> Enum.map(fn name ->
135+
Task.async(fn ->
136+
committed(fn ->
137+
@repo.transaction(fn ->
138+
WaitGroup.add_event_locked(@repo, wg_id, name, %{"ok" => true})
139+
end)
140+
end)
141+
end)
142+
end)
143+
|> Task.await_many(15_000)
144+
145+
received = committed(fn -> @repo.get!(WaitGroup, wg_id).received_events end)
146+
147+
assert map_size(received) == 8,
148+
"lost an event under contention: #{inspect(Map.keys(received))}"
149+
150+
assert Enum.sort(Map.keys(received)) == Enum.sort(event_names)
151+
152+
# Exactly one task observed the group transition to :completed.
153+
assert committed(fn -> @repo.get!(WaitGroup, wg_id).status end) == :completed
154+
end
155+
end
156+
end

durable/test/durable/orchestration_test.exs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule Durable.OrchestrationTest do
1313

1414
alias Durable.Config
1515
alias Durable.Executor
16-
alias Durable.Storage.Schemas.{PendingEvent, WorkflowExecution}
16+
alias Durable.Storage.Schemas.{PendingEvent, StepExecution, WorkflowExecution}
1717

1818
import Ecto.Query
1919

@@ -65,6 +65,30 @@ defmodule Durable.OrchestrationTest do
6565
assert parent.context["after_call"] == true
6666
end
6767

68+
test "records the spawned child id on the calling step (queryable parent→child link)" do
69+
config = Config.get(Durable)
70+
repo = config.repo
71+
72+
{:ok, parent} = create_and_execute_workflow(CallWorkflowParent, %{})
73+
assert parent.status == :waiting
74+
75+
child_id = parent.context["__child:simple_child_workflow"]
76+
assert child_id != nil
77+
78+
# The parent's :call_child step row links directly to the spawned child —
79+
# no need to parse the __call_children JSONB context.
80+
linked =
81+
repo.all(
82+
from(s in StepExecution,
83+
where: s.workflow_id == ^parent.id and not is_nil(s.child_workflow_id)
84+
)
85+
)
86+
87+
assert [step] = linked
88+
assert step.step_name == "call_child"
89+
assert step.child_workflow_id == child_id
90+
end
91+
6892
test "parent calls child, child fails, parent gets error" do
6993
config = Config.get(Durable)
7094
repo = config.repo

0 commit comments

Comments
 (0)