Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ public class BaseAndroidClient<JsonFlagType> extends BaseEppoClient<JsonFlagType
private static final long DEFAULT_POLLING_INTERVAL_MS = 5 * 60 * 1000;
private static final long DEFAULT_JITTER_INTERVAL_RATIO = 10;

private long pollingIntervalMs;
private long pollingJitterMs;
private volatile long pollingIntervalMs;
private volatile long pollingJitterMs;

/**
* Protected constructor. Use Builder to construct instances.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ protected CachingConfigurationStore(
/**
* Saves the configuration to storage and updates the in-memory cache.
*
* <p>The disk write is performed first. The in-memory cache is updated only after the write
* succeeds, ensuring that the in-memory state never reflects a configuration that failed to
* persist. Concurrent saves are safe: the last write to complete successfully wins in memory.
*
* @param config the configuration to save (must not be null)
* @return a future that completes when the write finishes, or completes exceptionally if the
* underlying {@link ByteStore} write fails (e.g. with an {@link java.io.IOException})
Expand All @@ -53,26 +57,16 @@ protected CachingConfigurationStore(
if (config == null) {
throw new IllegalArgumentException("config must not be null");
}
Configuration previousConfiguration = configuration.get();
byte[] bytes = codec.toBytes(config);
configuration.set(config); // optimistic update — in-memory reflects last submitted save
return byteStore
.write(bytes)
.whenComplete(
(v, ex) -> {
if (ex != null) {
// Revert the optimistic update, but only if no later save has superseded this
// one. compareAndSet atomically checks that the in-memory value is still the
// one we wrote; if a concurrent save has already advanced it, the revert is
// skipped.
//
// Edge case: if two concurrent saves both fail their IO writes, the second
// failure's revert may land on a value that was itself never persisted (the
// first save's value). This is acceptable — the in-memory state may diverge
// from disk, but the next successful save will reconcile them. Saves are
// serialized through a single-thread IO_EXECUTOR, so true concurrent IO
// failures are unlikely in practice.
configuration.compareAndSet(config, previousConfiguration);
if (ex == null) {
// Only update in-memory cache after a successful disk write.
// Use set() rather than compareAndSet() so the most-recently-persisted
// configuration always wins, regardless of submission order.
configuration.set(config);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
package cloud.eppo.android.framework.storage;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.jetbrains.annotations.NotNull;

/**
* {@link ByteStore} implementation that reads and writes a single file via {@link BaseCacheFile}.
*
* <p>Implements {@link java.io.Closeable}. Call {@link #close()} when the store is no longer needed
* to release the background IO thread. If not closed explicitly, the daemon thread will be
* reclaimed by the JVM/Android runtime on process exit.
*/
public final class FileBackedByteStore implements ByteStore {
public final class FileBackedByteStore implements ByteStore, java.io.Closeable {

// Dedicated single-thread executor avoids saturating ForkJoinPool.commonPool() with blocking I/O
// on low-core-count Android devices. Daemon thread so the executor does not block JVM/process
// exit in test environments (e.g. Robolectric).
private static final Executor IO_EXECUTOR =
// on low-core-count Android devices. Instance-level (not static) so it can be shut down via
// close(). The thread is a daemon so it does not block JVM/process exit when close() is omitted
// (e.g. in test environments such as Robolectric).
private final ExecutorService ioExecutor =
Executors.newSingleThreadExecutor(
r -> {
Thread t = new Thread(r, "eppo-io");
Expand All @@ -30,6 +35,15 @@ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) {
this.cacheFile = cacheFile;
}

/**
* Shuts down the background IO executor. In-flight operations are allowed to complete; no new
* operations will be accepted after this call.
*/
@Override
public void close() {
ioExecutor.shutdown();
}

@Override
@NotNull public CompletableFuture<byte[]> read() {
return CompletableFuture.supplyAsync(
Expand All @@ -43,7 +57,7 @@ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) {
throw new RuntimeException("Failed to read from cache file", e);
}
},
IO_EXECUTOR);
ioExecutor);
}

@Override
Expand All @@ -59,7 +73,7 @@ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) {
throw new RuntimeException("Failed to write to cache file", e);
}
},
IO_EXECUTOR);
ioExecutor);
}

private static byte[] readAllBytes(java.io.InputStream in) throws java.io.IOException {
Expand Down
2 changes: 1 addition & 1 deletion eppo/src/main/java/cloud/eppo/android/EppoClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public class EppoClient extends BaseEppoClient {

private long pollingIntervalMs, pollingJitterMs;

@Nullable private static EppoClient instance;
@Nullable private static volatile EppoClient instance;

private EppoClient(
String apiKey,
Expand Down
Loading