diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java index b8a354a6..3281088e 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSourceSynchronizerAdapter.java @@ -61,6 +61,11 @@ public DataSourceSynchronizerAdapter(DataSourceFactory dataSourceFactory) { this.dataSource = dataSourceFactory.create(convertingSink); } + @Override + public String name() { + return "AdaptedSynchronizer(V1->V2)"; + } + @Override public CompletableFuture next() { synchronized (startLock) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java index 70475e23..a59f726b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -2,6 +2,8 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.fdv2.SourceSignal; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.datasources.Synchronizer; @@ -48,6 +50,13 @@ class FDv2DataSource implements DataSource { private volatile boolean closed = false; + /** + * Avoid duplicate orchestration logs for the same synchronizer and {@link SourceSignal}. + */ + private String lastLoggedSynchronizerDedupeName; + + private SourceSignal lastLoggedSynchronizerDedupeStatus; + public interface DataSourceFactory { T build(); } @@ -73,7 +82,6 @@ public FDv2DataSource( ); } - public FDv2DataSource( ImmutableList> initializers, ImmutableList> synchronizers, @@ -117,7 +125,9 @@ private void run() { if (!sourceManager.hasAvailableSources()) { // There are not any initializer or synchronizers, so we are at the best state that // can be achieved. - logger.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data due to no initializers or synchronizers"); + logger.warn( + "LaunchDarkly client will not connect to LaunchDarkly for feature flag data due to no initializers or synchronizers configured." + ); dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); startFuture.complete(true); return; @@ -157,13 +167,16 @@ private void run() { private void runInitializers() { boolean anyDataReceived = false; Initializer initializer = sourceManager.getNextInitializerAndSetActive(); - while(initializer != null) { + while (initializer != null) { + String initializerName = initializer.name(); + logger.info("Initializer '{}' is starting.", initializerName); try { - try(FDv2SourceResult result = initializer.run().get()) { + try (FDv2SourceResult result = initializer.run().get()) { switch (result.getResultType()) { case CHANGE_SET: dataSourceUpdates.apply(result.getChangeSet()); anyDataReceived = true; + logger.info("Initialized via '{}'.", initializerName); if (!result.getChangeSet().getSelector().isEmpty()) { // We received data with a selector, so we end the initialization process. dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); @@ -176,6 +189,9 @@ private void runInitializers() { switch (status.getState()) { case INTERRUPTED: case TERMINAL_ERROR: + logger.warn("Initializer '{}' failed: {}", + initializerName, + detailForError(status.getErrorInfo())); // The data source updates handler will filter the state during initializing, but this // will make the error information available. dataSourceUpdates.updateStatus( @@ -194,8 +210,6 @@ private void runInitializers() { } } } catch (ExecutionException | InterruptedException | CancellationException e) { - // We don't expect these conditions to happen in practice. - // The data source updates handler will filter the state during initializing, but this // will make the error information available. dataSourceUpdates.updateStatus( @@ -204,9 +218,12 @@ private void runInitializers() { 0, e.toString(), new Date().toInstant())); - logger.warn("Error running initializer: {}", e.toString()); + Throwable root = e instanceof ExecutionException && e.getCause() != null ? e.getCause() : e; + logger.error("Error running initializer '{}': {}", + initializerName, + root.getMessage() != null ? root.getMessage() : LogValues.exceptionSummary(root)); } - initializer = sourceManager.getNextInitializerAndSetActive(); + initializer = sourceManager.getNextInitializerAndSetActive(); } // We received data without a selector, and we have exhausted initializers, so we are going to // consider ourselves initialized. @@ -249,6 +266,9 @@ private void runSynchronizers() { // We want to continue running synchronizers for as long as any are available. while (synchronizer != null) { + String synchronizerName = synchronizer.name(); + logger.info("Synchronizer '{}' is starting.", synchronizerName); + resetSynchronizerStatusDedupe(); try { boolean running = true; @@ -264,16 +284,19 @@ private void runSynchronizers() { Condition c = (Condition) res; switch (c.getType()) { case FALLBACK: - // For fallback, we will move to the next available synchronizer, which may loop. - // This is the default behavior of exiting the run loop, so we don't need to take - // any action. - logger.debug("A synchronizer has experienced an interruption and we are falling back."); + logger.info( + "Fallback condition met, falling back from synchronizer '{}'.", + synchronizer.name() + ); break; case RECOVERY: // For recovery, we will start at the first available synchronizer. // So we reset the source index, and finding the source will start at the beginning. sourceManager.resetSourceIndex(); - logger.debug("The data source is attempting to recover to a higher priority synchronizer."); + logger.info( + "Recovery condition met, moving from synchronizer '{}' to primary synchronizer.", + synchronizer.name() + ); break; } // A running synchronizer will only have fallback and recovery conditions that it can act on. @@ -292,6 +315,8 @@ private void runSynchronizers() { switch (result.getResultType()) { case CHANGE_SET: + // A data update breaks the "in a row" streak for status deduplication. + resetSynchronizerStatusDedupe(); dataSourceUpdates.apply(result.getChangeSet()); dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); // This could have been completed by any data source. But if it has not been completed before @@ -302,17 +327,33 @@ private void runSynchronizers() { FDv2SourceResult.Status status = result.getStatus(); switch (status.getState()) { case INTERRUPTED: + maybeLogSynchronizerStatusChange( + synchronizer.name(), + status.getState() + ); // Handled by conditions. dataSourceUpdates.updateStatus( DataSourceStatusProvider.State.INTERRUPTED, status.getErrorInfo()); break; case SHUTDOWN: + maybeLogSynchronizerStatusChange( + synchronizer.name(), + status.getState() + ); // We should be overall shutting down. logger.debug("Synchronizer shutdown."); return; case TERMINAL_ERROR: + maybeLogSynchronizerStatusChange( + synchronizer.name(), + status.getState() + ); sourceManager.blockCurrentSynchronizer(); + logger.warn( + "Synchronizer '{}' permanently failed and will not be used again until application restart.", + synchronizer.name() + ); running = false; dataSourceUpdates.updateStatus( DataSourceStatusProvider.State.INTERRUPTED, @@ -335,6 +376,7 @@ private void runSynchronizers() { !sourceManager.isCurrentSynchronizerFDv1Fallback() ) { sourceManager.fdv1Fallback(); + logger.info("Falling back to an FDv1 fallback synchronizer."); running = false; } } @@ -348,20 +390,55 @@ private void runSynchronizers() { e.toString(), new Date().toInstant() )); - logger.warn("Error running synchronizer: {}, will try next synchronizer, or retry.", e.toString()); + Throwable root = e instanceof ExecutionException && e.getCause() != null ? e.getCause() : e; + logger.error("Error running synchronizer '{}': {}", + synchronizer.name(), + root.getMessage() != null ? root.getMessage() : LogValues.exceptionSummary(root)); // Move to the next synchronizer. } // Get the next available synchronizer and set it active synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); } - } catch(Exception e) { + if (!closed) { + logger.warn("No more synchronizers available."); + } + } catch (Exception e) { // We are not expecting to encounter this situation, but if we do, then we should log it. logger.error("Unexpected error in data source: {}", e.toString()); - }finally { + } finally { sourceManager.close(); } } + private static String detailForError(DataSourceStatusProvider.ErrorInfo errorInfo) { + if (errorInfo == null) { + return "unknown error"; + } + if (errorInfo.getMessage() != null && !errorInfo.getMessage().isEmpty()) { + return errorInfo.getMessage(); + } + return errorInfo.toString(); + } + + private void resetSynchronizerStatusDedupe() { + lastLoggedSynchronizerDedupeName = null; + lastLoggedSynchronizerDedupeStatus = null; + } + + private void maybeLogSynchronizerStatusChange(String sourceName, SourceSignal state) { + if (state == SourceSignal.GOODBYE) { + return; + } + if (sourceName != null + && sourceName.equals(lastLoggedSynchronizerDedupeName) + && state == lastLoggedSynchronizerDedupeStatus) { + return; + } + lastLoggedSynchronizerDedupeName = sourceName; + lastLoggedSynchronizerDedupeStatus = state; + logger.info("Synchronizer '{}' reported status: {}.", sourceName, state.name()); + } + @Override public Future start() { if (!started.getAndSet(true)) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingInitializerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingInitializerImpl.java index 2e3b368f..9709b520 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingInitializerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingInitializerImpl.java @@ -16,6 +16,11 @@ public PollingInitializerImpl(FDv2Requestor requestor, LDLogger logger, Selector this.selectorSource = selectorSource; } + @Override + public String name() { + return "PollingInitializer(V2)"; + } + @Override public CompletableFuture run() { CompletableFuture pollResult = poll(selectorSource.getSelector(), true); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java index 65913b36..7d0f8292 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java @@ -36,6 +36,11 @@ public PollingSynchronizerImpl( } } + @Override + public String name() { + return "PollingSynchronizer(V2)"; + } + private void doPoll() { try { FDv2SourceResult res = poll(selectorSource.getSelector(), false).get(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index dc24e761..9ac29b06 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -94,6 +94,11 @@ public StreamingSynchronizerImpl( // The stream will lazily start when `next` is called. } + @Override + public String name() { + return "StreamingSynchronizer(V2)"; + } + private void startStream() { Headers headers = httpProperties.toHeadersBuilder() .add("Accept", "text/event-stream") diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java index c07d2635..9a25572f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java @@ -51,4 +51,16 @@ public interface Initializer extends Closeable { * @return The result of the initializer. */ CompletableFuture run(); + + /** + * Human-readable name for logging and diagnostics. Do not use this for influencing code behavior. + *

+ * Implementations may override; the default uses the runtime class simple name. + * + * @return the name + */ + default String name() { + String simple = getClass().getSimpleName(); + return simple.isEmpty() ? getClass().getName() : simple; + } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java index 298a0bff..8dc2ffbf 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -57,4 +57,16 @@ public interface Synchronizer extends Closeable { * @return a future that will complete when the next result is available */ CompletableFuture next(); + + /** + * Human-readable name for logging and diagnostics. Do not use this for influencing code behavior. + *

+ * Implementations may override; the default uses the runtime class simple name. + * + * @return the name + */ + default String name() { + String simple = getClass().getSimpleName(); + return simple.isEmpty() ? getClass().getName() : simple; + } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileInitializer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileInitializer.java index 2b336902..04f1528d 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileInitializer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileInitializer.java @@ -33,6 +33,11 @@ final class FileInitializer implements Initializer { this.synchronizer = new FileSynchronizer(sources, false, duplicateKeysHandling, logger, persist); } + @Override + public String name() { + return "FileInitializer(V2)"; + } + @Override public CompletableFuture run() { return synchronizer.next().thenApply(result -> { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileSynchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileSynchronizer.java index a1a7d508..1936ddf6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileSynchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileSynchronizer.java @@ -61,6 +61,11 @@ final class FileSynchronizer extends FileDataSourceBase implements Synchronizer this.fileWatcher = fw; } + @Override + public String name() { + return "FileSynchronizer(V2)"; + } + @Override public CompletableFuture next() { if (!started.getAndSet(true)) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java index aea4aaf8..fcd21f7b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/TestDataV2.java @@ -295,6 +295,11 @@ private final class TestDataV2SynchronizerImpl implements Synchronizer { private final AtomicBoolean initialSent = new AtomicBoolean(false); + @Override + public String name() { + return "TestData(V2)"; + } + void put(FDv2SourceResult result, CompletableFuture completion) { resultQueue.put(result); try { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2DataSourceTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2DataSourceTest.java index 68acf202..99b72c6c 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2DataSourceTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2DataSourceTest.java @@ -1,7 +1,9 @@ package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableList; +import com.launchdarkly.logging.LDLogLevel; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogCapture; import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.internal.collections.IterableAsyncQueue; import com.launchdarkly.sdk.fdv2.Selector; @@ -2677,6 +2679,198 @@ public void fdv1FallbackOnlyCalledOncePerDataSource() throws Exception { assertNull("FDv1 fallback should only be called once", secondCall); } + @Test + public void orchestrationLogging_warnsWhenNoInitializersOrSynchronizersConfigured() throws Exception { + executor = Executors.newScheduledThreadPool(1); + MockDataSourceUpdateSink sink = new MockDataSourceUpdateSink(); + FDv2DataSource dataSource = new FDv2DataSource( + ImmutableList.of(), + ImmutableList.of(), + null, + sink, + Thread.NORM_PRIORITY, + testLogger, + executor, + 120, + 300 + ); + resourcesToClose.add(dataSource); + dataSource.start().get(2, TimeUnit.SECONDS); + assertTrue( + logTextContains(logCapture, LDLogLevel.WARN, "no initializers or synchronizers configured") + ); + } + + @Test + public void orchestrationLogging_logsInitializerStartSuccessAndErrorOnException() throws Exception { + executor = Executors.newScheduledThreadPool(2); + MockDataSourceUpdateSink sink = new MockDataSourceUpdateSink(); + CompletableFuture first = new CompletableFuture<>(); + first.completeExceptionally(new RuntimeException("first-init-failure")); + CompletableFuture second = CompletableFuture.completedFuture( + FDv2SourceResult.changeSet(makeChangeSet(true), false) + ); + ImmutableList> initializers = ImmutableList.of( + () -> new MockInitializer(first, "first-init"), + () -> new MockInitializer(second, "second-init") + ); + FDv2DataSource dataSource = new FDv2DataSource( + initializers, + ImmutableList.of(), + null, + sink, + Thread.NORM_PRIORITY, + testLogger, + executor, + 120, + 300 + ); + resourcesToClose.add(dataSource); + dataSource.start().get(2, TimeUnit.SECONDS); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Initializer 'first-init' is starting.")); + assertTrue(logTextContains(logCapture, LDLogLevel.ERROR, "Error running initializer 'first-init':")); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Initializer 'second-init' is starting.")); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Initialized via 'second-init'.")); + } + + @Test + public void orchestrationLogging_logsInitializerFailedOnStatusResult() throws Exception { + executor = Executors.newScheduledThreadPool(1); + MockDataSourceUpdateSink sink = new MockDataSourceUpdateSink(); + CompletableFuture initFuture = CompletableFuture.completedFuture( + FDv2SourceResult.interrupted( + new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, + 0, + "bootstrap read failed", + Instant.now() + ), + false + ) + ); + ImmutableList> initializers = ImmutableList.of( + () -> new MockInitializer(initFuture, "file-like-init") + ); + FDv2DataSource dataSource = new FDv2DataSource( + initializers, + ImmutableList.of(), + null, + sink, + Thread.NORM_PRIORITY, + testLogger, + executor, + 120, + 300 + ); + resourcesToClose.add(dataSource); + dataSource.start().get(2, TimeUnit.SECONDS); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Initializer 'file-like-init' is starting.")); + assertTrue(logTextContains(logCapture, LDLogLevel.WARN, "Initializer 'file-like-init' failed:")); + assertTrue(logTextContains(logCapture, LDLogLevel.WARN, "bootstrap read failed")); + } + + @Test + public void orchestrationLogging_logsSynchronizerStartingStatusInterruptedAndShutdown() throws Exception { + executor = Executors.newScheduledThreadPool(2); + MockDataSourceUpdateSink sink = new MockDataSourceUpdateSink(); + IterableAsyncQueue q = new IterableAsyncQueue<>(); + q.put(FDv2SourceResult.interrupted( + new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, + 0, + "temp outage", + Instant.now() + ), + false + )); + q.put(FDv2SourceResult.shutdown()); + ImmutableList> synchronizers = ImmutableList.of( + () -> new MockQueuedSynchronizer(q, "primary-sync") + ); + FDv2DataSource dataSource = new FDv2DataSource( + ImmutableList.of(), + synchronizers, + null, + sink, + Thread.NORM_PRIORITY, + testLogger, + executor, + 120, + 300 + ); + resourcesToClose.add(dataSource); + dataSource.start().get(2, TimeUnit.SECONDS); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Synchronizer 'primary-sync' is starting.")); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Synchronizer 'primary-sync' reported status: INTERRUPTED.")); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Synchronizer 'primary-sync' reported status: SHUTDOWN.")); + } + + @Test + public void orchestrationLogging_logsSynchronizerPermanentFailureAndNoMoreSynchronizers() throws Exception { + executor = Executors.newScheduledThreadPool(1); + MockDataSourceUpdateSink sink = new MockDataSourceUpdateSink(); + IterableAsyncQueue q = new IterableAsyncQueue<>(); + q.put(makeTerminalErrorResult()); + ImmutableList> synchronizers = ImmutableList.of( + () -> new MockQueuedSynchronizer(q, "failing-sync") + ); + FDv2DataSource dataSource = new FDv2DataSource( + ImmutableList.of(), + synchronizers, + null, + sink, + Thread.NORM_PRIORITY, + testLogger, + executor, + 120, + 300 + ); + resourcesToClose.add(dataSource); + dataSource.start().get(2, TimeUnit.SECONDS); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Synchronizer 'failing-sync' is starting.")); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Synchronizer 'failing-sync' reported status: TERMINAL_ERROR.")); + assertTrue( + logTextContains( + logCapture, + LDLogLevel.WARN, + "Synchronizer 'failing-sync' permanently failed and will not be used again until application restart." + ) + ); + assertTrue(logTextContains(logCapture, LDLogLevel.WARN, "No more synchronizers available.")); + } + + @Test + public void orchestrationLogging_logsErrorWhenSynchronizerNextThrows() throws Exception { + executor = Executors.newScheduledThreadPool(1); + MockDataSourceUpdateSink sink = new MockDataSourceUpdateSink(); + // Two tiers: the first throws on next() once; the second shuts down immediately so the outer loop + // does not spin forever re-building the same throwing factory (single-tier would never block). + IterableAsyncQueue followUpQueue = new IterableAsyncQueue<>(); + followUpQueue.put(FDv2SourceResult.shutdown()); + ImmutableList> synchronizers = ImmutableList.of( + () -> new MockSynchronizer(() -> { + throw new RuntimeException("next-boom"); + }, "throwing-sync"), + () -> new MockQueuedSynchronizer(followUpQueue, "followup-sync") + ); + FDv2DataSource dataSource = new FDv2DataSource( + ImmutableList.of(), + synchronizers, + null, + sink, + Thread.NORM_PRIORITY, + testLogger, + executor, + 120, + 300 + ); + resourcesToClose.add(dataSource); + dataSource.start().get(2, TimeUnit.SECONDS); + assertTrue(logTextContains(logCapture, LDLogLevel.INFO, "Synchronizer 'throwing-sync' is starting.")); + assertTrue(logTextContains(logCapture, LDLogLevel.ERROR, "Error running synchronizer 'throwing-sync':")); + assertTrue(logTextContains(logCapture, LDLogLevel.ERROR, "next-boom")); + } + // ============================================================================ // Mock Implementations // ============================================================================ @@ -2760,15 +2954,27 @@ public void awaitApplyCount(int expectedCount, long timeout, TimeUnit unit) thro private static class MockInitializer implements Initializer { private final CompletableFuture result; private final ThrowingSupplier supplier; + private final String displayName; public MockInitializer(CompletableFuture result) { + this(result, null); + } + + public MockInitializer(CompletableFuture result, String displayName) { this.result = result; this.supplier = null; + this.displayName = displayName; } public MockInitializer(ThrowingSupplier supplier) { this.result = null; this.supplier = supplier; + this.displayName = null; + } + + @Override + public String name() { + return displayName != null ? displayName : "MockInitializer"; } @Override @@ -2794,17 +3000,33 @@ public void close() { private static class MockSynchronizer implements Synchronizer { private final CompletableFuture result; private final ThrowingSupplier supplier; + private final String displayName; private volatile boolean closed = false; private volatile boolean resultReturned = false; public MockSynchronizer(CompletableFuture result) { + this(result, null); + } + + public MockSynchronizer(CompletableFuture result, String displayName) { this.result = result; this.supplier = null; + this.displayName = displayName; } public MockSynchronizer(ThrowingSupplier supplier) { + this(supplier, null); + } + + public MockSynchronizer(ThrowingSupplier supplier, String displayName) { this.result = null; this.supplier = supplier; + this.displayName = displayName; + } + + @Override + public String name() { + return displayName != null ? displayName : "MockSynchronizer"; } @Override @@ -2838,18 +3060,34 @@ public void close() { private static class MockQueuedSynchronizer implements Synchronizer { private final IterableAsyncQueue results; + private final String displayName; private volatile boolean closed = false; public MockQueuedSynchronizer(BlockingQueue results) { + this(results, null); + } + + public MockQueuedSynchronizer(BlockingQueue results, String displayName) { // Convert BlockingQueue to IterableAsyncQueue by draining it this.results = new IterableAsyncQueue<>(); + this.displayName = displayName; java.util.ArrayList temp = new java.util.ArrayList<>(); results.drainTo(temp); temp.forEach(this.results::put); } public MockQueuedSynchronizer(IterableAsyncQueue results) { + this(results, null); + } + + public MockQueuedSynchronizer(IterableAsyncQueue results, String displayName) { this.results = results; + this.displayName = displayName; + } + + @Override + public String name() { + return displayName != null ? displayName : "MockQueuedSynchronizer"; } public void addResult(FDv2SourceResult result) { @@ -2873,6 +3111,15 @@ public void close() { } } + private static boolean logTextContains(LogCapture capture, LDLogLevel level, String substring) { + for (LogCapture.Message m : capture.getMessages()) { + if (m.getLevel() == level && m.getText() != null && m.getText().contains(substring)) { + return true; + } + } + return false; + } + @FunctionalInterface private interface ThrowingSupplier { T get() throws Exception;