Skip to content

Commit 3580edb

Browse files
authored
Merge branch 'main' into devin/1778880952-use-shared-contract-tests-action
2 parents fba4f0b + 5f605d4 commit 3580edb

23 files changed

Lines changed: 895 additions & 51 deletions

File tree

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
"lib/shared/common": "2.4.0",
55
"lib/shared/internal": "1.9.0",
66
"lib/shared/test-helpers": "2.1.0",
7-
"lib/sdk/server": "7.13.4"
7+
"lib/sdk/server": "7.14.0"
88
}

lib/sdk/server/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
44

5+
## [7.14.0](https://github.com/launchdarkly/java-core/compare/launchdarkly-java-server-sdk-7.13.4...launchdarkly-java-server-sdk-7.14.0) (2026-05-28)
6+
7+
8+
### Features
9+
10+
* add X-LaunchDarkly-Instance-Id header (SDK-2356) ([#162](https://github.com/launchdarkly/java-core/issues/162)) ([a363777](https://github.com/launchdarkly/java-core/commit/a363777b45b550ec89cc7f2eca67f9b0681e1590))
11+
* Drop persistent-store cache after FDv2 in-memory store init ([#167](https://github.com/launchdarkly/java-core/issues/167)) ([90f14f1](https://github.com/launchdarkly/java-core/commit/90f14f1f4f5bdb5d6bdcf4ed290f451c5ffda183))
12+
13+
14+
### Bug Fixes
15+
16+
* honor FDv1 fallback directive during initializer phase ([#158](https://github.com/launchdarkly/java-core/issues/158)) ([b0a3957](https://github.com/launchdarkly/java-core/commit/b0a3957afa481c9938684f22c521965e616f2785))
17+
518
## [7.13.4](https://github.com/launchdarkly/java-core/compare/launchdarkly-java-server-sdk-7.13.3...launchdarkly-java-server-sdk-7.13.4) (2026-04-24)
619

720

lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ public class TestService {
4242
"strongly-typed",
4343
"tags",
4444
"server-side-polling",
45-
"fdv1-fallback"
45+
"fdv1-fallback",
46+
"instance-id"
4647
};
4748

4849
static final Gson gson = new GsonBuilder().serializeNulls().create();

lib/sdk/server/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#x-release-please-start-version
2-
version=7.13.4
2+
version=7.14.0
33
#x-release-please-end
44

55
# See https://github.com/gradle/gradle/issues/11308 regarding the following property

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.launchdarkly.sdk.server.subsystems.HttpConfiguration;
99
import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration;
1010

11+
import java.util.UUID;
1112
import java.util.concurrent.Executors;
1213
import java.util.concurrent.ScheduledExecutorService;
1314

@@ -37,7 +38,7 @@ private ClientContextImpl(
3738
) {
3839
super(baseContext.getSdkKey(), baseContext.getApplicationInfo(), baseContext.getHttp(),
3940
baseContext.getLogging(), baseContext.isOffline(), baseContext.getServiceEndpoints(),
40-
baseContext.getThreadPriority(), baseContext.getWrapperInfo());
41+
baseContext.getThreadPriority(), baseContext.getWrapperInfo(), baseContext.getInstanceId());
4142
this.sharedExecutor = sharedExecutor;
4243
this.diagnosticStore = diagnosticStore;
4344
this.dataSourceUpdateSink = null;
@@ -79,22 +80,29 @@ static ClientContextImpl fromConfig(
7980
LDConfig config,
8081
ScheduledExecutorService sharedExecutor
8182
) {
83+
// Generate the instance ID once and thread it through every ClientContext we build for this
84+
// LDClient. Subsystems built from any of these contexts will all observe the same value.
85+
String instanceId = UUID.randomUUID().toString();
86+
8287
ClientContext minimalContext = new ClientContext(sdkKey, config.applicationInfo, null,
83-
null, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo);
88+
null, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo,
89+
instanceId);
8490
LoggingConfiguration loggingConfig = config.logging.build(minimalContext);
85-
91+
8692
ClientContext contextWithLogging = new ClientContext(sdkKey, config.applicationInfo, null,
87-
loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo);
93+
loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority,
94+
config.wrapperInfo, instanceId);
8895
HttpConfiguration httpConfig = config.http.build(contextWithLogging);
89-
96+
9097
if (httpConfig.getProxy() != null) {
9198
contextWithLogging.getBaseLogger().info("Using proxy: {} {} authentication.",
9299
httpConfig.getProxy(),
93100
httpConfig.getProxyAuthentication() == null ? "without" : "with");
94101
}
95-
96-
ClientContext contextWithHttpAndLogging = new ClientContext(sdkKey, config.applicationInfo, httpConfig,
97-
loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority, config.wrapperInfo);
102+
103+
ClientContext contextWithHttpAndLogging = new ClientContext(sdkKey, config.applicationInfo,
104+
httpConfig, loggingConfig, config.offline, config.serviceEndpoints, config.threadPriority,
105+
config.wrapperInfo, instanceId);
98106

99107
// Create a diagnostic store only if diagnostics are enabled. Diagnostics are enabled as long as 1. the
100108
// opt-out property was not set in the config, and 2. we are using the standard event processor.

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,15 @@ private Result transformResult(com.launchdarkly.sdk.server.subsystems.EventSende
304304
}
305305

306306
static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder {
307+
/**
308+
* HTTP header used to identify this SDK instance for the purpose of estimating
309+
* server-connection-minutes when polling. It contains a v4 UUID that is generated once per SDK
310+
* instance and remains constant for the lifetime of the client.
311+
*
312+
* <p>See: sdk-specs / SCMP-server-connection-minutes-polling.
313+
*/
314+
static final String INSTANCE_ID_HEADER = "X-LaunchDarkly-Instance-Id";
315+
307316
@Override
308317
public HttpConfiguration build(ClientContext clientContext) {
309318
LDLogger logger = clientContext.getBaseLogger();
@@ -340,6 +349,16 @@ else if (wrapperName != null) {
340349
headers.put("X-LaunchDarkly-Wrapper", wrapperId);
341350
}
342351

352+
// The instance ID originates on ClientContext (generated once when LDClient is constructed)
353+
// so every subsystem built from the same context observes a consistent value for the
354+
// lifetime of the SDK instance.
355+
String instanceId = clientContext.getInstanceId();
356+
if (instanceId != null && !instanceId.isEmpty()) {
357+
headers.put(INSTANCE_ID_HEADER, instanceId);
358+
}
359+
360+
// For consistency with other SDKs, custom headers are allowed to overwrite headers such as
361+
// User-Agent and Authorization.
343362
if (!customHeaders.isEmpty()) {
344363
headers.putAll(customHeaders);
345364
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.launchdarkly.sdk.server;
2+
3+
/**
4+
* Optional interface for data stores that can disable their internal cache.
5+
* <p>
6+
* This is currently for internal implementations only.
7+
*/
8+
interface DisableableCache {
9+
/**
10+
* Disables the internal cache. After this call, the cache is no longer
11+
* consulted on reads and no longer populated by writes.
12+
* <p>
13+
* Implementations should release the cache contents so the memory can be
14+
* reclaimed. The call must be idempotent: subsequent invocations should be
15+
* safe and have no further effect.
16+
*/
17+
void disableCache();
18+
}

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import java.util.Collections;
1616
import java.util.Date;
1717
import java.util.List;
18+
import java.util.Set;
19+
import java.util.WeakHashMap;
1820
import java.util.concurrent.*;
1921
import java.util.concurrent.atomic.AtomicBoolean;
2022
import java.util.stream.Collectors;
@@ -591,21 +593,102 @@ private void maybeReportUnexpectedExhaustion(String message) {
591593

592594
/**
593595
* Helper class to manage the lifecycle of conditions with automatic cleanup.
596+
*
597+
* <p>Before the aggregate completes, {@link #getFuture()} returns a
598+
* <em>fresh</em> {@link CompletableFuture} per call. This matters because
599+
* the run loop calls {@code CompletableFuture.anyOf(getFuture(),
600+
* synchronizerNext)} on every iteration: if {@code getFuture()} returned
601+
* the shared underlying aggregate while it was still pending, each
602+
* {@code anyOf} call would permanently attach an {@code OrRelay}
603+
* {@code Completion} to its {@code stack}. On a healthy primary
604+
* synchronizer that streams ChangeSets without ever arming the fallback
605+
* timer, the aggregate never completes, so those Completion nodes would
606+
* accumulate monotonically for the synchronizer's full tenure -- a real
607+
* memory leak proportional to event rate.
608+
*
609+
* <p>After the aggregate completes, {@link #getFuture()} returns the
610+
* aggregate directly: any continuation registered on an already-completed
611+
* CompletableFuture fires synchronously at registration time and is
612+
* removed from the stack immediately by {@code cleanStack}, so the same
613+
* accumulation cannot happen.
614+
*
615+
* <p>Fresh pre-completion futures are tracked in a {@link WeakHashMap}-backed
616+
* set, so a fresh future whose only strong references were in the caller's
617+
* loop iteration becomes garbage-collectable -- and automatically removed
618+
* from {@code pending} -- once that iteration ends.
619+
*
620+
* <p>Package-private (rather than private) so that direct unit tests can
621+
* exercise the API surface and assert per-call distinctness.
594622
*/
595-
private static class Conditions implements AutoCloseable {
623+
static class Conditions implements AutoCloseable {
596624
private final List<Condition> conditions;
597-
private final CompletableFuture<Object> conditionsFuture;
625+
private final CompletableFuture<Object> aggregate;
626+
private final Object lock = new Object();
627+
628+
/**
629+
* Tracks futures previously returned by {@link #getFuture()} that have
630+
* not yet been completed. Held weakly via {@link WeakHashMap} so that
631+
* fresh futures abandoned by the caller (the typical end-of-iteration
632+
* case) become GC-collectable. Set to {@code null} once the aggregate
633+
* has fired and the entries have been drained. Mutated only under
634+
* {@code lock}.
635+
*/
636+
private Set<CompletableFuture<Object>> pending =
637+
Collections.newSetFromMap(new WeakHashMap<>());
598638

599639
public Conditions(List<Condition> conditions) {
600640
this.conditions = conditions;
601-
this.conditionsFuture = conditions.isEmpty()
641+
this.aggregate = conditions.isEmpty()
602642
? new CompletableFuture<>() // Never completes if no conditions
603643
: CompletableFuture.anyOf(
604-
conditions.stream().map(Condition::execute).toArray(CompletableFuture[]::new));
644+
conditions.stream().map(Condition::execute).toArray(CompletableFuture[]::new));
645+
646+
// Single permanent listener. This is the only Completion node ever
647+
// attached to aggregate.stack while the aggregate is still pending
648+
// -- subsequent pre-completion getFuture() calls do not touch the
649+
// aggregate at all.
650+
this.aggregate.whenComplete((result, throwable) -> {
651+
List<CompletableFuture<Object>> snapshot;
652+
synchronized (lock) {
653+
if (pending == null) {
654+
return;
655+
}
656+
// Copy under the lock: the ArrayList holds strong
657+
// references so entries that survived GC to this point
658+
// stay alive until we complete them below.
659+
snapshot = new ArrayList<>(pending);
660+
pending = null;
661+
}
662+
for (CompletableFuture<Object> cf : snapshot) {
663+
if (throwable != null) {
664+
cf.completeExceptionally(throwable);
665+
} else {
666+
cf.complete(result);
667+
}
668+
}
669+
});
605670
}
606671

672+
/**
673+
* Returns a future that will complete when the underlying aggregate
674+
* condition fires. Pre-completion, this is a fresh future per call;
675+
* post-completion, this is the aggregate itself (already done).
676+
*/
607677
public CompletableFuture<Object> getFuture() {
608-
return conditionsFuture;
678+
if (aggregate.isDone()) {
679+
return aggregate;
680+
}
681+
682+
CompletableFuture<Object> fresh = new CompletableFuture<>();
683+
synchronized (lock) {
684+
if (pending == null) {
685+
// Raced with aggregate completion between isDone() and
686+
// the lock acquisition; aggregate is now done.
687+
return aggregate;
688+
}
689+
pending.add(fresh);
690+
}
691+
return fresh;
609692
}
610693

611694
public void inform(FDv2SourceResult result) {
@@ -615,6 +698,11 @@ public void inform(FDv2SourceResult result) {
615698
@Override
616699
public void close() {
617700
conditions.forEach(Condition::close);
701+
synchronized (lock) {
702+
if (pending != null) {
703+
pending.clear();
704+
}
705+
}
618706
}
619707
}
620708
}

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
* <p>
4444
* This class is only constructed by {@link PersistentDataStoreBuilder}.
4545
*/
46-
final class PersistentDataStoreWrapper implements DataStore, SettableCache {
46+
final class PersistentDataStoreWrapper implements DataStore, SettableCache, DisableableCache {
4747
private final PersistentDataStore core;
4848
private final LoadingCache<CacheKey, Optional<ItemDescriptor>> itemCache;
4949
private final LoadingCache<DataKind, KeyedItems<ItemDescriptor>> allCache;
@@ -54,9 +54,15 @@ final class PersistentDataStoreWrapper implements DataStore, SettableCache {
5454
private final AtomicBoolean inited = new AtomicBoolean(false);
5555
private final ListeningExecutorService cacheExecutor;
5656
private final LDLogger logger;
57-
57+
5858
private final Object externalStoreLock = new Object();
5959
private volatile CacheExporter externalCache;
60+
61+
// Once true, the cache is bypassed on reads and writes; entries already in
62+
// the cache have been invalidated by disableCache(). The cache instances
63+
// themselves remain alive until GC reclaims them; the LoadingCache loaders
64+
// are short-circuited because every touch site checks this flag first.
65+
private volatile boolean cacheDisabled;
6066

6167
PersistentDataStoreWrapper(
6268
final PersistentDataStore core,
@@ -151,14 +157,26 @@ public void close() throws IOException {
151157
core.close();
152158
}
153159

160+
@Override
161+
public void disableCache() {
162+
if (cacheDisabled) return;
163+
// Volatile write publishes the bypass flag before clearing cache contents.
164+
// Future readers observe cacheDisabled == true and skip the cache call
165+
// sites.
166+
cacheDisabled = true;
167+
if (itemCache != null) itemCache.invalidateAll();
168+
if (allCache != null) allCache.invalidateAll();
169+
if (initCache != null) initCache.invalidateAll();
170+
}
171+
154172
@Override
155173
public boolean isInitialized() {
156174
if (inited.get()) {
157175
return true;
158176
}
159177
boolean result;
160178
try {
161-
if (initCache != null) {
179+
if (initCache != null && !cacheDisabled) {
162180
result = initCache.get("");
163181
} else {
164182
result = core.isInitialized();
@@ -187,7 +205,7 @@ public void init(FullDataSet<ItemDescriptor> allData) {
187205
allBuilder.add(new AbstractMap.SimpleEntry<>(kind, items));
188206
}
189207
RuntimeException failure = initCore(new FullDataSet<>(allBuilder.build(), allData.shouldPersist()));
190-
if (itemCache != null && allCache != null) {
208+
if (itemCache != null && allCache != null && !cacheDisabled) {
191209
itemCache.invalidateAll();
192210
allCache.invalidateAll();
193211
if (failure != null && !cacheIndefinitely) {
@@ -228,7 +246,7 @@ private RuntimeException initCore(FullDataSet<SerializedItemDescriptor> allData)
228246
@Override
229247
public ItemDescriptor get(DataKind kind, String key) {
230248
try {
231-
ItemDescriptor ret = itemCache != null ? itemCache.get(CacheKey.forItem(kind, key)).orNull() :
249+
ItemDescriptor ret = (itemCache != null && !cacheDisabled) ? itemCache.get(CacheKey.forItem(kind, key)).orNull() :
232250
getAndDeserializeItem(kind, key);
233251
processError(null);
234252
return ret;
@@ -242,7 +260,7 @@ public ItemDescriptor get(DataKind kind, String key) {
242260
public KeyedItems<ItemDescriptor> getAll(DataKind kind) {
243261
try {
244262
KeyedItems<ItemDescriptor> ret;
245-
ret = allCache != null ? allCache.get(kind) : getAllAndDeserialize(kind);
263+
ret = (allCache != null && !cacheDisabled) ? allCache.get(kind) : getAllAndDeserialize(kind);
246264
processError(null);
247265
return ret;
248266
} catch (Exception e) {
@@ -281,7 +299,7 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) {
281299
}
282300
failure = e;
283301
}
284-
if (itemCache != null) {
302+
if (itemCache != null && !cacheDisabled) {
285303
CacheKey cacheKey = CacheKey.forItem(kind, key);
286304
if (failure == null) {
287305
if (updated) {
@@ -297,7 +315,7 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) {
297315
}
298316
}
299317
}
300-
if (allCache != null) {
318+
if (allCache != null && !cacheDisabled) {
301319
// If the cache has a finite TTL, then we should remove the "all items" cache entry to force
302320
// a reread the next time All is called. However, if it's an infinite TTL, we need to just
303321
// update the item within the existing "all items" entry (since we want things to still work
@@ -340,7 +358,7 @@ public void setCacheExporter(CacheExporter externalDataSource) {
340358

341359
@Override
342360
public CacheStats getCacheStats() {
343-
if (itemCache == null || allCache == null) {
361+
if (itemCache == null || allCache == null || cacheDisabled) {
344362
return null;
345363
}
346364
com.google.common.cache.CacheStats itemStats = itemCache.stats();
@@ -443,8 +461,9 @@ private boolean pollAvailabilityAfterOutage() {
443461
}
444462

445463
// Fall back to cache-based recovery if external store is not available/initialized
446-
// and we're in infinite cache mode
447-
if (cacheIndefinitely && allCache != null) {
464+
// and we're in infinite cache mode. Under FDv2 this branch is dead once
465+
// disableCache has run: the externalCache path above supersedes it.
466+
if (cacheIndefinitely && allCache != null && !cacheDisabled) {
448467
// If we're in infinite cache mode, then we can assume the cache has a full set of current
449468
// flag data (since presumably the data source has still been running) and we can just
450469
// write the contents of the cache to the underlying data store.

0 commit comments

Comments
 (0)