• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

juarezr / spark-streaming-google-pubsub / 68

30 Oct 2026 09:10PM UTC coverage: 79.199% (+8.1%) from 71.066%
68

push

github

web-flow
Fix: tune auto receive time to achieve the maximum throughput possible with the available heap and RSS memory in the driver (#23)

- Make the connector discover and monitor the available memory limits
for the driver in the instance
- Adapt the message rate to the available memory to avoid out-of-memory
errors and being killed by the OOM killer
- Refine cycle time estimation logic to improve the throughput
- Add a new ackMode=AfterCheckpoint option to improve memory usage and
throughput
- Add Spark v4.3 support and testing in CI

1226 of 1711 branches covered (71.65%)

Branch coverage included in aggregate %.

862 of 1033 new or added lines in 11 files covered. (83.45%)

4 existing lines in 1 file now uncovered.

2315 of 2760 relevant lines covered (83.88%)

0.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.2
/src/main/java/io/github/juarezr/spark/pubsub/structured/PubSubClient.java
1
package io.github.juarezr.spark.pubsub.structured;
2

3
import com.google.api.core.ApiService;
4
import com.google.api.gax.batching.FlowControlSettings;
5
import com.google.api.gax.batching.FlowController;
6
import com.google.api.gax.core.CredentialsProvider;
7
import com.google.cloud.pubsub.v1.AckReplyConsumer;
8
import com.google.cloud.pubsub.v1.MessageReceiver;
9
import com.google.cloud.pubsub.v1.Subscriber;
10
import com.google.cloud.pubsub.v1.SubscriptionAdminClient;
11
import com.google.cloud.pubsub.v1.SubscriptionAdminSettings;
12
import com.google.common.util.concurrent.MoreExecutors;
13
import com.google.protobuf.Timestamp;
14
import com.google.pubsub.v1.PubsubMessage;
15
import com.google.pubsub.v1.SeekRequest;
16
import io.github.juarezr.spark.pubsub.config.PubSubConfig;
17
import io.github.juarezr.spark.pubsub.config.SeekMode;
18
import java.io.Closeable;
19
import java.io.IOException;
20
import java.io.Serializable;
21
import java.time.Duration;
22
import java.time.Instant;
23
import java.util.ArrayList;
24
import java.util.List;
25
import java.util.concurrent.ConcurrentHashMap;
26
import java.util.concurrent.LinkedBlockingQueue;
27
import java.util.concurrent.TimeUnit;
28
import java.util.concurrent.TimeoutException;
29
import java.util.concurrent.atomic.AtomicBoolean;
30
import java.util.concurrent.atomic.AtomicLong;
31
import org.slf4j.Logger;
32
import org.slf4j.LoggerFactory;
33

34
/**
35
 * Warm {@link Subscriber} (StreamingPull) plus a bounded queue. The receiver never acks; Spark
36
 * commit calls {@link #acknowledge}.
37
 */
38
final class PubSubClient implements Closeable, Serializable {
39

40
  private static final long serialVersionUID = -3861530486L;
41
  private static final Logger LOG = LoggerFactory.getLogger(PubSubClient.class);
1 ✔
42

43
  static {
44
    MessageDispatcherLog.install();
1 ✔
45
  }
1 ✔
46

47
  private final RetryPolicy retryPolicy;
48
  private PubSubConnectionErrors connectionErrors;
49
  private transient PubSubEmulator emulator;
50

51
  private final PubSubConfig config;
52
  private final PubSubCredentialsProvider credentialsProvider;
53
  private transient Subscriber subscriber;
54
  private transient LinkedBlockingQueue<HeldMessage> queue;
55
  private transient ConcurrentHashMap<String, AckReplyConsumer> consumers;
56
  private transient AtomicLong outstandingBytes;
57
  private transient AtomicLong queuedBytes;
58
  private transient AtomicBoolean stopped;
59
  private transient boolean seekApplied;
60

61
  /** Caps the next {@link #poll(Duration)} so overflow stays queued for the following batch. */
62
  private transient int nextPollMax = Integer.MAX_VALUE;
1 ✔
63

64
  private final AtomicLong lastSeenNewestPublishMillis = new AtomicLong(Long.MIN_VALUE);
1 ✔
65
  private final AtomicLong lastHeapCeilingWarnMillis = new AtomicLong(Long.MIN_VALUE);
1 ✔
66
  private final AtomicLong lastHostReserveWarnMillis = new AtomicLong(Long.MIN_VALUE);
1 ✔
67
  private final AtomicBoolean intakePaused = new AtomicBoolean(false);
1 ✔
68
  private volatile long memoryBudget;
69
  private long baselineBytes;
70
  private long flowSeedBytes;
71
  private long memoryLimitBytes;
72
  private int calmStreak;
73
  private int hotStreak;
74
  private long lastMove;
75
  private boolean lastShrank;
76
  private int approachStreak;
77
  private volatile String pauseReason;
78
  private final AtomicLong cachedAvailableBytes = new AtomicLong(Long.MIN_VALUE);
1 ✔
79
  private final AtomicLong cachedAvailableAtMillis = new AtomicLong(Long.MIN_VALUE);
1 ✔
80
  private volatile long cycleBudgetMillis;
81
  private long hostPauseSinceMillis = Long.MIN_VALUE;
1 ✔
82
  private boolean hostCollected;
83

84
  final class PubSubMessageReceiver implements MessageReceiver {
1 ✔
85

86
    @Override
87
    public void receiveMessage(PubsubMessage message, AckReplyConsumer consumer) {
88
      if (stopped != null && stopped.get()) {
1 !
89
        consumer.nack();
×
90
        return;
×
91
      }
92
      PulledMessage pulled = toPulled(message);
1 ✔
93
      while (aboveHeapCeiling() && stopped != null && !stopped.get()) {
1 !
NEW
94
        noteHeapCeiling();
×
NEW
95
        collectHostReserveOnce();
×
96
        try {
97
          TimeUnit.MILLISECONDS.sleep(10);
×
98
        } catch (InterruptedException e) {
×
99
          Thread.currentThread().interrupt();
×
100
          consumer.nack();
×
101
          return;
×
102
        }
×
103
      }
104
      if (!DriverHeap.belowHostReserve(cachedAvailable(System.currentTimeMillis()))) {
1 !
105
        resetHostPause();
1 ✔
106
      }
107
      if (stopped != null && stopped.get()) {
1 !
108
        consumer.nack();
×
109
        return;
×
110
      }
111
      if (!awaitReserve(pulled.data().length)) {
1 !
NEW
112
        consumer.nack();
×
NEW
113
        return;
×
114
      }
115
      consumers.put(pulled.ackId(), consumer);
1 ✔
116
      outstandingBytes.addAndGet(pulled.data().length);
1 ✔
117
      try {
118
        queue.put(new HeldMessage(pulled, consumer));
1 ✔
119
      } catch (InterruptedException e) {
×
120
        Thread.currentThread().interrupt();
×
121
        consumers.remove(pulled.ackId());
×
NEW
122
        subtractQueued(pulled.data().length);
×
123
        outstandingBytes.addAndGet(-pulled.data().length);
×
124
        consumer.nack();
×
125
      }
1 ✔
126
    }
1 ✔
127

128
    private PulledMessage toPulled(PubsubMessage message) {
129
      byte[] data = message.getData().toByteArray();
1 ✔
130
      long publishMillis =
1 ✔
131
          message.getPublishTime().getSeconds() * 1000L
1 ✔
132
              + message.getPublishTime().getNanos() / 1_000_000L;
1 ✔
133
      String messageId = message.getMessageId();
1 ✔
134
      return new PulledMessage(
1 ✔
135
          messageId,
136
          data,
137
          message.getAttributesMap(),
1 ✔
138
          publishMillis,
139
          message.getOrderingKey(),
1 ✔
140
          messageId);
141
    }
142
  }
143

144
  static final class HeldMessage {
145
    final PulledMessage message;
146
    final AckReplyConsumer consumer;
147

148
    HeldMessage(PulledMessage message, AckReplyConsumer consumer) {
1 ✔
149
      this.message = message;
1 ✔
150
      this.consumer = consumer;
1 ✔
151
    }
1 ✔
152
  }
153

154
  PubSubClient(PubSubConfig config) {
155
    this(
1 ✔
156
        config,
157
        new PubSubCredentialsProvider(config.credentialsFile().orElse(null)),
1 ✔
158
        RetryPolicy.defaults(config.effectiveMaxRetryTime()));
1 ✔
159
  }
1 ✔
160

161
  PubSubClient(
162
      PubSubConfig config, PubSubCredentialsProvider credentialsProvider, RetryPolicy retryPolicy) {
1 ✔
163
    this.config = config;
1 ✔
164
    this.credentialsProvider = credentialsProvider;
1 ✔
165
    this.retryPolicy = retryPolicy;
1 ✔
166
    this.retryPolicy.lastPublishTime(this.lastSeenNewestPublishMillis::get);
1 ✔
167
  }
1 ✔
168

169
  synchronized void start() throws IOException {
170
    if (this.subscriber != null) {
1 !
171
      return;
×
172
    }
173
    this.outstandingBytes = new AtomicLong(0);
1 ✔
174
    this.queuedBytes = new AtomicLong(0);
1 ✔
175
    this.queue = new LinkedBlockingQueue<>();
1 ✔
176
    this.consumers = new ConcurrentHashMap<>();
1 ✔
177
    this.stopped = new AtomicBoolean(false);
1 ✔
178
    this.connectionErrors = new PubSubConnectionErrors(this.config.subscriptionPath());
1 ✔
179
    long heapMax = Runtime.getRuntime().maxMemory();
1 ✔
180
    long seeded = DriverHeap.seed(config.batchSize(), heapMax);
1 ✔
181
    if (seeded <= 0L) {
1 !
NEW
182
      seeded = config.batchSize() > 0 ? config.batchSize() : PubSubConfig.DEFAULT_BATCH_SIZE;
×
183
    }
184
    long limit = DriverHeap.memoryLimit(config.batchSize(), heapMax);
1 ✔
185
    long outstanding = seeded > Long.MAX_VALUE / 2 ? Long.MAX_VALUE : seeded * 2L;
1 !
186
    if (limit < seeded) {
1 !
NEW
187
      limit = seeded;
×
188
    }
189
    if (limit > outstanding) {
1 !
NEW
190
      limit = outstanding;
×
191
    }
192
    this.memoryBudget = seeded;
1 ✔
193
    // Baseline stays put. Outstanding starts at twice that seed. A later empty gather rebuilds
194
    // the subscriber so outstanding follows memoryBudget. The climb stops at memoryLimitBytes.
195
    this.baselineBytes = seeded;
1 ✔
196
    this.flowSeedBytes = seeded;
1 ✔
197
    this.memoryLimitBytes = limit;
1 ✔
198
    this.lastMove = DriverHeap.openingMove(seeded);
1 ✔
199
    this.lastShrank = false;
1 ✔
200
    this.approachStreak = 0;
1 ✔
201

202
    this.emulator = this.config.emulatorHost().map(PubSubEmulator::new).orElse(null);
1 ✔
203
    try {
204
      CredentialsProvider authProvider = this.credentialsProvider.providerFor(this.config);
1 ✔
205
      if (this.emulator == null) {
1 !
206
        connectionErrors.verifySubscriptionAccessible(authProvider);
×
207
      }
208
      applySeekIfNeeded(authProvider);
1 ✔
209

210
      startSubscriberAsyncWith(authProvider);
1 ✔
211

212
      this.connectionErrors.throwIfFatalConnection();
1 ✔
213
    } catch (Exception e) {
×
214
      this.close();
×
215
      if (e instanceof IOException) {
×
216
        throw (IOException) e;
×
217
      }
218
      throw new IOException("Unable to start Pub/Sub subscriber", e);
×
219
    }
1 ✔
220
  }
1 ✔
221

222
  /**
223
   * When the cap has moved, stop the subscriber and start another whose outstanding bytes are twice
224
   * {@code memoryBudget}. A prefetch still in the queue is nacked first. Bytes above that prefetch
225
   * stay outstanding. A failed start leaves the running subscriber in place.
226
   */
227
  synchronized void retargetFlow() {
228
    if (queuedBytes == null || outstandingBytes == null) {
1 !
NEW
229
      return;
×
230
    }
231
    if (!capMoved()) {
1 ✔
232
      return;
1 ✔
233
    }
234
    long nacked = nackQueued();
1 ✔
235
    if (nacked > 0L) {
1 ✔
236
      long remainder = outstandingBytes.get() - nacked;
1 ✔
237
      outstandingBytes.set(remainder > 0L ? remainder : 0L);
1 ✔
238
    }
239
    long previousSeed = flowSeedBytes;
1 ✔
240
    long nextSeed = memoryBudget;
1 ✔
241
    if (this.subscriber == null) {
1 !
242
      this.flowSeedBytes = nextSeed;
1 ✔
243
      logSubscriberOutstanding(previousSeed, nextSeed);
1 ✔
244
      return;
1 ✔
245
    }
NEW
246
    this.flowSeedBytes = nextSeed;
×
247
    Subscriber replacement;
248
    try {
NEW
249
      CredentialsProvider authProvider = this.credentialsProvider.providerFor(this.config);
×
NEW
250
      replacement = startSubscriber(authProvider);
×
NEW
251
    } catch (Exception e) {
×
NEW
252
      this.flowSeedBytes = previousSeed;
×
NEW
253
      LOG.warn(
×
254
          "HEAP: subscriber rebuild failed; outstanding stays {}",
NEW
255
          outstandingLimit(previousSeed),
×
256
          e);
NEW
257
      return;
×
NEW
258
    }
×
NEW
259
    Subscriber previous = this.subscriber;
×
NEW
260
    this.subscriber = replacement;
×
NEW
261
    stopSubscriber(previous);
×
NEW
262
    logSubscriberOutstanding(previousSeed, nextSeed);
×
NEW
263
  }
×
264

265
  private void startSubscriberAsyncWith(CredentialsProvider authProvider) {
266
    this.subscriber = startSubscriber(authProvider);
1 ✔
267
  }
1 ✔
268

269
  private Subscriber startSubscriber(CredentialsProvider authProvider) {
270

271
    Subscriber.Builder builder =
1 ✔
272
        Subscriber.newBuilder(this.config.subscriptionPath(), new PubSubMessageReceiver());
1 ✔
273
    builder.setFlowControlSettings(flowControl());
1 ✔
274

275
    long ackExtendSecs = config.effectiveAckDeadline(null).getSeconds();
1 ✔
276
    long ackExtendMax = Math.max(60L, ackExtendSecs);
1 ✔
277
    org.threeten.bp.Duration ackExtend = org.threeten.bp.Duration.ofSeconds(ackExtendMax);
1 ✔
278
    org.threeten.bp.Duration ackMaxPeriod = org.threeten.bp.Duration.ofMinutes(60);
1 ✔
279
    builder.setMaxAckExtensionPeriod(ackMaxPeriod);
1 ✔
280
    builder.setMaxDurationPerAckExtension(ackExtend);
1 ✔
281

282
    if (this.emulator != null) {
1 !
283
      this.emulator.configureSubscriber(builder);
1 ✔
284
    } else {
285
      builder.setCredentialsProvider(authProvider);
×
286
    }
287
    Subscriber created = builder.build();
1 ✔
288
    created.addListener(connectionErrors, MoreExecutors.directExecutor());
1 ✔
289
    created.startAsync().awaitRunning();
1 ✔
290
    return created;
1 ✔
291
  }
292

293
  private void stopSubscriber(Subscriber previous) {
NEW
294
    if (previous == null) {
×
NEW
295
      return;
×
296
    }
297
    try {
NEW
298
      previous.stopAsync().awaitTerminated(5, TimeUnit.SECONDS);
×
NEW
299
    } catch (TimeoutException e) {
×
300
      // The replacement is already serving. The old one can finish stopping after this gather.
NEW
301
      LOG.debug(
×
302
          "CONNECTION: new subscriber is already serving; the previous one was still stopping");
NEW
303
    } catch (Exception e) {
×
NEW
304
      LOG.warn("CONNECTION: Error shutting down subscriber", e);
×
NEW
305
    }
×
NEW
306
  }
×
307

308
  private void logSubscriberOutstanding(long previousSeed, long nextSeed) {
309
    LOG.debug(
1 ✔
310
        "HEAP: subscriber outstanding {} -> {}",
311
        outstandingLimit(previousSeed),
1 ✔
312
        outstandingLimit(nextSeed));
1 ✔
313
  }
1 ✔
314

315
  private static long outstandingLimit(long seed) {
316
    if (seed <= 0L) {
1 !
NEW
317
      return 0L;
×
318
    }
319
    return seed > Long.MAX_VALUE / 2 ? Long.MAX_VALUE : seed * 2L;
1 !
320
  }
321

322
  private FlowControlSettings flowControl() {
323
    long bytes = flowSeedBytes > 0L ? flowSeedBytes : queueCap();
1 !
324
    FlowControlSettings.Builder flow =
325
        FlowControlSettings.newBuilder()
1 ✔
326
            .setMaxOutstandingRequestBytes(outstandingLimit(bytes))
1 ✔
327
            .setLimitExceededBehavior(FlowController.LimitExceededBehavior.Block);
1 ✔
328
    if (config.batchCount() > 0) {
1 ✔
329
      long count = config.batchCount();
1 ✔
330
      long elements = count > Long.MAX_VALUE / 2 ? Long.MAX_VALUE : count * 2;
1 !
331
      flow.setMaxOutstandingElementCount(elements);
1 ✔
332
    }
333
    return flow.build();
1 ✔
334
  }
335

336
  private void applySeekIfNeeded(CredentialsProvider authProvider) throws IOException {
337
    if (seekApplied || this.config.seekMode() == SeekMode.NONE) {
1 !
338
      return;
1 ✔
339
    }
340
    final SeekRequest.Builder seek = getSeekRequestBuilder();
1 ✔
341
    final SubscriptionAdminSettings.Builder builder = SubscriptionAdminSettings.newBuilder();
1 ✔
342
    try (PubSubEmulator adminEmulator =
1 ✔
343
        this.config.emulatorHost().map(PubSubEmulator::new).orElse(null)) {
1 ✔
344
      if (adminEmulator != null) {
1 !
345
        adminEmulator.configureSubscriptionAdmin(builder);
1 ✔
346
      } else {
347
        builder.setCredentialsProvider(authProvider);
×
348
      }
349
      try (SubscriptionAdminClient admin = SubscriptionAdminClient.create(builder.build())) {
1 ✔
350
        LOG.warn(
1 ✔
351
            "CONNECTION: Applying seek={} on subscription {} (explicit rewind requested)",
352
            this.config.seekMode(),
1 ✔
353
            this.config.subscriptionPath());
1 ✔
354
        admin.seek(seek.build());
1 ✔
355
      }
356
    }
357
    seekApplied = true;
1 ✔
358
  }
1 ✔
359

360
  private SeekRequest.Builder getSeekRequestBuilder() {
361
    final SeekRequest.Builder seek =
362
        SeekRequest.newBuilder().setSubscription(this.config.subscriptionPath());
1 ✔
363

364
    switch (this.config.seekMode()) {
1 !
365
      case BEGINNING:
366
        seek.setTime(Timestamp.newBuilder().setSeconds(0).setNanos(0).build());
1 ✔
367
        break;
1 ✔
368
      case TIMESTAMP:
369
        final Instant seekInstant = this.config.seekTimeAsInstant();
×
370
        final Timestamp seekTime =
371
            Timestamp.newBuilder()
×
372
                .setSeconds(seekInstant.getEpochSecond())
×
373
                .setNanos(seekInstant.getNano())
×
374
                .build();
×
375
        seek.setTime(seekTime);
×
376
        break;
×
377
      case SNAPSHOT:
378
        seek.setSnapshot(this.config.seekSnapshot().orElseThrow());
×
379
        break;
×
380
      case NONE:
381
      default:
382
    }
383
    return seek;
1 ✔
384
  }
385

386
  private void ensureStarted() {
387
    if (this.subscriber == null) {
1 !
388
      try {
389
        start();
×
390
      } catch (IOException e) {
×
391
        throw new IllegalStateException("CONNECTION: Failed to start Pub/Sub client", e);
×
392
      }
×
393
    }
394
    if (this.connectionErrors != null) {
1 ✔
395
      try {
396
        this.connectionErrors.throwIfFatalConnection();
1 ✔
397
      } catch (IOException e) {
1 ✔
398
        throw new IllegalStateException(e.getMessage(), e);
1 ✔
399
      }
1 ✔
400
    }
401
  }
1 ✔
402

403
  @Override
404
  public synchronized void close() {
405
    if (stopped != null) {
1 !
406
      stopped.set(true);
1 ✔
407
    }
408
    if (this.subscriber != null) {
1 ✔
409
      try {
410
        ApiService service = this.subscriber.stopAsync();
1 ✔
411
        service.awaitTerminated(5, TimeUnit.SECONDS);
1 ✔
412
      } catch (Exception e) {
×
413
        LOG.warn("CONNECTION: Error shutting down subscriber", e);
×
414
      } finally {
415
        this.subscriber = null;
1 ✔
416
      }
417
    }
418
    nackQueued();
1 ✔
419
    nackRemainingConsumers();
1 ✔
420
    if (this.emulator != null) {
1 ✔
421
      this.emulator.close();
1 ✔
422
      this.emulator = null;
1 ✔
423
    }
424
  }
1 ✔
425

426
  /**
427
   * Nacks messages sitting in the queue. A reservation that has not been queued yet stays in {@code
428
   * queuedBytes}; zeroing the counter there made the next {@code take} go negative and {@code fits}
429
   * then refused every later message.
430
   *
431
   * @return bytes removed from the queue
432
   */
433
  private long nackQueued() {
434
    if (queue == null) {
1 ✔
435
      return 0L;
1 ✔
436
    }
437
    long nacked = 0L;
1 ✔
438
    HeldMessage held;
439
    while ((held = queue.poll()) != null) {
1 ✔
440
      nacked += held.message.data().length;
1 ✔
441
      if (consumers != null) {
1 !
442
        consumers.remove(held.message.ackId());
1 ✔
443
      }
444
      try {
445
        held.consumer.nack();
1 ✔
446
      } catch (RuntimeException e) {
×
447
        LOG.warn("CONNECTION: Failed to nack queued message on close: {}", e.getMessage());
×
448
      }
1 ✔
449
    }
450
    subtractQueued(nacked);
1 ✔
451
    return nacked;
1 ✔
452
  }
453

454
  private void nackRemainingConsumers() {
455
    if (consumers == null || consumers.isEmpty()) {
1 !
456
      return;
1 ✔
457
    }
NEW
458
    for (AckReplyConsumer consumer : consumers.values()) {
×
459
      try {
NEW
460
        consumer.nack();
×
NEW
461
      } catch (RuntimeException e) {
×
NEW
462
        LOG.warn("CONNECTION: Failed to nack consumer on close: {}", e.getMessage());
×
NEW
463
      }
×
NEW
464
    }
×
NEW
465
    consumers.clear();
×
NEW
466
  }
×
467

468
  private void subtractQueued(long bytes) {
469
    if (queuedBytes == null || bytes <= 0L) {
1 !
470
      return;
1 ✔
471
    }
472
    while (true) {
473
      long held = queuedBytes.get();
1 ✔
474
      long next = held > bytes ? held - bytes : 0L;
1 ✔
475
      if (queuedBytes.compareAndSet(held, next)) {
1 ✔
476
        return;
1 ✔
477
      }
478
    }
1 ✔
479
  }
480

481
  /**
482
   * Upper bound for the next {@link #poll(Duration)}. Messages beyond the cap stay in the queue.
483
   * {@code maxMessages <= 0} means no cap.
484
   */
485
  void limitNextPoll(int maxMessages) {
486
    this.nextPollMax = maxMessages <= 0 ? Integer.MAX_VALUE : maxMessages;
1 !
487
  }
1 ✔
488

489
  /**
490
   * Wait up to {@code timeout} for messages, up to {@link #limitNextPoll(int)}. When capped, keeps
491
   * waiting within the same timeout until the cap is reached or the deadline passes. When uncapped,
492
   * waits only for the first message, then drains the queue without blocking. Does not ack.
493
   */
494
  List<PulledMessage> poll(Duration timeout) {
495
    ensureStarted();
1 ✔
496
    int max = this.nextPollMax;
1 ✔
497
    this.nextPollMax = Integer.MAX_VALUE;
1 ✔
498
    List<PulledMessage> messages = new ArrayList<>();
1 ✔
499
    if (max <= 0) {
1 !
500
      return messages;
×
501
    }
502
    long deadlineNanos = System.nanoTime() + Math.max(1L, timeout.toNanos());
1 ✔
503
    try {
504
      long waitNanos = deadlineNanos - System.nanoTime();
1 ✔
505
      if (waitNanos <= 0L) {
1 ✔
506
        return messages;
1 ✔
507
      }
508
      HeldMessage first = queue.poll(waitNanos, TimeUnit.NANOSECONDS);
1 ✔
509
      if (first == null) {
1 ✔
510
        return messages;
1 ✔
511
      }
512
      take(first, messages);
1 ✔
513
      if (max == Integer.MAX_VALUE) {
1 ✔
514
        HeldMessage next;
515
        while ((next = queue.poll()) != null) {
1 ✔
516
          take(next, messages);
1 ✔
517
        }
518
      } else {
1 ✔
519
        while (messages.size() < max) {
1 ✔
520
          waitNanos = deadlineNanos - System.nanoTime();
1 ✔
521
          if (waitNanos <= 0L) {
1 !
522
            break;
×
523
          }
524
          HeldMessage next = queue.poll(waitNanos, TimeUnit.NANOSECONDS);
1 ✔
525
          if (next == null) {
1 ✔
526
            break;
1 ✔
527
          }
528
          take(next, messages);
1 ✔
529
        }
1 ✔
530
      }
531
    } catch (InterruptedException e) {
1 ✔
532
      Thread.currentThread().interrupt();
1 ✔
533
    }
1 ✔
534
    final PublishTimeWindow window = PublishTimeWindow.of(messages);
1 ✔
535
    if (window != null) {
1 ✔
536
      this.lastSeenNewestPublishMillis.set(window.newestMillis());
1 ✔
537
    }
538
    return messages;
1 ✔
539
  }
540

541
  private void take(HeldMessage held, List<PulledMessage> messages) {
542
    messages.add(held.message);
1 ✔
543
    subtractQueued(held.message.data().length);
1 ✔
544
  }
1 ✔
545

546
  void acknowledge(List<String> ackIds) {
547
    reply(ackIds, true);
1 ✔
548
  }
1 ✔
549

550
  void nack(List<String> ackIds) {
551
    reply(ackIds, false);
1 ✔
552
  }
1 ✔
553

554
  private void reply(List<String> ackIds, boolean ack) {
555
    if (ackIds == null || ackIds.isEmpty()) {
1 ✔
556
      return;
1 ✔
557
    }
558
    if (consumers == null) {
1 !
559
      return;
×
560
    }
561
    for (String id : ackIds) {
1 ✔
562
      AckReplyConsumer consumer = consumers.remove(id);
1 ✔
563
      if (consumer == null) {
1 ✔
564
        continue;
1 ✔
565
      }
566
      try {
567
        if (ack) {
1 ✔
568
          consumer.ack();
1 ✔
569
        } else {
570
          consumer.nack();
1 ✔
571
        }
572
      } catch (RuntimeException e) {
×
573
        LOG.warn("ACK: Failed to {} {}: {}", ack ? "ack" : "nack", id, e.getMessage());
×
574
      }
1 ✔
575
    }
1 ✔
576
  }
1 ✔
577

578
  void releaseMessages(List<PulledMessage> messages) {
579
    if (this.outstandingBytes != null) {
1 !
580
      long bytes = PulledMessage.payloadBytes(messages);
1 ✔
581
      if (bytes > 0) {
1 ✔
582
        this.outstandingBytes.addAndGet(-bytes);
1 ✔
583
      }
584
    }
585
  }
1 ✔
586

587
  void resetOutstandingBytes() {
588
    if (this.outstandingBytes != null) {
1 !
589
      this.outstandingBytes.set(0);
1 ✔
590
    }
591
  }
1 ✔
592

593
  long outstandingBytes() {
594
    return this.outstandingBytes == null ? 0L : this.outstandingBytes.get();
1 !
595
  }
596

597
  long queuedBytes() {
598
    return this.queuedBytes == null ? 0L : this.queuedBytes.get();
1 !
599
  }
600

601
  int queuedCount() {
602
    return queue == null ? 0 : queue.size();
1 !
603
  }
604

605
  long memoryBudget() {
606
    return memoryBudget;
1 ✔
607
  }
608

609
  boolean capMoved() {
610
    return memoryBudget > 0L && memoryBudget != flowSeedBytes;
1 !
611
  }
612

613
  long memoryBaseline() {
614
    return baselineBytes;
1 ✔
615
  }
616

617
  long flowOutstandingBytes() {
618
    long bytes = flowSeedBytes > 0L ? flowSeedBytes : queueCap();
1 !
619
    return outstandingLimit(bytes);
1 ✔
620
  }
621

622
  long memoryLimit() {
623
    return memoryLimitBytes;
1 ✔
624
  }
625

626
  void noteGather(long heapFraction, long memAvailable, long gatherBytes) {
627
    long ceiling = memoryLimitBytes > 0L ? memoryLimitBytes : memoryBudget;
1 !
628
    long move = lastMove;
1 ✔
629
    if (move <= 0L) {
1 ✔
630
      long seed = baselineBytes > 0L ? baselineBytes : memoryBudget;
1 !
631
      move = DriverHeap.openingMove(seed);
1 ✔
632
    }
633
    long opening = DriverHeap.openingMove(baselineBytes > 0L ? baselineBytes : memoryBudget);
1 !
634
    DriverHeap.BudgetStep step =
1 ✔
635
        DriverHeap.adjust(
1 ✔
636
            memoryBudget,
637
            ceiling,
638
            calmStreak,
639
            hotStreak,
640
            heapFraction,
641
            memAvailable,
642
            gatherBytes,
643
            move,
644
            lastShrank,
645
            approachStreak,
646
            opening);
647
    if (step.budget != memoryBudget) {
1 ✔
648
      LOG.debug(DriverHeap.budgetChange(memoryBudget, step.budget));
1 ✔
649
      memoryBudget = step.budget;
1 ✔
650
      lastMove = step.lastMove;
1 ✔
651
      lastShrank = step.lastShrank;
1 ✔
652
    }
653
    calmStreak = step.calmStreak;
1 ✔
654
    hotStreak = step.hotStreak;
1 ✔
655
    approachStreak = step.approachStreak;
1 ✔
656
  }
1 ✔
657

658
  /**
659
   * Takes {@code size} bytes only when they fit under the cap. An empty queue takes one message
660
   * that is larger than the cap, because waiting cannot make that message fit. A failed
661
   * compare-and-set retries. Bytes already above the cap are left for a gather.
662
   */
663
  boolean tryReserve(int size) {
664
    if (queuedBytes == null || size < 0) {
1 !
NEW
665
      return false;
×
666
    }
667
    long cap = queueCap();
1 ✔
668
    while (true) {
669
      long held = queuedBytes.get();
1 ✔
670
      if (!fits(held, size, cap)) {
1 ✔
671
        return false;
1 ✔
672
      }
673
      if (queuedBytes.compareAndSet(held, held + size)) {
1 ✔
674
        return true;
1 ✔
675
      }
676
    }
1 ✔
677
  }
678

679
  static boolean fits(long held, int size, long cap) {
680
    if (held < 0L || size < 0) {
1 !
NEW
681
      return false;
×
682
    }
683
    if (held == 0L) {
1 ✔
684
      return true;
1 ✔
685
    }
686
    if (held >= cap) {
1 ✔
687
      return false;
1 ✔
688
    }
689
    long room = cap - held;
1 ✔
690
    return size <= room;
1 ✔
691
  }
692

693
  private boolean awaitReserve(int size) {
694
    while (stopped != null && !stopped.get()) {
1 !
695
      if (tryReserve(size)) {
1 !
696
        return true;
1 ✔
697
      }
698
      try {
NEW
699
        TimeUnit.MILLISECONDS.sleep(10);
×
NEW
700
      } catch (InterruptedException e) {
×
NEW
701
        Thread.currentThread().interrupt();
×
NEW
702
        return false;
×
NEW
703
      }
×
704
    }
NEW
705
    return false;
×
706
  }
707

708
  private long queueCap() {
709
    if (memoryBudget > 0L) {
1 !
710
      return memoryBudget;
1 ✔
711
    }
NEW
712
    return config.batchSize() > 0 ? config.batchSize() : PubSubConfig.DEFAULT_BATCH_SIZE;
×
713
  }
714

715
  private String memoryReadings() {
NEW
716
    Runtime runtime = Runtime.getRuntime();
×
NEW
717
    long heapMax = runtime.maxMemory();
×
NEW
718
    long heapUsed = DriverHeap.used(runtime.totalMemory(), runtime.freeMemory());
×
NEW
719
    return DriverHeap.readings(
×
720
        heapUsed,
NEW
721
        DriverHeap.percent(heapUsed, heapMax),
×
NEW
722
        DriverHeap.residentBytes(),
×
NEW
723
        DriverHeap.availableBytes());
×
724
  }
725

726
  void noteCycleBudget(Duration cycle) {
727
    cycleBudgetMillis = cycle == null ? 0L : cycle.toMillis();
1 !
728
  }
1 ✔
729

730
  boolean aboveHeapCeiling() {
731
    boolean heap = heapAboveCeiling();
1 ✔
732
    boolean host = DriverHeap.belowHostReserve(cachedAvailable(System.currentTimeMillis()));
1 ✔
733
    if (!heap && !host && intakePaused.compareAndSet(true, false)) {
1 !
NEW
734
      LOG.debug(
×
735
          "HEAP: resumed taking messages reason={} {}",
NEW
736
          pauseReason == null ? "unknown" : pauseReason,
×
NEW
737
          memoryReadings());
×
738
    }
739
    return heap || host;
1 !
740
  }
741

742
  private boolean heapAboveCeiling() {
743
    Runtime runtime = Runtime.getRuntime();
1 ✔
744
    return DriverHeap.aboveCeiling(
1 ✔
745
        DriverHeap.used(runtime.totalMemory(), runtime.freeMemory()), runtime.maxMemory());
1 ✔
746
  }
747

748
  private long cachedAvailable(long nowMillis) {
749
    long at = cachedAvailableAtMillis.get();
1 ✔
750
    long cached = cachedAvailableBytes.get();
1 ✔
751
    if (at != Long.MIN_VALUE
1 !
752
        && cached != Long.MIN_VALUE
753
        && nowMillis >= at
754
        && nowMillis - at < DriverHeap.HOST_CACHE_MILLIS) {
755
      return cached;
1 ✔
756
    }
757
    long read = DriverHeap.availableBytes();
1 ✔
758
    cachedAvailableBytes.set(read);
1 ✔
759
    cachedAvailableAtMillis.set(nowMillis);
1 ✔
760
    return read;
1 ✔
761
  }
762

763
  private void collectHostReserveOnce() {
NEW
764
    long now = System.currentTimeMillis();
×
NEW
765
    if (!DriverHeap.belowHostReserve(cachedAvailable(now))) {
×
NEW
766
      resetHostPause();
×
NEW
767
      return;
×
768
    }
NEW
769
    if (hostPauseSinceMillis == Long.MIN_VALUE) {
×
NEW
770
      hostPauseSinceMillis = now;
×
771
    }
NEW
772
    if (DriverHeap.collectDue(hostPauseSinceMillis, now, cycleBudgetMillis, hostCollected)) {
×
NEW
773
      hostCollected = true;
×
NEW
774
      LOG.debug(
×
775
          "HEAP: collected once after cycleBudget while host MemAvailable is below {} bytes; {}",
NEW
776
          DriverHeap.HOST_RESERVE_BYTES,
×
NEW
777
          memoryReadings());
×
NEW
778
      System.gc();
×
779
    }
NEW
780
  }
×
781

782
  private void resetHostPause() {
783
    hostPauseSinceMillis = Long.MIN_VALUE;
1 ✔
784
    hostCollected = false;
1 ✔
785
  }
1 ✔
786

787
  void noteHeapCeiling() {
NEW
788
    long now = System.currentTimeMillis();
×
NEW
789
    boolean heap = heapAboveCeiling();
×
NEW
790
    boolean host = DriverHeap.belowHostReserve(cachedAvailable(now));
×
NEW
791
    if ((heap || host) && intakePaused.compareAndSet(false, true)) {
×
NEW
792
      pauseReason = DriverHeap.pauseReason(heap, host);
×
NEW
793
      LOG.debug("HEAP: paused intake reason={} {}", pauseReason, memoryReadings());
×
794
    }
NEW
795
    if (heap && claimHourly(lastHeapCeilingWarnMillis, now)) {
×
NEW
796
      Runtime runtime = Runtime.getRuntime();
×
NEW
797
      long heapMax = runtime.maxMemory();
×
NEW
798
      long heapUsed = DriverHeap.used(runtime.totalMemory(), runtime.freeMemory());
×
NEW
799
      LOG.warn(
×
NEW
800
          DriverHeap.heapStop(heapUsed, DriverHeap.percent(heapUsed, heapMax), memoryReadings()));
×
801
    }
NEW
802
    if (host && claimHourly(lastHostReserveWarnMillis, now)) {
×
NEW
803
      LOG.warn(
×
804
          "HEAP: stopped taking more messages because host MemAvailable is {} bytes, below {}"
805
              + " bytes; {}",
NEW
806
          DriverHeap.availableBytes(),
×
NEW
807
          DriverHeap.HOST_RESERVE_BYTES,
×
NEW
808
          memoryReadings());
×
809
    }
NEW
810
  }
×
811

812
  private static boolean claimHourly(AtomicLong clock, long now) {
NEW
813
    long previous = clock.get();
×
NEW
814
    if (!DriverHeap.warnDue(previous, now)) {
×
NEW
815
      return false;
×
816
    }
NEW
817
    return clock.compareAndSet(previous, now);
×
818
  }
819

820
  /**
821
   * Puts messages that did not fit this batch back on the local queue. Does not ack or nack them.
822
   * The subscriber still blocks at one {@code batchSize}; this does not raise that cap.
823
   */
824
  void requeue(List<PulledMessage> messages) {
825
    if (messages == null || messages.isEmpty()) {
1 !
NEW
826
      return;
×
827
    }
828
    List<String> failed = new ArrayList<>();
1 ✔
829
    for (PulledMessage message : messages) {
1 ✔
830
      if (!putBack(message)) {
1 !
NEW
831
        failed.add(message.ackId());
×
832
      }
833
    }
1 ✔
834
    if (!failed.isEmpty()) {
1 !
NEW
835
      nack(failed);
×
836
    }
837
  }
1 ✔
838

839
  private boolean putBack(PulledMessage message) {
840
    if (message == null || queue == null || consumers == null) {
1 !
NEW
841
      return false;
×
842
    }
843
    AckReplyConsumer consumer = consumers.get(message.ackId());
1 ✔
844
    if (consumer == null) {
1 !
NEW
845
      return false;
×
846
    }
847
    int bytes = message.data().length;
1 ✔
848
    if (queuedBytes != null) {
1 !
849
      queuedBytes.addAndGet(bytes);
1 ✔
850
    }
851
    try {
852
      queue.put(new HeldMessage(message, consumer));
1 ✔
853
      return true;
1 ✔
NEW
854
    } catch (InterruptedException e) {
×
NEW
855
      Thread.currentThread().interrupt();
×
NEW
856
      subtractQueued(bytes);
×
NEW
857
      return false;
×
858
    }
859
  }
860

861
  long retryAttempts() {
862
    return retryPolicy.retryAttempts();
1 ✔
863
  }
864

865
  long lastSeenNewestPublishMillis() {
866
    return lastSeenNewestPublishMillis.get();
1 ✔
867
  }
868

869
  public static String asString(final Duration duration) {
870
    return duration.toString().substring(2).replaceAll("(\\d[HMS])(?!$)", "$1").toLowerCase();
1 ✔
871
  }
872
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc