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

juarezr / spark-streaming-google-pubsub / 64

22 Oct 2026 11:18PM UTC coverage: 78.849% (+7.8%) from 71.066%
64

Pull #23

github

web-flow
Merge 59223447f 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

1148 of 1615 branches covered (71.08%)

Branch coverage included in aggregate %.

769 of 932 new or added lines in 10 files covered. (82.51%)

4 existing lines in 1 file now uncovered.

2222 of 2659 relevant lines covered (83.57%)

0.84 hits per line

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

84.96
/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 (holdsUntilCheckpoint()) {
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 (!holdsUntilCheckpoint() || 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 ✔
246
    } catch (RuntimeException e) {
1 ✔
247
      LOG.warn("BATCH: Failed to ack written batch {}", batchId, e);
1 ✔
248
      return false;
1 ✔
249
    }
1 ✔
250
    client.releaseMessages(pending);
1 ✔
251
    releasedBatchId = batchId;
1 ✔
252
    List<PulledMessage> stripped = PulledMessage.withoutPayloads(pending);
1 ✔
253
    messagesByBatch.put(batchId, stripped);
1 ✔
254
    ackCoordinator.replaceBatch(Long.toString(batchId), stripped);
1 ✔
255
    return true;
1 ✔
256
  }
257

258
  private boolean holdsUntilCheckpoint() {
259
    return config.ackMode() == AckMode.AFTER_COMMIT || config.ackMode() == AckMode.AFTER_CHECKPOINT;
1 ✔
260
  }
261

262
  private List<PulledMessage> gatherMessages(AdmissionLimits gatherLimits) {
263
    boolean release = config.ackMode() == AckMode.AFTER_CHECKPOINT || client.capMoved();
1 ✔
264
    if (!release || releaseWrittenBatch()) {
1 ✔
265
      client.retargetFlow();
1 ✔
266
    }
267
    client.noteCycleBudget(autoReceiveTime.cycleBudget());
1 ✔
268
    AdmissionLimits limits =
269
        availableNow
1 ✔
270
            ? gatherLimits.withDrainUntilIdle()
1 ✔
271
            : gatherLimits.withWaitTime(
1 ✔
272
                this.autoReceiveTime.receiveWindow(gatherLimits.waitTime()));
1 ✔
273
    limits = limits.withMemoryBudget(client.memoryBudget());
1 ✔
274

275
    long gatherStarted = System.nanoTime();
1 ✔
276
    List<PulledMessage> pulled =
277
        limits.drainUntilIdle()
1 ✔
278
            ? gatherUntilIdleOrMax(limits)
1 ✔
279
            : limits.singlePoll()
1 ✔
280
                ? gatherMessagesFromSinglePoll(limits)
1 ✔
281
                : gatherMessagesUntilDeadline(limits);
1 ✔
282
    long gatherFinished = System.nanoTime();
1 ✔
283

284
    this.autoReceiveTime.onGatherFinished(gatherFinished - gatherStarted, pulled.size());
1 ✔
285
    client.noteCycleBudget(autoReceiveTime.cycleBudget());
1 ✔
286
    long payloadBytes = PulledMessage.payloadBytes(pulled);
1 ✔
287
    long heapFraction = logDriverHeap("gather", payloadBytes, pulled.size());
1 ✔
288
    client.noteGather(heapFraction, previousMemAvailable, payloadBytes);
1 ✔
289
    noteIntakeLag(pulled);
1 ✔
290
    return pulled;
1 ✔
291
  }
292

293
  private List<PulledMessage> gatherMessagesFromSinglePoll(AdmissionLimits limits) {
294

295
    List<PulledMessage> messages = new ArrayList<>();
1 ✔
296
    if (gatherAborted()) {
1 !
297
      return messages;
×
298
    }
299
    if (limits.remainingRows(0) <= 0) {
1 !
300
      return messages;
×
301
    }
302
    if (stopForHeapCeiling()) {
1 !
NEW
303
      return messages;
×
304
    }
305
    List<PulledMessage> pulled = poll(limits, messages.size(), RECEIVE_IDLE);
1 ✔
306
    return retainPolled(messages, pulled, limits, 0L).messages;
1 ✔
307
  }
308

309
  private List<PulledMessage> gatherMessagesUntilDeadline(AdmissionLimits limits) {
310

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

385
  private List<PulledMessage> poll(AdmissionLimits limits, int already, Duration timeout) {
386
    client.limitNextPoll(limits.remainingRows(already));
1 ✔
387
    return client.poll(timeout);
1 ✔
388
  }
389

390
  private Duration remainingWait(long deadlineNanos) {
391
    return Duration.ofNanos(Math.max(1L, deadlineNanos - System.nanoTime()));
1 ✔
392
  }
393

394
  private static final class RetainResult {
395
    final List<PulledMessage> messages;
396
    final long payloadBytes;
397

398
    RetainResult(List<PulledMessage> messages, long payloadBytes) {
1 ✔
399
      this.messages = messages;
1 ✔
400
      this.payloadBytes = payloadBytes;
1 ✔
401
    }
1 ✔
402
  }
403

404
  private RetainResult retainPolled(
405
      List<PulledMessage> messages,
406
      List<PulledMessage> pulled,
407
      AdmissionLimits limits,
408
      long payloadBytes) {
409

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

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

481
          if (!split.inRange.isEmpty()) {
1 ✔
482
            RetainResult retained = retainPolled(messages, split.inRange, limits, payloadBytes);
1 ✔
483
            payloadBytes = retained.payloadBytes;
1 ✔
484
            messages = retained.messages;
1 ✔
485
          }
1 ✔
486
          break;
487
        }
488
        emptyStreak = 0;
1 ✔
489
        RetainResult retained = retainPolled(messages, split.inRange, limits, payloadBytes);
1 ✔
490
        payloadBytes = retained.payloadBytes;
1 ✔
491
        messages = retained.messages;
1 ✔
492
      }
1 ✔
493
    } catch (RuntimeException e) {
×
494
      abortGatherNackAndRelease(messages, null);
×
495
      throw e;
×
496
    }
1 ✔
497
    return messages;
1 ✔
498
  }
499

500
  private static final class BoundSplit {
501
    final List<PulledMessage> inRange;
502
    final List<PulledMessage> overLimit;
503

504
    BoundSplit(List<PulledMessage> inRange, List<PulledMessage> overLimit) {
1 ✔
505
      this.inRange = inRange;
1 ✔
506
      this.overLimit = overLimit;
1 ✔
507
    }
1 ✔
508
  }
509

510
  private BoundSplit splitAtLimit(List<PulledMessage> pulled) {
511
    Optional<Instant> limit = config.limitTimeAsInstant();
1 ✔
512
    if (limit.isEmpty()) {
1 ✔
513
      return new BoundSplit(pulled, List.of());
1 ✔
514
    }
515
    long boundMillis = limit.get().toEpochMilli();
1 ✔
516
    List<PulledMessage> inRange = new ArrayList<>();
1 ✔
517
    List<PulledMessage> overLimit = new ArrayList<>();
1 ✔
518
    for (PulledMessage message : pulled) {
1 ✔
519
      if (message.publishTimeMillis() >= boundMillis) {
1 ✔
520
        overLimit.add(message);
1 ✔
521
      } else {
522
        inRange.add(message);
1 ✔
523
      }
524
    }
1 ✔
525
    return new BoundSplit(inRange, overLimit);
1 ✔
526
  }
527

528
  private void nackOverLimit(List<PulledMessage> overLimit) {
529
    if (overLimit.isEmpty()) {
1 !
530
      return;
×
531
    }
532
    try {
533
      client.nack(PulledMessage.ackIds(overLimit));
1 ✔
534
    } catch (RuntimeException e) {
×
535
      LOG.warn(
×
536
          "BATCH: Failed to nack {} over-limit messages: {}", overLimit.size(), e.getMessage());
×
537
    } finally {
538
      client.releaseMessages(overLimit);
1 ✔
539
    }
540
  }
1 ✔
541

542
  private boolean gatherAborted() {
543
    return cancelled || Thread.currentThread().isInterrupted();
1 !
544
  }
545

546
  private List<PulledMessage> abortGatherNackAndRelease(
547
      List<PulledMessage> messages, List<PulledMessage> pulled) {
548

549
    if (pulled != null) {
1 !
550
      messages.addAll(pulled);
1 ✔
551
    }
552
    if (holdsUntilCheckpoint() && !messages.isEmpty()) {
1 !
553
      try {
554
        client.nack(PulledMessage.ackIds(messages));
1 ✔
555
      } catch (RuntimeException nackError) {
×
556
        LOG.warn("BATCH: Failed to nack messages after gather abort: {}", nackError.getMessage());
×
557
      } finally {
558
        client.releaseMessages(messages);
1 ✔
559
      }
560
    }
561
    return List.of();
1 ✔
562
  }
563

564
  private static Duration min(Duration left, Duration right) {
565
    return left.compareTo(right) <= 0 ? left : right;
1 ✔
566
  }
567

568
  /** True when Spark already persisted {@code produced} as the start of the next range. */
569
  static boolean startConsumed(Offset startOffset, PubSubOffset produced) {
570
    if (startOffset == null || produced == null) {
1 !
571
      return false;
1 ✔
572
    }
573
    final long startBatchId =
574
        startOffset instanceof PubSubOffset
1 !
575
            ? ((PubSubOffset) startOffset).batchId()
1 ✔
576
            : PubSubOffset.fromJson(startOffset.json()).batchId();
1 ✔
577
    return startBatchId >= produced.batchId();
1 ✔
578
  }
579

580
  @Override
581
  public Map<String, String> metrics(final Optional<Offset> latestConsumedOffset) {
582
    PubSubOffset produced = lastProduced;
1 ✔
583
    final Long producedBatchId = produced == null ? null : produced.batchId();
1 ✔
584
    final long retryTotal = client.retryAttempts();
1 ✔
585
    final long reportedTotal = lastReportedRetryAttempts.getAndSet(retryTotal);
1 ✔
586
    final long retryThisBatch =
1 ✔
587
        PubSubSourceMetrics.retryAttemptsThisBatch(retryTotal, reportedTotal);
1 ✔
588
    return PubSubSourceMetrics.snapshot(
1 ✔
589
        lastGatherMessageCount.get(),
1 ✔
590
        lastGatherPayloadBytes.get(),
1 ✔
591
        lastGatherNewestMessageAgeMs,
592
        client.outstandingBytes(),
1 ✔
593
        producedBatchId,
594
        latestConsumedOffset,
595
        retryThisBatch,
596
        retryTotal);
597
  }
598

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

628
  @Override
629
  public PartitionReaderFactory createReaderFactory() {
630
    return new PubSubPartitionReaderFactory(readSchema);
1 ✔
631
  }
632

633
  @Override
634
  public Offset initialOffset() {
635
    return currentOffset;
1 ✔
636
  }
637

638
  @Override
639
  public Offset deserializeOffset(String json) {
640
    PubSubOffset offset = PubSubOffset.fromJson(json);
1 ✔
641
    nextBatchId.updateAndGet(v -> Math.max(v, offset.batchId() + 1));
1 ✔
642
    currentOffset = offset;
1 ✔
643
    return offset;
1 ✔
644
  }
645

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

695
  @Override
696
  public void stop() {
697
    cancelled = true;
1 ✔
698
    try {
699
      int uncommitted = 0;
1 ✔
700
      if (lastProduced != null
1 ✔
701
          && holdsUntilCheckpoint()
1 !
702
          && lastProduced.batchId() != releasedBatchId) {
1 !
703
        String batchKey = Long.toString(lastProduced.batchId());
1 ✔
704
        List<PulledMessage> messages = ackCoordinator.messagesForBatch(batchKey);
1 ✔
705
        if (!messages.isEmpty()) {
1 !
706
          uncommitted = messages.size();
1 ✔
707
          ackCoordinator.abort(client, batchKey);
1 ✔
708
          messagesByBatch.remove(lastProduced.batchId());
1 ✔
709
        }
710
      }
711
      logStop(uncommitted);
1 ✔
712
    } finally {
713
      ackCoordinator.clear();
1 ✔
714
      messagesByBatch.clear();
1 ✔
715
      client.resetOutstandingBytes();
1 ✔
716
      client.close();
1 ✔
717
    }
718
  }
1 ✔
719

720
  private void retainGatheredWindow(long batchId, List<PulledMessage> pulled) {
721
    PublishTimeWindow window = PublishTimeWindow.of(pulled);
1 ✔
722
    lastGatheredBatchId = batchId;
1 ✔
723
    lastGatheredWindow = window;
1 ✔
724
    if (firstBatchLogged || window == null) {
1 !
725
      return;
1 ✔
726
    }
727
    this.firstBatchLogged = true;
1 ✔
728
    LOG.info(window.formatStats(batchId, "BATCH: First batch:"));
1 ✔
729
  }
1 ✔
730

731
  private boolean stopForHeapCeiling() {
732
    if (!client.aboveHeapCeiling()) {
1 !
733
      return false;
1 ✔
734
    }
NEW
735
    client.noteHeapCeiling();
×
NEW
736
    return true;
×
737
  }
738

739
  private void noteIntakeLag(List<PulledMessage> pulled) {
740
    Long ageMs = PubSubSourceMetrics.newestMessageAgeMs(pulled, System.currentTimeMillis());
1 ✔
741
    if (ageMs == null) {
1 ✔
742
      return;
1 ✔
743
    }
744
    Duration cycle = autoReceiveTime.cycleBudget();
1 ✔
745
    long triggerMs = cycle == null ? 0L : cycle.toMillis();
1 !
746
    String warning = intakePace.observe(System.currentTimeMillis(), ageMs, triggerMs);
1 ✔
747
    if (warning != null) {
1 !
NEW
748
      LOG.warn(warning);
×
749
    }
750
  }
1 ✔
751

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

789
  private void logStop(int uncommitted) {
790
    PublishTimeWindow window = lastGatheredWindow;
1 ✔
791
    if (window == null) {
1 ✔
792
      LOG.info("BATCH: Stopping stream; no messages gathered");
1 ✔
793
      return;
1 ✔
794
    }
795
    final long batchId = this.lastGatheredBatchId == null ? -1L : this.lastGatheredBatchId;
1 !
796
    final String prefix =
797
        uncommitted <= 0
1 !
798
            ? "BATCH: Stopping stream; 0 uncommitted messages found; Last batch is:"
×
799
            : String.format(
1 ✔
800
                "BATCH: Stopping stream; %d uncommitted messages will redeliver if not committed;"
801
                    + " Last batch is:",
802
                uncommitted);
1 ✔
803
    LOG.info(window.formatStats(batchId, prefix));
1 ✔
804
  }
1 ✔
805

806
  PublishTimeWindow lastGatheredWindow() {
807
    return lastGatheredWindow;
1 ✔
808
  }
809

810
  List<PulledMessage> retainedMessages(long batchId) {
811
    return messagesByBatch.getOrDefault(batchId, List.of());
1 ✔
812
  }
813

814
  boolean firstBatchLogged() {
815
    return firstBatchLogged;
1 ✔
816
  }
817

818
  Long lastGatheredBatchId() {
819
    return lastGatheredBatchId;
1 ✔
820
  }
821

822
  boolean availableNow() {
823
    return availableNow;
1 ✔
824
  }
825

826
  boolean limitReached() {
827
    return limitReached;
1 ✔
828
  }
829

830
  @Override
831
  public String toString() {
832
    return PubSubConfig.SHORT_NAME + ":" + config.subscriptionPath();
1 ✔
833
  }
834

835
  private void finishBatch(long batchId) {
836
    messagesByBatch.remove(batchId);
1 ✔
837
    if (lastProduced != null && lastProduced.batchId() == batchId) {
1 !
838
      lastProduced = null;
1 ✔
839
    }
840
  }
1 ✔
841
}
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