Skip to content

Commit 6ca393f

Browse files
authored
Merge branch 'main' into ta/SDK-2225/drop-persistent-cache
2 parents 402f260 + a363777 commit 6ca393f

11 files changed

Lines changed: 590 additions & 34 deletions

File tree

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/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
}

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/subsystems/ClientContext.java

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints;
99
import com.launchdarkly.sdk.server.interfaces.WrapperInfo;
1010

11+
import java.util.UUID;
12+
1113
/**
1214
* Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components.
1315
* <p>
@@ -31,11 +33,18 @@ public class ClientContext {
3133
private final boolean offline;
3234
private final ServiceEndpoints serviceEndpoints;
3335
private final int threadPriority;
36+
private final String instanceId;
3437
private WrapperInfo wrapperInfo;
3538

3639
/**
37-
* Constructor that sets all properties. All should be non-null.
38-
*
40+
* Constructor that sets all properties including an explicit instance ID. All should be
41+
* non-null.
42+
*
43+
* <p>The instance ID is sent on every outbound request in the {@code X-LaunchDarkly-Instance-Id}
44+
* header. It must be generated once per LDClient and remain stable for the client's lifetime.
45+
* The eight-argument constructor auto-generates a v4 UUID for callers that do not need to
46+
* supply their own value.
47+
*
3948
* @param sdkKey the SDK key
4049
* @param applicationInfo application metadata properties from
4150
* {@link Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder)}
@@ -46,6 +55,7 @@ public class ClientContext {
4655
* {@link Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder)}
4756
* @param threadPriority worker thread priority from {@link Builder#threadPriority(int)}
4857
* @param wrapperInfo wrapper configuration from {@link Builder#wrapper(com.launchdarkly.sdk.server.integrations.WrapperInfoBuilder)}
58+
* @param instanceId per-LDClient identifier for the {@code X-LaunchDarkly-Instance-Id} header
4959
*/
5060
public ClientContext(
5161
String sdkKey,
@@ -55,7 +65,8 @@ public ClientContext(
5565
boolean offline,
5666
ServiceEndpoints serviceEndpoints,
5767
int threadPriority,
58-
WrapperInfo wrapperInfo
68+
WrapperInfo wrapperInfo,
69+
String instanceId
5970
) {
6071
this.sdkKey = sdkKey;
6172
this.applicationInfo = applicationInfo;
@@ -65,19 +76,51 @@ public ClientContext(
6576
this.serviceEndpoints = serviceEndpoints;
6677
this.threadPriority = threadPriority;
6778
this.wrapperInfo = wrapperInfo;
68-
79+
this.instanceId = instanceId;
80+
6981
this.baseLogger = logging == null ? LDLogger.none() :
7082
LDLogger.withAdapter(logging.getLogAdapter(), logging.getBaseLoggerName());
7183
}
72-
84+
85+
/**
86+
* Constructor that sets all properties. All should be non-null. Auto-generates a v4 UUID for
87+
* the instance ID; use the nine-argument constructor if you need to thread an existing value
88+
* through (for example, when copying a context for an in-flight LDClient).
89+
*
90+
* @param sdkKey the SDK key
91+
* @param applicationInfo application metadata properties from
92+
* {@link Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder)}
93+
* @param http HTTP configuration properties from {@link Builder#http(ComponentConfigurer)}
94+
* @param logging logging configuration properties from {@link Builder#logging(ComponentConfigurer)}
95+
* @param offline true if the SDK should be entirely offline
96+
* @param serviceEndpoints service endpoint URI properties from
97+
* {@link Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder)}
98+
* @param threadPriority worker thread priority from {@link Builder#threadPriority(int)}
99+
* @param wrapperInfo wrapper configuration from {@link Builder#wrapper(com.launchdarkly.sdk.server.integrations.WrapperInfoBuilder)}
100+
*/
101+
public ClientContext(
102+
String sdkKey,
103+
ApplicationInfo applicationInfo,
104+
HttpConfiguration http,
105+
LoggingConfiguration logging,
106+
boolean offline,
107+
ServiceEndpoints serviceEndpoints,
108+
int threadPriority,
109+
WrapperInfo wrapperInfo
110+
) {
111+
this(sdkKey, applicationInfo, http, logging, offline, serviceEndpoints, threadPriority,
112+
wrapperInfo, UUID.randomUUID().toString());
113+
}
114+
73115
/**
74116
* Copy constructor.
75-
*
117+
*
76118
* @param copyFrom the instance to copy from
77119
*/
78120
protected ClientContext(ClientContext copyFrom) {
79121
this(copyFrom.sdkKey, copyFrom.applicationInfo, copyFrom.http, copyFrom.logging,
80-
copyFrom.offline, copyFrom.serviceEndpoints, copyFrom.threadPriority, copyFrom.wrapperInfo);
122+
copyFrom.offline, copyFrom.serviceEndpoints, copyFrom.threadPriority, copyFrom.wrapperInfo,
123+
copyFrom.instanceId);
81124
}
82125

83126
/**
@@ -86,20 +129,32 @@ protected ClientContext(ClientContext copyFrom) {
86129
* @param sdkKey the SDK key
87130
*/
88131
public ClientContext(String sdkKey) {
132+
this(sdkKey, UUID.randomUUID().toString());
133+
}
134+
135+
// Private delegating constructor: generates a single instance id up front and threads it
136+
// both into the default HttpConfiguration (so the X-LaunchDarkly-Instance-Id default
137+
// header carries it) and into this.instanceId (so getInstanceId() returns the same value).
138+
// The earlier shape, where the public single-arg ctor called defaultHttp(sdkKey) and then
139+
// let the eight-arg ctor auto-generate a fresh UUID, produced two different ids -- one in
140+
// the headers and one returned by getInstanceId().
141+
private ClientContext(String sdkKey, String instanceId) {
89142
this(
90143
sdkKey,
91144
new ApplicationInfo(null, null),
92-
defaultHttp(sdkKey),
145+
defaultHttp(sdkKey, instanceId),
93146
defaultLogging(),
94147
false,
95148
Components.serviceEndpoints().createServiceEndpoints(),
96149
Thread.MIN_PRIORITY,
97-
null
150+
null,
151+
instanceId
98152
);
99153
}
100-
101-
private static HttpConfiguration defaultHttp(String sdkKey) {
102-
ClientContext minimalContext = new ClientContext(sdkKey, null, null, null, false, null, 0, null);
154+
155+
private static HttpConfiguration defaultHttp(String sdkKey, String instanceId) {
156+
ClientContext minimalContext = new ClientContext(sdkKey, null, null, null, false, null, 0, null,
157+
instanceId);
103158
return Components.httpConfiguration().build(minimalContext);
104159
}
105160

@@ -199,13 +254,24 @@ public ServiceEndpoints getServiceEndpoints() {
199254
/**
200255
* Returns the worker thread priority that is set by
201256
* {@link Builder#threadPriority(int)}.
202-
*
257+
*
203258
* @return the thread priority
204259
*/
205260
public int getThreadPriority() {
206261
return threadPriority;
207262
}
208263

264+
/**
265+
* Returns the per-LDClient instance identifier sent in the {@code X-LaunchDarkly-Instance-Id}
266+
* header on outbound requests. The value is generated once when the {@link ClientContext} is
267+
* constructed and is stable for the client's lifetime.
268+
*
269+
* @return the instance ID
270+
*/
271+
public String getInstanceId() {
272+
return instanceId;
273+
}
274+
209275
/**
210276
* Returns the wrapper information.
211277
*

0 commit comments

Comments
 (0)