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.
@@ -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