This is an automated email from the ASF dual-hosted git repository.
markap14 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 6f1722052b5 NIFI-16003 Re-copy checkpoints before deleting lingering
migration table (#11320)
6f1722052b5 is described below
commit 6f1722052b56c6ad6f932fcfa5d547b1848e0d9b
Author: Alaksiej Ščarbaty <[email protected]>
AuthorDate: Wed Jun 10 18:23:25 2026 +0200
NIFI-16003 Re-copy checkpoints before deleting lingering migration table
(#11320)
When a prior legacy-checkpoint migration failed mid-flight (e.g. step 5
copy threw because DynamoDB's request router still cached the old table
schema after recreation), the migration table was left behind as the
only surviving copy of the per-shard checkpoint state. On restart,
LegacyCheckpointMigrator.cleanupLingeringMigration would unconditionally
delete that table, silently losing all checkpoints and forcing every
shard back to INITIAL_STREAM_POSITION.
Re-run the copy from the migration table into the checkpoint table
before deleting it. This is safe because any failure in the rename
sequence propagates out of ensureCheckpointTableExists and prevents the
processor from starting, so the checkpoint table cannot have advanced
past the migration table's contents — the re-copy either restores lost
state or re-puts identical rows.
---
.../aws/kinesis/KinesisShardManager.java | 2 +-
.../aws/kinesis/LegacyCheckpointMigrator.java | 13 +++++--
.../aws/kinesis/KinesisShardManagerTest.java | 45 ++++++++++++++++++----
3 files changed, 49 insertions(+), 11 deletions(-)
diff --git
a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/main/java/org/apache/nifi/processors/aws/kinesis/KinesisShardManager.java
b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/main/java/org/apache/nifi/processors/aws/kinesis/KinesisShardManager.java
index 53fbfd41bd3..cfaf218ae5d 100644
---
a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/main/java/org/apache/nifi/processors/aws/kinesis/KinesisShardManager.java
+++
b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/main/java/org/apache/nifi/processors/aws/kinesis/KinesisShardManager.java
@@ -149,7 +149,7 @@ final class KinesisShardManager {
}
case NEW -> {
CheckpointTableUtils.waitForTableActive(dynamoDbClient,
logger, checkpointTableName);
- migrator.cleanupLingeringMigration();
+ migrator.completeLingeringMigration();
}
case LEGACY -> {
migrator.migrateAndRename();
diff --git
a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/main/java/org/apache/nifi/processors/aws/kinesis/LegacyCheckpointMigrator.java
b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/main/java/org/apache/nifi/processors/aws/kinesis/LegacyCheckpointMigrator.java
index d994094d2c4..3118b70d1a0 100644
---
a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/main/java/org/apache/nifi/processors/aws/kinesis/LegacyCheckpointMigrator.java
+++
b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/main/java/org/apache/nifi/processors/aws/kinesis/LegacyCheckpointMigrator.java
@@ -77,13 +77,20 @@ final class LegacyCheckpointMigrator {
return null;
}
- void cleanupLingeringMigration() {
+ void completeLingeringMigration() {
final String lingeringMigration = findMigrationTable();
if (lingeringMigration == null) {
return;
}
- logger.info("Deleting orphaned migration table [{}]; legacy checkpoint
table [{}] retains original data", lingeringMigration, checkpointTableName);
- CheckpointTableUtils.deleteTable(dynamoDbClient, logger,
lingeringMigration);
+ logger.info("Checkpoint table [{}] is in place but migration table
[{}] still exists from a prior rename; not all checkpoints may have been
migrated, re-copying before deletion",
+ checkpointTableName, lingeringMigration);
+ if (acquireRenameLock(lingeringMigration)) {
+ CheckpointTableUtils.waitForTableActive(dynamoDbClient, logger,
lingeringMigration);
+ CheckpointTableUtils.copyCheckpointItems(dynamoDbClient, logger,
lingeringMigration, checkpointTableName);
+ CheckpointTableUtils.deleteTable(dynamoDbClient, logger,
lingeringMigration);
+ } else {
+ waitForTableRenamed(lingeringMigration);
+ }
}
void migrateAndRename() {
diff --git
a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/test/java/org/apache/nifi/processors/aws/kinesis/KinesisShardManagerTest.java
b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/test/java/org/apache/nifi/processors/aws/kinesis/KinesisShardManagerTest.java
index 31c900d701f..c76ba094e3d 100644
---
a/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/test/java/org/apache/nifi/processors/aws/kinesis/KinesisShardManagerTest.java
+++
b/nifi-extension-bundles/nifi-aws-bundle/nifi-aws-kinesis/src/test/java/org/apache/nifi/processors/aws/kinesis/KinesisShardManagerTest.java
@@ -31,6 +31,7 @@ import
software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest;
import software.amazon.awssdk.services.dynamodb.model.DescribeTableResponse;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
+import
software.amazon.awssdk.services.dynamodb.model.InternalServerErrorException;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
import software.amazon.awssdk.services.dynamodb.model.KeyType;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
@@ -51,6 +52,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -306,12 +308,17 @@ class KinesisShardManagerTest {
}
/**
- * Verifies that when the configured table has the new schema but a
lingering migration
- * table exists (crash after copy but before migration table deletion),
the items are
- * copied and the migration table is cleaned up.
+ * Regression test for NIFI-16003: cleanup of a lingering migration table
must NOT delete
+ * the migration table unless the recovery copy succeeds. Previously it
was deleted
+ * unconditionally, causing checkpoint loss when the prior copy had failed
(e.g. due to
+ * a stale DynamoDB router schema cache after table recreation).
+ *
+ * Phase 1 forces the copy to fail and verifies the migration table is
preserved.
+ * Phase 2 retries with a working copy and verifies items are recopied and
the migration
+ * table is deleted.
*/
@Test
- void testEnsureCheckpointTableCleansUpLingeringMigrationTable() {
+ void testEnsureCheckpointTableRecoversCopyFromLingeringMigrationTable() {
when(dynamoDb.describeTable(any(DescribeTableRequest.class))).thenAnswer(invocation
-> {
final DescribeTableRequest req = invocation.getArgument(0);
if ("test-table".equals(req.tableName()) ||
"test-table_migration".equals(req.tableName())) {
@@ -321,14 +328,38 @@ class KinesisShardManagerTest {
});
when(dynamoDb.deleteTable(any(DeleteTableRequest.class))).thenReturn(DeleteTableResponse.builder().build());
-
when(dynamoDb.scan(any(ScanRequest.class))).thenReturn(ScanResponse.builder().build());
+
when(dynamoDb.updateItem(any(UpdateItemRequest.class))).thenReturn(UpdateItemResponse.builder().build());
+
+ final Map<String, AttributeValue> checkpointItem = Map.of(
+ "streamName",
AttributeValue.builder().s("test-stream").build(),
+ "shardId", AttributeValue.builder().s("shard-1").build(),
+ "sequenceNumber", AttributeValue.builder().s("12345").build());
+
when(dynamoDb.scan(any(ScanRequest.class))).thenReturn(ScanResponse.builder().items(checkpointItem).build());
+
+ final InternalServerErrorException copyFailure =
InternalServerErrorException.builder()
+ .message("simulated transient DynamoDB failure during
checkpoint copy")
+ .build();
+ when(dynamoDb.putItem(any(PutItemRequest.class)))
+ .thenThrow(copyFailure)
+ .thenReturn(PutItemResponse.builder().build());
+
+ assertThrows(InternalServerErrorException.class, () ->
manager.ensureCheckpointTableExists(),
+ "A failed recovery copy must propagate so the processor does
not start with missing checkpoints");
+ verify(dynamoDb, never()).deleteTable(any(DeleteTableRequest.class));
manager.ensureCheckpointTableExists();
- verify(dynamoDb, never()).createTable(any(CreateTableRequest.class));
+ final ArgumentCaptor<PutItemRequest> putCaptor =
ArgumentCaptor.forClass(PutItemRequest.class);
+ verify(dynamoDb, times(2)).putItem(putCaptor.capture());
+ for (final PutItemRequest req : putCaptor.getAllValues()) {
+ assertEquals("test-table", req.tableName(), "Recovery copies must
target the configured checkpoint table");
+ assertEquals("12345", req.item().get("sequenceNumber").s(),
"Checkpoint data must be preserved across the failed copy");
+ }
+
final ArgumentCaptor<DeleteTableRequest> deleteCaptor =
ArgumentCaptor.forClass(DeleteTableRequest.class);
verify(dynamoDb).deleteTable(deleteCaptor.capture());
- assertEquals("test-table_migration",
deleteCaptor.getValue().tableName(), "Lingering migration table should be
deleted");
+ assertEquals("test-table_migration",
deleteCaptor.getValue().tableName(),
+ "Migration table must be deleted only after the recovery copy
succeeds");
}
private static DescribeTableResponse newSchemaActiveResponse() {