Skip to content

Commit e3d9eb6

Browse files
committed
perf(enactment): debounce snapshot writes via mailbox drain
Replace `GenServer.cast(self(), :take_snapshot)` with `send(self(), :take_snapshot)` and a `handle_info` clause that drains queued `:take_snapshot` messages via selective `receive` before writing. Bursts of `complete_workitems` calls now collapse into a single storage write of the latest state. Using a plain atom (rather than a cast envelope) avoids coupling to OTP's private `:"$gen_cast"` shape. Register a new `[:coloured_flow, :runner, :enactment, :take_snapshot]` telemetry event for observability and update the default logger so it does not crash on the new event. The integration test in `enactment_test.exs` attaches to this event to assert that a burst of 5 snapshot requests yields fewer than 5 storage writes.
1 parent 4c66c1c commit e3d9eb6

4 files changed

Lines changed: 136 additions & 18 deletions

File tree

lib/coloured_flow/runner/enactment/enactment.ex

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ defmodule ColouredFlow.Runner.Enactment do
339339
)
340340
|> case do
341341
{:ok, {{completed_workitems, new_state}, continue}} ->
342-
:ok = GenServer.cast(self(), :take_snapshot)
342+
send(self(), :take_snapshot)
343343

344344
{:reply, {:ok, completed_workitems}, new_state, continue}
345345

@@ -348,16 +348,6 @@ defmodule ColouredFlow.Runner.Enactment do
348348
end
349349
end
350350

351-
@impl GenServer
352-
def handle_cast(:take_snapshot, %__MODULE__{} = state) do
353-
Storage.take_enactment_snapshot(state.enactment_id, %Snapshot{
354-
version: state.version,
355-
markings: to_list(state.markings)
356-
})
357-
358-
{:noreply, state, Lifespan.timeout(state)}
359-
end
360-
361351
# we peek at the first workitem to pre-check the state
362352
@spec preflight_completion(state(), [Workitem.id()]) ::
363353
{:ok, :complete_e | :complete, state()} | {:error, Exception.t()}
@@ -516,10 +506,35 @@ defmodule ColouredFlow.Runner.Enactment do
516506
end
517507

518508
@impl GenServer
509+
def handle_info(:take_snapshot, %__MODULE__{} = state) do
510+
drain_take_snapshot_messages()
511+
512+
emit_event(:take_snapshot, state)
513+
514+
Storage.take_enactment_snapshot(state.enactment_id, %Snapshot{
515+
version: state.version,
516+
markings: to_list(state.markings)
517+
})
518+
519+
{:noreply, state, Lifespan.timeout(state)}
520+
end
521+
519522
def handle_info(:timeout, state) do
520523
{:stop, {:shutdown, "Terminated due to inactivity"}, state}
521524
end
522525

526+
# Selectively drain any queued `:take_snapshot` messages in the mailbox so
527+
# bursts of `complete_workitems` collapse into a single storage write of the
528+
# latest state.
529+
@spec drain_take_snapshot_messages() :: :ok
530+
defp drain_take_snapshot_messages do
531+
receive do
532+
:take_snapshot -> drain_take_snapshot_messages()
533+
after
534+
0 -> :ok
535+
end
536+
end
537+
523538
@impl GenServer
524539
def terminate({:shutdown, reason}, state) do
525540
emit_event(:stop, state, %{reason: reason})

lib/coloured_flow/runner/telemetry.ex

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,18 @@ defmodule ColouredFlow.Runner.Telemetry do
1111
- `[:coloured_flow, :runner, :enactment, :stop]`
1212
- `[:coloured_flow, :runner, :enactment, :terminate]`
1313
- `[:coloured_flow, :runner, :enactment, :exception]`
14+
- `[:coloured_flow, :runner, :enactment, :take_snapshot]`
1415
1516
All enactment events share the same measurements and metadata, but their
1617
metadata are different.
1718
18-
| event | measurements | metadata |
19-
| ------------ | --------------------------------- | ------------------------------------------------------------------------------- |
20-
| `:start` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state` |
21-
| `:stop` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state`, `:reason` |
22-
| `:terminate` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state`, `termination_type`, `:termination_message` |
23-
| `:exception` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state`, `:exception_reason`, `:exception` |
19+
| event | measurements | metadata |
20+
| ---------------- | --------------------------------- | ------------------------------------------------------------------------------- |
21+
| `:start` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state` |
22+
| `:stop` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state`, `:reason` |
23+
| `:terminate` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state`, `termination_type`, `:termination_message` |
24+
| `:exception` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state`, `:exception_reason`, `:exception` |
25+
| `:take_snapshot` | `:system_time`, `:monotonic_time` | `:enactment_id`, `:enactment_state` |
2426
2527
Note that `:start` and `:stop` events will be emitted when the enactment server
2628
starts and stops respectively. So a `:stop` event will be emitted right after
@@ -234,6 +236,7 @@ defmodule ColouredFlow.Runner.Telemetry do
234236
[:coloured_flow, :runner, :enactment, :stop],
235237
[:coloured_flow, :runner, :enactment, :terminate],
236238
[:coloured_flow, :runner, :enactment, :exception],
239+
[:coloured_flow, :runner, :enactment, :take_snapshot],
237240
[:coloured_flow, :runner, :enactment, :produce_workitems, :start],
238241
[:coloured_flow, :runner, :enactment, :produce_workitems, :stop],
239242
[:coloured_flow, :runner, :enactment, :produce_workitems, :exception],

lib/coloured_flow/runner/telemetry/default_logger.ex

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule ColouredFlow.Runner.Telemetry.DefaultLogger do
1111
metadata,
1212
opts
1313
)
14-
when transition in [:start, :stop, :terminate, :exception] do
14+
when transition in [:start, :stop, :terminate, :exception, :take_snapshot] do
1515
log(:enactment, opts, fn ->
1616
basic =
1717
case transition do
@@ -43,6 +43,12 @@ defmodule ColouredFlow.Runner.Telemetry.DefaultLogger do
4343
exception_reason: metadata.exception_reason,
4444
error: Exception.format_banner(:error, metadata.exception)
4545
}
46+
47+
:take_snapshot ->
48+
%{
49+
event: "enactment:take_snapshot",
50+
system_time: convert_system_time(measurements.system_time)
51+
}
4652
end
4753

4854
enactment = build_enactment_info(metadata.enactment_state)

test/coloured_flow/runner/enactment/enactment_test.exs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,4 +255,98 @@ defmodule ColouredFlow.Runner.EnactmentTest do
255255
Enum.map(current_workitems, & &1.id) -- Enum.map(previous_workitems, & &1.id)
256256
end
257257
end
258+
259+
describe "take_snapshot debounce" do
260+
test "drains queued :take_snapshot messages before writing", %{enactment: enactment} do
261+
enactment_id = enactment.id
262+
263+
state = %Enactment{
264+
enactment_id: enactment_id,
265+
version: 1,
266+
markings: %{},
267+
workitems: %{}
268+
}
269+
270+
# queue extra :take_snapshot messages before invoking the handler;
271+
# the first invocation should drain all of them in one shot.
272+
for _i <- 1..4, do: send(self(), :take_snapshot)
273+
274+
assert {:noreply, ^state, _timeout} =
275+
Enactment.handle_info(:take_snapshot, state)
276+
277+
# mailbox should now be empty of :take_snapshot messages because the
278+
# debounce drained them; no further snapshot writes are needed.
279+
refute_received :take_snapshot
280+
281+
# only one snapshot should have been persisted for the burst.
282+
assert %Schemas.Snapshot{version: 1} =
283+
Schemas.Snapshot |> from() |> where(enactment_id: ^enactment_id) |> Repo.one()
284+
end
285+
286+
test "single :take_snapshot message still writes one snapshot", %{enactment: enactment} do
287+
enactment_id = enactment.id
288+
289+
state = %Enactment{
290+
enactment_id: enactment_id,
291+
version: 2,
292+
markings: %{},
293+
workitems: %{}
294+
}
295+
296+
assert {:noreply, ^state, _timeout} =
297+
Enactment.handle_info(:take_snapshot, state)
298+
299+
refute_received :take_snapshot
300+
301+
assert %Schemas.Snapshot{version: 2} =
302+
Schemas.Snapshot |> from() |> where(enactment_id: ^enactment_id) |> Repo.one()
303+
end
304+
305+
test "coalesces a burst of :take_snapshot messages into fewer storage writes",
306+
%{enactment: enactment} do
307+
[enactment_server: enactment_server] = start_enactment(%{enactment: enactment})
308+
309+
# Wait until the boot-time snapshot from `handle_continue` has been emitted
310+
# so it is not counted in the burst measurement.
311+
:ok = wait_enactment_requests_handled!(enactment_server)
312+
313+
self_pid = self()
314+
handler_id = "snapshot-debounce-test-#{System.unique_integer([:positive])}"
315+
316+
:ok =
317+
:telemetry.attach(
318+
handler_id,
319+
[:coloured_flow, :runner, :enactment, :take_snapshot],
320+
fn _event, _measurements, _metadata, _config ->
321+
send(self_pid, :snapshot_taken)
322+
end,
323+
nil
324+
)
325+
326+
on_exit(fn -> :telemetry.detach(handler_id) end)
327+
328+
burst_size = 5
329+
for _i <- 1..burst_size, do: send(enactment_server, :take_snapshot)
330+
331+
# Block until the GenServer has processed the queued messages.
332+
:ok = wait_enactment_requests_handled!(enactment_server)
333+
334+
snapshot_stream =
335+
Stream.repeatedly(fn ->
336+
receive do
337+
:snapshot_taken -> :ok
338+
after
339+
50 -> :end_of_stream
340+
end
341+
end)
342+
343+
snapshot_count =
344+
snapshot_stream
345+
|> Enum.take_while(&(&1 == :ok))
346+
|> length()
347+
348+
assert snapshot_count >= 1
349+
assert snapshot_count < burst_size
350+
end
351+
end
258352
end

0 commit comments

Comments
 (0)