This is an automated email from the ASF dual-hosted git repository.
freeznet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar-connectors.git
The following commit(s) were added to refs/heads/master by this push:
new 03a4a30a feat(kinesis): add message key mode for Kinesis records (#35)
03a4a30a is described below
commit 03a4a30a1fce544a73a77b437b4d693ce39e4c19
Author: Rui Fu <[email protected]>
AuthorDate: Sun Jul 12 10:05:16 2026 +0800
feat(kinesis): add message key mode for Kinesis records (#35)
---
.../apache/pulsar/io/kinesis/KinesisRecord.java | 12 +-
.../pulsar/io/kinesis/KinesisRecordProcessor.java | 144 +++++++++++++++----
.../pulsar/io/kinesis/KinesisSourceConfig.java | 26 ++++
.../io/kinesis/KinesisRecordProcessorTest.java | 157 ++++++++++++++++++---
.../pulsar/io/kinesis/KinesisRecordTest.java | 39 +++++
.../pulsar/io/kinesis/KinesisSourceConfigTest.java | 25 ++++
6 files changed, 359 insertions(+), 44 deletions(-)
diff --git
a/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisRecord.java
b/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisRecord.java
index 83530175..730bdfc8 100644
--- a/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisRecord.java
+++ b/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisRecord.java
@@ -43,9 +43,19 @@ public class KinesisRecord implements Record<byte[]> {
private final long subSequenceNumber;
private final KinesisRecordProcessor recordProcessor;
+ /** Convenience overload that defaults to {@link
KinesisSourceConfig.MessageKeyMode#PARTITION_KEY}. */
public KinesisRecord(KinesisClientRecord record, String shardId, long
millisBehindLatest,
Set<String> propertiesToInclude,
KinesisRecordProcessor recordProcessor) {
- this.key = Optional.of(record.partitionKey());
+ this(record, shardId, millisBehindLatest, propertiesToInclude,
recordProcessor,
+ KinesisSourceConfig.MessageKeyMode.PARTITION_KEY);
+ }
+
+ public KinesisRecord(KinesisClientRecord record, String shardId, long
millisBehindLatest,
+ Set<String> propertiesToInclude,
KinesisRecordProcessor recordProcessor,
+ KinesisSourceConfig.MessageKeyMode messageKeyMode) {
+ this.key = (messageKeyMode ==
KinesisSourceConfig.MessageKeyMode.SHARD_ID)
+ ? Optional.of(shardId)
+ : Optional.of(record.partitionKey());
this.sequenceNumber = record.sequenceNumber();
this.subSequenceNumber = record.subSequenceNumber();
this.recordProcessor = recordProcessor;
diff --git
a/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisRecordProcessor.java
b/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisRecordProcessor.java
index daee5041..c0879786 100644
---
a/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisRecordProcessor.java
+++
b/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisRecordProcessor.java
@@ -18,8 +18,11 @@
*/
package org.apache.pulsar.io.kinesis;
+import java.util.ArrayDeque;
+import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -50,13 +53,25 @@ public class KinesisRecordProcessor implements
ShardRecordProcessor {
private final LinkedBlockingQueue<KinesisRecord> queue;
private final SourceContext sourceContext;
private final Set<String> propertiesToInclude;
+ private final KinesisSourceConfig.MessageKeyMode messageKeyMode;
private final ScheduledExecutorService checkpointExecutor;
private final AtomicReference<RecordProcessorCheckpointer> checkpointerRef
= new AtomicReference<>();
private final AtomicBoolean isCheckpointing = new AtomicBoolean(false);
private String kinesisShardId;
private final AtomicInteger numRecordsInFlight = new AtomicInteger(0);
- private volatile CheckpointSequenceNumber sequenceNumberNeedToCheckpoint =
null;
+ // All checkpoint-position state below is guarded by checkpointLock. Acks
arrive concurrently on the
+ // downstream sink's send-completion callback threads, processRecords runs
on the KCL thread, and
+ // triggerCheckpoint runs on the checkpointExecutor thread, so the
deque/set/highest-acked triple must
+ // be mutated atomically as a group.
+ private final Object checkpointLock = new Object();
+ // Positions appended in processRecords, in KCL delivery (sequence) order.
+ private final ArrayDeque<CheckpointSequenceNumber> deliveredInOrder = new
ArrayDeque<>();
+ // Positions acked but not yet collapsed into the contiguous prefix (acked
ahead of an earlier gap).
+ private final HashSet<CheckpointSequenceNumber> ackedAwaitingCollapse =
new HashSet<>();
+ // The highest position whose entire delivery-order prefix has been acked.
The ONLY thing we checkpoint.
+ private CheckpointSequenceNumber highestContiguousAcked = null;
+
private volatile CheckpointSequenceNumber lastCheckpointSequenceNumber =
null;
public KinesisRecordProcessor(LinkedBlockingQueue<KinesisRecord> queue,
KinesisSourceConfig config,
@@ -66,6 +81,7 @@ public class KinesisRecordProcessor implements
ShardRecordProcessor {
this.numRetries = config.getNumRetries();
this.backoffTime = config.getBackoffTime();
this.propertiesToInclude = config.getPropertiesToInclude();
+ this.messageKeyMode = config.getMessageKeyMode();
this.sourceContext = sourceContext;
this.checkpointExecutor = checkpointExecutor;
}
@@ -78,36 +94,76 @@ public class KinesisRecordProcessor implements
ShardRecordProcessor {
checkpointer.checkpoint(checkpoint.sequenceNumber(),
checkpoint.subSequenceNumber());
lastCheckpointSequenceNumber = checkpoint;
log.info("Successfully checkpointed shard {} at {}",
kinesisShardId, checkpoint);
- isCheckpointing.set(false);
- checkpointExecutor.schedule(this::triggerCheckpoint,
checkpointInterval, TimeUnit.MILLISECONDS);
+ scheduleNextCheckpoint();
} catch (ThrottlingException | KinesisClientLibDependencyException e) {
if (attempt >= numRetries) {
- log.error("Checkpoint for shard {} failed after {} attempts at
{}. Terminating.",
- kinesisShardId, numRetries, checkpoint, e);
- sourceContext.fatal(e);
+ // A failed periodic checkpoint is recoverable: skip this
round instead of terminating the
+ // connector, and let the next scheduled checkpoint retry with
the latest acked position.
+ log.error("Checkpoint for shard {} failed after {} attempts at
{}. Skipping this round; "
+ + "will retry on the next interval.", kinesisShardId,
numRetries, checkpoint, e);
+ scheduleNextCheckpoint();
} else {
log.warn("Throttling/Dependency error on checkpoint for shard
{} at {}. Scheduling retry {} "
+ "after {}ms.", kinesisShardId, checkpoint,
attempt + 1, backoffTime);
- checkpointExecutor.schedule(() ->
tryCheckpointWithRetry(checkpointer, checkpoint, attempt + 1),
- backoffTime, TimeUnit.MILLISECONDS);
+ safeSchedule(() -> tryCheckpointWithRetry(checkpointer,
checkpoint, attempt + 1), backoffTime);
}
+ } catch (IllegalArgumentException e) {
+ // KCL rejects a backward / out-of-range checkpoint with
IllegalArgumentException. With the
+ // contiguous-prefix logic this should no longer happen, so
surface it as a metric for alerting,
+ // but still skip-and-reschedule rather than crashing the
connector.
+ log.warn("KCL rejected a backward/out-of-range checkpoint for
shard {} at {}. Skipping this round; "
+ + "will retry on the next interval.", kinesisShardId,
checkpoint, e);
+ sourceContext.recordMetric("kinesisBackwardCheckpointDetected", 1);
+ scheduleNextCheckpoint();
} catch (Exception e) {
- log.error("Caught a non-retryable exception for shard {} during
checkpoint at {}. Terminating.",
- kinesisShardId, checkpoint, e);
- sourceContext.fatal(e);
+ // Other non-retryable errors must NOT crash the connector. Skip
this round; a later ack with a
+ // higher position will advance the checkpoint on a subsequent
cycle.
+ log.error("Non-retryable exception during checkpoint for shard {}
at {}. Skipping this round; "
+ + "will retry on the next interval.", kinesisShardId,
checkpoint, e);
+ scheduleNextCheckpoint();
+ }
+ }
+
+ private void scheduleNextCheckpoint() {
+ isCheckpointing.set(false);
+ safeSchedule(this::triggerCheckpoint, checkpointInterval);
+ }
+
+ private void safeSchedule(Runnable task, long delayMillis) {
+ try {
+ checkpointExecutor.schedule(task, delayMillis,
TimeUnit.MILLISECONDS);
+ } catch (RejectedExecutionException e) {
+ // The checkpoint executor has been shut down (connector is
closing); nothing left to schedule.
+ log.debug("Checkpoint executor rejected a task for shard {}
(likely shutting down).", kinesisShardId);
}
}
public void updateSequenceNumberToCheckpoint(String sequenceNumber, long
subSequenceNumber) {
- CheckpointSequenceNumber newCheckpoint = new
CheckpointSequenceNumber(sequenceNumber, subSequenceNumber);
- log.debug("{} Updating sequence number to checkpoint {}",
kinesisShardId, newCheckpoint);
- this.sequenceNumberNeedToCheckpoint = newCheckpoint;
+ CheckpointSequenceNumber acked = new
CheckpointSequenceNumber(sequenceNumber, subSequenceNumber);
+ log.debug("{} Record acked at {}", kinesisShardId, acked);
+ synchronized (checkpointLock) {
+ ackedAwaitingCollapse.add(acked);
+ // Collapse the longest contiguous, fully-acked prefix (in
delivery order) into highestContiguousAcked.
+ // Stop at the first un-acked head: we must never advance the
checkpoint past a still-in-flight
+ // lower sequence, otherwise a crash would resume AFTER it and
lose it.
+ while (!deliveredInOrder.isEmpty()) {
+ CheckpointSequenceNumber head = deliveredInOrder.peekFirst();
+ if (!ackedAwaitingCollapse.remove(head)) {
+ break;
+ }
+ deliveredInOrder.pollFirst();
+ highestContiguousAcked = head;
+ }
+ }
this.numRecordsInFlight.decrementAndGet();
}
public void failed() {
+ // Intentionally do NOT remove this record's position from
deliveredInOrder: a record that failed to
+ // be delivered downstream must keep blocking the contiguous-prefix
collapse so the checkpoint can
+ // never advance past it. fatal() terminates the connector, which
reprocesses from the last checkpoint.
numRecordsInFlight.decrementAndGet();
- sourceContext.fatal(new PulsarClientException("Failed to process
Kinesis records due send to pulsar topic"));
+ sourceContext.fatal(new PulsarClientException("Failed to send Kinesis
record to Pulsar topic"));
}
@Override
@@ -116,25 +172,37 @@ public class KinesisRecordProcessor implements
ShardRecordProcessor {
log.info("Initializing KinesisRecordProcessor for shard {},
extendedSequenceNumber: {}, pendingCheckSeq: {}",
kinesisShardId, initializationInput.extendedSequenceNumber(),
initializationInput.pendingCheckpointSequenceNumber());
- checkpointExecutor.schedule(this::triggerCheckpoint,
checkpointInterval, TimeUnit.MILLISECONDS);
+ safeSchedule(this::triggerCheckpoint, checkpointInterval);
}
private void triggerCheckpoint() {
try {
if (isCheckpointing.compareAndSet(false, true)) {
final RecordProcessorCheckpointer checkpointer =
checkpointerRef.get();
- final CheckpointSequenceNumber currentCheckpoint =
this.sequenceNumberNeedToCheckpoint;
+ final CheckpointSequenceNumber currentCheckpoint;
+ final int pendingGap;
+ synchronized (checkpointLock) {
+ currentCheckpoint = this.highestContiguousAcked;
+ pendingGap = deliveredInOrder.size();
+ }
+ // Emit metrics outside the lock. A rising
kinesisCheckpointPendingGap means acks are stalled
+ // behind a gap (a data-loss exposure under the old
last-writer-wins code; here it only delays
+ // the checkpoint from advancing).
+ sourceContext.recordMetric("kinesisRecordsInFlight",
numRecordsInFlight.get());
+ sourceContext.recordMetric("kinesisCheckpointPendingGap",
pendingGap);
if (checkpointer != null && currentCheckpoint != null &&
!currentCheckpoint.equals(
lastCheckpointSequenceNumber)) {
tryCheckpointWithRetry(checkpointer, currentCheckpoint, 1);
} else {
- isCheckpointing.set(false);
- checkpointExecutor.schedule(this::triggerCheckpoint,
checkpointInterval, TimeUnit.MILLISECONDS);
+ scheduleNextCheckpoint();
}
}
} catch (Throwable e) {
- log.error("Error while triggering checkpoint for shard {}.
Terminating.", kinesisShardId, e);
- sourceContext.fatal(e);
+ // Defensive: never let a checkpoint-trigger failure crash the
connector. Keep the loop alive
+ // so the next scheduled cycle can make progress.
+ log.error("Unexpected error while triggering checkpoint for shard
{}. Skipping this round.",
+ kinesisShardId, e);
+ scheduleNextCheckpoint();
}
}
@@ -147,12 +215,24 @@ public class KinesisRecordProcessor implements
ShardRecordProcessor {
for (KinesisClientRecord record : processRecordsInput.records()) {
log.debug("Add record with sequence number {}:{} to queue for
shard {}.",
record.sequenceNumber(), record.subSequenceNumber(),
kinesisShardId);
+ // Register the delivery-order position BEFORE the record can be
read/acked from the queue, so a
+ // fast ack can never race ahead of its own deliveredInOrder
entry. KCL delivers each position at
+ // most once to a given processor instance, so no de-duplication
is needed here. queue.put may
+ // block, so the lock is released before the put.
+ synchronized (checkpointLock) {
+ deliveredInOrder.addLast(
+ new CheckpointSequenceNumber(record.sequenceNumber(),
record.subSequenceNumber()));
+ }
try {
queue.put(new KinesisRecord(record, this.kinesisShardId,
millisBehindLatest,
- propertiesToInclude, this));
+ propertiesToInclude, this, messageKeyMode));
} catch (Exception e) {
+ // The record never made it onto the queue, so it will never
be read or acked. Its position
+ // stays in deliveredInOrder so the contiguous prefix cannot
advance past this gap (no data
+ // loss); fatal() terminates the connector, which then
reprocesses from the last checkpoint.
log.error("Unable to create and queue KinesisRecord for shard
{}.", kinesisShardId, e);
sourceContext.fatal(e);
+ return;
}
numRecordsInFlight.incrementAndGet();
}
@@ -169,7 +249,15 @@ public class KinesisRecordProcessor implements
ShardRecordProcessor {
numRecordsInFlight.get(), kinesisShardId);
try {
for (int i = 0; i < numRetries; i++) {
- if (numRecordsInFlight.get() == 0) {
+ // "Fully processed" means the entire delivery-order prefix
has been acked (deque empty), not
+ // merely that in-flight reached 0: a record that failed
downstream decrements in-flight but
+ // intentionally keeps its position in deliveredInOrder.
Gating SHARD_END on deque-empty keeps
+ // the full checkpoint() from ever marking the shard complete
while a gap remains.
+ boolean drained;
+ synchronized (checkpointLock) {
+ drained = deliveredInOrder.isEmpty();
+ }
+ if (drained) {
processedInTime = true;
break;
}
@@ -197,14 +285,18 @@ public class KinesisRecordProcessor implements
ShardRecordProcessor {
} else {
log.warn("Not all records for shard {} were processed or not a
shard end. "
+ "Performing best-effort checkpoint.",
kinesisShardId);
- final CheckpointSequenceNumber finalCheckpoint =
this.sequenceNumberNeedToCheckpoint;
+ final CheckpointSequenceNumber finalCheckpoint;
+ synchronized (checkpointLock) {
+ finalCheckpoint = this.highestContiguousAcked;
+ }
if (finalCheckpoint != null) {
checkpointer.checkpoint(finalCheckpoint.sequenceNumber(),
finalCheckpoint.subSequenceNumber());
}
}
} catch (Exception e) {
+ // Defensive: a failed final checkpoint must not crash the
connector. Under at-least-once
+ // semantics the un-checkpointed records will simply be
reprocessed by the next lease owner.
log.error("Failed to perform final checkpoint for shard {}. Data
may be reprocessed.", kinesisShardId, e);
- sourceContext.fatal(e);
}
}
@@ -219,4 +311,4 @@ public class KinesisRecordProcessor implements
ShardRecordProcessor {
log.info("Shutdown requested for shard {}, starting final checkpoint
process.", kinesisShardId);
finalizeAndCheckpoint(shutdownRequestedInput.checkpointer(), false);
}
-}
\ No newline at end of file
+}
diff --git
a/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisSourceConfig.java
b/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisSourceConfig.java
index ebcd06bc..01d17af1 100644
---
a/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisSourceConfig.java
+++
b/kinesis/src/main/java/org/apache/pulsar/io/kinesis/KinesisSourceConfig.java
@@ -144,6 +144,32 @@ public class KinesisSourceConfig extends BaseKinesisConfig
implements Serializab
+ "kinesis.partition.key,kinesis.sequence.number";
private transient Set<String> propertiesToInclude;
+ /**
+ * Determines which value is used as the Pulsar message key for each
Kinesis record.
+ */
+ public enum MessageKeyMode {
+ /**
+ * Use the Kinesis record partition key (default; preserves existing
downstream partitioning).
+ */
+ PARTITION_KEY,
+ /**
+ * Use the Kinesis shard ID as the message key, routing all records
from the same shard to
+ * the same downstream partition. This preserves per-shard ordering
and reduces checkpoint
+ * reordering gaps, but may create hot partitions if shard traffic is
skewed.
+ */
+ SHARD_ID
+ }
+
+ @FieldDoc(
+ required = false,
+ defaultValue = "PARTITION_KEY",
+ help = "Which value to use as the Pulsar message key for each record. "
+ + "PARTITION_KEY (default) uses the Kinesis record partition
key (preserves existing downstream "
+ + "partitioning). SHARD_ID routes all records of a Kinesis
shard to the same key, keeping them on "
+ + "one downstream partition to preserve per-shard ordering and
reduce checkpoint reordering gaps; "
+ + "note this changes downstream partition distribution and may
create hot partitions.")
+ private MessageKeyMode messageKeyMode = MessageKeyMode.PARTITION_KEY;
+
public static KinesisSourceConfig load(Map<String, Object> config,
SourceContext sourceContext) {
KinesisSourceConfig kinesisSourceConfig =
IOConfigUtils.loadWithSecrets(config,
KinesisSourceConfig.class, sourceContext);
diff --git
a/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisRecordProcessorTest.java
b/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisRecordProcessorTest.java
index 70c04fa8..52a4b805 100644
---
a/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisRecordProcessorTest.java
+++
b/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisRecordProcessorTest.java
@@ -41,6 +41,7 @@ import
software.amazon.kinesis.lifecycle.events.InitializationInput;
import software.amazon.kinesis.lifecycle.events.ProcessRecordsInput;
import software.amazon.kinesis.lifecycle.events.ShardEndedInput;
import software.amazon.kinesis.lifecycle.events.ShutdownRequestedInput;
+import software.amazon.kinesis.exceptions.ThrottlingException;
import software.amazon.kinesis.processor.RecordProcessorCheckpointer;
import software.amazon.kinesis.retrieval.KinesisClientRecord;
@@ -67,6 +68,7 @@ public class KinesisRecordProcessorTest {
when(config.getNumRetries()).thenReturn(1);
when(config.getBackoffTime()).thenReturn(100L);
when(config.getPropertiesToInclude()).thenReturn(Collections.emptySet());
+
when(config.getMessageKeyMode()).thenReturn(KinesisSourceConfig.MessageKeyMode.PARTITION_KEY);
recordProcessor = new KinesisRecordProcessor(queue, config,
sourceContext, checkpointExecutor);
}
@@ -93,32 +95,105 @@ public class KinesisRecordProcessorTest {
}
@Test
- public void testCheckpointLogicOnlyAdvancesForward() throws Exception {
- // Arrange
+ public void testOutOfOrderAcksDoNotAdvancePastGap() throws Exception {
+ // Arrange: deliver three records of the same shard IN ORDER via
processRecords. KCL delivery order
+ // is authoritative, so the contiguous prefix is seq-1/0 -> seq-1/1 ->
seq-1/2.
recordProcessor.initialize(createMockInitializationInput());
verify(checkpointExecutor, Mockito.times(1))
.schedule(scheduledTaskCaptor.capture(), anyLong(),
any(TimeUnit.class));
Runnable scheduledCheckpointTask = scheduledTaskCaptor.getValue();
- ProcessRecordsInput processRecordsInput =
createMockProcessRecordsInput(checkpointer);
- recordProcessor.processRecords(processRecordsInput);
+ recordProcessor.processRecords(createMockProcessRecordsInput(
+ createMockKinesisRecord("seq-1", 0L),
+ createMockKinesisRecord("seq-1", 1L),
+ createMockKinesisRecord("seq-1", 2L)));
- // Act & Assert 1: Ack a later sub-sequence number first.
- recordProcessor.updateSequenceNumberToCheckpoint("seq-1", 5L);
+ // Act & Assert 1: ack ONLY the highest (seq-1/2). There is still a
gap at seq-1/0, so the
+ // contiguous prefix is empty and nothing must be checkpointed.
+ recordProcessor.updateSequenceNumberToCheckpoint("seq-1", 2L);
+ scheduledCheckpointTask.run();
+ verify(checkpointer, never()).checkpoint(any(String.class), anyLong());
+
+ // Act & Assert 2: ack the lowest (seq-1/0). The contiguous prefix now
covers exactly seq-1/0,
+ // because seq-1/1 is still un-acked. Checkpoint must be seq-1/0, NOT
the already-acked seq-1/2.
+ recordProcessor.updateSequenceNumberToCheckpoint("seq-1", 0L);
+ scheduledCheckpointTask.run();
+ verify(checkpointer, times(1)).checkpoint("seq-1", 0L);
+
+ // Act & Assert 3: ack the middle (seq-1/1). The prefix now collapses
through seq-1/1 and the
+ // already-acked seq-1/2, advancing the checkpoint to seq-1/2.
+ recordProcessor.updateSequenceNumberToCheckpoint("seq-1", 1L);
+ scheduledCheckpointTask.run();
+ verify(checkpointer, times(1)).checkpoint("seq-1", 2L);
+ }
+
+ @Test
+ public void testOutOfOrderAcksAcrossSequenceNumbers() throws Exception {
+ // Distinct sequence numbers are delivered in order seq-100, seq-101,
seq-102.
+ recordProcessor.initialize(createMockInitializationInput());
+ verify(checkpointExecutor, Mockito.times(1))
+ .schedule(scheduledTaskCaptor.capture(), anyLong(),
any(TimeUnit.class));
+ Runnable scheduledCheckpointTask = scheduledTaskCaptor.getValue();
+ recordProcessor.processRecords(createMockProcessRecordsInput(
+ createMockKinesisRecord("seq-100", 0L),
+ createMockKinesisRecord("seq-101", 0L),
+ createMockKinesisRecord("seq-102", 0L)));
+
+ // Ack 102 then 100; 101 is still in-flight. The checkpoint must be
the contiguous head seq-100,
+ // never the higher seq-102 (which would skip the un-acked seq-101 and
lose it on resume).
+ recordProcessor.updateSequenceNumberToCheckpoint("seq-102", 0L);
+ recordProcessor.updateSequenceNumberToCheckpoint("seq-100", 0L);
+ scheduledCheckpointTask.run();
+ verify(checkpointer, times(1)).checkpoint("seq-100", 0L);
+ verify(checkpointer, never()).checkpoint("seq-102", 0L);
+
+ // Now ack 101: the prefix collapses through 101 and the already-acked
102, advancing to seq-102.
+ recordProcessor.updateSequenceNumberToCheckpoint("seq-101", 0L);
scheduledCheckpointTask.run();
- verify(checkpointer, times(1)).checkpoint("seq-1", 5L);
+ verify(checkpointer, times(1)).checkpoint("seq-102", 0L);
+ }
+
+ @Test
+ public void testNonRetryableCheckpointFailureDoesNotCrashConnector()
throws Exception {
+ // Arrange: initialize schedules the first checkpoint task.
+ recordProcessor.initialize(createMockInitializationInput());
+ verify(checkpointExecutor, times(1))
+ .schedule(scheduledTaskCaptor.capture(), anyLong(),
any(TimeUnit.class));
+ Runnable scheduledCheckpointTask = scheduledTaskCaptor.getValue();
+
recordProcessor.processRecords(createMockProcessRecordsInput(checkpointer));
+
+ // Simulate KCL rejecting a backward / out-of-range checkpoint (an
IllegalArgumentException).
+ Mockito.doThrow(new IllegalArgumentException("Could not checkpoint ...
did not fall into acceptable range"))
+ .when(checkpointer).checkpoint(any(String.class), anyLong());
+ recordProcessor.updateSequenceNumberToCheckpoint("seq-1", 5L);
- // Act & Assert 2: Ack an earlier (out-of-order) sub-sequence number.
- // The checkpointToCommit is now older than
lastSuccessfullyCheckpointed.
- recordProcessor.updateSequenceNumberToCheckpoint("seq-1", 3L);
+ // Act
scheduledCheckpointTask.run();
- // Verify checkpoint was NOT called again, because the position is not
new.
- verify(checkpointer, times(1)).checkpoint("seq-1", 5L);
- // Act & Assert 3: Ack a new, even later sub-sequence number.
- recordProcessor.updateSequenceNumberToCheckpoint("seq-1", 7L);
+ // Assert: the connector must not be terminated, and the checkpoint
loop must be rescheduled
+ // (one schedule from initialize + one from the failed round).
+ verify(sourceContext, never()).fatal(any());
+ verify(checkpointExecutor, times(2)).schedule(any(Runnable.class),
anyLong(), any(TimeUnit.class));
+ }
+
+ @Test
+ public void testExhaustedRetriableCheckpointFailureDoesNotCrashConnector()
throws Exception {
+ // Arrange: numRetries == 1 (see setup), so the first throttling
failure already exhausts retries.
+ recordProcessor.initialize(createMockInitializationInput());
+ verify(checkpointExecutor, times(1))
+ .schedule(scheduledTaskCaptor.capture(), anyLong(),
any(TimeUnit.class));
+ Runnable scheduledCheckpointTask = scheduledTaskCaptor.getValue();
+
recordProcessor.processRecords(createMockProcessRecordsInput(checkpointer));
+
+ Mockito.doThrow(new ThrottlingException("throttled"))
+ .when(checkpointer).checkpoint(any(String.class), anyLong());
+ recordProcessor.updateSequenceNumberToCheckpoint("seq-1", 1L);
+
+ // Act
scheduledCheckpointTask.run();
- // Verify checkpoint is now called with the new, advanced position.
- verify(checkpointer, times(1)).checkpoint("seq-1", 7L);
+
+ // Assert: retries exhausted, but the connector survives and the loop
is rescheduled.
+ verify(sourceContext, never()).fatal(any());
+ verify(checkpointExecutor, times(2)).schedule(any(Runnable.class),
anyLong(), any(TimeUnit.class));
}
@Test(timeOut = 3000)
@@ -160,6 +235,54 @@ public class KinesisRecordProcessorTest {
verify(shutdownCheckpointer, times(1)).checkpoint("seq-shutdown-1",
0L);
}
+ @Test(timeOut = 5000)
+ public void testShardEndedAllAckedPerformsFullCheckpoint() throws
Exception {
+ // Arrange: deliver two records and ack BOTH, so the contiguous prefix
fully drains (deque empty).
+ recordProcessor.processRecords(createMockProcessRecordsInput(
+ createMockKinesisRecord("seq-A", 0L),
+ createMockKinesisRecord("seq-B", 1L)
+ ));
+ queue.take().ack();
+ queue.take().ack();
+
+ RecordProcessorCheckpointer shardEndCheckpointer =
Mockito.mock(RecordProcessorCheckpointer.class);
+ ShardEndedInput shardEndedInput =
createMockShardEndedInput(shardEndCheckpointer);
+
+ // Act
+ recordProcessor.shardEnded(shardEndedInput);
+
+ // Assert: everything is acked, so KCL is told the shard is fully
consumed via the no-arg checkpoint().
+ verify(shardEndCheckpointer, times(1)).checkpoint();
+ verify(shardEndCheckpointer, never()).checkpoint(any(String.class),
anyLong());
+ }
+
+ @Test(timeOut = 5000)
+ public void testShardEndedWithGapDoesNotPerformFullCheckpoint() throws
Exception {
+ // Arrange: deliver three records; ack the 1st and 3rd, fail the 2nd.
in-flight reaches 0 but a gap
+ // (seq-B) remains in the delivery-order deque, so SHARD_END must NOT
mark the shard fully consumed.
+ recordProcessor.processRecords(createMockProcessRecordsInput(
+ createMockKinesisRecord("seq-A", 0L),
+ createMockKinesisRecord("seq-B", 1L),
+ createMockKinesisRecord("seq-C", 2L)
+ ));
+ KinesisRecord recA = queue.take();
+ KinesisRecord recB = queue.take();
+ KinesisRecord recC = queue.take();
+ recA.ack();
+ recC.ack(); // acked ahead of the gap; must not collapse past the
un-acked seq-B
+ recB.fail(); // sourceContext is a mock, so fatal() is a no-op and
the processor keeps running
+
+ RecordProcessorCheckpointer shardEndCheckpointer =
Mockito.mock(RecordProcessorCheckpointer.class);
+ ShardEndedInput shardEndedInput =
createMockShardEndedInput(shardEndCheckpointer);
+
+ // Act
+ recordProcessor.shardEnded(shardEndedInput);
+
+ // Assert: best-effort checkpoint at the contiguous prefix (seq-A)
only; no full SHARD_END checkpoint.
+ verify(shardEndCheckpointer, times(1)).checkpoint("seq-A", 0L);
+ verify(shardEndCheckpointer, never()).checkpoint();
+ }
+
private KinesisClientRecord createMockKinesisRecord(String sequenceNumber,
long subSequenceNumber) {
KinesisClientRecord mockRecord =
Mockito.mock(KinesisClientRecord.class);
when(mockRecord.partitionKey()).thenReturn("test-key");
@@ -198,4 +321,4 @@ public class KinesisRecordProcessorTest {
when(input.checkpointer()).thenReturn(checkpointer);
return input;
}
-}
\ No newline at end of file
+}
diff --git
a/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisRecordTest.java
b/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisRecordTest.java
index 6383cbab..29ba6bfd 100644
--- a/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisRecordTest.java
+++ b/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisRecordTest.java
@@ -110,4 +110,43 @@ public class KinesisRecordTest {
assertTrue(properties.isEmpty());
}
+
+ // --- MessageKeyMode tests ---
+
+ @Test
+ public void testDefaultKeyModeUsesPartitionKey() {
+ // The 5-arg constructor defaults to PARTITION_KEY.
+ KinesisRecord record = new KinesisRecord(mockRecord, shardId,
millisBehindLatest,
+ Collections.emptySet(), null);
+ assertTrue(record.getKey().isPresent());
+ assertEquals(record.getKey().get(), partitionKey);
+ }
+
+ @Test
+ public void testExplicitPartitionKeyMode() {
+ KinesisRecord record = new KinesisRecord(mockRecord, shardId,
millisBehindLatest,
+ Collections.emptySet(), null,
KinesisSourceConfig.MessageKeyMode.PARTITION_KEY);
+ assertTrue(record.getKey().isPresent());
+ assertEquals(record.getKey().get(), partitionKey);
+ }
+
+ @Test
+ public void testShardIdKeyMode() {
+ KinesisRecord record = new KinesisRecord(mockRecord, shardId,
millisBehindLatest,
+ Collections.emptySet(), null,
KinesisSourceConfig.MessageKeyMode.SHARD_ID);
+ assertTrue(record.getKey().isPresent());
+ assertEquals(record.getKey().get(), shardId);
+ }
+
+ @Test
+ public void testShardIdKeyModeDoesNotAffectPartitionKeyProperty() {
+ // Even in SHARD_ID mode, the kinesis.partition.key *property* must
still reflect the
+ // original record partition key (property semantics are independent
of the message key).
+ Set<String> propertiesToInclude =
Collections.singleton(KinesisRecord.PARTITION_KEY);
+ KinesisRecord record = new KinesisRecord(mockRecord, shardId,
millisBehindLatest,
+ propertiesToInclude, null,
KinesisSourceConfig.MessageKeyMode.SHARD_ID);
+ assertTrue(record.getKey().isPresent());
+ assertEquals(record.getKey().get(), shardId);
+ assertEquals(record.getProperties().get(KinesisRecord.PARTITION_KEY),
partitionKey);
+ }
}
\ No newline at end of file
diff --git
a/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisSourceConfigTest.java
b/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisSourceConfigTest.java
index 0cedb609..2db085d3 100644
---
a/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisSourceConfigTest.java
+++
b/kinesis/src/test/java/org/apache/pulsar/io/kinesis/KinesisSourceConfigTest.java
@@ -211,4 +211,29 @@ public class KinesisSourceConfigTest {
Set<String> properties = config.getPropertiesToInclude();
assertTrue(properties.isEmpty());
}
+
+ @Test
+ public final void messageKeyModeDefaultsToPartitionKey() {
+ Map<String, Object> map = new HashMap<>();
+ map.put("awsRegion", "us-west-1");
+ map.put("awsKinesisStreamName", "my-stream");
+ map.put("awsCredentialPluginParam",
"{\"accessKey\":\"myKey\",\"secretKey\":\"my-Secret\"}");
+
+ KinesisSourceConfig config = KinesisSourceConfig.load(map,
Mockito.mock(SourceContext.class));
+
+ assertEquals(config.getMessageKeyMode(),
KinesisSourceConfig.MessageKeyMode.PARTITION_KEY);
+ }
+
+ @Test
+ public final void messageKeyModeDeserializesShardIdFromString() {
+ Map<String, Object> map = new HashMap<>();
+ map.put("awsRegion", "us-west-1");
+ map.put("awsKinesisStreamName", "my-stream");
+ map.put("awsCredentialPluginParam",
"{\"accessKey\":\"myKey\",\"secretKey\":\"my-Secret\"}");
+ map.put("messageKeyMode", "SHARD_ID");
+
+ KinesisSourceConfig config = KinesisSourceConfig.load(map,
Mockito.mock(SourceContext.class));
+
+ assertEquals(config.getMessageKeyMode(),
KinesisSourceConfig.MessageKeyMode.SHARD_ID);
+ }
}
\ No newline at end of file