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.
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
@@ -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
@@ -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