AshStateMachine transitions from/to multiple states and no default initial state

Is there a way I can pass an array to from: and to:? Right now for the latter case it only makes sense if I can explicitly pass the state to transition to in the change builtin. Can I not specify a default initial state, and simply require that it be one of a set of initial states and not be nil? Tagging this Core for now.
76 Replies
ZachDaniel
ZachDaniel3y ago
from and to should support an array or a single item do you get an error if you do that?
\ ឵឵឵
\ ឵឵឵OP3y ago
Excellent
ZachDaniel
ZachDaniel3y ago
I've just pushed something up to ash_state_machine
\ ឵឵឵
\ ឵឵឵OP3y ago
Nope, seems ok. Then I'm for change transition_state(:executing, :state)?
ZachDaniel
ZachDaniel3y ago
So by default it adds and manages the state attribute for you
default_initial_state :foobar
default_initial_state :foobar
sets the default and I just pushed the enforcement of that attribute being allow_nil? false
\ ឵឵឵
\ ឵឵឵OP3y ago
Alright, grabbing the update now
ZachDaniel
ZachDaniel3y ago
It determines the possible values of the state attribute by looking at all states that can be transitioned from and to, as well as anything in the deprecated_states key in the DSL (just in case) like if you remove a possible state transition but there is still something in that state. change transition_state(:state) you don't specify the source state And that change will validate that you are making a possible state transition according to the rules
\ ឵឵឵
\ ឵឵឵OP3y ago
Rather than default_initial_states, I'm hoping to provide it a list of initial_states and have it enforce that an item must be created with one of those states.
ZachDaniel
ZachDaniel3y ago
ah, yeah you can do that to
initial_states [:foo, :bar, :baz]
initial_states [:foo, :bar, :baz]
default_initial_state just sets the default value for :state for convenience.
\ ឵឵឵
\ ឵឵឵OP3y ago
I was actually looking for the case of multiple resultant states, like if I had passed
transition :executing, from: [...], to: [:state, :other_state]
transition :executing, from: [...], to: [:state, :other_state]
Fantastic
ZachDaniel
ZachDaniel3y ago
We basically just look for any transition that allows from the current state to the next state for that action This example should work
\ ឵឵឵
\ ឵឵឵OP3y ago
Aha, I see, you're not naming the transition in transition_state at all, rather the resultant state. Right on, that works fine then 🙂
ZachDaniel
ZachDaniel3y ago
well, in transition you are referring to an update action
transition :execute, from: :waiting, to: :completed
transition :execute, from: :waiting, to: :completed
means "the :execute update action can move from state :waiting to state :completed" It will raise an error at compile time if there is no :execute update action
\ ឵឵឵
\ ឵឵឵OP3y ago
Aha...ok. Is it possible to specify transition without naming the action? Generally that's all I want, to be able to enforce that any state transition in any update follows the allowed paths.
ZachDaniel
ZachDaniel3y ago
🤔 not currently but we could add it
transition :*, from: :waiting, to: :completed
transition :*, from: :waiting, to: :completed
?
\ ឵឵឵
\ ឵឵឵OP3y ago
Don't get me wrong, I see the value in being able to specify what each action is allowed to do, and will most likely use that as well. Yup, that syntax looks good to me.
ZachDaniel
ZachDaniel3y ago
Yeah, FWIW I'd suggest making it explicit, but I can also see cases where that just becomes cumbersome
\ ឵឵឵
\ ឵឵឵OP3y ago
Just transition from: [...], to: [...] is nice as well, but maybe it's good to make it a bit more explicit.
ZachDaniel
ZachDaniel3y ago
Yeah, I tend to avoid optional arguments in the DSL makes things weird We could add like transition_any from: :waiting, to: :completed but I think its better to be up front about the fact that transitions all happen in actions, and you're picking one/any (and maybe eventually a list)
\ ឵឵឵
\ ឵឵឵OP3y ago
Either way is good, don't mind the asterisk.
ZachDaniel
ZachDaniel3y ago
like transition [:action1, :action2], from: :from, to: :to
\ ឵឵឵
\ ឵឵឵OP3y ago
That would be excellent for sure. Think that covers most of the bases, unless you want to start having some fun and having subtractive lists as well 😉
ZachDaniel
ZachDaniel3y ago
okay, pushed up to main
\ ឵឵឵
\ ឵឵឵OP3y ago
Rebuilding
ZachDaniel
ZachDaniel3y ago
Maybe, if I can see a really good use case. Wold probably have something like prevent_transition from: :foo, to: :bar but even still, that probably doesn't make sense oh, I guess alongside :* it might 😆
\ ឵឵឵
\ ឵឵឵OP3y ago
Yeah, kind of, but I think you're probably safe to wait until somebody comes by with a compelling use-case. Usually the utility comes from being explicit about what is allowed 😄
ZachDaniel
ZachDaniel3y ago
yeah, agreed.
\ ឵឵឵
\ ឵឵឵OP3y ago
Gotta scrub the duplicate checking for :*.
ZachDaniel
ZachDaniel3y ago
ah actually damn, thats interesting since you can duplicate an action name, it shouldn't even require unique action names, but that also causes problems 😆 need a few to fix
\ ឵឵឵
\ ឵឵឵OP3y ago
I'm rewriting a 30-state tree, take your time 😄
ZachDaniel
ZachDaniel3y ago
okay, should be fixed
\ ឵឵឵
\ ឵឵឵OP3y ago
Right on time 🙂 One other shorthand we might consider: wildcard for states as well. For me this would be nice for transition to terminal states, especially error states.
ZachDaniel
ZachDaniel3y ago
Oh, yeah I feel like maybe I already added something to that effect, but I may not have finished it?
defmodule Charts do
def chart(resource) do
resource
|> AshStateMachine.Info.state_machine_initial_states!()
|> Enum.reduce({["flowchart TD"], MapSet.new()}, fn state, {lines, checked} ->
add_to_chart(resource, state, lines ++ [], checked)
end)
|> elem(0)
|> Enum.join("\n")
end

defp add_to_chart(resource, state, lines, checked) do
if state in checked do
{lines, checked}
else
checked = MapSet.put(checked, state)

state
|> transitions_from(resource)
|> Enum.reduce({lines, checked}, fn event, {lines, checked} ->
Enum.reduce(List.wrap(event.to), {lines, checked}, fn to, {lines, checked} ->
name = case event.action do
:* -> ""
action -> "|#{action}|"
end

lines = lines ++ ["#{state} --> #{name} #{to}"]
add_to_chart(resource, to, lines, checked)
end)
end)
end
end

defp transitions_from(state, resource) do
resource
|> AshStateMachine.Info.state_machine_transitions()
|> Enum.filter(fn event ->
state in List.wrap(event.from)
end)
end
end
defmodule Charts do
def chart(resource) do
resource
|> AshStateMachine.Info.state_machine_initial_states!()
|> Enum.reduce({["flowchart TD"], MapSet.new()}, fn state, {lines, checked} ->
add_to_chart(resource, state, lines ++ [], checked)
end)
|> elem(0)
|> Enum.join("\n")
end

defp add_to_chart(resource, state, lines, checked) do
if state in checked do
{lines, checked}
else
checked = MapSet.put(checked, state)

state
|> transitions_from(resource)
|> Enum.reduce({lines, checked}, fn event, {lines, checked} ->
Enum.reduce(List.wrap(event.to), {lines, checked}, fn to, {lines, checked} ->
name = case event.action do
:* -> ""
action -> "|#{action}|"
end

lines = lines ++ ["#{state} --> #{name} #{to}"]
add_to_chart(resource, to, lines, checked)
end)
end)
end
end

defp transitions_from(state, resource) do
resource
|> AshStateMachine.Info.state_machine_transitions()
|> Enum.filter(fn event ->
state in List.wrap(event.from)
end)
end
end
generates a mermaid flow chart of your states 🙂
\ ឵឵឵
\ ឵឵឵OP3y ago
Noice 😄 This should be fun.
ZachDaniel
ZachDaniel3y ago
oh, thats right
If not specified, then any state is accepted.
If not specified, then any state is accepted.
So if you just don't specify from or to then its wildcarded transition :*, to: :errored would let any action transition from any state to the errored state for example
\ ឵឵឵
\ ឵឵឵OP3y ago
I'll put together a little mix task to do this for all my resources with the extension.
ZachDaniel
ZachDaniel3y ago
I'll put it in the repo actually and push it up
\ ឵឵឵
\ ឵឵឵OP3y ago
Right on, default wildcard works for me 🙂
ZachDaniel
ZachDaniel3y ago
Pushed. AshStateMachine.Charts.mermaid_flowchart(Resource) gets you the chart. You can look at how we do it in ash to generate flow charts for resources, we actually have it set up to take a format and actually produce images and things like that. If you want to PR that to the repo if you make it that would be awesome 🙂
\ ឵឵឵
\ ឵឵឵OP3y ago
Will do, halfway there Alright, had to play with it a bit on my stuff 😄
ZachDaniel
ZachDaniel3y ago
Anything interesting pop up?
\ ឵឵឵
\ ឵឵឵OP3y ago
Did what it was supposed to Flowcharts look good though 😄 How do you feel about adding terminal_states or is that somewhere?
ZachDaniel
ZachDaniel3y ago
🤔 I think we could derive terminal_states I guess terminal_states would be a sort of negative filter on transitions, where if you have nil from we don't include that state in the list of from, and if you do include it in a from we raise an error I'd be open to it
\ ឵឵឵
\ ឵឵឵OP3y ago
Yep, it could be derived for sure. By definition, it shouldn't change the functionality, since any state that is terminal won't have any exit transitions. Thinking more from a validation perspective. Could give 'em a different box as well in Mermaid.
ZachDaniel
ZachDaniel3y ago
I think the change it would have is for things like this:
transition :*, to: :errored
transition :*, to: :errored
What we do under the hood is replace :from with every single possible state But if you had terminal_states [:foo, :bar, :baz] we would replace it with every single possible state -- [:foo, :bar, :baz]
\ ឵឵឵
\ ឵឵឵OP3y ago
Yep, think it should definitely be in then The same applies to initial_states already I'm guessing. Although that's a little bit more finicky... A state can be initial and transitioned to
ZachDaniel
ZachDaniel3y ago
hmm....initial_states doesn't necessarily mean...yeah what you said 🙂
\ ឵឵឵
\ ឵឵឵OP3y ago
Although I think there's a fine line there perhaps...but right now as soon as we explicitly specify from anywhere it stops being wildcard, from what we've discussed. In some ways this makes me think it would be better to have to explicitly specify from: :* and to: :*.
ZachDaniel
ZachDaniel3y ago
Yeah, I mean the main problem is that if you have a state like :errored that you expect to be terminal And you do transition :*, to: :pending_approval you have no way to control what is in :* in that instance
\ ឵឵឵
\ ឵឵឵OP3y ago
I mean, my instinct would be that it makes more sense for initial_states to only allow transition from nil by default, not :*. Whereas, right now if I don't define any from: for my initial state it would allow transition from any state, which is not so good.
ZachDaniel
ZachDaniel3y ago
Yeah, I mean maybe it just means that from and to should be required?
\ ឵឵឵
\ ឵឵឵OP3y ago
To me that makes a lot more sense. That seems more in-line wth principle of least surprise.
ZachDaniel
ZachDaniel3y ago
You can always do:
@all_states [:foo, :bar, :baz]

transition :*, from: @all_states, to: :errored
transition :*, from: @all_states, to: :something_else
@all_states [:foo, :bar, :baz]

transition :*, from: @all_states, to: :errored
transition :*, from: @all_states, to: :something_else
\ ឵឵឵
\ ឵឵឵OP3y ago
Yeah, that's true for sure. I'm less convinced about the need for from: :* than I am for from: and to: defaulting to nil, though I still think it's a useful thing to have.
ZachDaniel
ZachDaniel3y ago
🤔 what would it mean if it defaulted to nil? That would effectively be a noop because it doesn't allow any transitions
\ ឵឵឵
\ ឵឵឵OP3y ago
We can also raise on unreachable states when initial_states and terminal_states are specified explicitly. If they are not, it's fine to derive them, I suppose, being states whose aggregate is from: nil and to: nil respectively. So, initial_states would have from: nil and terminal_states would be to: nil. Initial states can only be transitioned into on create by default, and terminal states can't be transitioned out of.
ZachDaniel
ZachDaniel3y ago
🤔 initial_states don't have from and to They just express what states are allowed for a newly created record I'm not sure about initial_states not being transitioned into, I think that should be done by the transition rules Honestly, I think at least for now I'm just going to make from and to required and not support any kind of wildcarding (except on the action) Then it just all has to be explicit, and even though slightly more verbose, it eliminates all of these confusinos I've pushed it up to make them required. I know it might be inconvenient, but we want to start this thing at a stable place. Its easy to make things optional later, hard to make them required once people are using it
\ ឵឵឵
\ ឵឵឵OP3y ago
I'm all good with having from: and to: be required. So my thinking on this was that a resource that defines state_machine is always in a defined, non-nil state, meaning it would be mandatory for it to enter one of the initial_states on create. Is this not the case? 100% it should be possible to transition into an initial state, but only if it is explicitly specified in from:. My point was that from: and to: should default to nil (or []), not :*.
ZachDaniel
ZachDaniel3y ago
Yeah, agreed. That’s effectively the same thing as requiring them
\ ឵឵឵
\ ឵឵឵OP3y ago
Requiring them to be specified explicitly of course has the same effect.
ZachDaniel
ZachDaniel3y ago
👍 So if you use the transition_state helper in a create action it will verify that you are transitioning to a valid initial state
\ ឵឵឵
\ ឵឵឵OP3y ago
Or put you in default_initial_state, if specified.
ZachDaniel
ZachDaniel3y ago
And if you use default_initial_state and don’t specify one, it sets it to that and we validate at compile time that it the default initial state is also an initial state
\ ឵឵឵
\ ឵឵឵OP3y ago
Right on. In reality, we could not require explicit initial_states or terminal_states and derive them from the states which have no incoming edges or outgoing edges, respectively. They could be specified to aid in validation. I'm not extremely in favor of this, but it's a thought since it would be possible now without defaulting to wildcard. You would obviously need to specify initial_states explicitly if you wanted to be able to have states with incoming edges also be initial states. For the reasons mentioned above, I'm cool if you want to hold off on this at the beginning, particularly so because I plan to always specify them.
ZachDaniel
ZachDaniel3y ago
Yeah, let’s see how it feels for a while and we can iterate. At the moment initial states is optional, but actually defaults to all states Not states with no incoming edges We should probably just require that too for now
\ ឵឵឵
\ ឵឵឵OP3y ago
Thinking it might make sense to just require it/have it default to [].
ZachDaniel
ZachDaniel3y ago
Have any cool flow charts you wouldn't mind sharing? Its cool if they are private 🙂 Just curious, could be good to put in the readme to show what you can do
\ ឵឵឵
\ ឵឵឵OP3y ago
These ones I can't unfortunately 😦
ZachDaniel
ZachDaniel3y ago
No worries 🙂 okay, pushed up the latest Well, this was very productive 🙂 I'm going to take the rest of the weekend off, keep in touch w/ how it goes! Its a relatively simple extension, so I'll probably cut a release of it and get it up on ash-hq in the next week or two, I don't foresee it needing that much more than it currently has, at least not for v1
\ ឵឵឵
\ ឵឵឵OP3y ago
Yup, looks good so far to me 🙂 Will be converting a bunch of things over the next couple days, so should have some feedback when you're back. Have an excellent rest of the weekend!
ZachDaniel
ZachDaniel3y ago
GitHub
feat: add mix task ash_state_machine.generate_flow_charts by bcks...
Add a Mix task for generating flowcharts: mix ash_state_machine.generate_flow_charts Contributor checklist Bug fixes include regression tests Features include unit/acceptance tests
ZachDaniel
ZachDaniel3y ago
we can get that merged, see my comment
\ ឵឵឵
\ ឵឵឵OP3y ago
😆 Fixed
ZachDaniel
ZachDaniel3y ago
GitHub
feat: add mix task ash_state_machine.generate_flow_charts by bcks...
Add a Mix task for generating flowcharts: mix ash_state_machine.generate_flow_charts Contributor checklist Bug fixes include regression tests Features include unit/acceptance tests
\ ឵឵឵
\ ឵឵឵OP3y ago
Long day
ZachDaniel
ZachDaniel3y ago
0.1.0released with docs and example chart 🙂

Did you find this page helpful?