omq 0.15.5 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3c8e182cc758212708988bb4c88a7d374c63f06053e3be829c3a98620e23450
4
- data.tar.gz: da1bdea2c47ec1c0a8e5e15d0b4ea69c9af642cd7fc67eae4b0931209314eac3
3
+ metadata.gz: f74ea96d0fc94d40c2d5ac0dd815e112a8fc8af8f74d5b932799fb19981c58b5
4
+ data.tar.gz: f79ddfe0a3519f7011353c03eeb18f054e9c5384e3e67250db699179c86be96e
5
5
  SHA512:
6
- metadata.gz: 6d56451299b8e3cfc500ea145826d8e1bc251edf814dc0e4fea6846428ff546bd3f7f66005e4c41b55d9b4b54c6e536f62619de6086311cdd538a16080e2802e
7
- data.tar.gz: 1f5b9061e5eae37357c4d6e1dc828bac2778f68ba6a10072faecc4d049b9517e2044b66bc4771b1a16dc38717583e7689cb74b9e653b0df15171e0723bd1150a
6
+ metadata.gz: 6ef57ca7cfd08b2add9adc5dc501a1a587b29ddb534ca55b20d1bf8cebf3571538c79cfc7ed1e1d2cddf93b69993b9f674c951cf37d32be54c93550465f00dca
7
+ data.tar.gz: 5d847021b68bed1f6b93d128afdc455c2354c8dc11febf8a711624c5559d4d03786a041db2b0d797b4518a789971029f9db8b6ad94becf44ae807a14ae396cf2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.1 — 2026-04-09
4
+
5
+ ### Changed
6
+
7
+ - **Depend on `protocol-zmtp ~> 0.4`.** Picks up the batched
8
+ `Connection#write_messages` used by the work-stealing send pumps and
9
+ the zero-alloc frame-header path on the unencrypted hot send path.
10
+
11
+ ### Fixed
12
+
13
+ - **PUB/XPUB/RADIO fan-out now honors `on_mute`.** Per-subscriber send queues
14
+ were hardcoded to `:block`, so a slow subscriber would back-pressure the
15
+ publisher despite PUB/XPUB/RADIO defaulting to `on_mute: :drop_newest`.
16
+ Fan-out now builds each subscriber's queue with the socket's `on_mute`
17
+ strategy — slow subscribers silently drop their own messages without
18
+ stalling the publisher or other subscribers.
19
+
20
+ ## 0.16.0 — 2026-04-09
21
+
22
+ ### Changed
23
+
24
+ - **Consolidate connection lifecycle into `Engine::ConnectionLifecycle`.** One
25
+ object per connection owns the full arc: handshake → ready → closed. Replaces
26
+ the scattered callback pattern where `Engine`, `ConnectionSetup`, and
27
+ `#close_connections_at` each held partial responsibility for registration,
28
+ monitor emission, routing add/remove, and reconnect scheduling. Side-effect
29
+ order (`:handshake_succeeded` before `connection_added`, `connection_removed`
30
+ before `:disconnected`) is now encoded as sequential statements in two
31
+ methods instead of implicit across multiple files. Teardown is idempotent via
32
+ an explicit 4-state transition table — racing pumps can no longer
33
+ double-fire `:disconnected` or double-call `routing.connection_removed`.
34
+ `ConnectionSetup` is absorbed and removed. `ConnectionRecord` collapses away
35
+ — `@connections` now stores lifecycles directly.
36
+
37
+ - **Consolidate socket-level state into `Engine::SocketLifecycle`.** Six ivars
38
+ (`@state`, `@peer_connected`, `@all_peers_gone`, `@reconnect_enabled`,
39
+ `@parent_task`, `@on_io_thread`) move into one cohesive object with an
40
+ explicit 4-state transition table (`:new → :open → :closing → :closed`).
41
+ `Engine#closed?`, `#peer_connected`, `#all_peers_gone`, `#parent_task`
42
+ remain as delegators — public API unchanged. Parallels
43
+ `ConnectionLifecycle` in naming and shape. Pure refactor, no behavior change.
44
+
45
+ - **Revert to per-socket HWM with work-stealing send pumps.** One shared
46
+ bounded send queue per socket, drained by N per-connection send pumps
47
+ that race to dequeue. Slow peers' pumps simply stop pulling; fast peers
48
+ absorb the load. Strictly better PUSH semantics than libzmq's strict
49
+ per-pipe round-robin (a known footgun where one slow worker stalls the
50
+ whole pipeline). Removes `StagingQueue`, per-connection queue maps, the
51
+ double-drain race in `add_*`, the disconnect-prepend ordering pretense,
52
+ and the `@cycle` / next-connection machinery. See `DESIGN.md`
53
+ "Per-socket HWM (not per-connection)" for full reasoning.
54
+ - **`RoundRobin` batch cap is now dual: 256 messages OR 512 KB**, whichever
55
+ hits first (previously 64 messages). The old cap was too aggressive for
56
+ large messages — with 64 KB payloads it forced a flush every ~4 MB,
57
+ capping multi-peer push_pull throughput at ~50 % of what the network
58
+ could handle. Dual cap lets large-message workloads batch ~8 messages
59
+ per cycle while small-message workloads still yield quickly enough to
60
+ keep other work-stealing pumps fair. push_pull +5–40 % across transports
61
+ and sizes; router_dealer +5–15 %.
62
+ - **Send pumps batched under a single mutex.** RoundRobin, ConnSendPump
63
+ and Pair now drain batches through
64
+ `Protocol::ZMTP::Connection#write_messages`, collapsing N lock
65
+ acquire/release pairs into one per batch. The size==1 path still uses
66
+ `send_message` (write+flush in one lock) to avoid an extra round-trip
67
+ at low throughput. push_pull inproc +18–28 %, tcp/ipc flat to +17 %.
68
+
69
+ ### Fixed
70
+
71
+ - **`disconnect(endpoint)` now emits `:disconnected`** on the monitor queue.
72
+ Previously silent because `close_connections_at` bypassed `connection_lost`.
73
+ - **PUSH/PULL round-robin test.** Previously asserted strict 1-msg-per-peer
74
+ distribution — a libzmq-ism OMQ never promised — and was silently
75
+ "passing" with 0 assertions and a 10 s Async-block timeout that masked a
76
+ hang. New test verifies both peers receive nonzero load over TCP.
77
+
78
+ ### Benchmarks
79
+
80
+ - Report throughput in bytes/s alongside msgs/s.
81
+ - Regenerated `bench/README.md` PUSH/PULL and REQ/REP tables: push_pull
82
+ throughput up 5–40 %, req_rep round-trip latency down 5–15 %.
83
+
3
84
  ## 0.15.5 — 2026-04-08
4
85
 
5
86
  - **`max_message_size` now defaults to `nil` (unlimited)** — previous
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class Engine
5
+ # Owns the full arc of one connection: handshake → ready → closed.
6
+ #
7
+ # Centralizes the ordering of side effects (monitor events, routing
8
+ # registration, promise resolution, reconnect scheduling) so the
9
+ # sequence lives in one place instead of being scattered across
10
+ # Engine, ConnectionSetup, and close paths.
11
+ #
12
+ # State machine:
13
+ #
14
+ # new ──┬── :handshaking ── :ready ── :closed
15
+ # └── :ready ── :closed (inproc fast path)
16
+ #
17
+ # #lost! and #close! are idempotent — the state guard ensures side
18
+ # effects run exactly once even if multiple pumps race to report a
19
+ # lost connection.
20
+ #
21
+ class ConnectionLifecycle
22
+ class InvalidTransition < RuntimeError; end
23
+
24
+ STATES = %i[new handshaking ready closed].freeze
25
+
26
+ TRANSITIONS = {
27
+ new: %i[handshaking ready closed].freeze,
28
+ handshaking: %i[ready closed].freeze,
29
+ ready: %i[closed].freeze,
30
+ closed: [].freeze,
31
+ }.freeze
32
+
33
+
34
+ # @return [Protocol::ZMTP::Connection, Transport::Inproc::DirectPipe, nil]
35
+ attr_reader :conn
36
+
37
+ # @return [String, nil]
38
+ attr_reader :endpoint
39
+
40
+ # @return [Symbol] current state
41
+ attr_reader :state
42
+
43
+
44
+ # @param engine [Engine]
45
+ # @param endpoint [String, nil]
46
+ # @param done [Async::Promise, nil] resolved when connection is lost
47
+ #
48
+ def initialize(engine, endpoint: nil, done: nil)
49
+ @engine = engine
50
+ @endpoint = endpoint
51
+ @done = done
52
+ @state = :new
53
+ @conn = nil
54
+ end
55
+
56
+
57
+ # Performs the ZMTP handshake and transitions to :ready.
58
+ #
59
+ # @param io [#read, #write, #close]
60
+ # @param as_server [Boolean]
61
+ # @return [Protocol::ZMTP::Connection]
62
+ #
63
+ def handshake!(io, as_server:)
64
+ transition!(:handshaking)
65
+ conn = Protocol::ZMTP::Connection.new(
66
+ io,
67
+ socket_type: @engine.socket_type.to_s,
68
+ identity: @engine.options.identity,
69
+ as_server: as_server,
70
+ mechanism: @engine.options.mechanism&.dup,
71
+ max_message_size: @engine.options.max_message_size,
72
+ )
73
+ conn.handshake!
74
+ Heartbeat.start(Async::Task.current, conn, @engine.options, @engine.tasks)
75
+ ready!(conn)
76
+ @conn
77
+ rescue Protocol::ZMTP::Error, *CONNECTION_LOST => error
78
+ @engine.emit_monitor_event(:handshake_failed, endpoint: @endpoint, detail: { error: error })
79
+ conn&.close
80
+ transition!(:closed)
81
+ raise
82
+ end
83
+
84
+
85
+ # Registers an already-connected inproc pipe as :ready.
86
+ # No handshake — inproc DirectPipe bypasses ZMTP entirely.
87
+ #
88
+ # @param pipe [Transport::Inproc::DirectPipe]
89
+ #
90
+ def ready_direct!(pipe)
91
+ ready!(pipe)
92
+ end
93
+
94
+
95
+ # Transitions to :closed, running the full loss sequence:
96
+ # routing removal, monitor event, reconnect scheduling.
97
+ # Idempotent: a no-op if already :closed.
98
+ #
99
+ def lost!
100
+ tear_down!(reconnect: true)
101
+ end
102
+
103
+
104
+ # Transitions to :closed without scheduling a reconnect.
105
+ # Used by shutdown paths (Engine#close, #disconnect, #unbind).
106
+ # Idempotent.
107
+ #
108
+ def close!
109
+ tear_down!(reconnect: false)
110
+ end
111
+
112
+
113
+ private
114
+
115
+ def ready!(conn)
116
+ conn = @engine.connection_wrapper.call(conn) if @engine.connection_wrapper
117
+ @conn = conn
118
+ @engine.connections[@conn] = self
119
+ @engine.emit_monitor_event(:handshake_succeeded, endpoint: @endpoint)
120
+ @engine.routing.connection_added(@conn)
121
+ @engine.peer_connected.resolve(@conn)
122
+ transition!(:ready)
123
+ end
124
+
125
+
126
+ def tear_down!(reconnect:)
127
+ return if @state == :closed
128
+ transition!(:closed)
129
+ @engine.connections.delete(@conn)
130
+ @engine.routing.connection_removed(@conn) if @conn
131
+ @conn&.close rescue nil
132
+ @engine.emit_monitor_event(:disconnected, endpoint: @endpoint)
133
+ @done&.resolve(true)
134
+ @engine.resolve_all_peers_gone_if_empty
135
+ @engine.maybe_reconnect(@endpoint) if reconnect
136
+ end
137
+
138
+
139
+ def transition!(new_state)
140
+ allowed = TRANSITIONS[@state]
141
+ unless allowed&.include?(new_state)
142
+ raise InvalidTransition, "#{@state} → #{new_state}"
143
+ end
144
+ @state = new_state
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class Engine
5
+ # Owns the socket-level state: `:new → :open → :closing → :closed`,
6
+ # the first-peer / last-peer signaling promises, the reconnect flag,
7
+ # and the captured parent task for the socket's task tree.
8
+ #
9
+ # Engine delegates state queries here and uses it to coordinate the
10
+ # ordering of close-time side effects. This consolidates six ivars
11
+ # (`@state`, `@peer_connected`, `@all_peers_gone`, `@reconnect_enabled`,
12
+ # `@parent_task`, `@on_io_thread`) into one cohesive object with
13
+ # explicit transitions.
14
+ #
15
+ class SocketLifecycle
16
+ class InvalidTransition < RuntimeError; end
17
+
18
+ STATES = %i[new open closing closed].freeze
19
+
20
+ TRANSITIONS = {
21
+ new: %i[open closed].freeze,
22
+ open: %i[closing closed].freeze,
23
+ closing: %i[closed].freeze,
24
+ closed: [].freeze,
25
+ }.freeze
26
+
27
+
28
+ # @return [Symbol]
29
+ attr_reader :state
30
+
31
+ # @return [Async::Promise] resolves with the first connected peer
32
+ attr_reader :peer_connected
33
+
34
+ # @return [Async::Promise] resolves once all peers are gone (after having had peers)
35
+ attr_reader :all_peers_gone
36
+
37
+ # @return [Async::Task, nil] root of the socket's task tree
38
+ attr_reader :parent_task
39
+
40
+ # @return [Boolean] true if parent_task is the shared Reactor thread
41
+ attr_reader :on_io_thread
42
+
43
+ # @return [Boolean] whether auto-reconnect is enabled
44
+ attr_accessor :reconnect_enabled
45
+
46
+
47
+ def initialize
48
+ @state = :new
49
+ @peer_connected = Async::Promise.new
50
+ @all_peers_gone = Async::Promise.new
51
+ @reconnect_enabled = true
52
+ @parent_task = nil
53
+ @on_io_thread = false
54
+ end
55
+
56
+
57
+ def open? = @state == :open
58
+ def closing? = @state == :closing
59
+ def closed? = @state == :closed
60
+ def alive? = @state == :new || @state == :open
61
+
62
+
63
+ # Captures the current Async task (or the shared Reactor root) as
64
+ # this socket's task tree root. Transitions `:new → :open`.
65
+ #
66
+ # @param linger [Numeric, nil] used to register the Reactor linger slot
67
+ # when falling back to the IO thread
68
+ # @return [Boolean] true on first-time capture, false if already captured
69
+ #
70
+ def capture_parent_task(linger:)
71
+ return false if @parent_task
72
+ if Async::Task.current?
73
+ @parent_task = Async::Task.current
74
+ else
75
+ @parent_task = Reactor.root_task
76
+ @on_io_thread = true
77
+ Reactor.track_linger(linger)
78
+ end
79
+ transition!(:open)
80
+ true
81
+ end
82
+
83
+
84
+ # Transitions `:open → :closing`.
85
+ def start_closing!
86
+ transition!(:closing)
87
+ end
88
+
89
+
90
+ # Transitions `:closing → :closed` (or `:new → :closed` for
91
+ # never-opened sockets).
92
+ def finish_closing!
93
+ transition!(:closed)
94
+ end
95
+
96
+
97
+ # Resolves `all_peers_gone` if we had peers and now have none.
98
+ # @param connections [Hash] current connection map
99
+ def resolve_all_peers_gone_if_empty(connections)
100
+ return unless @peer_connected.resolved? && connections.empty?
101
+ @all_peers_gone.resolve(true)
102
+ end
103
+
104
+
105
+ private
106
+
107
+ def transition!(new_state)
108
+ allowed = TRANSITIONS[@state]
109
+ unless allowed&.include?(new_state)
110
+ raise InvalidTransition, "#{@state} → #{new_state}"
111
+ end
112
+ @state = new_state
113
+ end
114
+ end
115
+ end
116
+ end
data/lib/omq/engine.rb CHANGED
@@ -4,7 +4,8 @@ require "async"
4
4
  require_relative "engine/recv_pump"
5
5
  require_relative "engine/heartbeat"
6
6
  require_relative "engine/reconnect"
7
- require_relative "engine/connection_setup"
7
+ require_relative "engine/connection_lifecycle"
8
+ require_relative "engine/socket_lifecycle"
8
9
  require_relative "engine/maintenance"
9
10
 
10
11
  module OMQ
@@ -25,13 +26,6 @@ module OMQ
25
26
  end
26
27
 
27
28
 
28
- # Per-connection metadata: the endpoint it was established on and an
29
- # optional Promise resolved when the connection is lost (used by
30
- # {#spawn_connection} to await connection teardown).
31
- #
32
- ConnectionRecord = Data.define(:endpoint, :done)
33
-
34
-
35
29
  # @return [Symbol] socket type (e.g. :REQ, :PAIR)
36
30
  #
37
31
  attr_reader :socket_type
@@ -63,46 +57,43 @@ module OMQ
63
57
  # @param options [Options]
64
58
  #
65
59
  def initialize(socket_type, options)
66
- @socket_type = socket_type
67
- @options = options
68
- @routing = nil
69
- @connections = {} # connection => ConnectionRecord
70
- @dialed = Set.new # endpoints we called connect() on (reconnect intent)
71
- @listeners = []
72
- @tasks = []
73
- @state = :open
74
- @last_endpoint = nil
75
- @last_tcp_port = nil
76
- @peer_connected = Async::Promise.new
77
- @all_peers_gone = Async::Promise.new
78
- @reconnect_enabled = true
79
- @parent_task = nil
80
- @on_io_thread = false
81
- @fatal_error = nil
82
- @monitor_queue = nil
83
- @verbose_monitor = false
84
- end
85
-
86
-
87
- # @return [Async::Promise] resolves when first peer completes handshake
88
- # @return [Async::Promise] resolves when all peers disconnect (after having had peers)
89
- # @return [Hash{Connection => ConnectionRecord}] active connections
90
- # @return [Async::Task, nil] root task for spawning subtrees
60
+ @socket_type = socket_type
61
+ @options = options
62
+ @routing = nil
63
+ @connections = {} # connection => ConnectionLifecycle
64
+ @dialed = Set.new # endpoints we called connect() on (reconnect intent)
65
+ @listeners = []
66
+ @tasks = []
67
+ @lifecycle = SocketLifecycle.new
68
+ @last_endpoint = nil
69
+ @last_tcp_port = nil
70
+ @fatal_error = nil
71
+ @monitor_queue = nil
72
+ @verbose_monitor = false
73
+ end
74
+
75
+
76
+ # @return [Hash{Connection => ConnectionLifecycle}] active connections
91
77
  # @return [Array<Async::Task>] background tasks (pumps, heartbeat, reconnect)
78
+ # @return [SocketLifecycle] socket-level state + signaling
92
79
  #
93
- attr_reader :peer_connected, :all_peers_gone, :connections, :parent_task, :tasks
80
+ attr_reader :connections, :tasks, :lifecycle
94
81
 
95
- # @!attribute [w] reconnect_enabled
96
- # @param value [Boolean] enable or disable auto-reconnect
97
82
  # @!attribute [w] monitor_queue
98
83
  # @param value [Async::Queue, nil] queue for monitor events
99
84
  #
100
- attr_writer :reconnect_enabled, :monitor_queue
85
+ attr_writer :monitor_queue
101
86
  attr_accessor :verbose_monitor
102
87
 
103
- # @return [Boolean] true if the engine has been closed
104
- #
105
- def closed? = @state == :closed
88
+
89
+ # Delegated to {SocketLifecycle}.
90
+ def peer_connected = @lifecycle.peer_connected
91
+ def all_peers_gone = @lifecycle.all_peers_gone
92
+ def parent_task = @lifecycle.parent_task
93
+ def closed? = @lifecycle.closed?
94
+ def reconnect_enabled=(value)
95
+ @lifecycle.reconnect_enabled = value
96
+ end
106
97
 
107
98
  # Optional proc that wraps new connections (e.g. for serialization).
108
99
  # Called with the raw connection; must return the (possibly wrapped) connection.
@@ -110,7 +101,7 @@ module OMQ
110
101
  attr_accessor :connection_wrapper
111
102
 
112
103
 
113
- # Spawns an inproc reconnect retry task under @parent_task.
104
+ # Spawns an inproc reconnect retry task under the socket's parent task.
114
105
  #
115
106
  # @param endpoint [String]
116
107
  # @yield [interval] the retry loop body
@@ -118,7 +109,7 @@ module OMQ
118
109
  def spawn_inproc_retry(endpoint)
119
110
  ri = @options.reconnect_interval
120
111
  ivl = ri.is_a?(Range) ? ri.begin : ri
121
- @tasks << @parent_task.async(transient: true, annotation: "inproc reconnect #{endpoint}") do
112
+ @tasks << @lifecycle.parent_task.async(transient: true, annotation: "inproc reconnect #{endpoint}") do
122
113
  yield ivl
123
114
  rescue Async::Stop
124
115
  end
@@ -224,11 +215,7 @@ module OMQ
224
215
  # @return [void]
225
216
  #
226
217
  def connection_ready(pipe, endpoint: nil)
227
- pipe = @connection_wrapper.call(pipe) if @connection_wrapper
228
- @connections[pipe] = ConnectionRecord.new(endpoint: endpoint, done: nil)
229
- emit_monitor_event(:handshake_succeeded, endpoint: endpoint)
230
- routing.connection_added(pipe)
231
- @peer_connected.resolve(pipe)
218
+ ConnectionLifecycle.new(self, endpoint: endpoint).ready_direct!(pipe)
232
219
  end
233
220
 
234
221
 
@@ -309,13 +296,25 @@ module OMQ
309
296
  # @return [void]
310
297
  #
311
298
  def connection_lost(connection)
312
- entry = @connections.delete(connection)
313
- routing.connection_removed(connection)
314
- connection.close
315
- emit_monitor_event(:disconnected, endpoint: entry&.endpoint)
316
- entry&.done&.resolve(true)
317
- @all_peers_gone.resolve(true) if @peer_connected.resolved? && @connections.empty?
318
- maybe_reconnect(entry&.endpoint)
299
+ @connections[connection]&.lost!
300
+ end
301
+
302
+
303
+ # Resolves `all_peers_gone` if we had peers and now have none.
304
+ # Called by ConnectionLifecycle during teardown.
305
+ #
306
+ def resolve_all_peers_gone_if_empty
307
+ @lifecycle.resolve_all_peers_gone_if_empty(@connections)
308
+ end
309
+
310
+
311
+ # Schedules a reconnect for +endpoint+ if auto-reconnect is enabled
312
+ # and the endpoint is still dialed.
313
+ #
314
+ def maybe_reconnect(endpoint)
315
+ return unless endpoint && @dialed.include?(endpoint)
316
+ return unless @lifecycle.open? && @lifecycle.reconnect_enabled
317
+ Reconnect.schedule(endpoint, @options, @lifecycle.parent_task, self)
319
318
  end
320
319
 
321
320
 
@@ -324,12 +323,12 @@ module OMQ
324
323
  # @return [void]
325
324
  #
326
325
  def close
327
- return unless @state == :open
328
- @state = :closing
326
+ return unless @lifecycle.open?
327
+ @lifecycle.start_closing!
329
328
  stop_listeners unless @connections.empty?
330
329
  drain_send_queues(@options.linger) if @options.linger.nil? || @options.linger > 0
331
- @state = :closed
332
- Reactor.untrack_linger(@options.linger) if @on_io_thread
330
+ @lifecycle.finish_closing!
331
+ Reactor.untrack_linger(@options.linger) if @lifecycle.on_io_thread
333
332
  stop_listeners
334
333
  close_connections
335
334
  stop_tasks
@@ -368,14 +367,14 @@ module OMQ
368
367
  # @param error [Exception]
369
368
  #
370
369
  def signal_fatal_error(error)
371
- return unless @state == :open
370
+ return unless @lifecycle.open?
372
371
  @fatal_error = begin
373
372
  raise OMQ::SocketDeadError, "internal error killed #{@socket_type} socket"
374
373
  rescue => wrapped
375
374
  wrapped
376
375
  end
377
376
  routing.recv_queue.push(nil) rescue nil
378
- @peer_connected.resolve(nil) rescue nil
377
+ @lifecycle.peer_connected.resolve(nil) rescue nil
379
378
  end
380
379
 
381
380
 
@@ -385,15 +384,8 @@ module OMQ
385
384
  # callers get the IO thread's root task, not an ephemeral work task.
386
385
  #
387
386
  def capture_parent_task
388
- return if @parent_task
389
- if Async::Task.current?
390
- @parent_task = Async::Task.current
391
- else
392
- @parent_task = Reactor.root_task
393
- @on_io_thread = true
394
- Reactor.track_linger(@options.linger)
395
- end
396
- Maintenance.start(@parent_task, @options.mechanism, @tasks)
387
+ return unless @lifecycle.capture_parent_task(linger: @options.linger)
388
+ Maintenance.start(@lifecycle.parent_task, @options.mechanism, @tasks)
397
389
  end
398
390
 
399
391
 
@@ -440,16 +432,17 @@ module OMQ
440
432
  private
441
433
 
442
434
  def spawn_connection(io, as_server:, endpoint: nil)
443
- task = @parent_task&.async(transient: true, annotation: "conn #{endpoint}") do
444
- done = Async::Promise.new
445
- conn = ConnectionSetup.run(io, self, as_server: as_server, endpoint: endpoint, done: done)
435
+ task = @lifecycle.parent_task&.async(transient: true, annotation: "conn #{endpoint}") do
436
+ done = Async::Promise.new
437
+ lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, done: done)
438
+ lifecycle.handshake!(io, as_server: as_server)
446
439
  done.wait
447
440
  rescue Async::Queue::ClosedError
448
441
  # connection dropped during drain — message re-staged
449
442
  rescue Protocol::ZMTP::Error, *CONNECTION_LOST
450
443
  # handshake failed or connection lost — subtree cleaned up
451
444
  ensure
452
- conn&.close rescue nil
445
+ lifecycle&.close!
453
446
  end
454
447
  @tasks << task if task
455
448
  end
@@ -465,15 +458,8 @@ module OMQ
465
458
  end
466
459
 
467
460
 
468
- def maybe_reconnect(endpoint)
469
- return unless endpoint && @dialed.include?(endpoint)
470
- return unless @state == :open && @reconnect_enabled
471
- Reconnect.schedule(endpoint, @options, @parent_task, self)
472
- end
473
-
474
-
475
461
  def schedule_reconnect(endpoint, delay: nil)
476
- Reconnect.schedule(endpoint, @options, @parent_task, self, delay: delay)
462
+ Reconnect.schedule(endpoint, @options, @lifecycle.parent_task, self, delay: delay)
477
463
  end
478
464
 
479
465
 
@@ -485,7 +471,7 @@ module OMQ
485
471
 
486
472
  def start_accept_loops(listener)
487
473
  return unless listener.respond_to?(:start_accept_loops)
488
- listener.start_accept_loops(@parent_task) do |io|
474
+ listener.start_accept_loops(@lifecycle.parent_task) do |io|
489
475
  handle_accepted(io, endpoint: listener.endpoint)
490
476
  end
491
477
  end
@@ -498,18 +484,12 @@ module OMQ
498
484
 
499
485
 
500
486
  def close_connections
501
- @connections.each_key(&:close)
502
- @connections.clear
487
+ @connections.values.each(&:close!)
503
488
  end
504
489
 
505
490
 
506
491
  def close_connections_at(endpoint)
507
- conns = @connections.filter_map { |conn, e| conn if e.endpoint == endpoint }
508
- conns.each do |conn|
509
- @connections.delete(conn)
510
- routing.connection_removed(conn)
511
- conn.close
512
- end
492
+ @connections.values.select { |lc| lc.endpoint == endpoint }.each(&:close!)
513
493
  end
514
494
 
515
495
 
@@ -21,7 +21,11 @@ module OMQ
21
21
  loop do
22
22
  batch = [q.dequeue]
23
23
  Routing.drain_send_queue(q, batch)
24
- batch.each { |parts| conn.write_message(parts) }
24
+ if batch.size == 1
25
+ conn.write_message(batch[0])
26
+ else
27
+ conn.write_messages(batch)
28
+ end
25
29
  conn.flush
26
30
  batch.each { |parts| engine.emit_verbose_monitor_event(:message_sent, parts: parts) }
27
31
  rescue Protocol::ZMTP::Error, *CONNECTION_LOST
@@ -83,7 +83,7 @@ module OMQ
83
83
  # @param conn [Connection]
84
84
  #
85
85
  def add_fan_out_send_connection(conn)
86
- q = Routing.build_queue(@engine.options.send_hwm, :block)
86
+ q = Routing.build_queue(@engine.options.send_hwm, @engine.options.on_mute)
87
87
  @conn_queues[conn] = q
88
88
  start_conn_send_pump(conn, q)
89
89
  end
@@ -107,10 +107,11 @@ module OMQ
107
107
  # are respected: a message enqueued before the async subscription listener
108
108
  # has processed SUBSCRIBE commands will still be delivered correctly.
109
109
  #
110
- # Per-connection queues use :block (Async::LimitedQueue) for
111
- # backpressure: when a subscriber's queue is full, the publisher
112
- # yields until the send pump drains it. This matches the old
113
- # shared-queue behavior and keeps the publisher fiber-friendly.
110
+ # Per-connection queues honor the socket's on_mute strategy.
111
+ # PUB/XPUB/RADIO default to :drop_newest so one slow subscriber
112
+ # silently drops its own messages without stalling the publisher
113
+ # or other subscribers. Applications can opt in to :block for
114
+ # strict backpressure.
114
115
  #
115
116
  # @param parts [Array<String>]
116
117
  #
@@ -4,9 +4,10 @@ module OMQ
4
4
  module Routing
5
5
  # PAIR socket routing: exclusive 1-to-1 bidirectional.
6
6
  #
7
- # Only one peer connection is allowed. Send and recv queues are
8
- # created per-connection (and destroyed on disconnection) so
9
- # HWM is consistent with multi-peer socket types.
7
+ # Only one peer connection is allowed at a time. The send queue
8
+ # is socket-level (one shared bounded queue), and a single send
9
+ # pump fiber drains it into the connected peer. On disconnect,
10
+ # the in-flight batch is dropped (matching libzmq).
10
11
  #
11
12
  class Pair
12
13
  include FairRecv
@@ -14,13 +15,12 @@ module OMQ
14
15
  # @param engine [Engine]
15
16
  #
16
17
  def initialize(engine)
17
- @engine = engine
18
- @connection = nil
19
- @recv_queue = FairQueue.new
20
- @send_queue = nil # created per-connection
21
- @staging_queue = StagingQueue.new(@engine.options.send_hwm)
22
- @send_pump = nil
23
- @tasks = []
18
+ @engine = engine
19
+ @connection = nil
20
+ @recv_queue = FairQueue.new
21
+ @send_queue = Routing.build_queue(@engine.options.send_hwm, :block)
22
+ @send_pump = nil
23
+ @tasks = []
24
24
  end
25
25
 
26
26
 
@@ -38,10 +38,6 @@ module OMQ
38
38
  add_fair_recv_connection(connection)
39
39
 
40
40
  unless connection.is_a?(Transport::Inproc::DirectPipe)
41
- @send_queue = Routing.build_queue(@engine.options.send_hwm, :block)
42
- while (msg = @staging_queue.dequeue)
43
- @send_queue.enqueue(msg)
44
- end
45
41
  start_send_pump(connection)
46
42
  end
47
43
  end
@@ -53,12 +49,6 @@ module OMQ
53
49
  if @connection == connection
54
50
  @connection = nil
55
51
  @recv_queue.remove_queue(connection)
56
- if @send_queue
57
- while (msg = @send_queue.dequeue(timeout: 0))
58
- @staging_queue.prepend(msg)
59
- end
60
- @send_queue = nil
61
- end
62
52
  @send_pump&.stop
63
53
  @send_pump = nil
64
54
  end
@@ -71,10 +61,8 @@ module OMQ
71
61
  conn = @connection
72
62
  if conn.is_a?(Transport::Inproc::DirectPipe) && conn.direct_recv_queue
73
63
  conn.send_message(parts)
74
- elsif @send_queue
75
- @send_queue.enqueue(parts)
76
64
  else
77
- @staging_queue.enqueue(parts)
65
+ @send_queue.enqueue(parts)
78
66
  end
79
67
  end
80
68
 
@@ -89,10 +77,10 @@ module OMQ
89
77
  end
90
78
 
91
79
 
92
- # @return [Boolean] true when the staging and send queues are empty
80
+ # @return [Boolean] true when the shared send queue is empty
93
81
  #
94
82
  def send_queues_drained?
95
- @staging_queue.empty? && (@send_queue.nil? || @send_queue.empty?)
83
+ @send_queue.empty?
96
84
  end
97
85
 
98
86
  private
@@ -102,15 +90,16 @@ module OMQ
102
90
  loop do
103
91
  batch = [@send_queue.dequeue]
104
92
  Routing.drain_send_queue(@send_queue, batch)
105
- begin
106
- batch.each { |parts| conn.write_message(parts) }
107
- conn.flush
108
- batch.each { |parts| @engine.emit_verbose_monitor_event(:message_sent, parts: parts) }
109
- rescue Protocol::ZMTP::Error, *CONNECTION_LOST
110
- batch.each { |parts| @staging_queue.prepend(parts) }
111
- @engine.connection_lost(conn)
112
- break
93
+ if batch.size == 1
94
+ conn.write_message(batch[0])
95
+ else
96
+ conn.write_messages(batch)
113
97
  end
98
+ conn.flush
99
+ batch.each { |parts| @engine.emit_verbose_monitor_event(:message_sent, parts: parts) }
100
+ rescue Protocol::ZMTP::Error, *CONNECTION_LOST
101
+ @engine.connection_lost(conn)
102
+ break
114
103
  end
115
104
  end
116
105
  @tasks << @send_pump
@@ -2,95 +2,69 @@
2
2
 
3
3
  module OMQ
4
4
  module Routing
5
- # Mixin for routing strategies that send via round-robin.
5
+ # Mixin for routing strategies that load-balance via work-stealing.
6
6
  #
7
- # Provides reactive connection management: Async::Promise waits
8
- # for the first connection, Array#cycle handles round-robin,
9
- # and a new Promise is created when all connections drop.
7
+ # One shared bounded send queue per socket (`send_hwm` enforced
8
+ # at the socket level, not per peer). Each connected peer gets its
9
+ # own send pump fiber that races to drain the shared queue. Slow
10
+ # peers' pumps naturally block on their own TCP flush; fast peers'
11
+ # pumps keep dequeuing. The result is work-stealing load balancing,
12
+ # which is strictly better than libzmq's strict per-pipe round-robin
13
+ # for PUSH-style patterns.
10
14
  #
11
- # Each connected peer gets its own bounded send queue and a
12
- # dedicated send pump fiber, ensuring HWM is enforced per peer.
15
+ # See DESIGN.md "Per-socket HWM (not per-connection)" for the
16
+ # full reasoning.
13
17
  #
14
18
  # Including classes must call `init_round_robin(engine)` from
15
19
  # their #initialize.
16
20
  #
17
21
  module RoundRobin
18
- # @return [Boolean] true when the staging queue and all per-connection
19
- # send queues are empty
22
+ # @return [Boolean] true when the shared send queue is empty
20
23
  #
21
24
  def send_queues_drained?
22
- @staging_queue.empty? && @conn_queues.values.all?(&:empty?)
25
+ @send_queue.empty?
23
26
  end
24
27
 
25
28
  private
26
29
 
27
- # Initializes round-robin state for the including class.
30
+ # Initializes the shared send queue for the including class.
28
31
  #
29
32
  # @param engine [Engine]
30
33
  #
31
34
  def init_round_robin(engine)
32
- @connections = []
33
- @cycle = @connections.cycle
34
- @connection_available = Async::Promise.new
35
- @conn_queues = {} # connection => send queue
36
- @conn_send_tasks = {} # connection => send pump task
37
- @direct_pipe = nil
38
- @staging_queue = StagingQueue.new(@engine.options.send_hwm)
35
+ @connections = []
36
+ @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
37
+ @direct_pipe = nil
38
+ @conn_send_tasks = {} # conn => send pump task
39
39
  end
40
40
 
41
41
 
42
- # Creates a per-connection send queue and starts its send pump.
43
- # Call from #connection_added after appending to @connections.
42
+ # Registers a connection and starts its send pump.
43
+ # Call from #connection_added.
44
44
  #
45
45
  # @param conn [Connection]
46
46
  #
47
47
  def add_round_robin_send_connection(conn)
48
- q = Routing.build_queue(@engine.options.send_hwm, :block)
49
- @conn_queues[conn] = q
50
- start_conn_send_pump(conn, q)
51
- drain_staging_to(q)
52
48
  @connections << conn
53
49
  update_direct_pipe
54
- # A message may have squeezed into staging while drain was
55
- # running (the pipe loop's blocked enqueue completed after
56
- # drain freed space). Now that @connections is set, no new
57
- # messages will go to staging, so this second drain is
58
- # guaranteed to finish quickly.
59
- drain_staging_to(q)
60
- signal_connection_available
50
+ start_conn_send_pump(conn)
61
51
  end
62
52
 
63
53
 
64
- # Stops the per-connection send pump and removes the queue.
65
- # Call from #connection_removed.
54
+ # Removes the connection and stops its send pump. Any message
55
+ # the pump had already dequeued but not yet written is dropped --
56
+ # matching libzmq's behavior on `pipe_terminated`. PUSH has no
57
+ # cross-peer ordering guarantee, so this is safe.
66
58
  #
67
59
  # @param conn [Connection]
68
60
  #
69
61
  def remove_round_robin_send_connection(conn)
70
62
  update_direct_pipe
71
- q = @conn_queues.delete(conn)
72
- if q
73
- while (msg = q.dequeue(timeout: 0))
74
- @staging_queue.prepend(msg)
75
- end
76
- q.close
77
- end
78
- @conn_send_tasks.delete(conn)
79
- end
80
-
81
-
82
- # Resolves the connection-available promise so blocked
83
- # senders can proceed.
84
- #
85
- def signal_connection_available
86
- unless @connection_available.resolved?
87
- @connection_available.resolve(true)
88
- end
63
+ @conn_send_tasks.delete(conn)&.stop
89
64
  end
90
65
 
91
66
 
92
67
  # Updates the direct-pipe shortcut for inproc single-peer bypass.
93
- # Call from connection_added after @connections is updated.
94
68
  #
95
69
  def update_direct_pipe
96
70
  if @connections.size == 1 && @connections.first.is_a?(Transport::Inproc::DirectPipe)
@@ -101,57 +75,17 @@ module OMQ
101
75
  end
102
76
 
103
77
 
104
- # Enqueues directly to the inproc peer's recv queue if possible.
105
- # When peers are connected, picks the next one round-robin and
106
- # enqueues into its per-connection send queue (blocking if full).
107
- # When no peers are connected yet, buffers in a staging queue
108
- # (bounded by send_hwm) — drained into the first peer's queue
109
- # when it connects.
78
+ # Enqueues a message. For inproc single-peer, bypasses the queue
79
+ # and writes directly to the peer's recv queue. Otherwise blocks
80
+ # on the shared bounded send queue (backpressure when full).
110
81
  #
111
82
  def enqueue_round_robin(parts)
112
83
  pipe = @direct_pipe
113
84
  if pipe&.direct_recv_queue
114
85
  pipe.send_message(transform_send(parts))
115
- elsif @connections.empty?
116
- @staging_queue.enqueue(parts)
117
86
  else
118
- conn = next_connection
119
- @conn_queues[conn].enqueue(parts)
120
- end
121
- rescue Async::Queue::ClosedError
122
- retry
123
- end
124
-
125
-
126
- # Drains the staging queue into the given per-connection queue.
127
- # Called when the first peer connects, to deliver messages that
128
- # were enqueued before any connection existed.
129
- #
130
- def drain_staging_to(q)
131
- while (msg = @staging_queue.dequeue)
132
- q.enqueue(msg)
87
+ @send_queue.enqueue(parts)
133
88
  end
134
- rescue Async::Queue::ClosedError
135
- # Connection dropped while draining — put the undelivered
136
- # message back at the front so ordering is preserved.
137
- @staging_queue.prepend(msg) if msg
138
- end
139
-
140
-
141
- # Blocks until a connection is available, then returns
142
- # the next one in round-robin order.
143
- #
144
- # @return [Connection]
145
- #
146
- def next_connection
147
- @cycle.next
148
- rescue StopIteration
149
- if @connections.empty?
150
- @connection_available = Async::Promise.new
151
- @connection_available.wait
152
- end
153
- @cycle = @connections.cycle
154
- retry
155
89
  end
156
90
 
157
91
 
@@ -164,24 +98,30 @@ module OMQ
164
98
  def transform_send(parts) = parts
165
99
 
166
100
 
167
- # Starts a dedicated send pump for one connection.
168
- # Batches messages for throughput; flushes after each batch.
169
- # Calls Engine#connection_lost on disconnect so reconnect fires.
101
+ # Spawns a send pump for one connection. Drains the shared send
102
+ # queue, writes to its peer, and yields. On disconnect, the
103
+ # in-flight batch is dropped and the engine reconnect kicks in.
104
+ #
105
+ # Each batch is capped at BATCH_MSG_CAP messages OR BATCH_BYTE_CAP
106
+ # bytes, whichever hits first. The cap exists for fairness when
107
+ # multiple peers share the queue: without it, the first pump that
108
+ # wakes up drains the entire queue in one non-blocking burst
109
+ # before any other pump runs (TCP send buffers absorb bursts
110
+ # without forcing a fiber yield). 512 KB lets large-message
111
+ # workloads batch naturally (8 × 64 KB per batch) while keeping
112
+ # per-pump latency bounded enough that small-message multi-peer
113
+ # fairness still benefits.
170
114
  #
171
115
  # @param conn [Connection]
172
- # @param q [Async::LimitedQueue] the connection's send queue
173
116
  #
174
- def start_conn_send_pump(conn, q)
117
+ def start_conn_send_pump(conn)
175
118
  task = @engine.spawn_pump_task(annotation: "send pump") do
176
119
  loop do
177
- msg = q.dequeue
178
- break unless msg # queue closed by remove_round_robin_send_connection
179
- batch = [msg]
180
- Routing.drain_send_queue(q, batch)
120
+ batch = [@send_queue.dequeue]
121
+ drain_send_queue_capped(batch)
181
122
  write_batch(conn, batch)
182
123
  batch.each { |parts| @engine.emit_verbose_monitor_event(:message_sent, parts: parts) }
183
124
  rescue Protocol::ZMTP::Error, *CONNECTION_LOST
184
- batch&.each { |parts| @staging_queue.prepend(parts) }
185
125
  @engine.connection_lost(conn)
186
126
  break
187
127
  end
@@ -191,11 +131,28 @@ module OMQ
191
131
  end
192
132
 
193
133
 
134
+ BATCH_MSG_CAP = 256
135
+ BATCH_BYTE_CAP = 512 * 1024
136
+
137
+ def drain_send_queue_capped(batch)
138
+ bytes = batch[0].sum(&:bytesize)
139
+ while batch.size < BATCH_MSG_CAP && bytes < BATCH_BYTE_CAP
140
+ msg = @send_queue.dequeue(timeout: 0)
141
+ break unless msg
142
+ batch << msg
143
+ bytes += msg.sum(&:bytesize)
144
+ end
145
+ end
146
+
147
+
194
148
  def write_batch(conn, batch)
195
149
  if batch.size == 1
196
150
  conn.send_message(transform_send(batch[0]))
197
151
  else
198
- batch.each { |parts| conn.write_message(transform_send(parts)) }
152
+ # Single mutex acquisition for the whole batch (up to
153
+ # BATCH_MSG_CAP messages). transform_send is identity for
154
+ # most routings and only allocates a new parts array for REQ.
155
+ conn.write_messages(batch.map { |parts| transform_send(parts) })
199
156
  conn.flush
200
157
  end
201
158
  end
data/lib/omq/routing.rb CHANGED
@@ -4,7 +4,6 @@ require "async"
4
4
  require "async/queue"
5
5
  require "async/limited_queue"
6
6
  require_relative "drop_queue"
7
- require_relative "routing/staging_queue"
8
7
  require_relative "routing/fair_queue"
9
8
  require_relative "routing/fair_recv"
10
9
  require_relative "routing/conn_send_pump"
@@ -96,6 +96,18 @@ module OMQ
96
96
  alias write_message send_message
97
97
 
98
98
 
99
+ # Batched form, for parity with Protocol::ZMTP::Connection. The
100
+ # work-stealing pumps call this when they dequeue more than one
101
+ # message at once; DirectPipe just loops — no mutex to amortize.
102
+ #
103
+ # @param messages [Array<Array<String>>]
104
+ # @return [void]
105
+ #
106
+ def write_messages(messages)
107
+ messages.each { |parts| send_message(parts) }
108
+ end
109
+
110
+
99
111
  # @return [Boolean] always false; inproc pipes are never encrypted
100
112
  #
101
113
  def encrypted? = false
data/lib/omq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OMQ
4
- VERSION = "0.15.5"
4
+ VERSION = "0.16.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.5
4
+ version: 0.16.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.3'
18
+ version: '0.4'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.3'
25
+ version: '0.4'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -67,11 +67,12 @@ files:
67
67
  - lib/omq.rb
68
68
  - lib/omq/drop_queue.rb
69
69
  - lib/omq/engine.rb
70
- - lib/omq/engine/connection_setup.rb
70
+ - lib/omq/engine/connection_lifecycle.rb
71
71
  - lib/omq/engine/heartbeat.rb
72
72
  - lib/omq/engine/maintenance.rb
73
73
  - lib/omq/engine/reconnect.rb
74
74
  - lib/omq/engine/recv_pump.rb
75
+ - lib/omq/engine/socket_lifecycle.rb
75
76
  - lib/omq/monitor_event.rb
76
77
  - lib/omq/options.rb
77
78
  - lib/omq/pair.rb
@@ -96,7 +97,6 @@ files:
96
97
  - lib/omq/routing/req.rb
97
98
  - lib/omq/routing/round_robin.rb
98
99
  - lib/omq/routing/router.rb
99
- - lib/omq/routing/staging_queue.rb
100
100
  - lib/omq/routing/sub.rb
101
101
  - lib/omq/routing/xpub.rb
102
102
  - lib/omq/routing/xsub.rb
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- class Engine
5
- # Performs ZMTP handshake and registers a new connection.
6
- #
7
- class ConnectionSetup
8
- # @param io [#read, #write, #close] underlying transport stream
9
- # @param engine [Engine]
10
- # @param as_server [Boolean]
11
- # @param endpoint [String, nil]
12
- # @param done [Async::Promise, nil] resolved when connection is lost
13
- # @return [Connection]
14
- #
15
- def self.run(io, engine, as_server:, endpoint: nil, done: nil)
16
- new(engine).run(io, as_server: as_server, endpoint: endpoint, done: done)
17
- end
18
-
19
-
20
- # @param engine [Engine]
21
- #
22
- def initialize(engine)
23
- @engine = engine
24
- end
25
-
26
-
27
- # Performs the ZMTP handshake, starts heartbeat, and registers the connection.
28
- #
29
- # @param io [#read, #write, #close]
30
- # @param as_server [Boolean]
31
- # @param endpoint [String, nil]
32
- # @param done [Async::Promise, nil] resolved when connection is lost
33
- # @return [Connection]
34
- #
35
- def run(io, as_server:, endpoint: nil, done: nil)
36
- conn = build_connection(io, as_server)
37
- conn.handshake!
38
- Heartbeat.start(Async::Task.current, conn, @engine.options, @engine.tasks)
39
- conn = @engine.connection_wrapper.call(conn) if @engine.connection_wrapper
40
- @engine.emit_monitor_event(:handshake_succeeded, endpoint: endpoint)
41
- register(conn, endpoint, done)
42
- conn
43
- rescue Protocol::ZMTP::Error, *CONNECTION_LOST => error
44
- @engine.emit_monitor_event(:handshake_failed, endpoint: endpoint, detail: { error: error })
45
- conn&.close
46
- raise
47
- end
48
-
49
- private
50
-
51
- def build_connection(io, as_server)
52
- Protocol::ZMTP::Connection.new(
53
- io,
54
- socket_type: @engine.socket_type.to_s,
55
- identity: @engine.options.identity,
56
- as_server: as_server,
57
- mechanism: @engine.options.mechanism&.dup,
58
- max_message_size: @engine.options.max_message_size,
59
- )
60
- end
61
-
62
-
63
- def register(conn, endpoint, done)
64
- @engine.connections[conn] = Engine::ConnectionRecord.new(endpoint: endpoint, done: done)
65
- @engine.routing.connection_added(conn)
66
- @engine.peer_connected.resolve(conn)
67
- end
68
- end
69
- end
70
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- module Routing
5
- # Bounded FIFO queue for staging unsent messages.
6
- #
7
- # Wraps an +Async::LimitedQueue+ for backpressure, with a small
8
- # prepend buffer checked first on dequeue (same trick as the
9
- # prefetch buffer in {OMQ::Readable#receive}).
10
- #
11
- class StagingQueue
12
- # @param max [Integer, nil] capacity (nil or 0 = unbounded)
13
- #
14
- def initialize(max = nil)
15
- @max = (max && max > 0) ? max : nil
16
- @queue = @max ? Async::LimitedQueue.new(@max) : Async::Queue.new
17
- @head = []
18
- @mu = Mutex.new
19
- end
20
-
21
-
22
- # Appends a message to the back.
23
- # Blocks (fiber-yields) when at capacity.
24
- #
25
- # @param msg [Array<String>]
26
- # @return [void]
27
- #
28
- def enqueue(msg)
29
- @queue.enqueue(msg)
30
- end
31
-
32
-
33
- # Inserts a message at the front (for re-staging after a
34
- # failed drain). Drops the message if the staging queue is
35
- # already at capacity (messages sent to a peer that disconnected
36
- # may be lost -- same as ZMQ).
37
- #
38
- # @param msg [Array<String>]
39
- # @return [void]
40
- #
41
- def prepend(msg)
42
- @mu.synchronize do
43
- return if @max && @head.size >= @max
44
- @head.push(msg)
45
- end
46
- end
47
-
48
-
49
- # Returns the first message: from the prepend buffer if
50
- # non-empty, otherwise non-blocking dequeue from the main queue.
51
- #
52
- # @return [Array<String>, nil]
53
- #
54
- def dequeue
55
- @mu.synchronize { @head.shift } || @queue.dequeue(timeout: 0)
56
- end
57
-
58
-
59
- # @return [Boolean]
60
- #
61
- def empty?
62
- @mu.synchronize { @head.empty? } && @queue.empty?
63
- end
64
- end
65
- end
66
- end