├── test ├── support │ ├── dependent_project │ │ ├── test │ │ │ ├── test_helper.exs │ │ │ └── dependent_project_test.exs │ │ ├── .formatter.exs │ │ ├── lib │ │ │ └── dependent_project.ex │ │ ├── README.md │ │ ├── .gitignore │ │ └── mix.exs │ ├── protean_test_helper.ex │ ├── trigger.ex │ ├── protean_test_case.ex │ └── test_machines.ex ├── protean_test_helper_test.exs ├── test_helper.exs ├── integration │ ├── misc_test.exs │ ├── send_event_test.exs │ ├── custom_action_test.exs │ ├── dependent_processes_test.exs │ ├── simple_test.exs │ ├── autoforward_test.exs │ ├── replies_test.exs │ ├── subscription_test.exs │ ├── ping_pong_test.exs │ ├── final_states_test.exs │ ├── choose_test.exs │ ├── spawned_stream_test.exs │ ├── guard_test.exs │ ├── automatic_transition_test.exs │ ├── delayed_transition_test.exs │ ├── internal_transitions_test.exs │ ├── spawned_task_test.exs │ └── spawned_machine_test.exs ├── protean │ ├── node_test.exs │ ├── context_test.exs │ ├── interpreter │ │ └── server_test.exs │ ├── interpreter_test.exs │ ├── transition_test.exs │ ├── machinery_test.exs │ ├── parser_test.exs │ └── action_test.exs └── protean_test.exs ├── CHANGELOG.md ├── config ├── config.exs └── .credo.exs ├── coveralls.json ├── .formatter.exs ├── lib └── protean │ ├── application.ex │ ├── events.ex │ ├── utils │ └── tree.ex │ ├── utils.ex │ ├── guard.ex │ ├── pub_sub.ex │ ├── transition.ex │ ├── machine_config.ex │ ├── interpreter │ ├── server.ex │ ├── hooks.ex │ └── features.ex │ ├── context.ex │ ├── process_manager.ex │ ├── machinery.ex │ ├── node.ex │ ├── interpreter.ex │ └── builder.ex ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── mix.exs ├── docs ├── guides │ └── introduction.livemd └── examples │ └── debounce_and_throttle.livemd ├── mix.lock └── README.md /test/support/dependent_project/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/support/dependent_project/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/protean_test_helper_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.TestHelperTest do 2 | use ExUnit.Case, async: true 3 | import Protean.TestHelper 4 | doctest Protean.TestHelper 5 | end 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.0 (2022-11-17) 4 | 5 | This release is being cut because I intend to make some significant changes to Protean and this represents a fairly stable initial implementation. 6 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :protean, 4 | supervisor: DynamicSupervisor, 5 | registry: Registry 6 | 7 | config :protean, :pubsub, 8 | name: Protean.PubSub, 9 | start: true 10 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/support" 4 | ], 5 | "terminal_options": { 6 | "print_files": false 7 | }, 8 | "coverage_options": { 9 | "treat_no_relevant_lines_as_covered": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/support/dependent_project/test/dependent_project_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DependentProjectTest do 2 | use ExUnit.Case 3 | doctest DependentProject 4 | 5 | test "greets the world" do 6 | assert DependentProject.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | inputs = 3 | Enum.flat_map( 4 | ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 5 | &Path.wildcard(&1, match_dot: true) 6 | ) 7 | 8 | [ 9 | inputs: inputs -- ["lib/protean/utils.ex"] 10 | ] 11 | -------------------------------------------------------------------------------- /config/.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["mix.exs", "lib/"] 7 | }, 8 | checks: [ 9 | {Credo.Check.Readability.ModuleDoc, false} 10 | ] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /lib/protean/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_type, _args) do 6 | children = [ 7 | Protean.ProcessManager, 8 | Protean.PubSub 9 | ] 10 | 11 | Supervisor.start_link(children, strategy: :one_for_one) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/support/dependent_project/lib/dependent_project.ex: -------------------------------------------------------------------------------- 1 | defmodule DependentProject do 2 | @moduledoc """ 3 | Documentation for `DependentProject`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> DependentProject.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(capture_log: true) 2 | 3 | # Ensure that all Protean-managed processes have been cleaned up. This should be useful in 4 | # catching process-leak bugs. 5 | ExUnit.after_suite(fn _ -> 6 | n_alive = length(Protean.ProcessManager.which_children()) 7 | n_registered = Protean.ProcessManager.count_registered() 8 | 9 | unless n_alive + n_registered == 0 do 10 | require Logger 11 | 12 | Logger.warn( 13 | "Machines still alive or registered:\n\tAlive: #{n_alive}\n\tRegistered: #{n_registered}" 14 | ) 15 | 16 | exit(:failure) 17 | end 18 | end) 19 | -------------------------------------------------------------------------------- /test/support/dependent_project/README.md: -------------------------------------------------------------------------------- 1 | # DependentProject 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `dependent_project` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:dependent_project, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /lib/protean/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Events do 2 | @moduledoc false 3 | 4 | defmodule Platform do 5 | @moduledoc false 6 | 7 | defstruct [:type, :id, :payload] 8 | end 9 | 10 | @doc "Create a platform event." 11 | def platform(:init) do 12 | %Platform{type: :init} 13 | end 14 | 15 | def platform(:done, id) do 16 | %Platform{type: :done, id: id} 17 | end 18 | 19 | def platform(:spawn, subtype, id) when subtype in [:done, :error] do 20 | %Platform{type: {:spawn, subtype}, id: id} 21 | end 22 | 23 | @doc "Add a payload to a platform event." 24 | def with_payload(%Platform{} = event, payload) do 25 | %{event | payload: payload} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/integration/misc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.MiscTest do 2 | use Protean.TestCase, async: true 3 | 4 | @moduletag trigger: MiscTrigger 5 | 6 | defmodule TestMachine do 7 | use Protean 8 | 9 | @machine [ 10 | initial: :init, 11 | states: [ 12 | atomic(:init, 13 | on: [ 14 | match(_, actions: Trigger.action(MiscTrigger, :received_event)) 15 | ] 16 | ) 17 | ] 18 | ] 19 | end 20 | 21 | @tag machine: TestMachine 22 | test "wildcard match shouldn't match init platform event", %{machine: machine} do 23 | Protean.matches?(machine, :init) 24 | refute Trigger.triggered?(MiscTrigger, :received_event) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/protean/utils/tree.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Utils.Tree do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Reduce over a generic tree structure in depth-first order. 6 | 7 | `reducer` will be called with each node and is expected to return a 2-element 8 | tuple, the accumulator and a list of any children of that node. 9 | """ 10 | @spec reduce(term(), acc, (term(), acc -> {acc, [term()]})) :: acc when acc: var 11 | def reduce(tree, acc, reducer), do: dfs_tree_reduce([tree], acc, reducer) 12 | 13 | defp dfs_tree_reduce([], acc, _reducer), do: acc 14 | 15 | defp dfs_tree_reduce([node | rest], acc, reducer) do 16 | {acc, children} = reducer.(node, acc) 17 | 18 | dfs_tree_reduce(List.wrap(children) ++ rest, acc, reducer) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/dependent_project/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | dependent_project-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /test/support/dependent_project/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule DependentProject.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :dependent_project, 7 | version: "0.1.0", 8 | elixir: "~> 1.13", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | # {:dep_from_hexpm, "~> 0.3.0"}, 25 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 26 | {:protean, path: "../../../"} 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | protean-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Dialyzer PLT files 29 | /priv/plts/ 30 | -------------------------------------------------------------------------------- /test/integration/send_event_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.SendEventTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule SendParent do 5 | use Protean 6 | alias Protean.Action 7 | 8 | @machine [ 9 | initial: "waiting", 10 | states: [ 11 | atomic(:waiting, 12 | on: [ 13 | match({:echo, _}, actions: "echo") 14 | ] 15 | ) 16 | ] 17 | ] 18 | 19 | @impl Protean 20 | def handle_action("echo", context, {:echo, echo}) do 21 | context 22 | |> Action.send({:echo, echo}, to: :parent) 23 | end 24 | end 25 | 26 | describe "SendParent:" do 27 | @describetag machine: SendParent 28 | 29 | test "sending event to: :parent", %{machine: machine} do 30 | Protean.call(machine, {:echo, :echo_back}) 31 | 32 | assert_receive {:echo, :echo_back} 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/protean/node_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.NodeTest do 2 | use ExUnit.Case 3 | 4 | alias Protean.Node 5 | 6 | setup context do 7 | TestMachines.with_test_machine(context) 8 | end 9 | 10 | describe "resolving to leaves" do 11 | @describetag machine: :simple_machine_2 12 | 13 | test "atomic nodes resolve to themselves", %{machine: machine} do 14 | node = machine.idmap[["state_a1_child", "state_a1", "state_a", "#"]] 15 | assert Node.resolve_to_leaves(node) == [node] 16 | end 17 | 18 | test "final nodes resolve to themselves", %{machine: machine} do 19 | node = machine.idmap[["state_b", "#"]] 20 | assert Node.resolve_to_leaves(node) == [node] 21 | end 22 | 23 | test "compound nodes resolve based on their initial child", %{machine: machine} do 24 | node = machine.idmap[["state_a", "#"]] 25 | [resolved] = Node.resolve_to_leaves(node) 26 | 27 | assert resolved.id == ["state_a1_child", "state_a1", "state_a", "#"] 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Zachary Allaun 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /test/support/protean_test_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.TestHelper do 2 | @moduledoc """ 3 | Utilities to improve Protean's internal testing. Should not be relied on externally. 4 | """ 5 | 6 | @doc """ 7 | Create a `Protean.Node` id from a more human-readable string. 8 | 9 | ## Examples 10 | 11 | iex> node_id("#") 12 | ["#"] 13 | 14 | iex> node_id("foo") 15 | ["foo", "#"] 16 | 17 | iex> node_id("foo.bar") 18 | ["bar", "foo", "#"] 19 | 20 | iex> node_id(".foo.bar") 21 | ["bar", "foo", "#"] 22 | 23 | iex> node_id("#foo.bar") 24 | ["bar", "foo", "#"] 25 | 26 | iex> node_id("#.foo.bar") 27 | ["bar", "foo", "#"] 28 | """ 29 | def node_id(human_id) when is_binary(human_id) do 30 | normalized = 31 | human_id 32 | |> String.replace_prefix("#", "") 33 | |> String.replace_prefix(".", "") 34 | 35 | case normalized do 36 | "" -> ["#"] 37 | other -> other |> String.split(".") |> then(&["#" | &1]) |> Enum.reverse() 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/integration/custom_action_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.CustomActionTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule CustomAction do 5 | @behaviour Protean.Action 6 | 7 | def custom do 8 | Protean.Action.new(__MODULE__, :custom) 9 | end 10 | 11 | def exec_action(:custom, interpreter) do 12 | {:halt, interpreter} 13 | end 14 | end 15 | 16 | defmodule TestMachine do 17 | use Protean 18 | 19 | @machine [ 20 | initial: "init", 21 | assigns: [data: nil], 22 | states: [ 23 | atomic(:init, 24 | entry: [ 25 | CustomAction.custom(), 26 | Protean.Action.assign(data: :ok) 27 | ], 28 | on: [ 29 | event: [] 30 | ] 31 | ) 32 | ] 33 | ] 34 | end 35 | 36 | @tag machine: TestMachine 37 | test "custom action halts action pipeline", %{machine: machine} do 38 | assert_protean(machine, 39 | assigns: [data: nil], 40 | call: "event", 41 | assigns: [data: nil] 42 | ) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/integration/dependent_processes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.DependentProcessesTest do 2 | # Cannot be async because test looks into internal Protean.ProcessManager.Supervisor that is 3 | # shared with other tests. 4 | use Protean.TestCase 5 | 6 | defmodule Child do 7 | use Protean 8 | 9 | @machine [ 10 | initial: "init", 11 | states: [atomic(:init)] 12 | ] 13 | end 14 | 15 | defmodule Parent do 16 | use Protean 17 | 18 | @machine [ 19 | initial: "init", 20 | states: [ 21 | atomic(:init, 22 | spawn: [ 23 | proc(Child, id: "child") 24 | ] 25 | ) 26 | ] 27 | ] 28 | end 29 | 30 | @tag machine: Parent 31 | test "child machines exit when their spawning process exits", %{machine: machine} do 32 | n = length(DynamicSupervisor.which_children(Protean.ProcessManager.Supervisor)) 33 | :ok = Protean.stop(machine) 34 | :timer.sleep(20) 35 | assert length(DynamicSupervisor.which_children(Protean.ProcessManager.Supervisor)) == n - 2 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/integration/simple_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.SimpleTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule SimpleMachine do 5 | use Protean 6 | 7 | @machine [ 8 | assigns: [ 9 | data: nil 10 | ], 11 | initial: "a", 12 | states: [ 13 | atomic(:a, 14 | on: [goto_b: "b"] 15 | ), 16 | atomic(:b, 17 | on: [goto_a: "a"] 18 | ) 19 | ], 20 | on: [ 21 | match({:set_data, _}, actions: "do_set") 22 | ] 23 | ] 24 | 25 | @impl true 26 | def handle_action("do_set", context, {_, data}) do 27 | context 28 | |> Protean.Action.assign(:data, data) 29 | end 30 | end 31 | 32 | @moduletag machine: SimpleMachine 33 | 34 | test "SimpleMachine", %{machine: machine} do 35 | assert_protean(machine, 36 | matches: "a", 37 | call: :goto_b, 38 | matches: "b", 39 | assigns: [data: nil], 40 | call: {:set_data, :ok}, 41 | assigns: [data: :ok], 42 | call: :goto_a, 43 | matches: "a", 44 | assigns: [data: :ok] 45 | ) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/integration/autoforward_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.AutoforwardTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule Child do 5 | use Protean 6 | 7 | @machine [ 8 | initial: "receiving", 9 | assigns: [data: []], 10 | states: [ 11 | atomic(:receiving, 12 | on: [ 13 | match({"event", _}, actions: :log_data) 14 | ] 15 | ) 16 | ] 17 | ] 18 | 19 | @impl true 20 | def handle_action(:log_data, context, {_, value}) do 21 | Protean.Action.update_in(context, [:data], &[value | &1]) 22 | end 23 | end 24 | 25 | defmodule Parent do 26 | use Protean 27 | 28 | @machine [ 29 | initial: "forwarding", 30 | states: [ 31 | atomic(:forwarding, 32 | spawn: [ 33 | proc({Child, name: ChildMachine}, 34 | id: "child", 35 | autoforward: true 36 | ) 37 | ] 38 | ) 39 | ] 40 | ] 41 | end 42 | 43 | @tag machine: Parent 44 | test "events are forwarded to spawned children with autoforward: true", %{machine: machine} do 45 | Protean.call(machine, {"event", "a"}) 46 | Protean.call(machine, {"event", "b"}) 47 | Protean.call(machine, {"event", "c"}) 48 | 49 | assert %{data: ["c", "b", "a"]} = Protean.current(ChildMachine).assigns 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/integration/replies_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.RepliesTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule Replying do 5 | use Protean 6 | 7 | @machine [ 8 | initial: "init", 9 | states: [atomic(:init)], 10 | on: [ 11 | {:no_reply, actions: [:no_response]}, 12 | {:one_reply, actions: [{:respond, :one}]}, 13 | {:two_replies, actions: [{:respond, :one}, {:respond, :two}]} 14 | ] 15 | ] 16 | 17 | @impl true 18 | def handle_action({:respond, response}, context, _) do 19 | {:reply, response, context} 20 | end 21 | 22 | def handle_action(:no_response, context, _) do 23 | {:noreply, context} 24 | end 25 | end 26 | 27 | describe "Replying:" do 28 | @describetag machine: Replying 29 | 30 | test "single response in an array", %{machine: machine} do 31 | assert {_, [:one]} = Protean.call(machine, :one_reply) 32 | end 33 | 34 | test "multiple responses come in action order", %{machine: machine} do 35 | assert {_, [:one, :two]} = Protean.call(machine, :two_replies) 36 | end 37 | 38 | test "subscriptions can be to transitions with replies only", %{machine: machine} do 39 | {:ok, id} = Protean.subscribe(machine, filter: :replies) 40 | 41 | Protean.call(machine, :one_reply) 42 | assert_receive {^id, _, [:one]} 43 | 44 | Protean.call(machine, :no_reply) 45 | refute_receive {^id, _, _} 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/protean/context_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.ContextTest do 2 | use ExUnit.Case 3 | 4 | alias Protean.Context 5 | 6 | test "Context.matches? with explicit node ids" do 7 | [ 8 | {[[]], [], true}, 9 | {[["#"]], [], true}, 10 | {[["#"]], ["#"], true}, 11 | {[["foo", "#"]], ["#"], true}, 12 | {[["#"]], ["foo", "#"], false}, 13 | {[["foo", "bar", "#"]], ["bar", "#"], true}, 14 | {[["foo", "bar", "#"]], ["foo", "baz", "#"], false}, 15 | {[["foo", "#"], ["bar", "#"]], ["bar", "#"], true}, 16 | {[["foo", "#"], ["baz", "bar", "#"]], ["bar", "#"], true} 17 | ] 18 | |> Enum.each(&matches_test/1) 19 | end 20 | 21 | test "Context.matches? with shorthand" do 22 | [ 23 | {[[]], "", false}, 24 | {[["#"]], "", true}, 25 | {[["#"]], "#", true}, 26 | {[["foo", "#"]], "", true}, 27 | {[["#"]], "foo", false}, 28 | {[["foo", "#"]], "#foo", true}, 29 | {[["foo", "#"]], "#.foo", true}, 30 | {[["foo", "bar", "#"]], "bar", true}, 31 | {[["foo", "bar", "#"]], "baz.foo", false}, 32 | {[["foo", "#"], ["bar", "#"]], "bar", true}, 33 | {[["foo", "#"], ["baz", "bar", "#"]], "bar", true}, 34 | {[["foo", "#"], ["baz", "bar", "#"]], "bar.baz", true}, 35 | {[["foo", "#"]], "#.##.##.foo", true} 36 | ] 37 | |> Enum.each(&matches_test/1) 38 | end 39 | 40 | defp matches_test({state_value, match_test, expected_result}) do 41 | with context <- %Context{value: state_value} do 42 | assert Context.matches?(context, match_test) === expected_result, 43 | "expected #{inspect(state_value)}" <> 44 | ((expected_result && " to match ") || " to NOT match ") <> 45 | "#{inspect(match_test)}" 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/protean/interpreter/server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.Interpreter.ServerTest do 2 | use Protean.TestCase, async: true 3 | 4 | alias Protean.Interpreter.Server 5 | alias Protean.Context 6 | 7 | defmodule TestMachine do 8 | use Protean 9 | 10 | @machine [ 11 | initial: :a, 12 | states: [ 13 | a: [ 14 | on: [ 15 | goto_b: :b 16 | ] 17 | ], 18 | b: [] 19 | ] 20 | ] 21 | end 22 | 23 | test "server can be started and stopped" do 24 | {:ok, pid} = start_supervised(TestMachine) 25 | assert Protean.matches?(pid, :a) 26 | assert :ok = Server.stop(pid, :normal, :infinity) 27 | refute Process.alive?(pid) 28 | end 29 | 30 | test "server can be started with a name" do 31 | {:ok, _} = start_supervised({TestMachine, name: NamedMachine}) 32 | assert %Context{} = Server.current(NamedMachine) 33 | end 34 | 35 | @tag machine: TestMachine 36 | test "call/3", %{machine: server} do 37 | assert {context = %Context{}, []} = Server.call(server, :goto_b, 5000) 38 | assert Context.matches?(context, :b) 39 | end 40 | 41 | @tag machine: TestMachine 42 | test "send/2", %{machine: server} do 43 | assert :ok = Server.send(server, :goto_b) 44 | assert Protean.matches?(server, :b) 45 | end 46 | 47 | @tag machine: TestMachine 48 | test "send_after/3", %{machine: server} do 49 | Server.send_after(server, :goto_b, 10) 50 | :timer.sleep(20) 51 | assert Protean.matches?(server, :b) 52 | end 53 | 54 | @tag machine: TestMachine 55 | test "send_after/3 can be canceled", %{machine: server} do 56 | timer = Server.send_after(server, :goto_b, 10) 57 | Process.cancel_timer(timer) 58 | :timer.sleep(20) 59 | assert Protean.matches?(server, :a) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/protean/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Utils do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Equivalent to `Enum.unzip/1` except for lists with 3-element tuples. 6 | """ 7 | def unzip3(list), 8 | do: unzip3(Enum.reverse(list), [], [], []) 9 | 10 | defp unzip3([{el1, el2, el3} | reversed_list], l1, l2, l3), 11 | do: unzip3(reversed_list, [el1 | l1], [el2 | l2], [el3 | l3]) 12 | 13 | defp unzip3([], l1, l2, l3), 14 | do: {l1, l2, l3} 15 | 16 | @doc """ 17 | Generate a UUIDv4 string. 18 | """ 19 | def uuid4 do 20 | <> = :crypto.strong_rand_bytes(16) 21 | 22 | <> 23 | |> uuid4_to_string() 24 | end 25 | 26 | defp uuid4_to_string(<< 27 | a1::4, a2::4, a3::4, a4::4, a5::4, a6::4, a7::4, a8::4, 28 | b1::4, b2::4, b3::4, b4::4, 29 | c1::4, c2::4, c3::4, c4::4, 30 | d1::4, d2::4, d3::4, d4::4, 31 | e1::4, e2::4, e3::4, e4::4, e5::4, e6::4, e7::4, e8::4, e9::4, e10::4, e11::4, e12::4 32 | >>) do 33 | << 34 | e(a1), e(a2), e(a3), e(a4), e(a5), e(a6), e(a7), e(a8), ?-, 35 | e(b1), e(b2), e(b3), e(b4), ?-, 36 | e(c1), e(c2), e(c3), e(c4), ?-, 37 | e(d1), e(d2), e(d3), e(d4), ?-, 38 | e(e1), e(e2), e(e3), e(e4), e(e5), e(e6), e(e7), e(e8), e(e9), e(e10), e(e11), e(e12) 39 | >> 40 | end 41 | 42 | @compile {:inline, e: 1} 43 | 44 | defp e(0), do: ?0 45 | defp e(1), do: ?1 46 | defp e(2), do: ?2 47 | defp e(3), do: ?3 48 | defp e(4), do: ?4 49 | defp e(5), do: ?5 50 | defp e(6), do: ?6 51 | defp e(7), do: ?7 52 | defp e(8), do: ?8 53 | defp e(9), do: ?9 54 | defp e(10), do: ?a 55 | defp e(11), do: ?b 56 | defp e(12), do: ?c 57 | defp e(13), do: ?d 58 | defp e(14), do: ?e 59 | defp e(15), do: ?f 60 | end 61 | -------------------------------------------------------------------------------- /lib/protean/guard.ex: -------------------------------------------------------------------------------- 1 | defprotocol Protean.Guard do 2 | @moduledoc """ 3 | Protocol for guarded transitions, actions, etc. 4 | 5 | Default implementations are provided for: 6 | 7 | * `BitString` - Call callback module with string, context, and event 8 | * `Atom` - Call callback module with atom, context, and event 9 | * `Function` - Call function with context and event 10 | * `Tuple` - Higher-order guard utilities: 11 | * `{:and, [guard1, ...]}` 12 | * `{:or, [guard1, ...]}` 13 | * `{:not, guard}` 14 | * `{:in, query}` - Delegates to `Protean.Context.matches?/2` 15 | 16 | """ 17 | 18 | @spec allows?(t, Protean.Context.t(), Protean.event(), callback_module :: module()) :: boolean() 19 | def allows?(guard, context, event, module) 20 | end 21 | 22 | defimpl Protean.Guard, for: BitString do 23 | def allows?(name, context, event, module) do 24 | module.guard(name, context, event) 25 | end 26 | end 27 | 28 | defimpl Protean.Guard, for: Atom do 29 | def allows?(name, context, event, module) do 30 | module.guard(name, context, event) 31 | end 32 | end 33 | 34 | defimpl Protean.Guard, for: Function do 35 | def allows?(guard_fun, context, event, _module) do 36 | guard_fun.(context, event) 37 | end 38 | end 39 | 40 | defimpl Protean.Guard, for: Tuple do 41 | def allows?({:and, guards}, context, event, module) when is_list(guards) do 42 | Enum.all?(guards, &Protean.Guard.allows?(&1, context, event, module)) 43 | end 44 | 45 | def allows?({:or, guards}, context, event, module) when is_list(guards) do 46 | Enum.any?(guards, &Protean.Guard.allows?(&1, context, event, module)) 47 | end 48 | 49 | def allows?({:not, guard}, context, event, module) do 50 | !Protean.Guard.allows?(guard, context, event, module) 51 | end 52 | 53 | def allows?({:in, match_query}, context, _event, _module) do 54 | Protean.Context.matches?(context, match_query) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/protean/interpreter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.InterpreterTest do 2 | use ExUnit.Case 3 | 4 | alias Protean.Interpreter 5 | alias Protean.Context 6 | 7 | setup context do 8 | TestMachines.with_test_machine(context) 9 | end 10 | 11 | describe "purely functional machine interpreter" do 12 | @describetag machine: :pure_machine_1 13 | 14 | test "can be created and started", %{machine: machine} do 15 | interpreter = Interpreter.new(machine: machine) 16 | assert !Interpreter.running?(interpreter) 17 | assert interpreter |> Interpreter.start() |> Interpreter.running?() 18 | end 19 | 20 | test "can be started twice to no effect", %{machine: machine} do 21 | interpreter = Interpreter.new(machine: machine) 22 | assert interpreter |> Interpreter.start() |> Interpreter.start() |> Interpreter.running?() 23 | end 24 | 25 | test "executes initial entry actions on start", %{interpreter: interpreter} do 26 | assert Enum.count(Context.actions(interpreter.context)) == 1 27 | 28 | with interpreter <- Interpreter.start(interpreter) do 29 | assert Enum.empty?(Context.actions(interpreter.context)) 30 | assert interpreter.context.assigns[:acc] == ["entering_a"] 31 | end 32 | end 33 | end 34 | 35 | describe "machine with basic guards" do 36 | @describetag machine: :silly_direction_machine 37 | 38 | test "transitions based on guard conditions", %{interpreter: interpreter} do 39 | with interpreter <- Interpreter.start(interpreter) do 40 | {interpreter, _} = Interpreter.handle_event(interpreter, :go) 41 | assert interpreter.context.value == MapSet.new([["straight", "#"]]) 42 | 43 | {interpreter, _} = Interpreter.handle_event(interpreter, :set_left) 44 | {interpreter, _} = Interpreter.handle_event(interpreter, :go) 45 | 46 | assert interpreter.context.value == MapSet.new([["left", "#"]]) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/integration/subscription_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.SubscriptionTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule TestMachine do 5 | use Protean 6 | 7 | @machine [ 8 | initial: "a", 9 | states: [ 10 | atomic(:a, 11 | on: [ 12 | {"b", target: "b", actions: :answer} 13 | ] 14 | ), 15 | atomic(:b, 16 | on: [ 17 | {"a", target: "a"} 18 | ] 19 | ) 20 | ] 21 | ] 22 | 23 | @impl true 24 | def handle_action(:answer, context, _) do 25 | {:reply, :answer, context} 26 | end 27 | end 28 | 29 | describe "subscribed processes" do 30 | @describetag machine: TestMachine 31 | 32 | test "should receive updates on transition", %{machine: machine} do 33 | {:ok, id} = Protean.subscribe(machine) 34 | 35 | Protean.call(machine, "b") 36 | assert_receive {^id, %Protean.Context{}, _} 37 | 38 | Protean.call(machine, "b") 39 | assert_receive {^id, %Protean.Context{}, _} 40 | 41 | Protean.unsubscribe(machine) 42 | 43 | Protean.call(machine, "a") 44 | refute_receive {^id, %Protean.Context{}, _} 45 | end 46 | 47 | test "can subscribe only to transitions with replies", %{machine: machine} do 48 | {:ok, id} = Protean.subscribe(machine, filter: :replies) 49 | 50 | Protean.call(machine, "b") 51 | assert_receive {^id, _, [:answer]} 52 | 53 | Protean.call(machine, "a") 54 | refute_receive {^id, _, _} 55 | end 56 | 57 | test "should receive two updates when subscribed twice", %{machine: machine} do 58 | {:ok, id} = Protean.subscribe(machine) 59 | {:ok, ^id} = Protean.subscribe(machine) 60 | 61 | Protean.call(machine, "b") 62 | assert_receive {^id, _, _} 63 | assert_receive {^id, _, _} 64 | 65 | :ok = Protean.unsubscribe(machine) 66 | 67 | Protean.call(machine, "b") 68 | refute_receive {^id, _, _} 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/protean/pub_sub.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.PubSub do 2 | @moduledoc false 3 | 4 | @compile {:inline, pubsub_name: 0} 5 | def child_spec(_) do 6 | configure!() 7 | 8 | %{ 9 | id: pubsub_name(), 10 | start: {__MODULE__, :start_link, []} 11 | } 12 | end 13 | 14 | # Phoenix.PubSub dispatch hook 15 | def dispatch(subscribers, :none, {message, filter}) do 16 | Enum.each(subscribers, fn 17 | {pid, {:filter, ^filter}} -> send(pid, message) 18 | {pid, {:filter, nil}} -> send(pid, message) 19 | _ -> :ok 20 | end) 21 | 22 | :ok 23 | end 24 | 25 | defp pubsub_name do 26 | :persistent_term.get({__MODULE__, :name}) 27 | end 28 | 29 | defp configure! do 30 | name = 31 | :protean 32 | |> Application.get_env(:pubsub, []) 33 | |> Keyword.fetch!(:name) 34 | 35 | :persistent_term.put({__MODULE__, :name}, name) 36 | end 37 | 38 | if Code.ensure_loaded?(Phoenix.PubSub) do 39 | def start_link do 40 | if start?() do 41 | Phoenix.PubSub.Supervisor.start_link(name: pubsub_name()) 42 | else 43 | :ignore 44 | end 45 | end 46 | 47 | @spec subscribe(binary(), term()) :: :ok | {:error, {:already_registered, pid()}} 48 | def subscribe(topic, filter) do 49 | Phoenix.PubSub.subscribe(pubsub_name(), topic, metadata: {:filter, filter}) 50 | end 51 | 52 | def subscribe(topic) do 53 | Phoenix.PubSub.subscribe(pubsub_name(), topic) 54 | end 55 | 56 | @spec unsubscribe(binary()) :: :ok 57 | def unsubscribe(topic) do 58 | Phoenix.PubSub.unsubscribe(pubsub_name(), topic) 59 | end 60 | 61 | @spec broadcast(binary(), term(), term()) :: :ok | {:error, term()} 62 | def broadcast(topic, message, filter) do 63 | Phoenix.PubSub.broadcast(pubsub_name(), topic, {message, filter}, __MODULE__) 64 | end 65 | 66 | defp start? do 67 | :protean 68 | |> Application.get_env(:pubsub, []) 69 | |> Keyword.get(:start, false) 70 | end 71 | else 72 | def start_link, do: :ignore 73 | def broadcast(_, _, _), do: :ok 74 | def unsubscribe(_), do: :ok 75 | 76 | def subscribe(_, _ \\ nil) do 77 | raise ArgumentError, """ 78 | Protean subscriptions depend on `:phoenix_pubsub`, which must be added to your \ 79 | application dependencies. 80 | """ 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/integration/ping_pong_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.PingPongTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule PingPong do 5 | use Protean 6 | alias Protean.Action 7 | 8 | @doc """ 9 | A ping pong machine that waits for a {"ping", pid} or {"pong", pid} and replies with the 10 | opposite. It then waits for one more cycle 11 | """ 12 | @machine [ 13 | initial: "waiting", 14 | assigns: [ 15 | received: [] 16 | ], 17 | states: [ 18 | atomic(:waiting, 19 | on: [ 20 | {"listen", "listening"} 21 | ] 22 | ), 23 | atomic(:listening, 24 | on: [ 25 | match({"ping", _}, target: "ponged", actions: "reply"), 26 | match({"pong", _}, target: "pinged", actions: "reply") 27 | ] 28 | ), 29 | atomic(:pinged, 30 | on: [ 31 | match({"pong", _}, target: "waiting", actions: "reply") 32 | ] 33 | ), 34 | atomic(:ponged, 35 | on: [ 36 | match({"ping", _}, target: "waiting", actions: "reply") 37 | ] 38 | ) 39 | ] 40 | ] 41 | 42 | @impl true 43 | def handle_action("reply", context, {ping_or_pong, from}) do 44 | %{assigns: %{received: received}} = context 45 | reply = if ping_or_pong == "ping", do: "pong", else: "ping" 46 | 47 | context 48 | |> Action.assign(received: [ping_or_pong | received]) 49 | |> Action.send({reply, self()}, to: from) 50 | end 51 | end 52 | 53 | @moduletag machines: [PingPong, PingPong] 54 | 55 | test "PingPong", %{machines: machines} do 56 | [%{machine: m1}, %{machine: m2}] = machines 57 | 58 | Protean.call(m1, "listen") 59 | Protean.call(m2, "listen") 60 | 61 | assert_protean(m1, 62 | call: {"ping", m2}, 63 | sleep: 10, 64 | assigns: [received: ["ping", "ping"]] 65 | ) 66 | 67 | assert_protean(m2, 68 | assigns: [received: ["pong", "pong"]] 69 | ) 70 | 71 | Protean.call(m1, "listen") 72 | Protean.call(m2, "listen") 73 | 74 | assert_protean(m2, 75 | call: {"ping", m1}, 76 | sleep: 10, 77 | assigns: [received: ["ping", "ping", "pong", "pong"]] 78 | ) 79 | 80 | assert_protean(m1, 81 | assigns: [received: ["pong", "pong", "ping", "ping"]] 82 | ) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/integration/final_states_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.FinalStatesTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule TestMachine do 5 | use Protean 6 | 7 | @machine [ 8 | initial: :init, 9 | states: [ 10 | atomic(:init), 11 | compound(:simple_compound, 12 | initial: "a", 13 | states: [ 14 | atomic(:a, on: [b: "b"]), 15 | final(:b) 16 | ], 17 | done: "nearly_done" 18 | ), 19 | parallel(:simple_parallel, 20 | states: [ 21 | compound(:p1, 22 | initial: "a", 23 | states: [ 24 | atomic(:a, on: [b: "b"]), 25 | final(:b) 26 | ] 27 | ), 28 | compound(:p2, 29 | initial: "c", 30 | states: [ 31 | atomic(:c, on: [d: "d"]), 32 | final(:d) 33 | ] 34 | ) 35 | ], 36 | done: "nearly_done" 37 | ), 38 | atomic(:nearly_done, 39 | on: [ 40 | all_done: "all_done" 41 | ] 42 | ), 43 | final(:all_done) 44 | ], 45 | on: [ 46 | simple_compound: "#simple_compound", 47 | simple_parallel: "#simple_parallel" 48 | ] 49 | ] 50 | end 51 | 52 | describe "final state" do 53 | @describetag machine: TestMachine 54 | 55 | test "in compound nodes", %{machine: machine} do 56 | assert_protean(machine, 57 | call: :simple_compound, 58 | matches: "simple_compound.a", 59 | call: :b, 60 | matches: "nearly_done" 61 | ) 62 | end 63 | 64 | test "in parallel nodes", %{machine: machine} do 65 | assert_protean(machine, 66 | call: :simple_parallel, 67 | matches: "simple_parallel.p1.a", 68 | matches: "simple_parallel.p2.c", 69 | call: :b, 70 | matches: "simple_parallel.p1.b", 71 | call: :d, 72 | matches: "nearly_done" 73 | ) 74 | end 75 | 76 | test "at root", %{machine: machine} do 77 | assert_protean(machine, 78 | call: :simple_compound, 79 | call: :b, 80 | matches: "nearly_done", 81 | call: :all_done 82 | ) 83 | 84 | assert_receive {:DOWN, _ref, :process, _pid, {:shutdown, %Protean.Context{}}} 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - '**' 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-20.04 10 | env: 11 | MIX_ENV: dev 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - pair: 17 | elixir: '1.14' 18 | otp: 25 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Elixir 23 | id: beam 24 | uses: erlef/setup-beam@v1 25 | with: 26 | otp-version: ${{matrix.pair.otp}} 27 | elixir-version: ${{matrix.pair.elixir}} 28 | 29 | - name: Deps Cache 30 | uses: actions/cache@v3 31 | with: 32 | path: deps 33 | key: mix-deps-${{ hashFiles('**/mix.lock') }} 34 | 35 | - name: PLT Cache (Dialyzer) 36 | uses: actions/cache@v3 37 | id: plt_cache 38 | with: 39 | key: ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt 40 | restore-keys: ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt 41 | path: priv/plts 42 | 43 | - run: mix deps.get 44 | 45 | - run: mix format --check-formatted 46 | 47 | - run: mix deps.unlock --check-unused 48 | 49 | - run: mix deps.compile 50 | 51 | - run: mix compile --warnings-as-errors 52 | 53 | - name: Create PLTs 54 | if: steps.plt_cache.outputs.cache-hit != 'true' 55 | run: MIX_ENV=dev mix dialyzer --plt 56 | 57 | - name: Run Dialyzer 58 | run: MIX_ENV=dev mix dialyzer --format github 59 | 60 | test: 61 | runs-on: ubuntu-20.04 62 | env: 63 | MIX_ENV: test 64 | strategy: 65 | fail-fast: false 66 | matrix: 67 | elixir: [1.13, 1.14] 68 | otp: [24.3, 25] 69 | steps: 70 | - uses: actions/checkout@v3 71 | 72 | - name: Set up Elixir 73 | id: beam 74 | uses: erlef/setup-beam@v1 75 | with: 76 | otp-version: ${{matrix.otp}} 77 | elixir-version: ${{matrix.elixir}} 78 | 79 | - name: Deps Cache 80 | uses: actions/cache@v3 81 | with: 82 | path: deps 83 | key: mix-deps-${{ hashFiles('**/mix.lock') }} 84 | 85 | - run: mix deps.get 86 | 87 | - run: mix deps.compile 88 | 89 | - run: mix test 90 | -------------------------------------------------------------------------------- /test/support/trigger.ex: -------------------------------------------------------------------------------- 1 | defmodule Trigger do 2 | @moduledoc """ 3 | Simple GenServer-based mechanism for waiting on another process. 4 | 5 | Each value can only be triggered once. Intended to be restarted before each test. Subsequent 6 | calls to `await` will immediately return with `{:error, :already_triggered}`. 7 | 8 | ## Usage 9 | 10 | # In some async process 11 | :timer.sleep(1000) 12 | Trigger.trigger(:after_sleep) 13 | 14 | # In the test 15 | :ok = Trigger.await(:after_sleep) 16 | assert ... 17 | {:error, :already_triggered} = Trigger.await(:after_sleep) 18 | 19 | """ 20 | 21 | use GenServer 22 | 23 | def start_link(name: name) do 24 | GenServer.start_link(__MODULE__, :ok, name: name) 25 | end 26 | 27 | @spec trigger(GenServer.server(), term()) :: :ok 28 | def trigger(server, value) do 29 | GenServer.call(server, {:trigger, value}) 30 | end 31 | 32 | @spec triggered?(GenServer.server(), term()) :: boolean() 33 | def triggered?(server, value) do 34 | GenServer.call(server, {:triggered?, value}) 35 | end 36 | 37 | @spec await(GenServer.server(), term(), timeout()) :: :ok | :triggered 38 | def await(server, value, timeout \\ 5000) do 39 | GenServer.call(server, {:await, value}, timeout) 40 | end 41 | 42 | # Protean Action helpers 43 | @behaviour Protean.Action 44 | 45 | def action(trigger, value) do 46 | Protean.Action.new(__MODULE__, {:trigger, trigger, value}) 47 | end 48 | 49 | @impl true 50 | def exec_action({:trigger, name, value}, interpreter) do 51 | trigger(name, value) 52 | {:cont, interpreter} 53 | end 54 | 55 | # Server callbacks 56 | 57 | @impl true 58 | def init(:ok) do 59 | {:ok, {Map.new(), MapSet.new()}} 60 | end 61 | 62 | @impl true 63 | def handle_call({:triggered?, value}, _from, {waiting, triggered}) do 64 | {:reply, value in triggered, {waiting, triggered}} 65 | end 66 | 67 | def handle_call({:await, value}, from, {waiting, triggered}) do 68 | if value in triggered do 69 | {:reply, {:error, :already_triggered}, {waiting, triggered}} 70 | else 71 | waiting = Map.update(waiting, value, [from], &[from | &1]) 72 | {:noreply, {waiting, triggered}} 73 | end 74 | end 75 | 76 | def handle_call({:trigger, value}, _from, {waiting, triggered}) do 77 | {interested, waiting} = Map.pop(waiting, value, []) 78 | for pid <- interested, do: GenServer.reply(pid, :ok) 79 | {:reply, :ok, {waiting, MapSet.put(triggered, value)}} 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/protean/transition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.TransitionTest do 2 | use ExUnit.Case 3 | 4 | alias Protean.Transition 5 | import Protean.TestHelper 6 | 7 | describe "internal transition domains" do 8 | test "to self" do 9 | t = transition("#.compound", ["#.compound"], true) 10 | assert Transition.domain(t) == node_id("#") 11 | end 12 | 13 | test "to child" do 14 | t = transition("#.compound", ["#.compound.child"], true) 15 | assert Transition.domain(t) == node_id("#.compound") 16 | end 17 | 18 | test "to sibling" do 19 | t = transition("#.compound", ["#.other"], true) 20 | assert Transition.domain(t) == node_id("#") 21 | end 22 | 23 | test "to self and child" do 24 | t = transition("#.compound", ["#.compound", "#.compound.parallel.child"], true) 25 | assert Transition.domain(t) == node_id("#") 26 | end 27 | 28 | test "to multiple children" do 29 | t = transition("#.c", ["#.c.p.child1", "#.c.p.child2"], true) 30 | assert Transition.domain(t) == node_id("#.c") 31 | end 32 | 33 | test "to child of sibling" do 34 | t = transition("#.c1.a", ["#.c2.a"], true) 35 | assert Transition.domain(t) == node_id("#") 36 | end 37 | end 38 | 39 | describe "external transition domains" do 40 | test "to self" do 41 | t = transition("#.compound", ["#.compound"]) 42 | assert Transition.domain(t) == node_id("#") 43 | end 44 | 45 | test "to child" do 46 | t = transition("#.compound", ["#.compound.child"]) 47 | assert Transition.domain(t) == node_id("#") 48 | end 49 | 50 | test "to sibling" do 51 | t = transition("#.compound", ["#.other"]) 52 | assert Transition.domain(t) == node_id("#") 53 | end 54 | 55 | test "to self and child" do 56 | t = transition("#.compound", ["#.compound", "#.compound.parallel.child"]) 57 | assert Transition.domain(t) == node_id("#") 58 | end 59 | 60 | test "to multiple children" do 61 | t = transition("#.c", ["#.c.p.child1", "#.c.p.child2"]) 62 | assert Transition.domain(t) == node_id("#") 63 | end 64 | 65 | test "to child of sibling" do 66 | t = transition("#.c1.a", ["#.c2.a"], true) 67 | assert Transition.domain(t) == node_id("#") 68 | end 69 | end 70 | 71 | defp transition(source_id, target_ids, internal \\ false) when is_list(target_ids) do 72 | Transition.new( 73 | source_id: node_id(source_id), 74 | target_ids: Enum.map(target_ids, &node_id/1), 75 | internal: internal 76 | ) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/integration/choose_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.ChooseTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule Choosey do 5 | use Protean 6 | alias Protean.Action 7 | 8 | @machine [ 9 | assigns: [switch: "on", data: []], 10 | initial: "a", 11 | states: [ 12 | atomic(:a, 13 | on: [ 14 | match({"set_switch", _}, actions: "set_switch"), 15 | match("make_a_choice", 16 | actions: [ 17 | Action.choose([ 18 | {"set_data_a", guard: "switch_on"}, 19 | {"set_data_b", guard: "switch_off"}, 20 | {"set_data_d", guard: "switch_off"}, 21 | "set_data_c" 22 | ]) 23 | ] 24 | ), 25 | match("make_a_choice_pure", actions: "make_a_choice") 26 | ] 27 | ) 28 | ] 29 | ] 30 | 31 | @impl Protean 32 | def handle_action("set_switch", context, {_, value}) do 33 | Action.assign(context, :switch, value) 34 | end 35 | 36 | def handle_action("set_data_" <> value, context, _event) do 37 | %{assigns: %{data: data}} = context 38 | Action.assign(context, :data, [value | data]) 39 | end 40 | 41 | def handle_action("make_a_choice", context, _event) do 42 | Action.choose(context, [ 43 | {"set_data_a", guard: "switch_on"}, 44 | {"set_data_b", guard: "switch_off"}, 45 | {"set_data_d", guard: "switch_off"}, 46 | "set_data_c" 47 | ]) 48 | end 49 | 50 | @impl Protean 51 | def guard("switch_" <> value, %{assigns: %{switch: value}}, _event), do: true 52 | end 53 | 54 | describe "choose action" do 55 | @describetag machine: Choosey 56 | 57 | test "inline chooses single action when its condition is true", %{machine: machine} do 58 | assert_protean(machine, 59 | call: "make_a_choice", 60 | assigns: [data: ["a"]], 61 | call: {"set_switch", "off"}, 62 | call: "make_a_choice", 63 | assigns: [data: ["b", "a"]], 64 | call: {"set_switch", "indeterminate"}, 65 | call: "make_a_choice", 66 | assigns: [data: ["c", "b", "a"]] 67 | ) 68 | end 69 | 70 | test "pure chooses single action when its condition is true", %{machine: machine} do 71 | assert_protean(machine, 72 | call: "make_a_choice_pure", 73 | assigns: [data: ["a"]], 74 | call: {"set_switch", "off"}, 75 | call: "make_a_choice_pure", 76 | assigns: [data: ["b", "a"]], 77 | call: {"set_switch", "indeterminate"}, 78 | call: "make_a_choice_pure", 79 | assigns: [data: ["c", "b", "a"]] 80 | ) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/protean/machinery_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.MachineryTest do 2 | use ExUnit.Case 3 | 4 | alias Protean.Machinery 5 | 6 | setup context do 7 | TestMachines.with_test_machine(context) 8 | end 9 | 10 | describe "simple compound machine" do 11 | @describetag machine: :simple_machine_1 12 | 13 | test "ignores unknown events", %{machine: machine, initial: initial} do 14 | maybe_different = Machinery.transition(machine, initial, "UNKNOWN_EVENT") 15 | assert maybe_different == initial 16 | end 17 | 18 | test "transitions to atomic nodes", %{machine: machine, initial: initial} do 19 | next = Machinery.transition(machine, initial, :event_a) 20 | assert next.value == MapSet.new([["state_b", "#"]]) 21 | end 22 | end 23 | 24 | @tag machine: :simple_machine_2 25 | test "transitions when parent responds to event", %{machine: machine, initial: initial} do 26 | next = Machinery.transition(machine, initial, :event_a) 27 | assert next.value == MapSet.new([["state_b", "#"]]) 28 | end 29 | 30 | describe "machine with basic actions" do 31 | @describetag machine: :machine_with_actions_1 32 | 33 | test "entry actions are collected in initial state", %{initial: initial} do 34 | assert initial.private.actions == ["entry_a"] 35 | end 36 | 37 | test "exit and entry actions are collected on transition", %{ 38 | machine: machine, 39 | initial: initial 40 | } do 41 | context = Machinery.transition(machine, initial, :event_a) 42 | assert context.value == MapSet.new([["state_b", "#"]]) 43 | assert context.private.actions == ["entry_a", "exit_a", "event_a_action", "entry_b"] 44 | end 45 | end 46 | 47 | describe "parallel machine with actions" do 48 | @describetag machine: :parallel_machine_with_actions_1 49 | 50 | test "has correct initial state and entry actions", %{initial: initial} do 51 | assert initial.value == 52 | MapSet.new([ 53 | ["state_a1", "parallel_state_a", "#"], 54 | ["foo", "state_a2", "parallel_state_a", "#"] 55 | ]) 56 | 57 | assert initial.private.actions == ["entry_parallel_a", "entry_a1", "entry_a2"] 58 | end 59 | 60 | test "can transition within a parallel state", %{machine: machine, initial: initial} do 61 | context = Machinery.transition(machine, initial, :foo_event) 62 | 63 | assert context.value == 64 | MapSet.new([ 65 | ["state_a1", "parallel_state_a", "#"], 66 | ["bar", "state_a2", "parallel_state_a", "#"] 67 | ]) 68 | 69 | assert context.private.actions == initial.private.actions ++ ["exit_foo", "entry_bar"] 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/protean/transition.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Transition do 2 | @moduledoc """ 3 | TODO 4 | 5 | Event descriptors 6 | Guards 7 | Internal 8 | Exact 9 | Actions 10 | """ 11 | 12 | alias __MODULE__ 13 | alias Protean.Action 14 | alias Protean.Context 15 | alias Protean.Events.Platform 16 | alias Protean.Guard 17 | alias Protean.Node 18 | 19 | defstruct [ 20 | :source_id, 21 | :target_ids, 22 | :match, 23 | :guard, 24 | :domain, 25 | internal: false, 26 | actions: [], 27 | _meta: %{} 28 | ] 29 | 30 | @type t :: %Transition{ 31 | source_id: Node.id(), 32 | target_ids: [Node.id()] | nil, 33 | match: (term() -> boolean()) | term() | nil, 34 | guard: Guard.t(), 35 | internal: boolean(), 36 | actions: [Action.t()], 37 | domain: Node.id(), 38 | _meta: map() 39 | } 40 | 41 | def new(opts \\ []) do 42 | opts 43 | |> Keyword.take([:source_id, :target_ids, :match, :guard, :internal, :actions, :_meta]) 44 | |> then(&struct(Transition, &1)) 45 | |> with_domain() 46 | end 47 | 48 | @doc "Return the actions associated with a transition" 49 | @spec actions(t) :: [Action.t()] 50 | def actions(%Transition{} = t), do: t.actions 51 | 52 | @doc """ 53 | Checks whether the transition is enabled for the given event. 54 | """ 55 | @spec enabled?(t, Protean.event() | nil, Context.t(), callback_module :: module()) :: 56 | as_boolean(term()) 57 | def enabled?(transition, event, context, module) do 58 | matches?(transition, event) && guard_allows?(transition, context, event, module) 59 | end 60 | 61 | @spec domain(Transition.t()) :: Node.id() 62 | def domain(%Transition{} = t), do: t.domain 63 | 64 | defp all_descendants_of?(id, ids) do 65 | Enum.all?(ids, &Node.descendant?(&1, id)) 66 | end 67 | 68 | defp matches?(%Transition{match: match}, event) do 69 | case {match, event} do 70 | {nil, _} -> true 71 | {%Platform{id: id, type: type}, %Platform{id: id, type: type}} -> true 72 | {_, %Platform{payload: nil}} -> false 73 | {match?, event} when is_function(match?) -> match?.(event) 74 | {match, event} -> match == event 75 | end 76 | end 77 | 78 | defp guard_allows?(%Transition{guard: nil}, _, _, _), do: true 79 | 80 | defp guard_allows?(%Transition{guard: guard}, context, event, module) do 81 | Guard.allows?(guard, context, event, module) 82 | end 83 | 84 | defp with_domain(%Transition{target_ids: target_ids, source_id: source_id} = t) do 85 | if t.internal && all_descendants_of?(source_id, target_ids) do 86 | %{t | domain: source_id} 87 | else 88 | %{t | domain: Node.common_ancestor_id([source_id | target_ids])} 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/integration/spawned_stream_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.SpawnedStreamTest do 2 | use Protean.TestCase, async: true 3 | 4 | @moduletag trigger: SpawnedStreamTrigger 5 | 6 | defmodule StreamMachine1 do 7 | use Protean 8 | alias Protean.Action 9 | 10 | @stream Stream.repeatedly(fn -> {:stream_data, 1} end) 11 | |> Stream.take(5) 12 | 13 | @machine [ 14 | initial: "main", 15 | assigns: [data: []], 16 | states: [ 17 | atomic(:main, 18 | spawn: [ 19 | stream(@stream, done: :stream_consumed) 20 | ], 21 | on: [ 22 | match({:stream_data, _}, actions: "write_data") 23 | ] 24 | ), 25 | atomic(:stream_consumed, 26 | entry: Trigger.action(SpawnedStreamTrigger, :stream_consumed) 27 | ) 28 | ] 29 | ] 30 | 31 | @impl true 32 | def handle_action("write_data", context, {_, value}) do 33 | context 34 | |> Action.update_in([:data], &[value | &1]) 35 | end 36 | end 37 | 38 | @tag machine: StreamMachine1 39 | test "spawned streams emit events until consumed", %{machine: machine} do 40 | Trigger.await(SpawnedStreamTrigger, :stream_consumed) 41 | %{assigns: assigns} = Protean.current(machine) 42 | assert length(assigns[:data]) == 5 43 | end 44 | 45 | defmodule StreamMachine2 do 46 | use Protean 47 | alias Protean.Action 48 | 49 | @machine [ 50 | initial: "waiting", 51 | assigns: [data: []], 52 | states: [ 53 | atomic(:waiting, 54 | on: [ 55 | match({:stream, _}, "consuming") 56 | ] 57 | ), 58 | atomic(:consuming, 59 | spawn: [ 60 | stream(:stream_from_event, 61 | done: [ 62 | target: :waiting, 63 | actions: Trigger.action(SpawnedStreamTrigger, :stream_done) 64 | ] 65 | ) 66 | ], 67 | on: [ 68 | match({:stream_data, _}, actions: "write_data") 69 | ] 70 | ) 71 | ] 72 | ] 73 | 74 | @impl true 75 | def spawn(:stream, :stream_from_event, _context, {_, stream}) do 76 | Stream.map(stream, &{:stream_data, &1}) 77 | end 78 | 79 | @impl true 80 | def handle_action("write_data", context, {_, value}) do 81 | context 82 | |> Action.update_in([:data], &[value | &1]) 83 | end 84 | end 85 | 86 | @tag machine: StreamMachine2 87 | test "spawned streams can be resolved by callback module", %{machine: machine} do 88 | Protean.send(machine, {:stream, Stream.repeatedly(fn -> 1 end) |> Stream.take(5)}) 89 | assert Trigger.await(SpawnedStreamTrigger, :stream_done) 90 | assert Protean.matches?(machine, :waiting) 91 | assert %{data: [1, 1, 1, 1, 1]} = Protean.current(machine).assigns 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/integration/guard_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.GuardTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule HigherOrderGuardMachine1 do 5 | use Protean 6 | 7 | @machine [ 8 | initial: :a, 9 | states: [ 10 | atomic(:a), 11 | atomic(:b), 12 | atomic(:c), 13 | atomic(:d) 14 | ], 15 | on: [ 16 | {:goto_a, target: ".a", guard: {:not, {:in, "#d"}}}, 17 | {:goto_b, target: ".b", guard: {:in, "#a"}}, 18 | {:goto_c, target: ".c", guard: {:or, [{:in, "#d"}, {:in, "#b"}]}}, 19 | match({:goto_d, _}, target: ".d", guard: {:and, [{:in, "#c"}, "asked_nicely"]}) 20 | ] 21 | ] 22 | 23 | @impl true 24 | def guard("asked_nicely", _context, {_, :please}), do: true 25 | end 26 | 27 | defmodule HigherOrderGuardMachine2 do 28 | use Protean 29 | 30 | @machine [ 31 | initial: :a, 32 | states: [ 33 | atomic(:a), 34 | atomic(:b), 35 | atomic(:c), 36 | atomic(:d) 37 | ], 38 | on: [ 39 | {:goto_a, target: ".a", guard: [:not, in: "d"]}, 40 | {:goto_b, target: ".b", guard: [in: "a"]}, 41 | {:goto_c, target: ".c", guard: [:or, in: "d", in: "b"]}, 42 | match({:goto_d, _}, target: ".d", guard: [:asked_nicely, in: "c"]) 43 | ] 44 | ] 45 | 46 | @impl true 47 | def guard(:asked_nicely, _context, {_, :please}), do: true 48 | end 49 | 50 | @tag machine: HigherOrderGuardMachine1 51 | test "higher order guards", %{machine: machine} do 52 | higher_order_guard_test(machine) 53 | end 54 | 55 | @tag machine: HigherOrderGuardMachine2 56 | test "sugary higher order guards", %{machine: machine} do 57 | higher_order_guard_test(machine) 58 | end 59 | 60 | def higher_order_guard_test(machine) do 61 | assert_protean(machine, 62 | call: :goto_c, 63 | matches: "a", 64 | call: :goto_b, 65 | matches: "b", 66 | call: {:goto_d, nil}, 67 | matches: "b", 68 | call: :goto_c, 69 | matches: "c", 70 | call: {:goto_d, nil}, 71 | matches: "c", 72 | call: {:goto_d, :please}, 73 | matches: "d", 74 | call: :goto_a, 75 | matches: "d", 76 | call: :goto_c, 77 | matches: "c", 78 | call: :goto_a, 79 | matches: "a" 80 | ) 81 | end 82 | 83 | defmodule FunctionGuards do 84 | use Protean 85 | 86 | @machine [ 87 | initial: :a, 88 | states: [ 89 | atomic(:a, 90 | on: [ 91 | match({:goto_b, _}, guard: fn _, {_, val} -> val == :please end, target: :b) 92 | ] 93 | ), 94 | atomic(:b) 95 | ] 96 | ] 97 | end 98 | 99 | @tag machine: FunctionGuards 100 | test "inline functions should be allowed as guards", %{machine: machine} do 101 | assert_protean(machine, 102 | call: {:goto_b, :hmm}, 103 | matches: :a, 104 | call: {:goto_b, :please}, 105 | matches: :b 106 | ) 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/integration/automatic_transition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.AutomaticTransitionTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule AutoTransitionMachine do 5 | use Protean 6 | alias Protean.Action 7 | 8 | @machine [ 9 | initial: :a, 10 | assigns: %{ 11 | acc: [], 12 | allow: false 13 | }, 14 | states: [ 15 | atomic(:a, 16 | on: [ 17 | goto_b: "b" 18 | ] 19 | ), 20 | atomic(:b, 21 | always: [ 22 | target: "c", 23 | actions: ["auto_to_c"] 24 | ] 25 | ), 26 | atomic(:c, 27 | always: [ 28 | transition(target: :d, actions: "auto_to_d") 29 | ] 30 | ), 31 | atomic(:d, 32 | always: [ 33 | target: "a", 34 | guard: "allow?" 35 | ], 36 | on: [ 37 | allow: [ 38 | target: "e", 39 | actions: [Action.assign(allow: true)] 40 | ] 41 | ] 42 | ), 43 | atomic(:e) 44 | ], 45 | on: [ 46 | goto_a: ".a", 47 | outer_allow: [ 48 | actions: [Action.assign(allow: true)] 49 | ] 50 | ] 51 | ] 52 | 53 | @impl true 54 | def handle_action(action_name, %{assigns: %{acc: acc}} = context, _event) do 55 | Action.assign(context, :acc, [action_name | acc]) 56 | end 57 | 58 | @impl true 59 | def guard("allow?", %{assigns: %{allow: true}}, _event), do: true 60 | end 61 | 62 | describe "AutoTransitionMachine:" do 63 | @describetag machine: AutoTransitionMachine 64 | 65 | test "automatic transitions trigger actions in correct order", %{machine: machine} do 66 | assert_protean(machine, 67 | call: :goto_b, 68 | matches: "d", 69 | assigns: [acc: ["auto_to_d", "auto_to_c"]] 70 | ) 71 | end 72 | 73 | test "normal transitions take precedence even if auto condition is true", %{machine: machine} do 74 | assert_protean(machine, 75 | call: :goto_b, 76 | call: :allow, 77 | matches: "e", 78 | call: :goto_a, 79 | call: :goto_b, 80 | matches: "a" 81 | ) 82 | end 83 | 84 | test "auto transition triggered when condition is met", %{machine: machine} do 85 | assert_protean(machine, 86 | call: :goto_b, 87 | matches: "d", 88 | call: :outer_allow, 89 | matches: "a" 90 | ) 91 | end 92 | end 93 | 94 | defmodule ImmediateTransitionMachine do 95 | use Protean 96 | 97 | @machine [ 98 | initial: :init, 99 | states: [ 100 | atomic(:init, 101 | always: :next 102 | ), 103 | atomic(:next) 104 | ] 105 | ] 106 | end 107 | 108 | @tag machine: ImmediateTransitionMachine 109 | test "machine can immediately transition", %{machine: machine} do 110 | assert Protean.matches?(machine, :next) 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/protean/machine_config.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.MachineConfig do 2 | @doc """ 3 | Data structure representing a Protean machine. 4 | """ 5 | 6 | alias __MODULE__ 7 | alias Protean.Context 8 | alias Protean.Node 9 | alias Protean.Parser 10 | alias Protean.Utils 11 | 12 | @enforce_keys [:id, :root, :default_assigns] 13 | 14 | @derive {Inspect, only: [:id, :root, :default_assigns, :callback_module]} 15 | defstruct [ 16 | :id, 17 | :root, 18 | :default_assigns, 19 | :callback_module, 20 | idmap: %{} 21 | ] 22 | 23 | @typedoc "Internal representation of a Protean machine" 24 | @type t :: %MachineConfig{ 25 | id: binary(), 26 | root: Node.t(), 27 | idmap: %{Node.id() => Node.t()}, 28 | default_assigns: Context.assigns(), 29 | callback_module: module() 30 | } 31 | 32 | @typedoc "User-defined machine configuration." 33 | @type config :: keyword() 34 | 35 | def new(config, opts \\ []) do 36 | {root, assigns} = Parser.parse!(config) 37 | 38 | idmap = 39 | Utils.Tree.reduce(root, %{}, fn node, idmap -> 40 | {Map.put(idmap, node.id, node), node.states} 41 | end) 42 | 43 | %MachineConfig{ 44 | id: opts[:id] || Utils.uuid4(), 45 | root: root, 46 | default_assigns: assigns, 47 | idmap: idmap, 48 | callback_module: opts[:callback_module] 49 | } 50 | end 51 | 52 | @doc """ 53 | Fetch a node by its id. Raises if the node cannot be found. 54 | """ 55 | @spec fetch!(t, Node.id()) :: Node.t() 56 | def fetch!(%MachineConfig{idmap: idmap}, id), do: Map.fetch!(idmap, id) 57 | 58 | @doc """ 59 | Compute the initial context for a machine configuration, including any entry actions that result 60 | from entering the default states. 61 | """ 62 | @spec initial_context(t) :: Context.t() 63 | def initial_context(%MachineConfig{} = config) do 64 | active_ids = 65 | config.root 66 | |> Node.resolve_to_leaves() 67 | |> Enum.map(& &1.id) 68 | 69 | entry_ids = 70 | active_ids 71 | |> Enum.flat_map(&Node.ancestor_ids/1) 72 | |> Enum.uniq() 73 | 74 | entry_actions = 75 | entry_ids 76 | |> Enum.map(&fetch!(config, &1)) 77 | |> Node.entry_order() 78 | |> Enum.flat_map(& &1.entry) 79 | 80 | Context.new(active_ids) 81 | |> Context.assign(config.default_assigns) 82 | |> Context.assign_actions(entry_actions) 83 | end 84 | 85 | @doc """ 86 | Compute the full set of active nodes for the given states. 87 | """ 88 | @spec active(t, Enumerable.t()) :: MapSet.t(Node.t()) 89 | def active(%MachineConfig{} = config, ids) do 90 | ids 91 | |> Enum.map(&fetch!(config, &1)) 92 | |> Enum.flat_map(&Node.resolve_to_leaves/1) 93 | |> Enum.map(& &1.id) 94 | |> Enum.flat_map(&lineage(config, &1)) 95 | |> MapSet.new() 96 | end 97 | 98 | @doc """ 99 | Return the full lineage of the given id, including itself and all of its ancestors. 100 | """ 101 | @spec lineage(t, Node.id()) :: [Node.t(), ...] 102 | def lineage(%MachineConfig{} = config, id) do 103 | id 104 | |> Node.ancestor_ids() 105 | |> Enum.map(&fetch!(config, &1)) 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/integration/delayed_transition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.DelayedTransitionTest do 2 | use Protean.TestCase, async: true 3 | 4 | @moduletag trigger: DelayedTransitionTrigger 5 | 6 | defmodule TestMachine do 7 | use Protean 8 | 9 | @machine [ 10 | assigns: %{ 11 | path: [] 12 | }, 13 | initial: "a", 14 | states: [ 15 | atomic(:a, 16 | entry: :save_path, 17 | after: [ 18 | delay(10, target: "b", actions: Trigger.action(DelayedTransitionTrigger, :a_after)) 19 | ] 20 | ), 21 | atomic(:b, 22 | entry: [:save_path, Trigger.action(DelayedTransitionTrigger, :b)], 23 | after: [ 24 | delay(10, target: "c") 25 | ] 26 | ), 27 | atomic(:c, 28 | entry: [:save_path, Trigger.action(DelayedTransitionTrigger, :c)] 29 | ), 30 | atomic(:d, 31 | entry: [:save_path, Trigger.action(DelayedTransitionTrigger, :d)] 32 | ) 33 | ], 34 | on: [ 35 | goto_c: ".c", 36 | goto_d: ".d" 37 | ] 38 | ] 39 | 40 | @impl Protean 41 | def handle_action(:save_path, %{assigns: %{path: path}} = context, _event) do 42 | Protean.Action.assign(context, :path, [MapSet.to_list(context.value) | path]) 43 | end 44 | end 45 | 46 | describe "delayed transitions:" do 47 | @describetag machine: TestMachine 48 | 49 | test "takes automatic delayed transitions", %{machine: machine} do 50 | Trigger.await(DelayedTransitionTrigger, :c) 51 | assert Protean.matches?(machine, :c) 52 | end 53 | 54 | test "delayed transitions can be short-circuited by transitioning early", 55 | %{machine: machine} do 56 | Protean.send(machine, :goto_c) 57 | Trigger.await(DelayedTransitionTrigger, :c) 58 | 59 | assert_protean(machine, 60 | matches: :c, 61 | assigns: [path: [[["c", "#"]], [["a", "#"]]]] 62 | ) 63 | end 64 | 65 | test "short-circuited transitions don't execute actions", %{machine: machine} do 66 | Protean.call(machine, :goto_d) 67 | assert Trigger.triggered?(DelayedTransitionTrigger, :d) 68 | refute Trigger.triggered?(DelayedTransitionTrigger, :b) 69 | refute Trigger.triggered?(DelayedTransitionTrigger, :c) 70 | end 71 | 72 | test "short-circuited transitions don't still send event", %{machine: machine} do 73 | Protean.call(machine, :goto_c) 74 | :timer.sleep(20) 75 | refute Trigger.triggered?(DelayedTransitionTrigger, :a_after) 76 | end 77 | end 78 | 79 | defmodule DynamicDelay do 80 | use Protean 81 | 82 | @machine [ 83 | initial: "will_transition", 84 | states: [ 85 | will_transition: [ 86 | after: [ 87 | delay: "some_delay", 88 | target: "new_state" 89 | ] 90 | ], 91 | new_state: [ 92 | entry: Trigger.action(DelayedTransitionTrigger, :new_state) 93 | ] 94 | ] 95 | ] 96 | 97 | @impl true 98 | def delay("some_delay", _, _), do: 10 99 | end 100 | 101 | @tag machine: DynamicDelay 102 | test "delays defined by callback", %{machine: machine} do 103 | assert Protean.matches?(machine, :will_transition) 104 | assert Trigger.await(DelayedTransitionTrigger, :new_state) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/protean/interpreter/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Interpreter.Server do 2 | @moduledoc false 3 | 4 | import Kernel, except: [send: 2] 5 | 6 | use GenServer 7 | 8 | alias Protean.Interpreter 9 | 10 | @prefix :"$protean" 11 | 12 | @gen_server_options [:name, :timeout, :debug, :spawn_opt, :hibernate_after] 13 | 14 | # Client API 15 | 16 | def start_link(opts) do 17 | {gen_server_opts, interpreter_opts} = Keyword.split(opts, @gen_server_options) 18 | 19 | GenServer.start_link(__MODULE__, interpreter_opts, gen_server_opts) 20 | end 21 | 22 | def call(server, event, timeout) do 23 | GenServer.call(server, event, timeout) 24 | end 25 | 26 | def send(server, event) do 27 | server 28 | |> resolve_server_to_pid() 29 | |> Kernel.send(event) 30 | 31 | :ok 32 | end 33 | 34 | def send_after(server, event, time) do 35 | server 36 | |> resolve_server_to_pid() 37 | |> Process.send_after(event, time) 38 | end 39 | 40 | def current(pid) do 41 | GenServer.call(pid, {@prefix, :current_context}) 42 | end 43 | 44 | def stop(pid, reason, timeout), do: GenServer.stop(pid, reason, timeout) 45 | 46 | def ping(pid), do: GenServer.call(pid, {@prefix, :ping}) 47 | 48 | def fetch_id(pid) do 49 | case GenServer.whereis(pid) do 50 | nil -> {:error, :not_started} 51 | pid -> {:ok, GenServer.call(pid, {@prefix, :id})} 52 | end 53 | end 54 | 55 | defp resolve_server_to_pid(ref) when is_reference(ref), do: ref 56 | defp resolve_server_to_pid(server), do: GenServer.whereis(server) 57 | 58 | # GenServer callbacks 59 | 60 | @impl true 61 | def init(opts) do 62 | Process.flag(:trap_exit, true) 63 | {:ok, Interpreter.new(opts), {:continue, {@prefix, :start}}} 64 | end 65 | 66 | @impl true 67 | def handle_call({@prefix, :current_context}, _from, interpreter) do 68 | {:reply, interpreter.context, interpreter} 69 | end 70 | 71 | def handle_call({@prefix, :ping}, _from, interpreter) do 72 | {:reply, :ok, interpreter} 73 | end 74 | 75 | def handle_call({@prefix, :id}, _from, interpreter) do 76 | {:reply, interpreter.id, interpreter} 77 | end 78 | 79 | def handle_call(event, _from, interpreter) do 80 | {interpreter, response} = run_event(interpreter, event) 81 | {:reply, response, interpreter, {:continue, {@prefix, :check_running}}} 82 | end 83 | 84 | @impl true 85 | def handle_cast(event, interpreter) do 86 | {interpreter, _response} = run_event(interpreter, event) 87 | {:noreply, interpreter, {:continue, {@prefix, :check_running}}} 88 | end 89 | 90 | @impl true 91 | def handle_info(event, interpreter), do: handle_cast(event, interpreter) 92 | 93 | @impl true 94 | def handle_continue({@prefix, :check_running}, interpreter) do 95 | if Interpreter.running?(interpreter) do 96 | {:noreply, interpreter} 97 | else 98 | {:stop, {:shutdown, interpreter.context}, interpreter} 99 | end 100 | end 101 | 102 | def handle_continue({@prefix, :start}, interpreter) do 103 | {:noreply, Interpreter.start(interpreter)} 104 | end 105 | 106 | @impl true 107 | def terminate(_reason, interpreter) do 108 | Interpreter.stop(interpreter) 109 | end 110 | 111 | defp run_event(interpreter, event) do 112 | {interpreter, replies} = Interpreter.handle_event(interpreter, event) 113 | {interpreter, {interpreter.context, replies}} 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.MixProject do 2 | use Mix.Project 3 | 4 | @name "Protean" 5 | @version "0.2.0-dev" 6 | @source_url "https://github.com/zachallaun/protean" 7 | 8 | def project do 9 | [ 10 | app: :protean, 11 | name: @name, 12 | version: @version, 13 | source_url: @source_url, 14 | package: package(), 15 | elixir: "~> 1.13", 16 | elixirc_paths: elixirc_paths(Mix.env()), 17 | start_permanent: Mix.env() == :prod, 18 | deps: deps(), 19 | docs: docs(), 20 | dialyzer: dialyzer(), 21 | aliases: aliases(), 22 | test_coverage: [tool: ExCoveralls], 23 | preferred_cli_env: [ 24 | t: :test, 25 | coveralls: :test, 26 | "coveralls.detail": :test, 27 | "coveralls.post": :test, 28 | "coveralls.html": :test 29 | ] 30 | ] 31 | end 32 | 33 | def application, do: application(Mix.env()) 34 | 35 | defp application(:prod) do 36 | [ 37 | mod: {Protean.Application, []}, 38 | extra_applications: [:logger, :crypto] 39 | ] 40 | end 41 | 42 | defp application(_) do 43 | [ 44 | mod: {Protean.Application, []}, 45 | extra_applications: [:logger, :crypto, :phoenix_pubsub] 46 | ] 47 | end 48 | 49 | defp package do 50 | [ 51 | description: description(), 52 | licenses: ["MIT"], 53 | links: %{ 54 | "GitHub" => @source_url 55 | } 56 | ] 57 | end 58 | 59 | defp deps do 60 | [ 61 | {:phoenix_pubsub, "~> 2.0", optional: true}, 62 | 63 | # dev/test 64 | {:credo, "~> 1.6", only: :dev, runtime: false}, 65 | {:ex_doc, "~> 0.29", only: :dev, runtime: false}, 66 | {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}, 67 | {:excoveralls, "~> 0.15", only: :test} 68 | ] 69 | end 70 | 71 | defp elixirc_paths(:test), do: ["lib", "test/support"] 72 | defp elixirc_paths(_), do: ["lib"] 73 | 74 | def aliases do 75 | [ 76 | t: "coveralls" 77 | ] 78 | end 79 | 80 | defp description do 81 | """ 82 | Library for managing stateful interaction and side-effects with state machines and 83 | statecharts. 84 | """ 85 | end 86 | 87 | defp dialyzer do 88 | [ 89 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 90 | flags: [ 91 | :underspecs, 92 | :extra_return, 93 | :missing_return 94 | ] 95 | ] 96 | end 97 | 98 | defp docs do 99 | [ 100 | main: @name, 101 | source_url: @source_url, 102 | extras: [ 103 | # "docs/guides/introduction.livemd": [title: "Introduction"] 104 | ], 105 | groups_for_extras: [ 106 | # Guides: Path.wildcard("docs/guides/*") 107 | ], 108 | groups_for_modules: [ 109 | Core: [ 110 | Protean.Action, 111 | Protean.Builder, 112 | Protean.Context, 113 | Protean.Guard, 114 | Protean.MachineConfig, 115 | Protean.Transition 116 | ], 117 | Mechanics: [ 118 | Protean.Interpreter, 119 | Protean.MachineConfig, 120 | Protean.Machinery, 121 | Protean.Node, 122 | Protean.Supervisor 123 | ] 124 | ], 125 | groups_for_functions: [ 126 | "Callback Actions": &(&1[:type] == :callback_action), 127 | "Inline Actions": &(&1[:type] == :inline_action) 128 | ] 129 | ] 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/protean/interpreter/hooks.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Interpreter.Hooks do 2 | @moduledoc """ 3 | Interpreter execution hooks. 4 | 5 | The purpose of this module is to define extensions at various points of machine interpretation. 6 | See the description of the interpretation loop in `Protean.Interpreter` before reading on. 7 | 8 | While hooks can implement "standalone" behaviors, such as ignoring a certain type of event, 9 | they are used by Protean to implement higher-level features such as `:spawn`. 10 | 11 | See `Protean.Interpreter.Features` for more. 12 | 13 | ## Hooks 14 | 15 | * `event_filter` is used to filter out or modify events. 16 | * `after_event_filter` is used to perform setup that you'd only want to occur if the event 17 | was not filtered out. 18 | * `after_microstep` runs after a microstep has occurred. 19 | * `after_macrostep` runs after a macrostep has occurred. 20 | * `on_stop` runs when interpretation stops. 21 | 22 | See function documentation for the individual hooks below. 23 | """ 24 | 25 | alias Protean.Interpreter 26 | 27 | @doc """ 28 | Register an `event_filter` hook. 29 | 30 | Function must accept one argument, `{interpreter, event}` and return one of: 31 | 32 | * `{:cont, {interpreter, event}}` - continue with interpreter and event 33 | * `{:halt, interpreter}` - halt with interpreter and ignore event 34 | 35 | """ 36 | def event_filter(%Interpreter{} = interpreter, hook) when is_function(hook, 1) do 37 | append_hook(interpreter, :event_filter, hook) 38 | end 39 | 40 | @doc """ 41 | Register an `after_event_fitler` hook. 42 | 43 | Function must accept two arguments, `interpreter` and `event`, and must return 44 | {:cont, `interpreter`}. 45 | """ 46 | def after_event_filter(%Interpreter{} = interpreter, hook) when is_function(hook, 2) do 47 | append_hook(interpreter, :after_event_filter, hook) 48 | end 49 | 50 | @doc """ 51 | Register an `after_microstep` hook. 52 | 53 | Function must accept `interpreter` and return {:cont, `interpreter`}. 54 | """ 55 | def after_microstep(%Interpreter{} = interpreter, hook) when is_function(hook, 1) do 56 | append_hook(interpreter, :after_microstep, hook) 57 | end 58 | 59 | @doc """ 60 | Register an `after_macrostep` hook. 61 | 62 | Function must accept an `interpreter` and return {:cont, `interpreter`}. 63 | """ 64 | def after_macrostep(%Interpreter{} = interpreter, hook) when is_function(hook, 1) do 65 | append_hook(interpreter, :after_macrostep, hook) 66 | end 67 | 68 | @doc """ 69 | Register an `on_stop` hook. 70 | 71 | Function must accept an `interpreter` and return {:cont, `interpreter`}. 72 | """ 73 | def on_stop(%Interpreter{} = interpreter, hook) when is_function(hook, 1) do 74 | append_hook(interpreter, :on_stop, hook) 75 | end 76 | 77 | ## Interpreter callbacks 78 | 79 | @doc false 80 | def run(interpreter, :event_filter, event) do 81 | interpreter 82 | |> get_hooks(:event_filter) 83 | |> run_hooks({interpreter, event}) 84 | end 85 | 86 | def run(interpreter, :after_event_filter, event) do 87 | interpreter 88 | |> get_hooks(:after_event_filter) 89 | |> run_hooks(interpreter, [event]) 90 | end 91 | 92 | @doc false 93 | def run(interpreter, hook) do 94 | interpreter 95 | |> get_hooks(hook) 96 | |> run_hooks(interpreter) 97 | end 98 | 99 | ## Utilities 100 | 101 | defp run_hooks(hooks, acc, extra_args \\ []) do 102 | Enum.reduce_while(hooks, acc, fn hook, acc -> 103 | apply(hook, [acc | extra_args]) 104 | end) 105 | end 106 | 107 | defp get_hooks(interpreter, hook_type) do 108 | Map.get(interpreter.hooks, hook_type, []) 109 | end 110 | 111 | defp append_hook(interpreter, hook_type, hook) do 112 | %{interpreter | hooks: Map.update(interpreter.hooks, hook_type, [hook], &(&1 ++ [hook]))} 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/protean/interpreter/features.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Interpreter.Features do 2 | @moduledoc "TODO" 3 | 4 | alias Protean.Events 5 | alias Protean.Context 6 | alias Protean.Interpreter 7 | alias Protean.Interpreter.Hooks 8 | alias Protean.Interpreter.Server 9 | alias Protean.ProcessManager 10 | alias Protean.PubSub 11 | 12 | @dialyzer {:nowarn_function, install_debug: 2} 13 | @debug false 14 | 15 | def install(interpreter) do 16 | interpreter 17 | |> install_debug(@debug) 18 | |> Hooks.event_filter(&exit_message/1) 19 | |> Hooks.event_filter(&task_completed/1) 20 | |> Hooks.event_filter(&task_failed/1) 21 | |> Hooks.after_event_filter(&autoforward_event/2) 22 | |> Hooks.after_macrostep(&broadcast_transition/1) 23 | |> Hooks.on_stop(&stop_all_subprocesses/1) 24 | end 25 | 26 | defp install_debug(interpreter, false), do: interpreter 27 | 28 | defp install_debug(interpreter, _) do 29 | interpreter 30 | |> Hooks.event_filter(&log/1) 31 | |> Hooks.after_event_filter(&log/2) 32 | end 33 | 34 | defp log({interpreter, event}) do 35 | require Logger 36 | Logger.info(inspect([:before, module: interpreter.config.callback_module, event: event])) 37 | {:cont, {interpreter, event}} 38 | end 39 | 40 | defp log(interpreter, event) do 41 | require Logger 42 | Logger.info(inspect([:after, module: interpreter.config.callback_module, event: event])) 43 | {:cont, interpreter} 44 | end 45 | 46 | defp exit_message({interpreter, {:EXIT, _pid, _reason}}) do 47 | {:halt, Interpreter.stop(interpreter)} 48 | end 49 | 50 | defp exit_message(acc), do: {:cont, acc} 51 | 52 | defp stop_all_subprocesses(interpreter) do 53 | ProcessManager.stop_all_subprocesses() 54 | {:cont, interpreter} 55 | end 56 | 57 | defp autoforward_event(interpreter, %Events.Platform{}), do: {:cont, interpreter} 58 | 59 | defp autoforward_event(interpreter, event) do 60 | for {_id, pid, _ref, opts} <- ProcessManager.registered_subprocesses(), 61 | Keyword.get(opts, :autoforward, false) do 62 | Server.send(pid, event) 63 | end 64 | 65 | {:cont, interpreter} 66 | end 67 | 68 | defp broadcast_transition(%Interpreter{id: nil} = interpreter), do: {:cont, interpreter} 69 | 70 | defp broadcast_transition(interpreter) do 71 | %{id: id, context: context} = interpreter 72 | replies = Context.get_replies(context) 73 | message = {id, context, replies} 74 | 75 | if Enum.empty?(replies) do 76 | PubSub.broadcast(id, message, nil) 77 | else 78 | PubSub.broadcast(id, message, :replies) 79 | end 80 | |> case do 81 | :ok -> 82 | :ok 83 | 84 | {:error, error} -> 85 | require Logger 86 | Logger.warn("PubSub broadcast error: #{inspect(error)}") 87 | end 88 | 89 | {:cont, interpreter} 90 | end 91 | 92 | defp task_completed({interpreter, {ref, result}}) when is_reference(ref) do 93 | with {id, _, _, _} <- ProcessManager.subprocess_by_ref(ref), 94 | _ <- Process.demonitor(ref, [:flush]), 95 | :ok <- ProcessManager.unregister_subprocess(id) do 96 | event = Events.platform(:spawn, :done, id) |> Events.with_payload(result) 97 | {:cont, {interpreter, event}} 98 | else 99 | nil -> {:cont, {interpreter, {ref, result}}} 100 | end 101 | end 102 | 103 | defp task_completed(acc), do: {:cont, acc} 104 | 105 | defp task_failed({interpreter, {:DOWN, ref, :process, _, reason} = down_event}) do 106 | with {id, _, _, _} <- ProcessManager.subprocess_by_ref(ref), 107 | :ok <- ProcessManager.unregister_subprocess(id), 108 | true <- otp_error?(reason) do 109 | event = Events.platform(:spawn, :error, id) 110 | {:cont, {interpreter, event}} 111 | else 112 | nil -> {:cont, {interpreter, down_event}} 113 | false -> {:halt, interpreter} 114 | end 115 | end 116 | 117 | defp task_failed(acc), do: {:cont, acc} 118 | 119 | defp otp_error?(reason) do 120 | case reason do 121 | :normal -> false 122 | :shutdown -> false 123 | {:shutdown, _} -> false 124 | _ -> true 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/protean/context.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Context do 2 | @moduledoc """ 3 | State available to observers of and callbacks in a machine. 4 | 5 | Functions in this module should rarely be used directly. Instead, rely on the API exposed by 6 | `Protean` and `Protean.Action` to query and modify machine context. 7 | """ 8 | 9 | alias __MODULE__ 10 | alias Protean.Action 11 | alias Protean.Node 12 | 13 | @derive {Inspect, only: [:value, :event, :assigns]} 14 | defstruct [ 15 | :value, 16 | :event, 17 | final: MapSet.new(), 18 | assigns: %{}, 19 | private: %{ 20 | actions: [], 21 | replies: [] 22 | } 23 | ] 24 | 25 | @type t :: %Context{ 26 | value: value, 27 | final: value, 28 | event: Protean.event() | nil, 29 | assigns: assigns, 30 | private: private_state 31 | } 32 | 33 | @type value :: MapSet.t(Node.id()) 34 | 35 | @type assigns :: %{any => any} 36 | 37 | @opaque private_state :: %{ 38 | actions: [Action.t()], 39 | replies: [term()] 40 | } 41 | 42 | @doc false 43 | @spec new(Enumerable.t()) :: t 44 | def new(value), do: %Context{value: MapSet.new(value)} 45 | 46 | @doc """ 47 | See `Protean.matches?/2`. 48 | """ 49 | @spec matches?(t, Node.id() | String.t() | atom()) :: boolean() 50 | def matches?(context, descriptor) 51 | 52 | def matches?(%Context{value: value}, query) when is_list(query) do 53 | Enum.any?(value, fn id -> id == query || Node.descendant?(id, query) end) 54 | end 55 | 56 | def matches?(context, query) when is_binary(query) do 57 | matches?(context, parse_match_query(query)) 58 | end 59 | 60 | def matches?(context, query) when is_atom(query) do 61 | matches?(context, to_string(query)) 62 | end 63 | 64 | defp parse_match_query(""), do: ["#"] 65 | defp parse_match_query("#"), do: ["#"] 66 | defp parse_match_query("#." <> query), do: parse_match_query(query) 67 | defp parse_match_query("#" <> query), do: parse_match_query(query) 68 | 69 | defp parse_match_query(query) do 70 | query 71 | |> String.split(".") 72 | |> List.insert_at(0, "#") 73 | |> Enum.reverse() 74 | end 75 | 76 | @doc false 77 | @spec assign_active(t, Enumerable.t()) :: t 78 | def assign_active(context, ids) do 79 | %{context | value: MapSet.new(ids)} 80 | end 81 | 82 | @doc false 83 | @spec assign_final(t, MapSet.t(Node.id())) :: t 84 | def assign_final(context, ids) do 85 | %{context | final: ids} 86 | end 87 | 88 | # Assign data to a context's assigns. 89 | # 90 | # Usage: 91 | # 92 | # * `assign(context, key, value)` - Assigns value to key in context's assigns. 93 | # * `assign(context, %{})` - Merges the update map into a context's assigns. 94 | # * `assign(context, enumerable)` - Collects the key/values of `enumerable` into a map, then 95 | # merges that map into the context's assigns. 96 | @doc false 97 | @spec assign(t, any, any) :: t 98 | def assign(%Context{assigns: assigns} = context, key, value), 99 | do: %{context | assigns: Map.put(assigns, key, value)} 100 | 101 | @doc false 102 | @spec assign(t, Enumerable.t()) :: t 103 | def assign(%Context{assigns: assigns} = context, updates) when is_map(updates), 104 | do: %{context | assigns: Map.merge(assigns, updates)} 105 | 106 | def assign(context, enum), 107 | do: assign(context, Enum.into(enum, %{})) 108 | 109 | @doc false 110 | def actions(context), do: context.private.actions 111 | 112 | @doc false 113 | def assign_actions(context, actions \\ []), 114 | do: put_in(context.private.actions, actions) 115 | 116 | @doc false 117 | def update_actions(context, fun), 118 | do: update_in(context.private.actions, fun) 119 | 120 | @doc false 121 | def put_actions(context, actions), 122 | do: update_actions(context, &(&1 ++ actions)) 123 | 124 | @doc false 125 | def pop_actions(context), 126 | do: {actions(context), assign_actions(context)} 127 | 128 | @doc false 129 | def put_reply(context, reply), 130 | do: update_in(context.private.replies, &[reply | &1]) 131 | 132 | @doc false 133 | def get_replies(context), 134 | do: context.private.replies |> Enum.reverse() 135 | 136 | @doc false 137 | def pop_replies(context), 138 | do: {get_replies(context), put_in(context.private.replies, [])} 139 | end 140 | -------------------------------------------------------------------------------- /docs/guides/introduction.livemd: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Introduction to Protean 4 | 5 | ```elixir 6 | Mix.install([ 7 | {:protean, git: "https://github.com/zachallaun/protean.git"} 8 | ]) 9 | ``` 10 | 11 | ## A very simple machine 12 | 13 | Let's start with a very simple machine: a counter. 14 | 15 | ```elixir 16 | defmodule Counter do 17 | use Protean 18 | 19 | defmachine [ 20 | context: %{ 21 | count: 0 22 | }, 23 | initial: :active, 24 | states: [ 25 | active: [ 26 | on: [ 27 | INC: [ 28 | actions: [ 29 | Protean.Action.assign(fn _state, %{count: count}, _event -> 30 | %{count: count + 1} 31 | end) 32 | ] 33 | ] 34 | ] 35 | ] 36 | ] 37 | ] 38 | end 39 | ``` 40 | 41 | 42 | 43 | ``` 44 | {:module, Counter, <<70, 79, 82, 49, 0, 0, 15, ...>>, :ok} 45 | ``` 46 | 47 | This machine has a single `:active` state that it will automatically enter when the machine starts (set by `:initial`). It also has some extended state, it's `:context`. While it's `:active`, it will listen for `"INC"` events and perform a pretty simple action: updating its context by incrementing the current count. 48 | 49 | Let's start the machine as a process and poke around a bit. When we defined our counter with `use Protean`, some functions were defined to start up our machine separately (or under a supervisor). 50 | 51 | ```elixir 52 | {:ok, counter} = Counter.start_link() 53 | ``` 54 | 55 | 56 | 57 | ``` 58 | {:ok, #PID<0.255.0>} 59 | ``` 60 | 61 | Under the hood, this started up a `GenServer` to encapsulate our running machine state. We can get the current state: 62 | 63 | ```elixir 64 | state = Protean.current(counter) 65 | ``` 66 | 67 | 68 | 69 | ``` 70 | %Protean.State{context: %{count: 0}, event: nil, private: %{actions: []}, value: [["active", "#"]]} 71 | ``` 72 | 73 | `Protean.current/1` returns a `%Protean.State{}` that represents the current state of our machine. Notice that we can access the current context. 74 | 75 | ```elixir 76 | state.context 77 | ``` 78 | 79 | 80 | 81 | ``` 82 | %{count: 0} 83 | ``` 84 | 85 | And though we know that our counter only has a single state that it can be in, we can check anyways. 86 | 87 | ```elixir 88 | Protean.matches?(state, :active) 89 | ``` 90 | 91 | 92 | 93 | ``` 94 | true 95 | ``` 96 | 97 | Let's send an event to our machine and see it increment that counter. 98 | 99 | ```elixir 100 | state = Protean.send(counter, "INC") 101 | state.context 102 | ``` 103 | 104 | 105 | 106 | ``` 107 | %{count: 1} 108 | ``` 109 | 110 | `Protean.send/2` sends an event syncronously and returns the updated state. We can also send events asyncronously: 111 | 112 | ```elixir 113 | :ok = Protean.send_async(counter, "INC") 114 | :ok = Protean.send_async(counter, "INC") 115 | :ok = Protean.send_async(counter, "INC") 116 | 117 | Protean.current(counter).context 118 | ``` 119 | 120 | 121 | 122 | ``` 123 | %{count: 4} 124 | ``` 125 | 126 | One last thing to note is that Protean machines don't mind receiving events they don't care about. Our counter doesn't know how to decrement, but we can send that event anyway and watch the counter do nothing. 127 | 128 | ```elixir 129 | Protean.send(counter, "DEC").context 130 | ``` 131 | 132 | 133 | 134 | ``` 135 | %{count: 4} 136 | ``` 137 | 138 | ## Changing state 139 | 140 | Let's define a slightly more interesting machine, one that models a request that can either succeed or fail. 141 | 142 | ```elixir 143 | defmodule Request do 144 | use Protean 145 | 146 | defmachine [ 147 | initial: :pending, 148 | states: [ 149 | pending: [ 150 | on: [ 151 | SUCCEEDED: :success, 152 | FAILED: :fail 153 | ] 154 | ], 155 | success: [], 156 | fail: [] 157 | ] 158 | ] 159 | end 160 | ``` 161 | 162 | 163 | 164 | ``` 165 | {:module, Request, <<70, 79, 82, 49, 0, 0, 14, ...>>, :ok} 166 | ``` 167 | 168 | ```elixir 169 | {:ok, request} = Request.start_link() 170 | Protean.matches?(request, :pending) 171 | ``` 172 | 173 | 174 | 175 | ``` 176 | true 177 | ``` 178 | 179 | ```elixir 180 | request 181 | |> Protean.send("SUCCEEDED") 182 | |> Protean.matches?(:success) 183 | ``` 184 | 185 | 186 | 187 | ``` 188 | true 189 | ``` 190 | -------------------------------------------------------------------------------- /test/protean/parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.ParserTest do 2 | use ExUnit.Case 3 | 4 | alias Protean.Action 5 | alias Protean.Events 6 | alias Protean.Parser 7 | 8 | describe "delayed transition syntax:" do 9 | test "single transition" do 10 | id = {["#"], 2000} 11 | 12 | [ 13 | [ 14 | after: [ 15 | delay: 2000, 16 | target: "b" 17 | ] 18 | ], 19 | [ 20 | entry: [ 21 | Action.spawn(:delayed_send, 2000, id) 22 | ], 23 | exit: [ 24 | Action.spawn(:cancel, id) 25 | ], 26 | on: [ 27 | {Events.platform(:spawn, :done, id), target: "b"} 28 | ] 29 | ] 30 | ] 31 | |> assert_parsed_same() 32 | end 33 | 34 | test "multiple transitions with condition" do 35 | [ 36 | [ 37 | after: [ 38 | [ 39 | delay: 1000, 40 | guard: "some_condition", 41 | target: "c" 42 | ], 43 | [ 44 | delay: 2000, 45 | target: "c" 46 | ] 47 | ] 48 | ], 49 | [ 50 | entry: [ 51 | Action.spawn(:delayed_send, 1000, {["#"], 1000}), 52 | Action.spawn(:delayed_send, 2000, {["#"], 2000}) 53 | ], 54 | exit: [ 55 | Action.spawn(:cancel, {["#"], 1000}), 56 | Action.spawn(:cancel, {["#"], 2000}) 57 | ], 58 | on: [ 59 | {Events.platform(:spawn, :done, {["#"], 1000}), target: "c", guard: "some_condition"}, 60 | {Events.platform(:spawn, :done, {["#"], 2000}), target: "c"} 61 | ] 62 | ] 63 | ] 64 | |> assert_parsed_same() 65 | end 66 | end 67 | 68 | describe "spawn syntax:" do 69 | test "tasks with anonymous functions" do 70 | task_fun = fn -> :result end 71 | 72 | [ 73 | [ 74 | spawn: [ 75 | id: "task_id", 76 | task: task_fun, 77 | done: "done_state", 78 | error: "error_state", 79 | autoforward: true 80 | ] 81 | ], 82 | [ 83 | entry: [ 84 | Action.spawn(:task, task_fun, "task_id", autoforward: true) 85 | ], 86 | exit: [ 87 | Action.spawn(:cancel, "task_id") 88 | ], 89 | on: [ 90 | {Events.platform(:spawn, :done, "task_id"), target: "done_state"}, 91 | {Events.platform(:spawn, :error, "task_id"), target: "error_state"} 92 | ] 93 | ] 94 | ] 95 | |> assert_parsed_same() 96 | end 97 | 98 | test "procs" do 99 | [ 100 | [ 101 | spawn: [ 102 | id: "proc_id", 103 | proc: Anything, 104 | done: "done_state", 105 | error: "error_state" 106 | ] 107 | ], 108 | [ 109 | entry: [ 110 | Action.spawn(:proc, Anything, "proc_id", autoforward: false) 111 | ], 112 | exit: [ 113 | Action.spawn(:cancel, "proc_id") 114 | ], 115 | on: [ 116 | {Events.platform(:spawn, :done, "proc_id"), target: "done_state"}, 117 | {Events.platform(:spawn, :error, "proc_id"), target: "error_state"} 118 | ] 119 | ] 120 | ] 121 | |> assert_parsed_same() 122 | end 123 | end 124 | 125 | test "done" do 126 | [ 127 | [ 128 | initial: "a", 129 | states: [ 130 | a: [] 131 | ], 132 | done: "other" 133 | ], 134 | [ 135 | initial: "a", 136 | states: [ 137 | a: [] 138 | ], 139 | on: [ 140 | {Events.platform(:done, ["#"]), target: "other"} 141 | ] 142 | ] 143 | ] 144 | |> assert_parsed_same() 145 | end 146 | 147 | defp assert_parsed_same(nodes) do 148 | [parsed1, parsed2] = Enum.map(nodes, &Parser.parse_node/1) 149 | 150 | unless parsed_same?(parsed1, parsed2) do 151 | assert parsed1 == parsed2 152 | end 153 | end 154 | 155 | defp parsed_same?(f1, f2) when is_function(f1) and is_function(f2), do: true 156 | 157 | defp parsed_same?(l1, l2) when is_list(l1) and is_list(l2) do 158 | length(l1) === length(l2) && 159 | Enum.all?(Enum.zip(l1, l2), fn {e1, e2} -> parsed_same?(e1, e2) end) 160 | end 161 | 162 | defp parsed_same?(t1, t2) when is_tuple(t1) and is_tuple(t2) do 163 | parsed_same?(Tuple.to_list(t1), Tuple.to_list(t2)) 164 | end 165 | 166 | defp parsed_same?(m1, m2) when is_map(m1) and is_map(m2) do 167 | [l1, l2] = 168 | for m <- [m1, m2] do 169 | m |> Map.to_list() |> Enum.sort_by(fn {k, _v} -> k end) 170 | end 171 | 172 | parsed_same?(l1, l2) 173 | end 174 | 175 | defp parsed_same?(x, x), do: true 176 | defp parsed_same?(_, _), do: false 177 | end 178 | -------------------------------------------------------------------------------- /test/support/protean_test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.TestCase do 2 | @moduledoc """ 3 | Utilities for testing modules defined with `use Protean`. 4 | 5 | ## Usage 6 | 7 | defmodule ProteanIntegration.ExampleTest do 8 | use Protean.TestCase 9 | 10 | defmodule ExampleMachine do 11 | use Protean 12 | 13 | @machine [ 14 | assigns: [ 15 | foo: 1, 16 | bar: 2 17 | ], 18 | initial: :a, 19 | states: [ 20 | a: [ 21 | on: [goto_b: :b] 22 | ], 23 | b: [] 24 | ] 25 | ] 26 | end 27 | 28 | @moduletag machine: ExampleMachine 29 | 30 | test "example", %{machine: machine} do 31 | assert_protean(machine, [ 32 | matches: "a", 33 | send: "goto_b", 34 | matches: "b", 35 | assigns: [foo: 1] 36 | ]) 37 | end 38 | end 39 | 40 | ### Tags 41 | 42 | Provides the following tag usage through ExUnit's `setup` and `on_exit` callbacks: 43 | 44 | * `@tag machine: Machine` - Starts and monitors the machine process. Adds the following to 45 | assigns: `%{machine: machine_pid, ref: monitor_ref}` 46 | * `@tag machine: {Machine, opts}` - Like the above but calls `Machine.start_link(opts)` to 47 | start the process with the given options. 48 | * `@tag machines: [{Machine, opts}, Machine]` - Will start all machines in the list with 49 | given options and will add to assigns: `%{machines: [%{machine: pid, ref: ref}, ...]}` 50 | 51 | ### Helpers 52 | 53 | Provides test helper to turn a list of instructions into events sent to the machine and 54 | assertions on the machine's context. See `assert_protean/2`. 55 | """ 56 | 57 | use ExUnit.CaseTemplate 58 | import ExUnit.Assertions 59 | 60 | using do 61 | quote do 62 | import Protean.TestCase 63 | end 64 | end 65 | 66 | setup context do 67 | if trigger = context[:trigger] do 68 | {:ok, _} = start_supervised({Trigger, name: trigger}) 69 | end 70 | 71 | Process.flag(:trap_exit, true) 72 | setup_context(context) 73 | end 74 | 75 | defp setup_context(%{machines: machines}) when is_list(machines) do 76 | {all_machines, exit_funs} = 77 | machines 78 | |> Enum.map(&setup_machine/1) 79 | |> Enum.unzip() 80 | 81 | on_exit(fn -> Enum.each(exit_funs, & &1.()) end) 82 | 83 | [machines: all_machines] 84 | end 85 | 86 | defp setup_context(%{machine: machine}) do 87 | {assigns_to_add, exit_fun} = setup_machine(machine) 88 | 89 | on_exit(exit_fun) 90 | 91 | assigns_to_add 92 | end 93 | 94 | defp setup_context(_other), do: :ok 95 | 96 | @doc """ 97 | Runs through a list of instructions in order, sending events to and making assertions on the 98 | running machine process. 99 | """ 100 | def assert_protean(pid, instructions) do 101 | Enum.reduce(instructions, nil, fn 102 | # Actions 103 | {:call, event}, _ -> 104 | Protean.call(pid, event) 105 | 106 | {:send_async, event}, _ -> 107 | Protean.send(pid, event) 108 | nil 109 | 110 | # Utilities 111 | {:sleep, milliseconds}, _ when is_integer(milliseconds) -> 112 | :timer.sleep(milliseconds) 113 | nil 114 | 115 | # Assertions 116 | {:matches, descriptor}, acc -> 117 | context = Protean.current(pid) 118 | assert Protean.matches?(context, descriptor) 119 | acc 120 | 121 | {:assigns, assigns}, acc -> 122 | current_assigns = Protean.current(pid).assigns 123 | 124 | for {key, value} <- Enum.into(assigns, []) do 125 | assert current_assigns[key] == value 126 | end 127 | 128 | acc 129 | 130 | {:last_matches, descriptor}, {context, _} = acc -> 131 | assert Protean.matches?(context, descriptor) 132 | acc 133 | 134 | {:last_assigns, assigns}, {context, _} = acc -> 135 | for {key, value} <- Enum.into(assigns, []) do 136 | assert context.assigns[key] == value 137 | end 138 | 139 | acc 140 | 141 | {:last_replies, expected}, {_, actual} = acc -> 142 | assert actual == expected 143 | acc 144 | 145 | {:last_matches, _}, nil -> 146 | raise "Used :last_matches but have no stored result" 147 | 148 | {:last_assigns, _}, nil -> 149 | raise "Used :last_assigns but have no stored result" 150 | 151 | {:last_replies, _}, nil -> 152 | raise "Used :last_replies but have no stored result" 153 | 154 | other, _ -> 155 | raise "Unknown `assert_protean/2` instruction: #{inspect(other)}" 156 | end) 157 | end 158 | 159 | defp setup_machine({module, opts}) do 160 | {:ok, pid} = Protean.start_machine(module, opts) 161 | ref = Process.monitor(pid) 162 | :ok = Protean.ping(pid) 163 | 164 | {%{machine: pid, ref: ref}, fn -> Process.exit(pid, :normal) end} 165 | end 166 | 167 | defp setup_machine(module), do: setup_machine({module, []}) 168 | end 169 | -------------------------------------------------------------------------------- /test/integration/internal_transitions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.InternalTransitionsTest do 2 | use Protean.TestCase, async: true 3 | 4 | defmodule Siblings do 5 | use Protean 6 | alias Protean.Action 7 | 8 | @machine [ 9 | assigns: [ 10 | on_entry: [], 11 | on_exit: [] 12 | ], 13 | initial: "a", 14 | states: [ 15 | compound(:a, 16 | initial: "a1", 17 | entry: ["add_a_entry"], 18 | exit: ["add_a_exit"], 19 | states: [ 20 | atomic(:a1, 21 | entry: ["add_a1_entry"], 22 | exit: ["add_a1_exit"], 23 | on: [ 24 | a1_self_external: [ 25 | target: "a1", 26 | internal: false 27 | ], 28 | a1_self_internal: [ 29 | target: "a1" 30 | ] 31 | ] 32 | ), 33 | atomic(:a2, 34 | entry: ["add_a2_entry"], 35 | exit: ["add_a2_exit"] 36 | ) 37 | ], 38 | on: [ 39 | a1_external: [ 40 | target: "a.a1", 41 | internal: false 42 | ], 43 | a1_internal: [ 44 | target: "a.a1" 45 | ], 46 | a2_external: [ 47 | target: "a.a2", 48 | internal: false 49 | ], 50 | a2_internal: [ 51 | target: "a.a2", 52 | internal: true 53 | ], 54 | b: [ 55 | target: "b" 56 | ] 57 | ] 58 | ), 59 | atomic(:b, 60 | on: [ 61 | back_to_a: "a" 62 | ] 63 | ) 64 | ] 65 | ] 66 | 67 | def handle_action("add_a_entry", context, _), do: add(context, :on_entry, "a") 68 | def handle_action("add_a1_entry", context, _), do: add(context, :on_entry, "a1") 69 | def handle_action("add_a2_entry", context, _), do: add(context, :on_entry, "a2") 70 | 71 | def handle_action("add_a_exit", context, _), do: add(context, :on_exit, "a") 72 | def handle_action("add_a1_exit", context, _), do: add(context, :on_exit, "a1") 73 | def handle_action("add_a2_exit", context, _), do: add(context, :on_exit, "a2") 74 | 75 | def add(context, key, value) do 76 | current = context.assigns[key] 77 | Action.assign(context, %{key => [value | current]}) 78 | end 79 | end 80 | 81 | describe "sibling states" do 82 | @describetag machine: Siblings 83 | 84 | test "internal self-transitions do not trigger entry/exit actions", %{machine: machine} do 85 | assert_protean(machine, 86 | call: :a1_self_internal, 87 | assigns: [on_entry: ["a1", "a"], on_exit: []] 88 | ) 89 | end 90 | 91 | test "external self-transitions trigger entry/exit actions", %{machine: machine} do 92 | assert_protean(machine, 93 | assigns: [on_entry: ["a1", "a"], on_exit: []], 94 | call: :a1_self_external, 95 | assigns: [on_entry: ["a1", "a1", "a"], on_exit: ["a1"]] 96 | ) 97 | end 98 | 99 | test "internal relative transitions do not trigger entry/exit actions", %{machine: machine} do 100 | assert_protean(machine, 101 | call: :a2_internal, 102 | matches: "a.a2", 103 | assigns: [on_entry: ["a2", "a1", "a"], on_exit: ["a1"]] 104 | ) 105 | end 106 | 107 | test "external relative transitions trigger entry/exit actions", %{machine: machine} do 108 | assert_protean(machine, 109 | call: :a2_external, 110 | matches: "a.a2", 111 | assigns: [on_entry: ["a2", "a", "a1", "a"], on_exit: ["a", "a1"]] 112 | ) 113 | end 114 | 115 | test "transition to sibling and back", %{machine: machine} do 116 | assert_protean(machine, 117 | call: :b, 118 | matches: "b", 119 | assigns: [on_entry: ["a1", "a"], on_exit: ["a", "a1"]], 120 | call: :back_to_a, 121 | matches: "a.a1", 122 | assigns: [on_entry: ["a1", "a", "a1", "a"], on_exit: ["a", "a1"]] 123 | ) 124 | end 125 | end 126 | 127 | defmodule Parents do 128 | use Protean 129 | 130 | @machine [ 131 | initial: "parent", 132 | states: [ 133 | parent: [ 134 | initial: "a", 135 | on: [ 136 | {:parent_internal, actions: [:noop]}, 137 | {:parent_external, actions: [:noop], internal: false} 138 | ], 139 | states: [ 140 | a: [ 141 | on: [ 142 | {:goto_b, target: "b"} 143 | ] 144 | ], 145 | b: [] 146 | ] 147 | ] 148 | ] 149 | ] 150 | 151 | @impl Protean 152 | def handle_action(:noop, context, _), do: context 153 | end 154 | 155 | describe "parent transitions" do 156 | @describetag machine: Parents 157 | 158 | test "internal parent transition does not change active children", %{machine: machine} do 159 | assert_protean(machine, 160 | matches: "parent.a", 161 | call: :goto_b, 162 | matches: "parent.b", 163 | call: :parent_internal, 164 | matches: "parent.b" 165 | ) 166 | end 167 | 168 | test "external parent transitions re-enter children", %{machine: machine} do 169 | assert_protean(machine, 170 | matches: "parent.a", 171 | call: :goto_b, 172 | matches: "parent.b", 173 | call: :parent_external, 174 | matches: "parent.a" 175 | ) 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "credo": {:hex, :credo, "1.6.6", "f51f8d45db1af3b2e2f7bee3e6d3c871737bda4a91bff00c5eec276517d1a19c", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "625520ce0984ee0f9f1f198165cd46fa73c1e59a17ebc520038b8fce056a5bdc"}, 5 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, 9 | "excoveralls": {:hex, :excoveralls, "0.15.0", "ac941bf85f9f201a9626cc42b2232b251ad8738da993cf406a4290cacf562ea4", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9631912006b27eca30a2f3c93562bc7ae15980afb014ceb8147dc5cdd8f376f1"}, 10 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 11 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 12 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 13 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 14 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 17 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 18 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 20 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 21 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, 22 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 23 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 24 | } 25 | -------------------------------------------------------------------------------- /lib/protean/process_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.ProcessManager do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | @compile {:inline, supervisor: 0, task_supervisor: 0, registry: 0, subprocess_key: 1} 7 | 8 | @supervisor Module.concat(__MODULE__, Supervisor) 9 | @task_supervisor Module.concat(__MODULE__, TaskSupervisor) 10 | @registry Module.concat(__MODULE__, Registry) 11 | 12 | @type subprocess :: {id :: term(), pid(), reference(), meta :: term()} 13 | 14 | @doc """ 15 | Start a registered and monitored subprocess of the calling process. 16 | 17 | The subprocess will be saved as a `{id, pid, ref, meta}` tuple. 18 | """ 19 | @spec start_subprocess(term(), Supervisor.child_spec(), term()) :: :ok | {:error, term()} 20 | def start_subprocess(id, child_spec, meta \\ nil) do 21 | with :error <- fetch_subprocess(id), 22 | {:ok, pid} <- start_child(child_spec), 23 | ref <- Process.monitor(pid), 24 | {:ok, _} <- register_subprocess({id, pid, ref, meta}) do 25 | :ok 26 | else 27 | {:ok, {_id, pid, _ref, _meta}} -> 28 | {:error, {:already_started, pid}} 29 | 30 | {:error, {:already_started, pid}} -> 31 | supervisor().terminate_child(@supervisor, pid) 32 | 33 | require Logger 34 | 35 | Logger.warn( 36 | "possible race condition occurred; terminated process #{pid} should not have started" 37 | ) 38 | 39 | {:error, {:already_started, pid}} 40 | 41 | other -> 42 | other 43 | end 44 | end 45 | 46 | @doc """ 47 | Start a registered async task. See `Task.Supervisor.async_nolink/3`. 48 | 49 | 50 | """ 51 | @spec start_task(term(), function() | mfa()) :: :ok 52 | def start_task(id, task) do 53 | with task <- async_nolink(task), 54 | {:ok, _} <- register_subprocess({id, task.pid, task.ref, task: true}) do 55 | :ok 56 | end 57 | end 58 | 59 | @doc """ 60 | Stop and deregister a subprocess of the calling process. 61 | """ 62 | @spec stop_subprocess(term()) :: :ok 63 | def stop_subprocess(id) do 64 | case fetch_subprocess(id) do 65 | {:ok, proc} -> stop_subprocesses([proc]) 66 | :error -> :ok 67 | end 68 | end 69 | 70 | @doc """ 71 | Stop and deregister all subprocesses of the calling process. 72 | """ 73 | @spec stop_all_subprocesses() :: :ok 74 | def stop_all_subprocesses do 75 | stop_subprocesses(registered_subprocesses()) 76 | end 77 | 78 | defp stop_subprocesses(subprocesses) do 79 | for {id, pid, ref, meta} <- subprocesses do 80 | if Keyword.get(meta, :task, false) do 81 | task_supervisor().terminate_child(@task_supervisor, pid) 82 | else 83 | supervisor().terminate_child(@supervisor, pid) 84 | end 85 | 86 | Process.demonitor(ref, [:flush]) 87 | unregister_subprocess(id) 88 | end 89 | 90 | :ok 91 | end 92 | 93 | @doc """ 94 | Select all registered subprocesses of the calling process. 95 | """ 96 | def registered_subprocesses do 97 | registry().select(@registry, [ 98 | { 99 | {subprocess_key(:_), self(), :"$1"}, 100 | [], 101 | [:"$1"] 102 | } 103 | ]) 104 | end 105 | 106 | @doc """ 107 | Look up the subprocess of the calling process registered by `id`. 108 | """ 109 | @spec fetch_subprocess(term()) :: {:ok, subprocess} | :error 110 | def fetch_subprocess(id) do 111 | case registry().lookup(@registry, subprocess_key(id)) do 112 | [{_, proc}] -> {:ok, proc} 113 | [] -> :error 114 | end 115 | end 116 | 117 | @doc """ 118 | Look up the subprocess of the calling process by its monitor `ref`. 119 | """ 120 | @spec subprocess_by_ref(reference()) :: subprocess | nil 121 | def subprocess_by_ref(ref) do 122 | registry().select(@registry, [ 123 | { 124 | {subprocess_key(:_), self(), {:"$1", :"$2", ref, :"$3"}}, 125 | [], 126 | [{{:"$1", :"$2", :"$3"}}] 127 | } 128 | ]) 129 | |> case do 130 | [{id, pid, meta}] -> {id, pid, ref, meta} 131 | _ -> nil 132 | end 133 | end 134 | 135 | def whereis(id) do 136 | case GenServer.whereis(via_registry(id)) do 137 | nil -> :error 138 | pid -> {:ok, pid} 139 | end 140 | end 141 | 142 | def via_registry(id) do 143 | {:via, registry(), {@registry, id}} 144 | end 145 | 146 | def register_subprocess({id, _, _, _} = value) do 147 | registry().register(@registry, subprocess_key(id), value) 148 | end 149 | 150 | def unregister_subprocess(id) do 151 | registry().unregister(@registry, subprocess_key(id)) 152 | end 153 | 154 | def start_child(child_spec) do 155 | supervisor().start_child(@supervisor, child_spec) 156 | end 157 | 158 | def async_nolink(fun) when is_function(fun) do 159 | task_supervisor().async_nolink(@task_supervisor, fun) 160 | end 161 | 162 | def async_nolink({mod, fun, args}) when is_atom(mod) and is_atom(fun) and is_list(args) do 163 | task_supervisor().async_nolink(@task_supervisor, mod, fun, args) 164 | end 165 | 166 | def which_children do 167 | supervisor().which_children(@supervisor) 168 | end 169 | 170 | def count_registered do 171 | registry().count(@registry) 172 | end 173 | 174 | defp subprocess_key(id) do 175 | {:subprocess, self(), id} 176 | end 177 | 178 | # Supervisor 179 | 180 | def start_link(_) do 181 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 182 | end 183 | 184 | @impl true 185 | def init(_arg) do 186 | configure!() 187 | 188 | children = [ 189 | {supervisor(), name: @supervisor, strategy: :one_for_one}, 190 | {task_supervisor(), name: @task_supervisor}, 191 | {registry(), name: @registry, keys: :unique} 192 | ] 193 | 194 | Supervisor.init(children, strategy: :one_for_one) 195 | end 196 | 197 | defp task_supervisor, do: Task.Supervisor 198 | defp supervisor, do: :persistent_term.get(@supervisor) 199 | defp registry, do: :persistent_term.get(@registry) 200 | 201 | defp configure! do 202 | :persistent_term.put( 203 | @supervisor, 204 | Application.get_env(:protean, :supervisor, DynamicSupervisor) 205 | ) 206 | 207 | :persistent_term.put( 208 | @registry, 209 | Application.get_env(:protean, :registry, Registry) 210 | ) 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /test/protean_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanTest do 2 | use Protean.TestCase, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | test "should warn if unknown options are given to `use Protean`" do 7 | moduledef = 8 | quote do 9 | defmodule ShouldWarn do 10 | use Protean, unknown: :option 11 | 12 | @machine [ 13 | initial: :init, 14 | states: [atomic(:init)] 15 | ] 16 | end 17 | end 18 | 19 | warning = 20 | capture_log(fn -> 21 | Code.eval_quoted(moduledef) 22 | end) 23 | 24 | assert warning =~ "unknown options" 25 | end 26 | 27 | defmodule SimpleMachine do 28 | use Protean 29 | 30 | @machine [ 31 | initial: :init, 32 | states: [ 33 | atomic(:init) 34 | ], 35 | on: [ 36 | match(:reply, actions: :reply) 37 | ] 38 | ] 39 | 40 | @impl true 41 | def handle_action(:reply, state, _) do 42 | {:reply, :ok, state} 43 | end 44 | end 45 | 46 | describe "start_machine/2" do 47 | test "should return error if name already started" do 48 | {:ok, _} = Protean.start_machine(SimpleMachine, name: Machine) 49 | assert {:error, {:already_started, _}} = Protean.start_machine(SimpleMachine, name: Machine) 50 | 51 | Protean.stop(Machine, :normal, 10) 52 | end 53 | 54 | test "should return pid if name supplied" do 55 | {:ok, pid} = Protean.start_machine(SimpleMachine, name: Machine) 56 | assert is_pid(pid) 57 | 58 | Protean.stop(Machine) 59 | end 60 | 61 | test "should return pid if name not supplied" do 62 | {:ok, pid} = Protean.start_machine(SimpleMachine) 63 | assert is_pid(pid) 64 | 65 | Protean.stop(pid) 66 | end 67 | end 68 | 69 | describe "stop_machine/3" do 70 | test "should accept an optional reason" do 71 | assert {:ok, name} = Protean.start_machine(SimpleMachine) 72 | assert :ok = Protean.stop(name, :normal) 73 | end 74 | 75 | test "should accept an optional reason and timeout" do 76 | assert {:ok, name} = Protean.start_machine(SimpleMachine) 77 | assert :ok = Protean.stop(name, :normal, 50) 78 | end 79 | end 80 | 81 | describe "basic API usage" do 82 | @describetag machine: SimpleMachine 83 | 84 | test "call/2", %{machine: machine} do 85 | assert {%Protean.Context{}, []} = Protean.call(machine, "event") 86 | end 87 | 88 | test "send/2", %{machine: machine} do 89 | :ok = Protean.send(machine, "event") 90 | end 91 | 92 | test "send_after/3", %{machine: machine} do 93 | assert timer = Protean.send_after(machine, "event", 1000) 94 | assert 0 < Process.cancel_timer(timer) 95 | end 96 | 97 | test "current/1", %{machine: machine} do 98 | assert %Protean.Context{} = Protean.current(machine) 99 | end 100 | 101 | test "stop/2", %{machine: machine, ref: ref} do 102 | :ok = Protean.stop(machine, :default) 103 | assert_receive {:DOWN, ^ref, :process, _, {:shutdown, %Protean.Context{}}} 104 | end 105 | 106 | test "matches?/2", %{machine: machine} do 107 | assert Protean.matches?(machine, "init") 108 | assert Protean.current(machine) |> Protean.matches?("init") 109 | end 110 | end 111 | 112 | describe "subscribe/2" do 113 | @describetag machine: SimpleMachine 114 | 115 | test "should subscribe caller to transitions", %{machine: machine} do 116 | {:ok, id} = Protean.subscribe(machine) 117 | Protean.call(machine, "event") 118 | assert_receive {^id, %Protean.Context{}, []} 119 | end 120 | 121 | test "can subscribe caller to only replies", %{machine: machine} do 122 | {:ok, id} = Protean.subscribe(machine, filter: :replies) 123 | 124 | Protean.call(machine, "event") 125 | refute_receive {^id, _, _} 126 | 127 | Protean.call(machine, :reply) 128 | assert_receive {^id, _, [:ok]} 129 | end 130 | 131 | test "should raise if given a bad filter", %{machine: machine} do 132 | assert_raise ArgumentError, fn -> Protean.subscribe(machine, filter: :bad) end 133 | end 134 | end 135 | 136 | describe "unsubscribe/1" do 137 | @describetag machine: SimpleMachine 138 | 139 | test "should unsubscribe calling process from transition", %{machine: machine} do 140 | {:ok, id} = Protean.subscribe(machine) 141 | :ok = Protean.unsubscribe(machine) 142 | assert Protean.call(machine, "event") 143 | refute_receive {^id, _, _} 144 | end 145 | end 146 | 147 | defmodule MachineWithoutCallbacks do 148 | use Protean, callback_module: ProteanTest.CallbackModule 149 | 150 | @machine [ 151 | initial: "init", 152 | states: [ 153 | atomic(:init, entry: :my_action) 154 | ] 155 | ] 156 | end 157 | 158 | defmodule CallbackModule do 159 | def handle_action(:my_action, state, _) do 160 | state 161 | |> Protean.Action.assign(:data, :foo) 162 | end 163 | end 164 | 165 | @tag machine: MachineWithoutCallbacks 166 | test "separate callback_module can be specified", %{machine: machine} do 167 | assert_protean(machine, 168 | assigns: [data: :foo] 169 | ) 170 | end 171 | 172 | defmodule DefaultAssigns do 173 | use Protean 174 | 175 | @machine [ 176 | assigns: %{data: :foo}, 177 | initial: "init", 178 | states: [ 179 | atomic(:init) 180 | ] 181 | ] 182 | end 183 | 184 | describe "machines with assigns:" do 185 | test "started with default assigns" do 186 | {:ok, pid} = start_supervised(DefaultAssigns) 187 | assert Protean.current(pid).assigns == %{data: :foo} 188 | end 189 | 190 | test "started with replacement assigns" do 191 | {:ok, pid} = start_supervised({DefaultAssigns, assigns: %{data: :bar}}) 192 | assert Protean.current(pid).assigns == %{data: :bar} 193 | end 194 | 195 | test "started with added assigns" do 196 | {:ok, pid} = start_supervised({DefaultAssigns, assigns: %{bar: :baz}}) 197 | assert Protean.current(pid).assigns == %{data: :foo, bar: :baz} 198 | end 199 | end 200 | 201 | test "Phoenix.PubSub is optional" do 202 | {stdout, 0} = 203 | System.cmd("mix", ["deps.compile", "--force"], 204 | cd: "./test/support/dependent_project", 205 | stderr_to_stdout: true 206 | ) 207 | 208 | refute stdout =~ "warning: Phoenix.PubSub" 209 | refute stdout =~ "warning: " 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/protean/machinery.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Machinery do 2 | @moduledoc """ 3 | Purely-functional statechart core based on the SCXML specification. 4 | 5 | Provides the underlying state-transition logic for a statechart, primarily through 6 | `transition/3`, a higher-level transition API for using `%Protean.Machine{}` apart from the 7 | interpreter provided by Protean, and `take_transitions/3`, a lower-level API used by a 8 | statechart interpreter. 9 | 10 | It is uncommon to use this module independently of the `Protean` behaviour. 11 | """ 12 | 13 | alias Protean.Context 14 | alias Protean.MachineConfig 15 | alias Protean.Node 16 | alias Protean.Transition 17 | 18 | @doc "Transition in response to an event." 19 | @spec transition(MachineConfig.t(), Context.t(), Protean.event()) :: Context.t() 20 | def transition(config, context, event) do 21 | with transitions <- select_transitions(config, context, event) do 22 | take_transitions(config, context, transitions) 23 | end 24 | end 25 | 26 | @doc "Select any machine transitions that apply to the given event in the current context." 27 | @spec select_transitions(MachineConfig.t(), Context.t(), Protean.event()) :: [Transition.t()] 28 | def select_transitions(config, context, event, attribute \\ :transitions) do 29 | # TODO: Handle conflicting transitions 30 | # TODO: order nodes correctly (specificity + document order) 31 | config 32 | |> MachineConfig.active(context.value) 33 | |> first_enabled_transition(config, context, event, attribute) 34 | |> List.wrap() 35 | end 36 | 37 | @spec take_transitions(MachineConfig.t(), Context.t(), [Transition.t()]) :: Context.t() 38 | def take_transitions(config, context, transitions) 39 | 40 | def take_transitions(_config, context, []), do: context 41 | 42 | def take_transitions(config, context, transitions) do 43 | {exit_sets, entry_sets} = 44 | transitions 45 | |> Enum.map(&transition_result(config, context, &1)) 46 | |> Enum.unzip() 47 | 48 | to_exit = exit_sets |> Enum.reduce(&MapSet.union/2) |> Node.exit_order() 49 | to_enter = entry_sets |> Enum.reduce(&MapSet.union/2) |> Node.entry_order() 50 | 51 | value = 52 | context.value 53 | |> MapSet.difference(leaf_ids(to_exit)) 54 | |> MapSet.union(leaf_ids(to_enter)) 55 | 56 | actions = 57 | Enum.concat([ 58 | Enum.flat_map(to_exit, &Node.exit_actions/1), 59 | Enum.flat_map(transitions, &Transition.actions/1), 60 | Enum.flat_map(to_enter, &Node.entry_actions/1) 61 | ]) 62 | 63 | final_states = final_ancestors(config, value) 64 | 65 | context 66 | |> Context.assign_active(value) 67 | |> Context.assign_final(final_states) 68 | |> Context.put_actions(actions) 69 | end 70 | 71 | defp transition_result(config, context, transition) do 72 | domain = Transition.domain(transition) 73 | 74 | active_in_scope = 75 | config 76 | |> MachineConfig.active(context.value) 77 | |> Enum.filter(&Node.descendant?(&1.id, domain)) 78 | |> MapSet.new() 79 | 80 | targets = effective_targets(config, transition) 81 | 82 | to_exit = 83 | Enum.filter(active_in_scope, fn node -> 84 | !Enum.all?(targets, fn target -> 85 | (transition.internal && node == target) || Node.descendant?(node.id, target.id) 86 | end) 87 | end) 88 | |> MapSet.new() 89 | 90 | remaining_active = MapSet.difference(active_in_scope, to_exit) 91 | 92 | to_enter = MapSet.difference(targets, remaining_active) 93 | 94 | {to_exit, to_enter} 95 | end 96 | 97 | @spec effective_targets(MachineConfig.t(), Transition.t()) :: MapSet.t(Node.t()) 98 | defp effective_targets(config, %Transition{internal: true} = t) do 99 | domain = Transition.domain(t) 100 | 101 | t.target_ids 102 | |> Enum.flat_map(&MachineConfig.lineage(config, &1)) 103 | |> Enum.filter(&Node.descendant?(&1.id, domain)) 104 | |> MapSet.new() 105 | end 106 | 107 | defp effective_targets(config, %Transition{internal: false} = t) do 108 | domain = Transition.domain(t) 109 | 110 | config 111 | |> MachineConfig.active(t.target_ids) 112 | |> Enum.filter(&Node.descendant?(&1.id, domain)) 113 | |> MapSet.new() 114 | end 115 | 116 | defp leaf_ids(nodes) do 117 | nodes 118 | |> Enum.filter(&Node.leaf?/1) 119 | |> Enum.map(& &1.id) 120 | |> MapSet.new() 121 | end 122 | 123 | defp loose_descendant?(id1, id2) do 124 | id1 == id2 || Node.descendant?(id1, id2) 125 | end 126 | 127 | defp first_enabled_transition(nodes, config, context, event, attribute) do 128 | nodes 129 | |> Enum.flat_map(&Map.get(&1, attribute)) 130 | |> find_enabled_transition(config, context, event) 131 | end 132 | 133 | defp find_enabled_transition(transitions, config, context, event) do 134 | Enum.find(transitions, fn transition -> 135 | Transition.enabled?(transition, event, context, config.callback_module) 136 | end) 137 | end 138 | 139 | # Return the ancestors of the given active leaves that are in a final state 140 | @spec final_ancestors(MachineConfig.t(), Context.value()) :: MapSet.t(Node.id()) 141 | defp final_ancestors(config, ids) do 142 | for id <- ids do 143 | parent_id = Node.parent_id(id) 144 | grandparent_id = Node.parent_id(parent_id) 145 | 146 | if grandparent_id && MachineConfig.fetch!(config, grandparent_id).type == :parallel do 147 | [parent_id, grandparent_id] 148 | else 149 | [parent_id] 150 | end 151 | end 152 | |> Enum.concat() 153 | |> Enum.uniq() 154 | |> Enum.filter(&in_final_state?(MachineConfig.fetch!(config, &1), ids)) 155 | |> MapSet.new() 156 | end 157 | 158 | @spec in_final_state?(Node.t(), Context.value()) :: boolean() 159 | defp in_final_state?(%Node{type: :atomic}, _), do: false 160 | 161 | defp in_final_state?(%Node{type: :final} = node, value) do 162 | node.id in value 163 | end 164 | 165 | defp in_final_state?(%Node{type: :compound} = node, value) do 166 | node 167 | |> active_child(value) 168 | |> in_final_state?(value) 169 | end 170 | 171 | defp in_final_state?(%Node{type: :parallel} = node, value) do 172 | Enum.all?(node.states, &in_final_state?(&1, value)) 173 | end 174 | 175 | defp active_child(%Node{type: :compound} = node, active_ids) do 176 | Enum.find(node.states, fn child -> 177 | Enum.any?(active_ids, &loose_descendant?(&1, child.id)) 178 | end) 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /lib/protean/node.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Node do 2 | @moduledoc """ 3 | Internal representation of an individual node in a `Protean.MachineConfig` struct. 4 | """ 5 | 6 | alias __MODULE__ 7 | alias Protean.Action 8 | alias Protean.Transition 9 | 10 | @enforce_keys [:id, :type] 11 | 12 | @derive {Inspect, only: [:id, :type, :initial, :order, :states]} 13 | defstruct [ 14 | :id, 15 | :type, 16 | :initial, 17 | :states, 18 | :order, 19 | automatic_transitions: [], 20 | transitions: [], 21 | entry: [], 22 | exit: [] 23 | ] 24 | 25 | @typedoc """ 26 | A Node is a node in a nested state machine. See the type docs for 27 | individual nodes for more details. 28 | """ 29 | @type t :: atomic | final | compound | parallel 30 | 31 | @type nodes :: [t] 32 | 33 | @typedoc """ 34 | A leaf Node is one that cannot have child states. 35 | """ 36 | @type leaf :: atomic | final 37 | 38 | @typedoc """ 39 | A simple `Node` is one that cannot have child nodes. 40 | """ 41 | @type simple :: atomic | final 42 | 43 | @typedoc """ 44 | A complex `Node` is one that has child nodes. 45 | """ 46 | @type complex :: compound | parallel 47 | 48 | @typedoc """ 49 | ID encompasses the node and all its ancestors. For example, a node `:child_a` 50 | defined as a child of a node `:parent_a` might have the id `[:child_a, :parent_a]` 51 | """ 52 | @type id :: [String.t(), ...] 53 | 54 | @typedoc """ 55 | An atomic node is a node without child states. 56 | """ 57 | @type atomic :: %Node{ 58 | type: :atomic, 59 | id: id, 60 | initial: nil, 61 | states: nil, 62 | automatic_transitions: [Transition.t()], 63 | transitions: [Transition.t()], 64 | entry: [Action.t()], 65 | exit: [Action.t()], 66 | order: non_neg_integer() | nil 67 | } 68 | 69 | @typedoc """ 70 | A final node is a type of atomic node that represents some form of completion, 71 | and can therefore define no transitions itself. Note, however, that activating 72 | a final node causes an event to be dispatched that a parent node can choose to 73 | handle. 74 | """ 75 | @type final :: %Node{ 76 | type: :final, 77 | id: id, 78 | initial: nil, 79 | states: nil, 80 | automatic_transitions: [], 81 | transitions: [], 82 | entry: [Action.t()], 83 | exit: [Action.t()], 84 | order: non_neg_integer() | nil 85 | } 86 | 87 | @typedoc """ 88 | A compound node is a node that defines children, of which only one can be 89 | active. It must additionally define an `:initial` attribute, the id of the 90 | child state that should default to active if the compound state is entered. 91 | """ 92 | @type compound :: %Node{ 93 | type: :compound, 94 | id: id, 95 | initial: id, 96 | states: [t, ...], 97 | automatic_transitions: [Transition.t()], 98 | transitions: [Transition.t()], 99 | entry: [Action.t()], 100 | exit: [Action.t()], 101 | order: non_neg_integer() | nil 102 | } 103 | 104 | @typedoc """ 105 | A parallel node defines children, all of which are entered when the parallel 106 | node is entered. 107 | """ 108 | @type parallel :: %Node{ 109 | type: :parallel, 110 | id: id, 111 | initial: nil, 112 | states: [t, ...], 113 | automatic_transitions: [Transition.t()], 114 | transitions: [Transition.t()], 115 | entry: [Action.t()], 116 | exit: [Action.t()], 117 | order: non_neg_integer() | nil 118 | } 119 | 120 | @doc false 121 | def append(%Node{} = n, attr, list) when is_atom(attr) and is_list(list) do 122 | Map.update(n, attr, list, &(&1 ++ list)) 123 | end 124 | 125 | @doc false 126 | def prepend(%Node{} = n, attr, list) when is_atom(attr) and is_list(list) do 127 | Map.update(n, attr, list, &(list ++ &1)) 128 | end 129 | 130 | @doc "Return the entry actions associated with the node." 131 | @spec entry_actions(t) :: [Action.t()] 132 | def entry_actions(%Node{} = n), do: n.entry 133 | 134 | @doc "Return the exit actions associated with the node." 135 | @spec exit_actions(t) :: [Action.t()] 136 | def exit_actions(%Node{} = n), do: n.exit 137 | 138 | @doc """ 139 | Resolve a Node to its leaves (atomic or final) by either returning the 140 | given node or following the node's children. 141 | """ 142 | @spec resolve_to_leaves(t) :: [leaf] 143 | def resolve_to_leaves(%Node{} = node) do 144 | case node.type do 145 | :atomic -> 146 | [node] 147 | 148 | :final -> 149 | [node] 150 | 151 | :compound -> 152 | node.states 153 | |> Enum.find(&(&1.id == node.initial)) 154 | |> resolve_to_leaves() 155 | 156 | :parallel -> 157 | node.states 158 | |> Enum.flat_map(&resolve_to_leaves/1) 159 | end 160 | end 161 | 162 | def leaf?(%Node{type: :atomic}), do: true 163 | def leaf?(%Node{type: :final}), do: true 164 | def leaf?(_), do: false 165 | 166 | @doc "Given a Node id, return a list containing that id and all of its ancestors." 167 | @spec ancestor_ids(id) :: [id] 168 | def ancestor_ids([]), do: [] 169 | def ancestor_ids([_self | parent] = id), do: [id | ancestor_ids(parent)] 170 | 171 | @doc "Given a Node id, return its parent's id. Returns nil if the Node is the root." 172 | @spec parent_id(id) :: id | nil 173 | def parent_id([_]), do: nil 174 | def parent_id([_ | parent]), do: parent 175 | 176 | @doc "Sort the given nodes in entry order." 177 | @spec entry_order(nodes) :: nodes 178 | def entry_order(nodes) do 179 | Enum.sort_by(nodes, & &1.order, :asc) 180 | end 181 | 182 | @doc "Sort the given nodes in exit order." 183 | @spec exit_order(nodes) :: nodes 184 | def exit_order(nodes) do 185 | Enum.sort_by(nodes, & &1.order, :desc) 186 | end 187 | 188 | @doc """ 189 | Tests whether `descendant_id` is in fact a descendant of `ancestor_id`. 190 | Returns false if they are the same node. 191 | """ 192 | @spec descendant?(id, id) :: boolean() 193 | def descendant?(descendant_id, ancestor_id) do 194 | descendant_id != ancestor_id && 195 | common_ancestor_id(descendant_id, ancestor_id) == ancestor_id 196 | end 197 | 198 | @spec common_ancestor_id(id, id) :: id 199 | def common_ancestor_id(id1, id2) do 200 | [id1, id2] 201 | |> Enum.map(&Enum.reverse/1) 202 | |> then(fn [rev1, rev2] -> get_prefix(rev1, rev2) end) 203 | |> Enum.reverse() 204 | end 205 | 206 | def common_ancestor_id(ids) do 207 | shortest = ids |> Enum.map(&Enum.count/1) |> Enum.min() 208 | 209 | ids 210 | |> Enum.map(&Enum.reverse/1) 211 | |> Enum.map(&Enum.take(&1, shortest - 1)) 212 | |> do_common_ancestor_id() 213 | end 214 | 215 | defp do_common_ancestor_id(ids, acc \\ []) 216 | 217 | defp do_common_ancestor_id([[] | _], acc), do: acc 218 | 219 | defp do_common_ancestor_id(ids, acc) do 220 | [id | _] = ids 221 | 222 | if Enum.all?(ids, &(hd(&1) == hd(id))) do 223 | ids 224 | |> Enum.map(&Enum.drop(&1, 1)) 225 | |> do_common_ancestor_id([hd(id) | acc]) 226 | else 227 | acc 228 | end 229 | end 230 | 231 | defp get_prefix([], _), do: [] 232 | defp get_prefix(_, []), do: [] 233 | defp get_prefix([x | xs], [x | ys]), do: [x | get_prefix(xs, ys)] 234 | defp get_prefix([_x | _xs], [_y | _ys]), do: [] 235 | end 236 | -------------------------------------------------------------------------------- /test/protean/action_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Protean.ActionTest do 2 | use ExUnit.Case, async: true 3 | import ExUnit.CaptureLog 4 | 5 | alias Protean.Action 6 | alias Protean.Context 7 | alias Protean.Interpreter 8 | 9 | defmodule BasicMachine do 10 | use Protean 11 | 12 | @machine [ 13 | initial: :init, 14 | states: [ 15 | atomic(:init) 16 | ] 17 | ] 18 | 19 | @impl true 20 | def handle_action(:invalid_return, _, _) do 21 | :should_raise 22 | end 23 | end 24 | 25 | defmodule MyAction do 26 | @behaviour Action 27 | 28 | @impl true 29 | def exec_action(:cont_with_empty_actions, interpreter) do 30 | {:cont, interpreter, []} 31 | end 32 | 33 | def exec_action(:cont_with_actions, interpreter) do 34 | {:cont, interpreter, [Action.assign(foo: :bar)]} 35 | end 36 | 37 | def exec_action(:cont_with_no_actions, interpreter) do 38 | {:cont, interpreter} 39 | end 40 | 41 | def exec_action(:halt, interpreter) do 42 | {:halt, interpreter} 43 | end 44 | 45 | def exec_action(:other, interpreter) do 46 | {:other, interpreter} 47 | end 48 | end 49 | 50 | setup do 51 | interpreter = Interpreter.new(machine: BasicMachine.__protean_machine__()) 52 | [interpreter: interpreter, context: interpreter.context] 53 | end 54 | 55 | describe "exec/2:" do 56 | test "should normalize actions that return no actions", %{interpreter: interpreter} do 57 | result = 58 | MyAction 59 | |> Action.new(:cont_with_empty_actions) 60 | |> Action.exec(interpreter) 61 | 62 | assert {:cont, _interpreter} = result 63 | 64 | result = 65 | MyAction 66 | |> Action.new(:cont_with_no_actions) 67 | |> Action.exec(interpreter) 68 | 69 | assert {:cont, _interpreter} = result 70 | end 71 | 72 | test "should allow additional actions", %{interpreter: interpreter} do 73 | result = 74 | MyAction 75 | |> Action.new(:cont_with_actions) 76 | |> Action.exec(interpreter) 77 | 78 | assert {:cont, _interpreter, [_]} = result 79 | end 80 | 81 | test "should allow halting actions", %{interpreter: interpreter} do 82 | result = 83 | MyAction 84 | |> Action.new(:halt) 85 | |> Action.exec(interpreter) 86 | 87 | assert {:halt, _interpreter} = result 88 | end 89 | 90 | test "should raise for anything else", %{interpreter: interpreter} do 91 | action = Action.new(MyAction, :other) 92 | assert_raise RuntimeError, fn -> Action.exec(action, interpreter) end 93 | end 94 | end 95 | 96 | describe "put:" do 97 | test "put/2 should insert an action into context", %{context: context} do 98 | assert [] = Context.actions(context) 99 | context = Action.put(context, Action.assign(foo: :bar)) 100 | assert [_] = Context.actions(context) 101 | end 102 | 103 | test "put/2 should raise if not given an action struct", %{context: context} do 104 | assert_raise FunctionClauseError, fn -> Action.put(context, :foo) end 105 | end 106 | end 107 | 108 | describe "delegate:" do 109 | test "delegate/2 should insert any action into context", %{context: context} do 110 | context = Action.delegate(context, :foo) 111 | assert [_] = Context.actions(context) 112 | end 113 | 114 | test "should log if callback returns invalid value", %{interpreter: interpreter} do 115 | message = 116 | capture_log(fn -> 117 | :invalid_return 118 | |> Action.delegate() 119 | |> Action.exec(interpreter) 120 | end) 121 | 122 | assert message =~ "invalid return" 123 | end 124 | end 125 | 126 | describe "assign:" do 127 | test "assign/1 should assign key/value data to context.assigns", 128 | %{interpreter: interpreter} do 129 | interpreter = 130 | Action.exec_all(interpreter, [ 131 | Action.assign(foo: nil, bar: 2), 132 | Action.assign(foo: 1), 133 | Action.assign(%{baz: 3}), 134 | Action.assign([{"buzz", 4}]) 135 | ]) 136 | 137 | assert %{"buzz" => 4, foo: 1, bar: 2, baz: 3} = interpreter.context.assigns 138 | end 139 | end 140 | 141 | describe "assign_in:" do 142 | test "assign_in/3 should add an action to context", %{context: context} do 143 | assert [] = Context.actions(context) 144 | context = Action.assign_in(context, [:foo], :bar) 145 | assert [_] = Context.actions(context) 146 | end 147 | 148 | test "assign_in/2 should assign a value based on path", %{interpreter: interpreter} do 149 | interpreter = 150 | Action.exec_all(interpreter, [ 151 | Action.assign(foo: 1, bar: 2), 152 | Action.assign_in([:foo], 11), 153 | Action.assign_in([:bar], fn -> :whatever end) 154 | ]) 155 | 156 | %{foo: foo, bar: bar} = interpreter.context.assigns 157 | assert 11 = foo 158 | assert is_function(bar) 159 | end 160 | 161 | test "assign_in/2 should raise if path is empty list", %{interpreter: interpreter} do 162 | assert_raise FunctionClauseError, 163 | fn -> Action.exec_all(interpreter, [Action.assign_in([], :foo)]) end 164 | end 165 | 166 | test "assign_in/2 should raise if path not a list", %{interpreter: interpreter} do 167 | assert_raise FunctionClauseError, 168 | fn -> Action.exec_all(interpreter, [Action.assign_in(:foo, :bar)]) end 169 | end 170 | end 171 | 172 | describe "update:" do 173 | test "update/2 should assign an action to context", %{context: context} do 174 | assert [] = Context.actions(context) 175 | context = Action.update(context, fn -> %{foo: 1} end) 176 | assert [_] = Context.actions(context) 177 | end 178 | 179 | test "update/1 should apply an update fn to assigns", %{interpreter: interpreter} do 180 | interpreter = 181 | Action.exec_all(interpreter, [ 182 | Action.assign(foo: 1), 183 | Action.update(fn %{foo: foo} -> %{foo: foo + 1} end) 184 | ]) 185 | 186 | assert %{foo: 2} = interpreter.context.assigns 187 | end 188 | 189 | test "update/1 merges result into assigns", %{interpreter: interpreter} do 190 | interpreter = 191 | Action.exec_all(interpreter, [ 192 | Action.assign(foo: 1), 193 | Action.update(fn _ -> %{bar: 2} end) 194 | ]) 195 | 196 | assert %{foo: 1, bar: 2} = interpreter.context.assigns 197 | end 198 | end 199 | 200 | describe "update_in:" do 201 | test "update_in/3 should assign an action to context", %{context: context} do 202 | assert [] = Context.actions(context) 203 | context = Action.update_in(context, [:foo], fn foo -> foo + 1 end) 204 | assert [_] = Context.actions(context) 205 | end 206 | 207 | test "update_in/2 applies a function to value in path", %{interpreter: interpreter} do 208 | interpreter = 209 | Action.exec_all(interpreter, [ 210 | Action.assign(foo: %{bar: %{baz: 1}}), 211 | Action.update_in([:foo, :bar, :baz], &(&1 + 1)) 212 | ]) 213 | 214 | assert %{foo: %{bar: %{baz: 2}}} = interpreter.context.assigns 215 | end 216 | end 217 | 218 | describe "send:" do 219 | test "send/2 should be able to send to self", %{interpreter: interpreter} do 220 | Action.exec_all(interpreter, [ 221 | Action.send(:message1), 222 | Action.send(:message2, to: :self) 223 | ]) 224 | 225 | assert_receive :message1 226 | assert_receive :message2 227 | end 228 | 229 | test "send/2 accepts an optional delay", %{interpreter: interpreter} do 230 | Action.exec_all(interpreter, [ 231 | Action.send(:message, to: self(), delay: 0) 232 | ]) 233 | 234 | assert_receive :message 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Docs](https://img.shields.io/badge/hex.pm-docs-8e7ce6.svg)](https://hexdocs.pm/protean/0.1.0-alpha.3/Protean.html) 2 | [![CI](https://github.com/zachallaun/protean/actions/workflows/ci.yml/badge.svg)](https://github.com/zachallaun/protean/actions/workflows/ci.yml) 3 | 4 | 5 | 6 | _Caveat emptor: Protean is likely to undergo significant changes and should not be considered stable._ 7 | 8 | A library for managing state and side-effects with event-driven statecharts. 9 | 10 | Protean was initially inspired by [XState](https://xstate.js.org/docs/), a robust JavaScript/TypeScript statechart implementation, but strays to adhere to Elixir and OTP conventions. 11 | We also follow much of the [SCXML W3C Standard](https://www.w3.org/TR/scxml/)'s recommendations, but compatibility is not a goal. 12 | 13 | **What are statecharts?** 14 | They are an extension to finite state machines that allow you to model complex behavior in a declarative, data-driven manner. 15 | They include nested and parallel states, enhanced/augmented state (through assigns), side-effects (through actions), process management (through spawn), and more. 16 | To learn more about statecharts, I recommend [statecharts.dev](https://statecharts.dev/). 17 | 18 | ## Goals 19 | 20 | This project is currently an exploration of statecharts as they fit into the assigns of Elixir and OTP. 21 | XState adopted the actor model in its implementation, so Elixir seemed like a natural fit. 22 | However, it may be that Elixir/OTP makes these abstractions unnecessary. 23 | 24 | ## Example 25 | 26 | This simple statechart has a single state that defines the behavior of a counter with an optional maximum and minimum. 27 | 28 | ```elixir 29 | defmodule Counter do 30 | use Protean 31 | alias Protean.Action 32 | 33 | @machine [ 34 | initial: :active, 35 | assigns: [ 36 | count: 0, 37 | min: nil, 38 | max: nil 39 | ], 40 | states: [ 41 | atomic(:active, 42 | on: [ 43 | match("Inc", actions: :increment, guard: [not: :at_max]), 44 | match("Dec", actions: :decrement, guard: [not: :at_min]), 45 | match({"Set", _}, actions: :set_min_or_max), 46 | match({"Log", _}, actions: :log) 47 | ] 48 | ) 49 | ] 50 | ] 51 | 52 | @impl true 53 | def handle_action(:increment, context, _event) do 54 | context 55 | |> Action.assign_in([:count], & &1 + 1) 56 | end 57 | 58 | def handle_action(:decrement, context, _event) do 59 | context 60 | |> Action.assign_in([:count], & &1 - 1) 61 | end 62 | 63 | def handle_action(:set_min_or_max, context, {"Set", {key, val}}) do 64 | context 65 | |> Action.assign(key, val) 66 | end 67 | 68 | def handle_action(:log, context, {"Log", attribute}) do 69 | %{assigns: assigns} = context 70 | IO.puts("#{attribute}: #{assigns[attribute]}") 71 | 72 | context 73 | end 74 | 75 | @impl true 76 | def guard(:at_max, %{assigns: %{max: max, count: count}}, _event) do 77 | max && count >= max 78 | end 79 | 80 | def guard(:at_min, %{assigns: %{min: min, count: count}}, _event) do 81 | min && count <= min 82 | end 83 | end 84 | ``` 85 | 86 | It can be started under a supervisor, but we'll start it directly using Protean's built-in `DynamicSupervisor`. 87 | 88 | ```elixir 89 | {:ok, pid} = Protean.start_machine(Counter) 90 | 91 | Protean.current(pid).assigns 92 | # %{count: 0, min: nil, max: nil} 93 | 94 | Protean.send(pid, "Inc") 95 | # :ok 96 | 97 | Enum.each(1..4, fn _ -> Protean.send(pid, "Inc") end) 98 | 99 | Protean.current(pid).assigns 100 | # %{count: 5, min: nil, max: nil} 101 | 102 | Protean.call(pid, {"Set", {:max, 10}}) 103 | # %Protean.Context{ 104 | # assigns: %{count: 5, max: 10, min: nil}, 105 | # event: {"Set", {:max, 10}}, 106 | # value: MapSet.new([["active", "#"]]) 107 | # } 108 | 109 | Enum.each(1..20, fn _ -> Protean.send(pid, "Inc") end) 110 | 111 | Protean.send(pid, {"Log", :count}) 112 | # count: 10 113 | ``` 114 | 115 | ## Defining a statechart 116 | 117 | Protean machines are event-driven _statecharts_, which means that, unlike ordinary finite-state machines, they can have complex, nested, potentially parallel states. 118 | This is more easily visualized than read, and I highly recommend looking at XContext's [introduction to state machines and statecharts](https://xstate.js.org/docs/guides/introduction-to-state-machines-and-statecharts/) for that reason. 119 | 120 | Refer to `Protean.Builder` for documentation on machine definitions. 121 | When `use Protean` is invoked, functions and macros from `Protean.Builder` are imported automatically. 122 | 123 | ### The `@machine` attribute 124 | 125 | By default, Protean assumes that your machine is defined on the `@machine` attribute. 126 | 127 | ```elixir 128 | defmodule MyMachine do 129 | use Protean 130 | 131 | @machine [ 132 | initial: :my_initial_state, 133 | states: [ 134 | atomic(:my_initial_state, 135 | # ... 136 | ), 137 | # ... 138 | ] 139 | ] 140 | end 141 | ``` 142 | 143 | ## Starting supervised machines 144 | 145 | Since state machines typically model structured interactions with a defined beginning and end, they will generally be started under a `DynamicSupervisor`. 146 | Protean starts one (as well as a `Registry`) by default, in order to manage subprocesses that are started during machine execution through the use of `Protean.Builder.proc/2` et al. 147 | 148 | Machines can be started under this supervisor using `Protean.start_machine/2`. 149 | 150 | ```elixir 151 | {:ok, machine} = Protean.start_machine(MyMachine) 152 | ``` 153 | 154 | Similar to `GenServer`, calling `use Protean` will also define a `child_spec/1` that allows you to start a machine in a standard supervision tree, if you wish: 155 | 156 | ```elixir 157 | children = [ 158 | Counter 159 | ] 160 | 161 | Supervisor.start_link(children, strategy: :one_for_one) 162 | ``` 163 | 164 | ## Interacting with Protean machines 165 | 166 | Under the hood, a Protean machine is a `GenServer`, and `Protean` exposes a similar set of functions for interacting with one. 167 | You can see the individual docs for the functions in this module for details on their behavior, but here are some highlights. 168 | 169 | ### Familiar functions 170 | 171 | * `call/3` - Send an event synchronously to a Protean machine and receive the machine context and any replies resulting from transition. 172 | * `send/2` - Send an event asynchronously to a Protean machine. Always returns `:ok`. 173 | * `send_after/3` - Send an event to a Protean machine after a given delay. Like `Process.send_after/4`, returns a timer reference so that the send can be canceled with `Process.cancel_timer/2`. 174 | 175 | ### Additional functions specific to Protean machines 176 | 177 | * `current/1` - Get the current machine context of a running Protean machine. 178 | * `matches?/2` - Query the currently active state(s) of a machine. 179 | * `subscribe/2` (and `unsubscribe/2`) - Subscribes the calling process to receive a message on every state transition. 180 | 181 | ## Subscriptions 182 | 183 | You can subscribe to a Protean machine to receive messages when the machine transitions. 184 | This functionality depends on the optional dependency `:phoenix_pubsub`. 185 | To use it, add the following to `deps` in your `mix.exs`: 186 | 187 | ```elixir 188 | defp deps do 189 | [ 190 | :phoenix_pubsub 191 | # ... 192 | ] 193 | end 194 | ``` 195 | 196 | If you are already starting a `Phoenix.PubSub` in your application (e.g. a Phoenix application), you need to configure the `:protean` application to use your process instead of starting its own. 197 | This can be done by adding the following to your `config.exs`: 198 | 199 | ```elixir 200 | config :protean, :pubsub, 201 | name: MyApp.PubSub, 202 | start: false 203 | ``` 204 | 205 | For subscription usage, see `subscribe/2`. 206 | 207 | 208 | 209 | ## Documentation 210 | 211 | Documentation can be found [on hexdocs](https://hexdocs.pm/protean/). 212 | Things are changing pretty regularly, however, and some documentation is certainly out-of-sync. 213 | 214 | ## Installation 215 | 216 | ```elixir 217 | def deps do 218 | [ 219 | {:protean, "~> 0.1.0"} 220 | ] 221 | end 222 | ``` 223 | -------------------------------------------------------------------------------- /test/integration/spawned_task_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.SpawnedTaskTest do 2 | use Protean.TestCase, async: true 3 | 4 | @moduletag trigger: SpawnedTaskTrigger 5 | 6 | defmodule OneOffTask do 7 | use Protean 8 | 9 | @machine [ 10 | initial: :init, 11 | states: [ 12 | atomic(:init, 13 | spawn: [ 14 | task(fn -> Trigger.trigger(SpawnedTaskTrigger, :ran_task) end) 15 | ] 16 | ) 17 | ] 18 | ] 19 | end 20 | 21 | describe "OneOffTask:" do 22 | @describetag machine: OneOffTask 23 | 24 | test "should run" do 25 | assert Trigger.await(SpawnedTaskTrigger, :ran_task) 26 | end 27 | end 28 | 29 | defmodule AnonymousFunctionTasks do 30 | use Protean 31 | alias Protean.Action 32 | 33 | @machine [ 34 | assigns: [ 35 | result: nil 36 | ], 37 | initial: :a, 38 | states: [ 39 | atomic(:a, 40 | spawn: [ 41 | task(fn -> :task_result end, 42 | done: [ 43 | actions: "save_result", 44 | target: :b 45 | ] 46 | ) 47 | ] 48 | ), 49 | atomic(:b, 50 | entry: Trigger.action(SpawnedTaskTrigger, :b), 51 | on: [ 52 | goto_c: "c" 53 | ] 54 | ), 55 | atomic(:c, 56 | spawn: [ 57 | task(fn -> :second_task_result end, 58 | done: [ 59 | actions: "save_result", 60 | target: :d 61 | ] 62 | ) 63 | ] 64 | ), 65 | atomic(:d, entry: Trigger.action(SpawnedTaskTrigger, :d)) 66 | ] 67 | ] 68 | 69 | @impl true 70 | def handle_action("save_result", context, result) do 71 | Action.assign(context, :result, result) 72 | end 73 | end 74 | 75 | describe "AnonymousFunctionTasks:" do 76 | @describetag machine: AnonymousFunctionTasks 77 | 78 | test "anonymous function task spawned from initial state", %{machine: machine} do 79 | assert Trigger.await(SpawnedTaskTrigger, :b) 80 | 81 | assert_protean(machine, 82 | assigns: [result: :task_result] 83 | ) 84 | end 85 | 86 | test "anonymous function task spawned after transition", %{machine: machine} do 87 | assert Trigger.await(SpawnedTaskTrigger, :b) 88 | Protean.send(machine, :goto_c) 89 | assert Trigger.await(SpawnedTaskTrigger, :d) 90 | end 91 | end 92 | 93 | defmodule MFATasks do 94 | use Protean 95 | alias Protean.Action 96 | 97 | @machine [ 98 | assigns: [result: nil], 99 | initial: "a", 100 | states: [ 101 | a: [ 102 | spawn: [ 103 | task({__MODULE__, :my_task, [:arg]}, 104 | done: [ 105 | actions: ["save_result"], 106 | target: "b" 107 | ] 108 | ) 109 | ] 110 | ], 111 | b: [ 112 | entry: Trigger.action(SpawnedTaskTrigger, :b) 113 | ] 114 | ] 115 | ] 116 | 117 | def my_task(value) do 118 | {:task_return, value} 119 | end 120 | 121 | @impl true 122 | def handle_action("save_result", state, result) do 123 | Action.assign(state, :result, result) 124 | end 125 | end 126 | 127 | describe "MFATasks:" do 128 | @describetag machine: MFATasks 129 | 130 | test "MFA task spawned in initial state", %{machine: machine} do 131 | assert Trigger.await(SpawnedTaskTrigger, :b) 132 | assert %{result: {:task_return, :arg}} = Protean.current(machine).assigns 133 | end 134 | end 135 | 136 | defmodule ErrorRaisingTasks do 137 | use Protean 138 | 139 | @machine [ 140 | initial: "init", 141 | states: [ 142 | init: [ 143 | spawn: [ 144 | task({__MODULE__, :raise_error, []}, 145 | done: "success", 146 | error: "failure" 147 | ) 148 | ] 149 | ], 150 | success: [ 151 | entry: Trigger.action(SpawnedTaskTrigger, :success) 152 | ], 153 | failure: [ 154 | entry: Trigger.action(SpawnedTaskTrigger, :failure) 155 | ] 156 | ] 157 | ] 158 | 159 | def raise_error do 160 | raise "any error" 161 | end 162 | end 163 | 164 | describe "ErrorRaisingTasks:" do 165 | import ExUnit.CaptureLog 166 | 167 | @describetag machine: ErrorRaisingTasks 168 | 169 | test "Error transition taken when task raises" do 170 | capture_log(fn -> 171 | assert Trigger.await(SpawnedTaskTrigger, :failure, :infinity) 172 | refute Trigger.triggered?(SpawnedTaskTrigger, :success) 173 | end) 174 | end 175 | end 176 | 177 | defmodule ResolvedTaskSpawn do 178 | use Protean 179 | 180 | @machine [ 181 | initial: "init", 182 | states: [ 183 | init: [ 184 | spawn: [ 185 | task("my_task", done: :success) 186 | ] 187 | ], 188 | success: [ 189 | entry: Trigger.action(SpawnedTaskTrigger, :success) 190 | ] 191 | ] 192 | ] 193 | 194 | @impl true 195 | def spawn(:task, "my_task", _state, _event) do 196 | fn -> :result end 197 | end 198 | end 199 | 200 | describe "ResolvedTaskspawn:" do 201 | @describetag machine: ResolvedTaskSpawn 202 | 203 | test "tasks can be resolved by callback module" do 204 | assert Trigger.await(SpawnedTaskTrigger, :success) 205 | end 206 | end 207 | 208 | defmodule CanceledTask do 209 | use Protean 210 | 211 | @machine [ 212 | initial: "init", 213 | states: [ 214 | init: [ 215 | spawn: [ 216 | task("send_message_to_self", done: "sent") 217 | ], 218 | on: [ 219 | cancel: "canceled" 220 | ] 221 | ], 222 | canceled: [ 223 | entry: Trigger.action(SpawnedTaskTrigger, :canceled), 224 | on: [ 225 | message: "sent" 226 | ] 227 | ], 228 | # shouldn't get here 229 | sent: [ 230 | entry: Trigger.action(SpawnedTaskTrigger, :sent) 231 | ] 232 | ] 233 | ] 234 | 235 | @impl true 236 | def spawn(:task, "send_message_to_self", _state, _event) do 237 | me = self() 238 | 239 | fn -> 240 | :timer.sleep(30) 241 | Protean.call(me, "message") 242 | end 243 | end 244 | end 245 | 246 | describe "CanceledTask:" do 247 | @describetag machine: CanceledTask 248 | 249 | test "transitioning out of invoking state should cancel task", %{machine: machine} do 250 | Protean.send(machine, :cancel) 251 | assert Trigger.await(SpawnedTaskTrigger, :canceled) 252 | refute Trigger.triggered?(SpawnedTaskTrigger, :sent) 253 | end 254 | end 255 | 256 | defmodule ManyTasks do 257 | use Protean 258 | 259 | @machine [ 260 | initial: :how_many, 261 | assigns: %{data: MapSet.new()}, 262 | states: [ 263 | atomic(:how_many, 264 | spawn: [ 265 | task(:one), 266 | task(:two, done: [actions: :save]), 267 | task( 268 | fn -> 269 | Trigger.trigger(SpawnedTaskTrigger, :three) 270 | :value_three 271 | end, 272 | done: [actions: :save] 273 | ) 274 | ], 275 | on: [ 276 | match(_, actions: :save) 277 | ] 278 | ) 279 | ] 280 | ] 281 | 282 | @impl true 283 | def spawn(:task, :one, _, _) do 284 | fn -> 285 | Trigger.trigger(SpawnedTaskTrigger, :one) 286 | :value_one 287 | end 288 | end 289 | 290 | def spawn(:task, :two, _, _) do 291 | fn -> 292 | Trigger.trigger(SpawnedTaskTrigger, :two) 293 | :value_two 294 | end 295 | end 296 | 297 | @impl true 298 | def handle_action(:save, state, value) when not is_nil(value) and is_atom(value) do 299 | state 300 | |> Protean.Action.update_in([:data], &MapSet.put(&1, value)) 301 | end 302 | 303 | def handle_action(:save, state, _), do: state 304 | end 305 | 306 | @tag machine: ManyTasks 307 | test "tasks can be spawned in many ways", %{machine: machine} do 308 | Trigger.await(SpawnedTaskTrigger, :one) 309 | Trigger.await(SpawnedTaskTrigger, :two) 310 | Trigger.await(SpawnedTaskTrigger, :three) 311 | :timer.sleep(5) 312 | 313 | expected = MapSet.new([:value_one, :value_two, :value_three]) 314 | assert %{data: ^expected} = Protean.current(machine).assigns 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /test/support/test_machines.ex: -------------------------------------------------------------------------------- 1 | defmodule TestMachines do 2 | alias Protean.Action 3 | alias Protean.Interpreter 4 | alias Protean.MachineConfig 5 | 6 | def with_test_machine(%{machine: machine} = assigns) do 7 | assigns 8 | |> Map.merge(test_machine(machine)) 9 | end 10 | 11 | def with_test_machine(%{machines: machines} = assigns) do 12 | assigns 13 | |> Map.put(:machines, Enum.map(machines, &test_machine/1)) 14 | end 15 | 16 | def with_test_machine(assigns), do: assigns 17 | 18 | def test_machine(machine) do 19 | machine = apply(TestMachines, machine, []) 20 | 21 | case machine do 22 | %MachineConfig{} = config -> 23 | %{machine: config, initial: MachineConfig.initial_context(config)} 24 | 25 | module when is_atom(module) -> 26 | config = module.__protean_machine__() 27 | 28 | %{ 29 | machine: config, 30 | initial: MachineConfig.initial_context(config), 31 | interpreter: Interpreter.new(machine: config) 32 | } 33 | end 34 | end 35 | 36 | def simple_machine_1 do 37 | Protean.MachineConfig.new( 38 | type: :compound, 39 | initial: :state_a, 40 | states: [ 41 | state_a: [ 42 | type: :atomic, 43 | on: [ 44 | event_a: [ 45 | target: :state_b 46 | ] 47 | ] 48 | ], 49 | state_b: [ 50 | type: :final 51 | ] 52 | ] 53 | ) 54 | end 55 | 56 | def simple_machine_2 do 57 | Protean.MachineConfig.new( 58 | initial: :state_a, 59 | states: [ 60 | state_a: [ 61 | initial: :state_a1, 62 | states: [ 63 | state_a1: [ 64 | type: :compound, 65 | initial: :state_a1_child, 66 | states: [ 67 | state_a1_child: [] 68 | ], 69 | on: [ 70 | event_a1: :state_b 71 | ] 72 | ] 73 | ], 74 | on: [ 75 | event_a: [ 76 | target: :state_b 77 | ] 78 | ] 79 | ], 80 | state_b: [ 81 | type: :final 82 | ] 83 | ] 84 | ) 85 | end 86 | 87 | def parallel_machine_1 do 88 | Protean.MachineConfig.new( 89 | type: :parallel, 90 | states: [ 91 | state_a: [], 92 | state_b: [] 93 | ] 94 | ) 95 | end 96 | 97 | def machine_with_actions_1 do 98 | Protean.MachineConfig.new( 99 | initial: :state_a, 100 | states: [ 101 | state_a: [ 102 | entry: ["entry_a"], 103 | exit: ["exit_a"], 104 | on: [ 105 | event_a: [ 106 | actions: ["event_a_action"], 107 | target: :state_b 108 | ] 109 | ] 110 | ], 111 | state_b: [ 112 | entry: ["entry_b"], 113 | exit: ["exit_b"], 114 | on: [ 115 | event_b: [ 116 | actions: ["event_b_action"], 117 | target: :state_c 118 | ] 119 | ] 120 | ], 121 | state_c: [ 122 | type: :final, 123 | entry: ["entry_c"] 124 | ] 125 | ] 126 | ) 127 | end 128 | 129 | def parallel_machine_with_actions_1 do 130 | Protean.MachineConfig.new( 131 | initial: :parallel_state_a, 132 | states: [ 133 | parallel_state_a: [ 134 | type: :parallel, 135 | entry: ["entry_parallel_a"], 136 | exit: ["exit_parallel_a"], 137 | states: [ 138 | state_a1: [ 139 | entry: ["entry_a1"], 140 | exit: ["exit_a1"], 141 | on: [ 142 | goto_b: [ 143 | actions: ["action_goto_b_1"], 144 | target: :"#.state_b" 145 | ] 146 | ] 147 | ], 148 | state_a2: [ 149 | entry: ["entry_a2"], 150 | exit: ["exit_a2"], 151 | initial: :foo, 152 | states: [ 153 | foo: [ 154 | exit: ["exit_foo"], 155 | on: [ 156 | foo_event: :bar 157 | ] 158 | ], 159 | bar: [ 160 | entry: ["entry_bar"], 161 | on: [ 162 | goto_b: [ 163 | actions: ["action_goto_b_2"], 164 | target: :"#.state_b" 165 | ] 166 | ] 167 | ] 168 | ] 169 | ] 170 | ] 171 | ], 172 | state_b: [ 173 | type: :final, 174 | entry: ["entry_b"] 175 | ] 176 | ] 177 | ) 178 | end 179 | 180 | defmodule SillyDirectionMachine do 181 | use Protean 182 | 183 | @machine [ 184 | assigns: %{ 185 | direction: :straight 186 | }, 187 | initial: :straight, 188 | states: [ 189 | straight: [], 190 | left: [], 191 | right: [] 192 | ], 193 | on: [ 194 | # go: [ 195 | # [target: :straight, guard: "direction_straight?"], 196 | # [target: :left, guard: "direction_left?"], 197 | # [target: :right, guard: "direction_right?"] 198 | # ], 199 | go: [target: "#straight", guard: "direction_straight?"], 200 | go: [target: "#left", guard: "direction_left?"], 201 | go: [target: "#right", guard: "direction_right?"], 202 | set_straight: [ 203 | actions: [Action.assign(direction: :straight)] 204 | ], 205 | set_left: [ 206 | actions: [Action.assign(direction: :left)] 207 | ], 208 | set_right: [ 209 | actions: [Action.assign(direction: :right)] 210 | ] 211 | ] 212 | ] 213 | 214 | @impl true 215 | def guard("direction_straight?", %{assigns: %{direction: :straight}}, _event), do: true 216 | def guard("direction_straight?", _, _), do: false 217 | 218 | def guard("direction_left?", %{assigns: %{direction: :left}}, _event), do: true 219 | def guard("direction_left?", _, _), do: false 220 | 221 | def guard("direction_right?", %{assigns: %{direction: :right}}, _event), do: true 222 | def guard("direction_right?", _, _), do: false 223 | end 224 | 225 | def silly_direction_machine do 226 | SillyDirectionMachine 227 | end 228 | 229 | defmodule PureMachine1 do 230 | use Protean 231 | 232 | @machine [ 233 | initial: :a, 234 | assigns: %{ 235 | acc: [] 236 | }, 237 | on: [ 238 | goto_a: :a 239 | ], 240 | states: [ 241 | a: [ 242 | initial: :a1, 243 | entry: ["entering_a"], 244 | exit: ["exiting_a"], 245 | on: [ 246 | goto_b: :b 247 | ], 248 | states: [ 249 | a1: [ 250 | on: [ 251 | goto_a2: :a2 252 | ] 253 | ], 254 | a2: [] 255 | ] 256 | ], 257 | b: [] 258 | ] 259 | ] 260 | 261 | @impl true 262 | def handle_action("entering_a", %{assigns: %{acc: acc}} = context, _event) do 263 | Action.assign(context, :acc, ["entering_a" | acc]) 264 | end 265 | 266 | def handle_action("exiting_a", %{assigns: %{acc: acc}} = context, _event) do 267 | Action.assign(context, :acc, ["exiting_a" | acc]) 268 | end 269 | end 270 | 271 | def pure_machine_1 do 272 | PureMachine1 273 | end 274 | 275 | defmodule AutoTransitionMachine1 do 276 | use Protean 277 | 278 | @machine [ 279 | initial: :a, 280 | states: [ 281 | a: [ 282 | always: :b 283 | ], 284 | b: [] 285 | ] 286 | ] 287 | end 288 | 289 | def auto_transition_machine_1 do 290 | AutoTransitionMachine1 291 | end 292 | 293 | defmodule AutoTransitionMachine2 do 294 | use Protean 295 | 296 | @machine [ 297 | initial: :a, 298 | assigns: %{ 299 | acc: [] 300 | }, 301 | states: [ 302 | a: [ 303 | on: [ 304 | goto_b: :b 305 | ] 306 | ], 307 | b: [ 308 | always: [ 309 | target: :c, 310 | actions: ["auto_to_c"] 311 | ] 312 | ], 313 | c: [ 314 | always: [ 315 | target: :d, 316 | actions: ["auto_to_d"] 317 | ] 318 | ], 319 | d: [] 320 | ] 321 | ] 322 | 323 | @impl true 324 | def handle_action(action_name, context, _event) do 325 | %{acc: acc} = context.assigns 326 | Action.assign(context, :acc, [action_name | acc]) 327 | end 328 | end 329 | 330 | def auto_transition_machine_2 do 331 | AutoTransitionMachine2 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /test/integration/spawned_machine_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProteanIntegration.SpawnedMachineTest do 2 | use Protean.TestCase, async: true 3 | import ExUnit.CaptureLog 4 | alias __MODULE__ 5 | 6 | @moduletag trigger: SpawnedMachineTrigger 7 | 8 | defmodule Parent do 9 | use Protean 10 | alias Protean.Action 11 | 12 | @machine [ 13 | initial: "parenting", 14 | states: [ 15 | atomic(:parenting, 16 | spawn: [ 17 | proc(SpawnedMachineTest.Child, id: "child") 18 | ], 19 | on: [ 20 | match(:grow_it, actions: [Action.send(:grow, to: "child")]), 21 | match({:child_grown, _}, "relax") 22 | ] 23 | ), 24 | atomic(:relax, 25 | entry: :trigger 26 | ) 27 | ] 28 | ] 29 | 30 | @impl true 31 | def handle_action(:trigger, state, _) do 32 | Trigger.trigger(SpawnedMachineTrigger, {:relax, self()}) 33 | 34 | state 35 | end 36 | end 37 | 38 | defmodule Child do 39 | use Protean 40 | alias Protean.Action 41 | 42 | @machine [ 43 | initial: "growing", 44 | assigns: %{name: nil}, 45 | states: [ 46 | atomic(:growing, 47 | on: [ 48 | grow: [ 49 | actions: :send_parent, 50 | target: "grown" 51 | ], 52 | status: [ 53 | actions: Action.send(:still_growing, to: :parent) 54 | ] 55 | ] 56 | ), 57 | atomic(:grown) 58 | ] 59 | ] 60 | 61 | @impl true 62 | def handle_action(:send_parent, state, _) do 63 | %{name: name} = state.assigns 64 | 65 | state 66 | |> Action.send({:child_grown, name}, to: :parent) 67 | end 68 | end 69 | 70 | describe "the parent/child relationship" do 71 | @describetag machine: Parent 72 | 73 | test "sending events between parent/child", %{machine: parent} do 74 | Protean.send(parent, :grow_it) 75 | assert Trigger.await(SpawnedMachineTrigger, {:relax, parent}) 76 | end 77 | end 78 | 79 | describe "multiple machines" do 80 | @describetag machines: [Parent, Parent] 81 | 82 | test "can use same ids for child processes", %{machines: machines} do 83 | [%{machine: m1}, %{machine: m2}] = machines 84 | 85 | Protean.send(m1, :grow_it) 86 | Trigger.await(SpawnedMachineTrigger, {:relax, m1}) 87 | 88 | assert Protean.matches?(m1, :relax) 89 | refute Protean.matches?(m2, :relax) 90 | 91 | Protean.call(m2, :grow_it) 92 | Trigger.await(SpawnedMachineTrigger, {:relax, m2}) 93 | 94 | assert Protean.matches?(m2, :relax) 95 | end 96 | end 97 | 98 | defmodule Crashes do 99 | use Protean 100 | 101 | @machine [ 102 | initial: "can_crash", 103 | states: [ 104 | can_crash: [ 105 | on: [ 106 | go_boom: [ 107 | actions: ["crash"] 108 | ] 109 | ] 110 | ] 111 | ] 112 | ] 113 | 114 | @impl Protean 115 | def handle_action("crash", _, _) do 116 | raise "boom" 117 | end 118 | end 119 | 120 | defmodule SpawnCrashes do 121 | use Protean 122 | 123 | @machine [ 124 | initial: "init", 125 | states: [ 126 | init: [ 127 | spawn: [ 128 | proc(ProteanIntegration.SpawnedMachineTest.Crashes, 129 | id: "crashes", 130 | error: [ 131 | actions: ["save_event"], 132 | target: "spawn_crashed" 133 | ] 134 | ) 135 | ], 136 | on: [ 137 | make_it_crash: [ 138 | actions: [Protean.Action.send(:go_boom, to: "crashes")] 139 | ] 140 | ] 141 | ], 142 | spawn_crashed: [ 143 | entry: Trigger.action(SpawnedMachineTrigger, :crashed) 144 | ] 145 | ] 146 | ] 147 | 148 | @impl Protean 149 | def handle_action("save_event", context, event) do 150 | Protean.Action.assign(context, :crash_event, event) 151 | end 152 | end 153 | 154 | describe "spawned machine crashes" do 155 | @describetag machine: SpawnCrashes 156 | 157 | test "trigger error transition", %{machine: machine} do 158 | error_message = 159 | capture_log(fn -> 160 | Protean.send(machine, :make_it_crash) 161 | assert Trigger.await(SpawnedMachineTrigger, :crashed) 162 | end) 163 | 164 | assert error_message =~ "boom" 165 | end 166 | end 167 | 168 | defmodule ImmediatelyCrashes do 169 | use Protean 170 | 171 | @machine [ 172 | initial: "crash_now", 173 | states: [ 174 | crash_now: [ 175 | always: [ 176 | actions: ["crash"] 177 | ] 178 | ] 179 | ] 180 | ] 181 | 182 | @impl Protean 183 | def handle_action("crash", _, _) do 184 | raise "boom" 185 | end 186 | end 187 | 188 | defmodule SpawnImmediatelyCrashes do 189 | use Protean 190 | 191 | @machine [ 192 | initial: "init", 193 | states: [ 194 | init: [ 195 | spawn: [ 196 | proc(ProteanIntegration.SpawnedMachineTest.ImmediatelyCrashes, 197 | error: [ 198 | target: "spawn_crashed" 199 | ] 200 | ) 201 | ] 202 | ], 203 | spawn_crashed: [ 204 | entry: Trigger.action(SpawnedMachineTrigger, :crashed_immediately) 205 | ] 206 | ] 207 | ] 208 | end 209 | 210 | describe "spawn machine immediately crashes" do 211 | @describetag machine: SpawnImmediatelyCrashes 212 | 213 | test "trigger error transition" do 214 | capture_log(fn -> 215 | assert Trigger.await(SpawnedMachineTrigger, :crashed_immediately) 216 | end) 217 | end 218 | end 219 | 220 | defmodule DuplicateIds do 221 | use Protean 222 | 223 | @machine [ 224 | initial: :outer1, 225 | assigns: %{log: []}, 226 | states: [ 227 | compound(:outer1, 228 | initial: :a, 229 | spawn: [ 230 | proc({SpawnedMachineTest.Child, assigns: %{name: "outer1_child"}}, 231 | id: :child, 232 | error: [ 233 | actions: Trigger.action(SpawnedMachineTrigger, :outer1_error) 234 | ] 235 | ) 236 | ], 237 | states: [ 238 | atomic(:a, 239 | spawn: [ 240 | proc(SpawnedMachineTest.Child, 241 | id: :child, 242 | error: [ 243 | actions: Trigger.action(SpawnedMachineTrigger, :inner_error) 244 | ] 245 | ) 246 | ] 247 | ) 248 | ], 249 | on: [ 250 | match(:goto_outer2, target: :outer2) 251 | ] 252 | ), 253 | atomic(:outer2, 254 | spawn: [ 255 | proc({SpawnedMachineTest.Child, assigns: %{name: "outer2_child"}}, 256 | id: :child, 257 | error: [ 258 | actions: Trigger.action(SpawnedMachineTrigger, :outer2_error) 259 | ] 260 | ) 261 | ] 262 | ) 263 | ], 264 | on: [ 265 | match(:grow, actions: Protean.Action.send(:grow, to: :child)), 266 | match(:status, actions: Protean.Action.send(:status, to: :child)), 267 | match({:child_grown, _}, actions: :trigger), 268 | match(:still_growing, 269 | actions: Trigger.action(SpawnedMachineTrigger, :child_growing) 270 | ) 271 | ] 272 | ] 273 | 274 | @impl true 275 | def handle_action(:trigger, state, event) do 276 | Trigger.trigger(SpawnedMachineTrigger, event) 277 | state 278 | end 279 | end 280 | 281 | describe "DuplicateIds:" do 282 | @describetag machine: DuplicateIds 283 | 284 | test "should take error transition when invoking with duplicate id" do 285 | assert Trigger.await(SpawnedMachineTrigger, :inner_error) 286 | refute Trigger.triggered?(SpawnedMachineTrigger, :outer1_error) 287 | end 288 | 289 | test "should keep non-errored child accessible", %{machine: machine} do 290 | Trigger.await(SpawnedMachineTrigger, :inner_error) 291 | Protean.send(machine, :grow) 292 | assert Trigger.await(SpawnedMachineTrigger, {:child_grown, "outer1_child"}) 293 | end 294 | 295 | test "should allow duplicate id after exiting spawn", %{machine: machine} do 296 | Protean.send(machine, :grow) 297 | Trigger.await(SpawnedMachineTrigger, {:child_grown, "outer1_child"}) 298 | Protean.send(machine, :goto_outer2) 299 | Protean.send(machine, :status) 300 | 301 | assert Trigger.await(SpawnedMachineTrigger, :child_growing) 302 | Protean.send(machine, :grow) 303 | assert Trigger.await(SpawnedMachineTrigger, {:child_grown, "outer2_child"}) 304 | end 305 | end 306 | end 307 | -------------------------------------------------------------------------------- /docs/examples/debounce_and_throttle.livemd: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Example: Debounce & Throttle 4 | 5 | ```elixir 6 | Mix.install([ 7 | {:protean, path: "./"}, 8 | :phoenix_pubsub 9 | ]) 10 | ``` 11 | 12 | 13 | 14 | ``` 15 | Resolving Hex dependencies... 16 | Dependency resolution completed: 17 | New: 18 | phoenix_pubsub 2.1.1 19 | * Getting phoenix_pubsub (Hex package) 20 | ==> phoenix_pubsub 21 | Compiling 11 files (.ex) 22 | Generated phoenix_pubsub app 23 | ==> protean 24 | Compiling 18 files (.ex) 25 | Generated protean app 26 | ``` 27 | 28 | 29 | 30 | ``` 31 | :ok 32 | ``` 33 | 34 | ## Introduction 35 | 36 | Debounce and throttle are techniques used to control streams of events, often in the assigns of user interfaces. Both are used to limit the flow of events. 37 | 38 | * **Debounce** limits events by buffering incoming events and only emitting the latest one after a certain amount of time has passed with no new events. Consider a real-time search field, for instance: the user starts typing, and each character emits a `change`. Instead of starting the search, canceling, and starting over on every keypress, you could _debounce_ the events, only emitting a change (and triggering the search) after the user has stopped typing for a certain amount of time. 39 | * **Throttle** emits the first event immediately, but then waits a certain amount of time before emitting another. This turns a potentially rapid stream of events into a constant interval (so long as events are coming faster than the timeout period). 40 | 41 | ## Debounce 42 | 43 | ```elixir 44 | defmodule Examples.Debounce do 45 | use Protean 46 | 47 | @type assigns :: %{ 48 | timeout: non_neg_integer() 49 | } 50 | 51 | @machine [ 52 | initial: :waiting, 53 | assigns: [ 54 | timeout: 1_000 55 | ], 56 | states: [ 57 | atomic(:waiting, 58 | on: [ 59 | match(_, target: :debouncing) 60 | ] 61 | ), 62 | atomic(:debouncing, 63 | after: [ 64 | delay(:timeout, target: :waiting, actions: :reply_with_latest_event) 65 | ], 66 | on: [ 67 | match(_, target: :debouncing, internal: false) 68 | ] 69 | ) 70 | ] 71 | ] 72 | 73 | @impl true 74 | def delay(:timeout, %{assigns: %{timeout: t}}, _), do: t 75 | 76 | @impl true 77 | def handle_action(:reply_with_latest_event, context, event) do 78 | {:reply, event, context} 79 | end 80 | end 81 | ``` 82 | 83 | 84 | 85 | ``` 86 | {:module, Examples.Debounce, <<70, 79, 82, 49, 0, 0, 36, ...>>, {:handle_action, 3}} 87 | ``` 88 | 89 | Our debounce machine relies on delayed- and self-transitions. 90 | 91 | We start in a `waiting` state, awaiting any event. When the first event comes in, we switch to a `debouncing` state. `debouncing` will define a delayed transition based on whatever timeout value is set in the machine assigns. If the delay goes by, we emit the latest event with a `:reply` tuple. 92 | 93 | However, if any events come in while we're `debouncing`, we do an external self-transition, resetting the timeout: 94 | 95 | 96 | 97 | ```elixir 98 | debouncing: [ 99 | # After the delay specified by the `delay(:timeout, context, event)` 100 | # callback, reply with the last event and transition back to waiting. 101 | after: [ 102 | delay: :timeout, 103 | actions: :reply_last, 104 | target: "waiting" 105 | ], 106 | on: [ 107 | # Transitioning to yourself is an internal transition by default, 108 | # which means that entry and exit actions aren't re-executed. Setting 109 | # internal: false makes it an external transition, so it's as if we 110 | # are leaving the debouncing state, which cancels the timer, and then 111 | # re-entering it, starting it again. 112 | match(_, target: "debouncing", actions: :set_last, internal: false) 113 | ] 114 | ] 115 | ``` 116 | 117 | Let's spin one up and give it a whirl. 118 | 119 | ```elixir 120 | {:ok, debounce, id} = Protean.start_machine(Examples.Debounce) 121 | Protean.subscribe(id, filter: :replies) 122 | ``` 123 | 124 | 125 | 126 | ``` 127 | :ok 128 | ``` 129 | 130 | Our debouncer is emitting debounced events as answers, so we've subscribed only to them (as opposed to every state transition). This will put messages in our mainbox. 131 | 132 | Now, let's send some events. We expect our debounce machine to send us one on our subscription 1000ms after the last one we send. 133 | 134 | ```elixir 135 | Enum.each(1..5, fn _ -> 136 | Protean.send(debounce, DateTime.utc_now()) 137 | :timer.sleep(250) 138 | end) 139 | 140 | receive do 141 | {_id, _context, [event]} -> 142 | [event: event, now: DateTime.utc_now()] 143 | after 144 | 5_000 -> IO.inspect(:nothing) 145 | end 146 | ``` 147 | 148 | 149 | 150 | ``` 151 | [event: ~U[2022-08-16 18:13:06.768085Z], now: ~U[2022-08-16 18:13:07.769273Z]] 152 | ``` 153 | 154 | By default, our debouncer is waiting for 1,000ms since the last event before emitting it, but we can control that behavior by providing a different `:timeout` in the machine's assigns. 155 | 156 | ```elixir 157 | {:ok, debounce_500ms, id_500ms} = 158 | Protean.start_machine(Examples.Debounce, assigns: %{timeout: 500}) 159 | 160 | Protean.subscribe(id_500ms, filter: :replies) 161 | ``` 162 | 163 | 164 | 165 | ``` 166 | :ok 167 | ``` 168 | 169 | ```elixir 170 | # Same block as before, except using pid_500ms and events every 250ms 171 | Enum.each(1..5, fn _ -> 172 | Protean.send(debounce_500ms, DateTime.utc_now()) 173 | :timer.sleep(250) 174 | end) 175 | 176 | receive do 177 | {_id, _context, [event]} -> 178 | [event: event, now: DateTime.utc_now()] 179 | after 180 | 5_000 -> IO.inspect(:nothing) 181 | end 182 | ``` 183 | 184 | 185 | 186 | ``` 187 | [event: ~U[2022-08-16 18:13:52.028072Z], now: ~U[2022-08-16 18:13:52.529290Z]] 188 | ``` 189 | 190 | ## Throttle (WIP) 191 | 192 | Throttle works a bit differently, emitting a constant stream of events, but no more frequently than specified by `:timeout`. This means we should receive an event immediately, and then the latest event every `:timeout` milliseconds. 193 | 194 | ```elixir 195 | defmodule Examples.Throttle do 196 | use Protean 197 | alias Protean.Action 198 | 199 | @type assigns :: %{ 200 | timeout: non_neg_integer(), 201 | last_event: term() 202 | } 203 | 204 | @machine [ 205 | initial: :waiting, 206 | assigns: [ 207 | timeout: 1_000, 208 | last_event: nil 209 | ], 210 | on: [ 211 | match(_, actions: :set_last) 212 | ], 213 | states: [ 214 | atomic(:waiting, 215 | always: transition(target: :throttling, guard: :has_event?) 216 | ), 217 | atomic(:throttling, 218 | entry: :reply_and_clear_last, 219 | after: [ 220 | delay(:timeout, 221 | guard: :has_event?, 222 | target: :throttling, 223 | internal: false 224 | ), 225 | delay(:timeout, 226 | guard: {:not, :has_event?}, 227 | target: :throttling, 228 | internal: true 229 | ) 230 | ] 231 | ) 232 | ] 233 | ] 234 | 235 | @impl true 236 | def delay(:timeout, %{assigns: %{timeout: t}}, _), do: t 237 | 238 | @impl true 239 | def guard(:has_event?, %{assigns: %{last_event: nil}}, _), do: false 240 | def guard(:has_event?, _, _), do: true 241 | 242 | @impl true 243 | def handle_action(:set_last, context, event) do 244 | Action.assign(context, :last_event, event) 245 | end 246 | 247 | def handle_action(:reply_last, context, _) do 248 | {:reply, context.assigns.last_event, context} 249 | end 250 | 251 | def handle_action(:reply_and_clear_last, context, _) do 252 | {:reply, context.assigns.last_event, Action.assign(context, :last_event, nil)} 253 | end 254 | end 255 | ``` 256 | 257 | 258 | 259 | ``` 260 | {:module, Examples.Throttle, <<70, 79, 82, 49, 0, 0, 49, ...>>, {:handle_action, 3}} 261 | ``` 262 | 263 | ```elixir 264 | {:ok, pid} = Examples.Throttle.start_link() 265 | ref = Protean.subscribe(pid, :replies) 266 | ``` 267 | 268 | 269 | 270 | ``` 271 | #Reference<0.2994576954.2563768321.243585> 272 | ``` 273 | 274 | ```elixir 275 | Task.async(fn -> 276 | Enum.each(1..5, fn _ -> 277 | Protean.send(pid, DateTime.utc_now()) 278 | :timer.sleep(500) 279 | end) 280 | end) 281 | 282 | Enum.each(1..4, fn _ -> 283 | receive do 284 | {:state, _, {_context, message}} -> 285 | time = DateTime.utc_now() 286 | IO.inspect({message, at: time}, label: "received") 287 | after 288 | 1_500 -> :nothing 289 | end 290 | end) 291 | ``` 292 | 293 | 294 | 295 | ``` 296 | received: {[~U[2022-08-12 23:10:55.869569Z]], [at: ~U[2022-08-12 23:10:55.869726Z]]} 297 | received: {[~U[2022-08-12 23:10:56.369774Z]], [at: ~U[2022-08-12 23:10:56.871015Z]]} 298 | received: {[~U[2022-08-12 23:10:57.371776Z]], [at: ~U[2022-08-12 23:10:57.871956Z]]} 299 | received: {[~U[2022-08-12 23:10:57.872757Z]], [at: ~U[2022-08-12 23:10:58.872945Z]]} 300 | ``` 301 | 302 | 303 | 304 | ``` 305 | :ok 306 | ``` 307 | 308 | ```elixir 309 | Process.info(self(), :messages) 310 | ``` 311 | 312 | 313 | 314 | ``` 315 | {:messages, 316 | [ 317 | {#Reference<0.2994576954.2563833857.243590>, :ok}, 318 | {:DOWN, #Reference<0.2994576954.2563833857.243590>, :process, #PID<0.621.0>, :normal} 319 | ]} 320 | ``` 321 | -------------------------------------------------------------------------------- /lib/protean/interpreter.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Interpreter do 2 | @moduledoc """ 3 | Execution logic for a Protean machine. 4 | 5 | ## Overview of interpretation loop 6 | 7 | At a high level, an SCXML-confirming statechart (which Protean is based on) defines an 8 | interpreter loop that maintains certain properties laid out in the SCXML specification. In 9 | addition to executing purely-functional state transformations, the interpreter is responsible 10 | for executing actions, side-effects, and tracking process state. 11 | 12 | The interpreter loop consists of the following steps: 13 | 14 | 1. Check if interpreter is running; if not, exit. 15 | 2. Execute any automatic transitions that are active in the current state, recursively until 16 | there are no more automatic transitions to be run. 17 | 3. Process internal event if one is present. 18 | a. Microstep the interpreter by running any transitions and executing any resulting 19 | actions. 20 | b. Loop back to 1. 21 | 4. Await an external event. 22 | 5. Process external event in the same manner as 3. 23 | 24 | Note that the loop above contains a conceptually "blocking" operation in awaiting an external 25 | event. `Protean.Interpreter.start/1` executes the first 3 steps of the interpreter, taking any 26 | automatic transitions that are immediately active based on the machine configuration, etc. This 27 | guarantees that the machine ends in a stable state where no more automatic transitions or 28 | internal events are left, at which point we can wait for an external event. 29 | 30 | > #### Macrosteps and microsteps {: .tip} 31 | > 32 | > The execution of this loop until it is waiting for an external event (or interpretation has 33 | > stopped) is called a _macrostep_. The execution of a single set of transitions resulting from 34 | > an automatic transition, internal event, or external event is called a _microstep_. Macrosteps 35 | > can consist of one or more microsteps. 36 | 37 | After this initialization phase, the trigger for the loop to continue is that we've received an 38 | external event. In the context of Protean, it's easier to think of this as initialization and 39 | event handling. 40 | 41 | Initialization: 42 | 43 | 1. Check if the interpreter is running; if not, exit. 44 | 2. Execute any automatic transitions that are active, looping back to 1. When there are none 45 | left, continue. 46 | 3. Process internal event if one is present, looping back to 1. 47 | 48 | This guarantees that the machine is in a stable state with no more automatic transitions or 49 | internal events left to run. 50 | 51 | Handle external event: 52 | 53 | 1. Run any transitions and execute actions associated with the external event. 54 | 2. Check if the interpreter is running; if not, exit. (A transition may have caused the 55 | machine to enter a final state.) 56 | 3. Execute any automatic transitions that have become active as a result of the event, 57 | looping back to 2. 58 | 4. Process internal event if one is present, looping back to 2. 59 | 60 | As with initialization, this guarantees that the machine is in a stable state, ready to handle 61 | the next external event. 62 | 63 | The SCXML specification references "run-to-completion", which refers to the property that, 64 | after interpretation has started, there should be no (visible) point where the interpreter is in 65 | a non-stable state. When the interpreter starts, it transitions until it is stable. When it 66 | receives an event, it transitions until it is stable. 67 | """ 68 | 69 | alias __MODULE__ 70 | alias Protean.Action 71 | alias Protean.Context 72 | alias Protean.Events 73 | alias Protean.Interpreter.Features 74 | alias Protean.Interpreter.Hooks 75 | alias Protean.MachineConfig 76 | alias Protean.Machinery 77 | alias Protean.Transition 78 | 79 | defstruct [ 80 | :id, 81 | :config, 82 | :context, 83 | :parent, 84 | running: false, 85 | internal_queue: :queue.new(), 86 | hooks: %{} 87 | ] 88 | 89 | @type t :: %Interpreter{ 90 | id: Protean.id() | nil, 91 | config: MachineConfig.t(), 92 | context: Context.t(), 93 | parent: pid(), 94 | running: boolean(), 95 | internal_queue: :queue.queue(), 96 | hooks: map() 97 | } 98 | 99 | @doc """ 100 | Create a new `Interpreter`. The returned interpreter will still need to be started, which could 101 | result in additional side-effects. See `start/1`. 102 | """ 103 | @spec new(keyword()) :: Interpreter.t() 104 | def new(opts) do 105 | config = Keyword.fetch!(opts, :machine) 106 | initial_assigns = Keyword.get(opts, :assigns, %{}) 107 | 108 | context = 109 | config 110 | |> MachineConfig.initial_context() 111 | |> Context.assign(initial_assigns) 112 | 113 | %Interpreter{ 114 | id: Keyword.get(opts, :id), 115 | config: config, 116 | context: context, 117 | parent: Keyword.get(opts, :parent) 118 | } 119 | |> Features.install() 120 | end 121 | 122 | @doc false 123 | @spec add_internal(t, term()) :: t 124 | def add_internal(interpreter, event) do 125 | update_in(interpreter.internal_queue, &:queue.in(event, &1)) 126 | end 127 | 128 | @spec add_many_internal(t, [term()]) :: t 129 | def add_many_internal(interpreter, [event | rest]) do 130 | interpreter 131 | |> add_internal(event) 132 | |> add_many_internal(rest) 133 | end 134 | 135 | def add_many_internal(interpreter, []), do: interpreter 136 | 137 | @doc false 138 | @spec with_context(t, map()) :: t 139 | def with_context(interpreter, context) do 140 | put_in(interpreter.context, context) 141 | end 142 | 143 | @doc false 144 | @spec put_reply(t, term()) :: t 145 | def put_reply(interpreter, reply) do 146 | update_in(interpreter.context, &Context.put_reply(&1, reply)) 147 | end 148 | 149 | @doc "Whether the interpreter has been started and can accept events." 150 | @spec running?(t) :: boolean() 151 | def running?(%Interpreter{running: true}), do: true 152 | def running?(%Interpreter{running: false}), do: false 153 | 154 | @doc """ 155 | Entrypoint for the interpreter that must be called before the interpreter will be in a state 156 | where it can handle external events. This is necessary in order to handle any initializing 157 | actions, spawns, or automatic transitions. 158 | 159 | Calling `start/1` on an already-running interpreter is a no-op. 160 | """ 161 | @spec start(t) :: t 162 | def start(%Interpreter{running: false} = interpreter) do 163 | %{interpreter | running: true} 164 | |> add_internal(Events.platform(:init)) 165 | |> run_interpreter() 166 | end 167 | 168 | def start(interpreter), do: interpreter 169 | 170 | @doc """ 171 | Stop an interpreter, preventing further event processing. 172 | """ 173 | @spec stop(t) :: t 174 | def stop(%Interpreter{running: true} = interpreter) do 175 | %{interpreter | running: false} 176 | |> Hooks.run(:on_stop) 177 | end 178 | 179 | def stop(interpreter), do: interpreter 180 | 181 | @doc """ 182 | Handle an event, executing any transitions, actions, and side-effects associated with the 183 | current machine context. 184 | 185 | Returns a tuple of the interpreter and any replies resulting from actions that were run. 186 | """ 187 | @spec handle_event(t, Protean.event()) :: {t, [term()]} 188 | def handle_event(%Interpreter{running: true} = interpreter, event) do 189 | interpreter 190 | |> macrostep(event) 191 | |> pop_replies() 192 | end 193 | 194 | def handle_event(interpreter, _event), do: {interpreter, []} 195 | 196 | @spec pop_replies(t) :: {t, [term()]} 197 | defp pop_replies(%Interpreter{context: context} = interpreter) do 198 | {replies, context} = Context.pop_replies(context) 199 | {with_context(interpreter, context), replies} 200 | end 201 | 202 | # Entrypoint for the SCXML main event loop. Ensures that any automatic transitions are run and 203 | # internal events are processed before processing any external events. 204 | @spec run_interpreter(t) :: t 205 | defp run_interpreter(%Interpreter{running: true} = interpreter) do 206 | interpreter 207 | |> run_automatic_transitions() 208 | |> process_internal_queue() 209 | end 210 | 211 | defp run_interpreter(%Interpreter{running: false} = interpreter), 212 | do: interpreter 213 | 214 | defp run_automatic_transitions(interpreter) do 215 | case select_automatic_transitions(interpreter) do 216 | [] -> 217 | interpreter 218 | 219 | transitions -> 220 | transitions 221 | |> microstep(interpreter) 222 | |> run_automatic_transitions() 223 | end 224 | end 225 | 226 | defp process_internal_queue(%Interpreter{internal_queue: queue} = interpreter) do 227 | case :queue.out(queue) do 228 | {:empty, _} -> 229 | interpreter 230 | 231 | {{:value, event}, queue} -> 232 | %{interpreter | internal_queue: queue} 233 | |> macrostep(event) 234 | end 235 | end 236 | 237 | defp macrostep(interpreter, event) do 238 | interpreter 239 | |> process_event(event) 240 | |> Hooks.run(:after_macrostep) 241 | end 242 | 243 | defp microstep(transitions, %Interpreter{context: context, config: config} = interpreter) do 244 | {actions, context} = 245 | config 246 | |> Machinery.take_transitions(context, transitions) 247 | |> Context.pop_actions() 248 | 249 | final_state_events = 250 | context.final 251 | |> MapSet.difference(interpreter.context.final) 252 | |> Enum.map(&Events.platform(:done, &1)) 253 | 254 | interpreter 255 | |> add_many_internal(final_state_events) 256 | |> with_context(context) 257 | |> Action.exec_all(actions) 258 | |> stop_if_final() 259 | |> Hooks.run(:after_microstep) 260 | end 261 | 262 | defp stop_if_final(%Interpreter{context: context, config: config} = interpreter) do 263 | if config.root.id in context.final, do: stop(interpreter), else: interpreter 264 | end 265 | 266 | defp process_event(interpreter, event) do 267 | with {interpreter, event} <- Hooks.run(interpreter, :event_filter, event), 268 | interpreter <- Hooks.run(interpreter, :after_event_filter, event) do 269 | interpreter 270 | |> select_transitions(event) 271 | |> microstep(set_event(interpreter, event)) 272 | |> run_interpreter() 273 | else 274 | interpreter -> 275 | interpreter 276 | |> run_interpreter() 277 | end 278 | end 279 | 280 | defp set_event(interpreter, %Events.Platform{payload: nil}), do: interpreter 281 | 282 | defp set_event(interpreter, %Events.Platform{payload: payload}) do 283 | put_in(interpreter.context.event, payload) 284 | end 285 | 286 | defp set_event(interpreter, event) do 287 | put_in(interpreter.context.event, event) 288 | end 289 | 290 | @spec select_automatic_transitions(t) :: [Transition.t()] 291 | defp select_automatic_transitions(%{config: machine, context: context}) do 292 | Machinery.select_transitions(machine, context, context.event, :automatic_transitions) 293 | end 294 | 295 | @spec select_transitions(t, Protean.event()) :: [Transition.t()] 296 | defp select_transitions(%{config: machine, context: context}, event) do 297 | Machinery.select_transitions(machine, context, event) 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /lib/protean/builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Protean.Builder do 2 | @moduledoc """ 3 | API for defining Protean machines. 4 | 5 | This module is imported by default when `use Protean` is spawned. 6 | 7 | ## Defining a machine 8 | 9 | At the outermost level, machines are specified as a keyword list, usually associated with the 10 | `@machine` module attribute of the defining module. 11 | 12 | @machine [ 13 | states: [ 14 | # ... 15 | ] 16 | ] 17 | 18 | For the most part, a machine definition is similar to a compound or parallel state definition, 19 | except that it allows for an addition `:assigns` option to specify the default assigns for the 20 | machine context. 21 | 22 | @machine [ 23 | assigns: %{ 24 | # ... 25 | }, 26 | # ... 27 | ] 28 | 29 | The top-level machine can be parallel by specifying `type: :parallel`: 30 | 31 | @machine [ 32 | type: :parallel, 33 | states: [ 34 | # ... 35 | ] 36 | ] 37 | 38 | See `compound/2` and `parallel/2` for corresponding options. 39 | """ 40 | 41 | alias Protean.Action 42 | alias Protean.Guard 43 | 44 | @type machine_options :: [compound_machine_option] | [parallel_machine_option] 45 | 46 | @type compound_machine_option :: 47 | {:assigns, assigns} 48 | | compound_state_option 49 | 50 | @type parallel_machine_option :: 51 | {:type, :parallel} 52 | | {:assigns, assigns} 53 | | parallel_machine_option 54 | 55 | @typedoc """ 56 | Additional state stored in the machine context. 57 | 58 | The specified assigns will be converted to a `map/0`. 59 | """ 60 | @type assigns :: Enumerable.t() 61 | 62 | @type final_state_options :: [final_state_option] 63 | @type final_state_option :: 64 | {:entry, actions} 65 | | {:exit, actions} 66 | 67 | @type atomic_state_options :: [atomic_state_option] 68 | @type atomic_state_option :: 69 | {:spawn, spawns} 70 | | {:entry, actions} 71 | | {:exit, actions} 72 | | {:always, transitions} 73 | | {:after, delayed_transitions} 74 | | {:on, event_transitions} 75 | 76 | @type compound_state_options :: [compound_state_option] 77 | @type compound_state_option :: 78 | atomic_state_option 79 | | {:initial, state_name} 80 | | {:states, states} 81 | | {:done, transitions} 82 | 83 | @type parallel_state_options :: [parallel_state_option] 84 | @type parallel_state_option :: 85 | atomic_state_option 86 | | {:states, states} 87 | | {:done, transitions} 88 | 89 | @type state_name :: atom() | String.t() 90 | @type states :: [state] 91 | @type state :: {state_name, keyword()} 92 | 93 | @type spawns :: keyword() | [keyword()] 94 | 95 | @type spawn_options :: [spawn_option] 96 | @type spawn_option :: 97 | {:id, String.t()} 98 | | {:done, transitions} 99 | | {:error, transitions} 100 | | {:autoforward, boolean()} 101 | 102 | @type actions :: action | [action] 103 | @type action :: Action.t() | term() 104 | 105 | @type transitions :: [transition] 106 | @type transition :: [transition_option] 107 | 108 | @type delayed_transitions :: [delayed_transition] 109 | @type delayed_transition :: [delayed_transition_option] 110 | @type delayed_transition_option :: 111 | transition_option 112 | | {:delay, milliseconds :: non_neg_integer()} 113 | 114 | @type event_transitions :: [event_transition] 115 | @type event_transition :: {matcher :: function() | term(), transition} 116 | 117 | @type transition_options :: [transition_option] 118 | @type transition_option :: 119 | {:target, state_name} 120 | | {:actions, actions} 121 | | {:guard, Guard.t()} 122 | 123 | @doc """ 124 | Builds an atomic state. 125 | 126 | Atomic states are simple states that cannot define children, but represent some intermediary 127 | state of the machine. 128 | 129 | states: [ 130 | atomic(:loading, 131 | # ... 132 | ) 133 | ] 134 | 135 | ## Options 136 | 137 | * `:spawn` - list of processes to spawn, see `proc/2`, `task/2`, `stream/2`; 138 | * `:entry` - actions to execute when entering this state; 139 | * `:exit` - actions to execute when exiting this state; 140 | * `:always` - transitions to immediately take when their guard is true, see `transition/1`; 141 | * `:after` - transitions to take after a given delay, see `delay/2`; 142 | * `:on` - transitions to take in response to an event, see `match/2`. 143 | 144 | """ 145 | @spec atomic(state_name, atomic_state_options) :: state 146 | def atomic(name, opts \\ []) do 147 | {name, Keyword.put(opts, :type, :atomic)} 148 | end 149 | 150 | @doc """ 151 | Builds a final state. 152 | 153 | Final states are a variation of atomic states that represent some form of completion. Final 154 | states cannot define transitions of their own, but entering a final state can trigger a 155 | transition in a compound or parallel parent. See `compound/2` and `parallel/2`. 156 | 157 | states: [ 158 | final(:completed) 159 | ] 160 | 161 | ## Options 162 | 163 | * `:entry` - actions to execute when entering this state; 164 | * `:exit` - actions to execute when exiting this state (as a result of a parent transition). 165 | 166 | """ 167 | @spec final(state_name, final_state_options) :: state 168 | def final(name, opts \\ []) do 169 | {name, Keyword.put(opts, :type, :final)} 170 | end 171 | 172 | @doc """ 173 | Builds a compound state. 174 | 175 | Compound states have children defined by a `:states` list, of which only one will be active at 176 | a given time. They additional define an `:initial` attribute specifying which child should 177 | become active if we transition directly to the compound state. 178 | 179 | states: [ 180 | compound(:parent, 181 | initial: :child_a, 182 | states: [ 183 | atomic(:child_a) 184 | ] 185 | ) 186 | ] 187 | 188 | Compound states can define a `:done` transition that will be taken if one of its `final` 189 | children become active. In the example below, the `:parent` state will transition to its 190 | sibling if the final `:child_b` state is entered. 191 | 192 | states: [ 193 | compound(:parent, 194 | initial: :child_a, 195 | done: transition(target: :sibling), 196 | states: [ 197 | atomic(:child_a, 198 | # ... 199 | ), 200 | final(:child_b) 201 | ] 202 | ) 203 | ] 204 | 205 | ## Options 206 | 207 | * `:initial` (required) - child state to enter when entering the compound state; 208 | * `:states` (required) - one or more child states; 209 | * `:done` - transition to take if a `final` child state is entered; 210 | * all options available to `atomic/2`. 211 | 212 | """ 213 | @spec compound(state_name, compound_state_options) :: state 214 | def compound(name, opts) do 215 | {name, Keyword.put(opts, :type, :compound)} 216 | end 217 | 218 | @doc """ 219 | Builds a parallel state. 220 | 221 | Parallel states have child states defined by a `:states` list, all of which will be considered 222 | active concurrently when the parallel state is active. 223 | 224 | states: [ 225 | parallel(:parent, 226 | states: [ 227 | atomic(:child_a, 228 | entry: :child_a_action 229 | ), 230 | atomic(:child_b, 231 | entry: :child_b_action 232 | ) 233 | ] 234 | ) 235 | ] 236 | 237 | In the example above, transitioning to `:parent` would enter both child states and cause both 238 | of their entry actions to execute. 239 | 240 | Parallel states can define a `:done` transition that will be taken when all of its children 241 | are in a final state. Usually, this means the parallel state's children are compound states 242 | with active final children. 243 | 244 | states: [ 245 | parallel(:parent, 246 | done: transition(target: :sibling), 247 | states: [ 248 | compound(:compound_a, 249 | states: [ 250 | atomic(:a_child1), 251 | final(:a_child2) 252 | ] 253 | ), 254 | compound(:compound_b, 255 | states: [ 256 | atomic(:b_child1), 257 | final(:b_child2) 258 | ] 259 | ) 260 | ] 261 | ) 262 | ] 263 | 264 | In the example above, the parent parallel state will transition to its sibling once both 265 | compound states have active final children. 266 | 267 | ## Options 268 | 269 | * `:states` (required) - one or more child states, all of which will be concurrently entered 270 | when the parallel state becomes active; 271 | * `:done` - transition to take when all children are in a final state. 272 | * all options available to `atomic/2`. 273 | 274 | """ 275 | @spec parallel(state_name, parallel_state_options) :: state 276 | def parallel(name, opts) do 277 | {name, Keyword.put(opts, :type, :parallel)} 278 | end 279 | 280 | @doc """ 281 | Builds a transition. 282 | 283 | ## Options 284 | 285 | * `:target` - the target state of the transition; 286 | * `:actions` - one or more actions that should be executed when the transition occurs; 287 | * `:guard` - condition that must be true in order for the transition to occur. 288 | 289 | ## Guards 290 | 291 | See `Protean.Guard` 292 | TODO 293 | """ 294 | @spec transition(transition_options) :: keyword() 295 | def transition(opts) do 296 | opts 297 | end 298 | 299 | @doc """ 300 | Builds a pattern-matching event transition. 301 | 302 | Accepts the same options as `transition/1`. 303 | 304 | ## Example 305 | 306 | on: [ 307 | match({:event_with_payload, _payload}, action: :save_payload), 308 | match(%Events.OtherEvent{}, target: :other) 309 | ] 310 | 311 | """ 312 | defmacro match(pattern, opts) do 313 | opts = if is_list(opts), do: opts, else: [target: opts] 314 | 315 | { 316 | quote(do: fn expr -> match?(unquote(pattern), expr) end), 317 | Keyword.put(opts, :_meta, Macro.escape(%{expr: pattern})) 318 | } 319 | end 320 | 321 | @doc """ 322 | Builds a delayed transition. 323 | 324 | Delayed transitions run automatically after the given delay so long as the machine is still in 325 | the state that defined it and any given guard allows it. 326 | 327 | Accepts the same options as `transition/1`. 328 | """ 329 | @spec delay(milliseconds :: non_neg_integer() | term(), transition_options) :: keyword() 330 | def delay(ms, opts) do 331 | Keyword.put(opts, :delay, ms) 332 | end 333 | 334 | @doc """ 335 | Specification for a spawned process linked to a parent state. 336 | """ 337 | @spec proc(term(), spawn_options) :: keyword() 338 | def proc(spec, opts \\ []), do: Keyword.put(opts, :proc, spec) 339 | 340 | @doc """ 341 | Specification for a spawned task linked to a parent state. 342 | """ 343 | @spec task(term(), spawn_options) :: keyword() 344 | def task(spec, opts \\ []), do: Keyword.put(opts, :task, spec) 345 | 346 | @doc """ 347 | Specification for a spawned stream linked to a parent state. 348 | """ 349 | @spec stream(term(), spawn_options) :: keyword() 350 | def stream(spec, opts \\ []), do: Keyword.put(opts, :stream, spec) 351 | end 352 | --------------------------------------------------------------------------------