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.
- checksums.yaml +7 -0
- data/lib/mqtt/core/client/acknowledgement.rb +31 -0
- data/lib/mqtt/core/client/client_id_generator.rb +19 -0
- data/lib/mqtt/core/client/connection.rb +193 -0
- data/lib/mqtt/core/client/enumerable_subscription.rb +224 -0
- data/lib/mqtt/core/client/filesystem_session_store.rb +197 -0
- data/lib/mqtt/core/client/memory_session_store.rb +84 -0
- data/lib/mqtt/core/client/qos0_session_store.rb +56 -0
- data/lib/mqtt/core/client/qos2_session_store.rb +45 -0
- data/lib/mqtt/core/client/qos_tracker.rb +119 -0
- data/lib/mqtt/core/client/retry_strategy.rb +63 -0
- data/lib/mqtt/core/client/session.rb +195 -0
- data/lib/mqtt/core/client/session_store.rb +128 -0
- data/lib/mqtt/core/client/socket_factory.rb +268 -0
- data/lib/mqtt/core/client/subscription.rb +109 -0
- data/lib/mqtt/core/client/uri.rb +77 -0
- data/lib/mqtt/core/client.rb +700 -0
- data/lib/mqtt/core/packet/connect.rb +39 -0
- data/lib/mqtt/core/packet/publish.rb +45 -0
- data/lib/mqtt/core/packet/subscribe.rb +190 -0
- data/lib/mqtt/core/packet/unsubscribe.rb +21 -0
- data/lib/mqtt/core/packet.rb +168 -0
- data/lib/mqtt/core/type/binary.rb +35 -0
- data/lib/mqtt/core/type/bit_flags.rb +89 -0
- data/lib/mqtt/core/type/boolean_byte.rb +48 -0
- data/lib/mqtt/core/type/fixed_int.rb +43 -0
- data/lib/mqtt/core/type/list.rb +41 -0
- data/lib/mqtt/core/type/password.rb +36 -0
- data/lib/mqtt/core/type/properties.rb +124 -0
- data/lib/mqtt/core/type/reason_codes.rb +60 -0
- data/lib/mqtt/core/type/remaining.rb +30 -0
- data/lib/mqtt/core/type/shape.rb +177 -0
- data/lib/mqtt/core/type/sub_type.rb +34 -0
- data/lib/mqtt/core/type/utf8_string.rb +39 -0
- data/lib/mqtt/core/type/utf8_string_pair.rb +29 -0
- data/lib/mqtt/core/type/var_int.rb +56 -0
- data/lib/mqtt/core/version.rb +10 -0
- data/lib/mqtt/core.rb +5 -0
- data/lib/mqtt/errors.rb +39 -0
- data/lib/mqtt/logger.rb +92 -0
- data/lib/mqtt/open.rb +239 -0
- data/lib/mqtt/options.rb +59 -0
- data/lib/mqtt/version.rb +6 -0
- data/lib/mqtt.rb +3 -0
- data/lib/patches/openssl.rb +19 -0
- 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
|