├── .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 | [](https://crystal-lang.org/)
6 | [](https://travis-ci.org/onyxframework/eda)
7 | [](https://docs.onyxframework.org/eda)
8 | [](https://api.onyxframework.org/eda)
9 | [](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 | [](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 |
--------------------------------------------------------------------------------