From 7b508f21131a4772393410bcbe77ce1a91ec2611 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Fri, 12 Sep 2025 17:48:34 -0300 Subject: [PATCH] Tests for destroy logic --- .../io/split/android/client/Destroyer.java | 128 +++++++ .../android/client/SplitFactoryImpl.java | 82 ++--- .../fallback/FallbacksSanitizerImpl.java | 2 - .../split/android/client/DestroyerTest.java | 332 ++++++++++++++++++ 4 files changed, 488 insertions(+), 56 deletions(-) create mode 100644 src/main/java/io/split/android/client/Destroyer.java create mode 100644 src/test/java/io/split/android/client/DestroyerTest.java diff --git a/src/main/java/io/split/android/client/Destroyer.java b/src/main/java/io/split/android/client/Destroyer.java new file mode 100644 index 000000000..87a0fd64c --- /dev/null +++ b/src/main/java/io/split/android/client/Destroyer.java @@ -0,0 +1,128 @@ +package io.split.android.client; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Lock; + +import io.split.android.client.factory.FactoryMonitor; +import io.split.android.client.impressions.ImpressionListener; +import io.split.android.client.lifecycle.SplitLifecycleManager; +import io.split.android.client.network.HttpClient; +import io.split.android.client.service.executor.SplitTaskExecutor; +import io.split.android.client.service.synchronizer.SyncManager; +import io.split.android.client.shared.SplitClientContainer; +import io.split.android.client.storage.common.SplitStorageContainer; +import io.split.android.client.telemetry.TelemetrySynchronizer; +import io.split.android.client.utils.logger.Logger; + +class Destroyer implements Runnable { + + // Lock to prevent concurrent shutdowns and ensure sequence + private final Lock mInitLock; + // Whe + private final AtomicBoolean mCheckClients; + private final SplitClientContainer mClientContainer; + private final SplitStorageContainer mStorageContainer; + private final long mInitStartTime; + private final TelemetrySynchronizer mTelemetrySynchronizer; + private final ExecutorService mImpressionsLoggingTaskExecutor; + private final ExecutorService mImpressionsObserverExecutor; + private final SyncManager mSyncManager; + private final SplitLifecycleManager mLifecycleManager; + private final FactoryMonitor mFactoryMonitor; + private final String mApiKey; + private final ImpressionListener mCustomerImpressionListener; + private final HttpClient mDefaultHttpClient; + private final SplitManager mSplitManager; + private final SplitTaskExecutor mSplitTaskExecutor; + private final SplitTaskExecutor mSplitSingleThreadTaskExecutor; + private final AtomicBoolean mIsTerminated; + + Destroyer( + Lock initLock, + AtomicBoolean checkClients, + SplitClientContainer clientContainer, + SplitStorageContainer storageContainer, + long initStartTime, + TelemetrySynchronizer telemetrySynchronizer, + ExecutorService impressionsLoggingTaskExecutor, + ExecutorService impressionsObserverExecutor, + SyncManager syncManager, + SplitLifecycleManager lifecycleManager, + FactoryMonitor factoryMonitor, + String apiKey, + ImpressionListener customerImpressionListener, + HttpClient defaultHttpClient, + SplitManager splitManager, + SplitTaskExecutor splitTaskExecutor, + SplitTaskExecutor splitSingleThreadTaskExecutor, + AtomicBoolean isTerminated + ) { + mInitLock = initLock; + mCheckClients = checkClients; + mClientContainer = clientContainer; + mStorageContainer = storageContainer; + mInitStartTime = initStartTime; + mTelemetrySynchronizer = telemetrySynchronizer; + mImpressionsLoggingTaskExecutor = impressionsLoggingTaskExecutor; + mImpressionsObserverExecutor = impressionsObserverExecutor; + mSyncManager = syncManager; + mLifecycleManager = lifecycleManager; + mFactoryMonitor = factoryMonitor; + mApiKey = apiKey; + mCustomerImpressionListener = customerImpressionListener; + mDefaultHttpClient = defaultHttpClient; + mSplitManager = splitManager; + mSplitTaskExecutor = splitTaskExecutor; + mSplitSingleThreadTaskExecutor = splitSingleThreadTaskExecutor; + mIsTerminated = isTerminated; + } + + @Override + public void run() { + mInitLock.lock(); + try { + if (mCheckClients.get() && !mClientContainer.getAll().isEmpty()) { + Logger.d("Avoiding shutdown due to active clients"); + return; + } + Logger.w("Shutdown called for split"); + mStorageContainer + .getTelemetryStorage() + .recordSessionLength( + System.currentTimeMillis() - mInitStartTime + ); + mTelemetrySynchronizer.flush(); + mTelemetrySynchronizer.destroy(); + Logger.d("Successful shutdown of telemetry"); + mImpressionsLoggingTaskExecutor.shutdown(); + mImpressionsObserverExecutor.shutdown(); + Logger.d("Successful shutdown of impressions logging executor"); + mSyncManager.stop(); + Logger.d("Flushing impressions and events"); + mLifecycleManager.destroy(); + mClientContainer.destroy(); + Logger.d("Successful shutdown of lifecycle manager"); + mFactoryMonitor.remove(mApiKey); + Logger.d("Successful shutdown of segment fetchers"); + mCustomerImpressionListener.close(); + Logger.d("Successful shutdown of ImpressionListener"); + mDefaultHttpClient.close(); + Logger.d("Successful shutdown of httpclient"); + mSplitManager.destroy(); + Logger.d("Successful shutdown of manager"); + mSplitTaskExecutor.stop(); + mSplitSingleThreadTaskExecutor.stop(); + Logger.d("Successful shutdown of task executor"); + mStorageContainer.getAttributesStorageContainer().destroy(); + Logger.d("Successful shutdown of attributes storage"); + mIsTerminated.set(true); + Logger.d("SplitFactory has been destroyed"); + } catch (Exception e) { + Logger.e(e, "We could not shutdown split"); + } finally { + mCheckClients.set(false); + mInitLock.unlock(); + } + } +} diff --git a/src/main/java/io/split/android/client/SplitFactoryImpl.java b/src/main/java/io/split/android/client/SplitFactoryImpl.java index 2ac7d457d..ea132639b 100644 --- a/src/main/java/io/split/android/client/SplitFactoryImpl.java +++ b/src/main/java/io/split/android/client/SplitFactoryImpl.java @@ -81,7 +81,7 @@ public class SplitFactoryImpl implements SplitFactory { private final Key mDefaultClientKey; private final SplitManager mManager; private final Runnable mDestroyer; - private boolean mIsTerminated = false; + private final AtomicBoolean mIsTerminated = new AtomicBoolean(false); private final AtomicBoolean mCheckClients = new AtomicBoolean(false); private final String mApiKey; @@ -302,58 +302,6 @@ private SplitFactoryImpl(@NonNull String apiToken, @NonNull Key key, @NonNull Sp streamingComponents.getPushNotificationManager(), componentsRegister, workManagerWrapper, mEventsTrackerProvider, flagSetsFilter, splitParser); - - mDestroyer = new Runnable() { - public void run() { - mInitLock.lock(); - try { - if (mCheckClients.get() && !mClientContainer.getAll().isEmpty()) { - Logger.d("Avoiding shutdown due to active clients"); - return; - } - Logger.w("Shutdown called for split"); - mStorageContainer.getTelemetryStorage().recordSessionLength(System.currentTimeMillis() - initializationStartTime); - telemetrySynchronizer.flush(); - telemetrySynchronizer.destroy(); - Logger.d("Successful shutdown of telemetry"); - impressionsLoggingTaskExecutor.shutdown(); - impressionsObserverExecutor.shutdown(); - Logger.d("Successful shutdown of impressions logging executor"); - mSyncManager.stop(); - Logger.d("Flushing impressions and events"); - mLifecycleManager.destroy(); - mClientContainer.destroy(); - Logger.d("Successful shutdown of lifecycle manager"); - mFactoryMonitor.remove(mApiKey); - Logger.d("Successful shutdown of segment fetchers"); - customerImpressionListener.close(); - Logger.d("Successful shutdown of ImpressionListener"); - defaultHttpClient.close(); - Logger.d("Successful shutdown of httpclient"); - mManager.destroy(); - Logger.d("Successful shutdown of manager"); - mSplitTaskExecutor.stop(); - splitSingleThreadTaskExecutor.stop(); - Logger.d("Successful shutdown of task executor"); - mStorageContainer.getAttributesStorageContainer().destroy(); - Logger.d("Successful shutdown of attributes storage"); - mIsTerminated = true; - Logger.d("SplitFactory has been destroyed"); - } catch (Exception e) { - Logger.e(e, "We could not shutdown split"); - } finally { - mCheckClients.set(false); - mInitLock.unlock(); - } - } - }; - Runtime.getRuntime().addShutdownHook(new Thread() { - @Override - public void run() { - // Using the full path to avoid conflicting with Thread.destroy() - SplitFactoryImpl.this.destroy(); - } - }); // Set up async initialization final SplitFactoryHelper.Initializer initializer = new SplitFactoryHelper.Initializer(apiToken, config, @@ -385,6 +333,32 @@ public void run() { mManager = new SplitManagerImpl( mStorageContainer.getSplitsStorage(), new SplitValidatorImpl(), splitParser); + mDestroyer = new Destroyer( + mInitLock, + mCheckClients, + mClientContainer, + mStorageContainer, + initializationStartTime, + telemetrySynchronizer, + impressionsLoggingTaskExecutor, + impressionsObserverExecutor, + mSyncManager, + mLifecycleManager, + mFactoryMonitor, + mApiKey, + customerImpressionListener, + defaultHttpClient, + mManager, + mSplitTaskExecutor, + splitSingleThreadTaskExecutor, + mIsTerminated); + Runtime.getRuntime().addShutdownHook(new Thread() { + @Override + public void run() { + // Using the full path to avoid conflicting with Thread.destroy() + SplitFactoryImpl.this.destroy(); + } + }); } private static String getFlagsSpec(@Nullable TestingConfig testingConfig) { @@ -423,7 +397,7 @@ public SplitManager manager() { @Override public void destroy() { synchronized (SplitFactoryImpl.class) { - if (!mIsTerminated) { + if (!mIsTerminated.get()) { ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); executor.schedule(mDestroyer, 100, TimeUnit.MILLISECONDS); executor.schedule(new Runnable() { diff --git a/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java b/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java index 70e34194d..81c35c79b 100644 --- a/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java +++ b/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java @@ -19,8 +19,6 @@ class FallbacksSanitizerImpl implements FallbacksSanitizer { private static final String TREATMENT_REGEXP = "^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$"; private static final Pattern TREATMENT_PATTERN = Pattern.compile(TREATMENT_REGEXP); - - @Override @Nullable public FallbackTreatment sanitizeGlobal(@Nullable FallbackTreatment global) { diff --git a/src/test/java/io/split/android/client/DestroyerTest.java b/src/test/java/io/split/android/client/DestroyerTest.java new file mode 100644 index 000000000..ccb31a35f --- /dev/null +++ b/src/test/java/io/split/android/client/DestroyerTest.java @@ -0,0 +1,332 @@ +package io.split.android.client; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Lock; + +import io.split.android.client.factory.FactoryMonitor; +import io.split.android.client.impressions.ImpressionListener; +import io.split.android.client.lifecycle.SplitLifecycleManager; +import io.split.android.client.network.HttpClient; +import io.split.android.client.service.executor.SplitTaskExecutor; +import io.split.android.client.service.synchronizer.SyncManager; +import io.split.android.client.shared.SplitClientContainer; +import io.split.android.client.storage.attributes.AttributesStorageContainer; +import io.split.android.client.storage.common.SplitStorageContainer; +import io.split.android.client.telemetry.TelemetrySynchronizer; +import io.split.android.client.telemetry.storage.TelemetryStorage; + +public class DestroyerTest { + + private Lock mInitLock; + private AtomicBoolean mCheckClients; + private SplitClientContainer mClientContainer; + private SplitStorageContainer mStorageContainer; + private TelemetryStorage mTelemetryStorage; + private AttributesStorageContainer mAttributesStorageContainer; + private TelemetrySynchronizer mTelemetrySynchronizer; + private ExecutorService mImpressionsLoggingTaskExecutor; + private ExecutorService mImpressionsObserverExecutor; + private SyncManager mSyncManager; + private SplitLifecycleManager mLifecycleManager; + private FactoryMonitor mFactoryMonitor; + private ImpressionListener mCustomerImpressionListener; + private HttpClient mDefaultHttpClient; + private SplitManager mSplitManager; + private SplitTaskExecutor mSplitTaskExecutor; + private SplitTaskExecutor mSplitSingleThreadTaskExecutor; + private AtomicBoolean mIsTerminated; + + private Destroyer mDestroyer; + private final String API_KEY = "test-api-key"; + private final long INIT_START_TIME = 1000L; + + @Before + public void setup() { + mInitLock = mock(Lock.class); + mCheckClients = new AtomicBoolean(true); + mClientContainer = mock(SplitClientContainer.class); + mStorageContainer = mock(SplitStorageContainer.class); + mTelemetryStorage = mock(TelemetryStorage.class); + mAttributesStorageContainer = mock(AttributesStorageContainer.class); + mTelemetrySynchronizer = mock(TelemetrySynchronizer.class); + mImpressionsLoggingTaskExecutor = mock(ExecutorService.class); + mImpressionsObserverExecutor = mock(ExecutorService.class); + mSyncManager = mock(SyncManager.class); + mLifecycleManager = mock(SplitLifecycleManager.class); + mFactoryMonitor = mock(FactoryMonitor.class); + mCustomerImpressionListener = mock(ImpressionListener.class); + mDefaultHttpClient = mock(HttpClient.class); + mSplitManager = mock(SplitManager.class); + mSplitTaskExecutor = mock(SplitTaskExecutor.class); + mSplitSingleThreadTaskExecutor = mock(SplitTaskExecutor.class); + mIsTerminated = new AtomicBoolean(false); + + when(mStorageContainer.getTelemetryStorage()).thenReturn(mTelemetryStorage); + when(mStorageContainer.getAttributesStorageContainer()).thenReturn(mAttributesStorageContainer); + when(mClientContainer.getAll()).thenReturn(new HashSet<>()); + + mDestroyer = new Destroyer( + mInitLock, + mCheckClients, + mClientContainer, + mStorageContainer, + INIT_START_TIME, + mTelemetrySynchronizer, + mImpressionsLoggingTaskExecutor, + mImpressionsObserverExecutor, + mSyncManager, + mLifecycleManager, + mFactoryMonitor, + API_KEY, + mCustomerImpressionListener, + mDefaultHttpClient, + mSplitManager, + mSplitTaskExecutor, + mSplitSingleThreadTaskExecutor, + mIsTerminated + ); + } + + @Test + public void shouldAcquireAndReleaseLock() { + mDestroyer.run(); + + verify(mInitLock).lock(); + verify(mInitLock).unlock(); + } + + @Test + public void shouldAcquireAndReleaseLockWhenRandomException() { + doThrow(new RuntimeException("Test exception")).when(mTelemetrySynchronizer).flush(); + + mDestroyer.run(); + + verify(mInitLock).lock(); + verify(mInitLock).unlock(); + } + + @Test + public void shouldSetCheckClientsToFalseAfterCompletion() { + mDestroyer.run(); + + assertFalse( + "CheckClients should be set to false after completion", + mCheckClients.get() + ); + } + + @Test + public void shouldNotShutdownWhenActiveClientsExist() { + Set activeClients = new HashSet<>(); + activeClients.add(mock(SplitClient.class)); + when(mClientContainer.getAll()).thenReturn(activeClients); + + mDestroyer.run(); + + verify(mTelemetrySynchronizer, never()).flush(); + verify(mTelemetrySynchronizer, never()).destroy(); + assertFalse( + "IsTerminated should remain false when clients are active", + mIsTerminated.get() + ); + } + + @Test + public void shouldRecordSessionLengthCorrectly() { + long currentTime = INIT_START_TIME + 5000L; + + mDestroyer = new Destroyer( + mInitLock, + mCheckClients, + mClientContainer, + mStorageContainer, + INIT_START_TIME, + mTelemetrySynchronizer, + mImpressionsLoggingTaskExecutor, + mImpressionsObserverExecutor, + mSyncManager, + mLifecycleManager, + mFactoryMonitor, + API_KEY, + mCustomerImpressionListener, + mDefaultHttpClient, + mSplitManager, + mSplitTaskExecutor, + mSplitSingleThreadTaskExecutor, + mIsTerminated + ) { + @Override + public void run() { + mInitLock.lock(); + try { + if ( + mCheckClients.get() && + !mClientContainer.getAll().isEmpty() + ) { + return; + } + mStorageContainer + .getTelemetryStorage() + .recordSessionLength(currentTime - INIT_START_TIME); + } finally { + mCheckClients.set(false); + mInitLock.unlock(); + } + } + }; + + mDestroyer.run(); + + verify(mTelemetryStorage).recordSessionLength(5000L); + } + + @Test + public void shouldShutdownAllComponentsInCorrectOrder() { + mDestroyer.run(); + + InOrder inOrder = inOrder( + mTelemetryStorage, + mTelemetrySynchronizer, + mImpressionsLoggingTaskExecutor, + mImpressionsObserverExecutor, + mSyncManager, + mLifecycleManager, + mClientContainer, + mFactoryMonitor, + mCustomerImpressionListener, + mDefaultHttpClient, + mSplitManager, + mSplitTaskExecutor, + mSplitSingleThreadTaskExecutor, + mAttributesStorageContainer + ); + + inOrder.verify(mTelemetryStorage).recordSessionLength(anyLong()); + inOrder.verify(mTelemetrySynchronizer).flush(); + inOrder.verify(mTelemetrySynchronizer).destroy(); + inOrder.verify(mImpressionsLoggingTaskExecutor).shutdown(); + inOrder.verify(mImpressionsObserverExecutor).shutdown(); + inOrder.verify(mSyncManager).stop(); + inOrder.verify(mLifecycleManager).destroy(); + inOrder.verify(mClientContainer).destroy(); + inOrder.verify(mFactoryMonitor).remove(API_KEY); + inOrder.verify(mCustomerImpressionListener).close(); + inOrder.verify(mDefaultHttpClient).close(); + inOrder.verify(mSplitManager).destroy(); + inOrder.verify(mSplitTaskExecutor).stop(); + inOrder.verify(mSplitSingleThreadTaskExecutor).stop(); + inOrder.verify(mAttributesStorageContainer).destroy(); + } + + @Test + public void shouldSetTerminatedToTrueAfterSuccessfulShutdown() { + mDestroyer.run(); + + assertTrue( + "IsTerminated should be set to true after successful shutdown", + mIsTerminated.get() + ); + } + + @Test + public void shouldHandleExceptionsDuringShutdown() { + doThrow(new RuntimeException("Test exception")) + .when(mTelemetrySynchronizer) + .flush(); + + mDestroyer.run(); + + verify(mTelemetrySynchronizer).flush(); + assertFalse( + "IsTerminated should remain false when exception occurs", + mIsTerminated.get() + ); + assertFalse( + "CheckClients should still be set to false in finally block", + mCheckClients.get() + ); + } + + @Test + public void shouldAlwaysReleaseLockEvenOnException() { + doThrow(new RuntimeException("Test exception")) + .when(mTelemetrySynchronizer) + .flush(); + + mDestroyer.run(); + + verify(mInitLock).lock(); + verify(mInitLock).unlock(); + } + + @Test + public void shouldRemoveFactoryMonitorWithCorrectApiKey() { + mDestroyer.run(); + + verify(mFactoryMonitor).remove(API_KEY); + } + + @Test + public void shouldNotProceedWhenCheckClientsIsTrueAndActiveClientsExist() { + Set activeClients = new HashSet<>(); + activeClients.add(mock(SplitClient.class)); + when(mClientContainer.getAll()).thenReturn(activeClients); + mCheckClients.set(true); + + mDestroyer.run(); + + verify(mStorageContainer, never()).getTelemetryStorage(); + verify(mTelemetrySynchronizer, never()).flush(); + assertFalse( + "CheckClients should remain true when shutdown is skipped", + mCheckClients.get() + ); + assertFalse( + "IsTerminated should remain false when shutdown is skipped", + mIsTerminated.get() + ); + } + + @Test + public void shouldProceedWhenCheckClientsIsFalse() { + Set activeClients = new HashSet<>(); + activeClients.add(mock(SplitClient.class)); + when(mClientContainer.getAll()).thenReturn(activeClients); + mCheckClients.set(false); + + mDestroyer.run(); + + verify(mTelemetryStorage).recordSessionLength(anyLong()); + verify(mTelemetrySynchronizer).flush(); + assertTrue("IsTerminated should be set to true", mIsTerminated.get()); + } + + @Test + public void shouldProceedWhenNoActiveClients() { + when(mClientContainer.getAll()).thenReturn(new HashSet<>()); + mCheckClients.set(true); + + mDestroyer.run(); + + verify(mTelemetryStorage).recordSessionLength(anyLong()); + verify(mTelemetrySynchronizer).flush(); + assertTrue("IsTerminated should be set to true", mIsTerminated.get()); + } +}