zzq 0.1.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.
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,108 @@
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
@@ -0,0 +1,67 @@
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
@@ -0,0 +1,49 @@
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
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "topic_match"
4
+
5
+ module ZZQ
6
+ module Routing
7
+ # Per-level topic-filter trie. Each node holds literal children,
8
+ # one +wildcard ("+") child, and one hash-wildcard ("#") child.
9
+ # Subscriptions are stored at the terminal node of their filter.
10
+ #
11
+ # Matches are collected by walking a topic down the trie, traversing
12
+ # literal + '+' branches in parallel and collecting '#' subtree
13
+ # entries at every step. Honours the $SYS-vs-bare-wildcard rule
14
+ # (MQTT-4.7.2-1): filters starting with `+` or `#` at the root do
15
+ # not match topics starting with `$SYS`.
16
+ #
17
+ # Shared-subscription filters ("$share/<group>/<filter>") are
18
+ # stored with their group stripped off but tagged with the group
19
+ # name on each SubscriberEntry; fan-out chooses exactly one entry
20
+ # per group (MVP: round-robin).
21
+ #
22
+ # Not thread-safe — a broker-owned trie is mutated from the broker
23
+ # fibers only.
24
+ class TopicTrie
25
+ SubscriberEntry = Struct.new(:session, :entry, :share_group, keyword_init: true) do
26
+ # Forward-compat shorthand for fan-out.
27
+ def filter = entry[:filter]
28
+ end
29
+
30
+
31
+ class Node
32
+ attr_accessor :plus_child, :hash_child
33
+ attr_reader :children, :entries
34
+
35
+
36
+ def initialize
37
+ @children = {}
38
+ @plus_child = nil
39
+ @hash_child = nil
40
+ @entries = []
41
+ end
42
+
43
+
44
+ def empty?
45
+ @children.empty? && @plus_child.nil? && @hash_child.nil? && @entries.empty?
46
+ end
47
+ end
48
+
49
+
50
+ def initialize
51
+ @root = Node.new
52
+ # Round-robin cursor for shared groups: group_key => index.
53
+ @share_cursor = Hash.new(0)
54
+ end
55
+
56
+
57
+ # Insert a subscription. +filter+ may be a plain MQTT filter
58
+ # ("a/+/c", "#", "$SYS/b/+") or a shared-subscription filter
59
+ # ("$share/group/a/+/c").
60
+ def add(filter, session, entry)
61
+ real_filter, group = strip_share(filter)
62
+ node, _segs = walk_or_create(real_filter)
63
+ node.entries << SubscriberEntry.new(session: session, entry: entry, share_group: group)
64
+ end
65
+
66
+
67
+ # Remove the entry for (session, filter). Returns true if found.
68
+ def remove(filter, session)
69
+ real_filter, _group = strip_share(filter)
70
+ segs = real_filter.split("/", -1)
71
+ path = [@root]
72
+ segs.each do |seg|
73
+ node = path.last
74
+ nxt =
75
+ case seg
76
+ when "+" then node.plus_child
77
+ when "#" then node.hash_child
78
+ else node.children[seg]
79
+ end
80
+ return false unless nxt
81
+ path << nxt
82
+ end
83
+ leaf = path.last
84
+ before = leaf.entries.size
85
+ leaf.entries.reject! { |e| e.session.equal?(session) && e.entry[:filter] == filter }
86
+ return false if leaf.entries.size == before
87
+ prune(path, segs)
88
+ true
89
+ end
90
+
91
+
92
+ # Drop every entry belonging to +session+, pruning empty nodes.
93
+ def remove_session(session)
94
+ walk_prune(@root, []) { |e| e.session.equal?(session) }
95
+ end
96
+
97
+
98
+ # Return the matching subscriber entries for +topic+. One entry
99
+ # per (session, filter); shared-group collapse is performed here
100
+ # so fan-out sees exactly one delivery per $share group.
101
+ def match(topic)
102
+ segs = topic.split("/", -1)
103
+ collected = []
104
+ traverse(@root, segs, 0, topic.start_with?("$"), collected)
105
+ collapse_shared(collected)
106
+ end
107
+
108
+
109
+ def empty? = @root.empty?
110
+
111
+
112
+ private
113
+
114
+
115
+ SHARE_PREFIX = "$share/"
116
+
117
+
118
+ def strip_share(filter)
119
+ return [filter, nil] unless filter.start_with?(SHARE_PREFIX)
120
+ rest = filter[SHARE_PREFIX.length..]
121
+ slash = rest.index("/") or raise ArgumentError, "malformed $share filter: #{filter}"
122
+ group = rest[0, slash]
123
+ real = rest[(slash + 1)..]
124
+ raise ArgumentError, "$share filter has empty group: #{filter}" if group.empty?
125
+ raise ArgumentError, "$share filter has empty inner filter: #{filter}" if real.empty?
126
+ [real, group]
127
+ end
128
+
129
+
130
+ def walk_or_create(filter)
131
+ segs = filter.split("/", -1)
132
+ node = @root
133
+ segs.each do |seg|
134
+ node =
135
+ case seg
136
+ when "+"
137
+ node.plus_child ||= Node.new
138
+ when "#"
139
+ node.hash_child ||= Node.new
140
+ else
141
+ node.children[seg] ||= Node.new
142
+ end
143
+ end
144
+ [node, segs]
145
+ end
146
+
147
+
148
+ def prune(path, segs)
149
+ # path[i] is the node reached after segs[0..i-1]; path[0] = root.
150
+ (segs.length - 1).downto(0) do |i|
151
+ parent = path[i]
152
+ child = path[i + 1]
153
+ next unless child.empty?
154
+ seg = segs[i]
155
+ case seg
156
+ when "+" then parent.plus_child = nil
157
+ when "#" then parent.hash_child = nil
158
+ else parent.children.delete(seg)
159
+ end
160
+ end
161
+ end
162
+
163
+
164
+ def walk_prune(node, path_segs, &block)
165
+ node.entries.reject!(&block)
166
+ node.children.each do |seg, child|
167
+ walk_prune(child, path_segs + [seg], &block)
168
+ node.children.delete(seg) if child.empty?
169
+ end
170
+ if node.plus_child
171
+ walk_prune(node.plus_child, path_segs + ["+"], &block)
172
+ node.plus_child = nil if node.plus_child.empty?
173
+ end
174
+ if node.hash_child
175
+ walk_prune(node.hash_child, path_segs + ["#"], &block)
176
+ node.hash_child = nil if node.hash_child.empty?
177
+ end
178
+ end
179
+
180
+
181
+ # Walk all trie branches compatible with segs[i..]. Collect
182
+ # entries at every node that marks a terminal filter. Rules:
183
+ # * +#+ at a node matches the rest of the topic (including zero
184
+ # more segments).
185
+ # * MQTT-4.7.2-1: filters starting with +/# at the root don't
186
+ # match topics starting with $.
187
+ def traverse(node, segs, i, sys_topic, out)
188
+ at_root = i.zero?
189
+
190
+ if node.hash_child && !(at_root && sys_topic)
191
+ out.concat(node.hash_child.entries)
192
+ end
193
+
194
+ if i == segs.length
195
+ out.concat(node.entries)
196
+ return
197
+ end
198
+
199
+ seg = segs[i]
200
+
201
+ if (lit = node.children[seg])
202
+ traverse(lit, segs, i + 1, sys_topic, out)
203
+ end
204
+
205
+ if (plus = node.plus_child) && !(at_root && sys_topic)
206
+ traverse(plus, segs, i + 1, sys_topic, out)
207
+ end
208
+ end
209
+
210
+
211
+ # For shared subscriptions: group matches by (group, filter),
212
+ # pick one member round-robin; unshared entries pass through.
213
+ def collapse_shared(entries)
214
+ return entries if entries.all? { |e| e.share_group.nil? }
215
+ out = []
216
+ grouped = Hash.new { |h, k| h[k] = [] }
217
+ entries.each do |e|
218
+ if e.share_group
219
+ grouped[[e.share_group, e.entry[:filter]]] << e
220
+ else
221
+ out << e
222
+ end
223
+ end
224
+ grouped.each do |key, members|
225
+ idx = (@share_cursor[key] += 1) % members.size
226
+ out << members[idx]
227
+ end
228
+ out
229
+ end
230
+ end
231
+ end
232
+ end
data/lib/zzq/socket.rb ADDED
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+ require_relative "error"
6
+ require_relative "monitor_event"
7
+ require_relative "options"
8
+ require_relative "engine"
9
+
10
+ module ZZQ
11
+ # Abstract base class for {ZZQ::Client} and {ZZQ::Broker}. Owns the
12
+ # Engine and exposes the infrastructure API (bind / connect / close /
13
+ # monitor / peer_connected / all_peers_gone). The role-specific API
14
+ # (subscribe / publish / ingest) lives on the subclasses.
15
+ #
16
+ # ZZQ is pure-Async: every public method that performs I/O must run
17
+ # inside an Async::Task. Either wrap calls in `Async do … end`, or
18
+ # use the block form of #connect / #bind which enters `Async{}` for
19
+ # you and closes the socket on block exit.
20
+ class Socket
21
+ attr_reader :options
22
+
23
+
24
+ def self.bind(endpoint, **opts, &block)
25
+ sock = new(**opts)
26
+ if block
27
+ sock.bind(endpoint, &block)
28
+ else
29
+ sock.bind(endpoint)
30
+ sock
31
+ end
32
+ end
33
+
34
+
35
+ def self.connect(endpoint, **opts, &block)
36
+ sock = new(**opts)
37
+ if block
38
+ sock.connect(endpoint, &block)
39
+ else
40
+ sock.connect(endpoint)
41
+ sock
42
+ end
43
+ end
44
+
45
+
46
+ def initialize(**opts)
47
+ @options = Options.new(**opts)
48
+ @engine = Engine.new(options: @options) { |engine| build_routing(engine) }
49
+ end
50
+
51
+
52
+ # Binds to +endpoint+. If a block is given, wraps the call in
53
+ # `Async do … end` (if not already inside one), yields the owning
54
+ # Async::Task to the block, and closes the socket on block exit.
55
+ def bind(endpoint, **opts, &block)
56
+ block ? run_in_async(endpoint, :bind, opts, &block) : run_sync(:bind, endpoint, **opts)
57
+ end
58
+
59
+
60
+ # Connects to +endpoint+. Block form yields the owning Async::Task
61
+ # and auto-closes on block exit, same as {#bind}.
62
+ def connect(endpoint, **opts, &block)
63
+ block ? run_in_async(endpoint, :connect, opts, &block) : run_sync(:connect, endpoint, **opts)
64
+ end
65
+
66
+
67
+ def close
68
+ if Async::Task.current?
69
+ @engine.close
70
+ else
71
+ Async { @engine.close }.wait
72
+ end
73
+ nil
74
+ end
75
+
76
+
77
+ def last_endpoint = @engine.last_endpoint
78
+ def connection_count = @engine.connections.size
79
+ def peer_connected = @engine.peer_connected
80
+ def all_peers_gone = @engine.all_peers_gone
81
+
82
+
83
+ # Yields lifecycle events for this socket until it's closed. Must
84
+ # be called from within an Async::Task.
85
+ def monitor(&block)
86
+ raise NotInAsyncContext unless Async::Task.current?
87
+ queue = Async::Queue.new
88
+ @engine.monitor_queue = queue
89
+ @engine.spawn_task(annotation: "zzq monitor") do
90
+ while (event = queue.dequeue)
91
+ block.call(event)
92
+ end
93
+ rescue Async::Stop
94
+ ensure
95
+ @engine.monitor_queue = nil
96
+ block.call(MonitorEvent.new(type: :monitor_stopped))
97
+ end
98
+ end
99
+
100
+
101
+ # Coerces +body+ to a frozen BINARY String. MQTT payloads are
102
+ # opaque bytes — uniform frozen+BINARY contract mirrors NNQ.
103
+ def coerce_binary(body)
104
+ body = body.to_str unless body.is_a?(String)
105
+ body.force_encoding(Encoding::BINARY) unless body.frozen? || body.encoding == Encoding::BINARY
106
+ body.freeze
107
+ end
108
+
109
+
110
+ private
111
+
112
+
113
+ def run_sync(op, endpoint, **opts)
114
+ raise NotInAsyncContext unless Async::Task.current?
115
+ @engine.capture_parent_task
116
+ @engine.public_send(op, endpoint, **opts)
117
+ end
118
+
119
+
120
+ def run_in_async(endpoint, op, opts, &block)
121
+ if (task = Async::Task.current?)
122
+ run_sync(op, endpoint, **opts)
123
+ begin
124
+ block.call(task)
125
+ ensure
126
+ close
127
+ end
128
+ else
129
+ Async do |task|
130
+ run_sync(op, endpoint, **opts)
131
+ begin
132
+ block.call(task)
133
+ ensure
134
+ close
135
+ end
136
+ end.wait
137
+ end
138
+ end
139
+
140
+
141
+ # Subclass hook — return a role-specific routing strategy instance.
142
+ def build_routing(_engine)
143
+ raise NotImplementedError, "#{self.class} must implement #build_routing"
144
+ end
145
+ end
146
+ end