zzq 0.1.1 → 0.2.0
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 +24 -0
- data/LICENSE-APACHE +202 -0
- data/LICENSE-ISC +15 -0
- data/README.md +67 -122
- data/ext/zzq_native/Cargo.toml +35 -0
- data/ext/zzq_native/extconf.rb +8 -0
- data/ext/zzq_native/src/broker.rs +163 -0
- data/ext/zzq_native/src/client.rs +294 -0
- data/ext/zzq_native/src/context.rs +51 -0
- data/ext/zzq_native/src/error.rs +117 -0
- data/ext/zzq_native/src/lib.rs +52 -0
- data/ext/zzq_native/src/op.rs +140 -0
- data/ext/zzq_native/src/options.rs +168 -0
- data/ext/zzq_native/src/rb.rs +424 -0
- data/ext/zzq_native/src/wake.rs +155 -0
- data/lib/zzq/broker.rb +90 -21
- data/lib/zzq/client.rb +202 -35
- data/lib/zzq/errors.rb +37 -0
- data/lib/zzq/message.rb +16 -107
- data/lib/zzq/options.rb +27 -86
- data/lib/zzq/version.rb +2 -1
- data/lib/zzq/waiting.rb +65 -0
- data/lib/zzq.rb +22 -19
- metadata +33 -77
- data/LICENSE +0 -13
- data/lib/zzq/engine/connection_lifecycle.rb +0 -139
- data/lib/zzq/engine/keepalive.rb +0 -75
- data/lib/zzq/engine/socket_lifecycle.rb +0 -79
- data/lib/zzq/engine.rb +0 -169
- data/lib/zzq/error.rb +0 -42
- data/lib/zzq/monitor_event.rb +0 -14
- data/lib/zzq/persistence/interface.rb +0 -43
- data/lib/zzq/persistence/memory.rb +0 -37
- data/lib/zzq/persistence/pstore.rb +0 -66
- data/lib/zzq/routing/broker.rb +0 -380
- data/lib/zzq/routing/client.rb +0 -327
- data/lib/zzq/routing/packet_id_allocator.rb +0 -54
- data/lib/zzq/routing/qos_tracker.rb +0 -108
- data/lib/zzq/routing/retained_store.rb +0 -67
- data/lib/zzq/routing/topic_match.rb +0 -49
- data/lib/zzq/routing/topic_trie.rb +0 -232
- data/lib/zzq/socket.rb +0 -146
- data/lib/zzq/subscription.rb +0 -70
- data/lib/zzq/transport/ipc.rb +0 -92
- data/lib/zzq/transport/mqtt.rb +0 -83
- data/lib/zzq/transport/mqtts.rb +0 -173
data/lib/zzq/routing/client.rb
DELETED
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async"
|
|
4
|
-
require "async/promise"
|
|
5
|
-
require "protocol/mqtt"
|
|
6
|
-
require_relative "../engine/keepalive"
|
|
7
|
-
require_relative "../message"
|
|
8
|
-
require_relative "../subscription"
|
|
9
|
-
require_relative "packet_id_allocator"
|
|
10
|
-
require_relative "qos_tracker"
|
|
11
|
-
require_relative "topic_match"
|
|
12
|
-
|
|
13
|
-
module ZZQ
|
|
14
|
-
module Routing
|
|
15
|
-
# Client-side routing.
|
|
16
|
-
#
|
|
17
|
-
# Responsibilities:
|
|
18
|
-
# * CONNECT / CONNACK handshake on +handle_connected+.
|
|
19
|
-
# * Per-connection read loop dispatching PINGRESP, DISCONNECT,
|
|
20
|
-
# PUBLISH (fan-out to matching subscriptions), SUBACK/UNSUBACK
|
|
21
|
-
# (resolve pending acks), and PUBACK/PUBREC/PUBREL/PUBCOMP
|
|
22
|
-
# (drive outbound QoS 1/2 state machines).
|
|
23
|
-
# * Keepalive fiber.
|
|
24
|
-
# * QoS 0/1/2 publish and subscribe.
|
|
25
|
-
class Client
|
|
26
|
-
HANDSHAKE_TIMEOUT = 10 # seconds to wait for CONNACK
|
|
27
|
-
SUBSCRIBE_TIMEOUT = 30 # seconds to wait for SUBACK / UNSUBACK
|
|
28
|
-
PUBLISH_TIMEOUT = 30 # seconds to wait for terminal ack on qos>0
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# @return [Protocol::MQTT::Packet::Connack, nil] the most recent
|
|
32
|
-
# CONNACK received, or nil before the first connect.
|
|
33
|
-
attr_reader :last_connack
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def initialize(engine)
|
|
37
|
-
@engine = engine
|
|
38
|
-
@conn = nil
|
|
39
|
-
@keepalive = nil
|
|
40
|
-
@last_connack = nil
|
|
41
|
-
@subscriptions = [] # ZZQ::Subscription
|
|
42
|
-
@packet_ids = PacketIdAllocator.new(
|
|
43
|
-
receive_maximum: @engine.options.receive_maximum,
|
|
44
|
-
)
|
|
45
|
-
@qos_tracker = QosTracker.new
|
|
46
|
-
@pending_ctl = {} # packet_id => Async::Promise (SUBACK / UNSUBACK)
|
|
47
|
-
@inbound_qos2 = {} # packet_id => Message awaiting PUBREL (manual-ack path; M9)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def session_present?
|
|
52
|
-
@last_connack&.session_present || false
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# Called by Engine#handle_connected. Runs inside the caller's
|
|
57
|
-
# Async task (Socket#connect → Engine.connect → Transport.connect
|
|
58
|
-
# → Engine.handle_connected), so we can synchronously drive the
|
|
59
|
-
# CONNECT/CONNACK exchange and raise to the caller on refusal.
|
|
60
|
-
def handle_connected(mqtt, lifecycle)
|
|
61
|
-
opts = @engine.options
|
|
62
|
-
mqtt.version = opts.version
|
|
63
|
-
|
|
64
|
-
connect_packet = build_connect(opts)
|
|
65
|
-
|
|
66
|
-
Async::Task.current.with_timeout(HANDSHAKE_TIMEOUT) do
|
|
67
|
-
mqtt.write_packet(connect_packet)
|
|
68
|
-
reply = mqtt.read_packet
|
|
69
|
-
raise ConnectionRefused.new(0, "no CONNACK from broker") if reply.nil?
|
|
70
|
-
raise ConnectionRefused.new(0, "expected CONNACK, got #{reply.class.name}") unless reply.is_a?(Protocol::MQTT::Packet::Connack)
|
|
71
|
-
unless success_connack?(reply)
|
|
72
|
-
raise ConnectionRefused.new(reply.reason_code)
|
|
73
|
-
end
|
|
74
|
-
@last_connack = reply
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
@conn = mqtt
|
|
78
|
-
lifecycle.handshake_succeeded!
|
|
79
|
-
|
|
80
|
-
start_read_loop(mqtt, lifecycle)
|
|
81
|
-
start_keepalive(mqtt, lifecycle)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def connection_added(_conn); end
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def connection_removed(conn)
|
|
89
|
-
return unless @conn.equal?(conn)
|
|
90
|
-
@conn = nil
|
|
91
|
-
@subscriptions.each(&:close)
|
|
92
|
-
@subscriptions.clear
|
|
93
|
-
@pending_ctl.each_value { |p| p.resolve(nil) unless p.resolved? }
|
|
94
|
-
@pending_ctl.clear
|
|
95
|
-
# Wake any fiber blocked on a publish ack. Don't clear the
|
|
96
|
-
# tracker yet — M6 retransmit-on-reconnect will want to inspect
|
|
97
|
-
# +inflight_outbound+. For now, just unblock with nil.
|
|
98
|
-
@qos_tracker.inflight_outbound.each do |pending|
|
|
99
|
-
pending.promise.resolve(nil) unless pending.promise.resolved?
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def close
|
|
105
|
-
return unless @conn
|
|
106
|
-
begin
|
|
107
|
-
@conn.write_packet(Protocol::MQTT::Packet::Disconnect.new)
|
|
108
|
-
rescue IOError, Errno::EPIPE, Errno::ECONNRESET, ClosedError
|
|
109
|
-
# peer already gone; fine
|
|
110
|
-
end
|
|
111
|
-
@conn = nil
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# Send a PUBLISH. For QoS 0 returns immediately after write. For
|
|
116
|
-
# QoS 1 blocks the fiber until PUBACK. For QoS 2 blocks until
|
|
117
|
-
# PUBCOMP.
|
|
118
|
-
def publish(topic:, payload:, qos: 0, retain: false, properties: {})
|
|
119
|
-
raise ClosedError, "client not connected" unless @conn
|
|
120
|
-
if qos.zero?
|
|
121
|
-
pkt = Protocol::MQTT::Packet::Publish.new(
|
|
122
|
-
topic: topic, payload: payload, qos: 0, retain: retain,
|
|
123
|
-
properties: properties,
|
|
124
|
-
)
|
|
125
|
-
@conn.write_packet(pkt)
|
|
126
|
-
@keepalive&.note_sent
|
|
127
|
-
return nil
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
packet_id = @packet_ids.acquire
|
|
131
|
-
pkt = Protocol::MQTT::Packet::Publish.new(
|
|
132
|
-
topic: topic, payload: payload, qos: qos, retain: retain,
|
|
133
|
-
packet_id: packet_id, properties: properties,
|
|
134
|
-
)
|
|
135
|
-
pending = qos == 1 \
|
|
136
|
-
? @qos_tracker.track_qos1(packet_id, pkt) \
|
|
137
|
-
: @qos_tracker.track_qos2(packet_id, pkt)
|
|
138
|
-
begin
|
|
139
|
-
@conn.write_packet(pkt)
|
|
140
|
-
@keepalive&.note_sent
|
|
141
|
-
result = Async::Task.current.with_timeout(PUBLISH_TIMEOUT) { pending.promise.wait }
|
|
142
|
-
raise ClosedError, "connection lost before publish ack" if result.nil?
|
|
143
|
-
ensure
|
|
144
|
-
@packet_ids.release(packet_id)
|
|
145
|
-
end
|
|
146
|
-
nil
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
# Send a SUBSCRIBE, wait for SUBACK, return a Subscription.
|
|
151
|
-
def subscribe(filter, qos: 0, no_local: false, retain_as_published: false,
|
|
152
|
-
retain_handling: 0)
|
|
153
|
-
raise ClosedError, "client not connected" unless @conn
|
|
154
|
-
packet_id = @packet_ids.acquire
|
|
155
|
-
filter_entry = {
|
|
156
|
-
filter: filter, qos: qos, no_local: no_local,
|
|
157
|
-
retain_as_published: retain_as_published,
|
|
158
|
-
retain_handling: retain_handling,
|
|
159
|
-
}
|
|
160
|
-
subscription = Subscription.new(
|
|
161
|
-
filters: [filter],
|
|
162
|
-
hwm: @engine.options.recv_hwm,
|
|
163
|
-
on_close: ->(sub) { send_unsubscribe(sub) },
|
|
164
|
-
)
|
|
165
|
-
@subscriptions << subscription
|
|
166
|
-
|
|
167
|
-
promise = Async::Promise.new
|
|
168
|
-
@pending_ctl[packet_id] = promise
|
|
169
|
-
begin
|
|
170
|
-
@conn.write_packet(Protocol::MQTT::Packet::Subscribe.new(
|
|
171
|
-
packet_id: packet_id, filters: [filter_entry],
|
|
172
|
-
))
|
|
173
|
-
@keepalive&.note_sent
|
|
174
|
-
|
|
175
|
-
suback = Async::Task.current.with_timeout(SUBSCRIBE_TIMEOUT) { promise.wait }
|
|
176
|
-
raise ClosedError, "connection lost before SUBACK" if suback.nil?
|
|
177
|
-
reason = suback.reason_codes.first
|
|
178
|
-
if reason >= 0x80
|
|
179
|
-
@subscriptions.delete(subscription)
|
|
180
|
-
raise Error, "SUBSCRIBE refused: reason=0x#{reason.to_s(16)}"
|
|
181
|
-
end
|
|
182
|
-
ensure
|
|
183
|
-
@packet_ids.release(packet_id)
|
|
184
|
-
end
|
|
185
|
-
subscription
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
private
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def build_connect(opts)
|
|
193
|
-
Protocol::MQTT::Packet::Connect.new(
|
|
194
|
-
client_id: opts.client_id.to_s,
|
|
195
|
-
clean_start: opts.clean_start,
|
|
196
|
-
keep_alive: opts.keep_alive,
|
|
197
|
-
username: opts.username,
|
|
198
|
-
password: opts.password,
|
|
199
|
-
will: opts.will,
|
|
200
|
-
properties: build_connect_properties(opts),
|
|
201
|
-
)
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def build_connect_properties(opts)
|
|
206
|
-
return {} unless opts.version == 5
|
|
207
|
-
props = {}
|
|
208
|
-
props[:session_expiry_interval] = opts.session_expiry_interval if opts.session_expiry_interval > 0
|
|
209
|
-
props[:receive_maximum] = opts.receive_maximum if opts.receive_maximum != 65_535
|
|
210
|
-
props[:maximum_packet_size] = opts.max_packet_size if opts.max_packet_size
|
|
211
|
-
props[:topic_alias_maximum] = opts.topic_alias_maximum if opts.topic_alias_maximum > 0
|
|
212
|
-
props
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def success_connack?(ack)
|
|
217
|
-
# v3 accepted = 0x00; v5 success = 0x00. Both map to 0.
|
|
218
|
-
ack.reason_code.zero?
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def send_unsubscribe(subscription)
|
|
223
|
-
return unless @conn
|
|
224
|
-
packet_id = @packet_ids.acquire
|
|
225
|
-
promise = Async::Promise.new
|
|
226
|
-
@pending_ctl[packet_id] = promise
|
|
227
|
-
begin
|
|
228
|
-
@conn.write_packet(Protocol::MQTT::Packet::Unsubscribe.new(
|
|
229
|
-
packet_id: packet_id, filters: subscription.filters.dup,
|
|
230
|
-
))
|
|
231
|
-
@keepalive&.note_sent
|
|
232
|
-
rescue *CONNECTION_LOST, ClosedError
|
|
233
|
-
@pending_ctl.delete(packet_id)
|
|
234
|
-
ensure
|
|
235
|
-
@packet_ids.release(packet_id)
|
|
236
|
-
end
|
|
237
|
-
@subscriptions.delete(subscription)
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def start_read_loop(mqtt, lifecycle)
|
|
242
|
-
lifecycle.barrier.async(annotation: "zzq client read #{lifecycle.endpoint}") do
|
|
243
|
-
loop do
|
|
244
|
-
pkt = mqtt.read_packet
|
|
245
|
-
break if pkt.nil? # clean EOF
|
|
246
|
-
@keepalive&.note_received
|
|
247
|
-
dispatch(pkt, mqtt)
|
|
248
|
-
rescue *CONNECTION_LOST, Protocol::MQTT::MalformedPacket
|
|
249
|
-
break
|
|
250
|
-
end
|
|
251
|
-
ensure
|
|
252
|
-
lifecycle.lost! unless lifecycle.state == :closed
|
|
253
|
-
end
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def start_keepalive(mqtt, lifecycle)
|
|
258
|
-
ka = @engine.options.keep_alive
|
|
259
|
-
return if ka.zero?
|
|
260
|
-
@keepalive = Engine::Keepalive.new(
|
|
261
|
-
mqtt, keep_alive: ka,
|
|
262
|
-
on_timeout: -> { lifecycle.lost! },
|
|
263
|
-
)
|
|
264
|
-
lifecycle.barrier.async(annotation: "zzq client keepalive") do
|
|
265
|
-
@keepalive.run
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def dispatch(pkt, mqtt)
|
|
271
|
-
case pkt
|
|
272
|
-
when Protocol::MQTT::Packet::Pingresp
|
|
273
|
-
# liveness refreshed via @keepalive.note_received above
|
|
274
|
-
when Protocol::MQTT::Packet::Disconnect
|
|
275
|
-
raise EOFError # cleanly unwind the read loop
|
|
276
|
-
when Protocol::MQTT::Packet::Publish
|
|
277
|
-
handle_inbound_publish(pkt, mqtt)
|
|
278
|
-
when Protocol::MQTT::Packet::Suback, Protocol::MQTT::Packet::Unsuback
|
|
279
|
-
promise = @pending_ctl.delete(pkt.packet_id)
|
|
280
|
-
promise&.resolve(pkt)
|
|
281
|
-
when Protocol::MQTT::Packet::Puback
|
|
282
|
-
@qos_tracker.on_puback(pkt.packet_id)
|
|
283
|
-
when Protocol::MQTT::Packet::Pubrec
|
|
284
|
-
entry = @qos_tracker.on_pubrec(pkt.packet_id)
|
|
285
|
-
if entry
|
|
286
|
-
mqtt.write_packet(Protocol::MQTT::Packet::Pubrel.new(packet_id: pkt.packet_id))
|
|
287
|
-
@keepalive&.note_sent
|
|
288
|
-
end
|
|
289
|
-
when Protocol::MQTT::Packet::Pubrel
|
|
290
|
-
# Inbound half of QoS 2 (broker → client). Release the held
|
|
291
|
-
# dedup entry and acknowledge with PUBCOMP.
|
|
292
|
-
@qos_tracker.on_pubrel(pkt.packet_id)
|
|
293
|
-
mqtt.write_packet(Protocol::MQTT::Packet::Pubcomp.new(packet_id: pkt.packet_id))
|
|
294
|
-
@keepalive&.note_sent
|
|
295
|
-
when Protocol::MQTT::Packet::Pubcomp
|
|
296
|
-
@qos_tracker.on_pubcomp(pkt.packet_id)
|
|
297
|
-
end
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def handle_inbound_publish(pkt, mqtt)
|
|
302
|
-
case pkt.qos
|
|
303
|
-
when 0
|
|
304
|
-
fan_out_publish(pkt)
|
|
305
|
-
when 1
|
|
306
|
-
fan_out_publish(pkt)
|
|
307
|
-
mqtt.write_packet(Protocol::MQTT::Packet::Puback.new(packet_id: pkt.packet_id))
|
|
308
|
-
@keepalive&.note_sent
|
|
309
|
-
when 2
|
|
310
|
-
# Only fan out the first receipt; duplicates (same packet id
|
|
311
|
-
# before PUBREL) are dropped but still PUBRECed.
|
|
312
|
-
fan_out_publish(pkt) if @qos_tracker.on_qos2_publish(pkt.packet_id)
|
|
313
|
-
mqtt.write_packet(Protocol::MQTT::Packet::Pubrec.new(packet_id: pkt.packet_id))
|
|
314
|
-
@keepalive&.note_sent
|
|
315
|
-
end
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
def fan_out_publish(pkt)
|
|
320
|
-
msg = Message.from_publish(pkt)
|
|
321
|
-
@subscriptions.each do |sub|
|
|
322
|
-
sub.deliver(msg) if sub.filters.any? { |f| TopicMatch.match?(f, pkt.topic) }
|
|
323
|
-
end
|
|
324
|
-
end
|
|
325
|
-
end
|
|
326
|
-
end
|
|
327
|
-
end
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async/condition"
|
|
4
|
-
|
|
5
|
-
module ZZQ
|
|
6
|
-
module Routing
|
|
7
|
-
# u16 packet-id allocator with an optional receive-maximum cap.
|
|
8
|
-
# Thread-unsafe (designed to be owned by a single connection's
|
|
9
|
-
# writer fiber). Packet id 0 is reserved (MQTT-2.2.1-3); we wrap
|
|
10
|
-
# 1..65535.
|
|
11
|
-
#
|
|
12
|
-
# When the outstanding count reaches +receive_maximum+, +#acquire+
|
|
13
|
-
# blocks the caller fiber until a later +#release+ frees a slot.
|
|
14
|
-
class PacketIdAllocator
|
|
15
|
-
def initialize(receive_maximum: 65_535)
|
|
16
|
-
@receive_maximum = receive_maximum
|
|
17
|
-
@outstanding = {} # packet_id => true
|
|
18
|
-
@next = 0
|
|
19
|
-
@free_cond = Async::Condition.new
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
# Acquire a fresh packet id. Blocks the calling fiber if the
|
|
24
|
-
# receive-maximum quota is exhausted.
|
|
25
|
-
def acquire
|
|
26
|
-
while @outstanding.size >= @receive_maximum
|
|
27
|
-
@free_cond.wait
|
|
28
|
-
end
|
|
29
|
-
id = loop do
|
|
30
|
-
@next = (@next % 0xFFFF) + 1
|
|
31
|
-
break @next unless @outstanding.key?(@next)
|
|
32
|
-
end
|
|
33
|
-
@outstanding[id] = true
|
|
34
|
-
id
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# Mark +id+ free. Wakes one waiter if any.
|
|
39
|
-
def release(id)
|
|
40
|
-
return false unless @outstanding.delete(id)
|
|
41
|
-
@free_cond.signal
|
|
42
|
-
true
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def outstanding?(id)
|
|
47
|
-
@outstanding.key?(id)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def size = @outstanding.size
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async/promise"
|
|
4
|
-
|
|
5
|
-
module ZZQ
|
|
6
|
-
module Routing
|
|
7
|
-
# Outbound inflight tracking + inbound QoS 2 dedup for one
|
|
8
|
-
# session.
|
|
9
|
-
#
|
|
10
|
-
# Each side of a QoS 1/2 exchange owns one tracker. The publisher
|
|
11
|
-
# side records a {PendingPublish} per outbound packet id, awaiting
|
|
12
|
-
# a PUBACK (QoS 1) or completing via PUBREC/PUBREL/PUBCOMP
|
|
13
|
-
# (QoS 2). The subscriber side remembers which QoS 2 packet ids
|
|
14
|
-
# have been received-but-not-yet-released so duplicate PUBLISH
|
|
15
|
-
# packets with the same id are dropped.
|
|
16
|
-
class QosTracker
|
|
17
|
-
# One outbound publication awaiting ack.
|
|
18
|
-
#
|
|
19
|
-
# +stage+ is one of:
|
|
20
|
-
# * :waiting_puback — QoS 1, awaiting PUBACK
|
|
21
|
-
# * :waiting_pubrec — QoS 2, awaiting PUBREC
|
|
22
|
-
# * :waiting_pubcomp — QoS 2, PUBREC received, PUBREL sent
|
|
23
|
-
PendingPublish = Struct.new(:packet_id, :packet, :stage, :promise, keyword_init: true)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def initialize
|
|
27
|
-
@outbound = {} # packet_id => PendingPublish
|
|
28
|
-
@inbound_qos2 = {} # packet_id => true (received, awaiting PUBREL)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# Register an outbound QoS 1 publish. Returns the PendingPublish.
|
|
33
|
-
def track_qos1(packet_id, packet)
|
|
34
|
-
promise = Async::Promise.new
|
|
35
|
-
@outbound[packet_id] = PendingPublish.new(
|
|
36
|
-
packet_id: packet_id, packet: packet, stage: :waiting_puback,
|
|
37
|
-
promise: promise,
|
|
38
|
-
)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
# Register an outbound QoS 2 publish (awaiting PUBREC).
|
|
43
|
-
def track_qos2(packet_id, packet)
|
|
44
|
-
promise = Async::Promise.new
|
|
45
|
-
@outbound[packet_id] = PendingPublish.new(
|
|
46
|
-
packet_id: packet_id, packet: packet, stage: :waiting_pubrec,
|
|
47
|
-
promise: promise,
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# Called on inbound PUBACK. Returns the PendingPublish that
|
|
53
|
-
# completed, or nil if the id was unknown / stage mismatched.
|
|
54
|
-
def on_puback(packet_id)
|
|
55
|
-
pending = @outbound[packet_id]
|
|
56
|
-
return nil unless pending && pending.stage == :waiting_puback
|
|
57
|
-
@outbound.delete(packet_id)
|
|
58
|
-
pending.promise.resolve(:acked) unless pending.promise.resolved?
|
|
59
|
-
pending
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# Called on inbound PUBREC. Transitions the entry to
|
|
64
|
-
# +:waiting_pubcomp+ and returns it so the caller can send
|
|
65
|
-
# PUBREL. Returns nil if unknown / stage mismatched.
|
|
66
|
-
def on_pubrec(packet_id)
|
|
67
|
-
pending = @outbound[packet_id]
|
|
68
|
-
return nil unless pending && pending.stage == :waiting_pubrec
|
|
69
|
-
pending.stage = :waiting_pubcomp
|
|
70
|
-
pending
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
# Called on inbound PUBCOMP. Completes a QoS 2 exchange.
|
|
75
|
-
def on_pubcomp(packet_id)
|
|
76
|
-
pending = @outbound[packet_id]
|
|
77
|
-
return nil unless pending && pending.stage == :waiting_pubcomp
|
|
78
|
-
@outbound.delete(packet_id)
|
|
79
|
-
pending.promise.resolve(:acked) unless pending.promise.resolved?
|
|
80
|
-
pending
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# Inbound QoS 2 PUBLISH dedup. Returns true if this id is a new
|
|
85
|
-
# (non-duplicate) reception; false if it's a duplicate.
|
|
86
|
-
def on_qos2_publish(packet_id)
|
|
87
|
-
return false if @inbound_qos2.key?(packet_id)
|
|
88
|
-
@inbound_qos2[packet_id] = true
|
|
89
|
-
true
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# Inbound PUBREL: peer tells us to release the held qos2 id.
|
|
94
|
-
def on_pubrel(packet_id)
|
|
95
|
-
@inbound_qos2.delete(packet_id)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
# Snapshot of still-pending outbound publications, used on
|
|
100
|
-
# reconnect for retransmission.
|
|
101
|
-
def inflight_outbound = @outbound.values
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def outbound_size = @outbound.size
|
|
105
|
-
def inbound_size = @inbound_qos2.size
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
end
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "topic_match"
|
|
4
|
-
|
|
5
|
-
module ZZQ
|
|
6
|
-
module Routing
|
|
7
|
-
# Exact-topic map of retained application messages.
|
|
8
|
-
#
|
|
9
|
-
# MQTT spec: the last PUBLISH with RETAIN=1 for a given topic is
|
|
10
|
-
# stored. A zero-length payload + RETAIN=1 deletes the retained
|
|
11
|
-
# message for that topic (MQTT-3.3.1-6).
|
|
12
|
-
#
|
|
13
|
-
# On SUBSCRIBE, the broker walks this store and delivers every
|
|
14
|
-
# retained message whose topic matches the new filter.
|
|
15
|
-
class RetainedStore
|
|
16
|
-
# +persistence+ is any {ZZQ::Persistence::Interface} subclass.
|
|
17
|
-
# Default is in-memory (no durability). On construction the
|
|
18
|
-
# in-process cache is warmed from the backend's load_retained.
|
|
19
|
-
def initialize(persistence: nil)
|
|
20
|
-
@store = {} # topic => ZZQ::Message
|
|
21
|
-
@persistence = persistence
|
|
22
|
-
@persistence&.load_retained { |msg| @store[msg.topic] = msg }
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# Upsert or delete per RETAIN semantics. Returns the stored
|
|
27
|
-
# message (nil if the empty-payload rule deleted it).
|
|
28
|
-
def store(message)
|
|
29
|
-
if message.payload.empty?
|
|
30
|
-
@store.delete(message.topic)
|
|
31
|
-
@persistence&.delete_retained(message.topic)
|
|
32
|
-
nil
|
|
33
|
-
else
|
|
34
|
-
@store[message.topic] = message
|
|
35
|
-
@persistence&.save_retained(message)
|
|
36
|
-
message
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def delete(topic)
|
|
42
|
-
@persistence&.delete_retained(topic)
|
|
43
|
-
@store.delete(topic)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# Enumerate retained messages matching +filter+.
|
|
48
|
-
def each_matching(filter)
|
|
49
|
-
return enum_for(:each_matching, filter) unless block_given?
|
|
50
|
-
@store.each_value do |msg|
|
|
51
|
-
yield msg if TopicMatch.match?(filter, msg.topic)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def size = @store.size
|
|
57
|
-
def empty? = @store.empty?
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# Test helper: wipe everything.
|
|
61
|
-
def clear
|
|
62
|
-
@persistence&.clear_retained
|
|
63
|
-
@store.clear
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module ZZQ
|
|
4
|
-
module Routing
|
|
5
|
-
# Tiny topic-filter matcher (MQTT §4.7). `+` matches exactly one
|
|
6
|
-
# topic level; `#` must be the last segment and matches zero or
|
|
7
|
-
# more remaining levels. Shared-subscription prefixes
|
|
8
|
-
# (`$share/<group>/<filter>`) are stripped before matching.
|
|
9
|
-
#
|
|
10
|
-
# Superseded by a proper trie in M7; this linear matcher is fine
|
|
11
|
-
# for MVP broker traffic.
|
|
12
|
-
module TopicMatch
|
|
13
|
-
module_function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# @param filter [String] topic filter with `+` / `#` wildcards.
|
|
17
|
-
# @param topic [String] concrete topic name.
|
|
18
|
-
# @return [Boolean]
|
|
19
|
-
def match?(filter, topic)
|
|
20
|
-
filter = strip_share(filter)
|
|
21
|
-
return false if topic.start_with?("$") && filter.start_with?("+", "#")
|
|
22
|
-
|
|
23
|
-
f_parts = filter.split("/", -1)
|
|
24
|
-
t_parts = topic.split("/", -1)
|
|
25
|
-
|
|
26
|
-
i = 0
|
|
27
|
-
while i < f_parts.length
|
|
28
|
-
f = f_parts[i]
|
|
29
|
-
if f == "#"
|
|
30
|
-
# Must be last; matches zero or more remaining levels.
|
|
31
|
-
return i == f_parts.length - 1
|
|
32
|
-
end
|
|
33
|
-
return false if i >= t_parts.length
|
|
34
|
-
return false unless f == "+" || f == t_parts[i]
|
|
35
|
-
i += 1
|
|
36
|
-
end
|
|
37
|
-
i == t_parts.length
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def strip_share(filter)
|
|
42
|
-
return filter unless filter.start_with?("$share/")
|
|
43
|
-
# $share/<group>/<filter> — drop first two segments.
|
|
44
|
-
parts = filter.split("/", 3)
|
|
45
|
-
parts.length == 3 ? parts[2] : filter
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|