diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java index 72ad5097..fdaa3093 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java @@ -138,6 +138,7 @@ public DataSystemBuilder persistentStore(ComponentConfigurer persiste * * @param overrideSource the override source configuration, or null for none * @return a reference to the builder + * @see FileOverrides * @since 7.18.0 */ public DataSystemBuilder overrides(ComponentConfigurer overrideSource) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java new file mode 100644 index 00000000..4d056392 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceBuilder.java @@ -0,0 +1,186 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; + +import java.nio.file.FileSystems; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Configures the file-based override source. Obtain an instance with {@link FileOverrides#source()}, + * call {@link #filePaths(String...)} or {@link #filePaths(Path...)} to specify the files, adjust + * any other options, and pass the builder to + * {@link DataSystemBuilder#overrides(ComponentConfigurer)}. + *

+ * For more details, see {@link FileOverrides}. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @since 7.18.0 + */ +public final class FileOverrideSourceBuilder implements ComponentConfigurer { + private final List filePaths = new ArrayList<>(); + private FileData.DuplicateKeysHandling duplicateKeysHandling = FileData.DuplicateKeysHandling.FAIL; + private ChangeDetection changeDetection = ChangeDetection.POLLING; + private Duration pollInterval = FileOverrides.DEFAULT_POLL_INTERVAL; + + FileOverrideSourceBuilder() {} + + /** + * Adds any number of files to load overrides from, specifying each file path as a string. The + * order is significant: it determines which file wins under the duplicate keys handling when the + * same key appears in more than one file. + *

+ * A file whose name ends in {@code .yaml} or {@code .yml}, in any case, is parsed as YAML, and any + * other file is parsed as JSON. A file is read as UTF-8, or in the encoding that a byte order mark + * names. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @param filePaths path(s) to the file(s); may be absolute or relative to the current working directory + * @return the same builder + * @throws InvalidPathException if one of the parameters is not a valid file path + */ + public FileOverrideSourceBuilder filePaths(String... filePaths) throws InvalidPathException { + for (String p : filePaths) { + this.filePaths.add(Paths.get(p)); + } + return this; + } + + /** + * Adds any number of files to load overrides from, specifying each file path as a Path. The + * order is significant: it determines which file wins under the duplicate keys handling when the + * same key appears in more than one file. + *

+ * A file whose name ends in {@code .yaml} or {@code .yml}, in any case, is parsed as YAML, and any + * other file is parsed as JSON. A file is read as UTF-8, or in the encoding that a byte order mark + * names. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @param filePaths path(s) to the file(s); may be absolute or relative to the current working directory + * @return the same builder + */ + public FileOverrideSourceBuilder filePaths(Path... filePaths) { + for (Path p : filePaths) { + this.filePaths.add(p); + } + return this; + } + + /** + * Specifies how to handle the same key appearing in more than one file. + *

+ * With {@link FileData.DuplicateKeysHandling#FAIL}, the default, a reload fails if the same flag or + * segment key appears in more than one file, and the previously loaded overrides stay in effect. + * With {@link FileData.DuplicateKeysHandling#IGNORE}, the entry from the first configured file that + * defines the key is kept and the others are discarded. A null value selects the default. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @param duplicateKeysHandling specifies how to handle duplicate keys + * @return the same builder + */ + public FileOverrideSourceBuilder duplicateKeysHandling(FileData.DuplicateKeysHandling duplicateKeysHandling) { + this.duplicateKeysHandling = duplicateKeysHandling == null + ? FileData.DuplicateKeysHandling.FAIL : duplicateKeysHandling; + return this; + } + + /** + * Selects how the source detects file changes. The default is {@link ChangeDetection#POLLING}. + * The two modes are alternatives, so setting one replaces the other. {@link ChangeDetection#WATCHING} + * requires every file path to be on the default file system; a path of another file system provider, + * such as a zip file system, can be polled but not watched, and {@link #build(ClientContext)} rejects it. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @param changeDetection the change detection mode + * @return the same builder + */ + public FileOverrideSourceBuilder changeDetection(ChangeDetection changeDetection) { + this.changeDetection = changeDetection; + return this; + } + + /** + * Sets the interval between examinations of the files in {@link ChangeDetection#POLLING} mode. + * {@link ChangeDetection#WATCHING} mode ignores it. The default is + * {@link FileOverrides#DEFAULT_POLL_INTERVAL}. An interval below + * {@link FileOverrides#MINIMUM_POLL_INTERVAL} is raised to the minimum. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @param pollInterval the polling interval + * @return the same builder + */ + public FileOverrideSourceBuilder pollInterval(Duration pollInterval) { + this.pollInterval = pollInterval; + return this; + } + + /** + * Called internally by the SDK to create the override source. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @param context the client context + * @return the override source + * @throws IllegalArgumentException if no file paths were specified, or the change detection mode + * or the poll interval is null + */ + @Override + public OverrideSource build(ClientContext context) { + if (filePaths.isEmpty()) { + throw new IllegalArgumentException("no file paths were specified for the file-based override source"); + } + if (changeDetection == null) { + throw new IllegalArgumentException("a change detection mode is required for the file-based override source"); + } + if (pollInterval == null) { + throw new IllegalArgumentException("a poll interval is required for the file-based override source"); + } + // Change notifications come from the default file system's watch service, which cannot watch a + // path of another file system provider, such as a zip file system. That is a configuration + // error, reported here rather than when the source starts. Polling reads such a path through its + // own provider. + if (changeDetection == ChangeDetection.WATCHING) { + for (Path p : filePaths) { + if (!p.getFileSystem().equals(FileSystems.getDefault())) { + throw new IllegalArgumentException( + "the file-based override source can watch only files on the default file system; use polling for " + p); + } + } + } + LDLogger logger = context.getBaseLogger().subLogger("FileOverrideSource"); + + // A path listed more than once would make every entry of that file a duplicate of itself, so + // the repeats are dropped and the first occurrence keeps its place in the order. + Set absolutePaths = new LinkedHashSet<>(); + for (Path p : filePaths) { + absolutePaths.add(p.toAbsolutePath().normalize()); + } + if (absolutePaths.size() != filePaths.size()) { + logger.warn("File paths list the same file more than once; the repeats are ignored"); + } + + Duration effectivePollInterval = pollInterval; + if (changeDetection == ChangeDetection.POLLING && pollInterval.compareTo(FileOverrides.MINIMUM_POLL_INTERVAL) < 0) { + logger.warn("Poll interval {} is below the minimum; using {}", pollInterval, FileOverrides.MINIMUM_POLL_INTERVAL); + effectivePollInterval = FileOverrides.MINIMUM_POLL_INTERVAL; + } + + return new FileOverrideSourceImpl(new ArrayList<>(absolutePaths), duplicateKeysHandling, changeDetection, effectivePollInterval, logger); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java new file mode 100644 index 00000000..aeae0563 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceImpl.java @@ -0,0 +1,207 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; +import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.FileSummary; +import com.launchdarkly.sdk.server.integrations.OverrideFileLoader.LoadResult; +import com.launchdarkly.sdk.server.subsystems.OverrideSink; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * The file-based override source. It reads one or more files in the file data source document + * format, merges them in the configured order, and supplies the result to the override sink as + * one snapshot. It reloads when the files change, by polling or by watching. + *

+ * A configured file that does not exist contributes no overrides. A file that exists but cannot + * be read or parsed fails that reload: the sink is not called, so the last good overrides stay in + * effect, and the reload is retried. A file whose content is in effect and is found empty may be in + * the middle of an in-place rewrite, so it is held back until it has stayed empty, without being + * written, for the retry delay, and for at most five retry delays from the first load that held it + * back. + */ +final class FileOverrideSourceImpl implements OverrideSource { + /** + * Creates the watcher for watching mode. Tests substitute one that fails. + */ + interface WatcherFactory { + FileDataWatcher create(List paths, LDLogger logger) throws IOException; + } + + private final List paths; + private final ChangeDetection changeDetection; + private final Duration pollInterval; + private final Duration retryDelay; + private final LDLogger logger; + private final OverrideFileLoader loader; + private final WatcherFactory watcherFactory; + + private volatile FileDataReloader reloader; + private volatile FileDataPoller poller; + private volatile FileDataWatcher watcher; + private final AtomicBoolean closed = new AtomicBoolean(false); + + FileOverrideSourceImpl( + List paths, + FileData.DuplicateKeysHandling duplicateKeysHandling, + ChangeDetection changeDetection, + Duration pollInterval, + LDLogger logger + ) { + this(paths, duplicateKeysHandling, changeDetection, pollInterval, FileDataReloader.DEFAULT_RETRY_DELAY, logger, + FileDataWatcher::create); + } + + /** + * Creates a source with a specific retry delay and watcher factory. Visible for tests. + */ + FileOverrideSourceImpl( + List paths, + FileData.DuplicateKeysHandling duplicateKeysHandling, + ChangeDetection changeDetection, + Duration pollInterval, + Duration retryDelay, + LDLogger logger, + WatcherFactory watcherFactory + ) { + this.paths = new ArrayList<>(paths); + this.changeDetection = changeDetection; + this.pollInterval = pollInterval; + this.retryDelay = retryDelay; + this.logger = logger; + this.loader = new OverrideFileLoader(paths, duplicateKeysHandling); + this.watcherFactory = watcherFactory; + } + + /** + * Performs the initial load synchronously, so overrides present in the files are in effect when + * the client constructor returns, then starts change detection. A file that does not exist yet + * contributes no overrides. A file that cannot be read or parsed is not fatal: the client runs + * with the last good overrides, the failure is logged, and the retry, plus the change signal, + * recovers once the file is readable. + */ + @Override + public void start(OverrideSink sink) { + reloader = new FileDataReloader( + loader, + new FileDataReloader.Handler() { + @Override + public void apply(LoadResult result) { + sink.setOverrides(result.getData()); + logOverridesInEffect(result); + } + + @Override + public void onError(FileDataException e) { + // The reloader has logged the failure. The last good overrides stay in effect. + } + }, + logger, + FileDataReloader.DEFAULT_DEBOUNCE_DELAY, + retryDelay, + true + ); + reloader.reloadNow(); + + if (closed.get()) { + return; + } + switch (changeDetection) { + case WATCHING: + try { + FileDataWatcher w = watcherFactory.create(paths, logger); + watcher = w; + w.start(reloader::trigger); + } catch (IOException e) { + // The watch service itself cannot be created, for example because the limit on inotify + // instances is reached. Without change detection a later edit would never take effect, so + // the source polls the files instead, no more often than polling mode allows. + Duration interval = pollInterval.compareTo(FileOverrides.MINIMUM_POLL_INTERVAL) < 0 + ? FileOverrides.MINIMUM_POLL_INTERVAL : pollInterval; + logger.error("Unable to watch override files; polling them instead every {}: {}", interval, + LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + poller = new FileDataPoller(paths, interval, reloader::trigger, logger); + } + break; + case POLLING: + default: + poller = new FileDataPoller(paths, pollInterval, reloader::trigger, logger); + break; + } + } + + /** + * Returns the interval between file examinations in polling mode. Visible for tests. + * + * @return the effective poll interval + */ + Duration getPollInterval() { + return pollInterval; + } + + // Reports the overrides now in effect and the file each came from. The reloader applies a + // snapshot only when the content changed, so this logs each change once. + private void logOverridesInEffect(LoadResult result) { + List details = new ArrayList<>(result.getFiles().size()); + for (FileSummary file : result.getFiles()) { + if (!file.isPresent()) { + details.add(file.getPath() + ": absent"); + } else if (file.getFlags() == 0 && file.getSegments() == 0) { + details.add(file.getPath() + ": no entries"); + } else { + details.add(file.getPath() + ": " + countsText(file.getFlags(), file.getSegments())); + } + } + String fileDetails = String.join("; ", details); + if (result.getFlagCount() == 0 && result.getSegmentCount() == 0) { + logger.info("Flag overrides: none in effect ({})", fileDetails); + return; + } + logger.info("Flag overrides in effect: {} ({})", countsText(result.getFlagCount(), result.getSegmentCount()), + fileDetails); + } + + // Formats flag and segment counts, for example "2 flags, 1 segment". + static String countsText(int flags, int segments) { + List parts = new ArrayList<>(2); + if (flags > 0) { + parts.add(pluralize(flags, "flag")); + } + if (segments > 0) { + parts.add(pluralize(segments, "segment")); + } + return String.join(", ", parts); + } + + private static String pluralize(int count, String noun) { + return count == 1 ? "1 " + noun : count + " " + noun + "s"; + } + + @Override + public void close() { + if (closed.getAndSet(true)) { + return; + } + FileDataWatcher w = watcher; + if (w != null) { + w.close(); + } + FileDataPoller p = poller; + if (p != null) { + p.close(); + } + FileDataReloader r = reloader; + if (r != null) { + r.close(); + } + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java new file mode 100644 index 00000000..02499f49 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileOverrides.java @@ -0,0 +1,123 @@ +package com.launchdarkly.sdk.server.integrations; + +import java.time.Duration; + +/** + * Integration between the LaunchDarkly SDK and file-based flag overrides. + *

+ * Flag overrides are currently experimental and subject to change. + *

+ * Overrides are flag and segment definitions that take precedence over data received from + * LaunchDarkly at evaluation time, on a per-key basis. They exist for resilience during an + * incident. An operator can force one or more flags to a known state on a running application, + * whether or not the application can reach LaunchDarkly. The override stays in effect until the + * operator removes it. Flags not present in the override data are completely unaffected. + *

+ * This class provides one override source: {@link #source()}, which reads overrides from local + * files and reloads them as the files change. Configure it with the data system builder: + *


+ *     LDConfig config = new LDConfig.Builder()
+ *         .dataSystem(Components.dataSystem().defaultMode()
+ *             .overrides(FileOverrides.source().filePaths("/etc/launchdarkly/overrides.json")))
+ *         .build();
+ * 
+ *

+ * An evaluation that an override affects is marked. The marking is direct or transitive: it + * applies when the evaluated flag, a prerequisite at any depth, or a segment read during the + * evaluation came from the override layer. {@link com.launchdarkly.sdk.EvaluationReason#isOverrideAffected()} + * reports the marking. Marked evaluations appear in analytics summary events only, under separate + * counters, so LaunchDarkly can distinguish them. They produce no individual evaluation events. + * + * @see FileOverrideSourceBuilder + * @see DataSystemBuilder#overrides(com.launchdarkly.sdk.server.subsystems.ComponentConfigurer) + * @since 7.18.0 + */ +public abstract class FileOverrides { + /** + * Selects how the file-based override source learns that a file changed. The two modes are + * alternatives. Flag overrides are currently experimental and subject to change. + * + * @see FileOverrideSourceBuilder#changeDetection(ChangeDetection) + */ + public enum ChangeDetection { + /** + * The source examines the files on a fixed interval and reloads when the modification time or + * the size of a file changes. Polling works on every file system, including network mounts and + * directories whose contents are swapped through symbolic links, as Kubernetes does for mounted + * ConfigMaps. It is the default. + * Flag overrides are currently experimental and subject to change. + */ + POLLING, + + /** + * The source reloads in response to file system change notifications. It reacts faster than + * polling. It depends on notifications, which some file systems do not deliver reliably. + * Flag overrides are currently experimental and subject to change. + */ + WATCHING + } + + /** + * The interval at which the file source examines the files for changes in + * {@link ChangeDetection#POLLING} mode when no interval was specified. Because the source reads + * local files rather than contacting a service, a short interval keeps an override responsive + * during an incident at negligible cost. + * Flag overrides are currently experimental and subject to change. + */ + public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(1); + + /** + * The shortest allowed polling interval. A configured interval below this is raised to it. The + * minimum exists only to prevent a pathological tight loop over the file system. + * Flag overrides are currently experimental and subject to change. + */ + public static final Duration MINIMUM_POLL_INTERVAL = Duration.ofSeconds(1); + + private FileOverrides() {} + + /** + * Creates a {@link FileOverrideSourceBuilder} for a file-based override source. The source reads + * flag and segment overrides from one or more local files and reloads them as the files change. + *

+ * The files use the same document format as the file data source (see {@link FileData}): each + * file is a JSON or YAML document with optional {@code "flags"}, {@code "flagValues"}, and + * {@code "segments"} members. A file whose name ends in {@code .yaml} or {@code .yml}, in any case, + * is YAML, and any other file is JSON; the YAML parser does not accept every JSON document, so give + * a JSON file a name that does not end in {@code .yaml} or {@code .yml}. A file is read as UTF-8, + * or in the encoding that a byte order mark names. A {@code "flagValues"} entry is expanded into a + * full flag definition that is on and serves the given value as its single variation by fallthrough + * for every context. Such a flag is not available to client-side SDKs, so + * {@link com.launchdarkly.sdk.server.LDClient#allFlagsState} with + * {@link com.launchdarkly.sdk.server.FlagsStateOption#CLIENT_SIDE_ONLY} leaves it out, even when + * the flag it overrides is available to them. To override such a flag, define it in + * {@code "flags"} with {@code "clientSide": true}. + * When multiple files are configured, their entries are combined in the configured order, and + * the duplicate keys handling decides which file wins for a key that appears more than once. + *

+ * A reload replaces the entire override set, so removing an entry from the files removes the + * override. A configured file that does not exist contributes no overrides, so deleting every file + * removes them all. A file that exists but cannot be read or parsed makes the reload fail: the + * overrides in effect stay, the source logs the failure, and it recovers on its own once the file + * can be read again. + *

+ * Files can be rewritten in place: if the source finds a file empty while it is being rewritten, it + * keeps the overrides in effect until the new content is written. To remove a file's overrides, + * delete the file or write a document with no entries, such as {}. An empty JSON file + * is not a valid document. For a YAML file, replacing its contents in one operation, such as by + * renaming a fully written temporary file over it, avoids briefly applying a partly written file. + *

+ * Whenever the set of overrides in effect changes, including at startup, the source logs the + * overrides in effect and what each configured file supplied, at Info level. + *

+ * By default the source polls the files for changes once per second. See + * {@link FileOverrideSourceBuilder#changeDetection(ChangeDetection)} and + * {@link FileOverrideSourceBuilder#pollInterval(Duration)}. + *

+ * Flag overrides are currently experimental and subject to change. + * + * @return a builder for the file-based override source + */ + public static FileOverrideSourceBuilder source() { + return new FileOverrideSourceBuilder(); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSource.java index dbaf0452..ba55d2b7 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/OverrideSource.java @@ -16,7 +16,8 @@ * {@link com.launchdarkly.sdk.server.integrations.DataSystemBuilder#overrides(ComponentConfigurer)}. * The SDK closes the source when the client is closed. An exception thrown from {@link #close()} is * logged and does not stop the client from shutting down. A snapshot that the source supplies after - * the client begins to close it is ignored. + * the client begins to close it is ignored. The SDK provides a file-based source; see + * {@link com.launchdarkly.sdk.server.integrations.FileOverrides}. *

* Flag overrides are currently experimental and subject to change. * diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverridesTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverridesTest.java index 11b79578..10d64336 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverridesTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverridesTest.java @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.integrations.DataSystemBuilder; +import com.launchdarkly.sdk.server.integrations.FileOverrides; import com.launchdarkly.sdk.server.integrations.TestDataV2; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; @@ -21,10 +22,15 @@ import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration.DataStoreMode; import com.launchdarkly.sdk.server.subsystems.OverrideSource; +import com.launchdarkly.testhelpers.TempDir; import org.junit.Test; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Map; @@ -168,6 +174,25 @@ public void allFlagsStateContainsOnlyOverridesWhenClientIsNotInitialized() throw } } + @Test + public void clientSideAllFlagsStateKeepsADefinitionWithClientSideButNotAFlagValuesEntry() throws Exception { + // A flagValues entry is not available to client-side SDKs. A full definition that sets + // clientSide is, so that is how an operator overrides a flag that client-side SDKs use. + try (TempDir dir = TempDir.create()) { + Path file = dir.getPath().resolve("overrides.json"); + Files.write(file, ("{\"flagValues\": {\"value-flag\": true}, \"flags\": {\"full-flag\": {" + + "\"key\": \"full-flag\", \"version\": 1, \"on\": true, \"variations\": [true], " + + "\"fallthrough\": {\"variation\": 0}, \"clientSide\": true}}}").getBytes(StandardCharsets.UTF_8)); + try (LDClient client = new LDClient(SDK_KEY, uninitializedConfig(FileOverrides.source().filePaths(file)))) { + assertEquals(new HashSet<>(Arrays.asList("full-flag", "value-flag")), + client.allFlagsState(CONTEXT).toValuesMap().keySet()); + FeatureFlagsState clientSide = client.allFlagsState(CONTEXT, FlagsStateOption.CLIENT_SIDE_ONLY); + assertTrue(clientSide.isValid()); + assertEquals(Collections.singletonMap("full-flag", LDValue.of(true)), clientSide.toValuesMap()); + } + } + } + @Test public void allFlagsStateOverridesOnlyWarningIsLoggedOnce() throws Exception { TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", "override-value"))); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceInPlaceRewriteTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceInPlaceRewriteTest.java new file mode 100644 index 00000000..39898115 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceInPlaceRewriteTest.java @@ -0,0 +1,261 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.TestComponents; +import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; +import com.launchdarkly.testhelpers.TempDir; + +import org.junit.After; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The file-based override source while its file is rewritten in place: the file is truncated, stays + * empty for a while, and then gets the new content. The overrides in effect must go from the old + * content to the new content without a moment in between without the override. + */ +@SuppressWarnings("javadoc") +public class FileOverrideSourceInPlaceRewriteTest { + private static final long WAIT_MILLIS = 10000; + private static final LDContext CONTEXT = LDContext.create("user-key"); + + // A watching source reads the file within the debounce delay of the truncation. The retry delay + // leaves a margin over the pause for a loaded machine. + private static final Duration WATCHING_PAUSE = Duration.ofMillis(300); + private static final Duration WATCHING_RETRY_DELAY = Duration.ofMillis(1500); + + // A polling source examines the file once per second. A pause longer than that makes sure that one + // examination finds the file empty, and the retry delay is longer than the pause by a margin. + private static final Duration POLL_INTERVAL = Duration.ofSeconds(1); + private static final Duration POLLING_PAUSE = Duration.ofMillis(1200); + private static final Duration POLLING_RETRY_DELAY = Duration.ofMillis(2500); + + private final LogCapture logCapture = Logs.capture(); + private final ClientContext context = TestComponents.clientContext("sdk-key", + new LDConfig.Builder().logging(Components.logging(logCapture).level(LDLogLevel.DEBUG)).build()); + private final List closeables = new ArrayList<>(); + + @After + public void close() throws Exception { + for (AutoCloseable c : closeables) { + c.close(); + } + } + + private static String doc(Path file, String value) { + return file.toString().endsWith(".yaml") ? "flagValues:\n flag: \"" + value + "\"\n" + : "{\"flagValues\":{\"flag\":\"" + value + "\"}}"; + } + + private static Path write(TempDir dir, String name, String value) throws Exception { + Path p = dir.getPath().resolve(name); + Files.write(p, doc(p, value).getBytes(StandardCharsets.UTF_8)); + return p; + } + + private static FileOverrideSourceImpl source(Path file, ChangeDetection mode, Duration retryDelay, + ClientContext context) { + return new FileOverrideSourceImpl(Collections.singletonList(file.toAbsolutePath()), + FileData.DuplicateKeysHandling.FAIL, mode, POLL_INTERVAL, retryDelay, + context.getBaseLogger().subLogger("FileOverrideSource"), FileDataWatcher::create); + } + + // The value of the override "flag" in a snapshot, or "none" when the snapshot does not have it. + private static String flagValue(Map> snapshot) { + ItemDescriptor item = snapshot.get(FEATURES.getName()).get("flag"); + if (item == null) { + return "none"; + } + return LDValue.parse(FEATURES.serialize(item)).get("variations").get(0).stringValue(); + } + + /** + * The writes a test makes to the file once the source has started. + */ + private interface Rewrites { + void run() throws Exception; + } + + // Starts the source on a file whose override has the value "a", makes the writes, and returns the + // value of the override in every snapshot the source supplies until one has the final value, and + // in any snapshot shortly after that. + private List appliedThroughRewrites(Path file, OverrideSource source, Rewrites rewrites, + String finalValue) throws Exception { + FileOverrideSourceTest.RecordingSink sink = new FileOverrideSourceTest.RecordingSink(); + closeables.add(source); + source.start(sink); + List applied = new ArrayList<>(); + applied.add(flagValue(sink.await())); + // Let the start of change detection settle. + Thread.sleep(300); + + rewrites.run(); + + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (!applied.get(applied.size() - 1).equals(finalValue) && System.currentTimeMillis() < deadline) { + Map> snapshot = sink.snapshots.poll(100, TimeUnit.MILLISECONDS); + if (snapshot != null) { + applied.add(flagValue(snapshot)); + } + } + // A later snapshot would be wrong too. + Map> late = sink.snapshots.poll(500, TimeUnit.MILLISECONDS); + if (late != null) { + applied.add(flagValue(late)); + } + return applied; + } + + private void sourceKeepsTheOverrideThroughARewrite(String name, ChangeDetection mode, Duration pause, + Duration retryDelay) throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, name, "a"); + List applied = appliedThroughRewrites(file, source(file, mode, retryDelay, context), + () -> InPlaceWrites.rewrite(file, doc(file, "b"), pause), "b"); + assertEquals(Arrays.asList("a", "b"), applied); + } + } + + // The override must have a value in every snapshot, and the last one must be the final value. An + // intermediate value may or may not be applied. + private static void assertNeverWithoutTheOverride(List applied, String finalValue) { + assertEquals(applied.toString(), "a", applied.get(0)); + assertEquals(applied.toString(), finalValue, applied.get(applied.size() - 1)); + assertTrue(applied.toString(), !applied.contains("none")); + } + + @Test + public void watchingSourceKeepsTheOverrideThroughARewriteOfAJsonFile() throws Exception { + sourceKeepsTheOverrideThroughARewrite("overrides.json", ChangeDetection.WATCHING, WATCHING_PAUSE, + WATCHING_RETRY_DELAY); + } + + @Test + public void watchingSourceKeepsTheOverrideThroughARewriteOfAYamlFile() throws Exception { + sourceKeepsTheOverrideThroughARewrite("overrides.yaml", ChangeDetection.WATCHING, WATCHING_PAUSE, + WATCHING_RETRY_DELAY); + } + + @Test + public void pollingSourceKeepsTheOverrideThroughARewriteOfAJsonFile() throws Exception { + sourceKeepsTheOverrideThroughARewrite("overrides.json", ChangeDetection.POLLING, POLLING_PAUSE, + POLLING_RETRY_DELAY); + } + + @Test + public void pollingSourceKeepsTheOverrideThroughARewriteOfAYamlFile() throws Exception { + sourceKeepsTheOverrideThroughARewrite("overrides.yaml", ChangeDetection.POLLING, POLLING_PAUSE, + POLLING_RETRY_DELAY); + } + + @Test + public void watchingSourceKeepsTheOverrideThroughTwoQuickRewrites() throws Exception { + // The write of b and the second truncation fall in one debounce window, so no load reads b. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", "a"); + List applied = appliedThroughRewrites(file, + source(file, ChangeDetection.WATCHING, WATCHING_RETRY_DELAY, context), () -> { + InPlaceWrites.rewrite(file, doc(file, "b"), Duration.ofMillis(150)); + InPlaceWrites.rewrite(file, doc(file, "c"), WATCHING_PAUSE); + }, "c"); + assertNeverWithoutTheOverride(applied, "c"); + } + } + + @Test + public void defaultSourceKeepsTheOverrideThroughRewritesASecondApart() throws Exception { + // The default source polls once per second and retries after one second. Two rewrites about a + // second apart can let a poll or a retry find the second truncation. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", "a"); + List applied = appliedThroughRewrites(file, FileOverrides.source().filePaths(file).build(context), + () -> { + InPlaceWrites.rewrite(file, doc(file, "b"), WATCHING_PAUSE); + Thread.sleep(700); + InPlaceWrites.rewrite(file, doc(file, "c"), WATCHING_PAUSE); + }, "c"); + assertNeverWithoutTheOverride(applied, "c"); + } + } + + private void clientServesTheOverrideThroughARewrite(String name) throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, name, "a"); + TestDataV2 td = TestDataV2.synchronizer(); + td.update(td.flag("flag").valueForAll(LDValue.of("launchdarkly-value"))); + LDConfig config = new LDConfig.Builder() + .dataSystem(Components.dataSystem().custom().synchronizers(td) + .overrides(ctx -> source(file, ChangeDetection.WATCHING, WATCHING_RETRY_DELAY, ctx))) + .events(Components.noEvents()) + .logging(Components.logging(logCapture).level(LDLogLevel.DEBUG)) + .build(); + try (LDClient client = new LDClient("sdk-key", config)) { + // The distinct values the client serves, in order. + List served = Collections.synchronizedList(new ArrayList<>()); + AtomicBoolean stop = new AtomicBoolean(false); + Thread sampler = new Thread(() -> { + while (!stop.get()) { + String value = client.stringVariation("flag", CONTEXT, "default"); + if (served.isEmpty() || !served.get(served.size() - 1).equals(value)) { + served.add(value); + } + try { + Thread.sleep(1); + } catch (InterruptedException e) { + return; + } + } + }); + sampler.start(); + try { + Thread.sleep(300); + InPlaceWrites.rewrite(file, doc(file, "b"), WATCHING_PAUSE); + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (!served.contains("b") && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + Thread.sleep(500); + } finally { + stop.set(true); + sampler.join(); + } + assertEquals(Arrays.asList("a", "b"), served); + } + } + } + + @Test + public void clientServesTheOverrideThroughARewriteOfAJsonFile() throws Exception { + clientServesTheOverrideThroughARewrite("overrides.json"); + } + + @Test + public void clientServesTheOverrideThroughARewriteOfAYamlFile() throws Exception { + clientServesTheOverrideThroughARewrite("overrides.yaml"); + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java new file mode 100644 index 00000000..de95ee6e --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileOverrideSourceTest.java @@ -0,0 +1,584 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.TestComponents; +import com.launchdarkly.sdk.server.integrations.FileOverrides.ChangeDetection; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.OverrideSink; +import com.launchdarkly.sdk.server.subsystems.OverrideSource; +import com.launchdarkly.testhelpers.TempDir; + +import org.junit.After; +import org.junit.Test; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class FileOverrideSourceTest { + private static final long WAIT_MILLIS = 10000; + + private final LogCapture logCapture = Logs.capture(); + private final ClientContext context = TestComponents.clientContext("sdk-key", + new LDConfig.Builder().logging(Components.logging(logCapture).level(LDLogLevel.DEBUG)).build()); + private final List sources = new ArrayList<>(); + + @After + public void closeSources() throws Exception { + for (OverrideSource s : sources) { + s.close(); + } + } + + /** + * Records every snapshot the source supplies, as a map of kind name to key to item. + */ + static final class RecordingSink implements OverrideSink { + final BlockingQueue>> snapshots = new LinkedBlockingQueue<>(); + + @Override + public void setOverrides(Iterable>> data) { + Map> snapshot = new HashMap<>(); + snapshot.put(FEATURES.getName(), new HashMap<>()); + snapshot.put(SEGMENTS.getName(), new HashMap<>()); + for (Map.Entry> kind : data) { + Map items = snapshot.computeIfAbsent(kind.getKey().getName(), k -> new HashMap<>()); + for (Map.Entry item : kind.getValue().getItems()) { + items.put(item.getKey(), item.getValue()); + } + } + snapshots.add(snapshot); + } + + Map> await() throws InterruptedException { + Map> s = snapshots.poll(WAIT_MILLIS, TimeUnit.MILLISECONDS); + if (s == null) { + throw new AssertionError("timed out waiting for an override snapshot"); + } + return s; + } + + void assertNoSnapshot(long millis) throws InterruptedException { + assertNull(snapshots.poll(millis, TimeUnit.MILLISECONDS)); + } + } + + private OverrideSource start(FileOverrideSourceBuilder builder, RecordingSink sink) { + OverrideSource source = builder.build(context); + sources.add(source); + source.start(sink); + return source; + } + + private static String flagValue(Map> snapshot, String key) { + ItemDescriptor item = snapshot.get(FEATURES.getName()).get(key); + if (item == null) { + return null; + } + LDValue json = LDValue.parse(FEATURES.serialize(item)); + return json.get("variations").get(0).stringValue(); + } + + private static Path write(TempDir dir, String name, String contents) throws Exception { + Path p = dir.getPath().resolve(name); + Files.write(p, contents.getBytes()); + return p; + } + + private static String docWith(String key, String value) { + return "{\"flagValues\":{\"" + key + "\":\"" + value + "\"}}"; + } + + @Test + public void buildRequiresFilePaths() { + try { + FileOverrides.source().build(context); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("no file paths")); + } + } + + @Test + public void buildRequiresChangeDetectionMode() { + try { + FileOverrides.source().filePaths("x.json").changeDetection(null).build(context); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("change detection")); + } + } + + @Test + public void buildRequiresPollInterval() { + try { + FileOverrides.source().filePaths("x.json").pollInterval(null).build(context); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("poll interval")); + } + } + + @Test + public void buildRejectsWatchingAPathOnAnotherFileSystem() throws Exception { + // The default watch service cannot watch such a path. Rejecting it here reports the + // configuration error when the client is created, instead of a failure when the source starts. + try (TempDir dir = TempDir.create(); FileSystem zip = newZipFileSystem(dir)) { + Path file = zip.getPath("/overrides.json"); + try { + FileOverrides.source().filePaths(file).changeDetection(ChangeDetection.WATCHING).build(context); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("can watch only files on the default file system")); + } + } + } + + @Test + public void pollingLoadsAPathOnAnotherFileSystem() throws Exception { + try (TempDir dir = TempDir.create(); FileSystem zip = newZipFileSystem(dir)) { + Path file = zip.getPath("/overrides.json"); + Files.write(file, docWith("flag", "value").getBytes(StandardCharsets.UTF_8)); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(ChangeDetection.POLLING), sink); + + assertEquals("value", flagValue(sink.await(), "flag")); + } + } + + private static FileSystem newZipFileSystem(TempDir dir) throws Exception { + URI uri = URI.create("jar:" + dir.getPath().resolve("overrides.zip").toUri()); + return FileSystems.newFileSystem(uri, Collections.singletonMap("create", "true")); + } + + @Test + public void nullDuplicateKeysHandlingSelectsFail() throws Exception { + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.json", docWith("flag", "a")); + Path b = write(dir, "b.json", docWith("flag", "b")); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(a, b).duplicateKeysHandling(null), sink); + sink.assertNoSnapshot(200); + assertThat(logCapture.getMessageStrings(), hasItem(allOf(startsWith("ERROR:Unable to load flags: "), + containsString("in features, key \"flag\" was already defined"), containsString(b.toString())))); + } + } + + @Test + public void repeatedPathIsDroppedWithAWarning() throws Exception { + // Under the default fail handling the same file listed twice would make every one of its + // keys a duplicate of itself. The repeat is ignored and the file loads once. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "a.json", docWith("flag", "value")); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file, file), sink); + + assertEquals("value", flagValue(sink.await(), "flag")); + assertThat(logCapture.getMessageStrings(), + hasItem("WARN:File paths list the same file more than once; the repeats are ignored")); + } + } + + @Test + public void initialLoadCompletesBeforeStartReturns() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", + "{\"flags\":{\"full\":{\"key\":\"full\",\"version\":7,\"on\":true,\"variations\":[\"x\"],\"fallthrough\":{\"variation\":0}}}," + + "\"flagValues\":{\"simple\":\"value\"}," + + "\"segments\":{\"seg\":{\"key\":\"seg\",\"version\":3,\"included\":[\"u\"]}}}"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + + Map> snapshot = sink.snapshots.poll(); + assertNotNull("the initial snapshot must be supplied before start returns", snapshot); + assertEquals(7, snapshot.get(FEATURES.getName()).get("full").getVersion()); + assertEquals("value", flagValue(snapshot, "simple")); + // A value-only entry is a flag served by fallthrough with version 0. + ItemDescriptor simple = snapshot.get(FEATURES.getName()).get("simple"); + assertEquals(0, simple.getVersion()); + LDValue simpleJson = LDValue.parse(FEATURES.serialize(simple)); + assertEquals(LDValue.of(true), simpleJson.get("on")); + assertEquals(LDValue.of(0), simpleJson.get("fallthrough").get("variation")); + assertEquals(LDValue.ofNull(), simpleJson.get("offVariation")); + assertEquals(3, snapshot.get(SEGMENTS.getName()).get("seg").getVersion()); + assertThat(logCapture.getMessageStrings(), hasItem( + "INFO:Flag overrides in effect: 2 flags, 1 segment (" + file + ": 2 flags, 1 segment)")); + } + } + + @Test + public void loadsYamlDocument() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", "flagValues:\n yaml-flag: \"override-value\"\n"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + assertEquals("override-value", flagValue(sink.await(), "yaml-flag")); + } + } + + @Test + public void mergesFilesInConfiguredOrderWithIgnoreHandling() throws Exception { + try (TempDir dir = TempDir.create()) { + Path first = write(dir, "first.json", docWith("shared", "first")); + Path second = write(dir, "second.json", "{\"flagValues\":{\"shared\":\"second\",\"only-second\":\"x\"}}"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(first, second) + .duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE), sink); + + Map> snapshot = sink.await(); + assertEquals("first", flagValue(snapshot, "shared")); + assertEquals("x", flagValue(snapshot, "only-second")); + assertThat(logCapture.getMessageStrings(), hasItem( + "INFO:Flag overrides in effect: 2 flags (" + first + ": 1 flag; " + second + ": 1 flag)")); + } + } + + @Test + public void duplicateKeysFailTheLoadByDefault() throws Exception { + try (TempDir dir = TempDir.create()) { + Path a = write(dir, "a.json", docWith("flag", "a")); + Path b = write(dir, "b.json", docWith("flag", "b")); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(a, b), sink); + sink.assertNoSnapshot(200); + } + } + + @Test + public void missingFileContributesNoEntriesAndIsNotAnError() throws Exception { + try (TempDir dir = TempDir.create()) { + Path present = write(dir, "present.json", docWith("flag", "value")); + Path missing = dir.getPath().resolve("missing.json"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(present, missing), sink); + + Map> snapshot = sink.await(); + assertEquals("value", flagValue(snapshot, "flag")); + assertEquals(1, snapshot.get(FEATURES.getName()).size()); + for (String message : logCapture.getMessageStrings()) { + assertTrue("unexpected error log: " + message, !message.startsWith("ERROR:")); + } + assertThat(logCapture.getMessageStrings(), hasItem( + "INFO:Flag overrides in effect: 1 flag (" + present + ": 1 flag; " + missing + ": absent)")); + } + } + + @Test + public void startsWithNoFilesAndLogsNoneInEffect() throws Exception { + try (TempDir dir = TempDir.create()) { + Path missing = dir.getPath().resolve("missing.json"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(missing), sink); + + Map> snapshot = sink.await(); + assertTrue(snapshot.get(FEATURES.getName()).isEmpty()); + assertThat(logCapture.getMessageStrings(), hasItem("INFO:Flag overrides: none in effect (" + missing + ": absent)")); + } + } + + @Test + public void emptyDocumentLogsNoEntries() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "empty.json", "{}"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + sink.await(); + assertThat(logCapture.getMessageStrings(), hasItem("INFO:Flag overrides: none in effect (" + file + ": no entries)")); + } + } + + @Test + public void emptyYamlFileAtStartupHasNoEntries() throws Exception { + for (String contents : new String[] { "", " \n\n" }) { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.yaml", contents); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + assertTrue(sink.await().get(FEATURES.getName()).isEmpty()); + assertThat(logCapture.getMessageStrings(), hasItem("INFO:Flag overrides: none in effect (" + file + ": no entries)")); + } + } + } + + @Test + public void emptyJsonFileAtStartupFailsToLoad() throws Exception { + for (String name : new String[] { "overrides.json", "overrides" }) { + for (String contents : new String[] { "", " \n\t\n" }) { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, name, contents); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + sink.assertNoSnapshot(200); + assertThat(logCapture.getMessageStrings(), hasItem("ERROR:Unable to load flags: file " + file + + ": the file has no content, which is not a JSON document")); + } + } + } + } + + @Test + public void malformedFileAtStartupSuppliesNothingAndLogsError() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "bad.json", "{\"flagValues\""); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file), sink); + sink.assertNoSnapshot(200); + assertThat(logCapture.getMessageStrings(), hasItem(startsWith("ERROR:Unable to load flags:"))); + } + } + + private void reloadsOnChange(ChangeDetection mode) throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", docWith("flag", "first")); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(mode).pollInterval(Duration.ofSeconds(1)), sink); + assertEquals("first", flagValue(sink.await(), "flag")); + + // A changed file is reloaded. + Files.write(file, docWith("flag", "second").getBytes()); + assertEquals("second", flagValue(sink.await(), "flag")); + + // A malformed edit keeps the last good overrides and supplies nothing. + Files.write(file, "{\"flagValues\"".getBytes()); + sink.assertNoSnapshot(1500); + assertThat(logCapture.getMessageStrings(), hasItem(startsWith("ERROR:Unable to load flags:"))); + + // A later good edit is applied. + Files.write(file, docWith("flag", "third").getBytes()); + assertEquals("third", flagValue(sink.await(), "flag")); + + // Deleting the file removes its overrides. + Files.delete(file); + assertTrue(sink.await().get(FEATURES.getName()).isEmpty()); + + // Recreating the file brings them back. + Files.write(file, docWith("flag", "fourth").getBytes()); + assertEquals("fourth", flagValue(sink.await(), "flag")); + } + } + + @Test + public void pollingModeReloadsOnChange() throws Exception { + reloadsOnChange(ChangeDetection.POLLING); + } + + @Test + public void watchingModeReloadsOnChange() throws Exception { + reloadsOnChange(ChangeDetection.WATCHING); + } + + @Test + public void watchingFallsBackToPollingWhenTheWatcherCannotBeCreated() throws Exception { + // Without change detection a later edit would never take effect. The source polls instead, at + // no less than the minimum poll interval, which watching mode otherwise ignores. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", docWith("flag", "first")); + FileOverrideSourceImpl source = new FileOverrideSourceImpl( + Collections.singletonList(file.toAbsolutePath()), FileData.DuplicateKeysHandling.FAIL, + ChangeDetection.WATCHING, Duration.ofMillis(10), FileDataReloader.DEFAULT_RETRY_DELAY, + context.getBaseLogger().subLogger("FileOverrideSource"), + (paths, logger) -> { + throw new IOException("watch service unavailable"); + }); + sources.add(source); + RecordingSink sink = new RecordingSink(); + source.start(sink); + assertEquals("first", flagValue(sink.await(), "flag")); + + Files.write(file, docWith("flag", "second").getBytes()); + assertEquals("second", flagValue(sink.await(), "flag")); + assertThat(logCapture.getMessageStrings(), hasItem(allOf( + startsWith("ERROR:Unable to watch override files; polling them instead every PT1S"), + containsString("watch service unavailable")))); + } + } + + @Test + public void fileThatDoesNotExistYetTakesEffectWhenItAppears() throws Exception { + for (ChangeDetection mode : ChangeDetection.values()) { + try (TempDir dir = TempDir.create()) { + Path file = dir.getPath().resolve("later.json"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(mode), sink); + assertTrue(sink.await().get(FEATURES.getName()).isEmpty()); + + Files.write(file, docWith("flag", "appeared").getBytes()); + assertEquals(mode.toString(), "appeared", flagValue(sink.await(), "flag")); + } + } + } + + @Test + public void pollIntervalBelowMinimumIsRaisedWithWarning() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", "{}"); + RecordingSink sink = new RecordingSink(); + OverrideSource source = start(FileOverrides.source().filePaths(file).pollInterval(Duration.ofMillis(10)), sink); + assertEquals(FileOverrides.MINIMUM_POLL_INTERVAL, ((FileOverrideSourceImpl) source).getPollInterval()); + assertThat(logCapture.getMessageStrings(), hasItem( + "WARN:Poll interval PT0.01S is below the minimum; using PT1S")); + } + } + + @Test + public void pollIntervalAtOrAboveMinimumIsKept() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", "{}"); + OverrideSource source = start(FileOverrides.source().filePaths(file).pollInterval(Duration.ofSeconds(3)), + new RecordingSink()); + assertEquals(Duration.ofSeconds(3), ((FileOverrideSourceImpl) source).getPollInterval()); + OverrideSource defaulted = start(FileOverrides.source().filePaths(file), new RecordingSink()); + assertEquals(FileOverrides.DEFAULT_POLL_INTERVAL, ((FileOverrideSourceImpl) defaulted).getPollInterval()); + } + } + + @Test + public void failedLoadIsRetriedWithoutAFileChange() throws Exception { + // Watching mode delivers no notification while the file is untouched, so only the source's own + // retry can attempt the load again. + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "bad.json", "{\"flagValues\""); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(ChangeDetection.WATCHING), sink); + assertThat(logCapture.getMessageStrings(), hasItem(startsWith("ERROR:Unable to load flags:"))); + + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (!logCapture.getMessageStrings().contains("DEBUG:Retrying flag data load after earlier failure")) { + if (System.currentTimeMillis() > deadline) { + fail("the failed load was not retried"); + } + Thread.sleep(50); + } + sink.assertNoSnapshot(100); + } + } + + @Test + public void watchingModeIgnoresPollIntervalMinimum() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", "{}"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(file).changeDetection(ChangeDetection.WATCHING) + .pollInterval(Duration.ofMillis(10)), sink); + for (String message : logCapture.getMessageStrings()) { + assertTrue(message, !message.startsWith("WARN:Poll interval")); + } + } + } + + private static int liveThreadsNamed(String prefix) { + int count = 0; + for (Thread t : Thread.getAllStackTraces().keySet()) { + if (t.isAlive() && t.getName().startsWith(prefix)) { + count++; + } + } + return count; + } + + private static void awaitLiveThreadsNamed(String prefix, int expected) throws InterruptedException { + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (liveThreadsNamed(prefix) != expected) { + if (System.currentTimeMillis() > deadline) { + fail("expected " + expected + " live threads named " + prefix + " but found " + liveThreadsNamed(prefix)); + } + Thread.sleep(20); + } + } + + private void closeReleasesThreads(ChangeDetection mode, String threadPrefix) throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", docWith("flag", "first")); + int baseline = liveThreadsNamed(threadPrefix); + RecordingSink sink = new RecordingSink(); + OverrideSource source = start(FileOverrides.source().filePaths(file).changeDetection(mode) + .pollInterval(Duration.ofSeconds(1)), sink); + sink.await(); + awaitLiveThreadsNamed(threadPrefix, baseline + 1); + + source.close(); + source.close(); + + awaitLiveThreadsNamed(threadPrefix, baseline); + Files.write(file, docWith("flag", "second").getBytes()); + sink.assertNoSnapshot(1500); + } + } + + @Test + public void closeIsIdempotentAndStopsThePoller() throws Exception { + closeReleasesThreads(ChangeDetection.POLLING, "LaunchDarkly-FileDataPoller"); + } + + @Test + public void closeIsIdempotentAndStopsTheWatcher() throws Exception { + closeReleasesThreads(ChangeDetection.WATCHING, "LaunchDarkly-FileDataWatcher"); + } + + @Test + public void countsTextFormatsCounts() { + assertEquals("1 flag", FileOverrideSourceImpl.countsText(1, 0)); + assertEquals("2 flags, 1 segment", FileOverrideSourceImpl.countsText(2, 1)); + assertEquals("3 segments", FileOverrideSourceImpl.countsText(0, 3)); + assertEquals("", FileOverrideSourceImpl.countsText(0, 0)); + } + + @Test + public void relativePathIsResolvedAgainstWorkingDirectory() throws Exception { + Path relative = java.nio.file.Paths.get("no-such-dir-for-override-test", "overrides.json"); + RecordingSink sink = new RecordingSink(); + start(FileOverrides.source().filePaths(relative.toString()), sink); + assertTrue(sink.await().get(FEATURES.getName()).isEmpty()); + assertThat(logCapture.getMessageStrings(), hasItem(containsString(relative.toAbsolutePath().normalize().toString()))); + } + + @Test + public void loggerUsesSubLoggerName() throws Exception { + try (TempDir dir = TempDir.create()) { + Path file = write(dir, "overrides.json", "{}"); + start(FileOverrides.source().filePaths(file), new RecordingSink()); + boolean found = false; + for (LogCapture.Message m : logCapture.getMessages()) { + if (m.getLevel() == LDLogLevel.INFO && m.getLoggerName().endsWith("FileOverrideSource")) { + found = true; + } + } + assertTrue(found); + } + } +}