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 +4 -4
- data/CHANGELOG.md +81 -0
- data/lib/omq/engine/connection_lifecycle.rb +148 -0
- data/lib/omq/engine/socket_lifecycle.rb +116 -0
- data/lib/omq/engine.rb +70 -90
- data/lib/omq/routing/conn_send_pump.rb +5 -1
- data/lib/omq/routing/fan_out.rb +6 -5
- data/lib/omq/routing/pair.rb +22 -33
- data/lib/omq/routing/round_robin.rb +63 -106
- data/lib/omq/routing.rb +0 -1
- data/lib/omq/transport/inproc/direct_pipe.rb +12 -0
- data/lib/omq/version.rb +1 -1
- metadata +5 -5
- data/lib/omq/engine/connection_setup.rb +0 -70
- data/lib/omq/routing/staging_queue.rb +0 -66
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f74ea96d0fc94d40c2d5ac0dd815e112a8fc8af8f74d5b932799fb19981c58b5
|
|
4
|
+
data.tar.gz: f79ddfe0a3519f7011353c03eeb18f054e9c5384e3e67250db699179c86be96e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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
|
|
67
|
-
@options
|
|
68
|
-
@routing
|
|
69
|
-
@connections
|
|
70
|
-
@dialed
|
|
71
|
-
@listeners
|
|
72
|
-
@tasks
|
|
73
|
-
@
|
|
74
|
-
@last_endpoint
|
|
75
|
-
@last_tcp_port
|
|
76
|
-
@
|
|
77
|
-
@
|
|
78
|
-
@
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 :
|
|
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 :
|
|
85
|
+
attr_writer :monitor_queue
|
|
101
86
|
attr_accessor :verbose_monitor
|
|
102
87
|
|
|
103
|
-
|
|
104
|
-
#
|
|
105
|
-
def
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 @
|
|
328
|
-
@
|
|
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
|
-
@
|
|
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 @
|
|
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
|
|
389
|
-
|
|
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
|
|
445
|
-
|
|
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
|
-
|
|
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.
|
|
502
|
-
@connections.clear
|
|
487
|
+
@connections.values.each(&:close!)
|
|
503
488
|
end
|
|
504
489
|
|
|
505
490
|
|
|
506
491
|
def close_connections_at(endpoint)
|
|
507
|
-
|
|
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.
|
|
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
|
data/lib/omq/routing/fan_out.rb
CHANGED
|
@@ -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,
|
|
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
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
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
|
#
|
data/lib/omq/routing/pair.rb
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
18
|
-
@connection
|
|
19
|
-
@recv_queue
|
|
20
|
-
@send_queue
|
|
21
|
-
@
|
|
22
|
-
@
|
|
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
|
-
@
|
|
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
|
|
80
|
+
# @return [Boolean] true when the shared send queue is empty
|
|
93
81
|
#
|
|
94
82
|
def send_queues_drained?
|
|
95
|
-
@
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
5
|
+
# Mixin for routing strategies that load-balance via work-stealing.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
|
|
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
|
-
@
|
|
25
|
+
@send_queue.empty?
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
private
|
|
26
29
|
|
|
27
|
-
# Initializes
|
|
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
|
-
@
|
|
34
|
-
@
|
|
35
|
-
@
|
|
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
|
-
#
|
|
43
|
-
# Call from #connection_added
|
|
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
|
-
|
|
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
|
-
#
|
|
65
|
-
#
|
|
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
|
-
|
|
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
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
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
|
|
117
|
+
def start_conn_send_pump(conn)
|
|
175
118
|
task = @engine.spawn_pump_task(annotation: "send pump") do
|
|
176
119
|
loop do
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
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.
|
|
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.
|
|
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.
|
|
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/
|
|
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
|