You should not export those fields, and instead make them available via methods where the reads are protected by the mutex. You'll probably also need to make a copy of the map since it's a reference type, OR make the accessor take the key name and fetch the value directly from the map and return it. I learned this when I wrote a similar simple state machine in Go ~7 years ago. :)
I'd also make sure to return `T` from the CurrentState accessor method and not `*T`, just to make it easier for consumers to do comparison operations with the returned result.
Reference on Go memory safety: https://go.dev/ref/mem
That being said, I appreciate the simplicity and it's a totally fine choice to leave out event based dispatch for less complex use cases!
One thing that has been mentioned here already: it's super helpful to have your library output a diagram file to visualize the FSM. This is a really great way to keep code and documentation in sync always.
Re the visualization, I think that would be cool. I might give that a shot.
> Add valid transitions between states:
> `fsm.AddRule(CustomStateEnumA, CustomStateEnumB)`
> `fsm.AddRule(CustomStateEnumB, CustomStateEnumC)`
The README has an example of shipment, so let’s think about that. It is a bad example though because it just has packed → shipped → delivered, which is not a real business process.
Let’s say you’re a seller, as opposed to UPS or FedEx. The first thing that happens is a customer makes an order. What happens next? Well, the customer can cancel their order. Or the order can be sent and then the customer can ask for a refund. Let’s try to model it:
Ordered + cancelled → cancelled
Ordered + warehouse packing → packed
Packed + cancelled → cancelled
Packed + warehouse handed package to shipper → shipped
Shipped + cancelled → too late, it’s still in the shipped state
Shipped + return request → awaiting return
Awaiting return + received return → refund
Shipped + received return → no RMA, so no refund. No state change.
Shipped + 30 days → archived
Etc. I am too lazy to do the full model, but you start to get the idea. The important thing is that actions happen from outside the system and then they cause state transitions based on what the current state is. Sometimes the system just stays in the state it is already is in, and sometimes the system moves backwards. Some actions can’t happen in certain states (can’t get something back before it goes out, can’t dismiss a closed modal dialog), but that’s not because of business logic but because of the real world. The business logic only enforces itself, not the world.
The README has an example of shipment, so let’s think about that. It is a bad example though because it just has packed → shipped → delivered, which is not a real business process.
Let’s say you’re a seller, as opposed to UPS or FedEx. The first thing that happens is a customer makes an order. What happens next? Well, the customer can cancel their order. Or the order can be sent and then the customer can ask for a refund. Let’s try to model it:
Ordered + cancelled → cancelled Ordered + warehouse packing → packed Packed + cancelled → cancelled Packed + warehouse handed package to shipper → shipped Shipped + cancelled → too late, it’s still in the shipped state Shipped + return request → awaiting return Awaiting return + received return → refund Shipped + received return → no RMA, so no refund. No state change. Shipped + 30 days → archived
Etc. I am too lazy to do the full model, but you start to get the idea. The important thing is that actions happen from outside the system and then they cause state transitions based on what the current state is. Sometimes the system just stays in the state it is already is in, and sometimes the system moves backwards. Some actions can’t happen in certain states (can’t get something back before it goes out, can’t dismiss a closed modal dialog), but that’s not because of business logic but because of the real world. The business logic only enforces itself, not the world.
IMO, AddValidTransition would be way better. I think I would go for AddTransition, though. It’s not as if there’s also a way to add invalid transitions.
Perhaps the real issue is commenters like GP is why YOU don't share things?
Not sore, but it looks like the Transition function has a race condition. It calls CanTansition() before acquiring the mutex lock. I think this could lead to illegal state transitions.
Recommend putting the tracking of previous states in a separate optional debugging type so you don’t have to pay the cost in general. Oh and using time stamps as a key is kinda weird, but even weirder in Go where maps are non-deterministically enumerable.
Otherwise, seems like a good use of generics, given Golangs particular take on it.
The benchmark was for six transitions. I've now updated a few things and added new benchmarks.
I also got rid of timestamps for the map keys - it's back to a regular slice. In retrospect, that was a tad bit off, I agree.
The runtime is multithreaded and parallel.
The idea is to execute the following state machine:
thread(s) = fact(variable) | send(message) | receive(message2);
thread(r) = fact(variable) | receive(message) | send(message2);
This program waits until thread(s) is true in another thread until thread(r) is true, everything left of the equals symbol needs to be true before it passes to the next group of facts after the = symbol. Then it waits for "fact(variable)" to be fired, then when all those atoms are true, the state transitions past the pipe symbol and does a send(message) while the other thread does a receive(message) and then the process is repeated in the opposite direction. I've not shown it here, but you can wait for multiple facts to be true too.Here's a state machine for an (echo) server that is intended to be mapped to epoll or libiouring:
accept | { submit_recv! | recv | submit_send } { submit_send! | send | submit_recv }
The curly brackets means parallel state machines that run independently, like a fork.FBP adapts the physical concept of unit record machines that operate over punchcard stacks, which predisposes it to thinking in terms of processing and transforming richer data like "characters in a string" or "records in an array", but it basically works for truth-signalling logic too - let truth packets wait in a buffer, and then when the node signals that the packets are able to move, that is the transition.
The emphasis does differ in that if we are thinking "FSM", the rest of the program and the data it handles have been abstracted, while if we are thinking "FBP" we're engaged with designing specific machines to connect together in terms of I/O, which is more helpful when you have a library of data operations to reuse.
no but seriously, sounds like a good/useful plan/approach. please let me know if you share out a spec or impl sometime
BONUS points for a Golang or C lib
1- Regular slice instead of timestamp-keyed map. That didn't make sense in retrospect. 2- Better benchmarks. 3- Non-exported current state and transitions. Mutexed getters to avoid concurrency issues. 4- Variadic rule parameters. 5- Better example.
func (fsm *FSM[T]) Transition(...) {
...
fsm.Transitions[time.Now()] = Transition[T]{
FromState: *fsm.CurrentState,
ToState: targetState,
Timestamp: &tn,
Metadata: metadata,
}
does fsm.Transitions grow without bounds?But yes, his name pisses me off because I didnt think of it either.
I really don't get how one can suggest the iota const dance with a straight face as an alternative.
probably some serious kind of disaster, right?