From 1a548dd28dd13b205689a3f5b46787ea67eb49a1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:17:54 -0800 Subject: [PATCH 01/15] chore: Add file data source support for FDv2. --- .../sdk/server/integrations/FileData.java | 239 +++++++++++- .../integrations/FileDataSourceBase.java | 223 ++++++++++++ .../integrations/FileDataSourceBuilder.java | 76 +++- .../integrations/FileDataSourceImpl.java | 344 +++++------------- .../server/integrations/FileInitializer.java | 41 +++ .../server/integrations/FileSynchronizer.java | 208 +++++++++++ .../server/integrations/DataLoaderTest.java | 4 +- .../integrations/FileInitializerTest.java | 135 +++++++ .../integrations/FileSynchronizerTest.java | 181 +++++++++ .../TestDataSourceBuildInputs.java | 28 ++ 10 files changed, 1214 insertions(+), 265 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileInitializer.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileSynchronizer.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataSourceBuildInputs.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index c75b64c0..54356b14 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -1,6 +1,13 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.server.LDConfig.Builder; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; /** * Integration between the LaunchDarkly SDK and file data. @@ -137,6 +144,236 @@ public enum DuplicateKeysHandling { public static FileDataSourceBuilder dataSource() { return new FileDataSourceBuilder(); } - + + /** + * Creates a builder for an FDv2 Initializer that loads file data. + *

+ * An initializer performs a one-shot load of the file data. This is used with the FDv2 data system + * for initial data loading. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. + * https://launchdarkly.com/docs/sdk/features/data-saving-mode + * + * @return a builder for configuring the file data initializer + */ + public static FileInitializerBuilder initializer() { + return new FileInitializerBuilder(); + } + + /** + * Creates a builder for an FDv2 Synchronizer that loads and watches file data. + *

+ * A synchronizer loads file data and can watch for changes (if autoUpdate is enabled). + * This is used with the FDv2 data system for ongoing data synchronization. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. + * https://launchdarkly.com/docs/sdk/features/data-saving-mode + * + * @return a builder for configuring the file data synchronizer + */ + public static FileSynchronizerBuilder synchronizer() { + return new FileSynchronizerBuilder(); + } + private FileData() {} + + /** + * Builder for creating an FDv2 {@link Initializer} that loads file data. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. + * https://launchdarkly.com/docs/sdk/features/data-saving-mode + */ + public static final class FileInitializerBuilder implements DataSourceBuilder { + private final FileDataSourceBuilder delegate = new FileDataSourceBuilder(); + + FileInitializerBuilder() { + delegate.shouldPersist(false); + } + + /** + * Adds any number of source files for loading flag data, specifying each file path as a string. + * + * @param filePaths path(s) to the source 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 + * @see FileDataSourceBuilder#filePaths(String...) + */ + public FileInitializerBuilder filePaths(String... filePaths) throws InvalidPathException { + delegate.filePaths(filePaths); + return this; + } + + /** + * Adds any number of source files for loading flag data, specifying each file path as a Path. + * + * @param filePaths path(s) to the source file(s); may be absolute or relative to the current working directory + * @return the same builder + * @see FileDataSourceBuilder#filePaths(Path...) + */ + public FileInitializerBuilder filePaths(Path... filePaths) { + delegate.filePaths(filePaths); + return this; + } + + /** + * Adds any number of classpath resources for loading flag data. + * + * @param resourceLocations resource location(s) in the format used by {@code ClassLoader.getResource()} + * @return the same builder + * @see FileDataSourceBuilder#classpathResources(String...) + */ + public FileInitializerBuilder classpathResources(String... resourceLocations) { + delegate.classpathResources(resourceLocations); + return this; + } + + /** + * Specifies how to handle keys that are duplicated across files. + * + * @param duplicateKeysHandling specifies how to handle duplicate keys + * @return the same builder + * @see FileDataSourceBuilder#duplicateKeysHandling(DuplicateKeysHandling) + */ + public FileInitializerBuilder duplicateKeysHandling(DuplicateKeysHandling duplicateKeysHandling) { + delegate.duplicateKeysHandling(duplicateKeysHandling); + return this; + } + + /** + * Configures whether file data should be persisted to persistent stores. + *

+ * By default, file data is not persisted ({@code shouldPersist = false}). + *

+ * Set this to {@code true} if you want the SDK to persist flag data to persistent stores. + * This isn't the recommended configuration but may be useful for testing scenarios. + *

+ * Example: + *


+     *     FileData fd = FileData.initializer()
+     *         .filePaths("./testData/flags.json")
+     *         .shouldPersist(true);
+     * 
+ * + * @param shouldPersist {@code true} if tile data should be persisted to persistent stores, false otherwise + * @return the same {@code TestData} instance + */ + public FileInitializerBuilder shouldPersist(boolean shouldPersist) { + delegate.shouldPersist(shouldPersist); + return this; + } + + + @Override + public Initializer build(DataSourceBuildInputs context) { + return delegate.buildInitializer(context); + } +} + + /** + * Builder for creating an FDv2 {@link Synchronizer} that loads and watches file data. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. + * https://launchdarkly.com/docs/sdk/features/data-saving-mode + */ + public static final class FileSynchronizerBuilder implements DataSourceBuilder { + private final FileDataSourceBuilder delegate = new FileDataSourceBuilder(); + + FileSynchronizerBuilder() { + delegate.shouldPersist(false); + } + + /** + * Configures whether file data should be persisted to persistent stores. + *

+ * By default, file data is not persisted ({@code shouldPersist = false}). + *

+ * Set this to {@code true} if you want the SDK to persist flag data to persistent stores. + * This isn't the recommended configuration but may be useful for testing scenarios. + *

+ * Example: + *


+     *     FileData fd = FileData.synchronizer()
+     *         .filePaths("./testData/flags.json")
+     *         .shouldPersist(true);
+     * 
+ * + * @param shouldPersist {@code true} if file data should be persisted to persistent stores, false otherwise + * @return the same {@code TestData} instance + */ + public FileSynchronizerBuilder shouldPersist(boolean shouldPersist) { + delegate.shouldPersist(shouldPersist); + return this; + } + + /** + * Adds any number of source files for loading flag data, specifying each file path as a string. + * + * @param filePaths path(s) to the source 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 + * @see FileDataSourceBuilder#filePaths(String...) + */ + public FileSynchronizerBuilder filePaths(String... filePaths) throws InvalidPathException { + delegate.filePaths(filePaths); + return this; + } + + /** + * Adds any number of source files for loading flag data, specifying each file path as a Path. + * + * @param filePaths path(s) to the source file(s); may be absolute or relative to the current working directory + * @return the same builder + * @see FileDataSourceBuilder#filePaths(Path...) + */ + public FileSynchronizerBuilder filePaths(Path... filePaths) { + delegate.filePaths(filePaths); + return this; + } + + /** + * Adds any number of classpath resources for loading flag data. + * + * @param resourceLocations resource location(s) in the format used by {@code ClassLoader.getResource()} + * @return the same builder + * @see FileDataSourceBuilder#classpathResources(String...) + */ + public FileSynchronizerBuilder classpathResources(String... resourceLocations) { + delegate.classpathResources(resourceLocations); + return this; + } + + /** + * Specifies whether the data source should watch for changes to the source file(s) and reload flags + * whenever there is a change. + * + * @param autoUpdate true if flags should be reloaded whenever a source file changes + * @return the same builder + * @see FileDataSourceBuilder#autoUpdate(boolean) + */ + public FileSynchronizerBuilder autoUpdate(boolean autoUpdate) { + delegate.autoUpdate(autoUpdate); + return this; + } + + /** + * Specifies how to handle keys that are duplicated across files. + * + * @param duplicateKeysHandling specifies how to handle duplicate keys + * @return the same builder + * @see FileDataSourceBuilder#duplicateKeysHandling(DuplicateKeysHandling) + */ + public FileSynchronizerBuilder duplicateKeysHandling(DuplicateKeysHandling duplicateKeysHandling) { + delegate.duplicateKeysHandling(duplicateKeysHandling); + return this; + } + + @Override + public Synchronizer build(DataSourceBuildInputs context) { + return delegate.buildSynchronizer(context); + } + } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java new file mode 100644 index 00000000..3c120049 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java @@ -0,0 +1,223 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.Instant; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; + +/** + * Base class containing shared logic for file data source implementations. + */ +class FileDataSourceBase { + protected final List sources; + protected final FileData.DuplicateKeysHandling duplicateKeysHandling; + protected final LDLogger logger; + private final DataLoader dataLoader; + + private final boolean persist; + + protected FileDataSourceBase( + List sources, + FileData.DuplicateKeysHandling duplicateKeysHandling, + LDLogger logger, + boolean persist + ) { + this.sources = new ArrayList<>(sources); + this.duplicateKeysHandling = duplicateKeysHandling; + this.logger = logger; + this.dataLoader = new DataLoader(sources); + this.persist = persist; + } + + /** + * Loads data from all configured files and returns an FDv2SourceResult. + * + * @param oneShot true if this is a one-shot load (Initializer), false for continuous (Synchronizer) + * @return an FDv2SourceResult containing either a ChangeSet or an error status + */ + protected FDv2SourceResult loadData(boolean oneShot) { + DataBuilder builder = new DataBuilder(duplicateKeysHandling); + int version; + try { + version = dataLoader.load(builder); + } catch (FileDataException e) { + String description = getErrorDescription(e); + logger.error(description); + ErrorInfo errorInfo = new ErrorInfo( + ErrorKind.INVALID_DATA, + 0, + description, + Instant.now() + ); + // For initializers, file errors are terminal. For synchronizers, they are recoverable. + return oneShot + ? FDv2SourceResult.terminalError(errorInfo, false) + : FDv2SourceResult.interrupted(errorInfo, false); + } + + FullDataSet fullData = builder.build(); + ChangeSet changeSet = buildChangeSet(fullData, version); + return FDv2SourceResult.changeSet(changeSet, false); + } + + /** + * Builds a ChangeSet from a FullDataSet. + */ + private ChangeSet buildChangeSet(FullDataSet fullData, int version) { + Selector selector = Selector.make(version, "file-data-" + version); + return new ChangeSet<>( + ChangeSetType.Full, + selector, + fullData.getData(), + null, // no environment ID for file data + persist + ); + } + + /** + * Returns the list of source infos for file watching. + */ + Iterable getSources() { + return sources; + } + + /** + * Safely gets an error description from a FileDataException, handling the case + * where the cause may be null. + */ + private String getErrorDescription(FileDataException e) { + // FileDataException.getDescription() has a bug where it calls getCause().toString() + // without null checking. We work around this by building our own description. + StringBuilder s = new StringBuilder(); + if (e.getMessage() != null) { + s.append(e.getMessage()); + } + if (e.getCause() != null) { + if (s.length() > 0) { + s.append(" "); + } + s.append("[").append(e.getCause().toString()).append("]"); + } + return s.toString(); + } + + /** + * Implements the loading of flag data from one or more files. Will throw an exception if any file can't + * be read or parsed, or if any flag or segment keys are duplicates. + */ + static final class DataLoader { + private final List sources; + private final AtomicInteger lastVersion; + + public DataLoader(List sources) { + this.sources = new ArrayList<>(sources); + this.lastVersion = new AtomicInteger(0); + } + + public Iterable getSources() { + return sources; + } + + /** + * Loads data from all sources into the builder. + * + * @param builder the data builder to populate + * @return the version number assigned to this load + * @throws FileDataException if any file cannot be read or parsed + */ + public int load(DataBuilder builder) throws FileDataException { + int version = lastVersion.incrementAndGet(); + for (SourceInfo s : sources) { + try { + byte[] data = s.readData(); + FlagFileParser parser = FlagFileParser.selectForContent(data); + FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); + if (fileContents.flags != null) { + for (Map.Entry e : fileContents.flags.entrySet()) { + builder.add(FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue(), version)); + } + } + if (fileContents.flagValues != null) { + for (Map.Entry e : fileContents.flagValues.entrySet()) { + builder.add(FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue(), version)); + } + } + if (fileContents.segments != null) { + for (Map.Entry e : fileContents.segments.entrySet()) { + builder.add(SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue(), version)); + } + } + } catch (FileDataException e) { + throw new FileDataException(e.getMessage(), e.getCause(), s); + } catch (IOException e) { + throw new FileDataException(null, e, s); + } + } + return version; + } + } + + /** + * Internal data structure that organizes flag/segment data into the format that the feature store + * expects. Will throw an exception if we try to add the same flag or segment key more than once. + */ + static final class DataBuilder { + private final Map> allData = new HashMap<>(); + private final FileData.DuplicateKeysHandling duplicateKeysHandling; + + public DataBuilder(FileData.DuplicateKeysHandling duplicateKeysHandling) { + this.duplicateKeysHandling = duplicateKeysHandling; + } + + public FullDataSet build() { + ImmutableList.Builder>> allBuilder = ImmutableList.builder(); + for (Map.Entry> e0 : allData.entrySet()) { + allBuilder.add(new AbstractMap.SimpleEntry<>(e0.getKey(), new KeyedItems<>(e0.getValue().entrySet()))); + } + return new FullDataSet<>(allBuilder.build()); + } + + public void add(DataKind kind, String key, ItemDescriptor item) throws FileDataException { + Map items = allData.get(kind); + if (items == null) { + items = new HashMap(); + allData.put(kind, items); + } + if (items.containsKey(key)) { + if (duplicateKeysHandling == FileData.DuplicateKeysHandling.IGNORE) { + return; + } + throw new FileDataException("in " + kind.getName() + ", key \"" + key + "\" was already defined", null, null); + } + items.put(key, item); + } + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 14d607c5..a4920deb 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -3,9 +3,12 @@ import com.google.common.io.ByteStreams; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.server.LDConfig.Builder; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.subsystems.ClientContext; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.DataSource; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; import java.io.IOException; import java.io.InputStream; @@ -30,6 +33,8 @@ public final class FileDataSourceBuilder implements ComponentConfigurer sources = new ArrayList<>(); // visible for tests private boolean autoUpdate = false; private FileData.DuplicateKeysHandling duplicateKeysHandling = FileData.DuplicateKeysHandling.FAIL; + + private boolean shouldPersist = false; /** * Adds any number of source files for loading flag data, specifying each file path as a string. The files will @@ -123,7 +128,76 @@ public DataSource build(ClientContext context) { LDLogger logger = context.getBaseLogger().subLogger("DataSource"); return new FileDataSourceImpl(context.getDataSourceUpdateSink(), sources, autoUpdate, duplicateKeysHandling, logger); } - + + /** + * Builds an {@link Initializer} for FDv2 data system integration. + *

+ * An initializer performs a one-shot load of the file data. If the file cannot be read, + * a terminal error is returned. + * + * @param context the data source build context + * @return an Initializer instance + */ + Initializer buildInitializer(DataSourceBuildInputs context) { + LDLogger logger = context.getBaseLogger().subLogger("FileDataSource.Initializer"); + return new FileInitializer(sources, duplicateKeysHandling, logger, shouldPersist); + } + + /** + * Builds a {@link Synchronizer} for FDv2 data system integration. + *

+ * A synchronizer can watch for file changes (if autoUpdate is enabled) and emit + * new change sets when files are modified. + * + * @param context the data source build context + * @return a Synchronizer instance + */ + Synchronizer buildSynchronizer(DataSourceBuildInputs context) { + LDLogger logger = context.getBaseLogger().subLogger("FileDataSource.Synchronizer"); + return new FileSynchronizer(sources, autoUpdate, duplicateKeysHandling, logger, shouldPersist); + } + + /** + * Returns whether auto-update is enabled. Package-private for use by FDv2 builders. + */ + boolean isAutoUpdate() { + return autoUpdate; + } + + /** + * Returns the duplicate keys handling mode. Package-private for use by FDv2 builders. + */ + FileData.DuplicateKeysHandling getDuplicateKeysHandling() { + return duplicateKeysHandling; + } + + /** + * Configures whether file data should be persisted to persistent stores. + *

+ * By default, file data is persisted ({@code shouldPersist = true}) to maintain consistency with + * previous versions' behavior. When {@code true}, the file data will be written to any configured persistent + * store (if the store is in READ_WRITE mode). This may be useful for integration tests that verify + * your persistent store configuration. + *

+ * FileData synchronizers and initializers to NOT persist data by default. + *

+ * Example: + *


+   *     FileData fd = FileData.dataSource()
+   *         .filePaths("./testData/flags.json")
+   *         .shouldPersist(true);
+   * 
+ *

+ * File data + * + * @param shouldPersist {@code true} if data from this source should be persisted + * @return an instance of this builder + */ + public FileDataSourceBuilder shouldPersist(boolean shouldPersist) { + this.shouldPersist = shouldPersist; + return this; + } + static abstract class SourceInfo { abstract byte[] readData() throws IOException; abstract Path toFilePath(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 744b8505..cb45ec69 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -1,303 +1,125 @@ package com.launchdarkly.sdk.server.integrations; -import com.google.common.collect.ImmutableList; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LogValues; -import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; -import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; -import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; -import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; -import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; -import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.subsystems.DataSource; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; -import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.nio.file.WatchEvent; -import java.nio.file.WatchKey; -import java.nio.file.WatchService; -import java.nio.file.Watchable; -import java.time.Instant; -import java.util.AbstractMap; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; -import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; -import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; -import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; /** * Implements taking flag data from files and putting it into the data store, at startup time and * optionally whenever files change. + *

+ * This is the legacy DataSource implementation for backward compatibility. It internally uses + * {@link FileSynchronizer} for file loading and watching, adapting the results to the DataSource API. + *

+ * For FDv2 integration, use {@link FileInitializer} or {@link FileSynchronizer} directly. */ final class FileDataSourceImpl implements DataSource { - private final DataSourceUpdateSink dataSourceUpdates; - private final DataLoader dataLoader; - private final FileData.DuplicateKeysHandling duplicateKeysHandling; - private final AtomicBoolean inited = new AtomicBoolean(false); - private final FileWatcher fileWatcher; - private final LDLogger logger; - - FileDataSourceImpl( - DataSourceUpdateSink dataSourceUpdates, - List sources, - boolean autoUpdate, - FileData.DuplicateKeysHandling duplicateKeysHandling, - LDLogger logger - ) { - this.dataSourceUpdates = dataSourceUpdates; - this.dataLoader = new DataLoader(sources); - this.duplicateKeysHandling = duplicateKeysHandling; - this.logger = logger; + private final DataSourceUpdateSink dataSourceUpdates; + private final FileSynchronizer synchronizer; + private final AtomicBoolean inited = new AtomicBoolean(false); + private final AtomicBoolean closed = new AtomicBoolean(false); + private final LDLogger logger; + private Thread updateThread; - FileWatcher fw = null; - if (autoUpdate) { - try { - fw = FileWatcher.create(dataLoader.getSources(), logger); - } catch (IOException e) { - // COVERAGE: there is no way to simulate this condition in a unit test - logger.error("Unable to watch files for auto-updating: {}", e.toString()); - logger.debug(e.toString(), e); - fw = null; - } + FileDataSourceImpl( + DataSourceUpdateSink dataSourceUpdates, + List sources, + boolean autoUpdate, + FileData.DuplicateKeysHandling duplicateKeysHandling, + LDLogger logger + ) { + this.dataSourceUpdates = dataSourceUpdates; + this.logger = logger; + // The FDv1 + this.synchronizer = new FileSynchronizer(sources, autoUpdate, duplicateKeysHandling, logger, true); } - fileWatcher = fw; - } - - @Override - public Future start() { - final Future initFuture = CompletableFuture.completedFuture(null); - - reload(); - - // Note that if reload() finds any errors, it will not set our status to "initialized". But we - // will still do all the other startup steps, because we still might end up getting valid data - // if we are told to reload by the file watcher. - if (fileWatcher != null) { - fileWatcher.start(this::reload); - } - - return initFuture; - } + @Override + public Future start() { + final Future initFuture = CompletableFuture.completedFuture(null); - private boolean reload() { - DataBuilder builder = new DataBuilder(duplicateKeysHandling); - try { - dataLoader.load(builder); - } catch (FileDataException e) { - logger.error(e.getDescription()); - dataSourceUpdates.updateStatus(State.INTERRUPTED, - new ErrorInfo(ErrorKind.INVALID_DATA, 0, e.getDescription(), Instant.now())); - return false; - } - dataSourceUpdates.init(builder.build()); - dataSourceUpdates.updateStatus(State.VALID, null); - inited.set(true); - return true; - } - - @Override - public boolean isInitialized() { - return inited.get(); - } + // Get initial data from the synchronizer + FDv2SourceResult initialResult; + try { + initialResult = synchronizer.next().get(); + } catch (Exception e) { + logger.error("Error getting initial file data: {}", LogValues.exceptionSummary(e)); + dataSourceUpdates.updateStatus(State.INTERRUPTED, null); + return initFuture; + } - @Override - public void close() throws IOException { - if (fileWatcher != null) { - fileWatcher.stop(); - } - } - - /** - * If auto-updating is enabled, this component watches for file changes on a worker thread. - */ - private static final class FileWatcher implements Runnable { - private final WatchService watchService; - private final Set watchedFilePaths; - private Runnable fileModifiedAction; - private final Thread thread; - private final LDLogger logger; - private volatile boolean stopped; + processResult(initialResult); - private static FileWatcher create(Iterable sources, LDLogger logger) throws IOException { - Set directoryPaths = new HashSet<>(); - Set absoluteFilePaths = new HashSet<>(); - FileSystem fs = FileSystems.getDefault(); - WatchService ws = fs.newWatchService(); - - // In Java, you watch for filesystem changes at the directory level, not for individual files. - for (SourceInfo s: sources) { - Path p = s.toFilePath(); - if (p != null) { - absoluteFilePaths.add(p); - directoryPaths.add(p.getParent()); - } - } - for (Path d: directoryPaths) { - d.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); - } - - return new FileWatcher(ws, absoluteFilePaths, logger); - } - - private FileWatcher(WatchService watchService, Set watchedFilePaths, LDLogger logger) { - this.watchService = watchService; - this.watchedFilePaths = watchedFilePaths; - this.logger = logger; - - thread = new Thread(this, FileDataSourceImpl.class.getName()); - thread.setDaemon(true); + // Note that if the initial load finds any errors, it will not set our status to "initialized". + // But we will still do all the other startup steps, because we still might end up getting + // valid data if we are told to reload by the file watcher. + + // Start a background thread to listen for file changes + updateThread = new Thread(this::runUpdateLoop, FileDataSourceImpl.class.getName()); + updateThread.setDaemon(true); + updateThread.start(); + + return initFuture; } - - public void run() { - while (!stopped) { - try { - WatchKey key = watchService.take(); // blocks until a change is available or we are interrupted - boolean watchedFileWasChanged = false; - for (WatchEvent event: key.pollEvents()) { - Watchable w = key.watchable(); - Object context = event.context(); - if (w instanceof Path && context instanceof Path) { - Path dirPath = (Path)w; - Path fileNamePath = (Path)context; - Path absolutePath = dirPath.resolve(fileNamePath); - if (watchedFilePaths.contains(absolutePath)) { - watchedFileWasChanged = true; - break; - } - } - } - if (watchedFileWasChanged) { + + private void runUpdateLoop() { + while (!closed.get()) { try { - fileModifiedAction.run(); + FDv2SourceResult result = synchronizer.next().get(); + if (closed.get()) { + break; + } + if (result.getResultType() == FDv2SourceResult.ResultType.STATUS && + result.getStatus().getState() == FDv2SourceResult.State.SHUTDOWN) { + break; + } + processResult(result); } catch (Exception e) { - // COVERAGE: there is no way to simulate this condition in a unit test - logger.warn("Unexpected exception when reloading file data: {}", LogValues.exceptionSummary(e)); + if (!closed.get()) { + logger.warn("Unexpected exception in file data update loop: {}", LogValues.exceptionSummary(e)); + } } - } - key.reset(); // if we don't do this, the watch on this key stops working - } catch (InterruptedException e) { - // if we've been stopped we will drop out at the top of the while loop } - } - } - - public void start(Runnable fileModifiedAction) { - this.fileModifiedAction = fileModifiedAction; - thread.start(); } - - public void stop() { - stopped = true; - thread.interrupt(); - } - } - - /** - * Implements the loading of flag data from one or more files. Will throw an exception if any file can't - * be read or parsed, or if any flag or segment keys are duplicates. - */ - static final class DataLoader { - private final List sources; - private final AtomicInteger lastVersion; - public DataLoader(List sources) { - this.sources = new ArrayList<>(sources); - this.lastVersion = new AtomicInteger(0); - } - - public Iterable getSources() { - return sources; - } - - public void load(DataBuilder builder) throws FileDataException - { - int version = lastVersion.incrementAndGet(); - for (SourceInfo s: sources) { - try { - byte[] data = s.readData(); - FlagFileParser parser = FlagFileParser.selectForContent(data); - FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); - if (fileContents.flags != null) { - for (Map.Entry e: fileContents.flags.entrySet()) { - builder.add(FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue(), version)); - } - } - if (fileContents.flagValues != null) { - for (Map.Entry e: fileContents.flagValues.entrySet()) { - builder.add(FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue(), version)); - } - } - if (fileContents.segments != null) { - for (Map.Entry e: fileContents.segments.entrySet()) { - builder.add(SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue(), version)); + private void processResult(FDv2SourceResult result) { + if (result.getResultType() == FDv2SourceResult.ResultType.CHANGE_SET) { + // Convert ChangeSet to FullDataSet for legacy init() + FullDataSet fullData = new FullDataSet<>(result.getChangeSet().getData()); + dataSourceUpdates.init(fullData); + dataSourceUpdates.updateStatus(State.VALID, null); + inited.set(true); + } else if (result.getResultType() == FDv2SourceResult.ResultType.STATUS) { + // Handle error/status results + if (result.getStatus().getState() != FDv2SourceResult.State.SHUTDOWN) { + dataSourceUpdates.updateStatus(State.INTERRUPTED, result.getStatus().getErrorInfo()); } - } - } catch (FileDataException e) { - throw new FileDataException(e.getMessage(), e.getCause(), s); - } catch (IOException e) { - throw new FileDataException(null, e, s); } - } } - } - - /** - * Internal data structure that organizes flag/segment data into the format that the feature store - * expects. Will throw an exception if we try to add the same flag or segment key more than once. - */ - static final class DataBuilder { - private final Map> allData = new HashMap<>(); - private final FileData.DuplicateKeysHandling duplicateKeysHandling; - - public DataBuilder(FileData.DuplicateKeysHandling duplicateKeysHandling) { - this.duplicateKeysHandling = duplicateKeysHandling; - } - - public FullDataSet build() { - ImmutableList.Builder>> allBuilder = ImmutableList.builder(); - for (Map.Entry> e0: allData.entrySet()) { - allBuilder.add(new AbstractMap.SimpleEntry<>(e0.getKey(), new KeyedItems<>(e0.getValue().entrySet()))); - } - // File data source data is not authoritative and should not be persisted - return new FullDataSet<>(allBuilder.build(), false); + + @Override + public boolean isInitialized() { + return inited.get(); } - - public void add(DataKind kind, String key, ItemDescriptor item) throws FileDataException { - Map items = allData.get(kind); - if (items == null) { - items = new HashMap(); - allData.put(kind, items); - } - if (items.containsKey(key)) { - if (duplicateKeysHandling == FileData.DuplicateKeysHandling.IGNORE) { - return; + + @Override + public void close() throws IOException { + closed.set(true); + synchronizer.close(); + if (updateThread != null) { + updateThread.interrupt(); } - throw new FileDataException("in " + kind.getName() + ", key \"" + key + "\" was already defined", null, null); - } - items.put(key, item); } - } } 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 new file mode 100644 index 00000000..3f8008b4 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileInitializer.java @@ -0,0 +1,41 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * One-shot file loading implementation for FDv2 initialization. + *

+ * This implements the {@link Initializer} interface, loading files once and returning + * the result. If loading fails, it returns a terminal error since an initializer + * cannot retry. + */ +final class FileInitializer extends FileDataSourceBase implements Initializer { + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + + FileInitializer( + List sources, + FileData.DuplicateKeysHandling duplicateKeysHandling, + LDLogger logger, + boolean persist + ) { + super(sources, duplicateKeysHandling, logger, persist); + } + + @Override + public CompletableFuture run() { + CompletableFuture loadResult = CompletableFuture.supplyAsync(() -> loadData(true)); + return CompletableFuture.anyOf(shutdownFuture, loadResult) + .thenApply(result -> (FDv2SourceResult) result); + } + + @Override + public void close() { + shutdownFuture.complete(FDv2SourceResult.shutdown()); + } +} 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 new file mode 100644 index 00000000..8f94c6dd --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileSynchronizer.java @@ -0,0 +1,208 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.Watchable; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; + +/** + * Streaming file updates implementation for FDv2 synchronization. + *

+ * This implements the {@link Synchronizer} interface, providing file watching + * and emitting results when files change. If autoUpdate is disabled, it only + * returns the initial load result. + */ +final class FileSynchronizer extends FileDataSourceBase implements Synchronizer { + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + private final AsyncQueue resultQueue = new AsyncQueue<>(); + private final FileWatcher fileWatcher; // null if autoUpdate=false + private volatile boolean started = false; + + FileSynchronizer( + List sources, + boolean autoUpdate, + FileData.DuplicateKeysHandling duplicateKeysHandling, + LDLogger logger, + boolean persist + ) { + super(sources, duplicateKeysHandling, logger, persist); + + FileWatcher fw = null; + if (autoUpdate) { + try { + fw = FileWatcher.create(getSources(), logger); + } catch (IOException e) { + // COVERAGE: there is no way to simulate this condition in a unit test + logger.error("Unable to watch files for auto-updating: {}", e.toString()); + logger.debug(e.toString(), e); + fw = null; + } + } + this.fileWatcher = fw; + } + + @Override + public CompletableFuture next() { + if (!started) { + started = true; + // Perform initial load + resultQueue.put(loadData(false)); + // Start file watching if enabled + if (fileWatcher != null) { + fileWatcher.start(this::onFileChange); + } + } + return CompletableFuture.anyOf(shutdownFuture, resultQueue.take()) + .thenApply(result -> (FDv2SourceResult) result); + } + + private void onFileChange() { + resultQueue.put(loadData(false)); + } + + @Override + public void close() { + shutdownFuture.complete(FDv2SourceResult.shutdown()); + if (fileWatcher != null) { + fileWatcher.stop(); + } + } + + /** + * A simple thread-safe async queue for passing results between the file watcher and the synchronizer. + */ + private static final class AsyncQueue { + private final Object lock = new Object(); + private final LinkedList queue = new LinkedList<>(); + private final LinkedList> pendingFutures = new LinkedList<>(); + + public void put(T item) { + synchronized (lock) { + CompletableFuture nextFuture = pendingFutures.pollFirst(); + if (nextFuture != null) { + nextFuture.complete(item); + return; + } + queue.addLast(item); + } + } + + public CompletableFuture take() { + synchronized (lock) { + if (!queue.isEmpty()) { + return CompletableFuture.completedFuture(queue.removeFirst()); + } + CompletableFuture takeFuture = new CompletableFuture<>(); + pendingFutures.addLast(takeFuture); + return takeFuture; + } + } + } + + /** + * If auto-updating is enabled, this component watches for file changes on a worker thread. + */ + private static final class FileWatcher implements Runnable { + private final WatchService watchService; + private final Set watchedFilePaths; + private Runnable fileModifiedAction; + private final Thread thread; + private final LDLogger logger; + private volatile boolean stopped; + + private static FileWatcher create(Iterable sources, LDLogger logger) throws IOException { + Set directoryPaths = new HashSet<>(); + Set absoluteFilePaths = new HashSet<>(); + FileSystem fs = FileSystems.getDefault(); + WatchService ws = fs.newWatchService(); + + // In Java, you watch for filesystem changes at the directory level, not for individual files. + for (SourceInfo s : sources) { + Path p = s.toFilePath(); + if (p != null) { + // Convert to absolute path to ensure we have a parent directory + // (relative paths like "flags.json" have null parent) + Path absolutePath = p.toAbsolutePath(); + absoluteFilePaths.add(absolutePath); + directoryPaths.add(absolutePath.getParent()); + } + } + for (Path d : directoryPaths) { + d.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); + } + + return new FileWatcher(ws, absoluteFilePaths, logger); + } + + private FileWatcher(WatchService watchService, Set watchedFilePaths, LDLogger logger) { + this.watchService = watchService; + this.watchedFilePaths = watchedFilePaths; + this.logger = logger; + + thread = new Thread(this, FileSynchronizer.class.getName()); + thread.setDaemon(true); + } + + public void run() { + while (!stopped) { + try { + WatchKey key = watchService.take(); // blocks until a change is available or we are interrupted + boolean watchedFileWasChanged = false; + for (WatchEvent event : key.pollEvents()) { + Watchable w = key.watchable(); + Object context = event.context(); + if (w instanceof Path && context instanceof Path) { + Path dirPath = (Path) w; + Path fileNamePath = (Path) context; + Path absolutePath = dirPath.resolve(fileNamePath); + if (watchedFilePaths.contains(absolutePath)) { + watchedFileWasChanged = true; + break; + } + } + } + if (watchedFileWasChanged) { + try { + fileModifiedAction.run(); + } catch (Exception e) { + // COVERAGE: there is no way to simulate this condition in a unit test + logger.warn("Unexpected exception when reloading file data: {}", LogValues.exceptionSummary(e)); + } + } + key.reset(); // if we don't do this, the watch on this key stops working + } catch (InterruptedException e) { + // if we've been stopped we will drop out at the top of the while loop + } + } + } + + public void start(Runnable fileModifiedAction) { + this.fileModifiedAction = fileModifiedAction; + thread.start(); + } + + public void stop() { + stopped = true; + thread.interrupt(); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index f572d861..ae068572 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -1,8 +1,8 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataBuilder; -import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataLoader; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBase.DataBuilder; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBase.DataLoader; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java new file mode 100644 index 00000000..b655593d --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java @@ -0,0 +1,135 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; + +import org.junit.Test; + +import java.nio.file.Paths; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public class FileInitializerTest { + private static final LDLogger testLogger = LDLogger.none(); + + @Test + public void initializerReturnsChangeSetOnSuccessfulLoad() throws Exception { + Initializer initializer = FileData.initializer() + .filePaths(resourceFilePath("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet(), notNullValue()); + assertThat(result.getChangeSet().getType(), equalTo(ChangeSetType.Full)); + assertNotNull(result.getChangeSet().getData()); + } finally { + initializer.close(); + } + } + + @Test + public void initializerReturnsTerminalErrorOnMissingFile() throws Exception { + Initializer initializer = FileData.initializer() + .filePaths(Paths.get("no-such-file.json")) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); + assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.TERMINAL_ERROR)); + assertNotNull(result.getStatus().getErrorInfo()); + } finally { + initializer.close(); + } + } + + @Test + public void initializerReturnsShutdownWhenClosedBeforeRun() throws Exception { + Initializer initializer = FileData.initializer() + .filePaths(resourceFilePath("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger)); + + // Close before calling run + initializer.close(); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); + assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.SHUTDOWN)); + } + + @Test + public void initializerCanLoadFromClasspathResource() throws Exception { + Initializer initializer = FileData.initializer() + .classpathResources(FileDataSourceTestData.resourceLocation("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet(), notNullValue()); + } finally { + initializer.close(); + } + } + + @Test + public void initializerRespectsIgnoreDuplicateKeysHandling() throws Exception { + Initializer initializer = FileData.initializer() + .filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ) + .duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + // Should succeed when ignoring duplicates + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + } finally { + initializer.close(); + } + } + + @Test + public void initializerFailsOnDuplicateKeysByDefault() throws Exception { + Initializer initializer = FileData.initializer() + .filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + // Should fail with terminal error when duplicate keys are not allowed + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); + assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.TERMINAL_ERROR)); + } finally { + initializer.close(); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java new file mode 100644 index 00000000..17abddb0 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java @@ -0,0 +1,181 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; +import com.launchdarkly.testhelpers.TempDir; +import com.launchdarkly.testhelpers.TempFile; + +import org.junit.Test; + +import java.nio.file.Paths; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public class FileSynchronizerTest { + private static final LDLogger testLogger = LDLogger.none(); + + @Test + public void synchronizerReturnsChangeSetOnSuccessfulLoad() throws Exception { + Synchronizer synchronizer = FileData.synchronizer() + .filePaths(resourceFilePath("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet(), notNullValue()); + assertThat(result.getChangeSet().getType(), equalTo(ChangeSetType.Full)); + assertNotNull(result.getChangeSet().getData()); + } finally { + synchronizer.close(); + } + } + + @Test + public void synchronizerReturnsInterruptedOnMissingFile() throws Exception { + Synchronizer synchronizer = FileData.synchronizer() + .filePaths(Paths.get("no-such-file.json")) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + // Synchronizers return INTERRUPTED for recoverable errors, not TERMINAL_ERROR + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); + assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.INTERRUPTED)); + assertNotNull(result.getStatus().getErrorInfo()); + } finally { + synchronizer.close(); + } + } + + @Test + public void synchronizerReturnsShutdownWhenClosed() throws Exception { + Synchronizer synchronizer = FileData.synchronizer() + .filePaths(resourceFilePath("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger)); + + // Get initial result + CompletableFuture initialResult = synchronizer.next(); + FDv2SourceResult result = initialResult.get(5, TimeUnit.SECONDS); + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + + // Start waiting for next result + CompletableFuture nextResult = synchronizer.next(); + + // Close the synchronizer + synchronizer.close(); + + // Should return shutdown + result = nextResult.get(5, TimeUnit.SECONDS); + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); + assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.SHUTDOWN)); + } + + @Test + public void synchronizerCanLoadFromClasspathResource() throws Exception { + Synchronizer synchronizer = FileData.synchronizer() + .classpathResources(FileDataSourceTestData.resourceLocation("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet(), notNullValue()); + } finally { + synchronizer.close(); + } + } + + @Test + public void synchronizerRespectsIgnoreDuplicateKeysHandling() throws Exception { + Synchronizer synchronizer = FileData.synchronizer() + .filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ) + .duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + // Should succeed when ignoring duplicates + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + } finally { + synchronizer.close(); + } + } + + @Test + public void synchronizerFailsOnDuplicateKeysByDefault() throws Exception { + Synchronizer synchronizer = FileData.synchronizer() + .filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + // Should fail with interrupted error when duplicate keys are not allowed + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); + assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.INTERRUPTED)); + } finally { + synchronizer.close(); + } + } + + @Test + public void synchronizerAutoUpdateEmitsNewResultOnFileChange() throws Exception { + try (TempDir dir = TempDir.create()) { + try (TempFile file = dir.tempFile(".json")) { + file.setContents(getResourceContents("flag-only.json")); + + Synchronizer synchronizer = FileData.synchronizer() + .filePaths(file.getPath()) + .autoUpdate(true) + .build(TestDataSourceBuildInputs.create(testLogger)); + + try { + // Get initial result + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + + // Start waiting for next result + CompletableFuture nextResultFuture = synchronizer.next(); + + // Modify the file + Thread.sleep(200); // Small delay to ensure file watcher is ready + file.setContents(getResourceContents("segment-only.json")); + + // Should get a new result with the updated data + // Note: File watching on MacOS can take up to 10 seconds + result = nextResultFuture.get(15, TimeUnit.SECONDS); + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + } finally { + synchronizer.close(); + } + } + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataSourceBuildInputs.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataSourceBuildInputs.java new file mode 100644 index 00000000..ad80c633 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataSourceBuildInputs.java @@ -0,0 +1,28 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; + +import java.util.concurrent.Executors; + +/** + * Test helper for creating DataSourceBuildInputs for FDv2 initializer and synchronizer tests. + */ +class TestDataSourceBuildInputs { + static DataSourceBuildInputs create(LDLogger logger) { + ServiceEndpoints endpoints = Components.serviceEndpoints().createServiceEndpoints(); + return new DataSourceBuildInputs( + logger, + Thread.NORM_PRIORITY, + null, // dataSourceUpdates not needed for these tests + endpoints, + null, // http not needed for these tests + Executors.newSingleThreadScheduledExecutor(), + null, // diagnosticStore not needed + () -> Selector.EMPTY // SelectorSource returning empty selector + ); + } +} From 0b18393db05b8162c7ce22fd0df75b778d5aee05 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:15:31 -0800 Subject: [PATCH 02/15] Use try-with-resources in file initializer tests. --- .../integrations/FileInitializerTest.java | 59 +++++++------------ 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java index b655593d..05146c57 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java @@ -23,11 +23,10 @@ public class FileInitializerTest { @Test public void initializerReturnsChangeSetOnSuccessfulLoad() throws Exception { - Initializer initializer = FileData.initializer() - .filePaths(resourceFilePath("all-properties.json")) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Initializer initializer = FileData.initializer() + .filePaths(resourceFilePath("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = initializer.run(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); @@ -35,26 +34,21 @@ public void initializerReturnsChangeSetOnSuccessfulLoad() throws Exception { assertThat(result.getChangeSet(), notNullValue()); assertThat(result.getChangeSet().getType(), equalTo(ChangeSetType.Full)); assertNotNull(result.getChangeSet().getData()); - } finally { - initializer.close(); } } @Test public void initializerReturnsTerminalErrorOnMissingFile() throws Exception { - Initializer initializer = FileData.initializer() - .filePaths(Paths.get("no-such-file.json")) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Initializer initializer = FileData.initializer() + .filePaths(Paths.get("no-such-file.json")) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = initializer.run(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.TERMINAL_ERROR)); assertNotNull(result.getStatus().getErrorInfo()); - } finally { - initializer.close(); } } @@ -76,60 +70,51 @@ public void initializerReturnsShutdownWhenClosedBeforeRun() throws Exception { @Test public void initializerCanLoadFromClasspathResource() throws Exception { - Initializer initializer = FileData.initializer() - .classpathResources(FileDataSourceTestData.resourceLocation("all-properties.json")) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Initializer initializer = FileData.initializer() + .classpathResources(FileDataSourceTestData.resourceLocation("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = initializer.run(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); assertThat(result.getChangeSet(), notNullValue()); - } finally { - initializer.close(); } } @Test public void initializerRespectsIgnoreDuplicateKeysHandling() throws Exception { - Initializer initializer = FileData.initializer() - .filePaths( - resourceFilePath("flag-only.json"), - resourceFilePath("flag-with-duplicate-key.json") - ) - .duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Initializer initializer = FileData.initializer() + .filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ) + .duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = initializer.run(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); // Should succeed when ignoring duplicates assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); - } finally { - initializer.close(); } } @Test public void initializerFailsOnDuplicateKeysByDefault() throws Exception { - Initializer initializer = FileData.initializer() - .filePaths( - resourceFilePath("flag-only.json"), - resourceFilePath("flag-with-duplicate-key.json") - ) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Initializer initializer = FileData.initializer() + .filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = initializer.run(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); // Should fail with terminal error when duplicate keys are not allowed assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.TERMINAL_ERROR)); - } finally { - initializer.close(); } } } From 1018c51445ef1d898eb252d0e45d8cb12567bfac Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:18:54 -0800 Subject: [PATCH 03/15] Use try-with-resources for synchronizer tests. --- .../integrations/FileSynchronizerTest.java | 71 +- sandbox/server-sandbox/.gitignore | 1 + .../.gradle/7.6/checksums/checksums.lock | Bin 0 -> 17 bytes .../.gradle/7.6/checksums/md5-checksums.bin | Bin 0 -> 18747 bytes .../.gradle/7.6/checksums/sha1-checksums.bin | Bin 0 -> 19091 bytes .../dependencies-accessors.lock | Bin 0 -> 17 bytes .../7.6/dependencies-accessors/gc.properties | 0 .../7.6/executionHistory/executionHistory.bin | Bin 0 -> 394847 bytes .../executionHistory/executionHistory.lock | Bin 0 -> 17 bytes .../.gradle/7.6/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/7.6/fileHashes/fileHashes.bin | Bin 0 -> 18797 bytes .../.gradle/7.6/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .../7.6/fileHashes/resourceHashesCache.bin | Bin 0 -> 19687 bytes .../server-sandbox/.gradle/7.6/gc.properties | 0 .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 + .../buildOutputCleanup/outputFiles.bin | Bin 0 -> 18965 bytes .../server-sandbox/.gradle/file-system.probe | Bin 0 -> 8 bytes .../.gradle/vcs-1/gc.properties | 0 sandbox/server-sandbox/README.md | 59 ++ sandbox/server-sandbox/build.gradle | 29 + .../build/classes/java/main/Hello$1.class | Bin 0 -> 726 bytes .../build/classes/java/main/Hello.class | Bin 0 -> 6769 bytes .../stash-dir/Hello$1.class.uniqueId0 | Bin 0 -> 726 bytes .../stash-dir/Hello.class.uniqueId1 | Bin 0 -> 6763 bytes .../compileJava/previous-compilation-data.bin | Bin 0 -> 18360 bytes sandbox/server-sandbox/flagdata.json | 849 ++++++++++++++++++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59536 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + sandbox/server-sandbox/gradlew | 234 +++++ sandbox/server-sandbox/gradlew.bat | 89 ++ sandbox/server-sandbox/settings.gradle | 1 + .../server-sandbox/src/main/java/Hello.java | 121 +++ 33 files changed, 1416 insertions(+), 45 deletions(-) create mode 100644 sandbox/server-sandbox/.gitignore create mode 100644 sandbox/server-sandbox/.gradle/7.6/checksums/checksums.lock create mode 100644 sandbox/server-sandbox/.gradle/7.6/checksums/md5-checksums.bin create mode 100644 sandbox/server-sandbox/.gradle/7.6/checksums/sha1-checksums.bin create mode 100644 sandbox/server-sandbox/.gradle/7.6/dependencies-accessors/dependencies-accessors.lock create mode 100644 sandbox/server-sandbox/.gradle/7.6/dependencies-accessors/gc.properties create mode 100644 sandbox/server-sandbox/.gradle/7.6/executionHistory/executionHistory.bin create mode 100644 sandbox/server-sandbox/.gradle/7.6/executionHistory/executionHistory.lock create mode 100644 sandbox/server-sandbox/.gradle/7.6/fileChanges/last-build.bin create mode 100644 sandbox/server-sandbox/.gradle/7.6/fileHashes/fileHashes.bin create mode 100644 sandbox/server-sandbox/.gradle/7.6/fileHashes/fileHashes.lock create mode 100644 sandbox/server-sandbox/.gradle/7.6/fileHashes/resourceHashesCache.bin create mode 100644 sandbox/server-sandbox/.gradle/7.6/gc.properties create mode 100644 sandbox/server-sandbox/.gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 sandbox/server-sandbox/.gradle/buildOutputCleanup/cache.properties create mode 100644 sandbox/server-sandbox/.gradle/buildOutputCleanup/outputFiles.bin create mode 100644 sandbox/server-sandbox/.gradle/file-system.probe create mode 100644 sandbox/server-sandbox/.gradle/vcs-1/gc.properties create mode 100644 sandbox/server-sandbox/README.md create mode 100644 sandbox/server-sandbox/build.gradle create mode 100644 sandbox/server-sandbox/build/classes/java/main/Hello$1.class create mode 100644 sandbox/server-sandbox/build/classes/java/main/Hello.class create mode 100644 sandbox/server-sandbox/build/tmp/compileJava/compileTransaction/stash-dir/Hello$1.class.uniqueId0 create mode 100644 sandbox/server-sandbox/build/tmp/compileJava/compileTransaction/stash-dir/Hello.class.uniqueId1 create mode 100644 sandbox/server-sandbox/build/tmp/compileJava/previous-compilation-data.bin create mode 100644 sandbox/server-sandbox/flagdata.json create mode 100644 sandbox/server-sandbox/gradle/wrapper/gradle-wrapper.jar create mode 100644 sandbox/server-sandbox/gradle/wrapper/gradle-wrapper.properties create mode 100755 sandbox/server-sandbox/gradlew create mode 100644 sandbox/server-sandbox/gradlew.bat create mode 100644 sandbox/server-sandbox/settings.gradle create mode 100644 sandbox/server-sandbox/src/main/java/Hello.java diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java index 17abddb0..172b96f6 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java @@ -26,11 +26,10 @@ public class FileSynchronizerTest { @Test public void synchronizerReturnsChangeSetOnSuccessfulLoad() throws Exception { - Synchronizer synchronizer = FileData.synchronizer() - .filePaths(resourceFilePath("all-properties.json")) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Synchronizer synchronizer = FileData.synchronizer() + .filePaths(resourceFilePath("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = synchronizer.next(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); @@ -38,18 +37,15 @@ public void synchronizerReturnsChangeSetOnSuccessfulLoad() throws Exception { assertThat(result.getChangeSet(), notNullValue()); assertThat(result.getChangeSet().getType(), equalTo(ChangeSetType.Full)); assertNotNull(result.getChangeSet().getData()); - } finally { - synchronizer.close(); } } @Test public void synchronizerReturnsInterruptedOnMissingFile() throws Exception { - Synchronizer synchronizer = FileData.synchronizer() - .filePaths(Paths.get("no-such-file.json")) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Synchronizer synchronizer = FileData.synchronizer() + .filePaths(Paths.get("no-such-file.json")) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = synchronizer.next(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); @@ -57,8 +53,6 @@ public void synchronizerReturnsInterruptedOnMissingFile() throws Exception { assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.INTERRUPTED)); assertNotNull(result.getStatus().getErrorInfo()); - } finally { - synchronizer.close(); } } @@ -87,60 +81,51 @@ public void synchronizerReturnsShutdownWhenClosed() throws Exception { @Test public void synchronizerCanLoadFromClasspathResource() throws Exception { - Synchronizer synchronizer = FileData.synchronizer() - .classpathResources(FileDataSourceTestData.resourceLocation("all-properties.json")) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Synchronizer synchronizer = FileData.synchronizer() + .classpathResources(FileDataSourceTestData.resourceLocation("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = synchronizer.next(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); assertThat(result.getChangeSet(), notNullValue()); - } finally { - synchronizer.close(); } } @Test public void synchronizerRespectsIgnoreDuplicateKeysHandling() throws Exception { - Synchronizer synchronizer = FileData.synchronizer() - .filePaths( - resourceFilePath("flag-only.json"), - resourceFilePath("flag-with-duplicate-key.json") - ) - .duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Synchronizer synchronizer = FileData.synchronizer() + .filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ) + .duplicateKeysHandling(FileData.DuplicateKeysHandling.IGNORE) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = synchronizer.next(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); // Should succeed when ignoring duplicates assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); - } finally { - synchronizer.close(); } } @Test public void synchronizerFailsOnDuplicateKeysByDefault() throws Exception { - Synchronizer synchronizer = FileData.synchronizer() - .filePaths( - resourceFilePath("flag-only.json"), - resourceFilePath("flag-with-duplicate-key.json") - ) - .build(TestDataSourceBuildInputs.create(testLogger)); - try { + try (Synchronizer synchronizer = FileData.synchronizer() + .filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ) + .build(TestDataSourceBuildInputs.create(testLogger))) { CompletableFuture resultFuture = synchronizer.next(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); // Should fail with interrupted error when duplicate keys are not allowed assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.STATUS)); assertThat(result.getStatus().getState(), equalTo(FDv2SourceResult.State.INTERRUPTED)); - } finally { - synchronizer.close(); } } @@ -150,12 +135,10 @@ public void synchronizerAutoUpdateEmitsNewResultOnFileChange() throws Exception try (TempFile file = dir.tempFile(".json")) { file.setContents(getResourceContents("flag-only.json")); - Synchronizer synchronizer = FileData.synchronizer() - .filePaths(file.getPath()) - .autoUpdate(true) - .build(TestDataSourceBuildInputs.create(testLogger)); - - try { + try (Synchronizer synchronizer = FileData.synchronizer() + .filePaths(file.getPath()) + .autoUpdate(true) + .build(TestDataSourceBuildInputs.create(testLogger))) { // Get initial result CompletableFuture resultFuture = synchronizer.next(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); @@ -172,8 +155,6 @@ public void synchronizerAutoUpdateEmitsNewResultOnFileChange() throws Exception // Note: File watching on MacOS can take up to 10 seconds result = nextResultFuture.get(15, TimeUnit.SECONDS); assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); - } finally { - synchronizer.close(); } } } diff --git a/sandbox/server-sandbox/.gitignore b/sandbox/server-sandbox/.gitignore new file mode 100644 index 00000000..9f11b755 --- /dev/null +++ b/sandbox/server-sandbox/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/sandbox/server-sandbox/.gradle/7.6/checksums/checksums.lock b/sandbox/server-sandbox/.gradle/7.6/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..f0b654169da283bddb5923018a7f71ed95b7a8c4 GIT binary patch literal 17 TcmZR!x@xpaCbT7m0Rp-JDe42# literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/7.6/checksums/md5-checksums.bin b/sandbox/server-sandbox/.gradle/7.6/checksums/md5-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..841f12f289ecaa8d210d1dde08793dac9c746f0c GIT binary patch literal 18747 zcmeI(-z!659LMp;k6mmwTQa7WP|8YL7HiT$xuD$PM!6v=%KVHQrD+%P52&Fe7b~U2 zjKY-*?vz^5DAU@d-HLdQb56UM;#%*gp67h~p6Aok^SV8qZXsNbPko5oaOO%X1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#~EF9L(|A{Om3uEs*OurCVXwaUU2I(*&OnZ8-j zUia)7-w%=_by-hv=w0t8jD9-RKCpea>(=_cW}{z~ID0G~cUJ5B=ZwD3tW0`mYchJj z%jg?V*biZ1{F literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/7.6/checksums/sha1-checksums.bin b/sandbox/server-sandbox/.gradle/7.6/checksums/sha1-checksums.bin new file mode 100644 index 0000000000000000000000000000000000000000..13b67b088b88d1aa968317070d5d67d3c61c0b4c GIT binary patch literal 19091 zcmeI(TS!xJ90%}|Gsv}2LUXg3zNn4TWLnA6!D2y^A_OmuRiIK?nG16WvM?2U@nH|5 zbQyWcE-Fe6yvBMNh8B@sh^5F(E5!%3haxat|HJ?PS7ILMG30yToc;OkeD^!&v&Zci z1wk3+XPdCTMXWEef&c^{009U<00Izz00bZa0SG_<0uX=z1Rwwb2tZ)72;`6(k<*(| z?hg_dq2+>*tRRc+)vl%6E!SS!=-Vb0|NX!*uM9qzelmvT&v@>+`6hSk?3v3f|ITx- zb@h`cv8jvZnF&1qHj|!x|HA_{JHM9aib8Mbr0d5d&5zIUJTmCTtKS7#4)!@`d9E=& zuG2?k9AoEq@jN9&em9~A# zsK2oAX!w1;p~@Mb(HdP+To;|zxp!=zI^ z{=G%6a9O%l)^w?%=c75wU~gMdy>^ceeYU=3v)B_C^8UbCQms*)Z8x2@QLDJ1DC)p@ zi>Xm(t{HgRAkUBO4yUt@9-^)0hk>)CT4Yw5bgM1KxoYiLP8!guZ@e4xd5zQUWQO=H P+sKn8;&$XKIcWa?^>REa literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/7.6/dependencies-accessors/dependencies-accessors.lock b/sandbox/server-sandbox/.gradle/7.6/dependencies-accessors/dependencies-accessors.lock new file mode 100644 index 0000000000000000000000000000000000000000..e96cce416be4705ac4f89dfe8a0d1b6b538826b2 GIT binary patch literal 17 ScmZR!GETS}VY*C>0SW*mD+2!j literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/7.6/dependencies-accessors/gc.properties b/sandbox/server-sandbox/.gradle/7.6/dependencies-accessors/gc.properties new file mode 100644 index 00000000..e69de29b diff --git a/sandbox/server-sandbox/.gradle/7.6/executionHistory/executionHistory.bin b/sandbox/server-sandbox/.gradle/7.6/executionHistory/executionHistory.bin new file mode 100644 index 0000000000000000000000000000000000000000..b58c7df49ee9a4c1d67f8177a2efb00ef79b150a GIT binary patch literal 394847 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 I+dn%0KZKS`DgXcg literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/7.6/executionHistory/executionHistory.lock b/sandbox/server-sandbox/.gradle/7.6/executionHistory/executionHistory.lock new file mode 100644 index 0000000000000000000000000000000000000000..90f0c57bc023ed036520e92e67579deb01f4a877 GIT binary patch literal 17 VcmZQx;=h$y-pOey0~jzC0stkz16cq7 literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/7.6/fileChanges/last-build.bin b/sandbox/server-sandbox/.gradle/7.6/fileChanges/last-build.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/7.6/fileHashes/fileHashes.bin b/sandbox/server-sandbox/.gradle/7.6/fileHashes/fileHashes.bin new file mode 100644 index 0000000000000000000000000000000000000000..15d9a7d9bc1942b8fcb8aba1e966fffbc51a627f GIT binary patch literal 18797 zcmeI(%PT}t0LSrb#v?{%EM$yFm9bFaRxhHmqCsFKgC zdENc%+TKpdh>|y}xq6rLS+U0z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1bz#o$PX5IMmVRTG08u^D);EVpz`H%*kSLN zB)4hbxT0*)&^v!9Nc`KVvejz}-E~2Y)uw@takIQBH$rw|z8l^93$6{vx#1~nEUo5f zVsucO9v$`Fu-F{p9hwW*#`g70@k+VJZphHhauK)l5 literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/7.6/fileHashes/resourceHashesCache.bin b/sandbox/server-sandbox/.gradle/7.6/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000000000000000000000000000000000000..631ef8581709a27dceb156c37957cfa70b6ab647 GIT binary patch literal 19687 zcmeI(c}$ac9LMo03uvL`7$Ap&fPzJi1`3YKJ;h!yK|x_-pa_V_7@`{xj)8QQ$r&~j z7B>)>GGN0^hs?-LIvEV8P(TDkj)i;C${`ty=6QZI>tkYKvSeAlB!wq$KKZqM0e{i% zBayUJ*6h)-Q}MSe`pApEp>EeVZdh|3A3+XNu&pWf5 zpHJm$TeJ=q;Cpk_*OY(Y&+BK`Vx6-Mb+YAQiR=8iHmtMws4KB=*Txkb#ebaKT-4Pb z7E*80mJRXqqfpn0)izyqSFOT2n}WLT_fx)iFNiDs5@ou57lfQdmG>1L)|s{*7(z|U>&S;j-l>GZWswX;{6Eg zykyjUqk@hmk1%L>4?sQQlln#_vilbNob!M8nIUd)b72V9xxJ{PuOyNjZi+47GYEhH z2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?x zfB*=900@8p2>crY*6W9AuisRqHx=#?{({zSkEIOH`=u;bKjwPW`{!ciE-iuWqHk{^ zIW~J?hQCByBtGg%W9=;nXilXT{bKX|EO8+=?6wmtGCNO-@~ELoBWH*UO4S~r+Gy2| zDL>Ik=4kGA;v$VdnRmcWMA6)QzG0!5+(cYl_st62K33A8xS6upzH8B#xTvwa!K8VG zMt##-(;%vTOZt`xnP*#MRQi6XeVFZH3;l$F@r3l91u~0*vP&&VDy+cyl7P6@#Ps9D z#j78w4fV8b?3ZoSe4WdKzFis8*ZIrbi#N@fz2sdrP-AA?7gs+aeFePCLejA|dAPFl z1N{eHJ9|=drSI*KSqR6Ao_943X7cX#jfK6L)d!rR>4$EG!j; z6;|9x4gWW`?5w>y2rH&0kz~K ziChmOiIM*Yaq-Nv!}LJ6&ZW5pM%IUd^q2hIwkU{wDsIG?=njYs<-(oYiD!I9k<2d& zR`ZA2*{ScA7jGag^n6@Q>aIk^-wDh~I`nOD5^>QiVEM25>qK1DFwf2yo|C>ON9Om` WS^jGCa$;9xu*&BI1_;;(065(RH~;_u literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/buildOutputCleanup/cache.properties b/sandbox/server-sandbox/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 00000000..3b11f709 --- /dev/null +++ b/sandbox/server-sandbox/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Fri Jan 16 15:53:01 PST 2026 +gradle.version=7.6 diff --git a/sandbox/server-sandbox/.gradle/buildOutputCleanup/outputFiles.bin b/sandbox/server-sandbox/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000000000000000000000000000000000000..b69e8600dc27447a927eea694c6d0763d302f3a6 GIT binary patch literal 18965 zcmeI%T}V@57{Kw>LY9rCu;_MNd_`+n&E2lck~$)|d2Led{m1E;Sv|W-d+_?}`(EGE zOzGp=Lk@dIXnxNgnG@08+;;wQX|S)ecMRWrYExBtNq!hj)L*kd0nzUQTy}lj?Wnh zV{2vqVeRkQ-6_t;-8*FeMeW1+&36ZH)cB;|(*CvK`|kQ!Znn(%qCFPtUgfztP%OPo z`_FI7E6=(d=cGrqPc98~72c_xkbYbHvZ;WTg?v^nfJW-9e*sDQOqzW%l|v+>?Z%yuYLn7VP=s4 literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/file-system.probe b/sandbox/server-sandbox/.gradle/file-system.probe new file mode 100644 index 0000000000000000000000000000000000000000..299e086949fdea46f85eb1175f5081466dbcbe3c GIT binary patch literal 8 PcmZQzV4TAv;J66@1g-(C literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/.gradle/vcs-1/gc.properties b/sandbox/server-sandbox/.gradle/vcs-1/gc.properties new file mode 100644 index 00000000..e69de29b diff --git a/sandbox/server-sandbox/README.md b/sandbox/server-sandbox/README.md new file mode 100644 index 00000000..90a4fc66 --- /dev/null +++ b/sandbox/server-sandbox/README.md @@ -0,0 +1,59 @@ +# Server Sandbox + +A Java application for testing features in the LaunchDarkly Java Server SDK. + +## Building + +This project uses Gradle for building. The Gradle wrapper is included, so you don't need to install Gradle separately. + +```bash +./gradlew build +``` + +## Dependency Resolution + +The project is configured to check for SDK dependencies in the following order: + +1. **mavenLocal** - For local development, build and publish the SDK locally first: + ```bash + cd ../../lib/sdk/server + ./gradlew publishToMavenLocal + ``` + +2. **mavenCentral** - Falls back to published versions from Maven Central + +The build.gradle dependency uses a version range (7.10.+) to pick up the latest compatible version. + +## Running + +Set your LaunchDarkly SDK key either as an environment variable or by editing the SDK_KEY in Hello.java: + +```bash +export LAUNCHDARKLY_SDK_KEY=your-sdk-key-here +./gradlew run +``` + +Or optionally set a custom flag key: + +```bash +export LAUNCHDARKLY_SDK_KEY=your-sdk-key-here +export LAUNCHDARKLY_FLAG_KEY=your-flag-key +./gradlew run +``` + +## What it does + +The Hello application: +- Initializes the LaunchDarkly SDK client +- Evaluates a feature flag (default: "sample-feature") +- Displays the flag value and shows a banner if the flag is on +- Listens for real-time flag changes +- Demonstrates proper SDK lifecycle management + +## Development + +This sandbox is useful for: +- Testing new SDK features during development +- Reproducing customer issues +- Validating SDK behavior changes +- Quick experimentation with SDK APIs diff --git a/sandbox/server-sandbox/build.gradle b/sandbox/server-sandbox/build.gradle new file mode 100644 index 00000000..a8a68e6f --- /dev/null +++ b/sandbox/server-sandbox/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'java' + id 'application' +} + +group = 'com.launchdarkly' +version = '1.0.0' +sourceCompatibility = 1.8 +targetCompatibility = 1.8 + +repositories { + // Check mavenLocal first for local SDK development + mavenLocal() + // Fall back to mavenCentral for published versions + mavenCentral() +} + +dependencies { + implementation 'com.launchdarkly:launchdarkly-java-server-sdk:7.10.+' + implementation 'org.slf4j:slf4j-simple:1.7.36' +} + +application { + mainClass = 'Hello' +} + +run { + standardInput = System.in +} diff --git a/sandbox/server-sandbox/build/classes/java/main/Hello$1.class b/sandbox/server-sandbox/build/classes/java/main/Hello$1.class new file mode 100644 index 0000000000000000000000000000000000000000..a35f1422783e48bbd8c0dce2ec0b7d6d7b894e3d GIT binary patch literal 726 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 E2hrfsCjbBd literal 0 HcmV?d00001 diff --git a/sandbox/server-sandbox/build/tmp/compileJava/compileTransaction/stash-dir/Hello$1.class.uniqueId0 b/sandbox/server-sandbox/build/tmp/compileJava/compileTransaction/stash-dir/Hello$1.class.uniqueId0 new file mode 100644 index 0000000000000000000000000000000000000000..a35f1422783e48bbd8c0dce2ec0b7d6d7b894e3d GIT binary patch literal 726 zcma)4O>Yx15PjZk<7T_1A%*h&9ZC`;R!H1}xP&5#wp1cjIB;?{R^nvi4c^@dei7$N zd`KMl0sJV$*sN5JJ$O7bdhgAQ$G`vl`~~0{It_TJarhxNP-oz;WtwK~rwqYbr0pbC zQWp&Sagr^gRFpcIO@vvbpQC)Th;nJx(nRsgfy4C!Y@)#tgxEsJ(4334AeNp+$K$z7 zNT~i?X;r*nsCBxd02i>s(F}3X!rSMm!LX5}SuQzt8FpO(l|_fg2cHtTDpaO9E;F>w zD!(hW7UNVhbUWvpqB~;n2ib%uJF(L8ZMhsvbMjB56=#V^N5Uu@S4qE^shpt^SFhJ6 zf2gKf6r~}RnkhBI_OKAi;z+D2QZUR)lgL-f_7*ti6WcabJM&Qy)`gL=ZIjSsDMx^8DQz}s8@nx~NmEjUGTEIZ+sy8S z-Ps(d2#NvnJPN!jN>L^-XRkh_2TeWM7d*FTuMxEc%GSn{aeJMBr?#%E z96ppQ@T;hRI&FH#XxcUsoMmOWs4C(Rid(_$sd&=vuv3Pf2q~x(STMB(yW`2emQ37? z87V(z!Q;bR0dIYMy(XLURLn;eS5@d2N(yB@EEjm*ujT%px#?>D z=Ngl}nwHm}>vekypIke&u5g$=n&0i#A{%yY3vX`SuwzT4Tki0R>s-Ey5|woGcP_&5 zG$}aOj}=&{V3mqmsmapKhG|*?73qF!c&m|4>wSg~^#Uc~c0bXyT15jIY2!Y_Hj+aE zD~qYNz{VjTPNpgIW!=-!x@FInw(fE=aEgjk32!L5K7l|n5=|1RdFtA;F|uLP1dTIQ zoF!?@+1R$BbLWn>$w37nfz?qfK}X3Xqx~^GHDHbg)3JeI+DHu8+nyhg6(W(Im&)RGf?LRGXf$t(}9EhH4Af?*2azZ^jO3#|{Crc1A0tGd*c1G^T@LD=}y# zjil{@Ba<>xj9xRT@<<#pOzlLMf?X=kM>lFv%w$!{ZN|xvA0v33tWjPs5Gs-&3gMIojY;Lz zF+-2wG8J#c<&1FgbUQ1XxNgQTHE09YH`Laf1eQqd)0t?L*{U~VnxmSmfO7Me`f#Oy zE~oVB3}P{|Kq)Ji5-5j`N`--&lAxIx9c<@SX>FSR~>IZd6^6NX%QqXhj;0?UpD z_DRMG^Y;p<9k!mbHDieIZd7oKK&4ZbW$d^aWI`m-eKM`?!~0cy03T#pv90!G+SZd4 zy^6ay)9rY|2N|B6=#0t|@MJOW=Hzd8Um< zb=mddPMWW6#E6nxnp-l_%%b+=_=JK_s`wN>&6;G2zt)VK?WM^Kd)P3@%Jv*9jsPsK z2d$KD20L>fp*-md2lHj0pv@RG3!jlS_1#C5(i735AP-Mr;2vV2lc8gk51$iQyh`ge zbekzmlXZe4bcPm5OK*_d9F*IPaiYw&j+l8h`xMnf0)pXT1@2REzdV5WWtrHb(@l6F z7{P-oK94WZ@T?9ua-+_a9vzTyZaNxNq}vUzJGah`w3b*KVg##a=4RmG$Dn!qVVU35k+1s3YD*b%%<&=`*j)o{xYBDp}U_12vcmh9>B!B9VoW^=mX40Iy=6L~0N{OFV z@eF=0Fq<~-=+D?OYdE>tvRE8UOX#i`&YBYZQpK~F?+RQwjdV|siQ zfXg#=+Yqx5k7-o!He=Ws5YUe1Wsz2A1|!NJ@J9uIQt@Y*QWqE9J2#mDUB+5!%~|Wi zUnz8Av?15o&`W2eNMS2RL(h)Hlg748qQ^*eO7+PDoE6pGNX>Sx(`)y~S>GrT$Fe+@ z3AVfmJVuB16%lylTFp_0BIZvxZ+_}ka99xwCazW-KOm;9 zVb{9;wvnhY=$v;Iv2fa~$q}+->WQA1ULy@$!}I_8nVt8DB;bhb1}6vYbS5f{hXD!v zvy1Dtc!VpU#0#diG^~43=64U-^4LzeOjXVdOD&dVr|pzJxYe-xtyp??nW*uL6=J0# zR;i*^)Ui66iXHLfkTqZgBaYBqp)>Q?R*E33NaVL3%g+e8MeYWhS+$u)x+cPak{K)# zgYt6ow1u6cgVRFRu@Aai=5JNFHoqjujfQp8?1=Be~eS4~*Tf~16%LJ7|;yhh@ePhJJ_T?Qh? zdH#*9AX_|*p|t)W#NGTT!SlIEfQrBKTXiSkANVI*et9Rx9=Ui2JkFSQ0LnOKbsxm+ zz?>`s_55XVTowyCkcBoyw~{M>0ID&MROaW=*YGcgJ-;lXa`cxu)X8#S*%)dh72ZfX zJV?E-8JBG8p_aofx3AA-&vB{go)-28&L+1hP&f?-Mdb8+n_8!2RM^MRUz|11K z4{9Jq#=K*&$1&2){%Y?*T-sb(U7E!k_Mswh1&8-@+M9FJ0&f|^Re|fWxIT;bG?xW# zX8$<$k%e2ccwe*PY4TP10=M0cs_N1MIHS7Elf{R2UFj414?mYT&lmWJv(Q!feyY1# zsrHTGV_Doq#d9ogc~kj>Z4PiEsqz{4EFV?nS!4tE^2=RQRlW~pV;Enw4~mB}Nhuz1 zxHyD|fP3+X7rO$Fk@^_EF^(s?$MIBmb?HHTFYv>I_;D6L8%GI65zYQ;e|K}mL)HE) zeolxXYv|?cMu36iM2@K99)j|JXvnwAuWs>(aIy$Cfvpwv^&s_I}zb= zWh+J4hKG5f_Bh{9;ygS}2tUgkuo85M+1MqTcq0|UZm|v*iWXcXHbNH{pkG{!y*CQ6?0E`A(J-K2c5(--~C2UsOOLhlhm9FCY24R?H&3a&oayRI=?C zbGT0rD%JJkCGMvn{32#K&vlBJ>w6JP6pZ*@#xez0D`K8_0mr|De)**w<}IkIh$^{A z{)@Ss>P~aUQKFDvp@`#NfFh3PK3)n_J;yJ@Shqy)zseA;UFXhig2@HD5y`Y1FB>G97g!>YSWYi0jfkPg0~bsz{^^ zO;S=x%8+OhG8836hC=#2EBD^d=llJ=et+nXp1s#z<1?-I^Q=__Cd4t%RZ=@UIf1#( z;ajTscVk25Zz?|;;7Kl+B*gjb+7qi%&FF$L-gRe2pFY+)h}5FAX#+;Yia3#qs2}ET zCAaU71=vZPWLDd)C5oMP&I2%arS;B%n3qzj*ez8EFc*Pf5awxTE0sCo@PlEz=3wy< z?V-A=qG2L-I2K?lbClXBB#yRnnNlzUbGNmVs$uRDN6ARcQ|_d&l8(Z>oNVo^rHa1h zSl{o_24f6<(H?6sPBdPmG6D0pliS$X%4`Gz!LOK`+-4%?A>A#t>#Hi%oFtsAMg0c7 z$+p?r2uAglPSKpIHO)YEI_CAGkzH)m9xeOMj>?`|SbDq}hIX_zV6=3sq7HBQBSfsmHv_!=If%&1ODz-_iq)Ne3 z%-v36vkdc)*f~j;W1ib3G8^d%%wzWtK4F-Kg=kq{FO&Oz59#}@X|_lU^HNBxcK*wa zL)icN`R}Ek&^Ff*>55j0R*CdPt3~=E6$22-c8Rsr+7NS>D{PFg5UcG{)Xz4B#9q2b zuGnded4W9bja78_n7Y0nC*=DI65k9w>mjW_hD|* zZ9Fqv(H4kZdAlO+&a~6%!I$@I{bh1M^tXsTi1`77mCi~>sl8Hg2*?24~;!-ej+*aO$~#B~`js4kT_Iw_>yIOYLLr1Sv_3zG_b zaj-tQ%n!!`qzYRQ*1o>w@)P#hwC2oPk#cc#=*N$A`e=XLEC3e<;)X%EHlr2{&E?io zJ3$Ee1Bt>$>c}tr=&7qad*0)o>x;8LT#G$(7#D@&q9eG-!Z-|SZyTk`5%;wJyg z4MD+yFC9L&j2|A3i;kj7$6yvRsiP&IempNfysmO*bhc@JucD>qg=tb3qjJ zVk@$)IV9tlx0T$^PHN>SIE8~W98W_Dh}70z z;=p^xHF>QZXxaQ^{L-1KqeeeZ!8Oj{;#6GcEE-C14#)iWIsgalLE|KLX}}goYdhPm z=b`Ui*MoD<+!i{ z*Sd|1@8H@8)b2uqK82kjfPU|Rj6rAkiwVCso7q1K&+08YRz9eFbR}*YfZvCYPIglM z7n3(Wp=Jej`a^tur%ZX_T7_#qz>Q}=gr2tU0LhHv&BkAEOuL*oML*+>_^!7%Ta9bg z;93U;&Eb#SzAH3}&+FAq@N_*rV@zc&u3Lv|*W;o`xTpaa={<&p{^z;Y2ppdPqvQ&{ za$d-d3q3LZf;}(ePOaE9>M5?(2pv8Hmj3IkpF_)kT)kAmH-Tp>ekJph>1FE<9(yoh zCO`bqtrwQ{lMa=nhRqtY#5DH>uJaPtX~IR#NI71?o3-Jhx1jRvpnnSKwmyyQfTqq4(sv*f`A(^fAJS2s zKSr4*e(svn^ZSj={lD|UZt>fJ2@<{yp9v64w!VkFVM_l{~*Zz#_cHyFK zT*Q6>rLu<2QrWjvDg}bCz=+-IBfp_}Dna1|J@5qH!AZ%-&0XNVrsT-LJA3VPwIz!l ze#fp;$a8Q6s^OIdo zikgSKI4enaY@YSj!r6_`b0>5>2%#ro;zfwP39%2My;yBz5AF-C6!P7+uyqUk2+TvF zkSqKN;2JpptQp&HrcPWQ@VsEb>p=a?K>>tGAR!Jyjf-*Al%I5l>b6Um7FBkA=i|JF z!WqGYW(c8mm@rX2gwGhoD{dI;wC;2?Sy1j9mhFAmHk8mjLTH5%t5m~bD7k#65-OE0 zzFnoOzkC$kbhAy<2s?9>&2Ywj_v6eU>frqwm zZ`+=%IPz>^hkK9Jg87+*MiyZfiK9xq@h6LZ3kr|l-Z}ck-8-S00ojCE4q=>27&2Et zX>9+&ZFvA79kh7Wu{n)Ff6QCo`_OLQ>$OK}uM)z1!lZyOI*wn1$w}bQ=q%{xZ(se02M?x=VZhsw52W6Gl~p_5(unkPuZ9B8wUV^OGXI1&1z^))JWi z|DXt^pbpk8uLq5gJ3a!=%B^gw>^$7B<3#z z^y&Z$hHaC7=(k<7wkm1imE_OS8A3IwD=Bs(MeZbca1Ro5-?7_X;7MZM;J2+Dw>j{s zjg*O@>di(+ZqU;Fgtn>*oZmC{fbT+Qja0+g*~{~<9>4EJntGF~eMpfRz${-9`2%EY zS}FUs9va^se{`;X^pZVC`uUM2{-oIjJOt0g`RZE!!U(^PX^v)bmelm^AY|91@l8%Pm=nH zB8YgVSyFhB5RBqg>pVtx^=Li8=RPa_b*1&DbEI|}sd=6> zjKR}kN={103=(sdULax5w{k*u1;A^KR`<45%X5yeii{ndxp)8Ei=^lhTEu0To0S9v z0?_qQLDPzZx3(mlSt<7m>6K)XV0pkrkG!|0lco$A_i%9i@44~Iwq%he+2ra-+!xOQ zRsv!K1K2I)LscTe-Fr>srbmbEE&KgORW7M{g%swITG{wjG?hMJ3Ot7u{Fxs1MgPpW zX|s#JUAW#qGd7 z6R*(43(`7u@I=4pw)27_Qd~e3_bE2S12)cl#O>NspPGATYOb<~^Rf(WEFraSkb04L zDb$fE9VH-2Y&tSvh3vWF*osky5CgGfp;LEzR)5}P$TjbhuQu`X|SOG)YI&QyB zLZJM^%xvv!9i912p3~^OnWE1*$90B~(dOxQNby}#D}}rV0!O^81Pbs&$&dFBlyte( zD!1ILS!C=ueBpgkyNc9&KpH(HHL6Lo8d4__uZ6kT^|`;h#4d%>kw35EG}X8=eCwGp z-K+DN$HF>NtDZDCFntD}X_6LwJ9ySQ`F??fUmyF|BhsRQ6f=)uVN!bu@_kQ`-U1c| z)vj0-klkIftbA7E)RxAuEv-*UaU;3<87X>Bintdr!yRaE_r+g=8~HbKLbms$dB}jf z_l8a>-}7R|C7UA~n@EG*XGs2!Dq7#%!#(k6QrMhT1B2X~NuyV!=rt*7Aw{cOLBKnt zj$0KHi0HsU9U=MO-e!+o%tg*EQ~CW6=3Y4C)23o`^c$&l~oXLU|r_2}2l z78lyoWbzAD^V>-?H?jk1l?1j3v?C0u%dwlPP0y&_?5Fvlc>nYdIKSp&_sU*#;bil< zCzoa{PF?wq)PGOvL=t{PCz6-Unr|OJV&aQd@gF{Ja~Di7pIGsM)Z6$GY{Hsf@{7Ce zJqtYMTzcZ4CA-rTK9L5WN!>1TO+9$xCwMoqKmZ65J0)+XmpyRL;5BZNfWm|E>TV5R zNYPhP^o7-Tb35&tXv$6NuUkTq{b)z5*a))jZ^ie`EYEd|LuExWTkt+9d zDmG>f4Dg`Do|L!?_kuc7gs0_-k$h$oF~i(?&wh)$F|ku8Zwd9LjC?4NN$aW>`gZmF%1}!C2xSp~hf&~g`*c!jt;XL;#D{F0b#kq0X;OzwT0S$J(mG15 zI?zJQ;8h9^&1rQXH*{JHgH?Xp*LjRGiJ(NsDN!UPvWNnP?ogL+-C+g#bpqzEkVCi% z#%SQ+uu}1R$^IQS>-ry8bsrH;iDD>_Q!Mbn%Ffo3xB10>)8g8@UfWHGf>9eUbi`2x z@s#))`I1ZkX-Vw2T1)uZ=@*)&AL)K#q|heD9CMC3Nr@9F?IcP!nG&9&%reQlgus_l zJB9Oh*7p5}sr^;~mtG-Z@RnOq~!!2Et>dajea*SDC1Zfwi*Aq$ol5B_^g zP%343mePAdoP$cT-4Op+gYw7F>zb>!-&YPKj(!RE-ajvmGE4*|;Ez`M8+pcUD>Li9 zrYFab7Uq$Ba6#E1t$^p98SZCdyALdCKTm0&#nUMOb`YNc-~k68et5Wk%TQ7OvA!M} zMZ#Fy3`*+)CCnxS7h%jFSufuEwsl3k!GQJiUawTnQu$-fC2H+uO6viU2|fLXBPvKf z?zz^8pkg~^#KGLqsrn!0WKrU5N;`)#IZ%W{Aj~f(Hl*p~Ss&QA>970h2`|-hDYGk- zMhu<@{rs13f_%mB#Hy)hmU(GS?0wZGxSe+LDrJ&S>0ZE>0#4|s)6#u(!$Ok~yyKEe z=%CL96zt8{`j!Naxeo;-<1v?uhJ8y{Z=7kmd|TzXLJCqs1V8L`k85J`+|pS^*&|*0 z#nzM){4N!LZ_VPS5ev2dP_7I9^A*WIUTH6US^Z@~M`QkpNm%c+>y*(>1>exFe)jOX z33+8p6ncvAzCmQx>h}LS?9eS9l`njCaub?b$Q~Gx()4P=D9tFtDN>B;? zNq0HfDgXyOAv;NaMzqEAK+j3(3-UGZCVUIJai7wyqBQF92Vf^ZxF@xKg7cqxhjh*> zeY0fi^|zPEGhrhiQsN-|F3t}eJs_+`Z`1n0AM-wZj_mBHrnGuMf_%%cX(@a3Cfuvb z@RKQL9T;0fiEAmHNW2cZ-D_p%gf@j@k`K2sIB8!*TS|vyscyWd99K^n{PGCw08+vL zJBW<0^DO(dUed`OmQwVxq_u(4d`y`

$MNJcWLKWO(F?QM}fai{CY8Kc7x-tg?8l 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

$MNJcWLKWO(F?QM}fai{CY8Kc7x-tg?8l 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL diff --git a/sandbox/server-sandbox/gradle/wrapper/gradle-wrapper.properties b/sandbox/server-sandbox/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 070cb702..00000000 --- a/sandbox/server-sandbox/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/sandbox/server-sandbox/gradlew b/sandbox/server-sandbox/gradlew deleted file mode 100755 index 1b6c7873..00000000 --- a/sandbox/server-sandbox/gradlew +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/sandbox/server-sandbox/gradlew.bat b/sandbox/server-sandbox/gradlew.bat deleted file mode 100644 index ac1b06f9..00000000 --- a/sandbox/server-sandbox/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/sandbox/server-sandbox/settings.gradle b/sandbox/server-sandbox/settings.gradle deleted file mode 100644 index ddeb228a..00000000 --- a/sandbox/server-sandbox/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'server-sandbox' diff --git a/sandbox/server-sandbox/src/main/java/Hello.java b/sandbox/server-sandbox/src/main/java/Hello.java deleted file mode 100644 index a678d70e..00000000 --- a/sandbox/server-sandbox/src/main/java/Hello.java +++ /dev/null @@ -1,121 +0,0 @@ -import java.io.IOException; -import java.time.Duration; -import java.time.Instant; -import java.util.Date; - -import com.launchdarkly.logging.LDLogLevel; -import com.launchdarkly.sdk.*; -import com.launchdarkly.sdk.server.*; -import com.launchdarkly.sdk.server.integrations.FileData; - - -public class Hello { - - static String SDK_KEY = ""; - static String FEATURE_FLAG_KEY = "my-boolean-flag"; - - private static void showMessage(String s) { - System.out.println("*** " + s); - System.out.println(); - } - - private static void showBanner() { - showMessage("\n ██ \n" + - " ██ \n" + - " ████████ \n" + - " ███████ \n" + - "██ LAUNCHDARKLY █\n" + - " ███████ \n" + - " ████████ \n" + - " ██ \n" + - " ██ \n"); - } - - public static void main(String... args) throws Exception { - boolean CIMode = System.getenv("CI") != null; - - String envSDKKey = System.getenv("LAUNCHDARKLY_SDK_KEY"); - if(envSDKKey != null) { - SDK_KEY = envSDKKey; - } - - String envFlagKey = System.getenv("LAUNCHDARKLY_FLAG_KEY"); - if(envFlagKey != null) { - FEATURE_FLAG_KEY = envFlagKey; - } - - LDConfig config = new LDConfig.Builder() -// .serviceEndpoints(Components.serviceEndpoints() -// .polling("http://localhost:3002/proxy-poll") -// .streaming("http://localhost:3001/proxy")) - .dataSource(FileData.dataSource().filePaths("stuff/flagdata.json").autoUpdate(true)) - .logging(Components.logging().level(LDLogLevel.DEBUG)) -// .dataSystem( -// Components.dataSystem().custom().synchronizers( -// FileData.synchronizer().filePaths("flagdata.json").autoUpdate(true))) - .build(); - - if (SDK_KEY == null || SDK_KEY.equals("")) { - showMessage("Please set the LAUNCHDARKLY_SDK_KEY environment variable or edit Hello.java to set SDK_KEY to your LaunchDarkly SDK key first."); - System.exit(1); - } - - final LDClient client = new LDClient(SDK_KEY, config); - if (client.isInitialized()) { - showMessage("SDK successfully initialized!"); - } else { - showMessage("SDK failed to initialize. Please check your internet connection and SDK credential for any typo."); - System.exit(1); - } - - final LDContext context = LDContext.builder("example-user-key") - .name("Sandy") - .build(); - - - showMessage("Start evals."); - Instant start = new Date().toInstant(); - for(int i = 0; i < 1000000; i++) { - client.boolVariation(FEATURE_FLAG_KEY, context, false); - } - Instant end = new Date().toInstant(); - showMessage("End evals."); - showMessage("Execution time: " + Duration.between(start, end)); - - - boolean flagValue = client.boolVariation(FEATURE_FLAG_KEY, context, false); - showMessage("The '" + FEATURE_FLAG_KEY + "' feature flag evaluates to " + flagValue + "."); - - if (flagValue) { - showBanner(); - } - - if(CIMode) { - System.exit(0); - } - - client.getFlagTracker().addFlagValueChangeListener(FEATURE_FLAG_KEY, context, event -> { - showMessage("The '" + FEATURE_FLAG_KEY + "' feature flag evaluates to " + event.getNewValue() + "."); - - if (event.getNewValue().booleanValue()) { - showBanner(); - } - }); - showMessage("Listening for feature flag changes."); - - Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { - public void run() { - try { - client.close(); - } catch (IOException e) { - // ignore - } - } - }, "ldclient-cleanup-thread")); - - Object mon = new Object(); - synchronized (mon) { - mon.wait(); - } - } -} From 40d70e930be0c81ee7fa76b57a74c99d89f618c6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:36:33 -0800 Subject: [PATCH 05/15] Consolidate implementation. --- .../integrations/FileDataSourceImpl.java | 19 ++++++------ .../server/integrations/FileInitializer.java | 29 ++++++++++++++----- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index cb45ec69..2ebe6b7b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -33,6 +33,8 @@ final class FileDataSourceImpl implements DataSource { private final LDLogger logger; private Thread updateThread; + private final boolean autoUpdate; + FileDataSourceImpl( DataSourceUpdateSink dataSourceUpdates, List sources, @@ -42,8 +44,8 @@ final class FileDataSourceImpl implements DataSource { ) { this.dataSourceUpdates = dataSourceUpdates; this.logger = logger; - // The FDv1 this.synchronizer = new FileSynchronizer(sources, autoUpdate, duplicateKeysHandling, logger, true); + this.autoUpdate = autoUpdate; } @Override @@ -62,14 +64,13 @@ public Future start() { processResult(initialResult); - // Note that if the initial load finds any errors, it will not set our status to "initialized". - // But we will still do all the other startup steps, because we still might end up getting - // valid data if we are told to reload by the file watcher. - - // Start a background thread to listen for file changes - updateThread = new Thread(this::runUpdateLoop, FileDataSourceImpl.class.getName()); - updateThread.setDaemon(true); - updateThread.start(); + // We only need to drive the update loop if auto-updating is enabled. + if(autoUpdate) { + // Start a background thread to listen for file changes + updateThread = new Thread(this::runUpdateLoop, FileDataSourceImpl.class.getName()); + updateThread.setDaemon(true); + updateThread.start(); + } return initFuture; } 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 3f8008b4..f7a7b03e 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 @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; +import java.io.IOException; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -14,9 +15,11 @@ * This implements the {@link Initializer} interface, loading files once and returning * the result. If loading fails, it returns a terminal error since an initializer * cannot retry. + *

+ * Internally delegates to {@link FileSynchronizer} with auto-update disabled. */ -final class FileInitializer extends FileDataSourceBase implements Initializer { - private final CompletableFuture shutdownFuture = new CompletableFuture<>(); +final class FileInitializer implements Initializer { + private final FileSynchronizer synchronizer; FileInitializer( List sources, @@ -24,18 +27,28 @@ final class FileInitializer extends FileDataSourceBase implements Initializer { LDLogger logger, boolean persist ) { - super(sources, duplicateKeysHandling, logger, persist); + // Use FileSynchronizer with autoUpdate=false for the actual file loading + this.synchronizer = new FileSynchronizer(sources, false, duplicateKeysHandling, logger, persist); } @Override public CompletableFuture run() { - CompletableFuture loadResult = CompletableFuture.supplyAsync(() -> loadData(true)); - return CompletableFuture.anyOf(shutdownFuture, loadResult) - .thenApply(result -> (FDv2SourceResult) result); + return synchronizer.next().thenApply(result -> { + // Convert INTERRUPTED to TERMINAL_ERROR for initializer semantics + // (initializers can't retry, so all errors are terminal) + if (result.getResultType() == FDv2SourceResult.ResultType.STATUS && + result.getStatus().getState() == FDv2SourceResult.State.INTERRUPTED) { + return FDv2SourceResult.terminalError( + result.getStatus().getErrorInfo(), + result.isFdv1Fallback() + ); + } + return result; + }); } @Override - public void close() { - shutdownFuture.complete(FDv2SourceResult.shutdown()); + public void close() throws IOException { + synchronizer.close(); } } From 99be7b4dd89005605e7e4255f86d9b6dc396c160 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:38:55 -0800 Subject: [PATCH 06/15] Always run the update thread. --- .../integrations/FileDataSourceImpl.java | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 2ebe6b7b..893b08db 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -33,19 +33,16 @@ final class FileDataSourceImpl implements DataSource { private final LDLogger logger; private Thread updateThread; - private final boolean autoUpdate; - FileDataSourceImpl( - DataSourceUpdateSink dataSourceUpdates, - List sources, - boolean autoUpdate, - FileData.DuplicateKeysHandling duplicateKeysHandling, - LDLogger logger + DataSourceUpdateSink dataSourceUpdates, + List sources, + boolean autoUpdate, + FileData.DuplicateKeysHandling duplicateKeysHandling, + LDLogger logger ) { this.dataSourceUpdates = dataSourceUpdates; this.logger = logger; this.synchronizer = new FileSynchronizer(sources, autoUpdate, duplicateKeysHandling, logger, true); - this.autoUpdate = autoUpdate; } @Override @@ -64,13 +61,11 @@ public Future start() { processResult(initialResult); - // We only need to drive the update loop if auto-updating is enabled. - if(autoUpdate) { - // Start a background thread to listen for file changes - updateThread = new Thread(this::runUpdateLoop, FileDataSourceImpl.class.getName()); - updateThread.setDaemon(true); - updateThread.start(); - } + // Start a background thread to listen for file changes + updateThread = new Thread(this::runUpdateLoop, FileDataSourceImpl.class.getName()); + updateThread.setDaemon(true); + updateThread.start(); + return initFuture; } @@ -83,7 +78,7 @@ private void runUpdateLoop() { break; } if (result.getResultType() == FDv2SourceResult.ResultType.STATUS && - result.getStatus().getState() == FDv2SourceResult.State.SHUTDOWN) { + result.getStatus().getState() == FDv2SourceResult.State.SHUTDOWN) { break; } processResult(result); From a28f375d4da7b83196b24ee1eaead200e77e9779 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:42:36 -0800 Subject: [PATCH 07/15] Add a comment about terminal errors. --- .../launchdarkly/sdk/server/integrations/FileDataSourceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 893b08db..b0b47ccc 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -102,6 +102,7 @@ private void processResult(FDv2SourceResult result) { if (result.getStatus().getState() != FDv2SourceResult.State.SHUTDOWN) { dataSourceUpdates.updateStatus(State.INTERRUPTED, result.getStatus().getErrorInfo()); } + // No terminal errors/shutdown for this adaptation. } } From 82baee3a851f182ed2f10fdf2049042f582b13a1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:49:33 -0800 Subject: [PATCH 08/15] Remove one-shot concept. --- .../sdk/server/integrations/FileDataSourceBase.java | 6 ++---- .../sdk/server/integrations/FileSynchronizer.java | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java index 03bc9c0c..0d5f8eb9 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java @@ -62,7 +62,7 @@ protected FileDataSourceBase( * @param oneShot true if this is a one-shot load (Initializer), false for continuous (Synchronizer) * @return an FDv2SourceResult containing either a ChangeSet or an error status */ - protected FDv2SourceResult loadData(boolean oneShot) { + protected FDv2SourceResult loadData() { DataBuilder builder = new DataBuilder(duplicateKeysHandling); int version; try { @@ -77,9 +77,7 @@ protected FDv2SourceResult loadData(boolean oneShot) { Instant.now() ); // For initializers, file errors are terminal. For synchronizers, they are recoverable. - return oneShot - ? FDv2SourceResult.terminalError(errorInfo, false) - : FDv2SourceResult.interrupted(errorInfo, false); + return FDv2SourceResult.interrupted(errorInfo, false); } FullDataSet fullData = builder.build(); 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 8f94c6dd..0be97bff 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 @@ -65,7 +65,7 @@ public CompletableFuture next() { if (!started) { started = true; // Perform initial load - resultQueue.put(loadData(false)); + resultQueue.put(loadData()); // Start file watching if enabled if (fileWatcher != null) { fileWatcher.start(this::onFileChange); @@ -76,7 +76,7 @@ public CompletableFuture next() { } private void onFileChange() { - resultQueue.put(loadData(false)); + resultQueue.put(loadData()); } @Override From 82fb9dd584db844e32030a4a1680692ac34dc946 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:31:19 -0800 Subject: [PATCH 09/15] Use IterableAsyncQueue from common package. --- lib/sdk/server/build.gradle | 2 +- .../server/DataSourceSynchronizerAdapter.java | 1 + .../sdk/server/IterableAsyncQueue.java | 32 -- .../sdk/server/PollingSynchronizerImpl.java | 1 + .../sdk/server/StreamingSynchronizerImpl.java | 1 + .../server/integrations/FileSynchronizer.java | 34 +- .../sdk/server/FDv2DataSourceTest.java | 1 + .../sdk/server/IterableAsyncQueueTest.java | 343 ------------------ 8 files changed, 7 insertions(+), 408 deletions(-) delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/IterableAsyncQueue.java delete mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/IterableAsyncQueueTest.java diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index b91fe425..e6c6661a 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -70,7 +70,7 @@ ext.versions = [ "gson": "2.13.1", "guava": "32.0.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "2.1.2", + "launchdarklyJavaSdkCommon": "2.2.1", "launchdarklyJavaSdkInternal": "1.6.1", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource 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 c589d1bb..36c773fb 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 @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.collections.IterableAsyncQueue; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.Synchronizer; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/IterableAsyncQueue.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/IterableAsyncQueue.java deleted file mode 100644 index 4fc804dd..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/IterableAsyncQueue.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.launchdarkly.sdk.server; - -import java.util.LinkedList; -import java.util.concurrent.CompletableFuture; - -class IterableAsyncQueue { - private final Object lock = new Object(); - private final LinkedList queue = new LinkedList<>(); - - private final LinkedList> pendingFutures = new LinkedList<>(); - - public void put(T item) { - synchronized (lock) { - CompletableFuture nextFuture = pendingFutures.pollFirst(); - if(nextFuture != null) { - nextFuture.complete(item); - return; - } - queue.addLast(item); - } - } - public CompletableFuture take() { - synchronized (lock) { - if(!queue.isEmpty()) { - return CompletableFuture.completedFuture(queue.removeFirst()); - } - CompletableFuture takeFuture = new CompletableFuture<>(); - pendingFutures.addLast(takeFuture); - return takeFuture; - } - } -} 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 43c95ee0..9fe08897 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 @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.collections.IterableAsyncQueue; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.SelectorSource; import com.launchdarkly.sdk.server.datasources.Synchronizer; 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 e528c517..7f132e7b 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 @@ -12,6 +12,7 @@ import com.launchdarkly.eventsource.StreamHttpErrorException; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.collections.IterableAsyncQueue; import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ProtocolHandler; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; 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 0be97bff..e49721cc 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 @@ -2,6 +2,7 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.collections.IterableAsyncQueue; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; @@ -33,7 +34,7 @@ */ final class FileSynchronizer extends FileDataSourceBase implements Synchronizer { private final CompletableFuture shutdownFuture = new CompletableFuture<>(); - private final AsyncQueue resultQueue = new AsyncQueue<>(); + private final IterableAsyncQueue resultQueue = new IterableAsyncQueue<>(); private final FileWatcher fileWatcher; // null if autoUpdate=false private volatile boolean started = false; @@ -87,37 +88,6 @@ public void close() { } } - /** - * A simple thread-safe async queue for passing results between the file watcher and the synchronizer. - */ - private static final class AsyncQueue { - private final Object lock = new Object(); - private final LinkedList queue = new LinkedList<>(); - private final LinkedList> pendingFutures = new LinkedList<>(); - - public void put(T item) { - synchronized (lock) { - CompletableFuture nextFuture = pendingFutures.pollFirst(); - if (nextFuture != null) { - nextFuture.complete(item); - return; - } - queue.addLast(item); - } - } - - public CompletableFuture take() { - synchronized (lock) { - if (!queue.isEmpty()) { - return CompletableFuture.completedFuture(queue.removeFirst()); - } - CompletableFuture takeFuture = new CompletableFuture<>(); - pendingFutures.addLast(takeFuture); - return takeFuture; - } - } - } - /** * If auto-updating is enabled, this component watches for file changes on a worker thread. */ 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 400b02b7..16df9062 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 @@ -3,6 +3,7 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.collections.IterableAsyncQueue; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.Initializer; diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/IterableAsyncQueueTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/IterableAsyncQueueTest.java deleted file mode 100644 index 2bd8c620..00000000 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/IterableAsyncQueueTest.java +++ /dev/null @@ -1,343 +0,0 @@ -package com.launchdarkly.sdk.server; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.*; - -@SuppressWarnings("javadoc") -public class IterableAsyncQueueTest { - - @Test - public void putThenTakeReturnsImmediately() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - - queue.put("item1"); - - CompletableFuture future = queue.take(); - assertTrue("Future should be completed immediately", future.isDone()); - assertEquals("item1", future.get()); - } - - @Test - public void takeThenPutCompletesWaitingFuture() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - - CompletableFuture future = queue.take(); - assertFalse("Future should not be completed yet", future.isDone()); - - queue.put("item1"); - - assertTrue("Future should be completed after put", future.isDone()); - assertEquals("item1", future.get()); - } - - @Test - public void multiplePutsThenMultipleTakesPreservesOrder() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - - // Put multiple items - queue.put(1); - queue.put(2); - queue.put(3); - - // Take them in order - assertEquals(Integer.valueOf(1), queue.take().get()); - assertEquals(Integer.valueOf(2), queue.take().get()); - assertEquals(Integer.valueOf(3), queue.take().get()); - } - - @Test - public void multipleTakesThenMultiplePutsCompletesInOrder() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - - // Multiple takes when queue is empty - CompletableFuture future1 = queue.take(); - CompletableFuture future2 = queue.take(); - CompletableFuture future3 = queue.take(); - - assertFalse(future1.isDone()); - assertFalse(future2.isDone()); - assertFalse(future3.isDone()); - - // Put items - should complete futures in FIFO order - queue.put(1); - assertTrue("First future should be completed", future1.isDone()); - assertFalse("Second future should not be completed yet", future2.isDone()); - assertFalse("Third future should not be completed yet", future3.isDone()); - assertEquals(Integer.valueOf(1), future1.get()); - - queue.put(2); - assertTrue("Second future should be completed", future2.isDone()); - assertFalse("Third future should not be completed yet", future3.isDone()); - assertEquals(Integer.valueOf(2), future2.get()); - - queue.put(3); - assertTrue("Third future should be completed", future3.isDone()); - assertEquals(Integer.valueOf(3), future3.get()); - } - - @Test - public void interleavedPutAndTakeOperations() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - - // Put one - queue.put("a"); - assertEquals("a", queue.take().get()); - - // Take when empty, then put - CompletableFuture future = queue.take(); - assertFalse(future.isDone()); - queue.put("b"); - assertEquals("b", future.get()); - - // Put multiple, take one, put one more, take remaining - queue.put("c"); - queue.put("d"); - assertEquals("c", queue.take().get()); - queue.put("e"); - assertEquals("d", queue.take().get()); - assertEquals("e", queue.take().get()); - } - - @Test - public void concurrentProducersAndConsumers() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - int itemCount = 1000; - int producerThreads = 5; - int consumerThreads = 5; - - ExecutorService executor = Executors.newFixedThreadPool(producerThreads + consumerThreads); - CountDownLatch producerLatch = new CountDownLatch(producerThreads); - CountDownLatch consumerLatch = new CountDownLatch(consumerThreads); - - List consumedItems = new ArrayList<>(); - Object consumedLock = new Object(); - - // Start producers - for (int t = 0; t < producerThreads; t++) { - final int threadId = t; - executor.submit(() -> { - try { - for (int i = 0; i < itemCount / producerThreads; i++) { - queue.put(threadId * 1000 + i); - Thread.sleep(1); // Small delay to encourage interleaving - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - producerLatch.countDown(); - } - }); - } - - // Start consumers - for (int t = 0; t < consumerThreads; t++) { - executor.submit(() -> { - try { - for (int i = 0; i < itemCount / consumerThreads; i++) { - Integer item = queue.take().get(5, TimeUnit.SECONDS); - synchronized (consumedLock) { - consumedItems.add(item); - } - } - } catch (Exception e) { - throw new RuntimeException(e); - } finally { - consumerLatch.countDown(); - } - }); - } - - // Wait for completion - assertTrue("Producers should complete", producerLatch.await(10, TimeUnit.SECONDS)); - assertTrue("Consumers should complete", consumerLatch.await(10, TimeUnit.SECONDS)); - - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - - // Verify all items were consumed - assertEquals("All items should be consumed", itemCount, consumedItems.size()); - } - - @Test - public void singleProducerAndConsumer() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - int itemCount = 10000; - - AtomicInteger producedCount = new AtomicInteger(0); - AtomicInteger consumedCount = new AtomicInteger(0); - - ExecutorService executor = Executors.newFixedThreadPool(2); - - // Producer - CompletableFuture producer = CompletableFuture.runAsync(() -> { - for (int i = 0; i < itemCount; i++) { - queue.put(i); - producedCount.incrementAndGet(); - } - }, executor); - - // Consumer - CompletableFuture consumer = CompletableFuture.runAsync(() -> { - try { - for (int i = 0; i < itemCount; i++) { - Integer item = queue.take().get(5, TimeUnit.SECONDS); - assertEquals(Integer.valueOf(i), item); - consumedCount.incrementAndGet(); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - }, executor); - - // Wait for both to complete - CompletableFuture.allOf(producer, consumer).get(10, TimeUnit.SECONDS); - - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - - assertEquals("All items should be produced", itemCount, producedCount.get()); - assertEquals("All items should be consumed", itemCount, consumedCount.get()); - } - - @Test - public void multipleProducersSingleConsumer() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - int producersCount = 10; - int itemsPerProducer = 100; - int totalItems = producersCount * itemsPerProducer; - - ExecutorService executor = Executors.newFixedThreadPool(producersCount + 1); - CountDownLatch producerLatch = new CountDownLatch(producersCount); - - // Start multiple producers - for (int p = 0; p < producersCount; p++) { - final int producerId = p; - executor.submit(() -> { - try { - for (int i = 0; i < itemsPerProducer; i++) { - queue.put("producer-" + producerId + "-item-" + i); - } - } finally { - producerLatch.countDown(); - } - }); - } - - // Single consumer - List consumed = new ArrayList<>(); - CompletableFuture consumer = CompletableFuture.runAsync(() -> { - try { - for (int i = 0; i < totalItems; i++) { - String item = queue.take().get(5, TimeUnit.SECONDS); - consumed.add(item); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - }, executor); - - assertTrue("Producers should complete", producerLatch.await(10, TimeUnit.SECONDS)); - consumer.get(10, TimeUnit.SECONDS); - - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - - assertEquals("Consumer should receive all items", totalItems, consumed.size()); - } - - @Test - public void singleProducerMultipleConsumers() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - int consumersCount = 10; - int totalItems = 1000; - int itemsPerConsumer = totalItems / consumersCount; - - ExecutorService executor = Executors.newFixedThreadPool(consumersCount + 1); - CountDownLatch consumerLatch = new CountDownLatch(consumersCount); - - List allConsumed = new ArrayList<>(); - Object consumedLock = new Object(); - - // Start multiple consumers - for (int c = 0; c < consumersCount; c++) { - executor.submit(() -> { - try { - for (int i = 0; i < itemsPerConsumer; i++) { - Integer item = queue.take().get(5, TimeUnit.SECONDS); - synchronized (consumedLock) { - allConsumed.add(item); - } - } - } catch (Exception e) { - throw new RuntimeException(e); - } finally { - consumerLatch.countDown(); - } - }); - } - - // Single producer - CompletableFuture producer = CompletableFuture.runAsync(() -> { - for (int i = 0; i < totalItems; i++) { - queue.put(i); - } - }, executor); - - producer.get(5, TimeUnit.SECONDS); - assertTrue("Consumers should complete", consumerLatch.await(10, TimeUnit.SECONDS)); - - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - - assertEquals("All items should be consumed", totalItems, allConsumed.size()); - } - - @Test - public void nullValuesAreSupported() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - - queue.put(null); - queue.put("not-null"); - queue.put(null); - - assertNull(queue.take().get()); - assertEquals("not-null", queue.take().get()); - assertNull(queue.take().get()); - } - - @Test - public void takeCompletesAsynchronously() throws Exception { - IterableAsyncQueue queue = new IterableAsyncQueue<>(); - - CompletableFuture future = queue.take(); - AtomicInteger callbackInvoked = new AtomicInteger(0); - - // Attach callback - future.thenAccept(item -> { - assertEquals("async-item", item); - callbackInvoked.incrementAndGet(); - }); - - assertFalse("Future should not be completed yet", future.isDone()); - assertEquals(0, callbackInvoked.get()); - - // Put item should trigger callback - queue.put("async-item"); - - // Give callback time to execute - Thread.sleep(50); - - assertTrue("Future should be completed", future.isDone()); - assertEquals("Callback should have been invoked", 1, callbackInvoked.get()); - } -} From 753a7d5b2a12408d602f27407c2cccac01be63a8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:46:23 -0800 Subject: [PATCH 10/15] PR feedback. --- .../sdk/server/integrations/FileDataSourceBase.java | 9 +++------ .../sdk/server/integrations/FileDataSourceBuilder.java | 9 ++++++++- .../sdk/server/integrations/FileDataSourceImpl.java | 10 +++++++--- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java index 0d5f8eb9..29f05f66 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java @@ -81,16 +81,17 @@ protected FDv2SourceResult loadData() { } FullDataSet fullData = builder.build(); - ChangeSet changeSet = buildChangeSet(fullData, version); + ChangeSet changeSet = buildChangeSet(fullData); return FDv2SourceResult.changeSet(changeSet, false); } /** * Builds a ChangeSet from a FullDataSet. */ - private ChangeSet buildChangeSet(FullDataSet fullData, int version) { + private ChangeSet buildChangeSet(FullDataSet fullData) { return new ChangeSet<>( ChangeSetType.Full, + // File data is currently selector-less. Selector.EMPTY, fullData.getData(), null, // no environment ID for file data @@ -138,10 +139,6 @@ public DataLoader(List sources) { this.lastVersion = new AtomicInteger(0); } - public Iterable getSources() { - return sources; - } - /** * Loads data from all sources into the builder. * diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index a4920deb..a6cbd94f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -126,7 +126,14 @@ public FileDataSourceBuilder duplicateKeysHandling(FileData.DuplicateKeysHandlin @Override public DataSource build(ClientContext context) { LDLogger logger = context.getBaseLogger().subLogger("DataSource"); - return new FileDataSourceImpl(context.getDataSourceUpdateSink(), sources, autoUpdate, duplicateKeysHandling, logger); + return new FileDataSourceImpl( + context.getDataSourceUpdateSink(), + sources, + autoUpdate, + duplicateKeysHandling, + logger, + shouldPersist + ); } /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index b0b47ccc..718dd90b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -38,11 +38,12 @@ final class FileDataSourceImpl implements DataSource { List sources, boolean autoUpdate, FileData.DuplicateKeysHandling duplicateKeysHandling, - LDLogger logger + LDLogger logger, + boolean persist ) { this.dataSourceUpdates = dataSourceUpdates; this.logger = logger; - this.synchronizer = new FileSynchronizer(sources, autoUpdate, duplicateKeysHandling, logger, true); + this.synchronizer = new FileSynchronizer(sources, autoUpdate, duplicateKeysHandling, logger, persist); } @Override @@ -93,7 +94,10 @@ private void runUpdateLoop() { private void processResult(FDv2SourceResult result) { if (result.getResultType() == FDv2SourceResult.ResultType.CHANGE_SET) { // Convert ChangeSet to FullDataSet for legacy init() - FullDataSet fullData = new FullDataSet<>(result.getChangeSet().getData()); + FullDataSet fullData = new FullDataSet<>( + result.getChangeSet().getData(), + result.getChangeSet().shouldPersist() + ); dataSourceUpdates.init(fullData); dataSourceUpdates.updateStatus(State.VALID, null); inited.set(true); From dff4b59db59cff676d376bfa0efa285d42ab92f7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:02:43 -0800 Subject: [PATCH 11/15] File data source shouldPersist tests. --- .../integrations/FileDataSourceBuilder.java | 2 +- .../integrations/FileDataSourceTest.java | 55 ++++++++++++++++++- .../integrations/FileInitializerTest.java | 44 +++++++++++++++ .../integrations/FileSynchronizerTest.java | 44 +++++++++++++++ 4 files changed, 142 insertions(+), 3 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index a6cbd94f..51764913 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -34,7 +34,7 @@ public final class FileDataSourceBuilder implements ComponentConfigurer initData = + dataSourceUpdates.receivedInits.poll(5, java.util.concurrent.TimeUnit.SECONDS); + + assertThat(initData, notNullValue()); + // The FullDataSet should have shouldPersist=true by default for legacy compatibility + assertThat(initData.shouldPersist(), equalTo(true)); + } + } + + @Test + public void dataSourceCanBeConfiguredToPersist() throws Exception { + FileDataSourceBuilder factory = FileData.dataSource() + .filePaths(resourceFilePath("all-properties.json")) + .shouldPersist(true); + try (DataSource fp = makeDataSource(factory)) { + fp.start(); + + // Wait for and get the init data + com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet initData = + dataSourceUpdates.receivedInits.poll(5, java.util.concurrent.TimeUnit.SECONDS); + + assertThat(initData, notNullValue()); + assertThat(initData.shouldPersist(), equalTo(true)); + } + } + + @Test + public void dataSourceCanBeConfiguredToNotPersist() throws Exception { + FileDataSourceBuilder factory = FileData.dataSource() + .filePaths(resourceFilePath("all-properties.json")) + .shouldPersist(false); + try (DataSource fp = makeDataSource(factory)) { + fp.start(); + + // Wait for and get the init data + com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet initData = + dataSourceUpdates.receivedInits.poll(5, java.util.concurrent.TimeUnit.SECONDS); + + assertThat(initData, notNullValue()); + assertThat(initData.shouldPersist(), equalTo(false)); + } + } + public static class SimulatedMaliciousType { static volatile boolean wasInstantiated = false; - + public SimulatedMaliciousType(String value) { wasInstantiated = true; } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java index 05146c57..5e3559d9 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileInitializerTest.java @@ -100,6 +100,50 @@ public void initializerRespectsIgnoreDuplicateKeysHandling() throws Exception { } } + @Test + public void initializerDefaultsToNotPersisting() throws Exception { + + try (Initializer initializer = FileData.initializer() + .filePaths(resourceFilePath("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger))) { + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet().shouldPersist(), equalTo(false)); + } + } + + @Test + public void initializerCanBeConfiguredToPersist() throws Exception { + + try (Initializer initializer = FileData.initializer() + .filePaths(resourceFilePath("all-properties.json")) + .shouldPersist(true) + .build(TestDataSourceBuildInputs.create(testLogger))) { + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet().shouldPersist(), equalTo(true)); + } + } + + @Test + public void initializerCanBeConfiguredToNotPersist() throws Exception { + + try (Initializer initializer = FileData.initializer() + .filePaths(resourceFilePath("all-properties.json")) + .shouldPersist(false) + .build(TestDataSourceBuildInputs.create(testLogger))) { + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet().shouldPersist(), equalTo(false)); + } + } + @Test public void initializerFailsOnDuplicateKeysByDefault() throws Exception { diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java index 172b96f6..af600cf2 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/FileSynchronizerTest.java @@ -159,4 +159,48 @@ public void synchronizerAutoUpdateEmitsNewResultOnFileChange() throws Exception } } } + + @Test + public void synchronizerDefaultsToNotPersisting() throws Exception { + + try (Synchronizer synchronizer = FileData.synchronizer() + .filePaths(resourceFilePath("all-properties.json")) + .build(TestDataSourceBuildInputs.create(testLogger))) { + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet().shouldPersist(), equalTo(false)); + } + } + + @Test + public void synchronizerCanBeConfiguredToPersist() throws Exception { + + try (Synchronizer synchronizer = FileData.synchronizer() + .filePaths(resourceFilePath("all-properties.json")) + .shouldPersist(true) + .build(TestDataSourceBuildInputs.create(testLogger))) { + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet().shouldPersist(), equalTo(true)); + } + } + + @Test + public void synchronizerCanBeConfiguredToNotPersist() throws Exception { + + try (Synchronizer synchronizer = FileData.synchronizer() + .filePaths(resourceFilePath("all-properties.json")) + .shouldPersist(false) + .build(TestDataSourceBuildInputs.create(testLogger))) { + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertThat(result.getResultType(), equalTo(FDv2SourceResult.ResultType.CHANGE_SET)); + assertThat(result.getChangeSet().shouldPersist(), equalTo(false)); + } + } } From 3c9e35f3854ad0a0da3c34719c48a4537df4d75c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:14:16 -0800 Subject: [PATCH 12/15] Use atomic boolean for FileSynchronizer started. --- .../sdk/server/integrations/FileSynchronizer.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 e49721cc..7ff3df41 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 @@ -16,10 +16,10 @@ import java.nio.file.WatchService; import java.nio.file.Watchable; import java.util.HashSet; -import java.util.LinkedList; import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; @@ -36,7 +36,7 @@ final class FileSynchronizer extends FileDataSourceBase implements Synchronizer private final CompletableFuture shutdownFuture = new CompletableFuture<>(); private final IterableAsyncQueue resultQueue = new IterableAsyncQueue<>(); private final FileWatcher fileWatcher; // null if autoUpdate=false - private volatile boolean started = false; + private AtomicBoolean started = new AtomicBoolean(false); FileSynchronizer( List sources, @@ -63,8 +63,7 @@ final class FileSynchronizer extends FileDataSourceBase implements Synchronizer @Override public CompletableFuture next() { - if (!started) { - started = true; + if (!started.getAndSet(true)) { // Perform initial load resultQueue.put(loadData()); // Start file watching if enabled From f54009f9f7dc2872a10e6759a519b1a3f6f585d1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:22:25 -0800 Subject: [PATCH 13/15] PR feedback. --- .../sdk/server/integrations/FileData.java | 8 +++---- .../integrations/FileDataSourceBase.java | 24 +++++++------------ .../integrations/FileDataSourceBuilder.java | 14 ----------- .../server/integrations/DataLoaderTest.java | 18 +++++++------- 4 files changed, 22 insertions(+), 42 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index 54356b14..3a174f0f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -34,13 +34,13 @@ public enum DuplicateKeysHandling { * Data loading will fail if keys are duplicated across files. */ FAIL, - + /** * Keys that are duplicated across files will be ignored, and the first occurrence will be used. */ IGNORE } - + /** * Creates a {@link FileDataSourceBuilder} which you can use to configure the file data source. * This allows you to use local files (or classpath resources containing file data) as a source of @@ -137,7 +137,7 @@ public enum DuplicateKeysHandling { *

* If the data source encounters any error in any file-- malformed content, a missing file, or a * duplicate key-- it will not load flags from any of the files. - * + * * @return a data source configuration object * @since 4.12.0 */ @@ -270,7 +270,7 @@ public FileInitializerBuilder shouldPersist(boolean shouldPersist) { public Initializer build(DataSourceBuildInputs context) { return delegate.buildInitializer(context); } -} + } /** * Builder for creating an FDv2 {@link Synchronizer} that loads and watches file data. diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java index 29f05f66..86489c66 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java @@ -15,7 +15,6 @@ import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; @@ -64,9 +63,8 @@ protected FileDataSourceBase( */ protected FDv2SourceResult loadData() { DataBuilder builder = new DataBuilder(duplicateKeysHandling); - int version; try { - version = dataLoader.load(builder); + dataLoader.load(builder); } catch (FileDataException e) { String description = getErrorDescription(e); logger.error(description); @@ -80,20 +78,20 @@ protected FDv2SourceResult loadData() { return FDv2SourceResult.interrupted(errorInfo, false); } - FullDataSet fullData = builder.build(); - ChangeSet changeSet = buildChangeSet(fullData); + Iterable>> data = builder.build(); + ChangeSet changeSet = buildChangeSet(data); return FDv2SourceResult.changeSet(changeSet, false); } /** - * Builds a ChangeSet from a FullDataSet. + * Builds a ChangeSet from the data entries. */ - private ChangeSet buildChangeSet(FullDataSet fullData) { + private ChangeSet buildChangeSet(Iterable>> data) { return new ChangeSet<>( ChangeSetType.Full, // File data is currently selector-less. Selector.EMPTY, - fullData.getData(), + data, null, // no environment ID for file data persist ); @@ -190,20 +188,16 @@ public DataBuilder(FileData.DuplicateKeysHandling duplicateKeysHandling) { this.duplicateKeysHandling = duplicateKeysHandling; } - public FullDataSet build() { + public Iterable>> build() { ImmutableList.Builder>> allBuilder = ImmutableList.builder(); for (Map.Entry> e0 : allData.entrySet()) { allBuilder.add(new AbstractMap.SimpleEntry<>(e0.getKey(), new KeyedItems<>(e0.getValue().entrySet()))); } - return new FullDataSet<>(allBuilder.build()); + return allBuilder.build(); } public void add(DataKind kind, String key, ItemDescriptor item) throws FileDataException { - Map items = allData.get(kind); - if (items == null) { - items = new HashMap(); - allData.put(kind, items); - } + Map items = allData.computeIfAbsent(kind, k -> new HashMap<>()); if (items.containsKey(key)) { if (duplicateKeysHandling == FileData.DuplicateKeysHandling.IGNORE) { return; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 51764913..54b3669f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -164,20 +164,6 @@ Synchronizer buildSynchronizer(DataSourceBuildInputs context) { return new FileSynchronizer(sources, autoUpdate, duplicateKeysHandling, logger, shouldPersist); } - /** - * Returns whether auto-update is enabled. Package-private for use by FDv2 builders. - */ - boolean isAutoUpdate() { - return autoUpdate; - } - - /** - * Returns the duplicate keys handling mode. Package-private for use by FDv2 builders. - */ - FileData.DuplicateKeysHandling getDuplicateKeysHandling() { - return duplicateKeysHandling; - } - /** * Configures whether file data should be persisted to persistent stores. *

diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index ae068572..90580e3c 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -158,23 +158,23 @@ public void versionsAreIncrementedForEachLoad() throws Exception { resourceFilePath("segment-only.json"), resourceFilePath("value-only.json") ).sources); - + DataBuilder data1 = new DataBuilder(FileData.DuplicateKeysHandling.FAIL); ds.load(data1); - assertVersionsMatch(data1.build(), 1); - + assertVersionsMatch(new FullDataSet<>(data1.build()), 1); + DataBuilder data2 = new DataBuilder(FileData.DuplicateKeysHandling.FAIL); ds.load(data2); - assertVersionsMatch(data2.build(), 2); + assertVersionsMatch(new FullDataSet<>(data2.build()), 2); } - + private void assertDataHasItemsOfKind(DataKind kind) { - Map items = toDataMap(builder.build()).get(kind); + Map items = toDataMap(new FullDataSet<>(builder.build())).get(kind); if (items == null || items.size() == 0) { Assert.fail("expected at least one item in \"" + kind.getName() + "\", received: " + builder.build()); } } - + private void assertVersionsMatch(FullDataSet data, int expectedVersion) { for (Map.Entry> kv1: data.getData()) { DataKind kind = kv1.getKey(); @@ -187,9 +187,9 @@ private void assertVersionsMatch(FullDataSet data, int expectedV } } } - + private JsonTestValue getItemAsJson(DataBuilder builder, DataKind kind, String key) { - ItemDescriptor flag = toDataMap(builder.build()).get(kind).get(key); + ItemDescriptor flag = toDataMap(new FullDataSet<>(builder.build())).get(kind).get(key); return jsonOf(kind.serialize(flag)); } } From 8353fae0f96489180969b74ac6a38d71a10a44a1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:53:30 -0800 Subject: [PATCH 14/15] Fix doc comments. --- .../com/launchdarkly/sdk/server/integrations/FileData.java | 6 +++--- .../sdk/server/integrations/FileDataSourceBase.java | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index 3a174f0f..e9e715bf 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -257,8 +257,8 @@ public FileInitializerBuilder duplicateKeysHandling(DuplicateKeysHandling duplic * .shouldPersist(true); * * - * @param shouldPersist {@code true} if tile data should be persisted to persistent stores, false otherwise - * @return the same {@code TestData} instance + * @param shouldPersist {@code true} if file data should be persisted to persistent stores, false otherwise + * @return the same {@code FileInitializerBuilder} instance */ public FileInitializerBuilder shouldPersist(boolean shouldPersist) { delegate.shouldPersist(shouldPersist); @@ -302,7 +302,7 @@ public static final class FileSynchronizerBuilder implements DataSourceBuilder * * @param shouldPersist {@code true} if file data should be persisted to persistent stores, false otherwise - * @return the same {@code TestData} instance + * @return the same {@code FileSynchronizerBuilder} instance */ public FileSynchronizerBuilder shouldPersist(boolean shouldPersist) { delegate.shouldPersist(shouldPersist); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java index 86489c66..38b807cf 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBase.java @@ -58,7 +58,6 @@ protected FileDataSourceBase( /** * Loads data from all configured files and returns an FDv2SourceResult. * - * @param oneShot true if this is a one-shot load (Initializer), false for continuous (Synchronizer) * @return an FDv2SourceResult containing either a ChangeSet or an error status */ protected FDv2SourceResult loadData() { From 22aefdd3faa612dc7c3f2d23a85480152b56ab79 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:25:23 -0800 Subject: [PATCH 15/15] Use interable async queue from internal. --- lib/sdk/server/build.gradle | 4 ++-- .../sdk/server/DataSourceSynchronizerAdapter.java | 2 +- .../com/launchdarkly/sdk/server/PollingSynchronizerImpl.java | 2 +- .../launchdarkly/sdk/server/StreamingSynchronizerImpl.java | 2 +- .../sdk/server/integrations/FileSynchronizer.java | 2 +- .../java/com/launchdarkly/sdk/server/FDv2DataSourceTest.java | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index e6c6661a..b5c9ea79 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -70,8 +70,8 @@ ext.versions = [ "gson": "2.13.1", "guava": "32.0.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "2.2.1", - "launchdarklyJavaSdkInternal": "1.6.1", + "launchdarklyJavaSdkCommon": "2.3.0", + "launchdarklyJavaSdkInternal": "1.7.0", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "4.2.0", 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 36c773fb..c43732e7 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 @@ -1,6 +1,6 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.collections.IterableAsyncQueue; +import com.launchdarkly.sdk.internal.collections.IterableAsyncQueue; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.Synchronizer; 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 9fe08897..9169a018 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 @@ -1,7 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.logging.LDLogger; -import com.launchdarkly.sdk.collections.IterableAsyncQueue; +import com.launchdarkly.sdk.internal.collections.IterableAsyncQueue; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.SelectorSource; import com.launchdarkly.sdk.server.datasources.Synchronizer; 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 7f132e7b..e0d87734 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 @@ -12,7 +12,7 @@ import com.launchdarkly.eventsource.StreamHttpErrorException; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LogValues; -import com.launchdarkly.sdk.collections.IterableAsyncQueue; +import com.launchdarkly.sdk.internal.collections.IterableAsyncQueue; import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ProtocolHandler; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; 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 7ff3df41..a1a7d508 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 @@ -2,7 +2,7 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LogValues; -import com.launchdarkly.sdk.collections.IterableAsyncQueue; +import com.launchdarkly.sdk.internal.collections.IterableAsyncQueue; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; 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 16df9062..2e072282 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 @@ -3,7 +3,7 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.Logs; -import com.launchdarkly.sdk.collections.IterableAsyncQueue; +import com.launchdarkly.sdk.internal.collections.IterableAsyncQueue; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.Initializer;