diff --git a/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md b/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md index 3655cb4..16b646a 100644 --- a/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md +++ b/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md @@ -1105,3 +1105,35 @@ the `name`, `origin` and `opts` handed to `deliver_internal/5`, and no reason either. This Note decides nothing about carrying the reason on the event: that would change what a chart's `_event.data` reads, and it is left for a later ruling. + +## Note (2026-10-11): the `:send_delayed` and `:cancel` rows are pinned by tests + +Pure addition: nothing above is edited and no decision moves. The Note of +2026-10-04 above says "No test fails a `:send_delayed` or a `:cancel`; +those two rows are read from `reentry_origin/1`." That sentence no longer +holds: two tests now fail each effect and read what the re-entry carries. +Appending this Note was decided by the conductor under a standing consent, +2026-10-11. The code they pin is unchanged; `reentry_origin/1` +(`lib/statifier_persistence/executions.ex`) was read on `main` at +`47193cd`. + +Both tests are in `test/statifier_persistence/executions_test.exs`, in +the "error re-entry (ADR-0004 decision 4)" group: + +- "a failed :send_delayed re-enters as error.communication carrying its + sendid": the executor fails the delayed ``, and the + `[:statifier_persistence, :execution, :step, :reentered]` event carries + the name `"error.communication"`, the origin `{:content, c_index, owner}` + of the failed effect, and options exactly `[sendid: id]` with the + author's id. The chart takes `error.communication` on + `_event.sendid` equal to that id, and the persisted position is the + state that transition enters. +- "a failed :cancel re-enters as error.communication with no sendid": the + executor fails the ``, and the same event carries the origin + `{:content, c_index, owner}` of the `` and options exactly `[]`. + The chart's transition on `_event.sendid` equal to the cancelled send's + id is not taken, and the persisted position is the state its fallback + transition enters. + +The table's `data` column is not read by either test; it stays as the +Note above reads it from the code. diff --git a/test/statifier_persistence/executions_test.exs b/test/statifier_persistence/executions_test.exs index d9fb883..637e6d2 100644 --- a/test/statifier_persistence/executions_test.exs +++ b/test/statifier_persistence/executions_test.exs @@ -40,7 +40,13 @@ defmodule StatifierPersistence.ExecutionsTest do :ok end - alias Statifier.Effect.{BudgetExhausted, Log, SendDelayed} + @spec forward_reentered([atom()], map(), map(), %{pid: pid()}) :: :ok + def forward_reentered(_name, _measurements, metadata, %{pid: pid}) do + send(pid, {:reentered, metadata}) + :ok + end + + alias Statifier.Effect.{BudgetExhausted, Cancel, Log, SendDelayed} alias Statifier.Event alias Statifier.Invoke.Types, as: InvokeTypes alias Statifier.Machine @@ -219,6 +225,44 @@ defmodule StatifierPersistence.ExecutionsTest do """ + # A delayed with an author id the executor can fail. The re-entry + # reaches "errored" only when it carries that id as its sendid; one that + # carries no sendid, or another, falls through to "unnamed". + @delayed_send_error_chart_source """ + + + + + + + + + + + + + + """ + + # A the executor can fail. The re-entry carries no sendid, so it + # reaches "errored"; one that carried the cancelled send's id would take + # the first transition into "named". + @cancel_error_chart_source """ + + + + + + + + + + + + + + """ + # A the executor can fail - observational, so the execution must not # take the error.communication transition that would betray a re-entry. @log_error_chart_source """ @@ -346,6 +390,25 @@ defmodule StatifierPersistence.ExecutionsTest do |> Enum.sort() end + # A fresh execution id, with this test's process subscribed to the + # `:reentered` events. The handler is global and the suite is async, so + # the id is unique and each assertion matches on it. + defp attach_reentered(label) do + unique = System.unique_integer([:positive]) + handler_id = "reentered-#{label}-#{unique}" + + :ok = + :telemetry.attach( + handler_id, + [:statifier_persistence, :execution, :step, :reentered], + &__MODULE__.forward_reentered/4, + %{pid: self()} + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + "execution-#{label}-#{unique}" + end + # An executor failing exactly the given effect tags, recording every call # through RecordingExecutor either way. defp failing_executor(tags, reason \\ :down) do @@ -820,6 +883,70 @@ defmodule StatifierPersistence.ExecutionsTest do assert active_ids(loaded) == ["errored"] end + # sabotage: reentry_origin/1's :send_delayed clause re-enters with no opts -> red on the :reentered payload + test "a failed :send_delayed re-enters as error.communication carrying its sendid", + %{store: store} do + machine = compile!(@delayed_send_error_chart_source) + executor = failing_executor([:send_delayed]) + execution_id = attach_reentered("delayed") + + {:ok, _execution, _ms} = + Executions.create(store, execution_id, machine, executor: executor) + + assert {:ok, %Execution{status: :active}, stepped} = + Executions.step(store, execution_id, machine, Event.external("go"), + executor: executor, + routes: Routes.new(parent?: true) + ) + + assert [{:send_delayed, %SendDelayed{send_id: "due-reminder"} = delayed}] = + Enum.filter(RecordingExecutor.effects(), &match?({:send_delayed, _}, &1)) + + assert_received {:reentered, + %{ + execution_id: ^execution_id, + name: "error.communication", + origin: origin, + opts: [sendid: "due-reminder"] + }} + + assert origin == {:content, delayed.c_index, delayed.owner} + assert active_ids(stepped) == ["errored"] + assert {:ok, loaded} = Storage.load_execution_position(store, execution_id, machine) + assert active_ids(loaded) == ["errored"] + end + + # sabotage: reentry_origin/1's :cancel clause re-enters with [sendid: send_id] -> red on the :reentered payload + test "a failed :cancel re-enters as error.communication with no sendid", %{store: store} do + machine = compile!(@cancel_error_chart_source) + executor = failing_executor([:cancel]) + execution_id = attach_reentered("cancel") + + {:ok, _execution, _ms} = + Executions.create(store, execution_id, machine, executor: executor) + + assert {:ok, %Execution{status: :active}, stepped} = + Executions.step(store, execution_id, machine, Event.external("go"), + executor: executor + ) + + assert [{:cancel, %Cancel{send_id: "due-reminder"} = cancel}] = + Enum.filter(RecordingExecutor.effects(), &match?({:cancel, _}, &1)) + + assert_received {:reentered, + %{ + execution_id: ^execution_id, + name: "error.communication", + origin: origin, + opts: [] + }} + + assert origin == {:content, cancel.c_index, cancel.owner} + assert active_ids(stepped) == ["errored"] + assert {:ok, loaded} = Storage.load_execution_position(store, execution_id, machine) + assert active_ids(loaded) == ["errored"] + end + # sabotage: reentry_origin/1's catch-all re-enters as {:state, 0} -> red (execution lands in errored) test "a failed :log is discarded and the execution is unaffected", %{store: store} do machine = compile!(@log_error_chart_source)