local_bus 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ class LocalBus
6
+ VERSION = "0.1.0"
7
+ 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: []