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

juarezr / spark-streaming-google-pubsub / 63

20 Oct 2026 06:34PM UTC coverage: 78.668% (+7.6%) from 71.066%
63

Pull #23

github

web-flow
Merge dba1aba38 into 4a4d04c78
Pull Request #23: Fix: tune auto receive time to achieve the maximum throughput possible with the available heap and RSS memory in the driver

1133 of 1596 branches covered (70.99%)

Branch coverage included in aggregate %.

739 of 905 new or added lines in 7 files covered. (81.66%)

4 existing lines in 1 file now uncovered.

2197 of 2637 relevant lines covered (83.31%)

0.83 hits per line

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

84.12
/src/main/java/io/github/juarezr/spark/pubsub/structured/PubSubMicroBatchStream.java
1
package io.github.juarezr.spark.pubsub.structured;
2

3
import io.github.juarezr.spark.pubsub.config.AckMode;
4
import io.github.juarezr.spark.pubsub.config.GatherMode;
5
import io.github.juarezr.spark.pubsub.config.PubSubConfig;
6
import java.io.IOException;
7
import java.time.Duration;
8
import java.time.Instant;
9
import java.util.ArrayList;
10
import java.util.List;
11
import java.util.Map;
12
import java.util.Optional;
13
import java.util.concurrent.ConcurrentHashMap;
14
import java.util.concurrent.ConcurrentMap;
15
import java.util.concurrent.atomic.AtomicInteger;
16
import java.util.concurrent.atomic.AtomicLong;
17
import org.apache.spark.sql.connector.read.InputPartition;
18
import org.apache.spark.sql.connector.read.PartitionReaderFactory;
19
import org.apache.spark.sql.connector.read.streaming.MicroBatchStream;
20
import org.apache.spark.sql.connector.read.streaming.Offset;
21
import org.apache.spark.sql.connector.read.streaming.ReadLimit;
22
import org.apache.spark.sql.connector.read.streaming.ReportsSourceMetrics;
23
import org.apache.spark.sql.connector.read.streaming.SupportsTriggerAvailableNow;
24
import org.apache.spark.sql.types.StructType;
25
import org.slf4j.Logger;
26
import org.slf4j.LoggerFactory;
27

28
/**
29
 * Spark micro-batch source that polls a warm streaming-pull {@link PubSubClient} queue (not unary
30
 * Pub/Sub Pull RPCs). Progress is tracked with synthetic offsets; the subscription cursor remains
31
 * the durable source of truth across process restarts (no rewind unless configured).
32
 */
33
final class PubSubMicroBatchStream
34
    implements MicroBatchStream, ReportsSourceMetrics, SupportsTriggerAvailableNow {
35
  private static final Logger LOG = LoggerFactory.getLogger(PubSubMicroBatchStream.class);
1 ✔
36

37
  /**
38
   * Pub/Sub has no log-end offset. AvailableNow treats this many consecutive empty polls as idle
39
   * and returns {@code null} so Spark stops.
40
   */
41
  static final int AVAILABLE_NOW_IDLE_POLLS = 3;
42

43
  static final Duration RECEIVE_IDLE = Duration.ofSeconds(1);
1 ✔
44

45
  private final PubSubConfig config;
46
  private final StructType readSchema;
47
  private final PubSubClient client;
48
  private final AckCoordinator ackCoordinator;
49
  private final AtomicLong nextBatchId = new AtomicLong(0);
1 ✔
50
  private final AtomicInteger lastGatherMessageCount = new AtomicInteger(0);
1 ✔
51
  private final AtomicLong lastGatherPayloadBytes = new AtomicLong(0);
1 ✔
52
  private volatile Long lastGatherNewestMessageAgeMs;
53
  private final AtomicLong lastReportedRetryAttempts = new AtomicLong(0);
1 ✔
54
  private final int numPartitions;
55
  private volatile PubSubOffset lastProduced;
56
  private volatile PubSubOffset currentOffset = PubSubOffset.empty(-1L);
1 ✔
57
  private volatile boolean firstBatchLogged;
58
  private volatile Long lastGatheredBatchId;
59
  private volatile PublishTimeWindow lastGatheredWindow;
60
  private final ConcurrentMap<Long, List<PulledMessage>> messagesByBatch =
1 ✔
61
      new ConcurrentHashMap<>();
62
  private final AutoReceiveTime autoReceiveTime;
63
  private final IntakePace intakePace = new IntakePace();
1 ✔
64
  private volatile boolean cancelled;
65
  private volatile boolean availableNow;
66
  private volatile boolean limitReached;
67
  private volatile boolean heapStartupLogged;
68
  private long releasedBatchId = Long.MIN_VALUE;
1 ✔
69
  private long previousHeapUsed = Long.MIN_VALUE;
1 ✔
70
  private long previousRss = Long.MIN_VALUE;
1 ✔
71
  private long previousMemAvailable = Long.MIN_VALUE;
1 ✔
72

73
  PubSubMicroBatchStream(PubSubConfig config, int numPartitions) {
74
    this(config, numPartitions, PubSubSchema.tableSchema(config, null));
×
75
  }
×
76

77
  PubSubMicroBatchStream(PubSubConfig config, int numPartitions, StructType readSchema) {
78
    this(config, numPartitions, new PubSubClient(config), true, readSchema);
1 ✔
79
  }
1 ✔
80

81
  PubSubMicroBatchStream(
82
      PubSubConfig config, int numPartitions, PubSubClient client, boolean startClient) {
83
    this(config, numPartitions, client, startClient, PubSubSchema.tableSchema(config, null));
1 ✔
84
  }
1 ✔
85

86
  PubSubMicroBatchStream(
87
      PubSubConfig config,
88
      int numPartitions,
89
      PubSubClient client,
90
      boolean startClient,
91
      StructType readSchema) {
1 ✔
92
    this.config = config;
1 ✔
93
    this.readSchema = readSchema == null ? PubSubSchema.tableSchema(config, null) : readSchema;
1 !
94
    this.client = client;
1 ✔
95
    this.ackCoordinator = new AckCoordinator(config.ackMode());
1 ✔
96
    this.autoReceiveTime = AutoReceiveTime.create(config);
1 ✔
97
    this.numPartitions = Math.max(1, numPartitions);
1 ✔
98
    if (!startClient) {
1 ✔
99
      return;
1 ✔
100
    }
101
    try {
102
      this.client.start();
1 ✔
103
    } catch (IOException e) {
×
104
      throw new IllegalStateException("Unable to start Pub/Sub client", e);
×
105
    }
1 ✔
106
  }
1 ✔
107

108
  @Override
109
  public void prepareForTriggerAvailableNow() {
110
    this.availableNow = true;
1 ✔
111
    if (config.limitTime().isPresent()) {
1 ✔
112
      LOG.info(
1 ✔
113
          "BATCH: AvailableNow recovery drain; limitTime={} exclusive; stop at bound or {}"
114
              + " consecutive empty polls",
115
          config.limitTime().get(),
1 ✔
116
          AVAILABLE_NOW_IDLE_POLLS);
1 ✔
117
    } else {
118
      LOG.info(
1 ✔
119
          "BATCH: AvailableNow drain enabled; stop after {} consecutive empty polls"
120
              + " (Pub/Sub has no log-end offset; use seek=snapshot for recovery)",
121
          AVAILABLE_NOW_IDLE_POLLS);
1 ✔
122
    }
123
  }
1 ✔
124

125
  @Override
126
  public ReadLimit getDefaultReadLimit() {
127
    if (config.batchCount() > 0) {
1 ✔
128
      return ReadLimit.maxRows(config.batchCount());
1 ✔
129
    }
130
    return ReadLimit.allAvailable();
1 ✔
131
  }
132

133
  @Override
134
  public Offset reportLatestOffset() {
135
    return lastProduced != null ? lastProduced : currentOffset;
1 ✔
136
  }
137

138
  @Override
139
  public Offset latestOffset() {
140
    return latestOffset(null, AdmissionLimits.from(config, null), false);
1 ✔
141
  }
142

143
  @Override
144
  public Offset latestOffset(Offset startOffset, ReadLimit limit) {
145
    return latestOffset(startOffset, AdmissionLimits.from(config, limit), true);
1 ✔
146
  }
147

148
  /**
149
   * Spark 3.5 commits batch N-1 only when constructing batch N. Returning the same {@code
150
   * lastProduced} after Spark has already accepted it as {@code startOffset} makes {@code
151
   * constructNextBatch} see no new data, so {@link #commit} never runs and the query idles with the
152
   * first batch still uncommitted.
153
   */
154
  private Offset latestOffset(Offset startOffset, AdmissionLimits limits, boolean nullIfEmpty) {
155

156
    if (config.limitTime().isPresent() && !this.availableNow) {
1 ✔
157
      throw new IllegalStateException(
1 ✔
158
          "limitTime requires Trigger.AvailableNow(); ProcessingTime cannot stop at the bound");
159
    }
160
    if (lastProduced != null && !startConsumed(startOffset, lastProduced)) {
1 ✔
161
      return lastProduced;
1 ✔
162
    }
163
    if (this.limitReached) {
1 ✔
164
      resetMetrics();
1 ✔
165
      return nullIfEmpty ? null : this.currentOffset;
1 !
166
    }
167
    if (!availableNow && autoReceiveTime.shouldProbe()) {
1 ✔
168
      resetMetrics();
1 ✔
169
      return nullIfEmpty ? null : currentOffset;
1 ✔
170
    }
171
    List<PulledMessage> pulled = gatherMessages(limits);
1 ✔
172
    if (pulled.isEmpty()) {
1 ✔
173
      resetMetrics();
1 ✔
174
      return nullIfEmpty ? null : currentOffset;
1 ✔
175
    }
176
    long batchId = nextBatchId.getAndIncrement();
1 ✔
177
    PubSubOffset offset = produceNextOffset(pulled, batchId);
1 ✔
178
    this.lastProduced = offset;
1 ✔
179
    updateMetrics(pulled, batchId);
1 ✔
180
    retainGatheredWindow(batchId, pulled);
1 ✔
181
    return offset;
1 ✔
182
  }
183

184
  private PubSubOffset produceNextOffset(List<PulledMessage> pulled, long batchId) {
185
    PubSubOffset offset = new PubSubOffset(batchId);
1 ✔
186
    String batchKey = Long.toString(batchId);
1 ✔
187
    messagesByBatch.put(batchId, pulled);
1 ✔
188
    try {
189
      if (config.ackMode() == AckMode.AFTER_COMMIT) {
1 ✔
190
        ackCoordinator.registerBatch(batchKey, pulled);
1 ✔
191
      } else if (config.gatherMode() == GatherMode.IMMEDIATE) {
1 ✔
192
        ackCoordinator.registerBatch(batchKey, pulled);
1 ✔
193
        ackCoordinator.onPulled(client, batchKey);
1 ✔
194
      }
195
    } catch (RuntimeException e) {
×
196
      ackCoordinator.abort(client, batchKey);
×
197
      messagesByBatch.remove(batchId);
×
198
      throw e;
×
199
    }
1 ✔
200
    return offset;
1 ✔
201
  }
202

203
  private void resetMetrics() {
204
    updateMetrics(null, -1L);
1 ✔
205
  }
1 ✔
206

207
  private void updateMetrics(List<PulledMessage> pulled, long batchId) {
208
    if (pulled == null) {
1 ✔
209
      this.lastGatherMessageCount.set(0);
1 ✔
210
      this.lastGatherPayloadBytes.set(0);
1 ✔
211
      this.lastGatherNewestMessageAgeMs = null;
1 ✔
212
    } else {
213
      final int pulledSize = pulled.size();
1 ✔
214
      final long pulledBytes = PulledMessage.payloadBytes(pulled);
1 ✔
215
      this.lastGatherMessageCount.set(pulledSize);
1 ✔
216
      this.lastGatherPayloadBytes.set(pulledBytes);
1 ✔
217
      long now = System.currentTimeMillis();
1 ✔
218
      this.lastGatherNewestMessageAgeMs = PubSubSourceMetrics.newestMessageAgeMs(pulled, now);
1 ✔
219
      LOG.debug(
1 ✔
220
          "BATCH: micro-batch batchId={} messages={} bytes={}", batchId, pulledSize, pulledBytes);
1 ✔
221
    }
222
  }
1 ✔
223

224
  /**
225
   * The previous batch is already written when the next gather starts. Spark commits it only after
226
   * {@code latestOffset} returns. Ack it before the subscriber is stopped, and remember the id so
227
   * {@link #commit} does not release those bytes again.
228
   *
229
   * @return false when the ack fails and the subscriber must stay
230
   */
231
  private boolean releaseWrittenBatch() {
232
    if (config.ackMode() != AckMode.AFTER_COMMIT || lastProduced == null) {
1 !
233
      return true;
1 ✔
234
    }
235
    long batchId = lastProduced.batchId();
1 ✔
236
    if (batchId == releasedBatchId) {
1 !
NEW
237
      return true;
×
238
    }
239
    List<PulledMessage> pending = messagesByBatch.get(batchId);
1 ✔
240
    if (pending == null || pending.isEmpty()) {
1 !
NEW
241
      return true;
×
242
    }
243
    List<String> ackIds = PulledMessage.ackIds(pending);
1 ✔
244
    try {
245
      client.acknowledge(ackIds);
1 ✔
NEW
246
    } catch (RuntimeException e) {
×
NEW
247
      LOG.warn("BATCH: Failed to ack written batch {} before subscriber rebuild", batchId, e);
×
NEW
248
      return false;
×
249
    }
1 ✔
250
    client.releaseMessages(pending);
1 ✔
251
    releasedBatchId = batchId;
1 ✔
252
    return true;
1 ✔
253
  }
254

255
  private List<PulledMessage> gatherMessages(AdmissionLimits gatherLimits) {
256
    if (!client.capMoved() || releaseWrittenBatch()) {
1 !
257
      client.retargetFlow();
1 ✔
258
    }
259
    client.noteCycleBudget(autoReceiveTime.cycleBudget());
1 ✔
260
    AdmissionLimits limits =
261
        availableNow
1 ✔
262
            ? gatherLimits.withDrainUntilIdle()
1 ✔
263
            : gatherLimits.withWaitTime(
1 ✔
264
                this.autoReceiveTime.receiveWindow(gatherLimits.waitTime()));
1 ✔
265
    limits = limits.withMemoryBudget(client.memoryBudget());
1 ✔
266

267
    long gatherStarted = System.nanoTime();
1 ✔
268
    List<PulledMessage> pulled =
269
        limits.drainUntilIdle()
1 ✔
270
            ? gatherUntilIdleOrMax(limits)
1 ✔
271
            : limits.singlePoll()
1 ✔
272
                ? gatherMessagesFromSinglePoll(limits)
1 ✔
273
                : gatherMessagesUntilDeadline(limits);
1 ✔
274
    long gatherFinished = System.nanoTime();
1 ✔
275

276
    this.autoReceiveTime.onGatherFinished(gatherFinished - gatherStarted, pulled.size());
1 ✔
277
    client.noteCycleBudget(autoReceiveTime.cycleBudget());
1 ✔
278
    long payloadBytes = PulledMessage.payloadBytes(pulled);
1 ✔
279
    long heapFraction = logDriverHeap("gather", payloadBytes, pulled.size());
1 ✔
280
    client.noteGather(heapFraction, previousMemAvailable, payloadBytes);
1 ✔
281
    noteIntakeLag(pulled);
1 ✔
282
    return pulled;
1 ✔
283
  }
284

285
  private List<PulledMessage> gatherMessagesFromSinglePoll(AdmissionLimits limits) {
286

287
    List<PulledMessage> messages = new ArrayList<>();
1 ✔
288
    if (gatherAborted()) {
1 !
289
      return messages;
×
290
    }
291
    if (limits.remainingRows(0) <= 0) {
1 !
292
      return messages;
×
293
    }
294
    if (stopForHeapCeiling()) {
1 !
NEW
295
      return messages;
×
296
    }
297
    List<PulledMessage> pulled = poll(limits, messages.size(), RECEIVE_IDLE);
1 ✔
298
    return retainPolled(messages, pulled, limits, 0L).messages;
1 ✔
299
  }
300

301
  private List<PulledMessage> gatherMessagesUntilDeadline(AdmissionLimits limits) {
302

303
    List<PulledMessage> messages = new ArrayList<>();
1 ✔
304
    Duration wait = limits.waitTime();
1 ✔
305
    final long waitNanos = wait == null ? RECEIVE_IDLE.toNanos() : wait.toNanos();
1 !
306
    final long deadlineNanos = System.nanoTime() + waitNanos;
1 ✔
307
    long payloadBytes = 0L;
1 ✔
308
    boolean debounced = false;
1 ✔
309
    try {
310
      while (System.nanoTime() < deadlineNanos) {
1 ✔
311
        if (gatherAborted()) {
1 !
312
          return abortGatherNackAndRelease(messages, null);
×
313
        }
314
        if (limits.reachedMax(messages.size(), payloadBytes)
1 !
315
            || limits.remainingRows(messages.size()) <= 0) {
1 !
316
          break;
×
317
        }
318
        if (client.aboveHeapCeiling()) {
1 ✔
319
          client.noteHeapCeiling();
1 ✔
320
          if (!messages.isEmpty()) {
1 ✔
321
            break;
1 ✔
322
          }
323
          // One millisecond reads the local queue and does not wait on the subscriber.
324
          List<PulledMessage> waiting = poll(limits, messages.size(), Duration.ofMillis(1));
1 ✔
325
          if (waiting != null && !waiting.isEmpty()) {
1 !
326
            RetainResult retained = retainPolled(messages, waiting, limits, payloadBytes);
1 ✔
327
            messages = retained.messages;
1 ✔
328
            break;
1 ✔
329
          }
330
          try {
331
            Thread.sleep(10);
1 ✔
NEW
332
          } catch (InterruptedException interrupted) {
×
NEW
333
            Thread.currentThread().interrupt();
×
NEW
334
            return abortGatherNackAndRelease(messages, null);
×
335
          }
1 ✔
336
          continue;
337
        }
338
        Duration slice = remainingWait(deadlineNanos);
1 ✔
339
        List<PulledMessage> pulled = poll(limits, messages.size(), slice);
1 ✔
340
        if (gatherAborted()) {
1 ✔
341
          return abortGatherNackAndRelease(messages, pulled);
1 ✔
342
        }
343
        if (!pulled.isEmpty()) {
1 ✔
344
          RetainResult retained = retainPolled(messages, pulled, limits, payloadBytes);
1 ✔
345
          payloadBytes = retained.payloadBytes;
1 ✔
346
          messages = retained.messages;
1 ✔
347
        } else if (config.gatherMode() == GatherMode.BATCH && messages.isEmpty() && !debounced) {
1 !
348
          debounced = true;
1 ✔
349
          List<PulledMessage> pulled2 =
1 ✔
350
              poll(limits, messages.size(), min(RECEIVE_IDLE, remainingWait(deadlineNanos)));
1 ✔
351
          if (gatherAborted()) {
1 !
352
            return abortGatherNackAndRelease(messages, pulled2);
×
353
          }
354
          if (pulled2.isEmpty()) {
1 !
355
            break;
1 ✔
356
          }
357
          RetainResult retained = retainPolled(messages, pulled2, limits, payloadBytes);
×
358
          payloadBytes = retained.payloadBytes;
×
359
          messages = retained.messages;
×
360
        } else if (config.gatherMode() == GatherMode.BATCH || limits.minRowsMet(messages.size())) {
1 !
361
          break;
×
362
        }
363
        if (limits.reachedMax(messages.size(), payloadBytes)) {
1 ✔
364
          break;
1 ✔
365
        }
366
        if (config.gatherMode() == GatherMode.IMMEDIATE && limits.minRowsMet(messages.size())) {
1 ✔
367
          break;
1 ✔
368
        }
369
      }
1 ✔
370
    } catch (RuntimeException e) {
×
371
      abortGatherNackAndRelease(messages, null);
×
372
      throw e;
×
373
    }
1 ✔
374
    return messages;
1 ✔
375
  }
376

377
  private List<PulledMessage> poll(AdmissionLimits limits, int already, Duration timeout) {
378
    client.limitNextPoll(limits.remainingRows(already));
1 ✔
379
    return client.poll(timeout);
1 ✔
380
  }
381

382
  private Duration remainingWait(long deadlineNanos) {
383
    return Duration.ofNanos(Math.max(1L, deadlineNanos - System.nanoTime()));
1 ✔
384
  }
385

386
  private static final class RetainResult {
387
    final List<PulledMessage> messages;
388
    final long payloadBytes;
389

390
    RetainResult(List<PulledMessage> messages, long payloadBytes) {
1 ✔
391
      this.messages = messages;
1 ✔
392
      this.payloadBytes = payloadBytes;
1 ✔
393
    }
1 ✔
394
  }
395

396
  private RetainResult retainPolled(
397
      List<PulledMessage> messages,
398
      List<PulledMessage> pulled,
399
      AdmissionLimits limits,
400
      long payloadBytes) {
401

402
    List<PulledMessage> overflow = new ArrayList<>();
1 ✔
403
    for (PulledMessage message : pulled) {
1 ✔
404
      if (limits.reachedMax(messages.size(), payloadBytes)
1 ✔
405
          || limits.remainingRows(messages.size()) <= 0) {
1 !
406
        overflow.add(message);
1 ✔
407
        continue;
1 ✔
408
      }
409
      messages.add(message);
1 ✔
410
      payloadBytes += message.data().length;
1 ✔
411
    }
1 ✔
412
    if (!overflow.isEmpty()) {
1 ✔
413
      try {
414
        client.requeue(overflow);
1 ✔
NEW
415
      } catch (RuntimeException e) {
×
NEW
416
        LOG.warn("BATCH: Failed to requeue overflow messages: {}", e.getMessage());
×
417
      }
1 ✔
418
    }
419
    List<PulledMessage> kept =
420
        overflow.isEmpty() ? pulled : pulled.subList(0, pulled.size() - overflow.size());
1 ✔
421
    if (config.ackMode() == AckMode.EARLY && !kept.isEmpty()) {
1 !
422
      client.acknowledge(PulledMessage.ackIds(kept));
1 ✔
423
      client.releaseMessages(kept);
1 ✔
424
    }
425
    return new RetainResult(messages, payloadBytes);
1 ✔
426
  }
427

428
  /**
429
   * AvailableNow receive: keep polling until {@code batchCount}/{@code batchSize} or {@link
430
   * #AVAILABLE_NOW_IDLE_POLLS} consecutive empty polls.
431
   */
432
  private List<PulledMessage> gatherUntilIdleOrMax(AdmissionLimits limits) {
433
    List<PulledMessage> messages = new ArrayList<>();
1 ✔
434
    long payloadBytes = 0L;
1 ✔
435
    int emptyStreak = 0;
1 ✔
436
    try {
437
      while (true) {
438
        if (gatherAborted()) {
1 !
439
          return abortGatherNackAndRelease(messages, null);
×
440
        }
441
        if (limits.reachedMax(messages.size(), payloadBytes)
1 ✔
442
            || limits.remainingRows(messages.size()) <= 0) {
1 !
443
          break;
×
444
        }
445
        if (stopForHeapCeiling()) {
1 !
NEW
446
          break;
×
447
        }
448
        List<PulledMessage> pulled = poll(limits, messages.size(), RECEIVE_IDLE);
1 ✔
449
        if (gatherAborted()) {
1 !
450
          return abortGatherNackAndRelease(messages, pulled);
×
451
        }
452
        if (pulled.isEmpty()) {
1 ✔
453
          emptyStreak++;
1 ✔
454
          if (emptyStreak >= AVAILABLE_NOW_IDLE_POLLS) {
1 ✔
455
            LOG.info(
1 ✔
456
                "BATCH: AvailableNow idle after {} consecutive empty polls; received={}",
457
                emptyStreak,
1 ✔
458
                messages.size());
1 ✔
459
            break;
1 ✔
460
          }
461
          continue;
462
        }
463
        BoundSplit split = splitAtLimit(pulled);
1 ✔
464
        if (!split.overLimit.isEmpty()) {
1 ✔
465
          this.limitReached = true;
1 ✔
466
          nackOverLimit(split.overLimit);
1 ✔
467
          LOG.info(
1 ✔
468
              "BATCH: AvailableNow reached limitTime={}; inRange={} overLimit={}",
469
              config.limitTime().orElse("-"),
1 ✔
470
              split.inRange.size(),
1 ✔
471
              split.overLimit.size());
1 ✔
472

473
          if (!split.inRange.isEmpty()) {
1 ✔
474
            RetainResult retained = retainPolled(messages, split.inRange, limits, payloadBytes);
1 ✔
475
            payloadBytes = retained.payloadBytes;
1 ✔
476
            messages = retained.messages;
1 ✔
477
          }
1 ✔
478
          break;
479
        }
480
        emptyStreak = 0;
1 ✔
481
        RetainResult retained = retainPolled(messages, split.inRange, limits, payloadBytes);
1 ✔
482
        payloadBytes = retained.payloadBytes;
1 ✔
483
        messages = retained.messages;
1 ✔
484
      }
1 ✔
485
    } catch (RuntimeException e) {
×
486
      abortGatherNackAndRelease(messages, null);
×
487
      throw e;
×
488
    }
1 ✔
489
    return messages;
1 ✔
490
  }
491

492
  private static final class BoundSplit {
493
    final List<PulledMessage> inRange;
494
    final List<PulledMessage> overLimit;
495

496
    BoundSplit(List<PulledMessage> inRange, List<PulledMessage> overLimit) {
1 ✔
497
      this.inRange = inRange;
1 ✔
498
      this.overLimit = overLimit;
1 ✔
499
    }
1 ✔
500
  }
501

502
  private BoundSplit splitAtLimit(List<PulledMessage> pulled) {
503
    Optional<Instant> limit = config.limitTimeAsInstant();
1 ✔
504
    if (limit.isEmpty()) {
1 ✔
505
      return new BoundSplit(pulled, List.of());
1 ✔
506
    }
507
    long boundMillis = limit.get().toEpochMilli();
1 ✔
508
    List<PulledMessage> inRange = new ArrayList<>();
1 ✔
509
    List<PulledMessage> overLimit = new ArrayList<>();
1 ✔
510
    for (PulledMessage message : pulled) {
1 ✔
511
      if (message.publishTimeMillis() >= boundMillis) {
1 ✔
512
        overLimit.add(message);
1 ✔
513
      } else {
514
        inRange.add(message);
1 ✔
515
      }
516
    }
1 ✔
517
    return new BoundSplit(inRange, overLimit);
1 ✔
518
  }
519

520
  private void nackOverLimit(List<PulledMessage> overLimit) {
521
    if (overLimit.isEmpty()) {
1 !
522
      return;
×
523
    }
524
    try {
525
      client.nack(PulledMessage.ackIds(overLimit));
1 ✔
526
    } catch (RuntimeException e) {
×
527
      LOG.warn(
×
528
          "BATCH: Failed to nack {} over-limit messages: {}", overLimit.size(), e.getMessage());
×
529
    } finally {
530
      client.releaseMessages(overLimit);
1 ✔
531
    }
532
  }
1 ✔
533

534
  private boolean gatherAborted() {
535
    return cancelled || Thread.currentThread().isInterrupted();
1 !
536
  }
537

538
  private List<PulledMessage> abortGatherNackAndRelease(
539
      List<PulledMessage> messages, List<PulledMessage> pulled) {
540

541
    if (pulled != null) {
1 !
542
      messages.addAll(pulled);
1 ✔
543
    }
544
    if (config.ackMode() == AckMode.AFTER_COMMIT && !messages.isEmpty()) {
1 !
545
      try {
546
        client.nack(PulledMessage.ackIds(messages));
1 ✔
547
      } catch (RuntimeException nackError) {
×
548
        LOG.warn("BATCH: Failed to nack messages after gather abort: {}", nackError.getMessage());
×
549
      } finally {
550
        client.releaseMessages(messages);
1 ✔
551
      }
552
    }
553
    return List.of();
1 ✔
554
  }
555

556
  private static Duration min(Duration left, Duration right) {
557
    return left.compareTo(right) <= 0 ? left : right;
1 ✔
558
  }
559

560
  /** True when Spark already persisted {@code produced} as the start of the next range. */
561
  static boolean startConsumed(Offset startOffset, PubSubOffset produced) {
562
    if (startOffset == null || produced == null) {
1 !
563
      return false;
1 ✔
564
    }
565
    final long startBatchId =
566
        startOffset instanceof PubSubOffset
1 !
567
            ? ((PubSubOffset) startOffset).batchId()
1 ✔
568
            : PubSubOffset.fromJson(startOffset.json()).batchId();
1 ✔
569
    return startBatchId >= produced.batchId();
1 ✔
570
  }
571

572
  @Override
573
  public Map<String, String> metrics(final Optional<Offset> latestConsumedOffset) {
574
    PubSubOffset produced = lastProduced;
1 ✔
575
    final Long producedBatchId = produced == null ? null : produced.batchId();
1 ✔
576
    final long retryTotal = client.retryAttempts();
1 ✔
577
    final long reportedTotal = lastReportedRetryAttempts.getAndSet(retryTotal);
1 ✔
578
    final long retryThisBatch =
1 ✔
579
        PubSubSourceMetrics.retryAttemptsThisBatch(retryTotal, reportedTotal);
1 ✔
580
    return PubSubSourceMetrics.snapshot(
1 ✔
581
        lastGatherMessageCount.get(),
1 ✔
582
        lastGatherPayloadBytes.get(),
1 ✔
583
        lastGatherNewestMessageAgeMs,
584
        client.outstandingBytes(),
1 ✔
585
        producedBatchId,
586
        latestConsumedOffset,
587
        retryThisBatch,
588
        retryTotal);
589
  }
590

591
  @Override
592
  public InputPartition[] planInputPartitions(Offset start, Offset end) {
593
    PubSubOffset endOffset = (PubSubOffset) end;
1 ✔
594
    List<PulledMessage> messages = messagesByBatch.getOrDefault(endOffset.batchId(), List.of());
1 ✔
595
    if (messages.isEmpty()) {
1 !
596
      int gathered = lastGatherMessageCount.get();
×
597
      if (gathered > 0) {
×
598
        LOG.warn(
×
599
            "BATCH: planInputPartitions batchId={} has 0 messages but lastGatherMessageCount={}",
600
            endOffset.batchId(),
×
601
            gathered);
×
602
      }
603
      return new InputPartition[] {new PubSubInputPartition(messages)};
×
604
    }
605
    int parts = Math.min(numPartitions, messages.size());
1 ✔
606
    List<List<PulledMessage>> slices = new ArrayList<>(parts);
1 ✔
607
    for (int i = 0; i < parts; i++) {
1 ✔
608
      slices.add(new ArrayList<>());
1 ✔
609
    }
610
    for (int i = 0; i < messages.size(); i++) {
1 ✔
611
      slices.get(i % parts).add(messages.get(i));
1 ✔
612
    }
613
    InputPartition[] partitions = new InputPartition[parts];
1 ✔
614
    for (int i = 0; i < parts; i++) {
1 ✔
615
      partitions[i] = new PubSubInputPartition(slices.get(i));
1 ✔
616
    }
617
    return partitions;
1 ✔
618
  }
619

620
  @Override
621
  public PartitionReaderFactory createReaderFactory() {
622
    return new PubSubPartitionReaderFactory(readSchema);
1 ✔
623
  }
624

625
  @Override
626
  public Offset initialOffset() {
627
    return currentOffset;
1 ✔
628
  }
629

630
  @Override
631
  public Offset deserializeOffset(String json) {
632
    PubSubOffset offset = PubSubOffset.fromJson(json);
1 ✔
633
    nextBatchId.updateAndGet(v -> Math.max(v, offset.batchId() + 1));
1 ✔
634
    currentOffset = offset;
1 ✔
635
    return offset;
1 ✔
636
  }
637

638
  @Override
639
  public void commit(Offset end) {
640
    PubSubOffset endOffset = (PubSubOffset) end;
1 ✔
641
    String batchKey = Long.toString(endOffset.batchId());
1 ✔
642
    List<PulledMessage> committed = messagesByBatch.getOrDefault(endOffset.batchId(), List.of());
1 ✔
643
    long committedBytes = PulledMessage.payloadBytes(committed);
1 ✔
644
    boolean alreadyReleased = endOffset.batchId() == releasedBatchId;
1 ✔
645
    if (config.ackMode() == AckMode.AFTER_COMMIT) {
1 ✔
646
      List<PulledMessage> messages = committed;
1 ✔
647
      List<String> ackIds = PulledMessage.ackIds(messages);
1 ✔
648
      try {
649
        if (!ackIds.isEmpty()) {
1 !
650
          try {
651
            client.acknowledge(ackIds);
1 ✔
652
            LOG.debug(
1 ✔
653
                "BATCH: Committed (acked) {} messages for batch {}",
654
                ackIds.size(),
1 ✔
655
                endOffset.batchId());
1 ✔
656
          } catch (RuntimeException e) {
×
657
            try {
658
              client.nack(ackIds);
×
659
            } catch (RuntimeException nackError) {
×
660
              LOG.warn(
×
661
                  "BATCH: Failed to nack {} messages after ack failure for batch {}",
662
                  ackIds.size(),
×
663
                  endOffset.batchId(),
×
664
                  nackError.getMessage());
×
665
            }
×
666
            throw e;
×
667
          }
1 ✔
668
        }
669
      } finally {
670
        if (!ackIds.isEmpty() && !alreadyReleased) {
1 !
671
          client.releaseMessages(messages);
1 ✔
672
        }
673
        ackCoordinator.discard(batchKey);
1 ✔
674
        finishBatch(endOffset.batchId());
1 ✔
675
      }
676
      currentOffset = endOffset;
1 ✔
677
    } else {
1 ✔
678
      ackCoordinator.commit(client, batchKey);
1 ✔
679
      finishBatch(endOffset.batchId());
1 ✔
680
      currentOffset = endOffset;
1 ✔
681
    }
682
    LOG.debug("BATCH: Committed offset batchId={}", endOffset.batchId());
1 ✔
683
    logDriverHeap("commit", committedBytes, committed.size());
1 ✔
684
    autoReceiveTime.onCommit();
1 ✔
685
  }
1 ✔
686

687
  @Override
688
  public void stop() {
689
    cancelled = true;
1 ✔
690
    try {
691
      int uncommitted = 0;
1 ✔
692
      if (lastProduced != null && config.ackMode() == AckMode.AFTER_COMMIT) {
1 !
693
        String batchKey = Long.toString(lastProduced.batchId());
1 ✔
694
        List<PulledMessage> messages = ackCoordinator.messagesForBatch(batchKey);
1 ✔
695
        if (!messages.isEmpty()) {
1 !
696
          uncommitted = messages.size();
1 ✔
697
          ackCoordinator.abort(client, batchKey);
1 ✔
698
          messagesByBatch.remove(lastProduced.batchId());
1 ✔
699
        }
700
      }
701
      logStop(uncommitted);
1 ✔
702
    } finally {
703
      ackCoordinator.clear();
1 ✔
704
      messagesByBatch.clear();
1 ✔
705
      client.resetOutstandingBytes();
1 ✔
706
      client.close();
1 ✔
707
    }
708
  }
1 ✔
709

710
  private void retainGatheredWindow(long batchId, List<PulledMessage> pulled) {
711
    PublishTimeWindow window = PublishTimeWindow.of(pulled);
1 ✔
712
    lastGatheredBatchId = batchId;
1 ✔
713
    lastGatheredWindow = window;
1 ✔
714
    if (firstBatchLogged || window == null) {
1 !
715
      return;
1 ✔
716
    }
717
    this.firstBatchLogged = true;
1 ✔
718
    LOG.info(window.formatStats(batchId, "BATCH: First batch:"));
1 ✔
719
  }
1 ✔
720

721
  private boolean stopForHeapCeiling() {
722
    if (!client.aboveHeapCeiling()) {
1 !
723
      return false;
1 ✔
724
    }
NEW
725
    client.noteHeapCeiling();
×
NEW
726
    return true;
×
727
  }
728

729
  private void noteIntakeLag(List<PulledMessage> pulled) {
730
    Long ageMs = PubSubSourceMetrics.newestMessageAgeMs(pulled, System.currentTimeMillis());
1 ✔
731
    if (ageMs == null) {
1 ✔
732
      return;
1 ✔
733
    }
734
    Duration cycle = autoReceiveTime.cycleBudget();
1 ✔
735
    long triggerMs = cycle == null ? 0L : cycle.toMillis();
1 !
736
    String warning = intakePace.observe(System.currentTimeMillis(), ageMs, triggerMs);
1 ✔
737
    if (warning != null) {
1 !
NEW
738
      LOG.warn(warning);
×
739
    }
740
  }
1 ✔
741

742
  private long logDriverHeap(String at, long gatherBytes, int gatherMsgs) {
743
    Runtime runtime = Runtime.getRuntime();
1 ✔
744
    long heapMax = runtime.maxMemory();
1 ✔
745
    long heapUsed = DriverHeap.used(runtime.totalMemory(), runtime.freeMemory());
1 ✔
746
    long rss = DriverHeap.residentBytes();
1 ✔
747
    long memAvailable = DriverHeap.availableBytes();
1 ✔
748
    long memTotal = DriverHeap.totalBytes();
1 ✔
749
    // TODO: drop this soak line to debug before a provider release.
750
    if (!heapStartupLogged) {
1 ✔
751
      heapStartupLogged = true;
1 ✔
752
      LOG.info(
1 ✔
753
          DriverHeap.startup(heapMax, memTotal, client.memoryBaseline(), client.memoryLimit()));
1 ✔
754
    }
755
    LOG.info(
1 ✔
756
        DriverHeap.line(
1 ✔
757
            at,
758
            heapMax,
759
            heapUsed,
760
            DriverHeap.percent(heapUsed, heapMax),
1 ✔
761
            DriverHeap.change(previousHeapUsed, heapUsed),
1 ✔
762
            rss,
763
            DriverHeap.change(previousRss, rss),
1 ✔
764
            memAvailable,
765
            DriverHeap.change(previousMemAvailable, memAvailable),
1 ✔
766
            memTotal,
767
            DriverHeap.percent(memAvailable, memTotal),
1 ✔
768
            gatherBytes,
769
            gatherMsgs,
770
            client.queuedBytes(),
1 ✔
771
            client.queuedCount(),
1 ✔
772
            client.memoryBudget()));
1 ✔
773
    previousHeapUsed = heapUsed;
1 ✔
774
    previousRss = rss;
1 ✔
775
    previousMemAvailable = memAvailable;
1 ✔
776
    return DriverHeap.percent(heapUsed, heapMax);
1 ✔
777
  }
778

779
  private void logStop(int uncommitted) {
780
    PublishTimeWindow window = lastGatheredWindow;
1 ✔
781
    if (window == null) {
1 ✔
782
      LOG.info("BATCH: Stopping stream; no messages gathered");
1 ✔
783
      return;
1 ✔
784
    }
785
    final long batchId = this.lastGatheredBatchId == null ? -1L : this.lastGatheredBatchId;
1 !
786
    final String prefix =
787
        uncommitted <= 0
1 !
788
            ? "BATCH: Stopping stream; 0 uncommitted messages found; Last batch is:"
×
789
            : String.format(
1 ✔
790
                "BATCH: Stopping stream; %d uncommitted messages will redeliver if not committed;"
791
                    + " Last batch is:",
792
                uncommitted);
1 ✔
793
    LOG.info(window.formatStats(batchId, prefix));
1 ✔
794
  }
1 ✔
795

796
  PublishTimeWindow lastGatheredWindow() {
797
    return lastGatheredWindow;
1 ✔
798
  }
799

800
  boolean firstBatchLogged() {
801
    return firstBatchLogged;
1 ✔
802
  }
803

804
  Long lastGatheredBatchId() {
805
    return lastGatheredBatchId;
1 ✔
806
  }
807

808
  boolean availableNow() {
809
    return availableNow;
1 ✔
810
  }
811

812
  boolean limitReached() {
813
    return limitReached;
1 ✔
814
  }
815

816
  @Override
817
  public String toString() {
818
    return PubSubConfig.SHORT_NAME + ":" + config.subscriptionPath();
1 ✔
819
  }
820

821
  private void finishBatch(long batchId) {
822
    messagesByBatch.remove(batchId);
1 ✔
823
    if (lastProduced != null && lastProduced.batchId() == batchId) {
1 !
824
      lastProduced = null;
1 ✔
825
    }
826
  }
1 ✔
827
}
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