local_bus 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +245 -0
- data/lib/local_bus/bus.rb +134 -0
- data/lib/local_bus/message.rb +70 -0
- data/lib/local_bus/pledge.rb +43 -0
- data/lib/local_bus/station.rb +227 -0
- data/lib/local_bus/subscriber.rb +133 -0
- data/lib/local_bus/version.rb +7 -0
- data/lib/local_bus.rb +29 -0
- metadata +268 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: bc7597ce848ed87503b21e8b893d9637f7f601945d4e6d0904938f2319b691f0
|
4
|
+
data.tar.gz: 6b65f578aac4e885afc4d76265fdf3812b2b3d8d32b2f0ba7d6abdc6f824fcf7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 18c4ca9e676956c6b2571093bec9f57a76714ceb833f7aae021ac6563965d438499c05fcd5214e63b5b9cb8a192ed739d6926b54bc9d97e87a9f33b193289052
|
7
|
+
data.tar.gz: b1b99ef58b04fcfcf45808596be55bc9919f455aeb65a3ef53a56157305bff5aaf1310d418a02c14413f6db735a06dea58440d5f9d3a849073fd6a53f837247d
|
data/README.md
ADDED
@@ -0,0 +1,245 @@
|
|
1
|
+
<p align="center">
|
2
|
+
<a href="http://blog.codinghorror.com/the-best-code-is-no-code-at-all/">
|
3
|
+
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-328-47d299.svg" />
|
4
|
+
</a>
|
5
|
+
<a href="https://rubygems.org/gems/local_bus">
|
6
|
+
<img alt="GEM Version" src="https://img.shields.io/gem/v/local_bus">
|
7
|
+
</a>
|
8
|
+
<a href="https://rubygems.org/gems/local_bus">
|
9
|
+
<img alt="GEM Downloads" src="https://img.shields.io/gem/dt/local_bus">
|
10
|
+
</a>
|
11
|
+
<a href="https://github.com/hopsoft/local_bus/actions">
|
12
|
+
<img alt="Tests" src="https://github.com/hopsoft/local_bus/actions/workflows/tests.yml/badge.svg" />
|
13
|
+
</a>
|
14
|
+
<a href="https://github.com/testdouble/standard">
|
15
|
+
<img alt="Ruby Style" src="https://img.shields.io/badge/style-standard-168AFE?logo=ruby&logoColor=FE1616" />
|
16
|
+
</a>
|
17
|
+
<a href="https://github.com/sponsors/hopsoft">
|
18
|
+
<img alt="Sponsors" src="https://img.shields.io/github/sponsors/hopsoft?color=eb4aaa&logo=GitHub%20Sponsors" />
|
19
|
+
</a>
|
20
|
+
<a href="https://twitter.com/hopsoft">
|
21
|
+
<img alt="Twitter Follow" src="https://img.shields.io/twitter/url?label=%40hopsoft&style=social&url=https%3A%2F%2Ftwitter.com%2Fhopsoft">
|
22
|
+
</a>
|
23
|
+
</p>
|
24
|
+
|
25
|
+
# LocalBus
|
26
|
+
|
27
|
+
LocalBus is a lightweight pub/sub system for Ruby that helps organize and simplify intra-process communication.
|
28
|
+
|
29
|
+
<!-- Tocer[start]: Auto-generated, don't remove. -->
|
30
|
+
|
31
|
+
## Table of Contents
|
32
|
+
|
33
|
+
- [Why LocalBus?](#why-localbus)
|
34
|
+
- [Installation](#installation)
|
35
|
+
- [Quick Start](#quick-start)
|
36
|
+
- [Interfaces](#interfaces)
|
37
|
+
- [Bus (immediate processing)](#bus-immediate-processing)
|
38
|
+
- [Station (background processing)](#station-background-processing)
|
39
|
+
- [Advanced Usage & Considerations](#advanced-usage--considerations)
|
40
|
+
- [Concurrency Controls](#concurrency-controls)
|
41
|
+
- [Bus Interface (Async)](#bus-interface-async)
|
42
|
+
- [Station Interface (Thread Pool)](#station-interface-thread-pool)
|
43
|
+
- [Error Handling & Recovery](#error-handling--recovery)
|
44
|
+
- [Memory Considerations](#memory-considerations)
|
45
|
+
- [Blocking Operations](#blocking-operations)
|
46
|
+
- [Shutdown & Cleanup](#shutdown--cleanup)
|
47
|
+
- [Limitations](#limitations)
|
48
|
+
- [Sponsors](#sponsors)
|
49
|
+
|
50
|
+
<!-- Tocer[finish]: Auto-generated, don't remove. -->
|
51
|
+
|
52
|
+
## Why LocalBus?
|
53
|
+
|
54
|
+
A message bus (or enterprise service bus) is an architectural pattern that enables different parts of an application to communicate without direct knowledge of each other. Think of it as a smart postal service for your application - components can send messages to topics, and other components can listen for those messages, all without knowing about each other directly.
|
55
|
+
|
56
|
+
Even within a single process, this pattern offers powerful benefits:
|
57
|
+
|
58
|
+
- **Decouple Components**: Break complex systems into maintainable parts that can evolve independently
|
59
|
+
- **Single Responsibility**: Each component can focus on its core task without handling cross-cutting concerns
|
60
|
+
- **Flexible Architecture**: Easily add new features by subscribing to existing events without modifying original code
|
61
|
+
- **Control Flow**: Choose immediate or background processing based on your needs
|
62
|
+
- **Testing**: Simplified testing as components can be tested in isolation
|
63
|
+
- **Stay Reliable**: Built-in error handling and thread safety
|
64
|
+
- **Non-Blocking**: Efficient message processing with async I/O
|
65
|
+
|
66
|
+
## Installation
|
67
|
+
|
68
|
+
```sh
|
69
|
+
bundle add local_bus
|
70
|
+
```
|
71
|
+
|
72
|
+
## Quick Start
|
73
|
+
|
74
|
+
### Interfaces
|
75
|
+
|
76
|
+
- **Bus**: Single-threaded, immediate message delivery using Socketry Async with non-blocking I/O operations
|
77
|
+
- **Station**: Multi-threaded message queuing powered by Concurrent Ruby's thread pool, processing messages through the Bus without blocking the main thread
|
78
|
+
|
79
|
+
Both interfaces ensure optimal performance:
|
80
|
+
|
81
|
+
- Bus leverages async I/O to prevent blocking on network or disk operations
|
82
|
+
- Station offloads work to a managed thread pool, keeping the main thread responsive
|
83
|
+
- Both interfaces support an explicit `wait` for subscribers
|
84
|
+
|
85
|
+
### Bus (immediate processing)
|
86
|
+
|
87
|
+
Best for real-time operations like logging, metrics, and state updates.
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
bus = LocalBus.instance.bus
|
91
|
+
|
92
|
+
bus.subscribe "user.created" do |message|
|
93
|
+
AuditLog.record(message.payload)
|
94
|
+
true
|
95
|
+
end
|
96
|
+
|
97
|
+
# publish returns a promise-like object that resolves to subscribers
|
98
|
+
result = bus.publish("user.created", user_id: 123)
|
99
|
+
|
100
|
+
result.wait # blocks until all subscribers complete
|
101
|
+
result.value # blocks and waits until all subscribers complete and returns the subscribers
|
102
|
+
```
|
103
|
+
|
104
|
+
### Station (background processing)
|
105
|
+
|
106
|
+
Best for async operations like emails, notifications, and resource-intensive tasks.
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
station = LocalBus::Station.new # ... or LocalBus.instance.station
|
110
|
+
|
111
|
+
station.subscribe "email.welcome" do |message|
|
112
|
+
WelcomeMailer.deliver(message.payload[:user_id])
|
113
|
+
true
|
114
|
+
end
|
115
|
+
|
116
|
+
# Returns a Promise or Future that resolves to subscribers
|
117
|
+
result = station.publish("email.welcome", user_id: 123)
|
118
|
+
|
119
|
+
result.wait # blocks until all subscribers complete
|
120
|
+
result.value # blocks and waits until all subscribers complete and returns the subscribers
|
121
|
+
```
|
122
|
+
|
123
|
+
## Advanced Usage & Considerations
|
124
|
+
|
125
|
+
### Concurrency Controls
|
126
|
+
|
127
|
+
#### Bus Interface (Async)
|
128
|
+
|
129
|
+
The Bus interface uses Async's Semaphore to limit resource consumption:
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
# Configure concurrency limits for the Bus
|
133
|
+
bus = LocalBus::Bus.new(concurrency_limit: 10)
|
134
|
+
|
135
|
+
# The semaphore ensures only N concurrent operations run at once
|
136
|
+
bus.subscribe "resource.intensive" do |message|
|
137
|
+
# Only 10 of these will run concurrently
|
138
|
+
perform_intensive_operation(message)
|
139
|
+
end
|
140
|
+
```
|
141
|
+
|
142
|
+
When the concurrency limit is reached, new publish operations will wait until a slot becomes available. This prevents memory bloat but means you should be mindful of timeouts in your subscribers.
|
143
|
+
|
144
|
+
#### Station Interface (Thread Pool)
|
145
|
+
|
146
|
+
The Station interface uses Concurrent Ruby's fixed thread pool with a fallback policy:
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
# Configure the thread pool size for the Station
|
150
|
+
station = LocalBus::Station.new(
|
151
|
+
max_queue: 5_000, # Maximum number of queued items
|
152
|
+
threads: 10, # Maximum pool size
|
153
|
+
fallback_policy: :caller_runs # Runs on calling thread
|
154
|
+
)
|
155
|
+
```
|
156
|
+
|
157
|
+
The fallback policy determines behavior when the thread pool is saturated:
|
158
|
+
|
159
|
+
- `:caller_runs` - Executes the task in the publishing thread (can block)
|
160
|
+
- `:abort` - Raises an error
|
161
|
+
- `:discard` - Silently drops the task
|
162
|
+
|
163
|
+
### Error Handling & Recovery
|
164
|
+
|
165
|
+
Both interfaces implement error boundaries to prevent individual subscriber failures from affecting others:
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
bus.subscribe "user.created" do |message|
|
169
|
+
raise "Something went wrong!"
|
170
|
+
true # Never reached
|
171
|
+
end
|
172
|
+
|
173
|
+
bus.subscribe "user.created" do |message|
|
174
|
+
# This still executes despite the error above
|
175
|
+
notify_admin(message)
|
176
|
+
true
|
177
|
+
end
|
178
|
+
|
179
|
+
# The publish operation completes with partial success
|
180
|
+
result = bus.publish("user.created", user_id: 123)
|
181
|
+
result.wait
|
182
|
+
errored_subscribers = result.value.select(&:error)
|
183
|
+
```
|
184
|
+
|
185
|
+
### Memory Considerations
|
186
|
+
|
187
|
+
Messages are held in memory until all subscribers complete processing. For the Station interface, this includes time spent in the thread pool queue. Consider this when publishing large payloads or during high load:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
# Memory-efficient publishing of large datasets
|
191
|
+
large_dataset.each_slice(100) do |batch|
|
192
|
+
station.publish("data.process", items: batch).wait
|
193
|
+
end
|
194
|
+
```
|
195
|
+
|
196
|
+
### Blocking Operations
|
197
|
+
|
198
|
+
The Bus interface uses non-blocking I/O but can still be blocked by CPU-intensive operations:
|
199
|
+
|
200
|
+
```ruby
|
201
|
+
# Bad - blocks the event loop
|
202
|
+
bus.subscribe "cpu.intensive" do |message|
|
203
|
+
perform_heavy_calculation(message)
|
204
|
+
end
|
205
|
+
|
206
|
+
# Better - offload to Station for CPU-intensive work
|
207
|
+
station.subscribe "cpu.intensive" do |message|
|
208
|
+
perform_heavy_calculation(message)
|
209
|
+
end
|
210
|
+
```
|
211
|
+
|
212
|
+
### Shutdown & Cleanup
|
213
|
+
|
214
|
+
LocalBus does its best to handle graceful shutdown when the process exits, and works to ensure published messages are processed.
|
215
|
+
However, it's possible that some messages may be lost when the process exits.
|
216
|
+
|
217
|
+
Factor for potential message loss when designing your system.
|
218
|
+
For example, idempotency _(i.e. messages that can be re-published without unintended side effects)_.
|
219
|
+
|
220
|
+
### Limitations
|
221
|
+
|
222
|
+
- The Bus interface is single-threaded - long-running subscribers can impact latency
|
223
|
+
- The Station interface may drop messages if configured with `:discard` fallback policy
|
224
|
+
- No persistence - pending messages are lost on process restart
|
225
|
+
- No distributed support - communication limited to single process
|
226
|
+
- Large payloads can impact memory usage, especially under high load
|
227
|
+
- No built-in retry mechanism for failed subscribers
|
228
|
+
|
229
|
+
Consider these limitations when designing your system architecture.
|
230
|
+
|
231
|
+
## See Also
|
232
|
+
|
233
|
+
- [Message Bus](https://github.com/discourse/message_bus) - A reliable and robust messaging bus for Ruby and Rack
|
234
|
+
- [Wisper](https://github.com/krisleech/wisper) - A micro library providing Ruby objects with Publish-Subscribe capabilities
|
235
|
+
|
236
|
+
## Sponsors
|
237
|
+
|
238
|
+
<p align="center">
|
239
|
+
<em>Proudly sponsored by</em>
|
240
|
+
</p>
|
241
|
+
<p align="center">
|
242
|
+
<a href="https://www.clickfunnels.com?utm_source=hopsoft&utm_medium=open-source&utm_campaign=local_bus">
|
243
|
+
<img src="https://images.clickfunnel.com/uploads/digital_asset/file/176632/clickfunnels-dark-logo.svg" width="575" />
|
244
|
+
</a>
|
245
|
+
</p>
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rbs_inline: enabled
|
4
|
+
|
5
|
+
class LocalBus
|
6
|
+
# Local in-process single threaded "message bus" with non-blocking I/O
|
7
|
+
class Bus
|
8
|
+
include MonitorMixin
|
9
|
+
|
10
|
+
# Constructor
|
11
|
+
# @note Creates a new Bus instance with specified concurrency
|
12
|
+
# @rbs concurrency: Integer -- maximum number of concurrent tasks (default: Concurrent.processor_count)
|
13
|
+
def initialize(concurrency: Concurrent.processor_count)
|
14
|
+
super()
|
15
|
+
@concurrency = concurrency.to_i
|
16
|
+
@subscriptions = Concurrent::Hash.new do |hash, key|
|
17
|
+
hash[key] = Concurrent::Set.new
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Maximum number of concurrent tasks that can run in "parallel"
|
22
|
+
# @rbs return: Integer -- current concurrency value
|
23
|
+
def concurrency
|
24
|
+
synchronize { @concurrency }
|
25
|
+
end
|
26
|
+
|
27
|
+
# Sets the concurrency
|
28
|
+
# @rbs concurrency: Integer -- max number of concurrent tasks that can run in "parallel"
|
29
|
+
# @rbs return: Integer -- new concurrency value
|
30
|
+
def concurrency=(value)
|
31
|
+
synchronize { @concurrency = value.to_i }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Registered topics that have subscribers
|
35
|
+
# @rbs return: Array[String] -- list of topic names
|
36
|
+
def topics
|
37
|
+
@subscriptions.keys
|
38
|
+
end
|
39
|
+
|
40
|
+
# Registered subscriptions
|
41
|
+
# @rbs return: Hash[String, Array[callable]] -- mapping of topics to callables
|
42
|
+
def subscriptions
|
43
|
+
@subscriptions.each_with_object({}) do |(topic, callables), memo|
|
44
|
+
memo[topic] = callables.to_a
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Subscribes a callable to a topic
|
49
|
+
# @rbs topic: String -- topic name
|
50
|
+
# @rbs callable: (Message) -> untyped -- callable that will process messages published to the topic
|
51
|
+
# @rbs &block: (Message) -> untyped -- alternative way to provide a callable
|
52
|
+
# @rbs return: self
|
53
|
+
# @raise [ArgumentError] if neither callable nor block is provided
|
54
|
+
def subscribe(topic, callable: nil, &block)
|
55
|
+
callable ||= block
|
56
|
+
raise ArgumentError, "Subscriber must respond to #call" unless callable.respond_to?(:call, false)
|
57
|
+
@subscriptions[topic.to_s].add callable
|
58
|
+
self
|
59
|
+
end
|
60
|
+
|
61
|
+
# Unsubscribes a callable from a topic
|
62
|
+
# @rbs topic: String -- topic name
|
63
|
+
# @rbs callable: (Message) -> untyped -- subscriber that should no longer receive messages
|
64
|
+
# @rbs return: self
|
65
|
+
def unsubscribe(topic, callable:)
|
66
|
+
topic = topic.to_s
|
67
|
+
@subscriptions[topic].delete callable
|
68
|
+
@subscriptions.delete(topic) if @subscriptions[topic].empty?
|
69
|
+
self
|
70
|
+
end
|
71
|
+
|
72
|
+
# Unsubscribes all subscribers from a topic and removes the topic
|
73
|
+
# @rbs topic: String -- topic name
|
74
|
+
# @rbs return: self
|
75
|
+
def unsubscribe_all(topic)
|
76
|
+
@subscriptions[topic].clear
|
77
|
+
@subscriptions.delete topic
|
78
|
+
self
|
79
|
+
end
|
80
|
+
|
81
|
+
# Executes a block and unsubscribes all subscribers from the topic afterwards
|
82
|
+
# @rbs topic: String -- topic name
|
83
|
+
# @rbs block: (String) -> void -- block to execute (yields the topic)
|
84
|
+
def with_topic(topic, &block)
|
85
|
+
block.call topic.to_s
|
86
|
+
ensure
|
87
|
+
unsubscribe_all topic
|
88
|
+
end
|
89
|
+
|
90
|
+
# Publishes a message to a topic
|
91
|
+
#
|
92
|
+
# @note If subscribers are rapidly created/destroyed mid-publish, there's a theoretical
|
93
|
+
# possibility of object_id reuse. However, this is extremely unlikely in practice.
|
94
|
+
#
|
95
|
+
# * If subscribers are added mid-publish, they will not receive the message
|
96
|
+
# * If subscribers are removed mid-publish, they will still receive the message
|
97
|
+
#
|
98
|
+
# @note If the timeout is exceeded, the task will be cancelled before all subscribers have completed.
|
99
|
+
#
|
100
|
+
# Check the Subscriber for any errors.
|
101
|
+
#
|
102
|
+
# @rbs topic: String -- topic name
|
103
|
+
# @rbs timeout: Float -- seconds to wait before cancelling (default: 300)
|
104
|
+
# @rbs payload: Hash -- message payload
|
105
|
+
# @rbs return: Array[Subscriber] -- list of performed subscribers (empty if no subscribers)
|
106
|
+
def publish(topic, timeout: 300, **payload)
|
107
|
+
barrier = Async::Barrier.new
|
108
|
+
message = Message.new(topic, timeout: timeout, **payload)
|
109
|
+
subscribers = subscriptions.fetch(message.topic, []).map { Subscriber.new _1, message }
|
110
|
+
|
111
|
+
if subscribers.any?
|
112
|
+
Sync do |task|
|
113
|
+
task.with_timeout timeout.to_f do
|
114
|
+
semaphore = Async::Semaphore.new(concurrency, parent: barrier)
|
115
|
+
|
116
|
+
subscribers.each do |subscriber|
|
117
|
+
semaphore.async do
|
118
|
+
subscriber.perform
|
119
|
+
end
|
120
|
+
end
|
121
|
+
rescue Async::TimeoutError => cause
|
122
|
+
barrier.stop
|
123
|
+
|
124
|
+
subscribers.select(&:pending?).each do |subscriber|
|
125
|
+
subscriber.timeout cause
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
Pledge.new(barrier, *subscribers)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rbs_inline: enabled
|
4
|
+
|
5
|
+
class LocalBus
|
6
|
+
# Represents a message in the LocalBus system
|
7
|
+
class Message
|
8
|
+
# Constructor
|
9
|
+
# @note Creates a new Message instance with the given topic and payload
|
10
|
+
# @rbs topic: String -- the topic of the message
|
11
|
+
# @rbs timeout: Float? -- optional timeout for message processing (in seconds)
|
12
|
+
# @rbs payload: Hash -- the message payload
|
13
|
+
def initialize(topic, timeout: nil, **payload)
|
14
|
+
@id = SecureRandom.uuid_v7
|
15
|
+
@topic = topic.to_s.freeze
|
16
|
+
@payload = payload.transform_keys(&:to_sym).freeze
|
17
|
+
@created_at = Time.now
|
18
|
+
@thread_id = Thread.current.object_id
|
19
|
+
@timeout = timeout.to_f
|
20
|
+
@metadata ||= {
|
21
|
+
id: id,
|
22
|
+
topic: topic,
|
23
|
+
payload: payload,
|
24
|
+
created_at: created_at,
|
25
|
+
thread_id: thread_id,
|
26
|
+
timeout: timeout
|
27
|
+
}.freeze
|
28
|
+
freeze
|
29
|
+
end
|
30
|
+
|
31
|
+
# Unique identifier for the message
|
32
|
+
# @rbs return: String
|
33
|
+
attr_reader :id
|
34
|
+
|
35
|
+
# Message topic
|
36
|
+
# @rbs return: String
|
37
|
+
attr_reader :topic
|
38
|
+
|
39
|
+
# Message payload
|
40
|
+
# @rbs return: Hash
|
41
|
+
attr_reader :payload
|
42
|
+
|
43
|
+
# Time when the message was created or published
|
44
|
+
# @rbs return: Time
|
45
|
+
attr_reader :created_at
|
46
|
+
|
47
|
+
# ID of the thread that created the message
|
48
|
+
# @rbs return: Integer
|
49
|
+
attr_reader :thread_id
|
50
|
+
|
51
|
+
# Timeout for message processing (in seconds)
|
52
|
+
# @rbs return: Float
|
53
|
+
attr_reader :timeout
|
54
|
+
|
55
|
+
# Metadata for the message
|
56
|
+
# @rbs return: Hash[Symbol, untyped]
|
57
|
+
attr_reader :metadata
|
58
|
+
|
59
|
+
# Converts the message to a hash
|
60
|
+
# @rbs return: Hash[Symbol, untyped]
|
61
|
+
alias_method :to_h, :metadata
|
62
|
+
|
63
|
+
# Allows pattern matching on message attributes
|
64
|
+
# @rbs keys: Array[Symbol] -- keys to extract from the message
|
65
|
+
# @rbs return: Hash[Symbol, untyped]
|
66
|
+
def deconstruct_keys(keys)
|
67
|
+
keys.any? ? to_h.slice(*keys) : to_h
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rbs_inline: enabled
|
4
|
+
|
5
|
+
class LocalBus
|
6
|
+
# A promise-like object that wraps an Async::Barrier and a list of Subscribers.
|
7
|
+
# Delegates #wait to the barrier and all other methods to the subscriber list.
|
8
|
+
class Pledge
|
9
|
+
# Constructor
|
10
|
+
# @rbs barrier: Async::Barrier -- barrier used to wait for all tasks
|
11
|
+
# @rbs subscribers: Array[Subscriber]
|
12
|
+
def initialize(barrier, *subscribers)
|
13
|
+
@barrier = barrier
|
14
|
+
@subscribers = subscribers
|
15
|
+
end
|
16
|
+
|
17
|
+
# Blocks and waits for the barrier... all subscribers to complete
|
18
|
+
# @rbs return: void
|
19
|
+
def wait
|
20
|
+
@barrier.wait
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
# Blocks and waits then returns all subscribers
|
25
|
+
# @rbs return: Array[Subscriber]
|
26
|
+
def value
|
27
|
+
wait
|
28
|
+
@subscribers
|
29
|
+
end
|
30
|
+
|
31
|
+
## @!group Delegatation to subscribers
|
32
|
+
|
33
|
+
# def method_missing(...)
|
34
|
+
# return @subscribers.send(...) if @subscribers.respond_to?(...)
|
35
|
+
# super
|
36
|
+
# end
|
37
|
+
|
38
|
+
# def respond_to_missing?(...)
|
39
|
+
# return true if @subscribers.respond_to?(...)
|
40
|
+
# super
|
41
|
+
# end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,227 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rbs_inline: enabled
|
4
|
+
# rubocop:disable Lint/MissingCopEnableDirective
|
5
|
+
# rubocop:disable Style/ArgumentsForwarding
|
6
|
+
|
7
|
+
class LocalBus
|
8
|
+
# An in-process message queuing system that buffers and publishes messages to Bus.
|
9
|
+
# This class acts as an intermediary, queuing messages internally before publishing them to the Bus.
|
10
|
+
#
|
11
|
+
# @note Station shares the same interface as Bus and is thus a message bus.
|
12
|
+
# The key difference is that Stations are multi-threaded and will not block the main thread.
|
13
|
+
#
|
14
|
+
# Three fallback policies are supported:
|
15
|
+
# 1. `abort` - Raises an exception and discards the task when the queue is full (default)
|
16
|
+
# 2. `discard` - Discards the task when the queue is full
|
17
|
+
# 3. `caller_runs` - Executes the task on the calling thread when the queue is full,
|
18
|
+
# This effectively jumps the queue (and blocks the main thread) but ensures the task is performed
|
19
|
+
#
|
20
|
+
# IMPORTANT: Be sure to release resources like database connections in subscribers when publishing via Station.
|
21
|
+
#
|
22
|
+
class Station
|
23
|
+
include MonitorMixin
|
24
|
+
|
25
|
+
class TimeoutError < StandardError; end
|
26
|
+
|
27
|
+
# Default options for Concurrent::FixedThreadPool (can be overridden via the constructor)
|
28
|
+
# @see https://ruby-concurrency.github.io/concurrent-ruby/1.3.4/Concurrent/ThreadPoolExecutor.html
|
29
|
+
THREAD_POOL_OPTIONS = {
|
30
|
+
max_queue: 5_000, # max number of pending tasks allowed in the queue
|
31
|
+
fallback_policy: :caller_runs # Options: :abort, :discard, :caller_runs
|
32
|
+
}.freeze
|
33
|
+
|
34
|
+
# Constructor
|
35
|
+
# @rbs bus: Bus -- local message bus (default: Bus.new)
|
36
|
+
# @rbs threads: Integer -- number of threads (default: Concurrent.processor_count)
|
37
|
+
# @rbs default_timeout: Float -- seconds to wait for a future to complete
|
38
|
+
# @rbs shutdown_timeout: Float -- seconds to wait for all futures to complete on process exit
|
39
|
+
# @rbs options: Hash[Symbol, untyped] -- Concurrent::FixedThreadPool options
|
40
|
+
# @rbs return: void
|
41
|
+
def initialize(
|
42
|
+
bus: Bus.new,
|
43
|
+
threads: Concurrent.processor_count,
|
44
|
+
default_timeout: 0,
|
45
|
+
shutdown_timeout: 8,
|
46
|
+
**options
|
47
|
+
)
|
48
|
+
super()
|
49
|
+
@bus = bus
|
50
|
+
@threads = [2, threads].max.to_i
|
51
|
+
@default_timeout = default_timeout.to_f
|
52
|
+
@shutdown_timeout = shutdown_timeout.to_f
|
53
|
+
@shutdown = Concurrent::AtomicBoolean.new(false)
|
54
|
+
start(**options)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Bus instance
|
58
|
+
# @rbs return: Bus
|
59
|
+
attr_reader :bus
|
60
|
+
|
61
|
+
# Number of threads used to process messages
|
62
|
+
# @rbs return: Integer
|
63
|
+
attr_reader :threads
|
64
|
+
|
65
|
+
# Default timeout for message processing (in seconds)
|
66
|
+
# @rbs return: Float
|
67
|
+
attr_reader :default_timeout
|
68
|
+
|
69
|
+
# Timeout for graceful shutdown (in seconds)
|
70
|
+
# @rbs return: Float
|
71
|
+
attr_reader :shutdown_timeout
|
72
|
+
|
73
|
+
# Starts the broker
|
74
|
+
# @rbs options: Hash[Symbol, untyped] -- Concurrent::FixedThreadPool options
|
75
|
+
# @rbs return: void
|
76
|
+
def start(**options)
|
77
|
+
synchronize do
|
78
|
+
return if running?
|
79
|
+
|
80
|
+
start_shutdown_handler
|
81
|
+
@pool = Concurrent::FixedThreadPool.new(threads, THREAD_POOL_OPTIONS.merge(options))
|
82
|
+
enable_safe_shutdown on: ["HUP", "INT", "QUIT", "TERM"]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Stops the broker
|
87
|
+
# @rbs timeout: Float -- seconds to wait for all futures to complete
|
88
|
+
# @rbs return: void
|
89
|
+
def stop(timeout: shutdown_timeout)
|
90
|
+
return unless @shutdown.make_true # Ensure we only stop once
|
91
|
+
|
92
|
+
synchronize do
|
93
|
+
if running?
|
94
|
+
# First try graceful shutdown
|
95
|
+
pool.shutdown
|
96
|
+
|
97
|
+
# If graceful shutdown fails, force termination
|
98
|
+
pool.kill unless pool.wait_for_termination(timeout)
|
99
|
+
|
100
|
+
@pool = nil
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Clean up shutdown handler
|
105
|
+
if @shutdown_thread&.alive?
|
106
|
+
@shutdown_queue&.close
|
107
|
+
@shutdown_thread&.join timeout
|
108
|
+
end
|
109
|
+
|
110
|
+
@shutdown_thread = nil
|
111
|
+
@shutdown_queue = nil
|
112
|
+
@shutdown_completed&.set
|
113
|
+
end
|
114
|
+
|
115
|
+
# Indicates if the broker is running
|
116
|
+
# @rbs return: bool
|
117
|
+
def running?
|
118
|
+
synchronize { pool&.running? }
|
119
|
+
end
|
120
|
+
|
121
|
+
# Subscribe to a topic
|
122
|
+
# @rbs topic: String -- topic name
|
123
|
+
# @rbs callable: (Message) -> untyped -- callable that will process messages published to the topic
|
124
|
+
# @rbs &block: (Message) -> untyped -- alternative way to provide a callable
|
125
|
+
# @rbs return: self
|
126
|
+
def subscribe(topic, callable: nil, &block)
|
127
|
+
callable ||= block
|
128
|
+
bus.subscribe(topic, &callable)
|
129
|
+
self
|
130
|
+
end
|
131
|
+
|
132
|
+
# Unsubscribe from a topic
|
133
|
+
# @rbs topic: String -- topic name
|
134
|
+
# @rbs return: self
|
135
|
+
def unsubscribe(topic)
|
136
|
+
bus.unsubscribe(topic)
|
137
|
+
self
|
138
|
+
end
|
139
|
+
|
140
|
+
# Unsubscribes all subscribers from a topic and removes the topic
|
141
|
+
# @rbs topic: String -- topic name
|
142
|
+
# @rbs return: self
|
143
|
+
def unsubscribe_all(topic)
|
144
|
+
bus.unsubscribe_all topic
|
145
|
+
self
|
146
|
+
end
|
147
|
+
|
148
|
+
# Publishes a message to Bus on a separate thread keeping the main thread free for additional work.
|
149
|
+
#
|
150
|
+
# @note This allows you to publish messages when performing operations like handling web requests
|
151
|
+
# without blocking the main thread and slowing down the response.
|
152
|
+
#
|
153
|
+
# @see https://ruby-concurrency.github.io/concurrent-ruby/1.3.4/Concurrent/Promises/Future.html
|
154
|
+
#
|
155
|
+
# @rbs topic: String | Symbol -- topic name
|
156
|
+
# @rbs timeout: Float -- seconds to wait before cancelling
|
157
|
+
# @rbs payload: Hash[Symbol, untyped] -- message payload
|
158
|
+
# @rbs return: Concurrent::Promises::Future
|
159
|
+
def publish(topic, timeout: default_timeout, **payload)
|
160
|
+
timeout = timeout.to_f
|
161
|
+
|
162
|
+
future = Concurrent::Promises.future_on(pool) do
|
163
|
+
case timeout
|
164
|
+
in 0 then bus.publish(topic, **payload).value
|
165
|
+
else bus.publish(topic, timeout: timeout, **payload).value
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# ensure calls to future.then use the thread pool
|
170
|
+
executor = pool
|
171
|
+
future.singleton_class.define_method :then do |&block|
|
172
|
+
future.then_on(executor, &block)
|
173
|
+
end
|
174
|
+
|
175
|
+
future
|
176
|
+
end
|
177
|
+
|
178
|
+
private
|
179
|
+
|
180
|
+
# Thread pool used for asynchronous operations
|
181
|
+
# @rbs return: Concurrent::FixedThreadPool
|
182
|
+
attr_reader :pool
|
183
|
+
|
184
|
+
# Starts the shutdown handler thread
|
185
|
+
# @rbs return: void
|
186
|
+
def start_shutdown_handler
|
187
|
+
return if @shutdown.true?
|
188
|
+
|
189
|
+
@shutdown_queue = Queue.new
|
190
|
+
@shutdown_completed = Concurrent::Event.new
|
191
|
+
@shutdown_thread = Thread.new do
|
192
|
+
catch :shutdown do
|
193
|
+
loop do
|
194
|
+
signal = @shutdown_queue.pop # blocks until something is available
|
195
|
+
throw :shutdown if @shutdown_queue.closed?
|
196
|
+
|
197
|
+
stop # initiate shutdown sequence
|
198
|
+
|
199
|
+
# Re-raise the signal to let the process terminate
|
200
|
+
if signal
|
201
|
+
# Remove our trap handler before re-raising
|
202
|
+
trap signal, "DEFAULT"
|
203
|
+
Process.kill signal, Process.pid
|
204
|
+
end
|
205
|
+
rescue ThreadError, ClosedQueueError
|
206
|
+
break # queue was closed, exit gracefully
|
207
|
+
end
|
208
|
+
end
|
209
|
+
@shutdown_completed.set
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Enables safe shutdown on process exit by trapping specified signals
|
214
|
+
# @rbs on: Array[String] -- signals to trap
|
215
|
+
# @rbs return: void
|
216
|
+
def enable_safe_shutdown(on:)
|
217
|
+
on.each do |signal|
|
218
|
+
trap signal do
|
219
|
+
# Only queue the signal if we haven't started shutdown
|
220
|
+
@shutdown_queue.push signal unless @shutdown.true?
|
221
|
+
rescue
|
222
|
+
nil
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rbs_inline: enabled
|
4
|
+
|
5
|
+
class LocalBus
|
6
|
+
# Wraps a Callable (Proc) and Message intended for asynchronous execution.
|
7
|
+
class Subscriber
|
8
|
+
# Custom error class for Subscriber errors
|
9
|
+
class Error < StandardError
|
10
|
+
# Constructor
|
11
|
+
# @rbs message: String -- error message
|
12
|
+
# @rbs cause: StandardError? -- underlying cause of the error
|
13
|
+
def initialize(message, cause:)
|
14
|
+
super(message)
|
15
|
+
@cause = cause
|
16
|
+
end
|
17
|
+
|
18
|
+
# Underlying cause of the error
|
19
|
+
# @rbs return: StandardError?
|
20
|
+
attr_reader :cause
|
21
|
+
end
|
22
|
+
|
23
|
+
# Constructor
|
24
|
+
# @rbs callable: #call -- the subscriber's callable object
|
25
|
+
# @rbs message: Message -- the message to be processed
|
26
|
+
def initialize(callable, message)
|
27
|
+
@callable = callable
|
28
|
+
@message = message
|
29
|
+
@id = callable.object_id
|
30
|
+
@source_location = callable.source_location
|
31
|
+
@metadata = {}
|
32
|
+
end
|
33
|
+
|
34
|
+
# Unique identifier for the subscriber
|
35
|
+
# @rbs return: String
|
36
|
+
attr_reader :id
|
37
|
+
|
38
|
+
# Source location of the callable
|
39
|
+
# @rbs return: Array[String, Integer]?
|
40
|
+
attr_reader :source_location
|
41
|
+
|
42
|
+
# Callable object -- Proc, lambda, etc. (must respond to #call)
|
43
|
+
# @rbs return: #call
|
44
|
+
attr_reader :callable
|
45
|
+
|
46
|
+
# Error if the subscriber fails (available after performing)
|
47
|
+
# @rbs return: Error?
|
48
|
+
attr_reader :error
|
49
|
+
|
50
|
+
# Message for the subscriber to process
|
51
|
+
# @rbs return: Message
|
52
|
+
attr_reader :message
|
53
|
+
|
54
|
+
# Metadata for the subscriber (available after performing)
|
55
|
+
# @rbs return: Hash[Symbol, untyped]
|
56
|
+
attr_reader :metadata
|
57
|
+
|
58
|
+
# Value returned by the callable (available after performing)
|
59
|
+
# @rbs return: untyped
|
60
|
+
attr_reader :value
|
61
|
+
|
62
|
+
# Indicates if the subscriber has been performed
|
63
|
+
# @rbs return: bool
|
64
|
+
def performed?
|
65
|
+
metadata.any?
|
66
|
+
end
|
67
|
+
|
68
|
+
# Checks if the subscriber is pending
|
69
|
+
# @rbs return: bool
|
70
|
+
def pending?
|
71
|
+
metadata.empty?
|
72
|
+
end
|
73
|
+
|
74
|
+
# Performs the subscriber's callable
|
75
|
+
# @rbs return: void
|
76
|
+
def perform
|
77
|
+
return if performed?
|
78
|
+
|
79
|
+
with_metadata do
|
80
|
+
@value = callable.call(message)
|
81
|
+
rescue => cause
|
82
|
+
@error = Error.new("Invocation failed! #{cause.message}", cause: cause)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Handles timeout for the subscriber
|
87
|
+
# @rbs cause: StandardError -- the cause of the timeout
|
88
|
+
# @rbs return: void
|
89
|
+
def timeout(cause)
|
90
|
+
return if performed?
|
91
|
+
|
92
|
+
with_metadata do
|
93
|
+
@error = Error.new("Timeout expired before invocation! Waited #{message.timeout} seconds!", cause: cause)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Returns the subscriber's data as a hash
|
98
|
+
# @rbs return: Hash[Symbol, untyped]
|
99
|
+
def to_h
|
100
|
+
{
|
101
|
+
error: error,
|
102
|
+
metadata: metadata,
|
103
|
+
value: value
|
104
|
+
}
|
105
|
+
end
|
106
|
+
|
107
|
+
# Allows pattern matching on subscriber attributes
|
108
|
+
# @rbs keys: Array[Symbol] -- keys to extract from the subscriber
|
109
|
+
# @rbs return: Hash[Symbol, untyped]
|
110
|
+
def deconstruct_keys(keys)
|
111
|
+
keys.any? ? to_h.slice(*keys) : to_h
|
112
|
+
end
|
113
|
+
|
114
|
+
private
|
115
|
+
|
116
|
+
# Captures metadata for the subscriber's performance
|
117
|
+
# @rbs return: void
|
118
|
+
def with_metadata
|
119
|
+
started_at = Time.now
|
120
|
+
yield
|
121
|
+
@metadata = {
|
122
|
+
id: SecureRandom.uuid_v7,
|
123
|
+
thread_id: Thread.current.object_id,
|
124
|
+
source_location: source_location,
|
125
|
+
started_at: started_at,
|
126
|
+
finished_at: Time.now,
|
127
|
+
duration: Time.now - started_at,
|
128
|
+
latency: Time.now - message.created_at,
|
129
|
+
message: message
|
130
|
+
}.freeze
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
data/lib/local_bus.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rbs_inline: enabled
|
4
|
+
|
5
|
+
require "zeitwerk"
|
6
|
+
loader = Zeitwerk::Loader.for_gem
|
7
|
+
loader.setup
|
8
|
+
|
9
|
+
require "async"
|
10
|
+
require "async/barrier"
|
11
|
+
require "async/semaphore"
|
12
|
+
require "concurrent-ruby"
|
13
|
+
require "monitor"
|
14
|
+
require "securerandom"
|
15
|
+
require "singleton"
|
16
|
+
|
17
|
+
class LocalBus
|
18
|
+
include Singleton
|
19
|
+
|
20
|
+
attr_reader :bus
|
21
|
+
attr_reader :station
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@bus = Bus.new
|
27
|
+
@station = Station.new(bus: bus)
|
28
|
+
end
|
29
|
+
end
|
metadata
ADDED
@@ -0,0 +1,268 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: local_bus
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nate Hopkins (hopsoft)
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-11-04 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: async
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: concurrent-ruby
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: zeitwerk
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: amazing_print
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: fiddle
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: minitest
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: minitest-reporters
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: ostruct
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: pry-byebug
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: pry-doc
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
153
|
+
- !ruby/object:Gem::Dependency
|
154
|
+
name: rake
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
160
|
+
type: :development
|
161
|
+
prerelease: false
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
163
|
+
requirements:
|
164
|
+
- - ">="
|
165
|
+
- !ruby/object:Gem::Version
|
166
|
+
version: '0'
|
167
|
+
- !ruby/object:Gem::Dependency
|
168
|
+
name: rbs-inline
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
170
|
+
requirements:
|
171
|
+
- - ">="
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: '0'
|
174
|
+
type: :development
|
175
|
+
prerelease: false
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ">="
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '0'
|
181
|
+
- !ruby/object:Gem::Dependency
|
182
|
+
name: standard
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
184
|
+
requirements:
|
185
|
+
- - ">="
|
186
|
+
- !ruby/object:Gem::Version
|
187
|
+
version: '0'
|
188
|
+
type: :development
|
189
|
+
prerelease: false
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
191
|
+
requirements:
|
192
|
+
- - ">="
|
193
|
+
- !ruby/object:Gem::Version
|
194
|
+
version: '0'
|
195
|
+
- !ruby/object:Gem::Dependency
|
196
|
+
name: tocer
|
197
|
+
requirement: !ruby/object:Gem::Requirement
|
198
|
+
requirements:
|
199
|
+
- - ">="
|
200
|
+
- !ruby/object:Gem::Version
|
201
|
+
version: '0'
|
202
|
+
type: :development
|
203
|
+
prerelease: false
|
204
|
+
version_requirements: !ruby/object:Gem::Requirement
|
205
|
+
requirements:
|
206
|
+
- - ">="
|
207
|
+
- !ruby/object:Gem::Version
|
208
|
+
version: '0'
|
209
|
+
- !ruby/object:Gem::Dependency
|
210
|
+
name: yard
|
211
|
+
requirement: !ruby/object:Gem::Requirement
|
212
|
+
requirements:
|
213
|
+
- - ">="
|
214
|
+
- !ruby/object:Gem::Version
|
215
|
+
version: '0'
|
216
|
+
type: :development
|
217
|
+
prerelease: false
|
218
|
+
version_requirements: !ruby/object:Gem::Requirement
|
219
|
+
requirements:
|
220
|
+
- - ">="
|
221
|
+
- !ruby/object:Gem::Version
|
222
|
+
version: '0'
|
223
|
+
description: |
|
224
|
+
LocalBus is a lightweight yet powerful pub/sub system for Ruby applications that enables decoupled communication within a single process.
|
225
|
+
It offers both non-blocking I/O and thread pool processing modes, robust error handling, and fine-grained concurrency controls.
|
226
|
+
Perfect for organizing event-driven architectures, handling background jobs, and managing complex workflows without external dependencies."
|
227
|
+
email:
|
228
|
+
- natehop@gmail.com
|
229
|
+
executables: []
|
230
|
+
extensions: []
|
231
|
+
extra_rdoc_files: []
|
232
|
+
files:
|
233
|
+
- README.md
|
234
|
+
- lib/local_bus.rb
|
235
|
+
- lib/local_bus/bus.rb
|
236
|
+
- lib/local_bus/message.rb
|
237
|
+
- lib/local_bus/pledge.rb
|
238
|
+
- lib/local_bus/station.rb
|
239
|
+
- lib/local_bus/subscriber.rb
|
240
|
+
- lib/local_bus/version.rb
|
241
|
+
homepage: https://github.com/hopsoft/local_bus
|
242
|
+
licenses:
|
243
|
+
- MIT
|
244
|
+
metadata:
|
245
|
+
homepage_uri: https://github.com/hopsoft/local_bus
|
246
|
+
source_code_uri: https://github.com/hopsoft/local_bus
|
247
|
+
changelog_uri: https://github.com/hopsoft/local_bus/blob/main/CHANGELOG.md
|
248
|
+
post_install_message:
|
249
|
+
rdoc_options: []
|
250
|
+
require_paths:
|
251
|
+
- lib
|
252
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
253
|
+
requirements:
|
254
|
+
- - ">="
|
255
|
+
- !ruby/object:Gem::Version
|
256
|
+
version: '3.0'
|
257
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
258
|
+
requirements:
|
259
|
+
- - ">="
|
260
|
+
- !ruby/object:Gem::Version
|
261
|
+
version: '0'
|
262
|
+
requirements: []
|
263
|
+
rubygems_version: 3.5.21
|
264
|
+
signing_key:
|
265
|
+
specification_version: 4
|
266
|
+
summary: A lightweight pub/sub system for decoupled intra-process communication in
|
267
|
+
Ruby applications
|
268
|
+
test_files: []
|