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
data/lib/zzq/engine.rb
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/clock"
|
|
5
|
+
require "set"
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "engine/socket_lifecycle"
|
|
8
|
+
require_relative "engine/connection_lifecycle"
|
|
9
|
+
|
|
10
|
+
module ZZQ
|
|
11
|
+
# Per-socket orchestrator. Owns listeners, the connection map, the
|
|
12
|
+
# transport registry, and the socket-level state machine. Adapted
|
|
13
|
+
# from NNQ::Engine minus reactor / inproc / SP-handshake concerns.
|
|
14
|
+
class Engine
|
|
15
|
+
# Scheme → transport module registry. Populated by each transport
|
|
16
|
+
# file on require.
|
|
17
|
+
@transports = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
attr_reader :transports
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
attr_reader :options
|
|
26
|
+
attr_reader :routing
|
|
27
|
+
attr_reader :connections # Hash{Protocol::MQTT::Connection => ConnectionLifecycle}
|
|
28
|
+
attr_reader :lifecycle
|
|
29
|
+
attr_reader :last_endpoint
|
|
30
|
+
attr_reader :dialed
|
|
31
|
+
attr_accessor :monitor_queue
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# @yieldparam engine [Engine] build_routing hook; returns the
|
|
35
|
+
# role-specific routing strategy.
|
|
36
|
+
def initialize(options:)
|
|
37
|
+
@options = options
|
|
38
|
+
@connections = {}
|
|
39
|
+
@listeners = []
|
|
40
|
+
@lifecycle = SocketLifecycle.new
|
|
41
|
+
@last_endpoint = nil
|
|
42
|
+
@monitor_queue = nil
|
|
43
|
+
@dialed = Set.new
|
|
44
|
+
@dial_opts = {}
|
|
45
|
+
@routing = yield(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def emit_monitor_event(type, endpoint: nil, detail: nil)
|
|
50
|
+
return unless @monitor_queue
|
|
51
|
+
@monitor_queue.enqueue(MonitorEvent.new(type: type, endpoint: endpoint, detail: detail))
|
|
52
|
+
rescue Async::Stop
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parent_task = @lifecycle.parent_task
|
|
57
|
+
def barrier = @lifecycle.barrier
|
|
58
|
+
def closed? = @lifecycle.closed?
|
|
59
|
+
def peer_connected = @lifecycle.peer_connected
|
|
60
|
+
def all_peers_gone = @lifecycle.all_peers_gone
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_all_peers_gone_if_empty
|
|
64
|
+
@lifecycle.resolve_all_peers_gone_if_empty(@connections)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Must be called from inside an Async::Task.
|
|
69
|
+
def capture_parent_task
|
|
70
|
+
@lifecycle.capture_parent_task
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Bind (listener) — role-agnostic. The routing layer (on accept)
|
|
75
|
+
# drives the MQTT handshake.
|
|
76
|
+
def bind(endpoint, **opts)
|
|
77
|
+
transport = transport_for(endpoint)
|
|
78
|
+
listener = transport.bind(endpoint, self, **opts)
|
|
79
|
+
listener.start_accept_loop(@lifecycle.barrier) do |io|
|
|
80
|
+
handle_accepted(io, endpoint: endpoint)
|
|
81
|
+
end
|
|
82
|
+
@listeners << listener
|
|
83
|
+
@last_endpoint = listener.endpoint
|
|
84
|
+
emit_monitor_event(:listening, endpoint: @last_endpoint)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Connect (dialer).
|
|
89
|
+
def connect(endpoint, **opts)
|
|
90
|
+
@dialed << endpoint
|
|
91
|
+
@dial_opts[endpoint] = opts unless opts.empty?
|
|
92
|
+
@last_endpoint = endpoint
|
|
93
|
+
transport_for(endpoint).connect(endpoint, self, **opts)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def dial_opts_for(endpoint)
|
|
98
|
+
@dial_opts[endpoint] || {}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def maybe_reconnect(endpoint)
|
|
103
|
+
# Reconnect loop not wired up yet (M4 job).
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def transport_for(endpoint)
|
|
108
|
+
scheme = endpoint[/\A([a-z+]+):\/\//i, 1] or raise Error, "no scheme: #{endpoint}"
|
|
109
|
+
Engine.transports[scheme] or raise Error, "unsupported transport: #{scheme}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Called by transports for each accepted connection. Hands off to
|
|
114
|
+
# routing so the broker can read CONNECT / write CONNACK.
|
|
115
|
+
def handle_accepted(io, endpoint:)
|
|
116
|
+
lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint)
|
|
117
|
+
mqtt = lifecycle.begin_handshake!(io)
|
|
118
|
+
@routing.handle_accepted(mqtt, lifecycle) if @routing.respond_to?(:handle_accepted)
|
|
119
|
+
rescue => e
|
|
120
|
+
lifecycle.handshake_failed!(error: e)
|
|
121
|
+
raise unless e.is_a?(ConnectionRejected) || CONNECTION_LOST.any? { |c| e.is_a?(c) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Called by transports for each dialed connection. Hands off to
|
|
126
|
+
# routing so the client can write CONNECT / read CONNACK.
|
|
127
|
+
def handle_connected(io, endpoint:)
|
|
128
|
+
lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint)
|
|
129
|
+
mqtt = lifecycle.begin_handshake!(io)
|
|
130
|
+
@routing.handle_connected(mqtt, lifecycle) if @routing.respond_to?(:handle_connected)
|
|
131
|
+
rescue => e
|
|
132
|
+
lifecycle.handshake_failed!(error: e)
|
|
133
|
+
raise unless e.is_a?(ConnectionRejected) || CONNECTION_LOST.any? { |c| e.is_a?(c) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def spawn_task(annotation:, parent: @lifecycle.barrier, &block)
|
|
138
|
+
parent.async(annotation: annotation, &block)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def close
|
|
143
|
+
return unless @lifecycle.alive?
|
|
144
|
+
|
|
145
|
+
@lifecycle.start_closing!
|
|
146
|
+
@listeners.each(&:stop)
|
|
147
|
+
@routing.close if @routing.respond_to?(:close)
|
|
148
|
+
@connections.values.each(&:close!)
|
|
149
|
+
|
|
150
|
+
emit_monitor_event(:closed)
|
|
151
|
+
@monitor_queue&.enqueue(nil)
|
|
152
|
+
|
|
153
|
+
@lifecycle.barrier&.stop
|
|
154
|
+
@lifecycle.finish_closing!
|
|
155
|
+
@lifecycle.peer_connected.resolve(nil) unless @lifecycle.peer_connected.resolved?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def handle_connection_lost(conn)
|
|
160
|
+
@connections[conn]&.lost!
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/zzq/error.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZZQ
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when sending on or interacting with a closed socket.
|
|
7
|
+
class ClosedError < Error; end
|
|
8
|
+
|
|
9
|
+
# Raised when the routing layer refuses to accept a peer (e.g. a
|
|
10
|
+
# duplicate client id collision on the broker).
|
|
11
|
+
class ConnectionRejected < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when a socket method is called outside an Async::Task. ZZQ
|
|
14
|
+
# is fiber-first: wrap calls in `Async do ... end` or use the block
|
|
15
|
+
# form of #connect / #bind.
|
|
16
|
+
class NotInAsyncContext < Error
|
|
17
|
+
def initialize(msg = "ZZQ socket methods must run inside an Async task. " \
|
|
18
|
+
"Wrap in `Async do ... end`, or use the block form " \
|
|
19
|
+
"of #connect/#bind.")
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Server responded with a non-success CONNACK.
|
|
25
|
+
class ConnectionRefused < Error
|
|
26
|
+
attr_reader :reason_code
|
|
27
|
+
|
|
28
|
+
def initialize(reason_code, message = nil)
|
|
29
|
+
@reason_code = reason_code
|
|
30
|
+
super(message || "broker refused connect: reason=0x#{reason_code.to_s(16)}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Authentication failed.
|
|
35
|
+
class AuthFailed < Error; end
|
|
36
|
+
|
|
37
|
+
# Network errors that indicate the connection is gone.
|
|
38
|
+
CONNECTION_LOST = [
|
|
39
|
+
Errno::ECONNRESET, Errno::EPIPE, Errno::ENOTCONN, Errno::ECONNABORTED,
|
|
40
|
+
Errno::EBADF, EOFError, IOError
|
|
41
|
+
].freeze
|
|
42
|
+
end
|
data/lib/zzq/message.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/mqtt"
|
|
4
|
+
|
|
5
|
+
module ZZQ
|
|
6
|
+
# One MQTT application message as delivered to a subscriber or
|
|
7
|
+
# accepted by {Client#publish} / {Broker#ingest}.
|
|
8
|
+
#
|
|
9
|
+
# `Message` is independent of any particular connection: it carries
|
|
10
|
+
# every wire-level field that a PUBLISH packet does, so it can be
|
|
11
|
+
# shipped across non-MQTT transports (OMQ, NNQ, a file, a queue) via
|
|
12
|
+
# {#to_wire} / {.from_wire} and re-injected at the other end.
|
|
13
|
+
#
|
|
14
|
+
# `#ack!` is only meaningful for QoS 1/2 messages received with
|
|
15
|
+
# `auto_ack: false` — it sends the appropriate PUBACK/PUBCOMP.
|
|
16
|
+
class Message
|
|
17
|
+
attr_reader :topic, :payload, :qos, :retain, :dup, :packet_id, :properties
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def initialize(topic:, payload:, qos: 0, retain: false, dup: false,
|
|
21
|
+
packet_id: nil, properties: {}, on_ack: nil)
|
|
22
|
+
@topic = topic
|
|
23
|
+
@payload = payload.b.freeze
|
|
24
|
+
@qos = qos
|
|
25
|
+
@retain = retain
|
|
26
|
+
@dup = dup
|
|
27
|
+
@packet_id = packet_id
|
|
28
|
+
@properties = properties
|
|
29
|
+
@on_ack = on_ack
|
|
30
|
+
@acked = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ack!
|
|
35
|
+
return if @qos.zero? || @acked
|
|
36
|
+
@acked = true
|
|
37
|
+
@on_ack&.call(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def acked?
|
|
42
|
+
@acked
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Encode this message as a self-describing byte string using the
|
|
47
|
+
# PUBLISH packet codec. The output can be cross-shipped over a
|
|
48
|
+
# non-MQTT transport and reconstructed via {.from_wire}. v5 wire
|
|
49
|
+
# format is used so the full property block round-trips.
|
|
50
|
+
def to_wire
|
|
51
|
+
packet = to_publish
|
|
52
|
+
packet.encode(version: 5)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Inverse of {#to_wire}. Raises +Protocol::MQTT::MalformedPacket+
|
|
57
|
+
# if the bytes don't decode as a PUBLISH packet.
|
|
58
|
+
def self.from_wire(bytes)
|
|
59
|
+
pkt, consumed = Protocol::MQTT::Packet.decode(bytes, version: 5)
|
|
60
|
+
raise Protocol::MQTT::MalformedPacket, "expected PUBLISH, got #{pkt.class}" unless pkt.is_a?(Protocol::MQTT::Packet::Publish)
|
|
61
|
+
raise Protocol::MQTT::MalformedPacket, "trailing bytes after PUBLISH" if consumed != bytes.bytesize
|
|
62
|
+
from_publish(pkt)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Build a PUBLISH packet that represents this message. Used both
|
|
67
|
+
# internally (for over-the-wire transmission) and by {#to_wire}.
|
|
68
|
+
def to_publish
|
|
69
|
+
Protocol::MQTT::Packet::Publish.new(
|
|
70
|
+
topic: @topic,
|
|
71
|
+
payload: @payload,
|
|
72
|
+
qos: @qos,
|
|
73
|
+
retain: @retain,
|
|
74
|
+
dup: @dup,
|
|
75
|
+
packet_id: @packet_id,
|
|
76
|
+
properties: @properties,
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Build a Message from a decoded PUBLISH packet.
|
|
82
|
+
def self.from_publish(pkt, on_ack: nil)
|
|
83
|
+
new(
|
|
84
|
+
topic: pkt.topic,
|
|
85
|
+
payload: pkt.payload,
|
|
86
|
+
qos: pkt.qos,
|
|
87
|
+
retain: pkt.retain,
|
|
88
|
+
dup: pkt.dup,
|
|
89
|
+
packet_id: pkt.packet_id,
|
|
90
|
+
properties: pkt.properties,
|
|
91
|
+
on_ack: on_ack,
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ==(other)
|
|
97
|
+
other.is_a?(Message) &&
|
|
98
|
+
other.topic == @topic &&
|
|
99
|
+
other.payload == @payload &&
|
|
100
|
+
other.qos == @qos &&
|
|
101
|
+
other.retain == @retain &&
|
|
102
|
+
other.dup == @dup &&
|
|
103
|
+
other.packet_id == @packet_id &&
|
|
104
|
+
other.properties == @properties
|
|
105
|
+
end
|
|
106
|
+
alias eql? ==
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def hash
|
|
110
|
+
[self.class, @topic, @payload, @qos, @retain, @dup, @packet_id, @properties].hash
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZZQ
|
|
4
|
+
# A single event emitted by a Socket's #monitor task. Event types
|
|
5
|
+
# cover both infrastructure lifecycle (:listening, :connected,
|
|
6
|
+
# :disconnected, :closed) and MQTT semantics (:session_resumed,
|
|
7
|
+
# :session_expired, :retained_updated, :publish_delivered,
|
|
8
|
+
# :will_published).
|
|
9
|
+
MonitorEvent = Data.define(:type, :endpoint, :detail) do
|
|
10
|
+
def initialize(type:, endpoint: nil, detail: nil)
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/zzq/options.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZZQ
|
|
4
|
+
# Per-socket configuration. Holds both infrastructure options (linger,
|
|
5
|
+
# HWMs, reconnect) lifted from NNQ::Options, and MQTT-specific ones
|
|
6
|
+
# (keepalive, session_expiry, receive_maximum, etc.) that shape the
|
|
7
|
+
# wire behaviour.
|
|
8
|
+
#
|
|
9
|
+
# Clients and brokers share this class; role-irrelevant fields are
|
|
10
|
+
# simply ignored by the other side.
|
|
11
|
+
class Options
|
|
12
|
+
DEFAULT_HWM = 1000
|
|
13
|
+
|
|
14
|
+
# --- infrastructure -------------------------------------------------
|
|
15
|
+
attr_accessor :linger
|
|
16
|
+
attr_accessor :read_timeout
|
|
17
|
+
attr_accessor :write_timeout
|
|
18
|
+
attr_accessor :reconnect_interval
|
|
19
|
+
attr_accessor :send_hwm
|
|
20
|
+
attr_accessor :recv_hwm
|
|
21
|
+
|
|
22
|
+
# --- MQTT wire ------------------------------------------------------
|
|
23
|
+
# 3 = MQTT v3.1.1, 5 = MQTT v5.0
|
|
24
|
+
attr_accessor :version
|
|
25
|
+
|
|
26
|
+
# Client identifier. Client-only; required unless empty-id + v5 is
|
|
27
|
+
# used (server assigns it).
|
|
28
|
+
attr_accessor :client_id
|
|
29
|
+
|
|
30
|
+
# Clean Start (v5) / Clean Session (v3.1.1). When true, any prior
|
|
31
|
+
# session state for this client id is discarded on CONNECT.
|
|
32
|
+
attr_accessor :clean_start
|
|
33
|
+
|
|
34
|
+
# Keep-alive interval in seconds. The broker will close the
|
|
35
|
+
# connection after 1.5× this period with no traffic. 0 disables.
|
|
36
|
+
attr_accessor :keep_alive
|
|
37
|
+
|
|
38
|
+
# Session expiry interval (v5 only), in seconds. Only meaningful
|
|
39
|
+
# when clean_start is false.
|
|
40
|
+
attr_accessor :session_expiry_interval
|
|
41
|
+
|
|
42
|
+
# Maximum outstanding unacknowledged QoS 1/2 messages (v5).
|
|
43
|
+
attr_accessor :receive_maximum
|
|
44
|
+
|
|
45
|
+
# Maximum packet size this peer is willing to accept (v5). nil =
|
|
46
|
+
# unlimited.
|
|
47
|
+
attr_accessor :max_packet_size
|
|
48
|
+
|
|
49
|
+
# Topic Alias Maximum we advertise (v5).
|
|
50
|
+
attr_accessor :topic_alias_maximum
|
|
51
|
+
|
|
52
|
+
# MQTT username / password (both optional).
|
|
53
|
+
attr_accessor :username
|
|
54
|
+
attr_accessor :password
|
|
55
|
+
|
|
56
|
+
# Last Will and Testament, client-side. Nil or a Hash of
|
|
57
|
+
# { topic:, payload:, qos:, retain:, properties: }.
|
|
58
|
+
attr_accessor :will
|
|
59
|
+
|
|
60
|
+
# Broker-side persistence backend. Nil = ephemeral (no durability).
|
|
61
|
+
# Pass an instance of a {ZZQ::Persistence::Interface} subclass
|
|
62
|
+
# (e.g. {ZZQ::Persistence::PStore}) to survive process restarts.
|
|
63
|
+
attr_accessor :persistence
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def initialize(
|
|
67
|
+
linger: Float::INFINITY, send_hwm: DEFAULT_HWM, recv_hwm: DEFAULT_HWM,
|
|
68
|
+
version: 5, client_id: nil, clean_start: true, keep_alive: 60,
|
|
69
|
+
session_expiry_interval: 0, receive_maximum: 65_535,
|
|
70
|
+
max_packet_size: nil, topic_alias_maximum: 0,
|
|
71
|
+
username: nil, password: nil, will: nil,
|
|
72
|
+
persistence: nil
|
|
73
|
+
)
|
|
74
|
+
@linger = linger
|
|
75
|
+
@read_timeout = nil
|
|
76
|
+
@write_timeout = nil
|
|
77
|
+
@reconnect_interval = 0.1
|
|
78
|
+
@send_hwm = send_hwm
|
|
79
|
+
@recv_hwm = recv_hwm
|
|
80
|
+
@version = version
|
|
81
|
+
@client_id = client_id
|
|
82
|
+
@clean_start = clean_start
|
|
83
|
+
@keep_alive = keep_alive
|
|
84
|
+
@session_expiry_interval = session_expiry_interval
|
|
85
|
+
@receive_maximum = receive_maximum
|
|
86
|
+
@max_packet_size = max_packet_size
|
|
87
|
+
@topic_alias_maximum = topic_alias_maximum
|
|
88
|
+
@username = username
|
|
89
|
+
@password = password
|
|
90
|
+
@will = will
|
|
91
|
+
@persistence = persistence
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|