Skip to content

Commit 17dfa47

Browse files
authored
fix: java shutdown sequence fixing (#167)
1 parent c013feb commit 17dfa47

22 files changed

+839
-69
lines changed

openfeature-provider/java/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@
177177
<artifactId>grpc-services</artifactId>
178178
<version>${grpc.version}</version>
179179
</dependency>
180+
<dependency>
181+
<groupId>io.grpc</groupId>
182+
<artifactId>grpc-testing</artifactId>
183+
<version>${grpc.version}</version>
184+
<scope>test</scope>
185+
</dependency>
180186
<dependency>
181187
<groupId>net.bytebuddy</groupId>
182188
<artifactId>byte-buddy</artifactId>

openfeature-provider/java/src/main/java/com/spotify/confidence/ChannelFactory.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
* <li>Production customization: custom TLS settings, proxies, connection pooling
2222
* <li>Debugging: add custom logging or tracing interceptors
2323
* </ul>
24+
*
25+
* <p><strong>Channel Lifecycle:</strong> Channels created by this factory must be shut down by the
26+
* caller when they are no longer needed. The factory does not manage channel lifecycles.
2427
*/
25-
@FunctionalInterface
2628
public interface ChannelFactory {
2729
/**
2830
* Creates a gRPC channel with the given target and interceptors.

openfeature-provider/java/src/main/java/com/spotify/confidence/DefaultChannelFactory.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
* </ul>
2222
*/
2323
public class DefaultChannelFactory implements ChannelFactory {
24-
2524
@Override
2625
public ManagedChannel create(String target, List<ClientInterceptor> defaultInterceptors) {
2726
final boolean useGrpcPlaintext =
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.spotify.confidence;
2+
3+
import java.io.IOException;
4+
import java.net.HttpURLConnection;
5+
import java.net.URL;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
9+
/**
10+
* Default implementation of HttpClientFactory that creates standard HTTP connections.
11+
*
12+
* <p>This factory:
13+
*
14+
* <ul>
15+
* <li>Creates HttpURLConnection instances for the given URLs
16+
* <li>Uses default timeouts and settings from the JVM
17+
* <li>Can be extended or replaced for testing or custom behavior
18+
* </ul>
19+
*/
20+
public class DefaultHttpClientFactory implements HttpClientFactory {
21+
private static final Logger logger = LoggerFactory.getLogger(DefaultHttpClientFactory.class);
22+
23+
@Override
24+
public HttpURLConnection create(String url) throws IOException {
25+
return (HttpURLConnection) new URL(url).openConnection();
26+
}
27+
28+
@Override
29+
public void shutdown() {
30+
// HTTP connections are stateless and don't require cleanup
31+
logger.debug("DefaultHttpClientFactory shutdown called (no-op for stateless HTTP)");
32+
}
33+
}

openfeature-provider/java/src/main/java/com/spotify/confidence/FlagsAdminStateFetcher.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import java.io.IOException;
44
import java.io.InputStream;
55
import java.net.HttpURLConnection;
6-
import java.net.URL;
76
import java.nio.charset.StandardCharsets;
87
import java.security.MessageDigest;
98
import java.security.NoSuchAlgorithmException;
@@ -26,15 +25,17 @@ class FlagsAdminStateFetcher implements AccountStateProvider {
2625
"https://confidence-resolver-state-cdn.spotifycdn.com/";
2726

2827
private final String clientSecret;
28+
private final HttpClientFactory httpClientFactory;
2929
// ETag for conditional GETs of resolver state
3030
private final AtomicReference<String> etagHolder = new AtomicReference<>();
3131
private final AtomicReference<byte[]> rawResolverStateHolder =
3232
new AtomicReference<>(
3333
com.spotify.confidence.flags.admin.v1.ResolverState.newBuilder().build().toByteArray());
3434
private String accountId = "";
3535

36-
public FlagsAdminStateFetcher(String clientSecret) {
36+
public FlagsAdminStateFetcher(String clientSecret, HttpClientFactory httpClientFactory) {
3737
this.clientSecret = clientSecret;
38+
this.httpClientFactory = httpClientFactory;
3839
}
3940

4041
public AtomicReference<byte[]> rawStateHolder() {
@@ -64,7 +65,7 @@ private void fetchAndUpdateStateIfChanged() {
6465
// Build CDN URL using SHA256 hash of client secret
6566
final var cdnUrl = CDN_BASE_URL + sha256Hex(clientSecret);
6667
try {
67-
final HttpURLConnection conn = (HttpURLConnection) new URL(cdnUrl).openConnection();
68+
final HttpURLConnection conn = httpClientFactory.create(cdnUrl);
6869
final String previousEtag = etagHolder.get();
6970
if (previousEtag != null) {
7071
conn.setRequestProperty("if-none-match", previousEtag);

openfeature-provider/java/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java

Lines changed: 120 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,13 @@
55
import com.google.common.annotations.VisibleForTesting;
66
import com.spotify.confidence.flags.resolver.v1.InternalFlagLoggerServiceGrpc;
77
import com.spotify.confidence.flags.resolver.v1.WriteFlagLogsRequest;
8-
import io.grpc.CallOptions;
9-
import io.grpc.Channel;
10-
import io.grpc.ClientCall;
11-
import io.grpc.ClientInterceptor;
12-
import io.grpc.ForwardingClientCall;
13-
import io.grpc.Metadata;
14-
import io.grpc.MethodDescriptor;
8+
import io.grpc.*;
9+
import java.time.Duration;
1510
import java.util.ArrayList;
1611
import java.util.List;
1712
import java.util.concurrent.ExecutorService;
1813
import java.util.concurrent.Executors;
14+
import java.util.concurrent.TimeUnit;
1915
import org.slf4j.Logger;
2016
import org.slf4j.LoggerFactory;
2117

@@ -28,54 +24,39 @@ public class GrpcWasmFlagLogger implements WasmFlagLogger {
2824
private static final Logger logger = LoggerFactory.getLogger(GrpcWasmFlagLogger.class);
2925
// Max number of flag_assigned entries per chunk to avoid exceeding gRPC max message size
3026
private static final int MAX_FLAG_ASSIGNED_PER_CHUNK = 1000;
27+
private static final Duration DEFAULT_SHUTDOWN_TIMEOUT = Duration.ofSeconds(10);
3128
private final InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub stub;
32-
private final String clientSecret;
3329
private final ExecutorService executorService;
3430
private final FlagLogWriter writer;
31+
private final Duration shutdownTimeout;
32+
private ManagedChannel channel;
3533

3634
@VisibleForTesting
3735
public GrpcWasmFlagLogger(String clientSecret, FlagLogWriter writer) {
38-
this.stub = createStub(new DefaultChannelFactory());
39-
this.clientSecret = clientSecret;
36+
this.stub = createAuthStub(new DefaultChannelFactory(), clientSecret);
4037
this.executorService = Executors.newCachedThreadPool();
4138
this.writer = writer;
39+
this.shutdownTimeout = DEFAULT_SHUTDOWN_TIMEOUT;
40+
}
41+
42+
@VisibleForTesting
43+
public GrpcWasmFlagLogger(String clientSecret, FlagLogWriter writer, Duration shutdownTimeout) {
44+
this.stub = createAuthStub(new DefaultChannelFactory(), clientSecret);
45+
this.executorService = Executors.newCachedThreadPool();
46+
this.writer = writer;
47+
this.shutdownTimeout = shutdownTimeout;
4248
}
4349

4450
public GrpcWasmFlagLogger(String clientSecret, ChannelFactory channelFactory) {
45-
this.stub = createStub(channelFactory);
46-
this.clientSecret = clientSecret;
51+
this.stub = createAuthStub(channelFactory, clientSecret);
4752
this.executorService = Executors.newCachedThreadPool();
53+
this.shutdownTimeout = DEFAULT_SHUTDOWN_TIMEOUT;
4854
this.writer =
4955
request ->
5056
executorService.submit(
5157
() -> {
5258
try {
53-
// Create a stub with authorization header interceptor
54-
InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub
55-
stubWithAuth =
56-
stub.withInterceptors(
57-
new ClientInterceptor() {
58-
@Override
59-
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
60-
MethodDescriptor<ReqT, RespT> method,
61-
CallOptions callOptions,
62-
Channel next) {
63-
return new ForwardingClientCall.SimpleForwardingClientCall<
64-
ReqT, RespT>(next.newCall(method, callOptions)) {
65-
@Override
66-
public void start(
67-
Listener<RespT> responseListener, Metadata headers) {
68-
Metadata.Key<String> authKey =
69-
Metadata.Key.of(
70-
"authorization", Metadata.ASCII_STRING_MARSHALLER);
71-
headers.put(authKey, "ClientSecret " + clientSecret);
72-
super.start(responseListener, headers);
73-
}
74-
};
75-
}
76-
});
77-
78-
stubWithAuth.clientWriteFlagLogs(request);
59+
stub.clientWriteFlagLogs(request);
7960
logger.debug(
8061
"Successfully sent flag log with {} entries",
8162
request.getFlagAssignedCount());
@@ -85,10 +66,10 @@ public void start(
8566
});
8667
}
8768

88-
private static InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub createStub(
89-
ChannelFactory channelFactory) {
90-
final var channel = createConfidenceChannel(channelFactory);
91-
return InternalFlagLoggerServiceGrpc.newBlockingStub(channel);
69+
private InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub createAuthStub(
70+
ChannelFactory channelFactory, String clientSecret) {
71+
this.channel = createConfidenceChannel(channelFactory);
72+
return addAuthInterceptor(InternalFlagLoggerServiceGrpc.newBlockingStub(channel), clientSecret);
9273
}
9374

9475
@Override
@@ -150,12 +131,107 @@ private void sendAsync(WriteFlagLogsRequest request) {
150131
writer.write(request);
151132
}
152133

134+
@Override
135+
public void writeSync(WriteFlagLogsRequest request) {
136+
if (request.getClientResolveInfoList().isEmpty()
137+
&& request.getFlagAssignedList().isEmpty()
138+
&& request.getFlagResolveInfoList().isEmpty()) {
139+
logger.debug("Skipping empty flag log request");
140+
return;
141+
}
142+
143+
final int flagAssignedCount = request.getFlagAssignedCount();
144+
145+
// If flag_assigned list is small enough, send everything as-is
146+
if (flagAssignedCount <= MAX_FLAG_ASSIGNED_PER_CHUNK) {
147+
sendSync(request);
148+
return;
149+
}
150+
151+
// Split flag_assigned into chunks and send each chunk synchronously
152+
logger.debug(
153+
"Synchronously splitting {} flag_assigned entries into chunks of {}",
154+
flagAssignedCount,
155+
MAX_FLAG_ASSIGNED_PER_CHUNK);
156+
157+
final List<WriteFlagLogsRequest> chunks = createFlagAssignedChunks(request);
158+
for (WriteFlagLogsRequest chunk : chunks) {
159+
sendSync(chunk);
160+
}
161+
}
162+
163+
private void sendSync(WriteFlagLogsRequest request) {
164+
try {
165+
stub.clientWriteFlagLogs(request);
166+
logger.debug("Synchronously sent flag log with {} entries", request.getFlagAssignedCount());
167+
} catch (Exception e) {
168+
logger.error("Failed to write flag logs synchronously", e);
169+
}
170+
}
171+
153172
/**
154-
* Shutdown the executor service. This will allow any pending async writes to complete. Call this
155-
* when the application is shutting down.
173+
* Shutdown the executor service and wait for pending async writes to complete. This method will
174+
* block for up to the configured shutdown timeout (default 10 seconds) waiting for pending log
175+
* writes to complete. Call this when the application is shutting down.
156176
*/
157177
@Override
158178
public void shutdown() {
159179
executorService.shutdown();
180+
try {
181+
if (!executorService.awaitTermination(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
182+
logger.warn(
183+
"Flag logger executor did not terminate within {} seconds, some logs may be lost",
184+
shutdownTimeout.getSeconds());
185+
executorService.shutdownNow();
186+
} else {
187+
logger.debug("Flag logger executor terminated gracefully");
188+
}
189+
} catch (InterruptedException e) {
190+
logger.warn("Interrupted while waiting for flag logger shutdown", e);
191+
executorService.shutdownNow();
192+
Thread.currentThread().interrupt();
193+
}
194+
195+
if (channel != null) {
196+
channel.shutdown();
197+
try {
198+
if (!channel.awaitTermination(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS)) {
199+
logger.warn(
200+
"Channel did not terminate within {} seconds, forcing shutdown",
201+
shutdownTimeout.getSeconds());
202+
channel.shutdownNow();
203+
} else {
204+
logger.debug("Channel terminated gracefully");
205+
}
206+
} catch (InterruptedException e) {
207+
logger.warn("Interrupted while waiting for channel shutdown", e);
208+
channel.shutdownNow();
209+
Thread.currentThread().interrupt();
210+
}
211+
}
212+
}
213+
214+
private static InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub
215+
addAuthInterceptor(
216+
InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub stub,
217+
String clientSecret) {
218+
// Create a stub with authorization header interceptor
219+
return stub.withInterceptors(
220+
new ClientInterceptor() {
221+
@Override
222+
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
223+
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
224+
return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
225+
next.newCall(method, callOptions)) {
226+
@Override
227+
public void start(Listener<RespT> responseListener, Metadata headers) {
228+
Metadata.Key<String> authKey =
229+
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
230+
headers.put(authKey, "ClientSecret " + clientSecret);
231+
super.start(responseListener, headers);
232+
}
233+
};
234+
}
235+
});
160236
}
161237
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.spotify.confidence;
2+
3+
import java.io.IOException;
4+
import java.net.HttpURLConnection;
5+
6+
/**
7+
* HttpClientFactory is an advanced/testing hook allowing callers to customize how HTTP connections
8+
* are created. The provider will pass the URL that needs to be fetched.
9+
*
10+
* <p>Implementations may modify request properties, change URLs, or replace the connection creation
11+
* mechanism entirely. This is particularly useful for:
12+
*
13+
* <ul>
14+
* <li>Unit testing: inject mock HTTP responses
15+
* <li>Integration testing: point to local mock HTTP servers
16+
* <li>Production customization: custom timeouts, proxies, headers
17+
* <li>Debugging: add custom logging or request tracking
18+
* </ul>
19+
*
20+
* <p><strong>Lifecycle:</strong> The factory is responsible for managing any resources it creates.
21+
* When {@link #shutdown()} is called, it should clean up any resources that were allocated.
22+
*/
23+
public interface HttpClientFactory {
24+
/**
25+
* Creates an HTTP connection for the given URL.
26+
*
27+
* @param url the URL to connect to (e.g.,
28+
* "https://confidence-resolver-state-cdn.spotifycdn.com/...")
29+
* @return a configured HttpURLConnection
30+
* @throws IOException if an I/O error occurs while opening the connection
31+
*/
32+
HttpURLConnection create(String url) throws IOException;
33+
34+
/**
35+
* Shuts down this factory and cleans up any resources. This method should be called when the
36+
* provider is shutting down to ensure proper resource cleanup.
37+
*
38+
* <p>Implementations should clean up any resources that were created and wait for them to
39+
* terminate gracefully if applicable.
40+
*/
41+
void shutdown();
42+
}

openfeature-provider/java/src/main/java/com/spotify/confidence/LocalProviderConfig.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@
22

33
public class LocalProviderConfig {
44
private final ChannelFactory channelFactory;
5+
private final HttpClientFactory httpClientFactory;
56

67
public LocalProviderConfig() {
7-
this(null);
8+
this(null, null);
89
}
910

1011
public LocalProviderConfig(ChannelFactory channelFactory) {
12+
this(channelFactory, null);
13+
}
14+
15+
public LocalProviderConfig(ChannelFactory channelFactory, HttpClientFactory httpClientFactory) {
1116
this.channelFactory = channelFactory != null ? channelFactory : new DefaultChannelFactory();
17+
this.httpClientFactory =
18+
httpClientFactory != null ? httpClientFactory : new DefaultHttpClientFactory();
1219
}
1320

1421
public ChannelFactory getChannelFactory() {
1522
return channelFactory;
1623
}
24+
25+
public HttpClientFactory getHttpClientFactory() {
26+
return httpClientFactory;
27+
}
1728
}

0 commit comments

Comments
 (0)