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,232 +0,0 @@
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 DELETED
@@ -1,146 +0,0 @@
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
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async/queue"
4
- require "async/limited_queue"
5
-
6
- module ZZQ
7
- # Handle returned by {Client#subscribe} and {Broker#subscribe}. A
8
- # thin wrapper around an +Async::LimitedQueue+ that also owns the
9
- # UNSUBSCRIBE lifecycle.
10
- #
11
- # Callers consume with {#pop} or {#each}; {#close} sends UNSUBSCRIBE
12
- # and wakes any blocked consumer with +nil+.
13
- #
14
- # Created internally — users should never call +.new+ directly.
15
- class Subscription
16
- # @return [Array<String>] topic filters this subscription matches.
17
- attr_reader :filters
18
-
19
- # @return [Integer] subscription identifier (v5) or nil (v3).
20
- attr_reader :subscription_id
21
-
22
-
23
- def initialize(filters:, hwm:, subscription_id: nil, on_close: nil)
24
- @filters = Array(filters).freeze
25
- @subscription_id = subscription_id
26
- @queue = Async::LimitedQueue.new(hwm)
27
- @on_close = on_close
28
- @closed = false
29
- end
30
-
31
-
32
- # Blocks the fiber until a message arrives. Returns +nil+ when
33
- # the subscription is closed.
34
- def pop
35
- return nil if @closed && @queue.empty?
36
- @queue.dequeue
37
- end
38
-
39
-
40
- # Yields each message until the subscription is closed.
41
- def each
42
- return enum_for(:each) unless block_given?
43
- while (msg = pop)
44
- yield msg
45
- end
46
- end
47
-
48
-
49
- # Sends UNSUBSCRIBE and wakes the consumer. Idempotent.
50
- def close
51
- return if @closed
52
- @closed = true
53
- @on_close&.call(self)
54
- @queue.enqueue(nil)
55
- end
56
-
57
-
58
- def closed?
59
- @closed
60
- end
61
-
62
-
63
- # Internal: deliver a message to the subscriber's queue. Blocks
64
- # the caller if the queue is at high-water-mark.
65
- def deliver(message)
66
- return if @closed
67
- @queue.enqueue(message)
68
- end
69
- end
70
- end
@@ -1,92 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "socket"
4
- require "io/stream"
5
- require_relative "../engine"
6
-
7
- module ZZQ
8
- module Transport
9
- # IPC transport using Unix domain sockets.
10
- #
11
- # Endpoint forms:
12
- # * `ipc:///var/run/zzq.sock` — filesystem path (portable).
13
- # * `ipc://@zzq-local` — Linux abstract namespace (the
14
- # leading `@` becomes a NUL byte in sockaddr.sun_path).
15
- module IPC
16
- Engine.transports["ipc"] = self
17
-
18
-
19
- class << self
20
- def bind(endpoint, engine, **)
21
- path = parse_path(endpoint)
22
- sock_path = to_socket_path(path)
23
-
24
- File.delete(sock_path) if !abstract?(path) && File.exist?(sock_path)
25
-
26
- server = UNIXServer.new(sock_path)
27
- Listener.new(endpoint, server, path, engine)
28
- end
29
-
30
-
31
- def connect(endpoint, engine, **)
32
- path = parse_path(endpoint)
33
- sock_path = to_socket_path(path)
34
- sock = UNIXSocket.new(sock_path)
35
- engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
36
- end
37
-
38
-
39
- def parse_path(endpoint)
40
- endpoint.sub(%r{\Aipc://}, "")
41
- end
42
-
43
-
44
- # Linux abstract-namespace Unix sockets: leading `@` becomes a
45
- # NUL byte in sun_path. Only supported on Linux.
46
- def to_socket_path(path)
47
- abstract?(path) ? "\0#{path[1..]}" : path
48
- end
49
-
50
-
51
- def abstract?(path)
52
- path.start_with?("@")
53
- end
54
- end
55
-
56
-
57
- class Listener
58
- attr_reader :endpoint
59
-
60
-
61
- def initialize(endpoint, server, path, engine)
62
- @endpoint = endpoint
63
- @server = server
64
- @path = path
65
- @engine = engine
66
- @task = nil
67
- end
68
-
69
-
70
- def start_accept_loop(parent_task, &on_accepted)
71
- @task = parent_task.async(annotation: "zzq ipc accept #{@endpoint}") do
72
- loop do
73
- client = @server.accept
74
- on_accepted.call(IO::Stream::Buffered.wrap(client))
75
- rescue Async::Stop, IOError
76
- break
77
- end
78
- ensure
79
- @server.close rescue nil
80
- end
81
- end
82
-
83
-
84
- def stop
85
- @task&.stop
86
- @server.close rescue nil
87
- File.delete(@path) rescue nil unless IPC.abstract?(@path)
88
- end
89
- end
90
- end
91
- end
92
- end
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "socket"
4
- require "uri"
5
- require "io/stream"
6
- require_relative "../engine"
7
-
8
- module ZZQ
9
- module Transport
10
- # Plain MQTT-over-TCP transport. Endpoint scheme: `mqtt://host:port`
11
- # (default port 1883). One server per bind, blocking accept inside
12
- # an Async fiber.
13
- module MQTT
14
- Engine.transports["mqtt"] = self
15
-
16
-
17
- class << self
18
- def bind(endpoint, engine, **)
19
- host, port = parse_endpoint(endpoint)
20
- host = "0.0.0.0" if host == "*"
21
- server = TCPServer.new(host, port)
22
- actual = server.local_address.ip_port
23
- host_part = host.include?(":") ? "[#{host}]" : host
24
- Listener.new("mqtt://#{host_part}:#{actual}", server, actual, engine)
25
- end
26
-
27
-
28
- def connect(endpoint, engine, **)
29
- host, port = parse_endpoint(endpoint)
30
- sock = ::Socket.tcp(host, port, connect_timeout: connect_timeout(engine.options))
31
- engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
32
- end
33
-
34
-
35
- def connect_timeout(options)
36
- ri = options.reconnect_interval
37
- ri = ri.end if ri.is_a?(Range)
38
- [ri, 0.5].max
39
- end
40
-
41
-
42
- def parse_endpoint(endpoint)
43
- uri = URI.parse(endpoint.sub("mqtt://", "tcp://"))
44
- [uri.hostname, uri.port]
45
- end
46
- end
47
-
48
-
49
- class Listener
50
- attr_reader :endpoint, :port
51
-
52
-
53
- def initialize(endpoint, server, port, engine)
54
- @endpoint = endpoint
55
- @server = server
56
- @port = port
57
- @engine = engine
58
- @task = nil
59
- end
60
-
61
-
62
- def start_accept_loop(parent_task, &on_accepted)
63
- @task = parent_task.async(annotation: "zzq mqtt accept #{@endpoint}") do
64
- loop do
65
- client = @server.accept
66
- on_accepted.call(IO::Stream::Buffered.wrap(client))
67
- rescue Async::Stop, IOError
68
- break
69
- end
70
- ensure
71
- @server.close rescue nil
72
- end
73
- end
74
-
75
-
76
- def stop
77
- @task&.stop
78
- @server.close rescue nil
79
- end
80
- end
81
- end
82
- end
83
- end