Skip to content

Commit f27aa42

Browse files
authored
Ensure worker re-initializes after startup failure (#1804)
1 parent f19bc3b commit f27aa42

4 files changed

Lines changed: 174 additions & 7 deletions

File tree

amazon-kinesis-client/src/main/java/software/amazon/kinesis/coordinator/DynamicMigrationComponentsInitializer.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ private void initializeStartupComponents(
169169
final LeaseAssignmentMode assignmentMode,
170170
final Supplier<? extends LeaderDecider> leaderDeciderSupplier,
171171
final boolean createLamAndStartMetrics) {
172+
if (initialized) {
173+
log.info("Startup components already initialized, skipping initializeStartupComponents");
174+
return;
175+
}
172176
this.dualMode = dualMode;
173177
this.currentAssignmentMode = assignmentMode;
174178

@@ -299,11 +303,7 @@ private void createGsi(final boolean blockingWait) throws DependencyException {
299303
if (blockingWait) {
300304
log.info("Waiting for Lease table GSI creation");
301305
final long secondsBetweenPolls = 10L;
302-
// TODO: there exists an issue where if this timeout is reached the KCL starts up erroneously for a new
303-
// application (Scheduler appears to start but doesn't take any leases and some threads don't start
304-
// properly) where the only recovery is bouncing the worker after the GSI is created.
305-
// Extending the timeout to a long time to ensure GSI is created as a short term fix and will revisit
306-
// on the proper fix in the future
306+
// Extending the timeout to a long time to ensure GSI is created.
307307
final long timeoutSeconds = 3600L;
308308
final boolean isIndexActive =
309309
leaseRefresher.waitUntilLeaseOwnerToLeaseKeyIndexExists(secondsBetweenPolls, timeoutSeconds);

amazon-kinesis-client/src/main/java/software/amazon/kinesis/coordinator/migration/MigrationStateMachineImpl.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,19 @@ public void initialize() throws DependencyException {
113113
coordinatorStateDAO, clientVersionConfig, random, workerId);
114114
final SimpleEntry<ClientVersion, MigrationState> dataForInitialization =
115115
startingStateInitializer.getInitialState();
116-
startingClientVersion = dataForInitialization.getKey();
117116
startingMigrationState = dataForInitialization.getValue();
118117

119118
// Create and enter the starting state. The state's enter() method
120119
// writes MigrationState to DDB (except CLIENT_VERSION_INIT which skips DDB)
121120
// and initializes components. If enter() throws DependencyException, Scheduler
122121
// retries the whole initialization loop.
123122
final MigrationClientVersionState startingState =
124-
createMigrationClientVersionState(startingClientVersion, startingMigrationState);
123+
createMigrationClientVersionState(dataForInitialization.getKey(), startingMigrationState);
125124
startingState.enter(ClientVersion.CLIENT_VERSION_INIT);
125+
126+
// Only set startingClientVersion after enter() succeeds — this is the guard
127+
// that prevents re-initialization from being skipped on retry.
128+
startingClientVersion = dataForInitialization.getKey();
126129
currentMigrationClientVersionState = startingState;
127130
log.info("MigrationStateMachine initial clientVersion {}", startingClientVersion);
128131

amazon-kinesis-client/src/test/java/software/amazon/kinesis/coordinator/DynamicMigrationComponentsInitializerTest.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,77 @@ public void testWorkerMetricsReporting() throws Exception {
409409
77.0, statsCaptor.getValue().getMetricStats().get("CPU").get(2));
410410
}
411411

412+
@Test
413+
public void testRetry_3x_gsiFailsThenSucceeds_noDoubleInit() throws Exception {
414+
// First call: GSI times out. Second call: GSI succeeds.
415+
when(mockLeaseRefresher.waitUntilLeaseOwnerToLeaseKeyIndexExists(anyLong(), anyLong()))
416+
.thenReturn(false)
417+
.thenReturn(true);
418+
419+
// First call should throw
420+
assertThrows(
421+
DependencyException.class,
422+
() -> migrationInitializer.initializeClientVersionFor3x(ClientVersion.CLIENT_VERSION_INIT));
423+
424+
// Second call should succeed
425+
migrationInitializer.initializeClientVersionFor3x(ClientVersion.CLIENT_VERSION_INIT);
426+
427+
// initializeStartupComponents internals should only be called once
428+
verify(mockWorkerMetricsManager, Mockito.times(1)).startManager();
429+
verify(mockDdbLockBasedLeaderDeciderCreator, Mockito.times(1)).get();
430+
verify(mockLamCreator, Mockito.times(1)).apply(any(), any());
431+
432+
// GSI creation and idempotent operations should be called twice (once per attempt)
433+
verify(mockLeaseRefresher, Mockito.times(2)).createLeaseOwnerToLeaseKeyIndexIfNotExists();
434+
verify(mockLam).start();
435+
}
436+
437+
@Test
438+
public void testRetry_upgradeFrom2x_gsiFailsThenSucceeds_noDoubleInit() throws Exception {
439+
when(mockLeaseRefresher.waitUntilLeaseOwnerToLeaseKeyIndexExists(anyLong(), anyLong()))
440+
.thenReturn(false)
441+
.thenReturn(true);
442+
443+
// UpgradeFrom2x uses createGsi(false) — non-blocking, but startWorkerMetricsReporting can throw
444+
Mockito.doThrow(new DependencyException(new RuntimeException("WorkerMetrics init failed")))
445+
.doNothing()
446+
.when(mockWorkerMetricsDAO)
447+
.initialize();
448+
449+
assertThrows(
450+
DependencyException.class,
451+
() -> migrationInitializer.initializeClientVersionForUpgradeFrom2x(ClientVersion.CLIENT_VERSION_INIT));
452+
453+
migrationInitializer.initializeClientVersionForUpgradeFrom2x(ClientVersion.CLIENT_VERSION_INIT);
454+
455+
// Non-idempotent startup components called only once
456+
verify(mockWorkerMetricsManager, Mockito.times(1)).startManager();
457+
verify(mockDeterministicLeaderDeciderCreator, Mockito.times(1)).get();
458+
verify(mockLamCreator, Mockito.times(1)).apply(any(), any());
459+
}
460+
461+
@Test
462+
public void testRetry_3xWithRollback_metricsFailsThenSucceeds_noDoubleInit() throws Exception {
463+
// startWorkerMetricsReporting throws on first call
464+
Mockito.doThrow(new DependencyException(new RuntimeException("WorkerMetrics init failed")))
465+
.doNothing()
466+
.when(mockWorkerMetricsDAO)
467+
.initialize();
468+
469+
assertThrows(
470+
DependencyException.class,
471+
() -> migrationInitializer.initializeClientVersionFor3xWithRollback(ClientVersion.CLIENT_VERSION_INIT));
472+
473+
migrationInitializer.initializeClientVersionFor3xWithRollback(ClientVersion.CLIENT_VERSION_INIT);
474+
475+
// Non-idempotent startup components called only once
476+
verify(mockWorkerMetricsManager, Mockito.times(1)).startManager();
477+
verify(mockDdbLockBasedLeaderDeciderCreator, Mockito.times(1)).get();
478+
verify(mockAdaptiveLeaderDeciderCreator, Mockito.times(1)).get();
479+
verify(mockLamCreator, Mockito.times(1)).apply(any(), any());
480+
verify(mockLam).start();
481+
}
482+
412483
private abstract static class DynamoDBLockBasedLeaderDeciderSupplier
413484
implements Supplier<DynamoDBLockBasedLeaderDecider> {}
414485

amazon-kinesis-client/src/test/java/software/amazon/kinesis/coordinator/migration/MigrationStateMachineTest.java

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import software.amazon.kinesis.metrics.NullMetricsFactory;
4242
import software.amazon.kinesis.worker.metricstats.WorkerMetricStatsDAO;
4343

44+
import static org.junit.jupiter.api.Assertions.assertThrows;
4445
import static org.mockito.ArgumentMatchers.any;
4546
import static org.mockito.ArgumentMatchers.anyLong;
4647
import static org.mockito.ArgumentMatchers.eq;
@@ -347,4 +348,96 @@ private void initiateAndTestSuccessfulUpgrade(final Runnable successfulUpgradeMo
347348
Assertions.assertEquals(ClientVersion.CLIENT_VERSION_3X, stateMachineUnderTest.getCurrentClientVersion());
348349
verify(mockInitializer).initializeClientVersionFor3x(ClientVersion.CLIENT_VERSION_3X_WITH_ROLLBACK);
349350
}
351+
352+
@Test
353+
public void testInitializeRetry_3x_enterFailsThenSucceeds() throws Exception {
354+
// First call to initializeClientVersionFor3x throws, simulating GSI timeout
355+
Mockito.doThrow(new DependencyException(new RuntimeException("GSI creation timed out")))
356+
.doNothing()
357+
.when(mockInitializer)
358+
.initializeClientVersionFor3x(any());
359+
360+
final MigrationStateMachineImpl stateMachine = new MigrationStateMachineImpl(
361+
nullMetricsFactory,
362+
mockTimeProvider,
363+
mockCoordinatorStateDAO,
364+
mockMigrationStateMachineThreadPool,
365+
ClientVersionConfig.CLIENT_VERSION_CONFIG_3X,
366+
mockRandom,
367+
mockInitializer,
368+
WORKER_ID,
369+
Duration.ofMinutes(0).getSeconds());
370+
371+
// First initialize() should throw
372+
assertThrows(DependencyException.class, stateMachine::initialize);
373+
// startingClientVersion should still be null — retry must not be skipped
374+
Assertions.assertNull(stateMachine.getStartingClientVersion());
375+
376+
// Second initialize() should succeed
377+
stateMachine.initialize();
378+
Assertions.assertEquals(ClientVersion.CLIENT_VERSION_3X, stateMachine.getStartingClientVersion());
379+
380+
// initializeClientVersionFor3x should have been called twice (once per attempt)
381+
verify(mockInitializer, times(2)).initializeClientVersionFor3x(any());
382+
}
383+
384+
@Test
385+
public void testInitializeRetry_upgradeFrom2x_enterFailsThenSucceeds() throws Exception {
386+
// First call throws, simulating DDB failure during enter
387+
Mockito.doThrow(new DependencyException(new RuntimeException("DDB connection timeout")))
388+
.doNothing()
389+
.when(mockInitializer)
390+
.initializeClientVersionForUpgradeFrom2x(any());
391+
392+
final MigrationStateMachineImpl stateMachine = new MigrationStateMachineImpl(
393+
nullMetricsFactory,
394+
mockTimeProvider,
395+
mockCoordinatorStateDAO,
396+
mockMigrationStateMachineThreadPool,
397+
ClientVersionConfig.CLIENT_VERSION_CONFIG_COMPATIBLE_WITH_2X,
398+
mockRandom,
399+
mockInitializer,
400+
WORKER_ID,
401+
Duration.ofMinutes(0).getSeconds());
402+
403+
// First initialize() should throw
404+
assertThrows(DependencyException.class, stateMachine::initialize);
405+
Assertions.assertNull(stateMachine.getStartingClientVersion());
406+
407+
// Second initialize() should succeed
408+
stateMachine.initialize();
409+
Assertions.assertEquals(ClientVersion.CLIENT_VERSION_UPGRADE_FROM_2X, stateMachine.getStartingClientVersion());
410+
411+
verify(mockInitializer, times(2)).initializeClientVersionForUpgradeFrom2x(any());
412+
}
413+
414+
@Test
415+
public void testInitializeRetry_phase1_enterFailsThenSucceeds() throws Exception {
416+
// initializeClientVersionForPhase1 doesn't throw checked exceptions,
417+
// so use RuntimeException to simulate failure
418+
Mockito.doThrow(new RuntimeException("DDB failure"))
419+
.doNothing()
420+
.when(mockInitializer)
421+
.initializeClientVersionForPhase1();
422+
423+
final MigrationStateMachineImpl stateMachine = new MigrationStateMachineImpl(
424+
nullMetricsFactory,
425+
mockTimeProvider,
426+
mockCoordinatorStateDAO,
427+
mockMigrationStateMachineThreadPool,
428+
ClientVersionConfig.CLIENT_VERSION_CONFIG_COMPATIBLE_WITH_2X_PHASE1,
429+
mockRandom,
430+
mockInitializer,
431+
WORKER_ID,
432+
Duration.ofMinutes(0).getSeconds());
433+
434+
// First initialize() should throw (wrapped as RuntimeException)
435+
Assertions.assertThrows(RuntimeException.class, stateMachine::initialize);
436+
Assertions.assertNull(stateMachine.getStartingClientVersion());
437+
438+
// Second initialize() should succeed
439+
stateMachine.initialize();
440+
441+
verify(mockInitializer, times(2)).initializeClientVersionForPhase1();
442+
}
350443
}

0 commit comments

Comments
 (0)