This is an automated email from the ASF dual-hosted git repository.

stankiewicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 6a1efc90e59 SolaceIO - fix for data loss during scaling/rebalancing 
(#36991) (#38603)
6a1efc90e59 is described below

commit 6a1efc90e5939f8509f03ff542c44d994c414b43
Author: Israel Herraiz <[email protected]>
AuthorDate: Mon Aug 31 09:36:50 2026 +0000

    SolaceIO - fix for data loss during scaling/rebalancing (#36991) (#38603)
    
    SolaceIO: Reliability, Checkpoint Handling, and Concurrency Improvements
    • Data Loss & Checkpoint Reliability: Tracks pending checkpoints 
sequentially via a TreeMap and resolves serialized checkpoint marks to active 
readers using an in-memory registry (ActiveReadersRegistry with weak 
references). Ensures strict sequential acknowledgments and catches up after 
lost intermediate finalizations.
    • Checkpoint Timeout & Eviction: Adds configurable ack deadlines 
(withAckDeadline) to evict expired checkpoints from memory. Supports optional 
asynchronous NACKing (withNackOnTimeout, disabled by default) for brokers 
supporting message settlement.
    • Performance & Concurrency: Offloads acknowledgments to a dedicated 
ExecutorService to avoid blocking finalizer threads. Minimizes lock contention 
by restricting synchronization to shared checkpoint state.
    • Observability & Serialization: Adds global counters for received and 
acknowledged messages to monitor in-flight progress. Uses String UUIDs to avoid 
JDK 17+ Avro serialization issues.
---
 .../org/apache/beam/sdk/io/solace/SolaceIO.java    |  49 ++++-
 .../sdk/io/solace/read/ActiveReadersRegistry.java  |  46 +++++
 .../sdk/io/solace/read/SolaceCheckpointMark.java   |  50 ++---
 .../sdk/io/solace/read/UnboundedSolaceReader.java  | 175 ++++++++++++++---
 .../sdk/io/solace/read/UnboundedSolaceSource.java  |  24 ++-
 .../beam/sdk/io/solace/SolaceIOReadTest.java       | 103 +++++++++-
 .../beam/sdk/io/solace/data/SolaceDataUtils.java   |  30 ++-
 .../io/solace/read/UnboundedSolaceReaderTest.java  | 215 +++++++++++++++++++++
 8 files changed, 628 insertions(+), 64 deletions(-)

diff --git 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/SolaceIO.java 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/SolaceIO.java
index 63509126022..c0ecac96fdb 100644
--- 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/SolaceIO.java
+++ 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/SolaceIO.java
@@ -414,6 +414,8 @@ public class SolaceIO {
   private static final boolean DEFAULT_DEDUPLICATE_RECORDS = false;
   private static final Duration DEFAULT_WATERMARK_IDLE_DURATION_THRESHOLD =
       Duration.standardSeconds(30);
+  private static final Duration DEFAULT_ACK_DEADLINE = 
Duration.standardSeconds(30);
+  public static final boolean DEFAULT_NACK_ON_TIMEOUT = false;
   public static final int DEFAULT_WRITER_NUM_SHARDS = 20;
   public static final int DEFAULT_WRITER_CLIENTS_PER_WORKER = 4;
   public static final Boolean DEFAULT_WRITER_PUBLISH_LATENCY_METRICS = false;
@@ -461,7 +463,9 @@ public class SolaceIO {
             .setParseFn(SolaceRecordMapper::map)
             .setTimestampFn(SENDER_TIMESTAMP_FUNCTION)
             .setDeduplicateRecords(DEFAULT_DEDUPLICATE_RECORDS)
-            
.setWatermarkIdleDurationThreshold(DEFAULT_WATERMARK_IDLE_DURATION_THRESHOLD));
+            
.setWatermarkIdleDurationThreshold(DEFAULT_WATERMARK_IDLE_DURATION_THRESHOLD)
+            .setAckDeadline(DEFAULT_ACK_DEADLINE)
+            .setNackOnTimeout(DEFAULT_NACK_ON_TIMEOUT));
   }
 
   /**
@@ -490,7 +494,9 @@ public class SolaceIO {
             .setParseFn(parseFn)
             .setTimestampFn(timestampFn)
             .setDeduplicateRecords(DEFAULT_DEDUPLICATE_RECORDS)
-            
.setWatermarkIdleDurationThreshold(DEFAULT_WATERMARK_IDLE_DURATION_THRESHOLD));
+            
.setWatermarkIdleDurationThreshold(DEFAULT_WATERMARK_IDLE_DURATION_THRESHOLD)
+            .setAckDeadline(DEFAULT_ACK_DEADLINE)
+            .setNackOnTimeout(DEFAULT_NACK_ON_TIMEOUT));
   }
 
   /**
@@ -576,6 +582,33 @@ public class SolaceIO {
       return this;
     }
 
+    /**
+     * Optional. Sets the deadline for acknowledging messages. If a checkpoint 
is not finalized
+     * within this duration, the messages in that checkpoint will be 
negatively acknowledged
+     * (Nacked) to the broker. The default ack deadline is 30 seconds.
+     */
+    public Read<T> withAckDeadline(Duration ackDeadline) {
+      configurationBuilder.setAckDeadline(ackDeadline);
+      return this;
+    }
+
+    /**
+     * Optional. Sets whether to explicitly negative-acknowledge (NACK) 
messages when a checkpoint
+     * times out (exceeds {@link #withAckDeadline(Duration)}).
+     *
+     * <p>Default is {@code false}. When disabled, timed out checkpoints are 
evicted from memory to
+     * prevent unbounded memory growth, and the broker will redeliver 
unacknowledged messages upon
+     * session reconnection or flow rebind.
+     *
+     * <p>Note: Enabling NACK requires Solace broker version 10.2.1+ and 
consumer flows configured
+     * to support settlement outcomes (e.g. {@code Outcome.FAILED}). Calling 
NACK on standard flows
+     * throws {@link com.solacesystems.jcsmp.InvalidOperationException}.
+     */
+    public Read<T> withNackOnTimeout(boolean nackOnTimeout) {
+      configurationBuilder.setNackOnTimeout(nackOnTimeout);
+      return this;
+    }
+
     /**
      * Optional, default: false. Set to deduplicate messages based on the 
{@link
      * BytesXMLMessage#getApplicationMessageId()} of the incoming {@link 
BytesXMLMessage}. If the
@@ -689,6 +722,10 @@ public class SolaceIO {
 
       abstract Duration getWatermarkIdleDurationThreshold();
 
+      abstract Duration getAckDeadline();
+
+      abstract boolean getNackOnTimeout();
+
       public static <T> Builder<T> builder() {
         Builder<T> builder =
             new 
org.apache.beam.sdk.io.solace.AutoValue_SolaceIO_Read_Configuration.Builder<T>();
@@ -719,6 +756,10 @@ public class SolaceIO {
 
         abstract Builder<T> setWatermarkIdleDurationThreshold(Duration 
idleDurationThreshold);
 
+        abstract Builder<T> setAckDeadline(Duration ackDeadline);
+
+        abstract Builder<T> setNackOnTimeout(boolean nackOnTimeout);
+
         abstract Configuration<T> build();
       }
     }
@@ -756,7 +797,9 @@ public class SolaceIO {
                   coder,
                   configuration.getTimestampFn(),
                   configuration.getWatermarkIdleDurationThreshold(),
-                  configuration.getParseFn())));
+                  configuration.getParseFn(),
+                  configuration.getAckDeadline(),
+                  configuration.getNackOnTimeout())));
     }
 
     @VisibleForTesting
diff --git 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/ActiveReadersRegistry.java
 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/ActiveReadersRegistry.java
new file mode 100644
index 00000000000..e8838f219dc
--- /dev/null
+++ 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/ActiveReadersRegistry.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.solace.read;
+
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.cache.Cache;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.cache.CacheBuilder;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A global registry to keep track of active {@link UnboundedSolaceReader} 
instances on the worker
+ * JVM using weak references.
+ *
+ * <p>This allows serialized {@link SolaceCheckpointMark} instances to resolve 
their originating
+ * reader and perform sequential acknowledgments.
+ */
+class ActiveReadersRegistry {
+  private static final Cache<String, UnboundedSolaceReader<?>> registry =
+      CacheBuilder.newBuilder().weakValues().build();
+
+  public static void register(String uuid, UnboundedSolaceReader<?> reader) {
+    registry.put(uuid, reader);
+  }
+
+  public static void unregister(String uuid) {
+    registry.invalidate(uuid);
+  }
+
+  public static @Nullable UnboundedSolaceReader<?> get(String uuid) {
+    return registry.getIfPresent(uuid);
+  }
+}
diff --git 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/SolaceCheckpointMark.java
 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/SolaceCheckpointMark.java
index 83aed07374b..8e9647549d7 100644
--- 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/SolaceCheckpointMark.java
+++ 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/SolaceCheckpointMark.java
@@ -17,15 +17,12 @@
  */
 package org.apache.beam.sdk.io.solace.read;
 
-import com.solacesystems.jcsmp.BytesXMLMessage;
 import java.util.Objects;
-import java.util.Queue;
 import org.apache.beam.sdk.annotations.Internal;
 import org.apache.beam.sdk.coders.DefaultCoder;
 import org.apache.beam.sdk.extensions.avro.coders.AvroCoder;
 import org.apache.beam.sdk.io.UnboundedSource;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
-import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -39,33 +36,41 @@ import org.slf4j.LoggerFactory;
 @VisibleForTesting
 public class SolaceCheckpointMark implements UnboundedSource.CheckpointMark {
   private static final Logger LOG = 
LoggerFactory.getLogger(SolaceCheckpointMark.class);
-  private transient Queue<BytesXMLMessage> safeToAck;
+  private String readerUuid;
+  private long checkpointId;
 
   @SuppressWarnings("initialization") // Avro will set the fields by breaking 
abstraction
-  private SolaceCheckpointMark() {}
+  private SolaceCheckpointMark() {
+    this.readerUuid = "";
+    this.checkpointId = 0;
+  }
 
   /**
    * Creates a new {@link SolaceCheckpointMark}.
    *
-   * @param safeToAck - a queue of {@link BytesXMLMessage} to be acknowledged.
+   * @param readerUuid - the UUID of the originating reader.
+   * @param checkpointId - the unique ID of this checkpoint.
    */
-  SolaceCheckpointMark(Queue<BytesXMLMessage> safeToAck) {
-    this.safeToAck = safeToAck;
+  SolaceCheckpointMark(String readerUuid, long checkpointId) {
+    this.readerUuid = readerUuid;
+    this.checkpointId = checkpointId;
   }
 
   @Override
   public void finalizeCheckpoint() {
-    BytesXMLMessage msg;
-    while ((msg = safeToAck.poll()) != null) {
-      try {
-        msg.ackMessage();
-      } catch (IllegalStateException e) {
-        LOG.error(
-            "SolaceIO.Read: cannot acknowledge the message with 
applicationMessageId={}, ackMessageId={}. It will not be retried.",
-            msg.getApplicationMessageId(),
-            msg.getAckMessageId(),
-            e);
-      }
+    if (readerUuid == null || readerUuid.isEmpty()) {
+      LOG.warn("SolaceIO.Read: Checkpoint has no reader UUID, cannot 
finalize.");
+      return;
+    }
+    UnboundedSolaceReader<?> reader = ActiveReadersRegistry.get(readerUuid);
+    if (reader != null) {
+      reader.finalizeCheckpoint(checkpointId);
+    } else {
+      LOG.warn(
+          "SolaceIO.Read: Reader with UUID {} not found in registry. "
+              + "Checkpoint {} cannot be finalized. Messages will be 
redelivered if session is closed.",
+          readerUuid,
+          checkpointId);
     }
   }
 
@@ -81,14 +86,11 @@ public class SolaceCheckpointMark implements 
UnboundedSource.CheckpointMark {
       return false;
     }
     SolaceCheckpointMark that = (SolaceCheckpointMark) o;
-    return safeToAck == that.safeToAck
-        || (safeToAck != null
-            && that.safeToAck != null
-            && Iterables.elementsEqual(safeToAck, that.safeToAck));
+    return checkpointId == that.checkpointId && Objects.equals(readerUuid, 
that.readerUuid);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(safeToAck);
+    return Objects.hash(readerUuid, checkpointId);
   }
 }
diff --git 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceReader.java
 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceReader.java
index dc84e0a0701..0bfd9f1c45d 100644
--- 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceReader.java
+++ 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceReader.java
@@ -20,28 +20,39 @@ package org.apache.beam.sdk.io.solace.read;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
 
 import com.solacesystems.jcsmp.BytesXMLMessage;
+import com.solacesystems.jcsmp.JCSMPException;
+import com.solacesystems.jcsmp.XMLMessage;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.NoSuchElementException;
 import java.util.Queue;
+import java.util.SortedMap;
+import java.util.TreeMap;
 import java.util.UUID;
-import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
 import org.apache.beam.sdk.io.UnboundedSource;
 import org.apache.beam.sdk.io.UnboundedSource.UnboundedReader;
 import org.apache.beam.sdk.io.solace.broker.SempClient;
 import org.apache.beam.sdk.io.solace.broker.SessionService;
 import org.apache.beam.sdk.io.solace.broker.SessionServiceFactory;
+import org.apache.beam.sdk.metrics.Counter;
+import org.apache.beam.sdk.metrics.Metrics;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
 import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.cache.Cache;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.cache.CacheBuilder;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.cache.RemovalNotification;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.joda.time.Instant;
 import org.slf4j.Logger;
@@ -55,16 +66,28 @@ class UnboundedSolaceReader<T> extends UnboundedReader<T> {
   private final UnboundedSolaceSource<T> currentSource;
   private final WatermarkPolicy<T> watermarkPolicy;
   private final SempClient sempClient;
-  private final UUID readerUuid;
+  final String readerUuid;
+  private final ExecutorService ackExecutor;
+  @VisibleForTesting Supplier<Long> clock = System::currentTimeMillis;
+  private final Object lock = new Object();
   private final SessionServiceFactory sessionServiceFactory;
   private @Nullable BytesXMLMessage solaceOriginalRecord;
   private @Nullable T solaceMappedRecord;
+  private final Counter messagesReceived =
+      Metrics.counter(UnboundedSolaceReader.class, "messages_received");
+  private final Counter messagesAcked =
+      Metrics.counter(UnboundedSolaceReader.class, "messages_acked");
+
+  private final Duration ackDeadline;
+  private final boolean nackOnTimeout;
 
   /**
-   * Queue to place advanced messages before {@link #getCheckpointMark()} is 
called. CAUTION:
-   * Accessed by both reader and checkpointing threads.
+   * Map to track pending checkpoints and their messages. Accessed by both 
reader
+   * (getCheckpointMark) and finalizer (finalizeCheckpoint) threads.
    */
-  private final Queue<BytesXMLMessage> safeToAckMessages = new 
ConcurrentLinkedQueue<>();
+  private final TreeMap<Long, PendingCheckpoint> pendingCheckpoints = new 
TreeMap<>();
+
+  private long nextCheckpointId = 1;
 
   /**
    * Queue for messages that were ingested in the {@link #advance()} method, 
but not sent yet to a
@@ -72,7 +95,7 @@ class UnboundedSolaceReader<T> extends UnboundedReader<T> {
    */
   private final Queue<BytesXMLMessage> receivedMessages = new ArrayDeque<>();
 
-  private static final Cache<UUID, SessionService> sessionServiceCache;
+  private static final Cache<String, SessionService> sessionServiceCache;
   private static final ScheduledExecutorService cleanUpThread = 
Executors.newScheduledThreadPool(1);
 
   static {
@@ -81,7 +104,7 @@ class UnboundedSolaceReader<T> extends UnboundedReader<T> {
         CacheBuilder.newBuilder()
             .expireAfterAccess(cacheExpirationTimeout)
             .removalListener(
-                (RemovalNotification<UUID, SessionService> notification) -> {
+                (RemovalNotification<String, SessionService> notification) -> {
                   LOG.info(
                       "SolaceIO.Read: Closing session for the reader with uuid 
{} as it has been idle for over {}.",
                       notification.getKey(),
@@ -108,7 +131,10 @@ class UnboundedSolaceReader<T> extends UnboundedReader<T> {
             currentSource.getTimestampFn(), 
currentSource.getWatermarkIdleDurationThreshold());
     this.sessionServiceFactory = currentSource.getSessionServiceFactory();
     this.sempClient = currentSource.getSempClientFactory().create();
-    this.readerUuid = UUID.randomUUID();
+    this.readerUuid = UUID.randomUUID().toString();
+    this.ackExecutor = Executors.newFixedThreadPool(4);
+    this.ackDeadline = 
java.time.Duration.ofMillis(currentSource.getAckDeadline().getMillis());
+    this.nackOnTimeout = currentSource.getNackOnTimeout();
   }
 
   private SessionService getSessionService() {
@@ -136,8 +162,7 @@ class UnboundedSolaceReader<T> extends UnboundedReader<T> {
 
   @Override
   public boolean advance() {
-    finalizeReadyMessages();
-
+    checkTimeouts();
     BytesXMLMessage receivedXmlMessage;
     try {
       receivedXmlMessage = getSessionService().getReceiver().receive();
@@ -152,29 +177,110 @@ class UnboundedSolaceReader<T> extends 
UnboundedReader<T> {
     solaceOriginalRecord = receivedXmlMessage;
     solaceMappedRecord = 
getCurrentSource().getParseFn().apply(receivedXmlMessage);
     receivedMessages.add(receivedXmlMessage);
+    messagesReceived.inc();
 
     return true;
   }
 
   @Override
   public void close() {
-    finalizeReadyMessages();
     sessionServiceCache.invalidate(readerUuid);
+    ActiveReadersRegistry.unregister(readerUuid);
+    ackExecutor.shutdown();
+    try {
+      if (!ackExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
+        ackExecutor.shutdownNow();
+      }
+    } catch (InterruptedException e) {
+      ackExecutor.shutdownNow();
+      Thread.currentThread().interrupt();
+    }
+  }
+
+  void finalizeCheckpoint(long checkpointId) {
+    List<BytesXMLMessage> messagesToAck = new ArrayList<>();
+
+    synchronized (lock) {
+      SortedMap<Long, PendingCheckpoint> toAck = 
pendingCheckpoints.headMap(checkpointId, true);
+      for (PendingCheckpoint cp : toAck.values()) {
+        messagesToAck.addAll(cp.messages);
+      }
+      toAck.clear();
+    }
+
+    List<CompletableFuture<Void>> futures = new ArrayList<>();
+    for (BytesXMLMessage msg : messagesToAck) {
+      futures.add(
+          CompletableFuture.runAsync(
+              () -> {
+                try {
+                  msg.ackMessage();
+                  messagesAcked.inc();
+                } catch (IllegalStateException e) {
+                  LOG.warn(
+                      "SolaceIO.Read: Failed to acknowledge message with 
applicationMessageId={}, ackMessageId={}. Session might be closed.",
+                      msg.getApplicationMessageId(),
+                      msg.getAckMessageId(),
+                      e);
+                }
+              },
+              ackExecutor));
+    }
+
+    try {
+      CompletableFuture.allOf(futures.toArray(new 
CompletableFuture<?>[0])).join();
+    } catch (Exception e) {
+      LOG.warn("SolaceIO.Read: Exception waiting for message 
acknowledgements", e);
+    }
   }
 
-  public void finalizeReadyMessages() {
-    BytesXMLMessage msg;
-    while ((msg = safeToAckMessages.poll()) != null) {
-      try {
-        msg.ackMessage();
-      } catch (IllegalStateException e) {
-        LOG.error(
-            "SolaceIO.Read: failed to acknowledge the message with 
applicationMessageId={}, ackMessageId={}. Returning the message to queue to 
retry.",
-            msg.getApplicationMessageId(),
-            msg.getAckMessageId(),
-            e);
-        safeToAckMessages.add(msg); // In case the error was transient, might 
succeed later
-        break; // Commit is only best effort
+  private void checkTimeouts() {
+    long now = clock.get();
+    List<PendingCheckpoint> expired = new ArrayList<>();
+    synchronized (lock) {
+      while (!pendingCheckpoints.isEmpty()) {
+        long oldestId = pendingCheckpoints.firstKey();
+        PendingCheckpoint oldest = pendingCheckpoints.get(oldestId);
+        if (oldest != null && now - oldest.timestamp > ackDeadline.toMillis()) 
{
+          pendingCheckpoints.remove(oldestId);
+          expired.add(oldest);
+        } else {
+          break;
+        }
+      }
+    }
+
+    if (expired.isEmpty()) {
+      return;
+    }
+
+    for (PendingCheckpoint cp : expired) {
+      if (nackOnTimeout) {
+        LOG.warn(
+            "SolaceIO.Read: Checkpoint {} timed out after {}ms. Nacking {} 
messages.",
+            cp.id,
+            now - cp.timestamp,
+            cp.messages.size());
+        for (BytesXMLMessage msg : cp.messages) {
+          ackExecutor.execute(
+              () -> {
+                try {
+                  msg.settle(XMLMessage.Outcome.FAILED);
+                } catch (JCSMPException | IllegalStateException e) {
+                  LOG.warn(
+                      "SolaceIO.Read: Failed to nack message with 
applicationMessageId={}, ackMessageId={}",
+                      msg.getApplicationMessageId(),
+                      msg.getAckMessageId(),
+                      e);
+                }
+              });
+        }
+      } else {
+        LOG.warn(
+            "SolaceIO.Read: Checkpoint {} timed out after {}ms. Evicting {} 
messages from memory without explicit nack (messages will be redelivered if 
session reconnects).",
+            cp.id,
+            now - cp.timestamp,
+            cp.messages.size());
       }
     }
   }
@@ -190,9 +296,14 @@ class UnboundedSolaceReader<T> extends UnboundedReader<T> {
 
   @Override
   public UnboundedSource.CheckpointMark getCheckpointMark() {
-    safeToAckMessages.addAll(receivedMessages);
+    long checkpointId = nextCheckpointId++;
+    ImmutableList<BytesXMLMessage> messages = 
ImmutableList.copyOf(receivedMessages);
     receivedMessages.clear();
-    return new SolaceCheckpointMark(safeToAckMessages);
+    synchronized (lock) {
+      pendingCheckpoints.put(
+          checkpointId, new PendingCheckpoint(checkpointId, messages, 
clock.get()));
+    }
+    return new SolaceCheckpointMark(readerUuid, checkpointId);
   }
 
   @Override
@@ -242,4 +353,16 @@ class UnboundedSolaceReader<T> extends UnboundedReader<T> {
       return BACKLOG_UNKNOWN;
     }
   }
+
+  private static class PendingCheckpoint {
+    final long id;
+    final List<BytesXMLMessage> messages;
+    final long timestamp;
+
+    PendingCheckpoint(long id, List<BytesXMLMessage> messages, long timestamp) 
{
+      this.id = id;
+      this.messages = messages;
+      this.timestamp = timestamp;
+    }
+  }
 }
diff --git 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceSource.java
 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceSource.java
index 1cb17a49fbd..0ca9bff9ebf 100644
--- 
a/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceSource.java
+++ 
b/sdks/java/io/solace/src/main/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceSource.java
@@ -49,6 +49,8 @@ public class UnboundedSolaceSource<T> extends 
UnboundedSource<T, SolaceCheckpoin
   private final SerializableFunction<T, Instant> timestampFn;
   private final Duration watermarkIdleDurationThreshold;
   private final SerializableFunction<@Nullable BytesXMLMessage, @Nullable T> 
parseFn;
+  private final Duration ackDeadline;
+  private final boolean nackOnTimeout;
 
   public Queue getQueue() {
     return queue;
@@ -74,6 +76,14 @@ public class UnboundedSolaceSource<T> extends 
UnboundedSource<T, SolaceCheckpoin
     return parseFn;
   }
 
+  public Duration getAckDeadline() {
+    return ackDeadline;
+  }
+
+  public boolean getNackOnTimeout() {
+    return nackOnTimeout;
+  }
+
   public UnboundedSolaceSource(
       Queue queue,
       SempClientFactory sempClientFactory,
@@ -83,7 +93,9 @@ public class UnboundedSolaceSource<T> extends 
UnboundedSource<T, SolaceCheckpoin
       Coder<T> coder,
       SerializableFunction<T, Instant> timestampFn,
       Duration watermarkIdleDurationThreshold,
-      SerializableFunction<@Nullable BytesXMLMessage, @Nullable T> parseFn) {
+      SerializableFunction<@Nullable BytesXMLMessage, @Nullable T> parseFn,
+      Duration ackDeadline,
+      boolean nackOnTimeout) {
     this.queue = queue;
     this.sempClientFactory = sempClientFactory;
     this.sessionServiceFactory = sessionServiceFactory;
@@ -93,6 +105,8 @@ public class UnboundedSolaceSource<T> extends 
UnboundedSource<T, SolaceCheckpoin
     this.timestampFn = timestampFn;
     this.watermarkIdleDurationThreshold = watermarkIdleDurationThreshold;
     this.parseFn = parseFn;
+    this.ackDeadline = ackDeadline;
+    this.nackOnTimeout = nackOnTimeout;
   }
 
   @Override
@@ -100,7 +114,9 @@ public class UnboundedSolaceSource<T> extends 
UnboundedSource<T, SolaceCheckpoin
       PipelineOptions options, @Nullable SolaceCheckpointMark checkpointMark) {
     // it makes no sense to resume a Solace Session with the previous 
checkpoint
     // so don't need the pass a checkpoint to new a Solace Reader
-    return new UnboundedSolaceReader<>(this);
+    UnboundedSolaceReader<T> reader = new UnboundedSolaceReader<>(this);
+    ActiveReadersRegistry.register(reader.readerUuid, reader);
+    return reader;
   }
 
   @Override
@@ -134,7 +150,9 @@ public class UnboundedSolaceSource<T> extends 
UnboundedSource<T, SolaceCheckpoin
               coder,
               timestampFn,
               watermarkIdleDurationThreshold,
-              parseFn);
+              parseFn,
+              ackDeadline,
+              nackOnTimeout);
       sourceList.add(source);
     }
     return sourceList;
diff --git 
a/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/SolaceIOReadTest.java
 
b/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/SolaceIOReadTest.java
index a1f80932edd..29e82142f94 100644
--- 
a/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/SolaceIOReadTest.java
+++ 
b/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/SolaceIOReadTest.java
@@ -97,7 +97,9 @@ public class SolaceIOReadTest {
         spec.inferCoder(pipeline, configuration.getTypeDescriptor()),
         configuration.getTimestampFn(),
         configuration.getWatermarkIdleDurationThreshold(),
-        configuration.getParseFn());
+        configuration.getParseFn(),
+        configuration.getAckDeadline(),
+        configuration.getNackOnTimeout());
   }
 
   @Test
@@ -458,18 +460,16 @@ public class SolaceIOReadTest {
     // mark all consumed messages as ready to be acknowledged
     CheckpointMark checkpointMark = reader.getCheckpointMark();
 
-    // consume 1 more message. This will call #ackMsg() on messages that were 
ready to be acked.
+    // consume 1 more message.
     reader.advance();
-    assertEquals(4, countAckMessages.get());
+    assertEquals(0, countAckMessages.get());
 
     // consume 1 more message. No change in the acknowledged messages.
     reader.advance();
-    assertEquals(4, countAckMessages.get());
+    assertEquals(0, countAckMessages.get());
 
     // acknowledge from the first checkpoint
     checkpointMark.finalizeCheckpoint();
-    // No change in the acknowledged messages, because they were acknowledged 
in the #advance()
-    // method.
     assertEquals(4, countAckMessages.get());
   }
 
@@ -542,7 +542,7 @@ public class SolaceIOReadTest {
   @Test
   public void testDefaultCoder() {
     Coder<SolaceCheckpointMark> coder =
-        new UnboundedSolaceSource<>(null, null, null, 0, false, null, null, 
null, null)
+        new UnboundedSolaceSource<>(null, null, null, 0, false, null, null, 
null, null, null, false)
             .getCheckpointMarkCoder();
     CoderProperties.coderSerializable(coder);
   }
@@ -607,4 +607,93 @@ public class SolaceIOReadTest {
     PAssert.that(destAreTopics).containsInAnyOrder(expected);
     pipeline.run();
   }
+
+  @Test
+  public void testLostCheckpointCatchUp() throws Exception {
+    AtomicInteger countConsumedMessages = new AtomicInteger(0);
+    AtomicInteger countAckMessages = new AtomicInteger(0);
+
+    // Broker that creates input data
+    SerializableFunction<Integer, BytesXMLMessage> recordFn =
+        index -> {
+          List<BytesXMLMessage> messages = new ArrayList<>();
+          for (int i = 0; i < 10; i++) {
+            messages.add(
+                SolaceDataUtils.getBytesXmlMessage(
+                    "payload_test" + i, "45" + i, (num) -> 
countAckMessages.incrementAndGet()));
+          }
+          countConsumedMessages.incrementAndGet();
+          return getOrNull(index, messages);
+        };
+
+    SessionServiceFactory fakeSessionServiceFactory =
+        
MockSessionServiceFactory.builder().recordFn(recordFn).minMessagesReceived(10).build();
+
+    Read<Record> spec =
+        getDefaultRead()
+            .withSessionServiceFactory(fakeSessionServiceFactory)
+            .withMaxNumConnections(4);
+
+    UnboundedSolaceSource<Record> initialSource = getSource(spec, pipeline);
+
+    UnboundedReader<Record> reader =
+        initialSource.createReader(PipelineOptionsFactory.create(), null);
+
+    // start the reader and move to the first record
+    assertTrue(reader.start());
+
+    // consume 3 messages (NB: start already consumed the first message)
+    for (int i = 0; i < 3; i++) {
+      assertTrue(reader.advance());
+    }
+    assertEquals(0, countAckMessages.get());
+
+    // Create Checkpoint T1 (contains 4 messages)
+    reader.getCheckpointMark();
+
+    // consume 3 more messages
+    for (int i = 0; i < 3; i++) {
+      assertTrue(reader.advance());
+    }
+    assertEquals(0, countAckMessages.get());
+
+    // Create Checkpoint T2 (contains 3 messages)
+    CheckpointMark checkpointMark2 = reader.getCheckpointMark();
+
+    // We "lose" checkpointMark1 (do NOT finalize it)
+    // We finalize checkpointMark2
+    checkpointMark2.finalizeCheckpoint();
+
+    // checkpointMark2 should have caught up and acked both T1 and T2 (4 + 3 = 
7 messages)
+    assertEquals(7, countAckMessages.get());
+  }
+
+  @Test
+  public void testWithNackOnTimeout() {
+    Read<Record> defaultRead =
+        getDefaultRead()
+            .withSessionServiceFactory(
+                MockSessionServiceFactory.builder()
+                    .sessionServiceType(SessionServiceType.EMPTY)
+                    .build());
+    assertFalse(defaultRead.configurationBuilder.build().getNackOnTimeout());
+
+    Read<Record> readWithNack =
+        getDefaultRead()
+            .withSessionServiceFactory(
+                MockSessionServiceFactory.builder()
+                    .sessionServiceType(SessionServiceType.EMPTY)
+                    .build())
+            .withNackOnTimeout(true);
+    assertTrue(readWithNack.configurationBuilder.build().getNackOnTimeout());
+
+    Read<Record> readWithoutNack =
+        getDefaultRead()
+            .withSessionServiceFactory(
+                MockSessionServiceFactory.builder()
+                    .sessionServiceType(SessionServiceType.EMPTY)
+                    .build())
+            .withNackOnTimeout(false);
+    
assertFalse(readWithoutNack.configurationBuilder.build().getNackOnTimeout());
+  }
 }
diff --git 
a/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/data/SolaceDataUtils.java
 
b/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/data/SolaceDataUtils.java
index 9e04c4cfd27..5e0b8182cf5 100644
--- 
a/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/data/SolaceDataUtils.java
+++ 
b/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/data/SolaceDataUtils.java
@@ -26,6 +26,7 @@ import com.solacesystems.jcsmp.MessageType;
 import com.solacesystems.jcsmp.ReplicationGroupMessageId;
 import com.solacesystems.jcsmp.SDTMap;
 import com.solacesystems.jcsmp.User_Cos;
+import com.solacesystems.jcsmp.XMLMessage.Outcome;
 import com.solacesystems.jcsmp.impl.ReplicationGroupMessageIdImpl;
 import java.io.IOException;
 import java.io.InputStream;
@@ -44,6 +45,11 @@ public class SolaceDataUtils {
   public static final ReplicationGroupMessageId DEFAULT_REPLICATION_GROUP_ID =
       new ReplicationGroupMessageIdImpl(1L, 136L);
 
+  @FunctionalInterface
+  public interface SettleConsumer {
+    void accept(Outcome outcome) throws JCSMPException;
+  }
+
   @DefaultSchema(JavaBeanSchema.class)
   public static class SimpleRecord {
     public String payload;
@@ -129,11 +135,29 @@ public class SolaceDataUtils {
     return getBytesXmlMessage(payload, messageId, ackMessageFn, null);
   }
 
+  public static BytesXMLMessage getBytesXmlMessageWithSettle(
+      String payload,
+      String messageId,
+      SerializableFunction<Integer, Integer> ackMessageFn,
+      SettleConsumer settleCallback) {
+    return getBytesXmlMessageInternal(payload, messageId, ackMessageFn, null, 
settleCallback);
+  }
+
   public static BytesXMLMessage getBytesXmlMessage(
       String payload,
       String messageId,
       SerializableFunction<Integer, Integer> ackMessageFn,
       ReplicationGroupMessageId replicationGroupMessageId) {
+    return getBytesXmlMessageInternal(
+        payload, messageId, ackMessageFn, replicationGroupMessageId, null);
+  }
+
+  private static BytesXMLMessage getBytesXmlMessageInternal(
+      String payload,
+      String messageId,
+      SerializableFunction<Integer, Integer> ackMessageFn,
+      ReplicationGroupMessageId replicationGroupMessageId,
+      SettleConsumer settleCallback) {
     long receiverTimestamp = 1708100477067L;
     long expiration = 1000L;
     long timeToLive = 1000L;
@@ -654,7 +678,11 @@ public class SolaceDataUtils {
       public void setUserData(byte[] arg0) {}
 
       @Override
-      public void settle(Outcome arg0) throws JCSMPException {}
+      public void settle(Outcome arg0) throws JCSMPException {
+        if (settleCallback != null) {
+          settleCallback.accept(arg0);
+        }
+      }
 
       @Override
       public int writeAttachment(byte[] arg0) {
diff --git 
a/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceReaderTest.java
 
b/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceReaderTest.java
new file mode 100644
index 00000000000..33d463201a5
--- /dev/null
+++ 
b/sdks/java/io/solace/src/test/java/org/apache/beam/sdk/io/solace/read/UnboundedSolaceReaderTest.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.solace.read;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.solacesystems.jcsmp.BytesXMLMessage;
+import com.solacesystems.jcsmp.InvalidOperationException;
+import com.solacesystems.jcsmp.XMLMessage.Outcome;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.beam.sdk.io.solace.MockSempClientFactory;
+import org.apache.beam.sdk.io.solace.MockSessionServiceFactory;
+import org.apache.beam.sdk.io.solace.data.Solace;
+import org.apache.beam.sdk.io.solace.data.SolaceDataUtils;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.transforms.SerializableFunction;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class UnboundedSolaceReaderTest {
+
+  @Test
+  public void testCheckpointTimeoutDefault_EvictsWithoutNack() throws 
Exception {
+    AtomicReference<Outcome> settledOutcome = new AtomicReference<>();
+
+    SerializableFunction<Integer, BytesXMLMessage> recordFn =
+        index -> {
+          if (index == 0) {
+            return SolaceDataUtils.getBytesXmlMessageWithSettle(
+                "payload_test0", "450", null, settledOutcome::set);
+          }
+          return null;
+        };
+
+    MockSessionServiceFactory fakeSessionServiceFactory =
+        
MockSessionServiceFactory.builder().recordFn(recordFn).minMessagesReceived(1).build();
+
+    UnboundedSolaceSource<Solace.Record> source =
+        new UnboundedSolaceSource<>(
+            
com.solacesystems.jcsmp.JCSMPFactory.onlyInstance().createQueue("queue"),
+            MockSempClientFactory.getDefaultMock(),
+            fakeSessionServiceFactory,
+            1, // maxNumConnections
+            false, // enableDeduplication
+            null, // coder
+            input -> org.joda.time.Instant.ofEpochMilli(1000L), // timestampFn
+            org.joda.time.Duration.standardSeconds(1), // 
watermarkIdleDurationThreshold
+            input -> SolaceDataUtils.getSolaceRecord("payload_test0", "450"), 
// parseFn
+            org.joda.time.Duration.standardSeconds(30), // ackDeadline
+            false // nackOnTimeout (default)
+            );
+
+    UnboundedSolaceReader<Solace.Record> reader =
+        (UnboundedSolaceReader<Solace.Record>)
+            source.createReader(PipelineOptionsFactory.create(), null);
+
+    AtomicReference<Long> currentTime = new AtomicReference<>(1000L);
+    reader.clock = currentTime::get;
+
+    assertTrue(reader.start());
+
+    // Create a checkpoint (T1)
+    reader.getCheckpointMark();
+    assertNull(settledOutcome.get());
+
+    // Advance time by 31 seconds (> 30s timeout)
+    currentTime.set(32000L);
+    reader.advance(); // Calls checkTimeouts()
+
+    // Shut down reader to wait for any background executions
+    reader.close();
+
+    // Verify that settle() was NEVER called because nackOnTimeout is false
+    assertNull(settledOutcome.get());
+  }
+
+  @Test
+  public void testCheckpointTimeout_WithNackEnabled() throws Exception {
+    AtomicReference<Outcome> settledOutcome = new AtomicReference<>();
+
+    SerializableFunction<Integer, BytesXMLMessage> recordFn =
+        index -> {
+          if (index == 0) {
+            return SolaceDataUtils.getBytesXmlMessageWithSettle(
+                "payload_test0", "450", null, settledOutcome::set);
+          }
+          return null;
+        };
+
+    MockSessionServiceFactory fakeSessionServiceFactory =
+        
MockSessionServiceFactory.builder().recordFn(recordFn).minMessagesReceived(1).build();
+
+    UnboundedSolaceSource<Solace.Record> source =
+        new UnboundedSolaceSource<>(
+            
com.solacesystems.jcsmp.JCSMPFactory.onlyInstance().createQueue("queue"),
+            MockSempClientFactory.getDefaultMock(),
+            fakeSessionServiceFactory,
+            1, // maxNumConnections
+            false, // enableDeduplication
+            null, // coder
+            input -> org.joda.time.Instant.ofEpochMilli(1000L), // timestampFn
+            org.joda.time.Duration.standardSeconds(1), // 
watermarkIdleDurationThreshold
+            input -> SolaceDataUtils.getSolaceRecord("payload_test0", "450"), 
// parseFn
+            org.joda.time.Duration.standardSeconds(30), // ackDeadline
+            true // nackOnTimeout
+            );
+
+    UnboundedSolaceReader<Solace.Record> reader =
+        (UnboundedSolaceReader<Solace.Record>)
+            source.createReader(PipelineOptionsFactory.create(), null);
+
+    AtomicReference<Long> currentTime = new AtomicReference<>(1000L);
+    reader.clock = currentTime::get;
+
+    assertTrue(reader.start());
+
+    // Create a checkpoint (T1)
+    reader.getCheckpointMark();
+    assertNull(settledOutcome.get());
+
+    // Advance time by 10 seconds (less than 30s timeout)
+    currentTime.set(11000L);
+    reader.advance();
+    assertNull(settledOutcome.get());
+
+    // Advance time by 21 more seconds (total 31 seconds, > 30s timeout)
+    currentTime.set(32000L);
+    reader.advance(); // Triggers NACK
+
+    // Wait for async nack to complete
+    reader.close();
+
+    // Verify that the message was Nacked with FAILED outcome
+    assertEquals(Outcome.FAILED, settledOutcome.get());
+  }
+
+  @Test
+  public void testCheckpointTimeout_NackExceptionHandledGracefully() throws 
Exception {
+    AtomicBoolean settleAttempted = new AtomicBoolean(false);
+
+    SerializableFunction<Integer, BytesXMLMessage> recordFn =
+        index -> {
+          if (index == 0) {
+            return SolaceDataUtils.getBytesXmlMessageWithSettle(
+                "payload_test0",
+                "450",
+                null,
+                outcome -> {
+                  settleAttempted.set(true);
+                  throw new InvalidOperationException("Flow does not support 
outcome FAILED");
+                });
+          }
+          return null;
+        };
+
+    MockSessionServiceFactory fakeSessionServiceFactory =
+        
MockSessionServiceFactory.builder().recordFn(recordFn).minMessagesReceived(1).build();
+
+    UnboundedSolaceSource<Solace.Record> source =
+        new UnboundedSolaceSource<>(
+            
com.solacesystems.jcsmp.JCSMPFactory.onlyInstance().createQueue("queue"),
+            MockSempClientFactory.getDefaultMock(),
+            fakeSessionServiceFactory,
+            1, // maxNumConnections
+            false, // enableDeduplication
+            null, // coder
+            input -> org.joda.time.Instant.ofEpochMilli(1000L), // timestampFn
+            org.joda.time.Duration.standardSeconds(1), // 
watermarkIdleDurationThreshold
+            input -> SolaceDataUtils.getSolaceRecord("payload_test0", "450"), 
// parseFn
+            org.joda.time.Duration.standardSeconds(30), // ackDeadline
+            true // nackOnTimeout
+            );
+
+    UnboundedSolaceReader<Solace.Record> reader =
+        (UnboundedSolaceReader<Solace.Record>)
+            source.createReader(PipelineOptionsFactory.create(), null);
+
+    AtomicReference<Long> currentTime = new AtomicReference<>(1000L);
+    reader.clock = currentTime::get;
+
+    assertTrue(reader.start());
+
+    // Create a checkpoint (T1)
+    reader.getCheckpointMark();
+
+    // Advance time past deadline
+    currentTime.set(32000L);
+    reader.advance(); // Triggers NACK which throws InvalidOperationException 
asynchronously
+
+    // Reader close gracefully shuts down executor without throwing unhandled 
exceptions
+    reader.close();
+
+    assertTrue(settleAttempted.get());
+  }
+}

Reply via email to