├── .gems ├── CHANGELOG ├── CONTRIBUTING ├── LICENSE ├── README.md ├── finist.gemspec ├── lib └── finist.rb ├── makefile └── test ├── all.rb └── helper.rb /.gems: -------------------------------------------------------------------------------- 1 | redic -v 1.2.0 2 | cutest -v 1.2.2 3 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.1.0 2 | 3 | Change order of returned values. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | This code tries to solve a particular problem with a very simple 2 | implementation. We try to keep the code to a minimum while making 3 | it as clear as possible. The design is very likely finished, and 4 | if some feature is missing it is possible that it was left out on 5 | purpose. That said, new usage patterns may arise, and when that 6 | happens we are ready to adapt if necessary. 7 | 8 | A good first step for contributing is to meet us on IRC and discuss 9 | ideas. We spend a lot of time on #lesscode at freenode, always ready 10 | to talk about code and simplicity. If connecting to IRC is not an 11 | option, you can create an issue explaining the proposed change and 12 | a use case. We pay a lot of attention to use cases, because our 13 | goal is to keep the code base simple. Usually the result of a 14 | conversation is the creation of a different tool. 15 | 16 | Please don't start the conversation with a pull request. The code 17 | should come at last, and even though it may help to convey an idea, 18 | more often than not it draws the attention to a particular 19 | implementation. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Michel Martens 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Finist 2 | ====== 3 | 4 | Redis based Finite State Machine. 5 | 6 | Description 7 | ----------- 8 | 9 | Finist is a finite state machine that is defined and persisted in 10 | [Redis][redis]. 11 | 12 | Community 13 | --------- 14 | 15 | Meet us on IRC: [#lesscode](irc://chat.freenode.net/#lesscode) on 16 | [freenode.net](http://freenode.net/). 17 | 18 | Related projects 19 | ---------------- 20 | 21 | * [Finist implemented in Lua][finist.lua] 22 | * [Finist implemented in Rust][finist.rust] 23 | 24 | Getting started 25 | --------------- 26 | 27 | Install [Redis][redis]. On most platforms it's as easy as grabbing 28 | the sources, running make and then putting the `redis-server` binary 29 | in the PATH. 30 | 31 | Once you have it installed, you can execute `redis-server` and it 32 | will run on `localhost:6379` by default. Check the `redis.conf` 33 | file that comes with the sources if you want to change some settings. 34 | 35 | Usage 36 | ----- 37 | 38 | Finist requires a [Redic][redic] compatible client. To make things 39 | easier, `redic` is listed as a runtime dependency so the examples 40 | in this document will work. 41 | 42 | ```ruby 43 | require "finist" 44 | 45 | # Initialize with a Redis client, the name of the machine and the 46 | # initial state. In this example, the machine is called "order" and 47 | # the initial status is "pending". The Redis client is connected to 48 | # the default host (127.0.0.1:6379). 49 | machine = Finist.new(Redic.new, "order", "pending") 50 | 51 | # Available transitions are defined with the `on` method 52 | # `machine.on(, , )` 53 | machine.on("approve", "pending", "approved") 54 | machine.on("cancel", "pending", "cancelled") 55 | machine.on("cancel", "approved", "cancelled") 56 | machine.on("reset", "cancelled", "pending") 57 | ``` 58 | 59 | Now that the possible transitions are defined, we can check the 60 | current state: 61 | 62 | ```ruby 63 | machine.state 64 | # => "pending" 65 | ``` 66 | 67 | And we can trigger an event: 68 | 69 | ```ruby 70 | machine.trigger("approve") 71 | # => ["approved", true] 72 | ``` 73 | 74 | The `trigger` method returns an array of two values: the first 75 | represents the current state, and the second represents whether 76 | a transition occurred. 77 | 78 | Here's what happens if an event doesn't cause a transition: 79 | 80 | ```ruby 81 | machine.trigger("reset") 82 | # => ["approved", false] 83 | ``` 84 | 85 | Here's a convenient way to use this flag: 86 | 87 | ```ruby 88 | state, changed = machine.trigger("reset") 89 | 90 | if changed 91 | printf("State changed to %s\n", state) 92 | end 93 | ``` 94 | 95 | If you need to remove all the transitions for a given event, you 96 | can use `rm`: 97 | 98 | ```ruby 99 | machine.rm("reset") 100 | ``` 101 | 102 | Note that every change is persisted in Redis. 103 | 104 | Representation 105 | -------------- 106 | 107 | Each event is represented as a hash in Redis, and its field/value 108 | pairs are the possible transitions. 109 | 110 | For the FSM described in the examples above, the keys are laid out 111 | as follows: 112 | 113 | ```ini 114 | # Current state 115 | finist:order (string) 116 | 117 | # Transitions for event `approve` 118 | finist:order:approve (hash) 119 | pending -> approved 120 | 121 | # Transitions for event `cancel` 122 | finist:order:cancel (hash) 123 | pending -> cancelled 124 | approved -> cancelled 125 | 126 | # Transitions for event `reset` 127 | finist:order:reset (hash) 128 | cancelled -> pending 129 | ``` 130 | 131 | Installation 132 | ------------ 133 | 134 | ``` 135 | $ gem install finist 136 | ``` 137 | 138 | [redis]: http://redis.io 139 | [redic]: https://github.com/amakawa/redic 140 | [finist.lua]: https://github.com/soveran/finist.lua 141 | [finist.rust]: https://github.com/badboy/finist 142 | -------------------------------------------------------------------------------- /finist.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "finist" 3 | s.version = "0.1.0" 4 | s.summary = %{Redis based Finite State Machine} 5 | s.description = %Q{Finist is a finite state machine that is defined and persisted in Redis.} 6 | s.authors = ["Michel Martens"] 7 | s.email = ["michel@soveran.com"] 8 | s.homepage = "https://github.com/soveran/finist" 9 | s.license = "MIT" 10 | 11 | s.files = `git ls-files`.split("\n") 12 | 13 | s.add_dependency "redic" 14 | s.add_development_dependency "cutest" 15 | end 16 | -------------------------------------------------------------------------------- /lib/finist.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require "redic" 4 | 5 | class Finist 6 | SCRIPT = <<-EOS 7 | local curr = redis.call("GET", KEYS[1]) 8 | local next = redis.call("HGET", KEYS[2], curr) 9 | 10 | if next then 11 | redis.call("SET", KEYS[1], next) 12 | return { next, true } 13 | else 14 | return { curr, false } 15 | end 16 | EOS 17 | 18 | def initialize(redis, name, init) 19 | @name = sprintf("finist:%s", name) 20 | @redis = redis 21 | @redis.call("SET", @name, init, "NX") 22 | end 23 | 24 | def event_key(ev) 25 | sprintf("%s:%s", @name, ev) 26 | end 27 | 28 | def on(ev, curr_state, next_state) 29 | @redis.call("HSET", event_key(ev), curr_state, next_state) 30 | end 31 | 32 | def rm(ev) 33 | @redis.call("DEL", event_key(ev)) 34 | end 35 | 36 | def state 37 | @redis.call("GET", @name) 38 | end 39 | 40 | def send_event(ev) 41 | @redis.call("EVAL", SCRIPT, "2", @name, event_key(ev)) 42 | end 43 | 44 | def trigger(ev) 45 | result = send_event(ev) 46 | return result[0], result[1] != nil 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | test: 4 | cutest -r ./test/helper.rb ./test/*.rb 5 | -------------------------------------------------------------------------------- /test/all.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | setup do 4 | Redic.new.tap do |c| 5 | c.call("FLUSHDB") 6 | end 7 | end 8 | 9 | test do |c| 10 | fsm = Finist.new(c, "myfsm", "pending") 11 | 12 | fsm.on("approve", "pending", "approved") 13 | fsm.on("cancel", "pending", "cancelled") 14 | fsm.on("cancel", "approved", "cancelled") 15 | fsm.on("reset", "cancelled", "pending") 16 | 17 | # Verify initial state 18 | assert_equal("pending", fsm.state) 19 | 20 | # Send an event 21 | fsm.trigger("approve") 22 | 23 | # Verify transition to "approved" 24 | assert_equal("approved", fsm.state) 25 | 26 | # Send an event 27 | fsm.trigger("cancel") 28 | 29 | # Verify transition to "cancelled" 30 | assert_equal("cancelled", fsm.state) 31 | 32 | # Send an event 33 | fsm.trigger("approve") 34 | 35 | # Verify state remains as "cancelled" 36 | assert_equal("cancelled", fsm.state) 37 | 38 | # Create a different fsm with client 39 | fsm2 = Finist.new(c, "myfsm", "pending") 40 | 41 | # Verify state remains as "cancelled" 42 | assert_equal("cancelled", fsm2.state) 43 | 44 | # A successful event returns true 45 | state, changed = fsm.trigger("reset") 46 | 47 | assert_equal(true, changed) 48 | assert_equal("pending", state) 49 | 50 | # An unsuccessful event returns false 51 | state, changed = fsm.trigger("reset") 52 | 53 | assert_equal(false, changed) 54 | assert_equal("pending", state) 55 | 56 | # Delete an event 57 | fsm.rm("approve") 58 | 59 | # Non existent events return false 60 | state, changed = fsm.trigger("approve") 61 | 62 | assert_equal(false, changed) 63 | assert_equal("pending", state) 64 | end 65 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require_relative "../lib/finist" 2 | --------------------------------------------------------------------------------