zzq 0.1.1 → 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 +24 -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 -169
  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
data/lib/zzq/client.rb CHANGED
@@ -1,51 +1,218 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "socket"
4
-
5
3
  module ZZQ
6
- # MQTT client. Owns exactly one MQTT session at a time (possibly
7
- # persisted across reconnects), subscriptions, and a QoS tracker for
8
- # inflight PUB flows.
4
+ # An MQTT 3.1.1 or 5.0 client. Calls wait without blocking other fibers
5
+ # under a fiber scheduler such as Async, and release the GVL in threads.
6
+ #
7
+ # Publishing and subscribing work from any fiber or thread. One fiber at
8
+ # a time receives.
9
9
  #
10
- # M3 stub: only the constructor and {#build_routing} stand. The
11
- # CONNECT/CONNACK handshake and publish/subscribe plumbing come in
12
- # M4 / M5.
13
- class Client < Socket
14
- def build_routing(engine)
15
- Routing::Client.new(engine)
10
+ # @example
11
+ # ZZQ::Client.connect("mqtt://localhost", client_id: "reader") do |client|
12
+ # client.subscribe("sensors/#", qos: 1)
13
+ # client.each { |message| puts "#{message.topic}: #{message.payload}" }
14
+ # end
15
+ class Client
16
+ OPTIONS = %i[
17
+ client_id version clean_start keep_alive session_expiry username password will inbox
18
+ reconnect connect_timeout tls
19
+ ].freeze
20
+
21
+ # Messages a receive takes from the native inbox at once.
22
+ BATCH = 64
23
+
24
+ # Connects a new client. With a block, yields it and closes it after.
25
+ #
26
+ # @param endpoint [String] see {#connect}
27
+ # @param options [Hash] see {#initialize}
28
+ # @return [Client, Object] the client, or the block's result
29
+ def self.connect(endpoint, **options, &block)
30
+ new(**options).connect(endpoint, &block)
16
31
  end
17
32
 
33
+ # @param client_id [String] empty lets a v5 broker assign one
34
+ # @param version [Integer] 5 (default) or 3 for MQTT 3.1.1
35
+ # @param clean_start [Boolean] start a new session (default true)
36
+ # @param keep_alive [Integer] seconds, 60 by default
37
+ # @param session_expiry [Integer] v5 seconds the broker keeps the session
38
+ # @param username [String, nil]
39
+ # @param password [String, nil]
40
+ # @param will [Hash, nil] +topic:+, +payload:+, +qos:+, +retain:+, +delay:+
41
+ # @param inbox [Integer] messages the client buffers before it stops
42
+ # reading, 4096 by default
43
+ # @param reconnect [Boolean, Hash] reconnect after a loss, optionally
44
+ # with +min:+ and +max:+ seconds between attempts
45
+ # @param connect_timeout [Numeric] seconds, 10 by default
46
+ # @param tls [Hash, nil] +ca:+, +cert:+, +key:+ (PEM strings),
47
+ # +server_name:+, +insecure:+
48
+ def initialize(**options)
49
+ @options = Options.check(options, OPTIONS)
50
+ @native = nil
51
+ @inbox = []
52
+ @receiving = false
53
+ @closed = false
54
+ end
18
55
 
19
- # Most recent CONNACK received from the broker, or nil before the
20
- # first successful connect.
21
- def last_connack = @engine.routing.last_connack
22
- def session_present? = @engine.routing.session_present?
56
+ # Connects and waits for CONNACK. With a block, yields the client and
57
+ # closes it after.
58
+ #
59
+ # @param endpoint [String] +mqtt://host[:port]+, +mqtts://+, +tcp://+,
60
+ # +tls://+, +ws://+, +wss://+, +quic://+, +ipc://+ or +inproc://+
61
+ # @return [self, Object] the client, or the block's result
62
+ # @raise [ConnectionRefused] if the broker refuses the connection
63
+ # @raise [TransportError] if the connection fails
64
+ def connect(endpoint)
65
+ raise Error, "already connected" if @native
23
66
 
67
+ @native = Waiting.await(Native::Client.connect(Options.endpoint(endpoint), @options))
68
+ return self unless block_given?
69
+
70
+ begin
71
+ yield self
72
+ ensure
73
+ close
74
+ end
75
+ end
76
+
77
+ # @return [Boolean] whether the broker resumed a session
78
+ def session_present?
79
+ native.session_present?
80
+ end
24
81
 
25
- # Publish a message. For QoS 0 returns once the packet is
26
- # written. For QoS 1 blocks until PUBACK; for QoS 2 blocks until
27
- # PUBCOMP.
28
- def publish(topic, payload, qos: 0, retain: false, properties: {})
29
- raise NotInAsyncContext unless Async::Task.current?
30
- @engine.routing.publish(
31
- topic: topic, payload: coerce_binary(payload),
32
- qos: qos, retain: retain, properties: properties,
33
- )
82
+ # @return [String, nil] the client ID a v5 broker assigned
83
+ def assigned_client_id
84
+ native.assigned_client_id
34
85
  end
35
86
 
87
+ # Publishes a message. QoS 0 returns once queued, QoS 1 and 2 once the
88
+ # broker acked it.
89
+ #
90
+ # @param topic [String]
91
+ # @param payload [String]
92
+ # @param qos [Integer] 0, 1 or 2
93
+ # @param retain [Boolean] the broker keeps it for later subscribers;
94
+ # an empty payload clears the topic
95
+ # @return [nil]
96
+ def publish(topic, payload, qos: 0, retain: false)
97
+ payload = payload.to_s unless payload.is_a?(String)
98
+ op = native.publish(topic, payload, qos, retain)
99
+ Waiting.await(op) if op
100
+ nil
101
+ end
36
102
 
37
- # Subscribe to a topic filter. Returns a {ZZQ::Subscription} whose
38
- # queue receives matching messages until it is closed.
103
+ # Subscribes to a topic filter.
104
+ #
105
+ # @param filter [String]
106
+ # @param qos [Integer] maximum QoS
107
+ # @param no_local [Boolean] v5: skip this client's own publishes
108
+ # @param retain_as_published [Boolean] v5: keep the publisher's retain
109
+ # flag
110
+ # @param retain_handling [Integer] v5: 0 send retained messages, 1 only
111
+ # for a new subscription, 2 never
112
+ # @param subscription_id [Integer] v5 Subscription Identifier, 0 for
113
+ # none
114
+ # @return [Integer] the granted QoS
115
+ # @raise [Rejected] if the broker refuses the filter
39
116
  def subscribe(filter, qos: 0, no_local: false, retain_as_published: false,
40
- retain_handling: 0)
41
- raise NotInAsyncContext unless Async::Task.current?
42
- @engine.routing.subscribe(
43
- filter, qos: qos, no_local: no_local,
44
- retain_as_published: retain_as_published,
45
- retain_handling: retain_handling,
46
- )
117
+ retain_handling: 0, subscription_id: 0)
118
+ spec = [filter, qos, no_local, retain_as_published, retain_handling]
119
+ code = Waiting.await(native.subscribe([spec], subscription_id)).first
120
+ raise Rejected.new("subscribe to #{filter.inspect} refused", code) if code >= 0x80
121
+
122
+ code
123
+ end
124
+
125
+ # Unsubscribes from topic filters.
126
+ #
127
+ # @param filters [Array<String>]
128
+ # @return [nil]
129
+ # @raise [Rejected] if a v5 broker refuses one
130
+ def unsubscribe(*filters)
131
+ codes = Waiting.await(native.unsubscribe(filters.flatten))
132
+ refused = codes.find { |code| code >= 0x80 }
133
+ raise Rejected.new("unsubscribe refused", refused) if refused
134
+
135
+ nil
136
+ end
137
+
138
+ # Takes the next message, waiting for one.
139
+ #
140
+ # @param timeout [Numeric, nil] seconds
141
+ # @return [Message, nil] nil once the client is closed
142
+ # @raise [IO::TimeoutError] if no message came within +timeout+
143
+ def receive(timeout: nil)
144
+ return @inbox.shift unless @inbox.empty?
145
+ return nil unless @native
146
+ raise Error, "another fiber is receiving" if @receiving
147
+
148
+ @receiving = true
149
+ begin
150
+ next_batch(timeout)
151
+ ensure
152
+ @receiving = false
153
+ end
154
+ end
155
+
156
+ # Yields each message until the client is closed.
157
+ #
158
+ # @yieldparam message [Message]
159
+ # @return [Enumerator, nil] an Enumerator without a block
160
+ def each
161
+ return enum_for(:each) unless block_given?
162
+
163
+ while (message = receive)
164
+ yield message
165
+ end
166
+ end
167
+
168
+ # Sends DISCONNECT and closes. A fiber waiting in {#receive} gets nil.
169
+ #
170
+ # @param reason [Integer] v5 reason code; 0x04 asks for the will
171
+ # @return [nil]
172
+ def disconnect(reason: 0)
173
+ return if @closed || @native.nil?
174
+
175
+ @closed = true
176
+ Waiting.await(@native.disconnect(reason))
177
+ nil
178
+ end
179
+
180
+ # Closes with a normal DISCONNECT.
181
+ #
182
+ # @return [nil]
183
+ def close
184
+ disconnect
185
+ end
186
+
187
+ # @return [Boolean]
188
+ def closed?
189
+ @closed
190
+ end
191
+
192
+ private
193
+
194
+ # Waits for messages, keeps them in the inbox and returns the first.
195
+ def next_batch(timeout)
196
+ Waiting.with_wake do |wake|
197
+ limit = Waiting.deadline(timeout)
198
+ loop do
199
+ batch = @native.receive(wake, BATCH)
200
+ return nil if batch.nil?
201
+
202
+ if batch.is_a?(Array)
203
+ @inbox = batch
204
+ return @inbox.shift
205
+ end
206
+ wake.wait(limit)
207
+ end
208
+ end
209
+ end
210
+
211
+ def native
212
+ raise ClosedError, "client closed" if @closed
213
+ raise ClosedError, "client not connected" unless @native
214
+
215
+ @native
47
216
  end
48
217
  end
49
218
  end
50
-
51
- require_relative "routing/client"
data/lib/zzq/errors.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZZQ
4
+ # Base class of zzq errors.
5
+ class Error < StandardError; end
6
+
7
+ # The client or broker is closed, or the connection is gone.
8
+ class ClosedError < Error; end
9
+
10
+ # The endpoint, the connection or TLS failed.
11
+ class TransportError < Error; end
12
+
13
+ # The peer broke the MQTT protocol.
14
+ class ProtocolError < Error; end
15
+
16
+ # A topic name or filter is not valid.
17
+ class InvalidTopic < Error; end
18
+
19
+ # A message or field is larger than MQTT can carry.
20
+ class TooLarge < Error; end
21
+
22
+ # The broker rejected a request, such as a subscription.
23
+ class Rejected < Error
24
+ # @return [Integer, nil] MQTT reason code
25
+ attr_reader :reason_code
26
+
27
+ # @param message [String, nil]
28
+ # @param reason_code [Integer, nil]
29
+ def initialize(message = nil, reason_code = nil)
30
+ super(message)
31
+ @reason_code = reason_code
32
+ end
33
+ end
34
+
35
+ # The broker refused the connection.
36
+ class ConnectionRefused < Rejected; end
37
+ end
data/lib/zzq/message.rb CHANGED
@@ -1,113 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "protocol/mqtt"
4
-
5
3
  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.
4
+ # A received message. The topic is a frozen UTF-8 String and the payload
5
+ # a frozen binary String.
13
6
  #
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
7
+ # @!attribute [r] topic
8
+ # @return [String]
9
+ # @!attribute [r] payload
10
+ # @return [String]
11
+ # @!attribute [r] qos
12
+ # @return [Integer] delivery QoS, 0 to 2
13
+ # @!attribute [r] retain
14
+ # @return [Boolean] a retained message, or the publisher's flag under
15
+ # Retain As Published
16
+ # @!attribute [r] subscription_ids
17
+ # @return [Array<Integer>] v5 Subscription Identifiers of the matching
18
+ # subscriptions
19
+ Message = Data.define(:topic, :payload, :qos, :retain, :subscription_ids) do
20
+ alias_method :retain?, :retain
112
21
  end
113
22
  end
data/lib/zzq/options.rb CHANGED
@@ -1,94 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
+ # @api private
5
+ module Options
6
+ module_function
7
+
8
+ # +given+ with String keys, after checking every key is +allowed+.
9
+ def check(given, allowed)
10
+ unknown = given.keys - allowed
11
+ unless unknown.empty?
12
+ raise ArgumentError, "unknown option#{"s" if unknown.size > 1}: #{unknown.join(", ")}"
13
+ end
14
+
15
+ stringify(given)
16
+ end
64
17
 
18
+ def stringify(hash)
19
+ hash.to_h { |key, value| [key.to_s, value.is_a?(Hash) ? stringify(value) : value] }
20
+ end
65
21
 
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
22
+ # +mqtt://+ and +mqtts://+ as the +tcp://+ and +tls://+ endpoints they
23
+ # name. Other schemes pass through.
24
+ def endpoint(uri)
25
+ uri = uri.to_s
26
+ if uri.start_with?("mqtt://")
27
+ "tcp://#{uri.delete_prefix("mqtt://")}"
28
+ elsif uri.start_with?("mqtts://")
29
+ "tls://#{uri.delete_prefix("mqtts://")}"
30
+ else
31
+ uri
32
+ end
92
33
  end
93
34
  end
94
35
  end
data/lib/zzq/version.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZZQ
4
- VERSION = "0.1.1"
4
+ # Gem version.
5
+ VERSION = "0.2.0"
5
6
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZZQ
4
+ module Native
5
+ # A pipe a fiber waits on while a Rust future is pending.
6
+ class Wake
7
+ # Waits for a wake, raising IO::TimeoutError past +deadline+.
8
+ #
9
+ # @param deadline [Float, nil] monotonic clock time
10
+ def wait(deadline)
11
+ timeout = deadline && [deadline - Waiting.now, 0].max
12
+ @io ||= IO.for_fd(fd, autoclose: false)
13
+ raise IO::TimeoutError, "timed out" unless @io.wait_readable(timeout)
14
+
15
+ clear
16
+ end
17
+ end
18
+ end
19
+
20
+ # Waits for native ops without blocking other fibers: under a fiber
21
+ # scheduler only the calling fiber waits, otherwise the thread waits
22
+ # without holding the GVL.
23
+ #
24
+ # @api private
25
+ module Waiting
26
+ # Wakes kept for reuse. More are made while more fibers wait at once.
27
+ WAKES = []
28
+ KEEP = 64
29
+
30
+ module_function
31
+
32
+ def now
33
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ end
35
+
36
+ def deadline(timeout)
37
+ timeout && now + timeout
38
+ end
39
+
40
+ # Yields a wake for this call's waits.
41
+ def with_wake
42
+ wake = WAKES.pop || Native::Wake.new
43
+ yield wake
44
+ ensure
45
+ WAKES.push(wake) if wake && WAKES.size < KEEP
46
+ end
47
+
48
+ # Polls +op+ until it is done and returns its result. An interrupted
49
+ # wait cancels it.
50
+ def await(op, timeout = nil)
51
+ with_wake do |wake|
52
+ limit = deadline(timeout)
53
+ loop do
54
+ result = op.poll(wake)
55
+ return result unless Native::PENDING.equal?(result)
56
+
57
+ wake.wait(limit)
58
+ end
59
+ end
60
+ rescue Exception # rubocop:disable Lint/RescueException
61
+ op.cancel
62
+ raise
63
+ end
64
+ end
65
+ end
data/lib/zzq.rb CHANGED
@@ -1,26 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # ZZQ — async-native MQTT client and broker for Ruby.
4
- #
5
- # Built on `async`, `io-stream`, and `protocol-mqtt`. Applies the
6
- # omq/nnq architecture (Socket base, Engine, pluggable transports,
7
- # barrier-scoped lifecycle) to MQTT 3.1.1 and 5.0.
8
- module ZZQ
9
- end
3
+ require "io/wait"
10
4
 
11
5
  require_relative "zzq/version"
12
- require_relative "zzq/error"
13
- require_relative "zzq/monitor_event"
14
- require_relative "zzq/options"
6
+ require_relative "zzq/errors"
15
7
  require_relative "zzq/message"
16
- require_relative "zzq/subscription"
17
- require_relative "zzq/engine"
18
- require_relative "zzq/socket"
19
- require_relative "zzq/transport/mqtt"
20
- require_relative "zzq/transport/ipc"
21
- require_relative "zzq/transport/mqtts"
22
- require_relative "zzq/persistence/interface"
23
- require_relative "zzq/persistence/memory"
24
- require_relative "zzq/persistence/pstore"
8
+ require_relative "zzq/zzq_native"
9
+ require_relative "zzq/options"
10
+ require_relative "zzq/waiting"
25
11
  require_relative "zzq/client"
26
12
  require_relative "zzq/broker"
13
+
14
+ # MQTT 3.1.1 and 5.0 client and broker, backed by the zzq Rust crates.
15
+ module ZZQ
16
+ class << self
17
+ # @return [Integer] IO lanes: threads that run connections
18
+ def io_threads
19
+ Native.io_threads
20
+ end
21
+
22
+ # Sets the number of IO lanes, before the first client or broker.
23
+ #
24
+ # @param count [Integer]
25
+ def io_threads=(count)
26
+ Native.io_threads = count
27
+ end
28
+ end
29
+ end