zzq 0.1.0 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/LICENSE-APACHE +202 -0
  4. data/LICENSE-ISC +15 -0
  5. data/README.md +67 -122
  6. data/ext/zzq_native/Cargo.toml +35 -0
  7. data/ext/zzq_native/extconf.rb +8 -0
  8. data/ext/zzq_native/src/broker.rs +163 -0
  9. data/ext/zzq_native/src/client.rs +294 -0
  10. data/ext/zzq_native/src/context.rs +51 -0
  11. data/ext/zzq_native/src/error.rs +117 -0
  12. data/ext/zzq_native/src/lib.rs +52 -0
  13. data/ext/zzq_native/src/op.rs +140 -0
  14. data/ext/zzq_native/src/options.rs +168 -0
  15. data/ext/zzq_native/src/rb.rs +424 -0
  16. data/ext/zzq_native/src/wake.rs +155 -0
  17. data/lib/zzq/broker.rb +90 -21
  18. data/lib/zzq/client.rb +202 -35
  19. data/lib/zzq/errors.rb +37 -0
  20. data/lib/zzq/message.rb +16 -107
  21. data/lib/zzq/options.rb +27 -86
  22. data/lib/zzq/version.rb +2 -1
  23. data/lib/zzq/waiting.rb +65 -0
  24. data/lib/zzq.rb +22 -19
  25. metadata +33 -77
  26. data/LICENSE +0 -13
  27. data/lib/zzq/engine/connection_lifecycle.rb +0 -139
  28. data/lib/zzq/engine/keepalive.rb +0 -75
  29. data/lib/zzq/engine/socket_lifecycle.rb +0 -79
  30. data/lib/zzq/engine.rb +0 -163
  31. data/lib/zzq/error.rb +0 -42
  32. data/lib/zzq/monitor_event.rb +0 -14
  33. data/lib/zzq/persistence/interface.rb +0 -43
  34. data/lib/zzq/persistence/memory.rb +0 -37
  35. data/lib/zzq/persistence/pstore.rb +0 -66
  36. data/lib/zzq/routing/broker.rb +0 -380
  37. data/lib/zzq/routing/client.rb +0 -327
  38. data/lib/zzq/routing/packet_id_allocator.rb +0 -54
  39. data/lib/zzq/routing/qos_tracker.rb +0 -108
  40. data/lib/zzq/routing/retained_store.rb +0 -67
  41. data/lib/zzq/routing/topic_match.rb +0 -49
  42. data/lib/zzq/routing/topic_trie.rb +0 -232
  43. data/lib/zzq/socket.rb +0 -146
  44. data/lib/zzq/subscription.rb +0 -70
  45. data/lib/zzq/transport/ipc.rb +0 -92
  46. data/lib/zzq/transport/mqtt.rb +0 -83
  47. data/lib/zzq/transport/mqtts.rb +0 -173
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ZZQ
4
- module Persistence
5
- # Abstract base for persistence backends.
6
- #
7
- # Concrete backends (Memory, PStore, future Redis/SQLite) implement
8
- # the methods below. Broker state calls through this interface; the
9
- # in-memory runtime state in {Routing::Broker} is the source of
10
- # truth during a session, and the backend is the durable record
11
- # that survives restarts.
12
- #
13
- # v1 scope: retained messages only. Session persistence will land
14
- # alongside session-expiry (M8's deferred piece).
15
- class Interface
16
- # Yields each persisted {ZZQ::Message} (retained). Called once at
17
- # broker start to warm the in-memory RetainedStore.
18
- def load_retained
19
- raise NotImplementedError
20
- end
21
-
22
-
23
- # Persist a retained message under +message.topic+. Replaces any
24
- # existing retained message for that topic.
25
- def save_retained(_message)
26
- raise NotImplementedError
27
- end
28
-
29
-
30
- # Remove the retained message for +topic+ (zero-length-payload
31
- # rule, MQTT-3.3.1-6).
32
- def delete_retained(_topic)
33
- raise NotImplementedError
34
- end
35
-
36
-
37
- # Wipe all retained state. Mostly for tests and operator tooling.
38
- def clear_retained
39
- raise NotImplementedError
40
- end
41
- end
42
- end
43
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "interface"
4
-
5
- module ZZQ
6
- module Persistence
7
- # In-process persistence backend. Default for ephemeral brokers and
8
- # tests. State is lost on process exit.
9
- class Memory < Interface
10
- def initialize
11
- super()
12
- @retained = {} # topic => ZZQ::Message
13
- end
14
-
15
-
16
- def load_retained
17
- return enum_for(:load_retained) unless block_given?
18
- @retained.each_value { |msg| yield msg }
19
- end
20
-
21
-
22
- def save_retained(message)
23
- @retained[message.topic] = message
24
- end
25
-
26
-
27
- def delete_retained(topic)
28
- @retained.delete(topic)
29
- end
30
-
31
-
32
- def clear_retained
33
- @retained.clear
34
- end
35
- end
36
- end
37
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "pstore"
5
- require "protocol/mqtt"
6
- require_relative "interface"
7
- require_relative "../message"
8
-
9
- module ZZQ
10
- module Persistence
11
- # PStore-backed durable persistence. Single-process deployments
12
- # only — PStore uses file locking but doesn't coordinate well
13
- # across processes.
14
- #
15
- # `ultra_safe` is enabled by default: each commit does a full fsync
16
- # of the data file AND its parent directory, surviving power loss
17
- # (not just clean crashes). Fits a broker where "I told the
18
- # publisher QoS 1 ack" must survive a kernel panic.
19
- #
20
- # Messages are serialized via {ZZQ::Message#to_wire}, so the
21
- # on-disk format is the PUBLISH wire encoding — self-describing
22
- # and version-tagged.
23
- class PStore < Interface
24
- DEFAULT_FILENAME = "retained.pstore"
25
-
26
-
27
- def initialize(data_dir:, filename: DEFAULT_FILENAME, ultra_safe: true)
28
- super()
29
- FileUtils.mkdir_p(data_dir)
30
- @store = ::PStore.new(File.join(data_dir, filename))
31
- @store.ultra_safe = ultra_safe
32
- end
33
-
34
-
35
- def load_retained
36
- return enum_for(:load_retained) unless block_given?
37
- @store.transaction(true) do
38
- (@store[:retained] || {}).each_value do |bytes|
39
- yield ZZQ::Message.from_wire(bytes)
40
- end
41
- end
42
- end
43
-
44
-
45
- def save_retained(message)
46
- bytes = message.to_wire
47
- @store.transaction do
48
- @store[:retained] ||= {}
49
- @store[:retained][message.topic] = bytes
50
- end
51
- end
52
-
53
-
54
- def delete_retained(topic)
55
- @store.transaction do
56
- (@store[:retained] || {}).delete(topic)
57
- end
58
- end
59
-
60
-
61
- def clear_retained
62
- @store.transaction { @store[:retained] = {} }
63
- end
64
- end
65
- end
66
- end
@@ -1,380 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async"
4
- require "securerandom"
5
- require "protocol/mqtt"
6
- require_relative "../engine/keepalive"
7
- require_relative "../message"
8
- require_relative "packet_id_allocator"
9
- require_relative "qos_tracker"
10
- require_relative "retained_store"
11
- require_relative "topic_match"
12
- require_relative "topic_trie"
13
-
14
- module ZZQ
15
- module Routing
16
- # Broker-side routing.
17
- #
18
- # Current scope:
19
- # * Read CONNECT, send CONNACK.
20
- # * Per-connection read loop: PINGREQ/PINGRESP, DISCONNECT,
21
- # PUBLISH fan-out to matching sessions (QoS 0/1/2), SUBSCRIBE /
22
- # UNSUBSCRIBE with SUBACK / UNSUBACK, and PUBACK/PUBREC/PUBREL/
23
- # PUBCOMP to drive outbound QoS state per session.
24
- # * Simple linear session map; proper topic trie + retained store
25
- # come in M7.
26
- class Broker
27
- CONNECT_TIMEOUT = 10 # seconds to wait for the initial CONNECT
28
-
29
-
30
- Session = Struct.new(
31
- :client_id, :conn, :subscriptions, :packet_ids, :qos_tracker,
32
- :will, :keepalive, :clean_disconnect,
33
- keyword_init: true,
34
- )
35
-
36
-
37
- def initialize(engine)
38
- @engine = engine
39
- @sessions = {} # client_id => Session
40
- @by_conn = {} # Connection => Session
41
- @trie = TopicTrie.new
42
- @retained = RetainedStore.new(persistence: engine.options.persistence)
43
- end
44
-
45
-
46
- def handle_accepted(mqtt, lifecycle)
47
- connect = nil
48
- Async::Task.current.with_timeout(CONNECT_TIMEOUT) do
49
- connect = mqtt.read_connect_packet
50
- raise ConnectionRejected, "no CONNECT received" if connect.nil?
51
-
52
- client_id = connect.client_id
53
- if client_id.empty? && mqtt.version == 3 && !connect.clean_start
54
- write_connack(mqtt, reason: Protocol::MQTT::ReasonCodes::V3Connack::IDENTIFIER_REJECTED)
55
- raise ConnectionRejected, "empty ClientId with clean_session=false is illegal in v3"
56
- end
57
- assigned = client_id.empty?
58
- client_id = generate_client_id if assigned
59
-
60
- # v5 Receive Maximum: client advertises the max number of
61
- # outstanding QoS > 0 publishes it is willing to accept. Sized
62
- # on the packet-id allocator used for fan-out to this session.
63
- client_rcv_max = connect.properties[:receive_maximum] || 65_535
64
- session = build_session(client_id, mqtt, will: connect.will,
65
- receive_maximum: client_rcv_max)
66
- @sessions[client_id] = session
67
- @by_conn[mqtt] = session
68
-
69
- write_connack(mqtt, reason: 0, assigned_client_id: assigned ? client_id : nil)
70
- end
71
-
72
- lifecycle.handshake_succeeded!
73
- start_read_loop(mqtt, lifecycle)
74
- start_keepalive(mqtt, lifecycle, connect.keep_alive)
75
- end
76
-
77
-
78
- def connection_added(_conn); end
79
-
80
-
81
- def connection_removed(conn)
82
- session = @by_conn.delete(conn)
83
- return unless session
84
- publish_will(session) unless session.clean_disconnect
85
- @sessions.delete(session.client_id)
86
- @trie.remove_session(session)
87
- end
88
-
89
-
90
- # Publish the client's Last Will and Testament via the normal
91
- # dispatch path. Called when the session is torn down without
92
- # having seen a clean DISCONNECT.
93
- def publish_will(session)
94
- will = session.will
95
- return unless will
96
- msg = Message.new(
97
- topic: will.fetch(:topic),
98
- payload: will.fetch(:payload),
99
- qos: will.fetch(:qos, 0),
100
- retain: will.fetch(:retain, false),
101
- properties: will.fetch(:properties, {}) || {},
102
- )
103
- # Also honor RETAIN on wills.
104
- apply_retain(msg) if msg.retain
105
- dispatch_message(msg)
106
- end
107
-
108
-
109
- def close; end
110
-
111
-
112
- # Used by tests.
113
- def session_count = @sessions.size
114
-
115
-
116
- # Fan out a Message to every session subscribed to a matching
117
- # filter. Used by both inbound PUBLISH handling and
118
- # {Broker#ingest} from a non-MQTT source.
119
- def dispatch_message(message, origin: nil)
120
- delivered = {} # session.object_id => true (one copy per session)
121
- @trie.match(message.topic).each do |hit|
122
- # v5 No Local (MQTT-3.8.3-3): when set on the subscription,
123
- # don't echo a publish back to the publishing client.
124
- next if hit.entry[:no_local] && origin && hit.session.conn.equal?(origin)
125
- next if delivered[hit.session.object_id]
126
- delivered[hit.session.object_id] = true
127
- forward_publish(hit.session, message, hit.entry)
128
- end
129
- end
130
-
131
-
132
- private
133
-
134
-
135
- def build_session(client_id, mqtt, will: nil, receive_maximum: 65_535)
136
- Session.new(
137
- client_id: client_id,
138
- conn: mqtt,
139
- subscriptions: [],
140
- packet_ids: PacketIdAllocator.new(receive_maximum: receive_maximum),
141
- qos_tracker: QosTracker.new,
142
- will: will,
143
- keepalive: nil,
144
- clean_disconnect: false,
145
- )
146
- end
147
-
148
-
149
- def start_keepalive(mqtt, lifecycle, keep_alive)
150
- return if keep_alive.nil? || keep_alive.zero?
151
- session = @by_conn[mqtt] or return
152
- session.keepalive = Engine::Keepalive.new(
153
- mqtt, keep_alive: keep_alive, role: :watcher,
154
- on_timeout: -> { lifecycle.lost! },
155
- )
156
- lifecycle.barrier.async(annotation: "zzq broker keepalive #{lifecycle.endpoint}") do
157
- session.keepalive.run
158
- end
159
- end
160
-
161
-
162
- def write_connack(mqtt, reason:, assigned_client_id: nil, session_present: false)
163
- props = {}
164
- props[:assigned_client_identifier] = assigned_client_id if assigned_client_id && mqtt.version == 5
165
- mqtt.write_packet(
166
- Protocol::MQTT::Packet::Connack.new(
167
- session_present: session_present,
168
- reason_code: reason,
169
- properties: props,
170
- ),
171
- )
172
- end
173
-
174
-
175
- def generate_client_id
176
- "zzq-#{SecureRandom.hex(6)}"
177
- end
178
-
179
-
180
- def start_read_loop(mqtt, lifecycle)
181
- lifecycle.barrier.async(annotation: "zzq broker read #{lifecycle.endpoint}") do
182
- loop do
183
- pkt = mqtt.read_packet
184
- break if pkt.nil?
185
- @by_conn[mqtt]&.keepalive&.note_received
186
- dispatch(pkt, mqtt)
187
- rescue *CONNECTION_LOST, Protocol::MQTT::MalformedPacket
188
- break
189
- end
190
- ensure
191
- lifecycle.lost! unless lifecycle.state == :closed
192
- end
193
- end
194
-
195
-
196
- def dispatch(pkt, mqtt)
197
- case pkt
198
- when Protocol::MQTT::Packet::Pingreq
199
- mqtt.write_packet(Protocol::MQTT::Packet::Pingresp.new)
200
- when Protocol::MQTT::Packet::Disconnect
201
- # Clean DISCONNECT: v3 = any disconnect; v5 = reason 0x00
202
- # (NORMAL_DISCONNECTION). v5 reason 0x04 explicitly requests
203
- # that the will still be published. Others suppress (spec).
204
- session = @by_conn[mqtt]
205
- if session
206
- reason = mqtt.version == 5 ? pkt.reason_code : 0
207
- session.clean_disconnect = (reason != 0x04)
208
- end
209
- raise EOFError # cleanly unwind the read loop
210
- when Protocol::MQTT::Packet::Publish
211
- handle_publish(pkt, mqtt)
212
- when Protocol::MQTT::Packet::Subscribe
213
- handle_subscribe(pkt, mqtt)
214
- when Protocol::MQTT::Packet::Unsubscribe
215
- handle_unsubscribe(pkt, mqtt)
216
- when Protocol::MQTT::Packet::Puback
217
- handle_puback(pkt, mqtt)
218
- when Protocol::MQTT::Packet::Pubrec
219
- handle_pubrec(pkt, mqtt)
220
- when Protocol::MQTT::Packet::Pubrel
221
- handle_pubrel(pkt, mqtt)
222
- when Protocol::MQTT::Packet::Pubcomp
223
- handle_pubcomp(pkt, mqtt)
224
- end
225
- end
226
-
227
-
228
- def handle_publish(pkt, mqtt)
229
- session = @by_conn[mqtt] or return
230
- message = Message.from_publish(pkt)
231
- case pkt.qos
232
- when 0
233
- apply_retain(message) if pkt.retain
234
- dispatch_message(message, origin: mqtt)
235
- when 1
236
- apply_retain(message) if pkt.retain
237
- dispatch_message(message, origin: mqtt)
238
- mqtt.write_packet(Protocol::MQTT::Packet::Puback.new(packet_id: pkt.packet_id))
239
- when 2
240
- # Fan out only on first receipt; duplicates are still PUBRECed.
241
- if session.qos_tracker.on_qos2_publish(pkt.packet_id)
242
- apply_retain(message) if pkt.retain
243
- dispatch_message(message, origin: mqtt)
244
- end
245
- mqtt.write_packet(Protocol::MQTT::Packet::Pubrec.new(packet_id: pkt.packet_id))
246
- end
247
- end
248
-
249
-
250
- def apply_retain(message)
251
- @retained.store(message)
252
- end
253
-
254
-
255
- def handle_subscribe(pkt, mqtt)
256
- session = @by_conn[mqtt] or return
257
- added = []
258
- reasons = pkt.filters.map do |entry|
259
- sub_entry = {
260
- filter: entry.fetch(:filter),
261
- qos: entry.fetch(:qos, 0),
262
- no_local: entry.fetch(:no_local, false),
263
- retain_as_published: entry.fetch(:retain_as_published, false),
264
- retain_handling: entry.fetch(:retain_handling, 0),
265
- }
266
- session.subscriptions << sub_entry
267
- @trie.add(sub_entry[:filter], session, sub_entry)
268
- added << sub_entry
269
- sub_entry[:qos] # granted QoS == requested (MVP)
270
- end
271
- mqtt.write_packet(Protocol::MQTT::Packet::Suback.new(
272
- packet_id: pkt.packet_id, reason_codes: reasons,
273
- ))
274
- # Replay retained messages for each newly-added filter.
275
- added.each { |sub_entry| replay_retained(session, sub_entry) }
276
- end
277
-
278
-
279
- def handle_unsubscribe(pkt, mqtt)
280
- session = @by_conn[mqtt]
281
- reasons = pkt.filters.map do |filter|
282
- matched = session&.subscriptions&.reject! { |s| s[:filter] == filter }
283
- @trie.remove(filter, session) if matched && session
284
- matched && !matched.empty? ? 0x00 : 0x11 # 0x11 = No subscription existed
285
- end
286
- mqtt.write_packet(Protocol::MQTT::Packet::Unsuback.new(
287
- packet_id: pkt.packet_id, reason_codes: reasons,
288
- ))
289
- end
290
-
291
-
292
- # For each retained message matching the new filter, deliver a
293
- # PUBLISH copy with RETAIN=1 (MQTT-3.3.1-8). Retain handling per
294
- # v5: 0 = send retained on every SUBSCRIBE; 1 = only if the sub
295
- # didn't exist; 2 = never. MVP honours 0/2; 1 is a future polish.
296
- def replay_retained(session, sub_entry)
297
- return if sub_entry[:retain_handling] == 2
298
- @retained.each_matching(sub_entry[:filter]) do |msg|
299
- forward_publish(session, msg, sub_entry, retain: true)
300
- end
301
- end
302
-
303
-
304
- # --- outbound-QoS ack handlers -------------------------------------
305
-
306
- def handle_puback(pkt, mqtt)
307
- session = @by_conn[mqtt] or return
308
- pending = session.qos_tracker.on_puback(pkt.packet_id)
309
- session.packet_ids.release(pkt.packet_id) if pending
310
- end
311
-
312
-
313
- def handle_pubrec(pkt, mqtt)
314
- session = @by_conn[mqtt] or return
315
- entry = session.qos_tracker.on_pubrec(pkt.packet_id)
316
- return unless entry
317
- mqtt.write_packet(Protocol::MQTT::Packet::Pubrel.new(packet_id: pkt.packet_id))
318
- end
319
-
320
-
321
- # Inbound PUBREL: the client is telling us to release its own
322
- # inbound QoS 2 dedup entry (it published at QoS 2; we PUBRECed;
323
- # now it's clearing). Respond with PUBCOMP.
324
- def handle_pubrel(pkt, mqtt)
325
- session = @by_conn[mqtt] or return
326
- session.qos_tracker.on_pubrel(pkt.packet_id)
327
- mqtt.write_packet(Protocol::MQTT::Packet::Pubcomp.new(packet_id: pkt.packet_id))
328
- end
329
-
330
-
331
- def handle_pubcomp(pkt, mqtt)
332
- session = @by_conn[mqtt] or return
333
- pending = session.qos_tracker.on_pubcomp(pkt.packet_id)
334
- session.packet_ids.release(pkt.packet_id) if pending
335
- end
336
-
337
-
338
- def forward_publish(session, message, entry, retain: false)
339
- granted_qos = [message.qos, entry[:qos]].min
340
- # retain_as_published (v5): when true, preserve publisher's
341
- # RETAIN flag; otherwise spec forces RETAIN=0 on fan-out (and
342
- # =1 only on retained-replay, tracked via the +retain+ arg).
343
- out_retain =
344
- if retain
345
- true
346
- elsif entry[:retain_as_published]
347
- message.retain
348
- else
349
- false
350
- end
351
-
352
- if granted_qos.zero?
353
- out = Protocol::MQTT::Packet::Publish.new(
354
- topic: message.topic, payload: message.payload,
355
- qos: 0, retain: out_retain, dup: false, packet_id: nil,
356
- properties: message.properties,
357
- )
358
- session.conn.write_packet(out)
359
- return
360
- end
361
-
362
- packet_id = session.packet_ids.acquire
363
- out = Protocol::MQTT::Packet::Publish.new(
364
- topic: message.topic, payload: message.payload,
365
- qos: granted_qos, retain: out_retain, dup: false,
366
- packet_id: packet_id, properties: message.properties,
367
- )
368
- if granted_qos == 1
369
- session.qos_tracker.track_qos1(packet_id, out)
370
- else
371
- session.qos_tracker.track_qos2(packet_id, out)
372
- end
373
- session.conn.write_packet(out)
374
- rescue *CONNECTION_LOST, ClosedError
375
- # recipient is gone; tear-down handled by their read loop.
376
- session.packet_ids.release(packet_id) if packet_id
377
- end
378
- end
379
- end
380
- end