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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +13 -0
- data/README.md +153 -0
- data/lib/zzq/broker.rb +34 -0
- data/lib/zzq/client.rb +51 -0
- data/lib/zzq/engine/connection_lifecycle.rb +139 -0
- data/lib/zzq/engine/keepalive.rb +75 -0
- data/lib/zzq/engine/socket_lifecycle.rb +79 -0
- data/lib/zzq/engine.rb +163 -0
- data/lib/zzq/error.rb +42 -0
- data/lib/zzq/message.rb +113 -0
- data/lib/zzq/monitor_event.rb +14 -0
- data/lib/zzq/options.rb +94 -0
- data/lib/zzq/persistence/interface.rb +43 -0
- data/lib/zzq/persistence/memory.rb +37 -0
- data/lib/zzq/persistence/pstore.rb +66 -0
- data/lib/zzq/routing/broker.rb +380 -0
- data/lib/zzq/routing/client.rb +327 -0
- data/lib/zzq/routing/packet_id_allocator.rb +54 -0
- data/lib/zzq/routing/qos_tracker.rb +108 -0
- data/lib/zzq/routing/retained_store.rb +67 -0
- data/lib/zzq/routing/topic_match.rb +49 -0
- data/lib/zzq/routing/topic_trie.rb +232 -0
- data/lib/zzq/socket.rb +146 -0
- data/lib/zzq/subscription.rb +70 -0
- data/lib/zzq/transport/ipc.rb +92 -0
- data/lib/zzq/transport/mqtt.rb +83 -0
- data/lib/zzq/transport/mqtts.rb +173 -0
- data/lib/zzq/version.rb +5 -0
- data/lib/zzq.rb +26 -0
- metadata +128 -0
|
@@ -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
|