mqtt-core 0.0.1.ci.release

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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/lib/mqtt/core/client/acknowledgement.rb +31 -0
  3. data/lib/mqtt/core/client/client_id_generator.rb +19 -0
  4. data/lib/mqtt/core/client/connection.rb +193 -0
  5. data/lib/mqtt/core/client/enumerable_subscription.rb +224 -0
  6. data/lib/mqtt/core/client/filesystem_session_store.rb +197 -0
  7. data/lib/mqtt/core/client/memory_session_store.rb +84 -0
  8. data/lib/mqtt/core/client/qos0_session_store.rb +56 -0
  9. data/lib/mqtt/core/client/qos2_session_store.rb +45 -0
  10. data/lib/mqtt/core/client/qos_tracker.rb +119 -0
  11. data/lib/mqtt/core/client/retry_strategy.rb +63 -0
  12. data/lib/mqtt/core/client/session.rb +195 -0
  13. data/lib/mqtt/core/client/session_store.rb +128 -0
  14. data/lib/mqtt/core/client/socket_factory.rb +268 -0
  15. data/lib/mqtt/core/client/subscription.rb +109 -0
  16. data/lib/mqtt/core/client/uri.rb +77 -0
  17. data/lib/mqtt/core/client.rb +700 -0
  18. data/lib/mqtt/core/packet/connect.rb +39 -0
  19. data/lib/mqtt/core/packet/publish.rb +45 -0
  20. data/lib/mqtt/core/packet/subscribe.rb +190 -0
  21. data/lib/mqtt/core/packet/unsubscribe.rb +21 -0
  22. data/lib/mqtt/core/packet.rb +168 -0
  23. data/lib/mqtt/core/type/binary.rb +35 -0
  24. data/lib/mqtt/core/type/bit_flags.rb +89 -0
  25. data/lib/mqtt/core/type/boolean_byte.rb +48 -0
  26. data/lib/mqtt/core/type/fixed_int.rb +43 -0
  27. data/lib/mqtt/core/type/list.rb +41 -0
  28. data/lib/mqtt/core/type/password.rb +36 -0
  29. data/lib/mqtt/core/type/properties.rb +124 -0
  30. data/lib/mqtt/core/type/reason_codes.rb +60 -0
  31. data/lib/mqtt/core/type/remaining.rb +30 -0
  32. data/lib/mqtt/core/type/shape.rb +177 -0
  33. data/lib/mqtt/core/type/sub_type.rb +34 -0
  34. data/lib/mqtt/core/type/utf8_string.rb +39 -0
  35. data/lib/mqtt/core/type/utf8_string_pair.rb +29 -0
  36. data/lib/mqtt/core/type/var_int.rb +56 -0
  37. data/lib/mqtt/core/version.rb +10 -0
  38. data/lib/mqtt/core.rb +5 -0
  39. data/lib/mqtt/errors.rb +39 -0
  40. data/lib/mqtt/logger.rb +92 -0
  41. data/lib/mqtt/open.rb +239 -0
  42. data/lib/mqtt/options.rb +59 -0
  43. data/lib/mqtt/version.rb +6 -0
  44. data/lib/mqtt.rb +3 -0
  45. data/lib/patches/openssl.rb +19 -0
  46. metadata +98 -0
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'qos2_session_store'
4
+ require 'concurrent_monitor'
5
+
6
+ module MQTT
7
+ module Core
8
+ class Client
9
+ # A Session Store held in memory for the duration of this process only
10
+ # Supports QoS 2 to recover from network errors but cannot recover from a crash
11
+ class MemorySessionStore < Qos2SessionStore
12
+ # @!visibility private
13
+ # Allow server-assigned client ids (and anonymous sessions in MQTT 3)
14
+ attr_writer :client_id
15
+
16
+ def initialize(expiry_interval: nil, client_id: '')
17
+ super
18
+ @clean = true
19
+ # outgoing packet store, waiting for ACK
20
+ @store = {}
21
+ end
22
+
23
+ def connected!
24
+ @expiry_timeout = ConcurrentMonitor::TimeoutClock.new(expiry_interval)
25
+ @clean = false
26
+ end
27
+
28
+ def disconnected!
29
+ # We can start the timeout here rather than tracking all packet activity because there is no
30
+ # method to recover an in memory session from a full crash.
31
+ @expiry_timeout&.start!
32
+ end
33
+
34
+ def expired?
35
+ @expiry_timeout&.expired?
36
+ end
37
+
38
+ def clean?
39
+ @clean
40
+ end
41
+
42
+ def store_packet(packet, replace: false)
43
+ raise KeyError, 'packet id already exists' if !replace && stored_packet?(packet.id)
44
+
45
+ @store[packet.id] = packet
46
+ end
47
+
48
+ def delete_packet(id)
49
+ @store.delete(id)
50
+ end
51
+
52
+ def stored_packet?(id)
53
+ @store.key?(id)
54
+ end
55
+
56
+ def retry_packets
57
+ @store.values
58
+ end
59
+
60
+ def qos2_recover
61
+ [] # nothing to recover
62
+ end
63
+
64
+ def qos_unhandled_packets
65
+ {} # nothing was persisted
66
+ end
67
+
68
+ def store_qos_received(packet, unique_id)
69
+ # For memory store, we don't need to persist received packets
70
+ # This is just for tracking during the current session
71
+ end
72
+
73
+ def qos_handled(packet, unique_id)
74
+ # For memory store, we don't need to persist handled status
75
+ # This is just for tracking during the current session
76
+ end
77
+
78
+ def restart_clone
79
+ self # don't actually clone.
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'session_store'
4
+
5
+ module MQTT
6
+ module Core
7
+ class Client
8
+ # A minimal Session Store limited to handling QoS0 packets only
9
+ # Always a clean session on restart
10
+ class Qos0SessionStore < SessionStore
11
+ # @!visibility private
12
+ # Allow server-assigned client ids (and anonymous sessions in MQTT 3)
13
+ attr_writer :client_id
14
+
15
+ def initialize(client_id: '')
16
+ super(expiry_interval: 0, client_id:)
17
+ @store = nil
18
+ end
19
+
20
+ def max_qos
21
+ 0
22
+ end
23
+
24
+ def connected!
25
+ @store = Set.new
26
+ end
27
+
28
+ def disconnected!
29
+ @store&.clear
30
+ end
31
+
32
+ # Always use a clean session
33
+ def clean?
34
+ true
35
+ end
36
+
37
+ # We still need to track packet id for subscribe/unsubscribe
38
+ def store_packet(packet, **)
39
+ raise KeyError, 'packet id already exists' unless @store.add?(packet.id)
40
+ end
41
+
42
+ def delete_packet(id)
43
+ @store.delete(id)
44
+ end
45
+
46
+ def stored_packet?(id)
47
+ @store.include?(id)
48
+ end
49
+
50
+ def retry_packets
51
+ []
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'session_store'
4
+ module MQTT
5
+ module Core
6
+ class Client
7
+ # Session store that supports QoS 1/2 messages
8
+ # @abstract
9
+ class Qos2SessionStore < SessionStore
10
+ class SessionNotRecoverable < Error; end
11
+
12
+ def max_qos
13
+ 2
14
+ end
15
+
16
+ # Initialise recovery of the persistent session.
17
+ # @!method qos2_recover
18
+ # @raise SessionNotRecoverable if there are unhandled QOS2 messages not explicitly marked to retry
19
+ # @return [Array<Integer>] list of QOS 2 packets ids waiting for PUBREL
20
+
21
+ # Load the unhandled QoS 1 and 2 PUBLISH packets that should be re-delivered for this session
22
+ # @!method qos_unhandled_packets(&deserializer)
23
+ # @return [Hash<Packet,String>] map of deserialized packets to their unique session id
24
+
25
+ # Persist a received QoS 1/2 packet
26
+ # @!method store_qos_received(packet, unique_id)
27
+
28
+ # Check if a QoS2 PUBLISH packet has previously been received
29
+ # @!method qos2_published?(packet_id)
30
+ # @param [Integer] packet_id
31
+ # @return [Boolean] true if this packet_id was already received but is still waiting for PUBREL.
32
+
33
+ # Release a received packet id (before we send PUBCOMP)
34
+ # @!method qos2_release(packet_id)
35
+
36
+ # Mark the received packet as handled from the client application perspective
37
+ # @!method qos_handled(packet, unique_id)
38
+
39
+ # Mark a previously received QOS1/2 packet as handled
40
+ # @!method qos_handled(packet, unique_id)
41
+ # @return [void]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MQTT
4
+ module Core
5
+ class Client
6
+ # Modules for tracking received QOS packets in a Session
7
+ module QosTracker
8
+ def qos_initialize
9
+ @birth_complete = false
10
+
11
+ # This is only manipulated from the 'receive' thread, does not require synchronisation
12
+ @qos2_pending = Set.new
13
+ @qos2_pending.merge(session_store.qos2_recover) if session_store.max_qos == 2
14
+
15
+ # QOS packets - count down tracking
16
+ @qos_packets = {}
17
+ qos_load { |io| deserialize(io) } if session_store.max_qos.positive?
18
+ end
19
+
20
+ # Have we already received a QOS2 packet with this packet id?
21
+ def qos2_published?(id)
22
+ !@qos2_pending.add?(id)
23
+ end
24
+
25
+ # Called when a QOS1/2 PUBLISH arrives from the server and has been matched against available subscriptions
26
+ def qos_received(packet, subs)
27
+ if birth_complete? && subs.zero?
28
+ log.warn("No subscription for #{packet.topic_name}")
29
+ return
30
+ end
31
+
32
+ pkt_info = { unique_id: format('%013d', Time.now.to_f * 1000), counter: subs, subscribed: subs.positive? }
33
+ session_store.store_qos_received(packet, pkt_info[:unique_id])
34
+ synchronize { @qos_packets[packet] = pkt_info }
35
+ end
36
+
37
+ # Release the pending qos2 packet (return true if we had previously seen it)
38
+ # rubocop:disable Naming/PredicateMethod
39
+ def qos2_release(id)
40
+ session_store.qos2_release(id)
41
+ !!@qos2_pending.delete?(id)
42
+ end
43
+ # rubocop:enable Naming/PredicateMethod
44
+
45
+ # Called when a new subscription arrives.
46
+ # @return [Array<PUBLISH>] a list of matching packets to enqueue on the subscription.
47
+ def qos_subscribed(&matcher)
48
+ return [] if @birth_complete
49
+
50
+ synchronize do
51
+ return if @birth_complete
52
+
53
+ @qos_packets.filter_map do |packet, data|
54
+ next false unless matcher.call(packet)
55
+
56
+ data[:counter] += 1
57
+ data[:subscribed] = true
58
+ packet
59
+ end
60
+ end
61
+ end
62
+
63
+ # Called when topics are explicitly unsubscribed
64
+ def qos_unsubscribed(&matcher)
65
+ return if @birth_complete
66
+
67
+ synchronize do
68
+ return if @birth_complete
69
+
70
+ @qos_packets.delete_if { |packet, data| !data[:subscribed] && matcher.call(packet) }
71
+ end
72
+ end
73
+
74
+ # Called when a Subscription completes handling of a QoS1/2 message.
75
+ def handled!(packet)
76
+ unique_id = synchronize do
77
+ counter = (@qos_packets[packet][:counter] -= 1)
78
+ return unless counter.zero? && @birth_complete
79
+
80
+ @qos_packets.delete(packet)[:unique_id]
81
+ end
82
+
83
+ session_store.qos_handled(packet, unique_id)
84
+ end
85
+
86
+ # Complete the birth phase and process all pending zero-counter messages
87
+ def birth_complete!
88
+ handled = synchronize do
89
+ @birth_complete = true
90
+
91
+ @qos_packets.select { |_pkt, data| data[:counter].zero? }.tap do |handled|
92
+ handled.each do |pkt, data|
93
+ log.warn("No subscription for #{pkt.topic_name}") unless data[:subscribed]
94
+ @qos_packets.delete(pkt)
95
+ end
96
+ end
97
+ end
98
+
99
+ handled.each { |(packet, data)| session_store.qos_handled(packet, data[:unique_id]) }
100
+ end
101
+
102
+ # @return [Boolean] True if the birth phase is complete
103
+ def birth_complete?
104
+ @birth_complete || synchronize { @birth_complete }
105
+ end
106
+
107
+ private
108
+
109
+ # These are unhandled QoS 1/2 packets, mapped to their unique id (timestamps)
110
+ # @return [Array<Packet>] the list of unhandled packets to send to subscriptions as they connect
111
+ def qos_load(&)
112
+ @qos_packets.merge!(session_store.qos_unhandled_packets(&).transform_values do |v|
113
+ { unique_id: v, counter: 0 }
114
+ end)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../errors'
4
+
5
+ module MQTT
6
+ module Core
7
+ class Client
8
+ # Implements retry strategy with configurable backoff and jitter
9
+ class RetryStrategy
10
+ include Logger
11
+
12
+ # @return [Integer] maximum number of retry attempts (default 0 = retry forever)
13
+ attr_reader :max_attempts
14
+
15
+ # @return [Float] initial interval in seconds to wait before retrying (default 1.0)
16
+ attr_reader :base_interval
17
+
18
+ # @return [Float] multiplier for exponential backoff (default 1.5)
19
+ attr_reader :backoff
20
+
21
+ # @return [Float] maximum interval in seconds (default 300)
22
+ attr_reader :max_interval
23
+
24
+ # @return [Float] percentage of random jitter to add to retry intervals, 0-100 (default 25.0)
25
+ attr_reader :jitter
26
+
27
+ def initialize(max_attempts: 0, base_interval: 1.0, backoff: 1.5, max_interval: 300, jitter: 25.0)
28
+ @max_attempts = max_attempts.to_i
29
+ @base_interval = base_interval.to_f
30
+ @backoff = backoff.to_f
31
+ @max_interval = max_interval.to_f
32
+ @jitter = jitter.to_f
33
+ end
34
+
35
+ # This is the retry strategy interface. Caller will retry on completion of this method
36
+ # if it does not raise an exception.
37
+ # @param [Integer] retry_count
38
+ # @param [Proc] raiser
39
+ # @raise [StandardError] the error raised by raiser if retry count has exceeded max attempts
40
+ # @return [Integer] slept duration in seconds
41
+ def retry!(retry_count, &raiser)
42
+ raiser.call
43
+ rescue Error::Retriable, *RETRIABLE_NETWORK_ERRORS => e
44
+ raise e if max_attempts.positive? && retry_count >= max_attempts
45
+
46
+ log.error(e)
47
+ duration = calculate_retry_duration(retry_count)
48
+ log.warn { "Retry attempt #{retry_count} in #{duration.round(2)}s" }
49
+ sleep(duration)
50
+ end
51
+
52
+ private
53
+
54
+ # Calculate the retry duration with exponential backoff and jitter
55
+ def calculate_retry_duration(retry_count)
56
+ base_duration = [base_interval * (backoff**retry_count), max_interval].min
57
+ max_jitter_amount = (base_duration * jitter / 100.0)
58
+ base_duration + rand(-max_jitter_amount..max_jitter_amount)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'socket_factory'
4
+ require_relative 'qos_tracker'
5
+ require_relative '../../logger'
6
+ require 'forwardable'
7
+ require 'concurrent_monitor'
8
+
9
+ module MQTT
10
+ module Core
11
+ class Client
12
+ # @!visibility private
13
+ # Common session handling across MQTT protocol versions
14
+ # * responsible for building packets from user data for PUBLISH, SUBSCRIBE, UNSUBSCRIBE
15
+ # * handles the protocol for a given client id - packet identifier assignment, QOS handling
16
+ # * spans connections - retries by resending packets from the unacknowledged packet store
17
+ # @abstract - MQTT Protocol version specific session behaviour in concrete implementations
18
+ class Session
19
+ extend Forwardable
20
+ include Logger
21
+ include ConcurrentMonitor
22
+ include QosTracker
23
+
24
+ def initialize(client:, monitor:, session_store:)
25
+ @client = client
26
+ @monitor = monitor.new_monitor
27
+ @session_store = session_store
28
+
29
+ qos_initialize
30
+ end
31
+
32
+ def connect_data(**connect)
33
+ check_session_managed_fields(:connect, connect, :client_id)
34
+ { client_id: session_store.client_id }
35
+ end
36
+
37
+ def disconnect_data(**_disconnect)
38
+ {}
39
+ end
40
+
41
+ def expired!(clean: clean?)
42
+ return if clean || !session_store.expired?
43
+
44
+ raise SessionExpiacks.deletered, "Session #{session_store} for '#{client_id}' has expired"
45
+ end
46
+
47
+ def connected!(_connect, connack)
48
+ return session_store.connected! if connack.session_present? || clean?
49
+
50
+ expired!(clean: false)
51
+ raise SessionNotPresent, "Server does not have a session for '#{client_id}'"
52
+ end
53
+
54
+ # Sending a message, store a duplicate packet in the packet store for resending
55
+ def publish(qos: 0, **publish)
56
+ check_session_managed_fields(__method__, publish, :packet_identifier, :dup)
57
+ validate_qos!(qos)
58
+
59
+ dup = packet_with_id(:publish, qos:, dup: true, **publish) if qos.positive?
60
+
61
+ p = build_packet(:publish, qos:, dup: false, packet_identifier: dup&.id, **publish)
62
+ yield p
63
+ end
64
+
65
+ def subscribe(**subscribe)
66
+ check_session_managed_fields(__method__, subscribe, :packet_identifier)
67
+ pkt = packet_with_id(:subscribe, **subscribe)
68
+ validate_qos!(pkt.max_qos)
69
+
70
+ yield pkt
71
+ end
72
+
73
+ def unsubscribe(**unsubscribe)
74
+ check_session_managed_fields(__method__, unsubscribe, :packet_identifier)
75
+ yield packet_with_id(:unsubscribe, **unsubscribe)
76
+ end
77
+
78
+ # Receiving a message
79
+ def handle_publish(packet)
80
+ # Notify the client the message has been received (unless we are qos and have already seen it)
81
+ unless packet.qos == 2 && qos2_published?(packet.id)
82
+ matched_subs = receive_publish(packet)
83
+ qos_received(packet, matched_subs.size) if packet.qos.positive?
84
+ matched_subs.each { |sub| sub.put(packet) }
85
+ end
86
+
87
+ return unless packet.qos.positive?
88
+
89
+ # Build and send the appropriate ACK packet
90
+ ack = build_packet(packet.qos == 2 ? :pubrec : :puback, packet_identifier: packet.id)
91
+ push_packet(ack)
92
+ end
93
+
94
+ # replace the stored publish message with a pubrel message
95
+ def handle_pubrec(packet)
96
+ packet.success!
97
+ # We don't need to sync this as the only possible conflict would be a protocol error
98
+ # anyway, so we will always see pub_rec after publish.
99
+ pubrel = store_packet(qos2_response(:pubrel, packet.id, stored_packet?(packet.id)), replace: true)
100
+ push_packet(pubrel)
101
+ rescue ResponseError => _e
102
+ release_packet(packet)
103
+ end
104
+
105
+ def handle_pubrel(packet)
106
+ push_packet(qos2_response(:pubcomp, packet.id, qos2_release(packet.id)))
107
+ end
108
+
109
+ def release_packet(packet)
110
+ # Notify the client the request has been acknowledged
111
+ receive_ack(packet)
112
+ ensure
113
+ delete_packet(packet.id)
114
+ end
115
+
116
+ alias handle_puback release_packet
117
+ alias handle_pubcomp release_packet
118
+ alias handle_suback release_packet
119
+ alias handle_unsuback release_packet
120
+ private :release_packet
121
+
122
+ MAX_PACKET_ID = 65_535
123
+
124
+ def max_packet_id
125
+ MAX_PACKET_ID
126
+ end
127
+
128
+ def_delegators :session_store, :disconnected!, :client_id, :retry_packets, :expiry_interval=
129
+
130
+ private
131
+
132
+ attr_reader :session_store
133
+
134
+ # Client helpers and callbacks
135
+ def_delegators :@client, :build_packet, :deserialize, :push_packet, :receive_ack, :receive_publish
136
+
137
+ # Session store interface
138
+ def_delegators :session_store, :clean?, :expired?, :stored_packet?, :store_packet, :delete_packet,
139
+ :max_qos, :validate_qos!, :qos2_published?, :qos2_release
140
+
141
+ # used in handle PUBREC/PUBREL to handle unknown packet id errors
142
+ def qos2_response(response_name, id, exists, **data)
143
+ raise ProtocolError, "Packet id #{id} does not exist in session for #{client_id}" unless exists
144
+
145
+ build_packet(response_name, packet_identifier: id, **data)
146
+ end
147
+
148
+ def packet_with_id(packet_type, **packet_data)
149
+ next_packet_id { |id| build_packet(packet_type, packet_identifier: id, **packet_data) }
150
+ end
151
+
152
+ # Generates unique packet IDs for a single client session.
153
+ # Uses random allocation and initial optimistic collision check before any synchronisation around use
154
+ # of the session store.
155
+ # The cost of generating a random number is low compared to the RTT for QOS 1/2 flow.
156
+ def next_packet_id(&)
157
+ init_id = rand(1..max_packet_id)
158
+ attempts = 0
159
+ loop do
160
+ result = claim_id(((init_id + attempts) % max_packet_id) + 1, &)
161
+
162
+ return result if result
163
+
164
+ packet_id_backoff(attempts += 1)
165
+ end
166
+ end
167
+
168
+ def claim_id(id)
169
+ return nil if stored_packet?(id)
170
+
171
+ packet = yield id
172
+
173
+ synchronize { packet.tap { store_packet(packet) } unless stored_packet?(packet.id) }
174
+ end
175
+
176
+ def packet_id_backoff(attempts)
177
+ return unless attempts >= max_packet_id
178
+
179
+ # if we've done a whole lap of available ids, start backing off...
180
+ log.warn { "Packet id contention: #{attempts} attempts" } if (attempts % max_packet_id).zero?
181
+ sleep(0.01 * Math.log(attempts - max_packet_id + 2))
182
+ end
183
+
184
+ def check_session_managed_fields(packet_type, data, *invalid_fields)
185
+ data.delete_if do |k, _v|
186
+ next false unless invalid_fields.include?(k)
187
+
188
+ log.warn { "#{packet_type}: Ignoring session managed property #{k}" }
189
+ true
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'client_id_generator'
4
+
5
+ module MQTT
6
+ module Core
7
+ class Client
8
+ # @abstract The session store interface
9
+ # The Session Store is responsible for...
10
+ # * Keeping track of packet ids and ACK status of packets that we are sending.
11
+ # These incomplete packets are retried on reconnecting to an established session.
12
+ # * Meeting the Quality of Service guarantees for received PUBLISH packets.
13
+ class SessionStore
14
+ include ClientIdGenerator
15
+
16
+ # @!visibility private
17
+ def self.extract_uri_params(uri_params)
18
+ # amazonq-ignore-next-line
19
+ uri_params.slice(*%w[client_id expiry_interval]).transform_keys(&:to_sym).tap do |params|
20
+ params[:expiry_interval] = Integer(params[:expiry_interval]) if params.key?(:expiry_interval)
21
+ end
22
+ end
23
+
24
+ # Raised from PUBLISH or SUBSCRIBE if the session store does not support the requested QoS level
25
+ class QoSNotSupported < Error; end
26
+
27
+ # Maximum Session Expiry Interval
28
+ # The spec says this means 'never' expire, but it also equates to 136 years so there is no practical
29
+ # need for special handling.
30
+ MAX_EXPIRY_INTERVAL = 0xFFFFFFFF
31
+
32
+ include MQTT::Logger
33
+
34
+ # @!attribute [r] expiry_interval
35
+ # @return [Integer] duration in seconds before the session data expires after disconnect.
36
+ # @note the writer method is api private for MQTT 5.0 sever assigned expiry.
37
+ attr_accessor :expiry_interval
38
+
39
+ # @!attribute [r] client_id
40
+ # @return [String]
41
+ # @note the writer method (if defined in subclass) is ap private for MQTT 5.0 server assigned client id
42
+ attr_reader :client_id
43
+
44
+ # @param [String|nil] client_id
45
+ # * Empty string (default) is a session local anonymous or auto-assigned id (version-dependent handling)
46
+ # * Explicitly `nil` to generate random id
47
+ # * Otherwise a valid client_id for the server
48
+ # @param [Integer|nil] expiry_interval
49
+ def initialize(expiry_interval:, client_id:)
50
+ init_client_id(client_id)
51
+ init_expiry_interval(expiry_interval)
52
+ end
53
+
54
+ def validate_qos!(requested_qos)
55
+ return if requested_qos <= max_qos
56
+
57
+ raise QoSNotSupported, "QoS #{requested_qos} is not supported by #{self.class}"
58
+ end
59
+
60
+ # @!method retry_packets
61
+ # @return [Array<Packet>] the list of unacknowledged packets to resend on re-connect
62
+
63
+ # @!method clean?
64
+ # @return [Boolean] true if reconnection should establish a new session
65
+
66
+ # @!method connected!
67
+ # Connection has been acknowledged server-assigned client_id and expiry_interval are available
68
+ # @return [void]
69
+
70
+ # @!method disconnected!
71
+ # Connection has been disconnected
72
+ # @return [void]
73
+
74
+ # @!method expired?
75
+ # @return [Boolean] true if the {expiry_interval} has passed since the latest activity
76
+
77
+ # @!method stored_packet?(packet_id)
78
+ # @param [Integer] packet_id
79
+ # @return [Boolean] true if the packet_id is in use and waiting acknowledgement
80
+
81
+ # @!method store_packet(packet, replace: false)
82
+ # @param [Packet] packet the packet (with packet_identifier) to store
83
+ # @param [Boolean] replace allow overwriting of packet_id (part of QOS2 flow)
84
+ # @return [void]
85
+ # @raise [KeyError] if packet_id is in use and replace is not true
86
+
87
+ # Discard the packet with this packet id
88
+ # @!method release_packet(packet_id)
89
+ # @param [Integer] packet_id
90
+ # @return [void]
91
+
92
+ # Maximum Quality of Service level supported by this session store
93
+ # @!method max_qos
94
+ # @return [Integer]
95
+
96
+ private
97
+
98
+ def init_client_id(client_id)
99
+ client_id ||= generate_client_id
100
+ @client_id = client_id
101
+
102
+ return if client_id.length.positive? || allow_server_assigned_client_id?
103
+
104
+ raise ArgumentError, "#{self.class} requires a non-empty client id"
105
+ end
106
+
107
+ # client id can only be empty if the session store allows server-assigned client ids
108
+ # which it can do by defining a writer for :client_id
109
+ def allow_server_assigned_client_id?
110
+ respond_to?(:client_id=)
111
+ end
112
+
113
+ def init_expiry_interval(expiry_interval)
114
+ expiry_interval ||= MAX_EXPIRY_INTERVAL
115
+ unless expiry_interval.between?(0, MAX_EXPIRY_INTERVAL)
116
+ raise ArgumentError, "expiry_interval must be between 0 and #{MAX_EXPIRY_INTERVAL}"
117
+ end
118
+
119
+ if expiry_interval.zero? && max_qos.positive?
120
+ raise ArgumentError, "#{self.class} requires a non-zero expiry_interval"
121
+ end
122
+
123
+ @expiry_interval = expiry_interval
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end