omq-qos 0.3.2
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 +7 -0
- data/LICENSE +15 -0
- data/README.md +101 -0
- data/lib/omq/qos/connection_ext.rb +36 -0
- data/lib/omq/qos/dead_letter.rb +36 -0
- data/lib/omq/qos/dedup_set.rb +69 -0
- data/lib/omq/qos/error_codes.rb +102 -0
- data/lib/omq/qos/hasher.rb +64 -0
- data/lib/omq/qos/lifecycle_ext.rb +106 -0
- data/lib/omq/qos/options_ext.rb +35 -0
- data/lib/omq/qos/peer_registry.rb +146 -0
- data/lib/omq/qos/pending_store.rb +89 -0
- data/lib/omq/qos/retry_scheduler.rb +22 -0
- data/lib/omq/qos/routing_ext.rb +481 -0
- data/lib/omq/qos/socket_ext.rb +173 -0
- data/lib/omq/qos/version.rb +8 -0
- data/lib/omq/qos/zmtp/command_ext.rb +114 -0
- data/lib/omq/qos.rb +307 -0
- metadata +88 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/clock"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
class QoS
|
|
7
|
+
# Tracks sent-but-unacknowledged messages per peer for QoS >= 2.
|
|
8
|
+
#
|
|
9
|
+
# Keyed by {Protocol::ZMTP::PeerInfo} so entries survive reconnects:
|
|
10
|
+
# when the same peer comes back, {#resume} returns the peer's pending
|
|
11
|
+
# entries for retransmit on the new connection. When a peer stays away
|
|
12
|
+
# longer than +dead_letter_timeout+, {#sweep_dead_letters} drains its
|
|
13
|
+
# entries as {DeadLetter}s.
|
|
14
|
+
#
|
|
15
|
+
# Backpressure lives on the owning {OMQ::QoS} instance (an
|
|
16
|
+
# {Async::Semaphore} bounded at +send_hwm+). The registry itself is a
|
|
17
|
+
# pure peer-indexed store; it does not block.
|
|
18
|
+
#
|
|
19
|
+
class PeerRegistry
|
|
20
|
+
# One pending message.
|
|
21
|
+
#
|
|
22
|
+
# +peer_info+ is the peer the entry is pinned to; +promise+ resolves
|
|
23
|
+
# to +:delivered+ on ACK/COMP or to a {DeadLetter} on
|
|
24
|
+
# dead-letter/terminal NACK/retry-exhaustion/socket-close.
|
|
25
|
+
Entry = Data.define(:parts, :peer_info, :sent_at, :promise, :retry_count)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Per-peer state: the currently-connected {Protocol::ZMTP::Connection}
|
|
29
|
+
# (or +nil+ when the peer is gone), the disconnect timestamp used by
|
|
30
|
+
# the dead-letter sweep, and the digest-keyed pending map. Insertion
|
|
31
|
+
# order is preserved so {#resume} can replay in original send order.
|
|
32
|
+
class PeerState
|
|
33
|
+
attr_accessor :connection, :disconnected_at
|
|
34
|
+
attr_reader :entries
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def initialize(connection)
|
|
38
|
+
@connection = connection
|
|
39
|
+
@disconnected_at = nil
|
|
40
|
+
@entries = {}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# @param capacity [Integer] send_hwm (kept for symmetry with PendingStore;
|
|
46
|
+
# the actual bound is enforced by the OMQ::QoS#send_semaphore)
|
|
47
|
+
def initialize(capacity:)
|
|
48
|
+
@capacity = capacity
|
|
49
|
+
@peers = {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Records a fresh pending entry, pinned to +peer_info+ on +conn+.
|
|
54
|
+
def track(peer_info, digest, entry, conn)
|
|
55
|
+
state = (@peers[peer_info] ||= PeerState.new(conn))
|
|
56
|
+
state.connection = conn
|
|
57
|
+
state.entries[digest] = entry
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Removes and returns the entry matching +digest+ for +peer_info+,
|
|
62
|
+
# or +nil+ if unknown (e.g. late ACK after dead-letter).
|
|
63
|
+
def ack(peer_info, digest)
|
|
64
|
+
@peers[peer_info]&.entries&.delete(digest)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# @return [Protocol::ZMTP::Connection, nil] the live connection
|
|
69
|
+
# currently pinned to +peer_info+, if any
|
|
70
|
+
def connection_for(peer_info)
|
|
71
|
+
@peers[peer_info]&.connection
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Marks the peer as disconnected. Entries stay in place until either
|
|
76
|
+
# the peer reconnects ({#resume}) or the dead-letter timer fires
|
|
77
|
+
# ({#sweep_dead_letters}).
|
|
78
|
+
def disconnect(peer_info)
|
|
79
|
+
state = @peers[peer_info] or return
|
|
80
|
+
state.connection = nil
|
|
81
|
+
state.disconnected_at = Async::Clock.now
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Rebinds +conn+ to +peer_info+ and returns the existing pending
|
|
86
|
+
# entries in insertion order. Caller retransmits them on +conn+.
|
|
87
|
+
def resume(peer_info, conn)
|
|
88
|
+
state = (@peers[peer_info] ||= PeerState.new(conn))
|
|
89
|
+
state.connection = conn
|
|
90
|
+
state.disconnected_at = nil
|
|
91
|
+
state.entries.values
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Returns all entries whose peer has been gone for at least +timeout+
|
|
96
|
+
# seconds, pairs them with their peer_info, and removes their
|
|
97
|
+
# PeerState from the registry.
|
|
98
|
+
#
|
|
99
|
+
# @param now [Float]
|
|
100
|
+
# @param timeout [Numeric]
|
|
101
|
+
# @return [Array<Array(Entry, Protocol::ZMTP::PeerInfo)>]
|
|
102
|
+
def sweep_dead_letters(now, timeout)
|
|
103
|
+
expired = []
|
|
104
|
+
@peers.delete_if do |peer_info, state|
|
|
105
|
+
next false unless state.disconnected_at
|
|
106
|
+
next false if (now - state.disconnected_at) < timeout
|
|
107
|
+
state.entries.each_value { |entry| expired << [entry, peer_info] }
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
expired
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Resolves all outstanding promises with a {DeadLetter} carrying
|
|
115
|
+
# +reason+. Called from {OMQ::QoS#shutdown} so no +#wait+-ing fiber
|
|
116
|
+
# hangs.
|
|
117
|
+
def drain_with_dead_letter(reason)
|
|
118
|
+
@peers.each do |peer_info, state|
|
|
119
|
+
state.entries.each_value do |entry|
|
|
120
|
+
next if entry.promise.resolved?
|
|
121
|
+
dl = DeadLetter.new(
|
|
122
|
+
parts: entry.parts,
|
|
123
|
+
reason: reason,
|
|
124
|
+
peer_info: peer_info,
|
|
125
|
+
error: nil,
|
|
126
|
+
)
|
|
127
|
+
entry.promise.resolve(dl)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
@peers.clear
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# @return [Integer] total pending entries across all peers
|
|
135
|
+
def size
|
|
136
|
+
@peers.sum { |_peer, state| state.entries.size }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# @return [Boolean]
|
|
141
|
+
def empty?
|
|
142
|
+
@peers.all? { |_peer, state| state.entries.empty? }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/semaphore"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
class QoS
|
|
7
|
+
# Tracks sent-but-unacknowledged messages for QoS 1.
|
|
8
|
+
#
|
|
9
|
+
# Keyed by 8-byte XXH3 digest of the raw ZMTP wire bytes.
|
|
10
|
+
#
|
|
11
|
+
# Bounded by an internal {Async::Semaphore} of +capacity+ permits
|
|
12
|
+
# (typically +send_hwm+). The sender acquires a permit via
|
|
13
|
+
# {#wait_for_slot} before each {#track}; {#ack} and {#messages_for}
|
|
14
|
+
# release permits back. This gives the QoS path the same
|
|
15
|
+
# backpressure semantics the send queue has at QoS 0: once
|
|
16
|
+
# +send_hwm+ messages are outstanding, the next acquire blocks until
|
|
17
|
+
# an ACK (or a connection drop that drains the entries) frees a
|
|
18
|
+
# slot.
|
|
19
|
+
#
|
|
20
|
+
class PendingStore
|
|
21
|
+
Entry = Data.define(:parts, :connection, :sent_at)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# @param capacity [Integer] max pending entries
|
|
25
|
+
def initialize(capacity:)
|
|
26
|
+
@entries = {}
|
|
27
|
+
@capacity = capacity
|
|
28
|
+
@semaphore = Async::Semaphore.new(capacity)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Blocks the caller until a pending-slot permit is available and
|
|
33
|
+
# then acquires it. Caller MUST follow up with a {#track}; the
|
|
34
|
+
# corresponding {#ack} (or {#messages_for} drop) releases.
|
|
35
|
+
def wait_for_slot
|
|
36
|
+
@semaphore.acquire
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def track(hash, parts, connection)
|
|
41
|
+
@entries[hash] = Entry.new(
|
|
42
|
+
parts: parts,
|
|
43
|
+
connection: connection,
|
|
44
|
+
sent_at: Async::Clock.now,
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Acknowledges a message. Releases one semaphore permit when the
|
|
50
|
+
# entry existed.
|
|
51
|
+
#
|
|
52
|
+
# @return [Entry, nil]
|
|
53
|
+
def ack(hash)
|
|
54
|
+
entry = @entries.delete(hash)
|
|
55
|
+
@semaphore.release if entry
|
|
56
|
+
entry
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Returns and removes all pending entries for a connection.
|
|
61
|
+
# Releases one semaphore permit per removed entry.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Entry>]
|
|
64
|
+
def messages_for(connection)
|
|
65
|
+
removed = []
|
|
66
|
+
@entries.delete_if do |_hash, entry|
|
|
67
|
+
if entry.connection.equal?(connection)
|
|
68
|
+
removed << entry
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
removed.size.times { @semaphore.release }
|
|
73
|
+
removed
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# @return [Integer]
|
|
78
|
+
def size
|
|
79
|
+
@entries.size
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def empty?
|
|
85
|
+
@entries.empty?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
class QoS
|
|
5
|
+
# Computes exponential backoff delays for QoS 3 retries.
|
|
6
|
+
#
|
|
7
|
+
# +range.begin+ is the first-retry delay. Each subsequent retry
|
|
8
|
+
# doubles the delay, capped at +range.end+.
|
|
9
|
+
#
|
|
10
|
+
module RetryScheduler
|
|
11
|
+
# @param retry_count [Integer] number of retries already attempted
|
|
12
|
+
# (0 before the first retry)
|
|
13
|
+
# @param range [Range] +retry_backoff+ configuration (min..max)
|
|
14
|
+
# @return [Numeric] seconds to sleep before the next retry attempt
|
|
15
|
+
def self.delay(retry_count, range)
|
|
16
|
+
return range.begin if retry_count <= 0
|
|
17
|
+
scaled = range.begin * (2 ** retry_count)
|
|
18
|
+
scaled < range.end ? scaled : range.end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|