google-cloud-pubsub 3.3.0 → 3.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 500ef51067933f91a1f6134ba3849fae4159f83d79e3f3dc6d6ef9237006e69e
4
- data.tar.gz: 32be664ffdeed8864e5be601131fc784805fc82564cd4ca03f355894f7c2f588
3
+ metadata.gz: 2706c0520b9cd5aba014d233fddf25b3c5265cc35a30b65719ed91647a271bef
4
+ data.tar.gz: d6f9a3e8c9b74a08bd344582ce7c7e9b5a4bc03cebe0243ffb09ac17cdcc0afd
5
5
  SHA512:
6
- metadata.gz: a75dd428cfa30499d42c0bd5b4a5726cd2c5029768adf0c027177c7e4ccbbfd982115b459bf310fe96cf2597fb90aae351a4a60157c0b436ea5253c5f3fa53cb
7
- data.tar.gz: 7160f84ef94817b60a6e53d57e37c586dd44c5ecbb31b9f8e181be42a67f4b6d3c2a735ac0dc70631c4f340ba632748fbe7018c9bad0dec1acc0061fa9272018
6
+ metadata.gz: 426ac33d83d07732c22854216b673def03cf2b5813718a5e57428546f0e5499178d6848e75473da2e71169b79d76246879663d94f1ce8415758737efe6f5817a
7
+ data.tar.gz: b5def0275205fa9bd13ea1f463ce50992903ba2c423817f22401675ecfc1eb55e1ef57f9e97178d83659ee40fdc1e87a21075aa4180dfed177526e487d043327
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Release History
2
2
 
3
+ ### 3.4.0 (2026-07-29)
4
+
5
+ #### Features
6
+
7
+ * implement streaming keep-alive logic ([#34653](https://github.com/googleapis/google-cloud-ruby/issues/34653))
8
+
3
9
  ### 3.3.0 (2026-06-11)
4
10
 
5
11
  #### Features
@@ -0,0 +1,159 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require "concurrent"
16
+
17
+ module Google
18
+ module Cloud
19
+ module PubSub
20
+ class MessageListener
21
+ ##
22
+ # @private
23
+ # Monitors gRPC streaming connection health via periodic bi-directional keep-alive pings and pongs.
24
+ #
25
+ # Tracks ping and pong timestamps over the active gRPC stream to detect silent connection drops or freezes,
26
+ # and triggers a stream restart when server responses exceed the configured pong deadline.
27
+ # This prevents firewalls and load balancers from dropping idle connections during periods of low
28
+ # message publisher volume, which minimizes message delivery latency by maintaining a healthy lease.
29
+ #
30
+ # For users, this ensures highly reliable, resilient, and responsive message delivery operations.
31
+ # It directly prevents issues where Google Cloud Pub/Sub subscribers may unexpectedly stall
32
+ # or stop receiving messages for extended periods due to unnotified intermediate network disruptions
33
+ # (e.g., TCP halfway drops). By proactively maintaining the connection's liveness, subscribers
34
+ # avoid sudden latency spikes and lost leases.
35
+ class KeepaliveMonitor
36
+ # Default interval in seconds between keep-alive ping requests.
37
+ DEFAULT_INTERVAL = 30.0
38
+ # Default deadline in seconds to receive a keep-alive pong response.
39
+ DEFAULT_DEADLINE = 15.0
40
+ # Divisor applied to keep-alive interval to calculate monitor polling interval (1/5th of interval).
41
+ MONITOR_DIVISOR = 5.0
42
+ # Minimum floor in seconds (10ms) for the monitor polling interval to prevent CPU spinning.
43
+ MIN_MONITOR_INTERVAL = 0.01
44
+
45
+ # Initializes the KeepaliveMonitor.
46
+ #
47
+ # @param [Stream] stream The parent streaming pull connection to monitor.
48
+ # @param [Float] interval The interval in seconds between keep-alive pings.
49
+ # @param [Float] deadline The deadline in seconds for receiving a pong.
50
+ def initialize stream, interval: DEFAULT_INTERVAL, deadline: DEFAULT_DEADLINE
51
+ @stream = stream
52
+ @interval = interval
53
+ @deadline = deadline
54
+ @last_ping_at = nil
55
+ @last_pong_at = nil
56
+ @ping_task = nil
57
+ @monitor_task = nil
58
+ end
59
+
60
+ # Starts the background keep-alive ping and liveness monitor timer tasks.
61
+ #
62
+ # @return [KeepaliveMonitor] self for chaining.
63
+ def start
64
+ @stream.synchronize do
65
+ return self if @ping_task
66
+
67
+ @ping_task = Concurrent::TimerTask.new(execution_interval: [@interval, MIN_MONITOR_INTERVAL].max) do
68
+ send_ping!
69
+ end
70
+ @ping_task.execute
71
+
72
+ @monitor_task = Concurrent::TimerTask.new(
73
+ execution_interval: [@interval / MONITOR_DIVISOR, MIN_MONITOR_INTERVAL].max
74
+ ) do
75
+ check_liveness!
76
+ end
77
+ @monitor_task.execute
78
+ end
79
+ self
80
+ end
81
+
82
+ # Shuts down and cleans up the background keep-alive and monitor timer tasks.
83
+ #
84
+ # @return [KeepaliveMonitor] self for chaining.
85
+ def stop
86
+ @stream.synchronize do
87
+ @ping_task&.shutdown
88
+ @ping_task = nil
89
+ @monitor_task&.shutdown
90
+ @monitor_task = nil
91
+ end
92
+ self
93
+ end
94
+
95
+ # Records the initial stream handshake by setting ping and pong timestamps to the current monotonic time.
96
+ #
97
+ # @return [void]
98
+ def record_handshake!
99
+ @stream.synchronize do
100
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+ @last_ping_at = now
102
+ @last_pong_at = now
103
+ end
104
+ end
105
+
106
+ # Records a received pong by updating the last pong timestamp to the current monotonic time.
107
+ #
108
+ # @return [void]
109
+ def record_pong!
110
+ @stream.synchronize do
111
+ @stream.log_info "received keepAlive pong from stream for subscription #{@stream.subscriber.subscription_name}"
112
+ @last_pong_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ end
114
+ end
115
+
116
+ # Evaluates stream state and sends a bi-directional keep-alive ping if appropriate.
117
+ #
118
+ # @return [void]
119
+ def send_ping!
120
+ @stream.synchronize do
121
+ # Push unconditional keep-alive pings only when the stream is actively open and request queue is active.
122
+ # Note: ACKs are sent via unary RPCs (TimedUnaryBuffer), not over this stream.
123
+ return unless @stream.stream_open? && !@stream.stopped? && @stream.request_queue_active?
124
+
125
+ @stream.log_info "sending keepAlive to stream for subscription #{@stream.subscriber.subscription_name}"
126
+ # Only advance @last_ping_at if the previous ping was successfully ponged.
127
+ # If a pong is outstanding (@last_pong_at < @last_ping_at), freezing @last_ping_at preserves the start time
128
+ # of the un-ponged cycle so check_liveness! can accurately evaluate the elapsed deadline.
129
+ @last_ping_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @last_pong_at.nil? || @last_ping_at.nil? || @last_pong_at >= @last_ping_at
130
+ @stream.send_ping_request!
131
+ end
132
+ end
133
+
134
+ # Checks whether a pong response has been received within the configured deadline,
135
+ # and triggers a stream restart if the deadline has been exceeded.
136
+ #
137
+ # @return [void]
138
+ def check_liveness!
139
+ @stream.synchronize do
140
+ # Do not check pong deadline if paused (client flow control inventory full).
141
+ # When paused, background_run waits on condition variable and stops calling enum.next,
142
+ # so incoming server pongs sit buffered in gRPC and last_pong_at stays un-updated.
143
+ return unless @stream.stream_open? && @last_ping_at && @last_pong_at && !@stream.stopped? && !@stream.paused?
144
+
145
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
146
+ if now - @last_ping_at >= @deadline && @last_pong_at < @last_ping_at
147
+ elapsed_pong = (now - @last_pong_at).round(2)
148
+ @stream.log_error "Keep-alive pong not received within #{@deadline}s (last pong #{elapsed_pong}s ago); restarting stream."
149
+ @stream.restart_stream_for_timeout!
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ Pubsub = PubSub unless const_defined? :Pubsub
158
+ end
159
+ end
@@ -15,6 +15,7 @@
15
15
  require "google/cloud/pubsub/message_listener/sequencer"
16
16
  require "google/cloud/pubsub/message_listener/enumerator_queue"
17
17
  require "google/cloud/pubsub/message_listener/inventory"
18
+ require "google/cloud/pubsub/message_listener/keepalive_monitor"
18
19
  require "google/cloud/pubsub/service"
19
20
  require "google/cloud/errors"
20
21
  require "monitor"
@@ -49,6 +50,25 @@ module Google
49
50
  # @private exactly_once_delivery_enabled.
50
51
  attr_reader :exactly_once_delivery_enabled
51
52
 
53
+ ##
54
+ # @private KeepaliveMonitor.
55
+ attr_reader :keepalive_monitor
56
+
57
+ # Initial backoff delay in seconds when reconnecting after a transient stream disconnection.
58
+ INITIAL_RECONNECT_DELAY = 1.0
59
+
60
+ # Maximum backoff delay in seconds for stream reconnection attempts.
61
+ MAX_RECONNECT_DELAY = 60.0
62
+
63
+ # Exponential backoff multiplier applied to reconnect delay on successive retry attempts.
64
+ RECONNECT_BACKOFF_MULTIPLIER = 1.5
65
+
66
+ # Inventory capacity ratio (80%) below which a flow-control paused stream will unpause.
67
+ UNPAUSE_INVENTORY_RATIO = 0.8
68
+
69
+ # The keep-alive streaming pull protocol version sent in the initial request during handshake.
70
+ PROTOCOL_VERSION = 1
71
+
52
72
  ##
53
73
  # @private Create an empty Subscriber::Stream object.
54
74
  def initialize subscriber
@@ -59,7 +79,12 @@ module Google
59
79
  @request_queue = nil
60
80
  @stopped = nil
61
81
  @paused = nil
82
+ # Condition variable used to cooperatively pause the background thread during flow control
83
+ # Governed by the Stream's MonitorMixin `synchronize` lock.
62
84
  @pause_cond = new_cond
85
+ # Condition variable used to cooperatively sleep the background thread during connection retries
86
+ # Governed by the Stream's MonitorMixin `synchronize` lock.
87
+ @backoff_cond = new_cond
63
88
  @exactly_once_delivery_enabled = false
64
89
 
65
90
  @inventory = Inventory.new self, **@subscriber.stream_inventory
@@ -68,17 +93,9 @@ module Google
68
93
 
69
94
  @callback_thread_pool = Concurrent::ThreadPoolExecutor.new max_threads: @subscriber.callback_threads
70
95
 
71
- @stream_keepalive_task = Concurrent::TimerTask.new(
72
- execution_interval: 30
73
- ) do
74
- # push empty request every 30 seconds to keep stream alive
75
- unless inventory.empty?
76
- subscriber.service.logger.log :info, "subscriber-streams" do
77
- "sending keepAlive to stream for subscription #{@subscriber.subscription_name}"
78
- end
79
- push Google::Cloud::PubSub::V1::StreamingPullRequest.new
80
- end
81
- end.execute
96
+ @keepalive_monitor = KeepaliveMonitor.new self
97
+ @stream_open = false
98
+ @reconnect_delay = nil
82
99
  end
83
100
 
84
101
  def start
@@ -86,6 +103,7 @@ module Google
86
103
  break if @background_thread
87
104
 
88
105
  @inventory.start
106
+ @keepalive_monitor.start
89
107
 
90
108
  start_streaming!
91
109
  end
@@ -107,6 +125,9 @@ module Google
107
125
  # Signal to the background thread that we are stopped.
108
126
  @stopped = true
109
127
  @pause_cond.broadcast
128
+ @backoff_cond.broadcast
129
+
130
+ @keepalive_monitor.stop
110
131
 
111
132
  # Now that the reception thread is stopped, immediately stop the
112
133
  # callback thread pool. All queued callbacks will see the stream
@@ -124,6 +145,10 @@ module Google
124
145
  synchronize { @stopped }
125
146
  end
126
147
 
148
+ def stream_open?
149
+ synchronize { @stream_open }
150
+ end
151
+
127
152
  def paused?
128
153
  synchronize { @paused }
129
154
  end
@@ -139,6 +164,38 @@ module Google
139
164
  self
140
165
  end
141
166
 
167
+
168
+ def request_queue_active?
169
+ !@request_queue.nil?
170
+ end
171
+
172
+ def send_ping_request!
173
+ push Google::Cloud::PubSub::V1::StreamingPullRequest.new
174
+ end
175
+
176
+ def restart_stream_for_timeout!
177
+ synchronize do
178
+ @stream_open = false
179
+ # Push self as a stream-closing sentinel to @request_queue.
180
+ # When EnumeratorQueue#each pops the sentinel object, it terminates the request enumerator,
181
+ # cleanly sending an HTTP/2 END_STREAM flag and unblocking the write-side gRPC C-core pipeline.
182
+ @request_queue&.push self
183
+ @background_thread&.raise RestartStream
184
+ end
185
+ end
186
+
187
+ def log_info msg
188
+ subscriber.service.logger.log :info, "subscriber-streams" do
189
+ msg
190
+ end
191
+ end
192
+
193
+ def log_error msg
194
+ subscriber.service.logger.log :error, "subscriber-streams" do
195
+ msg
196
+ end
197
+ end
198
+
142
199
  ##
143
200
  # @private
144
201
  def acknowledge *messages, &callback
@@ -219,6 +276,15 @@ module Google
219
276
 
220
277
  # rubocop:disable all
221
278
 
279
+ def backoff_and_wait!
280
+ @reconnect_delay = @reconnect_delay ? [@reconnect_delay * RECONNECT_BACKOFF_MULTIPLIER, MAX_RECONNECT_DELAY].min : INITIAL_RECONNECT_DELAY
281
+ synchronize do
282
+ # Disable liveness checker during backoff sleep to prevent monitor task from interrupting and double-backing off
283
+ @stream_open = false
284
+ @backoff_cond.wait(@reconnect_delay + rand(0.0..0.5)) unless @stopped
285
+ end
286
+ end
287
+
222
288
  def background_run
223
289
  synchronize do
224
290
  # Don't allow a stream to restart if already stopped
@@ -240,16 +306,32 @@ module Google
240
306
  # Always create a new request queue
241
307
  @request_queue = EnumeratorQueue.new self
242
308
  @request_queue.push initial_input_request
243
- old_queue.each { |obj| @request_queue.push obj }
309
+ old_queue.each do |obj|
310
+ @request_queue.push obj unless obj == self
311
+ end
244
312
  end
245
313
 
246
314
  # Call the StreamingPull API to get the response enumerator
247
315
  options = { :"metadata" => { :"x-goog-request-params" => @subscriber.subscription_name } }
316
+ # Temporarily disable the liveness monitor while establishing the gRPC connection
317
+ # to prevent check_liveness! from evaluating stale timestamps during the handshake.
318
+ synchronize do
319
+ @stream_open = false
320
+ end
248
321
  enum = @subscriber.service.streaming_pull @request_queue.each, options
249
322
  subscriber.service.logger.log :info, "subscriber-streams" do
250
323
  "rpc: streamingPull, subscription: #{@subscriber.subscription_name}, stream opened"
251
324
  end
252
325
 
326
+ # Once the stream handshake completes, initialize ping/pong timestamps to monotonic now
327
+ # and mark @stream_open = true under synchronization.
328
+ # This ensures @keepalive_monitor evaluates liveness against a fresh window and prevents
329
+ # false-positive disconnect detections from stale timestamps prior to reconnecting.
330
+ synchronize do
331
+ @keepalive_monitor.record_handshake!
332
+ @stream_open = true
333
+ end
334
+
253
335
  loop do
254
336
  synchronize do
255
337
  if @paused && !@stopped
@@ -264,8 +346,17 @@ module Google
264
346
  begin
265
347
  # Cannot synchronize the enumerator, causes deadlock
266
348
  response = enum.next
267
- new_exactly_once_delivery_enabled = response&.subscription_properties&.exactly_once_delivery_enabled
349
+ synchronize do
350
+ @keepalive_monitor.record_pong!
351
+ # Reset backoff delay only after successfully reading a frame from enum.next.
352
+ # If the connection drops immediately upon reading, @reconnect_delay is preserved.
353
+ @reconnect_delay = nil
354
+ end
268
355
  received_messages = response.received_messages
356
+ # Skip processing properties and inventory on Pong frames (empty received_messages).
357
+ # Subscription properties on keep-alive Pongs are not valid.
358
+ next if received_messages.empty?
359
+ new_exactly_once_delivery_enabled = response&.subscription_properties&.exactly_once_delivery_enabled
269
360
 
270
361
  # Use synchronize so changes happen atomically
271
362
  synchronize do
@@ -310,11 +401,13 @@ module Google
310
401
  "#{status_code}; will be retried."
311
402
  end
312
403
  # Restart the stream with an incremental back for a retriable error.
404
+ backoff_and_wait!
313
405
  retry
314
406
  rescue RestartStream
315
407
  subscriber.service.logger.log :info, "subscriber-streams" do
316
408
  "Subscriber stream for subscription #{@subscriber.subscription_name} has ended; will be retried."
317
409
  end
410
+ backoff_and_wait!
318
411
  retry
319
412
  rescue StandardError => e
320
413
  subscriber.service.logger.log :error, "subscriber-streams" do
@@ -322,6 +415,7 @@ module Google
322
415
  end
323
416
  @subscriber.error! e
324
417
 
418
+ backoff_and_wait!
325
419
  retry
326
420
  end
327
421
 
@@ -417,21 +511,27 @@ module Google
417
511
  end
418
512
 
419
513
  def unpause_streaming!
420
- return unless unpause_streaming?
421
-
422
- @paused = nil
423
- subscriber.service.logger.log :info, "subscriber-flow-control" do
424
- "subscriber for #{@subscriber.subscription_name} is unblocking client-side flow control"
514
+ synchronize do
515
+ return unless unpause_streaming?
516
+
517
+ @paused = nil
518
+ # Record pong when unpausing flow control. While paused, incoming server pongs sit buffered in gRPC,
519
+ # leaving last_pong_at stale. Updating timestamp guarantees the monitor thread will not trigger an immediate
520
+ # false-positive restart while the reader thread wakes up to drain buffered frames.
521
+ @keepalive_monitor.record_pong!
522
+ subscriber.service.logger.log :info, "subscriber-flow-control" do
523
+ "subscriber for #{@subscriber.subscription_name} is unblocking client-side flow control"
524
+ end
525
+ # Signal to the background thread that we are unpaused
526
+ @pause_cond.broadcast
425
527
  end
426
- # signal to the background thread that we are unpaused
427
- @pause_cond.broadcast
428
528
  end
429
529
 
430
530
  def unpause_streaming?
431
531
  return false if @stopped
432
532
  return false if @paused.nil?
433
533
 
434
- @inventory.count < @inventory.limit * 0.8
534
+ @inventory.count < @inventory.limit * UNPAUSE_INVENTORY_RATIO
435
535
  end
436
536
 
437
537
  def initial_input_request
@@ -443,6 +543,7 @@ module Google
443
543
  req.client_id = @subscriber.service.client_id
444
544
  req.max_outstanding_messages = @inventory.limit
445
545
  req.max_outstanding_bytes = @inventory.bytesize
546
+ req.protocol_version = PROTOCOL_VERSION
446
547
  end
447
548
  end
448
549
 
@@ -16,7 +16,7 @@
16
16
  module Google
17
17
  module Cloud
18
18
  module PubSub
19
- VERSION = "3.3.0".freeze
19
+ VERSION = "3.4.0".freeze
20
20
  end
21
21
 
22
22
  Pubsub = PubSub unless const_defined? :Pubsub
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: google-cloud-pubsub
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Moore
@@ -100,6 +100,7 @@ files:
100
100
  - lib/google/cloud/pubsub/message_listener.rb
101
101
  - lib/google/cloud/pubsub/message_listener/enumerator_queue.rb
102
102
  - lib/google/cloud/pubsub/message_listener/inventory.rb
103
+ - lib/google/cloud/pubsub/message_listener/keepalive_monitor.rb
103
104
  - lib/google/cloud/pubsub/message_listener/sequencer.rb
104
105
  - lib/google/cloud/pubsub/message_listener/stream.rb
105
106
  - lib/google/cloud/pubsub/message_listener/timed_unary_buffer.rb