├── .editorconfig ├── .gitignore ├── .travis.yml ├── .travis └── trigger-dependant-builds.js ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── onyx-eda │ ├── actor_spec.cr │ └── channel │ │ ├── memory_spec.cr │ │ └── redis_spec.cr ├── onyx-eda_spec.cr └── spec_helper.cr └── src ├── onyx-eda.cr └── onyx-eda ├── actor.cr ├── channel.cr ├── channel ├── duplicate_consumer_error.cr ├── memory.cr ├── redis.cr ├── subscription.cr └── subscription │ └── inactive_error.cr ├── consumer.cr ├── event.cr ├── ext └── uuid │ └── msgpack.cr └── subscriber.cr /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in applications that use them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | services: 3 | - docker 4 | before_install: 5 | - docker run -p 6379:6379 -d redis 6 | - nvm install 9 7 | - npm install shelljs got 8 | script: 9 | - env REDIS_URL=redis://localhost:6379 crystal spec 10 | - crystal docs 11 | after_success: 12 | - node .travis/trigger-dependant-builds.js 13 | deploy: 14 | provider: pages 15 | skip_cleanup: true 16 | keep_history: true 17 | github_token: $GITHUB_TOKEN 18 | on: 19 | branch: master 20 | local_dir: docs 21 | -------------------------------------------------------------------------------- /.travis/trigger-dependant-builds.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict"; 3 | 4 | const shell = require("shelljs"); 5 | const path = require("path"); 6 | const got = require("got"); 7 | 8 | console.log("Fetching Git commit hash..."); 9 | 10 | const gitCommitRet = shell.exec("git rev-parse HEAD", { 11 | cwd: path.join(__dirname, "..") 12 | }); 13 | 14 | if (0 !== gitCommitRet.code) { 15 | console.error("Error getting git commit hash"); 16 | process.exit(-1); 17 | } 18 | 19 | const gitCommitHash = gitCommitRet.stdout.trim(); 20 | 21 | const gitSubjectRet = shell.exec(`git show -s --format=%s ${gitCommitHash}`, { 22 | cwd: path.join(__dirname, "..") 23 | }); 24 | 25 | const gitCommitSubject = gitSubjectRet.stdout.trim(); 26 | 27 | const triggerBuild = (username, repo, branch) => { 28 | console.log(`Triggering ${username}/${repo}@${branch}...`); 29 | 30 | got.post(`https://api.travis-ci.org/repo/${username}%2F${repo}/requests`, { 31 | headers: { 32 | "Content-Type": "application/json", 33 | "Accept": "application/json", 34 | "Travis-API-Version": "3", 35 | "Authorization": `token ${process.env.TRAVIS_API_TOKEN}`, 36 | }, 37 | body: JSON.stringify({ 38 | request: { 39 | message: `[onyx-eda] ${gitCommitSubject}`, 40 | branch: branch, 41 | config: { 42 | env: `ONYX_EDA_COMMIT=${gitCommitHash}` 43 | } 44 | }, 45 | }), 46 | }) 47 | .then(() => { 48 | console.log(`Triggered ${username}/${repo}@${branch}`); 49 | }) 50 | .catch((err) => { 51 | console.error(err); 52 | process.exit(-1); 53 | }); 54 | }; 55 | 56 | triggerBuild("vladfaust", "onyx-40-loc-distributed-chat", "master"); 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Vlad Faust 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Onyx::EDA 4 | 5 | [![Built with Crystal](https://img.shields.io/badge/built%20with-crystal-000000.svg?style=flat-square)](https://crystal-lang.org/) 6 | [![Travis CI build](https://img.shields.io/travis/onyxframework/eda/master.svg?style=flat-square)](https://travis-ci.org/onyxframework/eda) 7 | [![Docs](https://img.shields.io/badge/docs-online-brightgreen.svg?style=flat-square)](https://docs.onyxframework.org/eda) 8 | [![API docs](https://img.shields.io/badge/api_docs-online-brightgreen.svg?style=flat-square)](https://api.onyxframework.org/eda) 9 | [![Latest release](https://img.shields.io/github/release/onyxframework/eda.svg?style=flat-square)](https://github.com/onyxframework/eda/releases) 10 | 11 | An Event-Driven Architecture framework to build reactive apps. 12 | 13 | ## About 👋 14 | 15 | Onyx::EDA is an [Event-Driven Architecture](https://en.wikipedia.org/wiki/Event-driven_architecture) framework. It allows to emit certain *events* and subscribe to them. 16 | 17 | Currently the framework has these *channels* implemented: 18 | 19 | * [Memory channel](https://api.onyxframework.org/eda/Onyx/EDA/Channel/Memory.html) 20 | * [Redis channel](https://api.onyxframework.org/eda/Onyx/EDA/Channel/Redis.html) (working on [Redis streams](https://redis.io/topics/streams-intro)) 21 | 22 | Onyx::EDA is a **real-time** events framework. It does not process events happend in the past and currently does not care about reliability in case of third-party service dependant channels (i.e. Redis). 23 | 24 | 👍 The framework is a great choice for reactive and/or distributed applications, effectively allowing to have multiple loosely-coupled components which do not directly interact with each other, but rely on events instead. 25 | 26 | 👎 However, Onyx::EDA is not a good choice for tasks requiring reliability, for example, background processing. If a Redis consumer dies during processing, the event is likely to not be processed. This behaviour may change in the future. 27 | 28 | ## Installation 📥 29 | 30 | Add this to your application's `shard.yml`: 31 | 32 | ```yaml 33 | dependencies: 34 | onyx: 35 | github: onyxframework/onyx 36 | version: ~> 0.6.0 37 | onyx-eda: 38 | github: onyxframework/eda 39 | version: ~> 0.4.0 40 | ``` 41 | 42 | This shard follows [Semantic Versioning v2.0.0](http://semver.org/), so check [releases](https://github.com/vladfaust/timer.cr/releases) and change the `version` accordingly. 43 | 44 | > Note that until Crystal is officially released, this shard would be in beta state (`0.*.*`), with every **minor** release considered breaking. For example, `0.1.0` → `0.2.0` is breaking and `0.1.0` → `0.1.1` is not. 45 | 46 | ## Usage 💻 47 | 48 | First of all, you must require channels you'd need: 49 | 50 | ```crystal 51 | require "onyx/eda/memory" 52 | require "onyx/eda/redis" 53 | ``` 54 | 55 | Then define events to emit: 56 | 57 | ```crystal 58 | struct MyEvent 59 | include Onyx::EDA::Event 60 | 61 | getter foo 62 | 63 | def initialize(@foo : String) 64 | end 65 | end 66 | ``` 67 | 68 | ### Basic subscribing 69 | 70 | You must define a block which would be run on incoming event: 71 | 72 | ```crystal 73 | Onyx::EDA.memory.subscribe(MyEvent) do |event| 74 | pp event.foo 75 | end 76 | ``` 77 | 78 | Subscribing and emitting are **asynchronous** operations. You must then `yield` the control with `sleep` or `Fiber.yield` to let notifications reach their subscriptions: 79 | 80 | ```crystal 81 | Onyx::EDA.memory.emit(MyEvent.new("bar")) 82 | sleep(1) 83 | ``` 84 | 85 | Output, as expected: 86 | 87 | ``` 88 | bar 89 | ``` 90 | 91 | You can cancel a subscription as well: 92 | 93 | ```crystal 94 | sub = Onyx::EDA.memory.subscribe(MyEvent) do |event| 95 | pp event.foo 96 | end 97 | 98 | sub.unsubscribe 99 | ``` 100 | 101 | ### Subscribing with filters 102 | 103 | You can filter incoming events and run the subscription block only if the event's getters match the filter: 104 | 105 | ```crystal 106 | # Would only put "bar" 107 | Onyx::EDA.memory.subscribe(MyEvent, foo: "bar") do |event| 108 | pp event.foo 109 | end 110 | 111 | Onyx::EDA.memory.emit(MyEvent.new("qux")) # Would not notify the subscription above 112 | Onyx::EDA.memory.emit(MyEvent.new("bar")) # OK, condition is met 113 | ``` 114 | 115 | ### Consuming 116 | 117 | You can create an event consumption instead of a subscription. From docs: 118 | 119 | > Consumption differs from subscription in a way that only a single consuming subscription instance with certain *consumer_id* among all this channel subscribers would be notified about an event after it successfully acquires a lock. The lock implementation differs in channels. 120 | 121 | In this code only **one** `"bar"` will be put, because both subscriptions have `"MyConsumer"` as the consumer ID: 122 | 123 | ```crystal 124 | sub1 = Onyx::EDA.memory.subscribe(MyEvent, "MyConsumer") do |event| 125 | puts event.foo 126 | end 127 | 128 | sub2 = Onyx::EDA.memory.subscribe(MyEvent, "MyConsumer") do |event| 129 | puts event.foo 130 | end 131 | 132 | Onyx::EDA.memory.emit(MyEvent.new("bar")) 133 | ``` 134 | 135 | The consuming works as expected with [Redis channel](https://api.onyxframework.org/eda/Onyx/EDA/Channel/Redis.html) as well. It relies on [Redis streams](https://redis.io/topics/streams-intro). However, if a consumer crashes, then no other consumer with the same ID would try to process this event anymore (i.e. the behavior is unreliable). This may change in the future. 136 | 137 | Note that you can not use event filters while consuming. 138 | 139 | ### Awaiting 140 | 141 | It is possible to await for a certain event to happen in a **blocking** manner: 142 | 143 | ```crystal 144 | # Will block the execution until the event is received 145 | Onyx::EDA.memory.await(MyEvent) do |event| 146 | pp event.foo 147 | end 148 | ``` 149 | 150 | It is particularly useful in `select` blocks: 151 | 152 | ```crystal 153 | select 154 | when event = Onyx::EDA.memory.await(MyEvent) 155 | pp event.foo 156 | when Timer.new(30.seconds) 157 | raise "Timeout!" 158 | end 159 | ``` 160 | 161 | *💡 See [timer.cr](https://github.com/vladfaust/timer.cr) for a timer shard.* 162 | 163 | You can use filters with awaiting, making it possible to wait for a specific event hapenning: 164 | 165 | ```crystal 166 | record MyEventHandled, parent_event_id : UUID do 167 | include Onyx::EDA::Event 168 | end 169 | 170 | event = Onyx::EDA.redis.emit(MyEvent.new("bar")) 171 | 172 | select 173 | when event = Onyx::EDA.redis.await(MyEventHandled, parent_event_id: event.event_id) 174 | puts "Handled" 175 | when Timer.new(30.seconds) 176 | raise "Timeout!" 177 | end 178 | ``` 179 | 180 | ### `Subscriber` and `Consumer` 181 | 182 | You can include the `Subscriber(T)` and `Consumer(T)` modules into an object, turning it into an event (`T`) subscriber or consumer. It must implement `handle(event : T)` and be explicitly subscribed to a channel. 183 | 184 | ```crystal 185 | class Actor::Logger 186 | include Onyx::EDA::Subscriber(Event::User::Registered) 187 | include Onyx::EDA::Consumer(Event::Payment::Successfull) 188 | 189 | # This method will be called in *all* Actor::Logger instances 190 | def handle(event : Event::User::Registered) 191 | log_into_terminal("New user with id #{event.id}") 192 | end 193 | 194 | # This method will be called in only *one* Actor::Logger instance 195 | def handle(event : Event::Payment::Successfull) 196 | send_email("admin@example.com", "New payment of $#{event.amount}") 197 | end 198 | end 199 | 200 | actor = Actor::Logger.new 201 | actor.subscribe(Onyx::EDA.memory) # Non-blocking method 202 | actor.unsubscribe(Onyx::EDA.memory) # Can be unsubscribed as well 203 | ``` 204 | 205 | ## Documentation 📚 206 | 207 | The documentation is available online at [docs.onyxframework.org/eda](https://docs.onyxframework.org/eda). 208 | 209 | ## Community 🍪 210 | 211 | There are multiple places to talk about Onyx: 212 | 213 | * [Gitter](https://gitter.im/onyxframework) 214 | * [Twitter](https://twitter.com/onyxframework) 215 | 216 | ## Support 🕊 217 | 218 | This shard is maintained by me, [Vlad Faust](https://vladfaust.com), a passionate developer with years of programming and product experience. I love creating Open-Source and I want to be able to work full-time on Open-Source projects. 219 | 220 | I will do my best to answer your questions in the free communication channels above, but if you want prioritized support, then please consider becoming my patron. Your issues will be labeled with your patronage status, and if you have a sponsor tier, then you and your team be able to communicate with me privately in [Twist](https://twist.com). There are other perks to consider, so please, don't hesistate to check my Patreon page: 221 | 222 | 223 | 224 | You could also help me a lot if you leave a star to this GitHub repository and spread the word about Crystal and Onyx! 📣 225 | 226 | ## Contributing 227 | 228 | 1. Fork it ( https://github.com/onyxframework/eda/fork ) 229 | 2. Create your feature branch (git checkout -b my-new-feature) 230 | 3. Commit your changes (git commit -am 'feat: some feature') using [Angular style commits](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit) 231 | 4. Push to the branch (git push origin my-new-feature) 232 | 5. Create a new Pull Request 233 | 234 | ## Contributors 235 | 236 | - [Vlad Faust](https://github.com/vladfaust) - creator and maintainer 237 | 238 | ## Licensing 239 | 240 | This software is licensed under [MIT License](LICENSE). 241 | 242 | [![Open Source Initiative](https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Opensource.svg/100px-Opensource.svg.png)](https://opensource.org/licenses/MIT) 243 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: onyx-eda 2 | version: 0.4.0 3 | 4 | authors: 5 | - Vlad Faust 6 | 7 | crystal: 0.30.1 8 | 9 | license: MIT 10 | 11 | dependencies: 12 | mini_redis: 13 | github: vladfaust/mini_redis 14 | version: ~> 0.2.0 15 | msgpack: 16 | github: crystal-community/msgpack-crystal 17 | version: ~> 0.14.0 18 | -------------------------------------------------------------------------------- /spec/onyx-eda/actor_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Onyx::EDA::Actor 4 | record TestEvent::A, payload : String do 5 | include Event 6 | end 7 | 8 | record TestEvent::B, payload : Int32 do 9 | include Event 10 | end 11 | 12 | class SimpleSubscriber 13 | include Onyx::EDA::Subscriber(TestEvent::A) 14 | 15 | class_getter latest_value : String? = nil 16 | 17 | def handle(event) 18 | @@latest_value = event.payload 19 | end 20 | end 21 | 22 | class DoubleSubscriber 23 | include Onyx::EDA::Subscriber(TestEvent::A) 24 | include Onyx::EDA::Subscriber(TestEvent::B) 25 | 26 | class_getter latest_value_a : String? = nil 27 | class_getter latest_value_b : Int32? = nil 28 | 29 | def handle(event : TestEvent::A) 30 | @@latest_value_a = event.payload 31 | end 32 | 33 | def handle(event : TestEvent::B) 34 | @@latest_value_b = event.payload 35 | end 36 | end 37 | 38 | class SimpleConsumer 39 | include Onyx::EDA::Subscriber(TestEvent::A) 40 | 41 | class_getter latest_value : String? = nil 42 | 43 | def handle(event) 44 | @@latest_value = event.payload 45 | end 46 | end 47 | 48 | class DoubleConsumer 49 | include Onyx::EDA::Consumer(TestEvent::A) 50 | include Onyx::EDA::Consumer(TestEvent::B) 51 | 52 | class_getter latest_value_a : String? = nil 53 | class_getter latest_value_b : Int32? = nil 54 | 55 | def handle(event : TestEvent::A) 56 | @@latest_value_a = event.payload 57 | end 58 | 59 | def handle(event : TestEvent::B) 60 | @@latest_value_b = event.payload 61 | end 62 | end 63 | 64 | class MixedActor 65 | include Onyx::EDA::Subscriber(TestEvent::A) 66 | include Onyx::EDA::Consumer(TestEvent::B) 67 | 68 | class_getter latest_value_a : String? = nil 69 | class_getter latest_value_b : Int32? = nil 70 | 71 | def handle(event : TestEvent::A) 72 | @@latest_value_a = event.payload 73 | end 74 | 75 | def handle(event : TestEvent::B) 76 | @@latest_value_b = event.payload 77 | end 78 | end 79 | 80 | describe self do 81 | channel = Onyx::EDA::Channel::Memory.new 82 | 83 | simple_subscriber = SimpleSubscriber.new 84 | simple_subscriber.subscribe(channel) 85 | 86 | double_subscriber = DoubleSubscriber.new 87 | double_subscriber.subscribe(channel) 88 | 89 | simple_consumer = SimpleConsumer.new 90 | simple_consumer.subscribe(channel) 91 | 92 | double_consumer = DoubleConsumer.new 93 | double_consumer.subscribe(channel) 94 | 95 | mixed_actor = MixedActor.new 96 | mixed_actor.subscribe(channel) 97 | 98 | it do 99 | channel.emit(TestEvent::A.new("foo")) 100 | Fiber.yield 101 | 102 | SimpleSubscriber.latest_value.should eq "foo" 103 | DoubleSubscriber.latest_value_a.should eq "foo" 104 | SimpleConsumer.latest_value.should eq "foo" 105 | DoubleConsumer.latest_value_a.should eq "foo" 106 | MixedActor.latest_value_a.should eq "foo" 107 | 108 | channel.emit(TestEvent::B.new(42)) 109 | Fiber.yield 110 | 111 | DoubleSubscriber.latest_value_b.should eq 42 112 | DoubleConsumer.latest_value_b.should eq 42 113 | MixedActor.latest_value_b.should eq 42 114 | 115 | simple_subscriber.unsubscribe(channel) 116 | double_subscriber.unsubscribe(channel) 117 | simple_consumer.unsubscribe(channel) 118 | double_consumer.unsubscribe(channel) 119 | mixed_actor.unsubscribe(channel) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/onyx-eda/channel/memory_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | class Onyx::EDA::Channel::Memory 4 | struct TestEvent::A 5 | include Onyx::EDA::Event 6 | 7 | getter payload : String 8 | 9 | def initialize(@payload : String) 10 | end 11 | end 12 | 13 | struct TestEvent::B 14 | include Onyx::EDA::Event 15 | 16 | getter payload : Int32 17 | 18 | def initialize(@payload : Int32) 19 | end 20 | end 21 | 22 | describe self do 23 | channel = self.new 24 | buffer = Hash(String, String | Int32).new 25 | 26 | describe "subscription" do 27 | it do 28 | sub_a = channel.subscribe(TestEvent::A) do |event| 29 | buffer["a"] = event.payload 30 | end 31 | 32 | sub_b = channel.subscribe(TestEvent::B) do |event| 33 | buffer["b"] = event.payload 34 | end 35 | 36 | sub_c = channel.subscribe(TestEvent::B, payload: 42) do |event| 37 | buffer["c"] = event.payload 38 | end 39 | 40 | channel.emit(TestEvent::A.new("foo"), TestEvent::B.new(42)) 41 | Fiber.yield 42 | 43 | buffer["a"].should eq "foo" 44 | buffer["b"].should eq 42 45 | buffer["c"].should eq 42 46 | 47 | buffer.clear 48 | 49 | channel.emit(TestEvent::B.new(43)) 50 | Fiber.yield 51 | 52 | buffer["a"]?.should be_nil # A is not emitted 53 | buffer["b"].should eq 43 54 | buffer["c"]?.should be_nil # C doesn't match the filter 55 | 56 | buffer.clear 57 | 58 | sub_a.unsubscribe.should be_true 59 | channel.unsubscribe(sub_b).should be_true 60 | sub_c.unsubscribe.should be_true 61 | end 62 | end 63 | 64 | describe "consumption" do 65 | it do 66 | sub_a = channel.subscribe(TestEvent::A, "foo") do |event| 67 | buffer["a"] = event.payload 68 | end 69 | 70 | sub_b = channel.subscribe(TestEvent::B, "foo") do |event| 71 | buffer["b"] = event.payload 72 | end 73 | 74 | sub_c = channel.subscribe(TestEvent::B, "bar") do |event| 75 | buffer["c"] = event.payload 76 | end 77 | 78 | channel.emit(TestEvent::A.new("foo"), TestEvent::B.new(42)) 79 | Fiber.yield 80 | 81 | buffer["a"].should eq "foo" 82 | buffer["b"].should eq 42 83 | buffer["c"].should eq 42 84 | 85 | buffer.clear 86 | 87 | sub_a.unsubscribe.should be_true 88 | channel.unsubscribe(sub_b).should be_true 89 | sub_c.unsubscribe.should be_true 90 | end 91 | end 92 | 93 | describe "awaiting" do 94 | spawn do 95 | buffer["a"] = channel.await(TestEvent::A).payload 96 | end 97 | 98 | spawn do 99 | select 100 | when event = channel.await(TestEvent::A, payload: "bar") 101 | buffer["b"] = event.payload 102 | end 103 | end 104 | 105 | spawn do 106 | buffer["c"] = channel.await(TestEvent::B, &.payload) 107 | end 108 | 109 | Fiber.yield 110 | 111 | it do 112 | channel.emit([TestEvent::A.new("foo")]) 113 | channel.emit([TestEvent::B.new(42)]) 114 | Fiber.yield 115 | 116 | buffer["a"].should eq "foo" 117 | buffer["b"]?.should be_nil # Because it has filter 118 | buffer["c"].should eq 42 119 | 120 | buffer.clear 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/onyx-eda/channel/redis_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | require "../../../src/onyx-eda/channel/redis" 3 | 4 | class Onyx::EDA::Channel::Redis 5 | struct TestEvent::A 6 | include Onyx::EDA::Event 7 | 8 | getter payload : String 9 | 10 | def initialize(@payload : String) 11 | end 12 | end 13 | 14 | struct TestEvent::B 15 | include Onyx::EDA::Event 16 | 17 | getter payload : Int32 18 | 19 | def initialize(@payload : Int32) 20 | end 21 | end 22 | 23 | describe self do 24 | channel = self.new(ENV["REDIS_URL"], logger: Logger.new(STDOUT)) 25 | buffer = Hash(String, String | Int32).new 26 | 27 | describe "subscription" do 28 | it do 29 | sub_a = channel.subscribe(TestEvent::A) do |event| 30 | buffer["a"] = event.payload 31 | end 32 | 33 | sub_b = channel.subscribe(TestEvent::B) do |event| 34 | buffer["b"] = event.payload 35 | end 36 | 37 | sub_c = channel.subscribe(TestEvent::B, payload: 42) do |event| 38 | buffer["c"] = event.payload 39 | end 40 | 41 | sleep(0.25) 42 | channel.emit(TestEvent::A.new("foo"), TestEvent::B.new(42)) 43 | sleep(0.25) 44 | 45 | buffer["a"].should eq "foo" 46 | buffer["b"].should eq 42 47 | buffer["c"].should eq 42 48 | 49 | buffer.clear 50 | 51 | channel.emit(TestEvent::B.new(43)) 52 | sleep(0.25) 53 | 54 | buffer["a"]?.should be_nil # A is not emitted 55 | buffer["b"].should eq 43 56 | buffer["c"]?.should be_nil # C doesn't match the filter 57 | 58 | buffer.clear 59 | 60 | sub_a.unsubscribe.should be_true 61 | channel.unsubscribe(sub_b).should be_true 62 | sub_c.unsubscribe.should be_true 63 | end 64 | end 65 | 66 | describe "consumption" do 67 | it do 68 | sub_a = channel.subscribe(TestEvent::A, "foo") do |event| 69 | buffer["a"] = event.payload 70 | end 71 | 72 | sub_b = channel.subscribe(TestEvent::B, "foo") do |event| 73 | buffer["b"] = event.payload 74 | end 75 | 76 | sub_c = channel.subscribe(TestEvent::B, "bar") do |event| 77 | buffer["c"] = event.payload 78 | end 79 | 80 | sleep(0.1) 81 | channel.emit(TestEvent::A.new("foo"), TestEvent::B.new(42)) 82 | sleep(0.1) 83 | 84 | buffer["a"].should eq "foo" 85 | buffer["b"].should eq 42 86 | buffer["c"].should eq 42 87 | 88 | buffer.clear 89 | 90 | sub_a.unsubscribe.should be_true 91 | channel.unsubscribe(sub_b).should be_true 92 | sub_c.unsubscribe.should be_true 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/onyx-eda_spec.cr: -------------------------------------------------------------------------------- 1 | require "./onyx-eda/*" 2 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/onyx-eda" 3 | -------------------------------------------------------------------------------- /src/onyx-eda.cr: -------------------------------------------------------------------------------- 1 | # Powerful framework for modern applications. 2 | # See [onyxframework.org](https://onyxframework.org). 3 | module Onyx 4 | # Event-Driven Architecture framework. Read more at [Wikipedia](https://en.wikipedia.org/wiki/Event-driven_architecture). 5 | module EDA 6 | end 7 | end 8 | 9 | require "./onyx-eda/*" 10 | -------------------------------------------------------------------------------- /src/onyx-eda/actor.cr: -------------------------------------------------------------------------------- 1 | # A module which turns an object into event actor. 2 | # It is included into `Subscriber` and `Consumer` modules. 3 | module Onyx::EDA::Actor 4 | @subscribed_channels = Array(Onyx::EDA::Channel).new 5 | 6 | # Subscribe to a *channel*. 7 | # Raises if this actor is already subsribed to this channel. 8 | # Returns self. 9 | def subscribe(channel : Onyx::EDA::Channel) : self 10 | raise "Already subscribed to #{channel}" if @subscribed_channels.includes?(channel) 11 | @subscribed_channels.push(channel) 12 | self 13 | end 14 | 15 | # Unsubscribe from a *channel*. Raises if this actor is already 16 | # unsubscribed or not subscribed to this channel yet. Return self. 17 | def unsubscribe(channel : Onyx::EDA::Channel) : self 18 | raise "Not subscribed to #{channel} yet" unless @subscribed_channels.includes?(channel) 19 | @subscribed_channels.delete(channel) 20 | self 21 | end 22 | 23 | private macro subscribe_impl(var, t, has_method, &block) 24 | {% if has_method %} 25 | previous_def 26 | {% else %} 27 | super 28 | {% end %} 29 | 30 | subscription = ({{yield.id}}) 31 | 32 | channel_hash = {{var}}[channel.hash] ||= Hash(UInt64, Void*).new 33 | channel_hash[{{t}}.hash] = Box(Onyx::EDA::Channel::Subscription({{t}})).box(subscription.as(Onyx::EDA::Channel::Subscription({{t}}))) 34 | 35 | self 36 | end 37 | 38 | private macro unsubscribe_impl(var, t, has_method) 39 | {% if has_method %} 40 | previous_def 41 | {% else %} 42 | super 43 | {% end %} 44 | 45 | channel_hash = {{var}}[channel.hash] 46 | void = channel_hash.delete({{t}}.hash) 47 | subscription = Box(Onyx::EDA::Channel::Subscription({{t}})).unbox(void.not_nil!) 48 | subscription.unsubscribe 49 | 50 | if channel_hash.empty? 51 | {{var}}.delete(channel_hash) 52 | end 53 | 54 | self 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /src/onyx-eda/channel.cr: -------------------------------------------------------------------------------- 1 | require "./channel/subscription" 2 | require "./channel/duplicate_consumer_error" 3 | require "./channel/memory" 4 | 5 | module Onyx::EDA 6 | # An abstract event channel. 7 | # It implements basic logic used in other channels. 8 | abstract class Channel 9 | # Emit *events* returning themselves. 10 | # This method usually blocks until all events are delivered, 11 | # but the subscription block calls happen asynchronously. 12 | abstract def emit(events : Enumerable) : Enumerable 13 | 14 | # ditto 15 | abstract def emit(*events) : Enumerable 16 | 17 | # Emit *event* returning itself. 18 | # This method usually blocks until the event is delivered, 19 | # but the subscription block calls happen asynchronously. 20 | abstract def emit(event : T) : T forall T 21 | 22 | # Subscribe to an *event*. Returns a `Subscription` instance, which can be cancelled. 23 | # Every subscription instance gets notified about an `#emit`ted event. 24 | # 25 | # This is a non-blocking method, as it spawns a subscription fiber. 26 | # 27 | # ``` 28 | # record MyEvent, payload : String do 29 | # include Onyx::EDA::Event 30 | # end 31 | # 32 | # sub = channel.subscribe(MyEvent) do |event| 33 | # puts event.payload 34 | # end 35 | # 36 | # channel.emit(MyEvent.new("foo")) 37 | # 38 | # # Need to yield the control 39 | # sleep(0.1) 40 | # 41 | # # Can cancel afterwards 42 | # sub.unsubscribe 43 | # ``` 44 | # 45 | # You can *filter* the events by their getters, for example: 46 | # 47 | # ``` 48 | # channel.subscribe(MyEvent, payload: "bar") do |event| 49 | # puts event.payload # Would only output events with "bar" payload 50 | # end 51 | # 52 | # channel.emit(MyEvent.new("foo")) # Would not trigger the above subscription 53 | # ``` 54 | # 55 | # See `Subscriber` for an includable subscribing module. 56 | abstract def subscribe(event : T.class, **filter, &block : T -> _) : Subscription(T) forall T 57 | 58 | # Begin consuming an *event*. Consumption differs from subscription in a way that 59 | # only a single consuming subscription instance with certain *consumer_id* among 60 | # all this channel subscribers would be notified about an event after it 61 | # successfully acquires a lock. The lock implementation differs in channels. 62 | # 63 | # Returns a `Subscription` instance. May raise `DuplicateConsumerError` if a 64 | # duplicate consumer ID found for this event in this very process. 65 | # 66 | # This is a non-blocking method, as it spawns a subscription fiber. 67 | # 68 | # ``` 69 | # record MyEvent, payload : String do 70 | # include Onyx::EDA::Event 71 | # end 72 | # 73 | # channel = Onyx::EDA::Channel::Redis.new 74 | # 75 | # sub = channel.subscribe(MyEvent, "MyConsumer") do |event| 76 | # puts event.payload 77 | # end 78 | # ``` 79 | # 80 | # Launch two subscribing processes, then emit an event in another process: 81 | # 82 | # ``` 83 | # # Only one consumer of the two above will be notified 84 | # channel.emit(MyEvent.new("foo")) 85 | # ``` 86 | # 87 | # See `Consumer` for an includable consumption module. 88 | abstract def subscribe(event : T.class, consumer_id : String, &block : T -> _) : Subscription(T) forall T 89 | 90 | # Cancel a *subscription*. Returns a boolean value indicating whether was it 91 | # successufully cancelled or not (for instance, it may be already cancelled, 92 | # returning `false`). 93 | abstract def unsubscribe(subscription : Subscription) : Bool 94 | 95 | # Wait for an *event* to happen, returning the *block* execution result. 96 | # An event can be *filter*ed by its getters. 97 | # 98 | # It is a **blocking** method. 99 | # 100 | # ``` 101 | # record MyEvent, payload : String do 102 | # include Onyx::EDA::Event 103 | # end 104 | # 105 | # # Will block the execution unless MyEvent is received with "foo" payload 106 | # payload = channel.await(MyEvent, payload: "foo") do |event| 107 | # event.payload 108 | # end 109 | # 110 | # # In another fiber... 111 | # channel.emit(MyEvent.new("foo")) 112 | # ``` 113 | # 114 | # This method can be used within the `select` block. It works better with the [timer.cr](https://github.com/vladfaust/timer.cr) shard. 115 | # 116 | # ``` 117 | # select 118 | # when payload = channel.await(MyEvent, &.payload) 119 | # puts payload 120 | # when Timer.new(30.seconds) 121 | # raise "Timeout!" 122 | # end 123 | # ``` 124 | def await( 125 | event : T.class, 126 | **filter, 127 | &block : T -> U 128 | ) : U forall T, U 129 | await_channel(T, **filter, &block).receive 130 | end 131 | 132 | # The same as block-version, but returns an *event* instance itself. 133 | # 134 | # ``` 135 | # event = channel.await(MyEvent) 136 | # ``` 137 | # 138 | # This method can be used within the `select` block. It works better with the [timer.cr](https://github.com/vladfaust/timer.cr) shard: 139 | # 140 | # ``` 141 | # select 142 | # when event = channel.await(MyEvent) 143 | # puts event.payload 144 | # when Timer.new(30.seconds) 145 | # raise "Timeout!" 146 | # end 147 | # ``` 148 | def await(event, **filter) 149 | await(event, **filter, &.itself) 150 | end 151 | 152 | protected abstract def acquire_lock?(event : T, consumer_id : String, timeout : Time::Span) : Bool forall T 153 | 154 | # Event hash -> Array(Subscription) 155 | @subscriptions = Hash(UInt64, Array(Void*)).new 156 | 157 | # Event hash -> ID -> Subscription 158 | @consumers = Hash(UInt64, Hash(String, Void*)).new 159 | 160 | protected def emit_impl(events : Enumerable(T)) : Enumerable(T) forall T 161 | {% raise "Can only emit non-abstract event objects (given `#{T}`)" unless (T < Reference || T < Struct) && !T.abstract? && !T.union? %} 162 | 163 | if subscriptions = @subscriptions[T.hash]? 164 | subscriptions.each do |void| 165 | subscription = Box(Subscription(T)).unbox(void) 166 | 167 | events.each do |event| 168 | subscription.notify(event) 169 | end 170 | end 171 | end 172 | 173 | if consumers = @consumers[T.hash]? 174 | consumers.each do |_, void| 175 | subscription = Box(Subscription(T)).unbox(void) 176 | 177 | events.each do |event| 178 | subscription.notify(event) 179 | end 180 | end 181 | end 182 | 183 | events 184 | end 185 | 186 | protected def emit_impl(*events : *T) : Enumerable forall T 187 | {% for t in T %} 188 | ary = Array({{t}}).new 189 | 190 | events.each do |event| 191 | if event.is_a?({{t}}) 192 | ary << event 193 | end 194 | end 195 | 196 | emit_impl(ary) 197 | {% end %} 198 | 199 | events 200 | end 201 | 202 | protected def subscribe_impl( 203 | event : T.class, 204 | **filter : **U, 205 | &block : T -> _ 206 | ) : Subscription(T) forall T, U 207 | subscription = Subscription(T).new(self, **filter, &block) 208 | void = Box(Subscription(T)).box(subscription) 209 | (@subscriptions[T.hash] ||= Array(Void*).new).push(void) 210 | return subscription 211 | end 212 | 213 | protected def subscribe_impl( 214 | event : T.class, 215 | consumer_id : String, 216 | &block : T -> _ 217 | ) : Subscription(T) forall T, U 218 | {% raise "Can only subscribe to non-abstract event objects (given `#{T}`)" unless (T < Reference || T < Struct) && !T.abstract? && !T.union? %} 219 | 220 | existing = @consumers[T.hash]?.try &.[consumer_id]? 221 | raise DuplicateConsumerError.new(T, consumer_id) if existing 222 | 223 | channel = self 224 | 225 | subscription = Subscription(T).new(self, consumer_id) do |event| 226 | if channel.acquire_lock?(event, consumer_id) 227 | block.call(event) 228 | end 229 | end 230 | 231 | (@consumers[T.hash] ||= Hash(String, Void*).new)[consumer_id] = Box(Subscription(T)).box(subscription) 232 | 233 | return subscription 234 | end 235 | 236 | protected def unsubscribe_impl(subscription : Subscription(T)) : Bool forall T 237 | subscription.cancel 238 | 239 | if consumer_id = subscription.consumer_id 240 | return !!@consumers[T.hash]?.try(&.delete(consumer_id)) 241 | else 242 | ary = @subscriptions[T.hash]? 243 | return false unless ary 244 | 245 | index = ary.index do |element| 246 | Box(Subscription(T)).unbox(element) == subscription 247 | end 248 | 249 | deleted = index ? !!ary.delete_at(index) : false 250 | 251 | if deleted && ary.empty? 252 | @subscriptions.delete(T.hash) 253 | end 254 | 255 | return deleted 256 | end 257 | end 258 | 259 | # :nodoc: 260 | def await_select_action( 261 | event : T.class, 262 | **filter, 263 | &block : T -> _ 264 | ) forall T 265 | await_channel(event, **filter, &block).receive_select_action 266 | end 267 | 268 | # :nodoc: 269 | def await_select_action(event, **filter) 270 | await_select_action(event, **filter, &.itself) 271 | end 272 | 273 | protected def await_channel( 274 | event klass : T.class, 275 | **filter, 276 | &block : T -> U 277 | ) : ::Channel(U) forall T, U 278 | result_channel = ::Channel(U).new 279 | 280 | subscribe(T, **filter) do |event| 281 | result_channel.send(block.call(event)) 282 | end 283 | 284 | result_channel 285 | end 286 | 287 | # Return an event object by its `Object#hash`. 288 | protected def hash_to_event_type(hash : UInt64) 289 | {% begin %} 290 | case hash 291 | {% for type in Object.all_subclasses.select { |t| t <= Onyx::EDA::Event && (t < Reference || t < Struct) && !t.abstract? } %} 292 | when {{type}}.hash then {{type}} 293 | {% end %} 294 | else 295 | raise "BUG: Unknown hash #{hash}" 296 | end 297 | {% end %} 298 | end 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /src/onyx-eda/channel/duplicate_consumer_error.cr: -------------------------------------------------------------------------------- 1 | abstract class Onyx::EDA::Channel 2 | # Raised on `Channel#subscribe` with *consumer_id* argument if a consumer with the 3 | # same ID already exists in this process for this channel. 4 | class DuplicateConsumerError(T) < Exception 5 | getter consumer_id : String 6 | 7 | def event : T.class 8 | T.class 9 | end 10 | 11 | protected def self.new(event : T.class, consumer_id : String) : DuplicateConsumerError(T) forall T 12 | DuplicateConsumerError(T).new(consumer_id) 13 | end 14 | 15 | # :nodoc: 16 | protected def initialize(@consumer_id : String) 17 | super("There can be only one `#{T}` consumer with ID #{@consumer_id.inspect} within a single channel instance") 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/onyx-eda/channel/memory.cr: -------------------------------------------------------------------------------- 1 | require "../channel" 2 | 3 | module Onyx::EDA 4 | # An in-memory channel. Emitted events are visible within current process only. 5 | class Channel::Memory < Channel 6 | def emit(events : Enumerable(T)) : Enumerable(T) forall T 7 | emit_impl(events) 8 | end 9 | 10 | def emit(*events : *T) : Enumerable forall T 11 | emit_impl(*events) 12 | end 13 | 14 | def emit(event : T) : T forall T 15 | emit_impl(event).first 16 | end 17 | 18 | def subscribe( 19 | event : T.class, 20 | **filter, 21 | &block : T -> _ 22 | ) : Subscription(T) forall T 23 | subscribe_impl(event, **filter, &block) 24 | end 25 | 26 | def subscribe( 27 | event : T.class, 28 | consumer_id : String, 29 | **filter, 30 | &block : T -> _ 31 | ) : Subscription(T) forall T 32 | subscribe_impl(event, consumer_id, **filter, &block) 33 | end 34 | 35 | def unsubscribe(subscription : Subscription) : Bool 36 | unsubscribe_impl(subscription) 37 | end 38 | 39 | protected def acquire_lock?(*args) 40 | true 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /src/onyx-eda/channel/redis.cr: -------------------------------------------------------------------------------- 1 | require "mini_redis" 2 | require "msgpack" 3 | 4 | require "../channel" 5 | require "../ext/uuid/msgpack" 6 | 7 | {% for type in Object.all_subclasses.select { |t| t <= Onyx::EDA::Event && !t.abstract? } %} 8 | {% if type < Struct %} 9 | struct {{type}} 10 | {% elsif type < Reference %} 11 | class {{type}} 12 | {% end %} 13 | include MessagePack::Serializable 14 | 15 | def self.to_redis_key : String 16 | {{type.stringify.split("::").map(&.underscore).join(":")}} 17 | end 18 | end 19 | {% end %} 20 | 21 | module Onyx::EDA 22 | module Event 23 | macro included 24 | include MessagePack::Serializable 25 | 26 | # Get a Redis key for this event. Currently formats like this: 27 | # 28 | # ``` 29 | # Namespace::MyEvent => "namespace:my_event" 30 | # ``` 31 | def self.to_redis_key : String 32 | self.to_s.split("::").map(&.underscore).join(":") 33 | end 34 | end 35 | end 36 | 37 | # A Redis channel. All subscribers to the same Redis instance receive notifications 38 | # about events emitted within this channel, which leads to an easy distribution. 39 | # 40 | # NOTE: It relies on Redis streams feature, which **requires Redis version >= 5**! 41 | # 42 | # In Onyx::EDA events are delivered unreliably and in real-time, which means that 43 | # fresh subscribers do not have access to recent events, only to the future ones. 44 | # That's why consumption is implemented with locks instead of consumer groups. 45 | # 46 | # All events are serialized with [MessagePack](https://github.com/crystal-community/msgpack-crystal). 47 | # 48 | # ``` 49 | # # Process #1 50 | # require "onyx-eda/channel/redis" 51 | # 52 | # record MyEvent, payload : String do 53 | # include Onyx::EDA::Event 54 | # end 55 | # 56 | # channel = Onyx::EDA::Channel::Redis.new("redis://localhost:6379") 57 | # channel.emit(MyEvent.new("foo")) 58 | # ``` 59 | # 60 | # ``` 61 | # # Process #2 62 | # require "onyx-eda/channel/redis" 63 | # 64 | # record MyEvent, payload : String do 65 | # include Onyx::EDA::Event 66 | # end 67 | # 68 | # channel = Onyx::EDA::Channel::Redis.new("redis://localhost:6379") 69 | # channel.subscribe(MyEvent) do |event| 70 | # puts event.payload 71 | # exit 72 | # end 73 | # 74 | # sleep 75 | # ``` 76 | class Channel::Redis < Channel 77 | @client_id : Int64 78 | @blocked : Bool = false 79 | @siphash_key = StaticArray(UInt8, 16).new(0) 80 | 81 | # Initialize with Redis *uri* and Redis *namespace*. 82 | # *args* and *nargs* are passed directly to a `MiniRedis` instance. 83 | def self.new(uri : URI, namespace : String = "onyx-eda", *args, **nargs) 84 | new( 85 | MiniRedis.new(uri, *args, **nargs), 86 | MiniRedis.new(uri, *args, **nargs), 87 | namespace 88 | ) 89 | end 90 | 91 | # ditto 92 | def self.new(uri : String, namespace : String = "onyx-eda", *args, **nargs) 93 | new( 94 | MiniRedis.new(URI.parse(uri), *args, **nargs), 95 | MiniRedis.new(URI.parse(uri), *args, **nargs), 96 | namespace 97 | ) 98 | end 99 | 100 | # Explicitly initialize with two [`MiniRedis`](https://github.com/vladfaust/mini_redis) 101 | # instances (one would block-read and another would issue commands) 102 | # and Redis *namespace*. 103 | def initialize( 104 | @redis : MiniRedis, 105 | @sidekick : MiniRedis, 106 | @namespace : String = "onyx-eda" 107 | ) 108 | @client_id = @redis.send("CLIENT", "ID").raw.as(Int64) 109 | spawn routine 110 | spawn unblocking_routine 111 | end 112 | 113 | # Emit *events*, sending them to an appropriate stream. See `Channel#emit`. 114 | # The underlying `XADD` command has `MAXLEN ~ 1000` option. 115 | # 116 | # This method **blocks** until all subscribers to this event read it from the stream. 117 | # 118 | # TODO: Allow to change `MAXLEN`. 119 | def emit( 120 | events : Enumerable(T), 121 | redis : MiniRedis = @sidekick 122 | ) : Enumerable(T) forall T 123 | {% raise "Can only emit non-abstract event objects (given `#{T}`)" unless (T < Reference || T < Struct) && !T.abstract? && !T.union? %} 124 | 125 | stream = T.to_redis_key 126 | 127 | proc = ->(r : MiniRedis) do 128 | events.each do |event| 129 | r.send( 130 | "XADD", 131 | @namespace + ':' + stream, 132 | "MAXLEN", 133 | "~", 134 | 1000, 135 | "*", 136 | "pld", 137 | event.to_msgpack, 138 | ) 139 | end 140 | end 141 | 142 | if redis.transaction? 143 | response = proc.call(redis) 144 | else 145 | response = redis.transaction(&proc) 146 | end 147 | 148 | events 149 | end 150 | 151 | # ditto 152 | def emit(*events : *T) : Enumerable forall T 153 | @sidekick.transaction do |tx| 154 | {% for t in T %} 155 | ary = Array({{t}}).new 156 | 157 | events.each do |event| 158 | if event.is_a?({{t}}) 159 | ary << event 160 | end 161 | end 162 | 163 | emit(ary, tx) 164 | {% end %} 165 | end 166 | 167 | events 168 | end 169 | 170 | # See `#emit(events)`. 171 | def emit(event : T) : T forall T 172 | emit({event}).first 173 | end 174 | 175 | # Subscribe to an *event* reading from its stream. 176 | # You should yield the control to actually start reading. 177 | # See `Channel#subscribe(event, **filter, &block)`. 178 | def subscribe( 179 | event : T.class, 180 | **filter, 181 | &block : T -> _ 182 | ) : Subscription forall T 183 | wrap_changes do 184 | subscribe_impl(T, **filter, &block) 185 | end 186 | end 187 | 188 | # Begin consuming an *event* reading from its stream. It is guaranteed that 189 | # only a **single** consuming subscription with given *id* accross the whole 190 | # application would be notified about an event. 191 | # 192 | # But such notifications are non-reliable, i.e. a single consumer 193 | # could crash during event handling, meaning that this event would not be handled 194 | # properly. If you need reliability, use a background job processing istead, 195 | # for example, [Worcr](https://worcr.com). 196 | # 197 | # See `Channel#subscribe(event, consumer_id, &block)`. 198 | def subscribe( 199 | event : T.class, 200 | consumer_id : String, 201 | &block : T -> _ 202 | ) : Subscription forall T 203 | wrap_changes do 204 | subscribe_impl(T, consumer_id, &block) 205 | end 206 | end 207 | 208 | # See `Channel#unsubscribe`. 209 | def unsubscribe(subscription : Subscription) : Bool 210 | wrap_changes { unsubscribe_impl(subscription) } 211 | end 212 | 213 | protected def acquire_lock?( 214 | event : T, 215 | consumer_id : String, 216 | timeout : Time::Span = 5.seconds 217 | ) : Bool forall T 218 | key = "#{@namespace}:lock:#{T.to_redis_key}:#{consumer_id}:#{event.event_id.hexstring}" 219 | 220 | response = @sidekick.send( 221 | "SET", key, "t", 222 | "PX", (timeout.total_seconds * 1000).round.to_i, 223 | "NX" 224 | ) 225 | 226 | return !response.raw.nil? 227 | end 228 | 229 | # Wrap (un)subscribing, checking if the list of watched events changed. 230 | # This could trigger the main client unblocking. 231 | protected def wrap_changes(&block) 232 | before = (@subscriptions.keys + @consumers.keys).uniq! 233 | 234 | yield.tap do 235 | want_unblock if before != (@subscriptions.keys + @consumers.keys).uniq! 236 | end 237 | end 238 | 239 | protected def routine 240 | # The exact time to read messages since, 241 | # because "$" IDs with multiple stream keys 242 | # will lead to a single stream reading 243 | now = (Time.now.to_unix_ms - 1).to_s 244 | 245 | # Cache for last read message IDs 246 | last_read_ids = Hash(String, String).new 247 | 248 | loop do 249 | streams = (@subscriptions.keys + @consumers.keys).uniq!.map do |hash| 250 | hash_to_event_type(hash).to_redis_key 251 | end 252 | 253 | if streams.empty? 254 | # If there are no events to subscribe to, then just block 255 | # 256 | 257 | begin 258 | @blocked = true 259 | @redis.send("BLPOP", UUID.random.bytes.to_slice, 0) 260 | rescue ex : MiniRedis::Error 261 | if ex.message =~ /^UNBLOCKED/ 262 | next @blocked = false 263 | else 264 | raise ex 265 | end 266 | end 267 | end 268 | 269 | loop do 270 | begin 271 | @blocked = true 272 | 273 | commands = ["XREAD", "COUNT", 1, "BLOCK", 0, "STREAMS"] 274 | commands.concat(streams.map { |s| @namespace + ':' + s }) 275 | commands.concat(streams.map { |s| last_read_ids.fetch(s) { now } }) 276 | 277 | response = @redis.send(commands) 278 | rescue ex : MiniRedis::Error 279 | if ex.message =~ /^UNBLOCKED/ 280 | break @blocked = false 281 | else 282 | raise ex 283 | end 284 | end 285 | 286 | parse_xread(response) do |stream, message_id| 287 | last_read_ids[stream] = message_id 288 | end 289 | end 290 | end 291 | end 292 | 293 | # Parse the `XREAD` response, yielding events one-by-one. 294 | protected def parse_xread(response, &block) 295 | response.raw.as(Array).each do |entry| 296 | stream_name = String.new(entry.raw.as(Array)[0].raw.as(Bytes)).match(/#{@namespace}:(.+)/).not_nil![1] 297 | 298 | {% begin %} 299 | case stream_name 300 | {% for type in Object.all_subclasses.select { |t| t < Onyx::EDA::Event && !t.abstract? } %} 301 | when {{type.stringify.split("::").map(&.underscore).join(':')}} 302 | entry.raw.as(Array)[1].raw.as(Array).each do |message| 303 | redis_message_id = String.new(message.raw.as(Array)[0].raw.as(Bytes)) 304 | 305 | args = message.raw.as(Array)[1].raw.as(Array) 306 | payload_index = args.map{ |v| String.new(v.raw.as(Bytes)) }.index("pld").not_nil! + 1 307 | payload = args[payload_index].raw.as(Bytes) 308 | 309 | event = {{type}}.from_msgpack(payload) 310 | emit_impl({event}) 311 | 312 | yield stream_name, redis_message_id 313 | end 314 | {% end %} 315 | end 316 | {% end %} 317 | end 318 | end 319 | 320 | @unblock_channel = ::Channel(Nil).new(1) 321 | 322 | protected def unblocking_routine 323 | loop do 324 | @unblock_channel.receive 325 | 326 | if @blocked 327 | @sidekick.send("CLIENT", "UNBLOCK", @client_id, "ERROR") 328 | @blocked = false 329 | end 330 | end 331 | end 332 | 333 | # Unblock the subscribed client. 334 | protected def want_unblock 335 | spawn do 336 | @unblock_channel.send(nil) unless @unblock_channel.full? 337 | end 338 | end 339 | end 340 | end 341 | -------------------------------------------------------------------------------- /src/onyx-eda/channel/subscription.cr: -------------------------------------------------------------------------------- 1 | require "./subscription/inactive_error" 2 | 3 | abstract class Onyx::EDA::Channel 4 | # An event subscription instance. All subscribers are notified about an event unless 5 | # it doesn't match the filters. 6 | # 7 | # You should not initialize this class manually, use `Channel#subscribe` instead. 8 | # When you want to stop subscription, call the `#unsubscribe` method on a 9 | # `Subscription` instance or `Channel#unsubscribe`. 10 | class Subscription(T) 11 | @active = true 12 | 13 | # Whether is this subscription currently active. 14 | getter? active 15 | 16 | # Cancel this subscription. 17 | # May raise `InactiveError` if the subscription is currently not active 18 | # (i.e. already cancelled). 19 | def unsubscribe 20 | raise InactiveError.new unless @active 21 | @active = false 22 | @eda_channel.unsubscribe(self) 23 | end 24 | 25 | @eda_channel : Channel 26 | @consumer_id : String | Nil 27 | 28 | @event_channel = ::Channel(T).new 29 | @cancel_channel = ::Channel(Nil).new(1) 30 | 31 | @cancelled = false 32 | 33 | protected getter consumer_id 34 | 35 | # :nodoc: 36 | protected def initialize( 37 | @eda_channel : Channel, 38 | @consumer_id : String? = nil, 39 | **filter : **U, 40 | &block : T -> V 41 | ) : self forall U, V 42 | spawn do 43 | loop do 44 | select 45 | when event = @event_channel.receive 46 | {% for k, v in U %} 47 | next unless event.{{k.id}} == filter[{{k.stringify}}].as({{v}}) 48 | {% end %} 49 | 50 | spawn block.call(event) 51 | when @cancel_channel.receive 52 | break 53 | end 54 | end 55 | end 56 | 57 | self 58 | end 59 | 60 | protected def notify(event : T) 61 | raise InactiveError.new unless @active 62 | @event_channel.send(event) 63 | end 64 | 65 | protected def cancel 66 | @cancel_channel.send(nil) unless @cancelled 67 | @cancelled = true 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /src/onyx-eda/channel/subscription/inactive_error.cr: -------------------------------------------------------------------------------- 1 | abstract class Onyx::EDA::Channel 2 | class Subscription(T) 3 | # Raised on `Subscription#unsubscribe` method call if the instance is not 4 | # currently active (i.e. already cancelled). 5 | class InactiveError < Exception 6 | # :nodoc: 7 | protected def initialize 8 | super("This subscription is not active") 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /src/onyx-eda/consumer.cr: -------------------------------------------------------------------------------- 1 | # A module which would make an including object an event consumer. 2 | # Consumption differs from subscription in a way that only a single consumption 3 | # instance with certain ID would be notified about an event. 4 | # In this module, consumer ID equals to the including object class name. 5 | # 6 | # This module behaves a lot like `Subscriber`, see its docs for details. 7 | module Onyx::EDA::Consumer(T) 8 | include Actor 9 | 10 | @consumers = Hash(UInt64, Hash(UInt64, Void*)).new 11 | 12 | # Handle incoming event. Must be defined explicitly in a consumer. 13 | # TODO: Find a way to enable per-event custom ID. 14 | abstract def handle(event : T) 15 | 16 | macro included 17 | {% raise "Cannot include both Subscriber and Consumer modules for the same event `#{T}`" if @type < Onyx::EDA::Subscriber(T) %} 18 | 19 | def subscribe(channel : Onyx::EDA::Channel) : self 20 | subscribe_impl(@consumers, {{T}}, {{!!@type.methods.find { |m| m.name == "subscribe" }}}) do 21 | channel.subscribe({{T}}, {{@type.stringify.split("::").join(".")}}) do |event| 22 | handle(event) 23 | end 24 | end 25 | end 26 | 27 | def unsubscribe(channel : Onyx::EDA::Channel) : self 28 | unsubscribe_impl(@consumers, {{T}}, {{!!@type.methods.find { |m| m.name == "unsubscribe" }}}) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /src/onyx-eda/event.cr: -------------------------------------------------------------------------------- 1 | require "uuid" 2 | 3 | # A basic event module to include. 4 | # 5 | # According to [Wikipedia](https://en.wikipedia.org/wiki/Event-driven_architecture#Event_flow_layers): 6 | # 7 | # > a significant temporal state or fact 8 | # 9 | # Code example: 10 | # 11 | # ``` 12 | # struct MyEvent 13 | # include Onyx::EDA::Event 14 | # 15 | # getter foo 16 | # 17 | # def initialize(@foo : String) 18 | # end 19 | # end 20 | # 21 | # channel.subscribe(MyEvent) do |event| 22 | # puts event.foo 23 | # end 24 | # 25 | # event = channel.emit(MyEvent.new) 26 | # pp event.event_id # => 27 | # 28 | # sleep # You need to yield the control, see more in Channel docs 29 | # ``` 30 | module Onyx::EDA::Event 31 | @event_id : UUID = UUID.random 32 | 33 | # This event ID. Defaults to a random `UUID`. 34 | getter event_id : UUID 35 | end 36 | -------------------------------------------------------------------------------- /src/onyx-eda/ext/uuid/msgpack.cr: -------------------------------------------------------------------------------- 1 | require "uuid" 2 | require "msgpack" 3 | 4 | # Extensions to stdlib `UUID` struct. 5 | struct UUID 6 | # Read from MessagePack input. 7 | def self.new(pull : MessagePack::Unpacker) 8 | new(Bytes.new(pull)) 9 | end 10 | 11 | # Serialize into MessagePack bytes. 12 | def to_msgpack(packer : MessagePack::Packer) 13 | bytes.to_slice.to_msgpack(packer) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/onyx-eda/subscriber.cr: -------------------------------------------------------------------------------- 1 | # A module which would make an including object an event subscriber. 2 | # Subscribers are notified about every incoming event of type `T`. 3 | # 4 | # A single object can have multiple `Subscriber` and `Consumer` modules included, 5 | # just make sure you have `#handle` method defined for each event. 6 | # 7 | # NOTE: You can not have **both** `Subscriber` and `Consumer` modules included for 8 | # a single event type. 9 | # 10 | # A single actor instance (this module includes `Actor` module) can be subscribed to 11 | # multiple channels simultaneously. 12 | # 13 | # TODO: Have an internal buffer to filter repeating (i.e. with the same ID) events 14 | # among multiple channels. 15 | # 16 | # ``` 17 | # class Actor::Logger 18 | # include Onyx::EDA::Subscriber(Event::User::Registered) 19 | # include Onyx::EDA::Consumer(Event::Payment::Successfull) 20 | # 21 | # # This method will be called in *all* Actor::Logger instances 22 | # def handle(event : Event::User::Registered) 23 | # log_into_terminal("New user with id #{event.id}") 24 | # end 25 | # 26 | # # This method will be called in only *one* Actor::Logger instance 27 | # def handle(event : Event::Payment::Successfull) 28 | # send_email("admin@example.com", "New payment of $#{event.amount}") 29 | # end 30 | # end 31 | # 32 | # actor = Actor::Logger.new 33 | # actor.subscribe(channel) # Non-blocking method 34 | # # ... 35 | # actor.unsubscribe(channel) 36 | # ``` 37 | module Onyx::EDA::Subscriber(T) 38 | include Actor 39 | 40 | @subscriptions = Hash(UInt64, Hash(UInt64, Void*)).new 41 | 42 | # Handle incoming event. Must be defined explicitly in a consumer. 43 | # TODO: Find a way to enable per-event filtering. 44 | abstract def handle(event : T) 45 | 46 | macro included 47 | {% raise "Cannot include both Subscriber and Consumer modules for the same event `#{T}`" if @type < Onyx::EDA::Consumer(T) %} 48 | 49 | def subscribe(channel : Onyx::EDA::Channel) : self 50 | subscribe_impl(@subscriptions, {{T}}, {{!!@type.methods.find { |m| m.name == "subscribe" }}}) do 51 | channel.subscribe({{T}}) do |event| 52 | handle(event) 53 | end 54 | end 55 | end 56 | 57 | def unsubscribe(channel : Onyx::EDA::Channel) : self 58 | unsubscribe_impl(@subscriptions, {{T}}, {{!!@type.methods.find { |m| m.name == "unsubscribe" }}}) 59 | end 60 | end 61 | end 62 | --------------------------------------------------------------------------------