volcano-sdk 0.31.0 → 0.32.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.
@@ -14,6 +14,8 @@ module Volcano
14
14
  class ServerError < StandardError
15
15
  attr_reader :code
16
16
 
17
+ # @dynamic code
18
+
17
19
  def initialize(message, code: nil)
18
20
  super(message)
19
21
  @code = code.is_a?(String) ? code.dup.freeze : code
@@ -46,12 +48,10 @@ module Volcano
46
48
  def self.connect(id:, token:) = { 'id' => id, 'connect' => { 'token' => token } }
47
49
 
48
50
  def self.subscribe(id:, channel:, recoverable: false, join_leave: false, recovery: nil)
51
+ # @type var options: Hash[String, Object]
49
52
  options = { 'channel' => channel }
50
- if recovery
51
- add_recovery_options(options, recovery)
52
- elsif recoverable
53
- options['recoverable'] = true
54
- end
53
+ add_recovery_options(options, recovery) if recovery
54
+ options['recoverable'] = true if !recovery && recoverable
55
55
  options['join_leave'] = true if join_leave
56
56
  { 'id' => id, 'subscribe' => options }
57
57
  end
@@ -88,8 +88,9 @@ module Volcano
88
88
  def connect(token:)
89
89
  result = request('connect') { |id| self.class.connect(id: id, token: token) }
90
90
  ensure_open!
91
- @connected = true
92
- result
91
+ raise TypeError, 'realtime connect result must be an object' unless result.is_a?(Hash)
92
+
93
+ result.tap { @connected = true }
93
94
  end
94
95
 
95
96
  def subscribe(channel:, recoverable: false, join_leave: false, recovery: nil)
@@ -111,7 +112,11 @@ module Volcano
111
112
  def unsubscribe(channel:)
112
113
  @subscription_lock.acquire do
113
114
  ensure_open!
114
- next {} unless @subscriptions.include?(channel)
115
+ unless @subscriptions.include?(channel)
116
+ # @type var empty_reply: Hash[String, Object]
117
+ empty_reply = {}
118
+ next empty_reply
119
+ end
115
120
 
116
121
  result = request('unsubscribe') { |id| self.class.unsubscribe(id: id, channel: channel) }
117
122
  @subscriptions.delete(channel)
@@ -146,7 +151,7 @@ module Volcano
146
151
  end
147
152
 
148
153
  def ensure_open!
149
- raise @closed_error if @closed
154
+ raise(@closed_error || ClosedError.new('realtime connection closed')) if @closed
150
155
 
151
156
  self
152
157
  end
@@ -4,6 +4,9 @@ module Volcano
4
4
  class Realtime
5
5
  # Dispatches protocol replies and publications to their waiting consumers.
6
6
  module ProtocolDispatch
7
+ # @dynamic write_frame, dispatch_publication, dispatch_queued_callback, close_with, closed_error
8
+ PresenceDelivery = Data.define(:handlers, :event, :data)
9
+
7
10
  private
8
11
 
9
12
  def process(frame)
@@ -12,7 +15,10 @@ module Volcano
12
15
  elsif frame.key?('id')
13
16
  dispatch_reply(frame)
14
17
  elsif frame.key?('push')
15
- dispatch_push(frame.fetch('push'))
18
+ push = frame.fetch('push')
19
+ raise TypeError, 'realtime push must be an object' unless push.is_a?(Hash)
20
+
21
+ dispatch_push(push)
16
22
  end
17
23
  end
18
24
 
@@ -27,7 +33,10 @@ module Volcano
27
33
  end
28
34
 
29
35
  def dispatch_reply(frame)
30
- pending = @pending[frame.fetch('id')]
36
+ id = frame.fetch('id')
37
+ return unless id.is_a?(Integer)
38
+
39
+ pending = @pending.fetch(id, nil)
31
40
  return unless pending
32
41
 
33
42
  reply = reply_value(frame, pending.reply_key)
@@ -45,12 +54,18 @@ module Volcano
45
54
 
46
55
  def reply_value(frame, reply_key)
47
56
  error = frame['error']
48
- return frame.fetch(reply_key) { frame.fetch('result') { frame.except('id') } } unless error
57
+ return successful_reply(frame, reply_key) unless error
58
+
59
+ raise TypeError, 'realtime error must be an object' unless error.is_a?(Hash)
49
60
 
50
61
  message = error['message'] || 'realtime command failed'
51
62
  Protocol::Failure.new(error: ServerError.new(message, code: error['code']))
52
63
  end
53
64
 
65
+ def successful_reply(frame, reply_key)
66
+ frame.fetch(reply_key) { frame.fetch('result') { frame.except('id') } }
67
+ end
68
+
54
69
  def dispatch_presence(push, event)
55
70
  channel = matching_channel(@presence_handlers, push['channel'].to_s)
56
71
  info = push.dig(event, 'info')
@@ -61,7 +76,8 @@ module Volcano
61
76
  return
62
77
  end
63
78
 
64
- @callback_queue.enqueue([@presence_handlers.fetch(channel).dup, event, info])
79
+ delivery = PresenceDelivery.new(handlers: @presence_handlers.fetch(channel).dup, event:, data: info)
80
+ @callback_queue.enqueue(delivery)
65
81
  end
66
82
 
67
83
  def matching_channel(handlers, channel)
@@ -93,7 +109,7 @@ module Volcano
93
109
 
94
110
  def dispatch_callback_delivery(handlers, event, data)
95
111
  handlers.each do |handler|
96
- break if @callback_stopping
112
+ break handlers if @callback_stopping
97
113
 
98
114
  handler.call(event, data)
99
115
  rescue StandardError
@@ -105,9 +121,11 @@ module Volcano
105
121
  return if @callback_queue.size >= @max_callback_queue
106
122
 
107
123
  channel, handlers = @pending_presence_resyncs.shift
108
- return unless channel
124
+ return unless channel && handlers
109
125
 
110
- @callback_queue.enqueue([handlers, 'sync_required', {}])
126
+ # @type var empty_info: Hash[String, Object?]
127
+ empty_info = {}
128
+ @callback_queue.enqueue(PresenceDelivery.new(handlers:, event: 'sync_required', data: empty_info))
111
129
  end
112
130
  end
113
131
  end
@@ -6,12 +6,15 @@ module Volcano
6
6
  class Protocol
7
7
  # Coordinates protocol requests and the socket reader loop.
8
8
  module IO
9
+ # @dynamic ensure_open!, close_with, process
10
+
9
11
  private
10
12
 
11
13
  def request(reply_key, on_reply: nil)
14
+ # @type var registered_id: Integer?
12
15
  ensure_open!
13
- id, reply_queue = register_request(reply_key, on_reply)
14
- write_frame(yield(id))
16
+ registered_id, reply_queue = register_request(reply_key, on_reply)
17
+ write_frame(yield(registered_id || raise(TypeError, 'invalid realtime request id')))
15
18
  reply = await_reply(reply_queue)
16
19
  raise reply.error if reply.is_a?(Failure)
17
20
 
@@ -19,7 +22,7 @@ module Volcano
19
22
  rescue StandardError => e
20
23
  raise Redaction.exception(e, secrets: @secrets), cause: nil
21
24
  ensure
22
- @pending.delete(id)
25
+ @pending.delete(registered_id) if registered_id
23
26
  end
24
27
 
25
28
  def register_request(reply_key, on_reply)
@@ -83,7 +86,8 @@ module Volcano
83
86
 
84
87
  def closed_error(error)
85
88
  ClosedError.new(Redaction.message(error.message, secrets: @secrets)).tap do |closed|
86
- closed.set_backtrace(error.backtrace)
89
+ trace = error.backtrace
90
+ closed.set_backtrace(trace) if trace
87
91
  end
88
92
  end
89
93
  end
@@ -5,6 +5,8 @@ module Volcano
5
5
  class Protocol
6
6
  # Exposes connection state and manages publication handlers.
7
7
  module Lifecycle
8
+ # @dynamic ensure_open!, initialize_recovery_positions, initialize_publication_producer, dispatch_callbacks
9
+ # @dynamic dispatch_publication_batches, read_loop
8
10
  def connected? = @connected && !@closed
9
11
 
10
12
  def on_publication(channel, on_rejection: nil, &handler)
@@ -4,6 +4,8 @@ module Volcano
4
4
  class Realtime
5
5
  # Normalizes publications and admits live deliveries without blocking the reader.
6
6
  module ProtocolPublicationDispatch
7
+ # @dynamic enqueue_live_publication, matching_channel, drop_publication, mark_unknown_gap
8
+ # @dynamic dispatch_callback_delivery
7
9
  Registration = Data.define(:handler, :on_rejection)
8
10
  PublicationDelivery = Data.define(
9
11
  :channel, :handlers, :event, :data, :publication, :recovered, :on_rejection, :rejection_key
@@ -24,12 +26,15 @@ module Volcano
24
26
  def publication_delivery(channel, publication, recovered:)
25
27
  matched_channel = matching_publication_channel(channel)
26
28
  return unless matched_channel
27
- return reject_invalid_publication(matched_channel, publication) unless valid_publication?(publication)
29
+ return reject_invalid_publication(matched_channel, publication) unless publication.is_a?(Hash)
30
+
31
+ data = publication['data']
32
+ return reject_invalid_publication(matched_channel, publication) unless data.is_a?(Hash)
28
33
  unless @publication_handlers.key?(matched_channel)
29
34
  return drop_handlerless_publication(matched_channel, publication)
30
35
  end
31
36
 
32
- build_publication_delivery(matched_channel, publication, recovered: recovered)
37
+ build_publication_delivery(matched_channel, publication, data, recovered: recovered)
33
38
  end
34
39
 
35
40
  def matching_publication_channel(channel)
@@ -38,10 +43,6 @@ module Volcano
38
43
  [handler_channel, recovery_channel].compact.max_by(&:length)
39
44
  end
40
45
 
41
- def valid_publication?(publication)
42
- publication.is_a?(Hash) && publication['data'].is_a?(Hash)
43
- end
44
-
45
46
  def reject_invalid_publication(channel, publication)
46
47
  if publication.is_a?(Hash)
47
48
  drop_publication(channel, publication)
@@ -56,9 +57,8 @@ module Volcano
56
57
  nil
57
58
  end
58
59
 
59
- def build_publication_delivery(channel, publication, recovered:)
60
+ def build_publication_delivery(channel, publication, data, recovered:)
60
61
  registrations = @publication_handlers.fetch(channel).dup.freeze
61
- data = publication.fetch('data')
62
62
  PublicationDelivery.new(
63
63
  channel: channel.dup.freeze,
64
64
  handlers: registrations.map(&:handler).freeze,
@@ -98,14 +98,16 @@ module Volcano
98
98
  def dispatch_queued_callback(delivery)
99
99
  if delivery.is_a?(PublicationDelivery)
100
100
  dispatch_publication_callbacks(delivery)
101
+ elsif delivery.is_a?(ProtocolDispatch::PresenceDelivery)
102
+ dispatch_callback_delivery(delivery.handlers, delivery.event, delivery.data)
101
103
  else
102
- dispatch_callback_delivery(*delivery)
104
+ raise TypeError, 'invalid realtime callback delivery'
103
105
  end
104
106
  end
105
107
 
106
108
  def dispatch_publication_callbacks(delivery)
107
109
  delivery.handlers.each do |handler|
108
- break if @callback_stopping
110
+ break delivery.handlers if @callback_stopping
109
111
 
110
112
  handler.call(
111
113
  delivery.event, delivery.data, delivery.publication, recovered: delivery.recovered
@@ -4,6 +4,10 @@ module Volcano
4
4
  class Realtime
5
5
  # Validates recovery subscribe results before updating stream positions.
6
6
  module ProtocolRecovery
7
+ # @dynamic class, request, dispatch_recovered_publications, recovery_position
8
+ # @dynamic delete_recovery_state, set_recovery_position, mark_unknown_gap
9
+ # @dynamic mark_publication_gap, same_epoch?
10
+
7
11
  private
8
12
 
9
13
  def subscribe_request(channel:, recoverable:, join_leave:, recovery:)
@@ -28,7 +32,7 @@ module Volcano
28
32
 
29
33
  def parse_recovery_result(channel:, recovery:, result:)
30
34
  result_position, publications = validated_recovery_result(result)
31
- return block_malformed_recovery_result(channel, recovery) unless result_position
35
+ return block_malformed_recovery_result(channel, recovery) unless result_position && publications
32
36
 
33
37
  requested_position = requested_recovery_position(recovery)
34
38
  return block_recovery_result(channel, requested_position) if unrecovered_empty_result?(
@@ -50,7 +54,7 @@ module Volcano
50
54
  end
51
55
 
52
56
  def unrecovered_empty_result?(result, requested_position, publications)
53
- requested_position && result['recovered'] == false && publications.empty?
57
+ requested_position && result.is_a?(Hash) && result['recovered'] == false && publications.empty?
54
58
  end
55
59
 
56
60
  def validated_recovery_result(result)
@@ -68,7 +72,7 @@ module Volcano
68
72
  validated = publications.map { |publication| validated_recovery_publication(publication, result_position) }
69
73
  return [nil, nil] if validated.any?(&:nil?)
70
74
 
71
- [result_position, validated]
75
+ [result_position, validated.compact]
72
76
  end
73
77
 
74
78
  def validated_recovery_publication(publication, fallback_position)
@@ -4,6 +4,8 @@ module Volcano
4
4
  class Realtime
5
5
  # Preserves publication order while recovered deliveries wait for callback capacity.
6
6
  module ProtocolRecoveryDispatch
7
+ # @dynamic publication_delivery, reject_publication_delivery, admit_live_publication
8
+ # @dynamic close_with, closed_error
7
9
  PublicationBatch = Data.define(:channel, :deliveries, :rejection_key)
8
10
  private_constant :PublicationBatch
9
11
 
@@ -4,6 +4,7 @@ module Volcano
4
4
  class Realtime
5
5
  # Reconnects unexpected transport losses and restores subscription intent.
6
6
  module Reconnect
7
+ # @dynamic channel_lock, protocol_lock, connect_protocol, report_channel_error
7
8
  RECONNECT_MIN_DELAY = 0.1
8
9
  RECONNECT_MAX_DELAY = 20.0
9
10
  MAX_BACKOFF_EXPONENT = 8
@@ -19,7 +20,9 @@ module Volcano
19
20
  def stop_reconnect
20
21
  task = @reconnect_task
21
22
  @reconnect_task = nil
22
- task&.stop unless task.equal?(Async::Task.current)
23
+ return unless task
24
+
25
+ task.stop unless task.equal?(Async::Task.current)
23
26
  end
24
27
 
25
28
  def reconnect_loop
@@ -32,134 +32,148 @@ module Volcano
32
32
 
33
33
  # Recursively snapshots JSON-compatible values for callback and state safety.
34
34
  module Immutable
35
- module_function
36
-
37
- def optional(value)
38
- value && call(value)
35
+ def self.optional(value)
36
+ value && snapshot(value)
39
37
  end
40
38
 
41
- def call(value)
39
+ def self.call(value) = snapshot(value)
40
+
41
+ def self.snapshot(value)
42
42
  case value
43
43
  when Hash then call_hash(value)
44
- when Array then value.map { |child| call(child) }.freeze
44
+ when Array then value.map { |child| snapshot(child) }.freeze
45
45
  when String then value.dup.freeze
46
46
  else value.freeze
47
47
  end
48
48
  end
49
49
 
50
- def call_hash(value)
51
- value.to_h { |key, item| [call(key), call(item)] }.freeze
50
+ def self.call_hash(value)
51
+ value.to_h { |key, item| [snapshot(key), snapshot(item)] }.freeze
52
52
  end
53
53
  end
54
54
  private_constant :Immutable
55
55
 
56
- PresenceInfo = Data.define(:client, :user, :data) do
56
+ PresenceInfo = Data.define(:client, :user, :data)
57
+
58
+ # An immutable snapshot of one presence client.
59
+ class PresenceInfo
60
+ # @dynamic client, user, data, members, with, to_h, deconstruct, deconstruct_keys
61
+ # @dynamic self.[], self.members
57
62
  def initialize(client:, user: nil, data: {})
58
- super(
59
- client: Immutable.call(client.to_s),
60
- user: user.nil? ? nil : Immutable.call(user.to_s),
61
- data: Immutable.call(data)
62
- )
63
+ client = Immutable.call(client.to_s)
64
+ user = user.nil? ? nil : Immutable.call(user.to_s)
65
+ data = Immutable.call(data)
66
+ super
63
67
  end
64
68
  end
65
69
 
66
70
  CHANNEL_TYPES = %i[broadcast presence postgres].freeze
67
71
 
68
- def initialize(client, api_url:, socket_factory: nil, reconnect_delay: nil)
69
- @client = client
70
- @api_url = api_url
71
- @socket_factory = socket_factory || method(:open_socket)
72
- @reconnect_delay = reconnect_delay || method(:default_reconnect_delay)
73
- initialize_realtime_state
74
- initialize_postgres_database
75
- end
72
+ # Owns the public realtime facade and its channel collection.
73
+ module Core
74
+ # @dynamic initialize_postgres_database, connect_protocol, close_protocol, public_error
75
+ # @dynamic protocol_error, open_socket, default_reconnect_delay, __send__
76
+ def initialize(client, api_url:, socket_factory: nil, reconnect_delay: nil)
77
+ @client = client
78
+ @api_url = api_url
79
+ @socket_factory = socket_factory || ->(address) { open_socket(address) }
80
+ @reconnect_delay = reconnect_delay || ->(attempt) { default_reconnect_delay(attempt) }
81
+ initialize_realtime_state
82
+ initialize_postgres_database
83
+ end
76
84
 
77
- def initialize_realtime_state
78
- @protocol = nil
79
- @protocol_lock = nil
80
- @channel_lock = nil
81
- @channels = {}
82
- @connection_callbacks = { connect: {}, disconnect: {}, error: {} }
83
- @next_connection_callback_id = 0
84
- @protocol_session_lineage = nil
85
- @reconnect_task = nil
86
- @closed = false
87
- end
88
- private :initialize_realtime_state
89
-
90
- def channel(
91
- name, type: :broadcast, auto_fetch: true,
92
- fetch_batch_window_ms: 20, fetch_max_batch_size: 50
93
- )
94
- type = normalize_channel_type(type)
95
- batch_config = PostgresBatchConfig.new(
96
- auto_fetch:, batch_window_ms: fetch_batch_window_ms,
97
- max_batch_size: fetch_max_batch_size
85
+ def initialize_realtime_state
86
+ @protocol = nil
87
+ @protocol_lock = nil
88
+ @channel_lock = nil
89
+ @channels = {}
90
+ @connection_callbacks = { connect: {}, disconnect: {}, error: {} }
91
+ @next_connection_callback_id = 0
92
+ @protocol_session_lineage = nil
93
+ @reconnect_task = nil
94
+ @closed = false
95
+ end
96
+ private :initialize_realtime_state
97
+
98
+ def channel(
99
+ name, type: :broadcast, auto_fetch: true,
100
+ fetch_batch_window_ms: 20, fetch_max_batch_size: 50
98
101
  )
99
- channel_lock.acquire do
100
- ensure_open!
101
- channel_name = "#{type}:#{name}"
102
- channel = @channels[channel_name]
103
- next cached_channel(channel, batch_config) if channel
102
+ type = normalize_channel_type(type)
103
+ batch_config = PostgresBatchConfig.new(
104
+ auto_fetch:, batch_window_ms: fetch_batch_window_ms,
105
+ max_batch_size: fetch_max_batch_size
106
+ )
107
+ channel_lock.acquire do
108
+ ensure_open!
109
+ channel_name = "#{type}:#{name}"
110
+ channel = @channels[channel_name]
111
+ next cached_channel(channel, batch_config) if channel
104
112
 
105
- @channels[channel_name] = build_channel(channel_name, type, batch_config)
113
+ @channels[channel_name] = build_channel(channel_name, type, batch_config)
114
+ end
106
115
  end
107
- end
108
-
109
- def protocol
110
- ensure_open!
111
- return @protocol if @protocol
112
116
 
113
- protocol_lock.acquire do
117
+ def protocol
114
118
  ensure_open!
115
- @protocol ||= connect_protocol
119
+ current_protocol = @protocol
120
+ return current_protocol if current_protocol
121
+
122
+ protocol_lock.acquire do
123
+ ensure_open!
124
+ @protocol ||= connect_protocol
125
+ end
126
+ rescue StandardError => e
127
+ raise public_error(e), cause: nil
116
128
  end
117
- rescue StandardError => e
118
- raise public_error(e), cause: nil
119
- end
120
- private :protocol
129
+ private :protocol
121
130
 
122
- def disconnect
123
- @protocol_lock ? @protocol_lock.acquire { close_protocol } : close_protocol
124
- rescue StandardError => e
125
- raise public_error(e), cause: nil
126
- end
131
+ def disconnect
132
+ lock = @protocol_lock
133
+ lock ? lock.acquire { close_protocol } : close_protocol
134
+ rescue StandardError => e
135
+ raise public_error(e), cause: nil
136
+ end
127
137
 
128
- def ensure_open!
129
- raise ClosedError, 'realtime connection closed' if @closed
138
+ def ensure_open!
139
+ raise ClosedError, 'realtime connection closed' if @closed
130
140
 
131
- self
132
- end
141
+ self
142
+ end
133
143
 
134
- private
144
+ private
135
145
 
136
- def normalize_channel_type(type)
137
- normalized = type.to_s.to_sym
138
- return normalized if CHANNEL_TYPES.include?(normalized)
146
+ def normalize_channel_type(type)
147
+ normalized = type.to_s.to_sym
148
+ return normalized if CHANNEL_TYPES.include?(normalized)
139
149
 
140
- raise ArgumentError, "unsupported realtime channel type: #{type}"
141
- end
150
+ raise ArgumentError, "unsupported realtime channel type: #{type}"
151
+ end
142
152
 
143
- def report_channel_error(error) = protocol_error(public_error(error))
153
+ def report_channel_error(error) = protocol_error(public_error(error))
144
154
 
145
- def cached_channel(channel, batch_config)
146
- channel.__send__(:ensure_fetch_config!, batch_config)
147
- channel
148
- end
155
+ def cached_channel(channel, batch_config)
156
+ channel.__send__(:ensure_fetch_config!, batch_config)
157
+ channel
158
+ end
149
159
 
150
- def build_channel(name, type, batch_config)
151
- Channel.new(self, method(:protocol), name, type, batch_config:)
152
- end
160
+ def build_channel(name, type, batch_config)
161
+ Channel.new(self, -> { protocol }, name, type, batch_config:)
162
+ end
153
163
 
154
- def protocol_lock
155
- require 'async/semaphore'
156
- @protocol_lock ||= Async::Semaphore.new(1)
157
- end
164
+ def protocol_lock
165
+ require 'async/semaphore'
166
+ @protocol_lock ||= Async::Semaphore.new(1)
167
+ end
158
168
 
159
- def channel_lock
160
- require 'async/semaphore'
161
- @channel_lock ||= Async::Semaphore.new(1)
169
+ def channel_lock
170
+ require 'async/semaphore'
171
+ @channel_lock ||= Async::Semaphore.new(1)
172
+ end
162
173
  end
174
+ include Core
175
+
176
+ private_constant :Core
163
177
 
164
178
  # Represents one realtime broadcast channel.
165
179
  class Channel
@@ -175,14 +189,15 @@ module Volcano
175
189
 
176
190
  attr_reader :name
177
191
 
192
+ # @dynamic name
193
+
178
194
  def initialize(realtime, protocol_provider, name, type, batch_config:)
179
195
  @realtime = realtime
180
196
  @protocol_provider = protocol_provider
181
197
  @name = name.freeze
182
198
  @handler_registered = @subscribed = @subscription_desired = @closed = false
183
199
  @lifecycle_lock = nil
184
- @recovery_position = {}.freeze
185
- @recovery_lineage = nil
200
+ initialize_recovery_state
186
201
  initialize_callback_dispatch
187
202
  initialize_presence(type)
188
203
  initialize_postgres_delivery(batch_config)
@@ -208,7 +223,7 @@ module Volcano
208
223
 
209
224
  def unsubscribe
210
225
  state = with_lifecycle_lock { unsubscribe_protocol }
211
- emit_presence_sync(state)
226
+ emit_presence_sync(state) if state.is_a?(Hash)
212
227
  nil
213
228
  end
214
229
 
@@ -218,11 +233,12 @@ module Volcano
218
233
 
219
234
  def restore_subscription(protocol)
220
235
  state = with_lifecycle_lock do
221
- next unless restoration_needed?
222
-
223
- subscribe_protocol(protocol)
236
+ restoration_needed? ? subscribe_protocol(protocol) : nil
237
+ end
238
+ if state
239
+ subscribed_protocol, epoch = state
240
+ sync_presence(subscribed_protocol, epoch)
224
241
  end
225
- sync_presence(*state) if state
226
242
  nil
227
243
  end
228
244
 
@@ -240,6 +256,13 @@ module Volcano
240
256
 
241
257
  private
242
258
 
259
+ def initialize_recovery_state
260
+ # @type var recovery_position: Hash[Symbol, String | Integer]
261
+ recovery_position = {}
262
+ @recovery_position = recovery_position.freeze
263
+ @recovery_lineage = nil
264
+ end
265
+
243
266
  def subscribe_with_intent
244
267
  ensure_open!
245
268
  raise DuplicateSubscriptionError, "already subscribed to #{@name}" if @subscription_desired
@@ -254,8 +277,8 @@ module Volcano
254
277
 
255
278
  def with_lifecycle_lock(&)
256
279
  require 'async/semaphore'
257
- @lifecycle_lock ||= Async::Semaphore.new(1)
258
- @lifecycle_lock.acquire(&)
280
+ lock = @lifecycle_lock ||= Async::Semaphore.new(1)
281
+ lock.acquire(&)
259
282
  end
260
283
 
261
284
  def ensure_open!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Volcano
4
- VERSION = '0.31.0'
4
+ VERSION = '0.32.0'
5
5
  end