diff --git a/.gitignore b/.gitignore index 17d12f5..5cee7db 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.log build/ out/ +/absmarly.db diff --git a/build.gradle b/build.gradle index ac752b9..f6764e3 100644 --- a/build.gradle +++ b/build.gradle @@ -13,7 +13,7 @@ plugins { ext { - VERSION = "1.5.3" + VERSION = "1.6.0" GROUP_ID = "com.absmartly.sdk" slf4jVersion = "1.7.30" @@ -21,6 +21,8 @@ ext { jacksonVersion = "2.13.4.2" jacksonDataTypeVersion = "2.13.4" + sqliteVersion = "3.40.1.0" + junitVersion = "5.7.0" mockitoVersion = "3.6.28" } @@ -63,6 +65,7 @@ configure(subprojects.findAll { from javadoc.destinationDir } + //Config for local maven publish afterEvaluate { publishing { publications { diff --git a/core-api/build.gradle b/core-api/build.gradle index ca14315..4a01af5 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -8,6 +8,14 @@ description = """ABSmartly Java SDK""" def httpClientVersion = "5.1.3" +jar { + from { + configurations.runtimeClasspath.collect { + it.getName().contains("simpleCircuitBreaker") ? zipTree(it) : null + } + } +} + dependencies { implementation group: "org.slf4j", name: "slf4j-api", version: slf4jVersion @@ -21,6 +29,9 @@ dependencies { implementation group: "com.fasterxml.jackson.core", name: "jackson-databind", version: jacksonVersion implementation group: "com.fasterxml.jackson.datatype", name: "jackson-datatype-jsr310", version: jacksonDataTypeVersion + implementation group: 'org.xerial', name: 'sqlite-jdbc', version: sqliteVersion + + implementation files('../libs/simpleCircuitBreaker-2.0.5.jar') testImplementation group: "org.junit.jupiter", name: "junit-jupiter-api", version: junitVersion testImplementation group: "org.junit.jupiter", name: "junit-jupiter-params", version: junitVersion diff --git a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java index 5f70c63..e68d085 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java @@ -25,6 +25,11 @@ private ABSmartly(@Nonnull ABSmartlyConfig config) { audienceDeserializer_ = config.getAudienceDeserializer(); scheduler_ = config.getScheduler(); + if (config.getResilienceConfig() != null + && config.getResilienceConfig().getLocalCache() == null) { + throw new IllegalArgumentException("Missing LocalCache instance"); + } + if ((contextDataProvider_ == null) || (contextEventHandler_ == null)) { client_ = config.getClient(); if (client_ == null) { @@ -32,11 +37,23 @@ private ABSmartly(@Nonnull ABSmartlyConfig config) { } if (contextDataProvider_ == null) { - contextDataProvider_ = new DefaultContextDataProvider(client_); + if (config.getResilienceConfig() != null) { + contextDataProvider_ = new ResilientContextDataProvider(client_, + config.getResilienceConfig().getLocalCache()); + } else { + contextDataProvider_ = new DefaultContextDataProvider(client_); + } } if (contextEventHandler_ == null) { - contextEventHandler_ = new DefaultContextEventHandler(client_); + if (config.getResilienceConfig() != null) { + contextEventHandler_ = new ResilientContextEventHandler( + client_, + config.getResilienceConfig()); + } else { + contextEventHandler_ = new DefaultContextEventHandler(client_); + } + } } diff --git a/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java b/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java index c4346d0..3d7e406 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java @@ -38,6 +38,15 @@ public ABSmartlyConfig setVariableParser(@Nonnull final VariableParser variableP return this; } + public ResilienceConfig getResilienceConfig() { + return this.resilienceConfig; + } + + public ABSmartlyConfig setResilienceConfig(@Nonnull final ResilienceConfig resilienceConfig) { + this.resilienceConfig = resilienceConfig; + return this; + } + public ScheduledExecutorService getScheduler() { return scheduler_; } @@ -83,4 +92,5 @@ public ABSmartlyConfig setClient(Client client) { private AudienceDeserializer audienceDeserializer_; private ScheduledExecutorService scheduler_; private Client client_; + private ResilienceConfig resilienceConfig; } diff --git a/core-api/src/main/java/com/absmartly/sdk/Client.java b/core-api/src/main/java/com/absmartly/sdk/Client.java index e172461..9cbdb53 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Client.java +++ b/core-api/src/main/java/com/absmartly/sdk/Client.java @@ -53,7 +53,7 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H executor_ = config.getExecutor(); if (deserializer_ == null) { - deserializer_ = new DefaultContextDataDeserializer(); + deserializer_ = new DefaultContextDataSerializer(); } if (serializer_ == null) { @@ -154,6 +154,6 @@ public void close() throws IOException { private final Map headers_; private final HTTPClient httpClient_; private final Executor executor_; - private ContextDataDeserializer deserializer_; + private ContextDataSerializer deserializer_; private ContextEventSerializer serializer_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/ClientConfig.java b/core-api/src/main/java/com/absmartly/sdk/ClientConfig.java index 1be7a17..d93e7dc 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ClientConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ClientConfig.java @@ -60,11 +60,11 @@ public ClientConfig setApplication(@Nonnull final String application) { return this; } - public ContextDataDeserializer getContextDataDeserializer() { + public ContextDataSerializer getContextDataDeserializer() { return deserializer_; } - public ClientConfig setContextDataDeserializer(@Nonnull final ContextDataDeserializer deserializer) { + public ClientConfig setContextDataDeserializer(@Nonnull final ContextDataSerializer deserializer) { deserializer_ = deserializer; return this; } @@ -91,7 +91,7 @@ public ClientConfig setExecutor(@Nonnull final Executor executor) { private String apiKey_; private String environment_; private String application_; - private ContextDataDeserializer deserializer_; + private ContextDataSerializer deserializer_; private ContextEventSerializer serializer_; private Executor executor_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index db4e33e..7905c29 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -11,6 +11,7 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; import java8.util.concurrent.CompletableFuture; import java8.util.concurrent.CompletionException; +import java8.util.function.BiConsumer; import java8.util.function.Consumer; import java8.util.function.Function; @@ -71,6 +72,13 @@ private Context(Clock clock, ContextConfig config, ScheduledExecutorService sche cassignments_ = (cassignments != null) ? new HashMap(cassignments) : new HashMap(); + dataFuture.whenComplete(new BiConsumer() { + @Override + public void accept(ContextData unused, Throwable throwable) { + eventHandler_.onContextReady(); + } + }); + if (dataFuture.isDone()) { dataFuture.thenAccept(new Consumer() { @Override @@ -602,7 +610,6 @@ public void run() { @Override public Void apply(Throwable throwable) { Context.this.logError(throwable); - result.completeExceptionally(throwable); return null; } @@ -920,7 +927,7 @@ private void setData(final ContextData data) { new Comparator() { @Override public int compare(ExperimentVariables a, ExperimentVariables b) { - return Integer.valueOf(a.data.id).compareTo(b.data.id); + return Integer.compare(Integer.valueOf(a.data.id), b.data.id); } }); diff --git a/core-api/src/main/java/com/absmartly/sdk/ContextDataDeserializer.java b/core-api/src/main/java/com/absmartly/sdk/ContextDataSerializer.java similarity index 66% rename from core-api/src/main/java/com/absmartly/sdk/ContextDataDeserializer.java rename to core-api/src/main/java/com/absmartly/sdk/ContextDataSerializer.java index 35e28ce..259f2fd 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextDataDeserializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextDataSerializer.java @@ -4,6 +4,9 @@ import com.absmartly.sdk.json.ContextData; -interface ContextDataDeserializer { +public interface ContextDataSerializer { + + byte[] serialize(@Nonnull final ContextData contextData); + ContextData deserialize(@Nonnull final byte[] bytes, final int offset, final int length); } diff --git a/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java b/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java index c5576b3..0101191 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java @@ -7,5 +7,6 @@ import com.absmartly.sdk.json.PublishEvent; public interface ContextEventHandler { - CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event); + CompletableFuture publish(final Context context, @Nonnull final PublishEvent event); + void onContextReady(); } diff --git a/core-api/src/main/java/com/absmartly/sdk/ContextEventSerializer.java b/core-api/src/main/java/com/absmartly/sdk/ContextEventSerializer.java index d02b0de..f4b01f8 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextEventSerializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextEventSerializer.java @@ -6,4 +6,6 @@ public interface ContextEventSerializer { byte[] serialize(@Nonnull final PublishEvent publishEvent); + + PublishEvent deserialize(@Nonnull final byte[] bytes, final int offset, final int length); } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java b/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataSerializer.java similarity index 54% rename from core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java rename to core-api/src/main/java/com/absmartly/sdk/DefaultContextDataSerializer.java index b28eb79..954bd22 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataSerializer.java @@ -7,21 +7,40 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; import com.absmartly.sdk.json.ContextData; -public class DefaultContextDataDeserializer implements ContextDataDeserializer { - private static final Logger log = LoggerFactory.getLogger(DefaultContextDataDeserializer.class); +public class DefaultContextDataSerializer implements ContextDataSerializer { + private static final Logger log = LoggerFactory.getLogger(DefaultContextDataSerializer.class); - public DefaultContextDataDeserializer() { + public DefaultContextDataSerializer() { final ObjectMapper objectMapper = new ObjectMapper(); objectMapper.enable(MapperFeature.USE_STATIC_TYPING); + + this.writer_ = objectMapper.writerFor(ContextData.class); this.reader_ = objectMapper.readerFor(ContextData.class); } + public DefaultContextDataSerializer(final ObjectWriter writer, final ObjectReader reader) { + this.writer_ = writer; + this.reader_ = reader; + } + + @Override + public byte[] serialize(@Nonnull ContextData contextData) { + try { + return writer_.writeValueAsBytes(contextData); + } catch (JsonProcessingException e) { + log.error("", e); + return null; + } + } + public ContextData deserialize(@Nonnull final byte[] bytes, final int offset, final int length) { try { return reader_.readValue(bytes, offset, length); @@ -31,5 +50,6 @@ public ContextData deserialize(@Nonnull final byte[] bytes, final int offset, fi } } + final private ObjectWriter writer_; private final ObjectReader reader_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventHandler.java b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventHandler.java index 7e30584..43d27f4 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventHandler.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventHandler.java @@ -12,9 +12,13 @@ public DefaultContextEventHandler(@Nonnull final Client client) { } @Override - public CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event) { + public CompletableFuture publish(final Context context, @Nonnull final PublishEvent event) { return client_.publish(event); } - private final Client client_; + @Override + public void onContextReady() { + } + + final Client client_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java index 6aba4fb..3705c52 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java @@ -1,12 +1,16 @@ package com.absmartly.sdk; +import java.io.IOException; + import javax.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.ObjectWriter; import com.absmartly.sdk.json.PublishEvent; @@ -17,10 +21,14 @@ public class DefaultContextEventSerializer implements ContextEventSerializer { public DefaultContextEventSerializer() { final ObjectMapper objectMapper = new ObjectMapper(); this.writer_ = objectMapper.writerFor(PublishEvent.class); + + objectMapper.enable(MapperFeature.USE_STATIC_TYPING); + this.reader_ = objectMapper.readerFor(PublishEvent.class); } - public DefaultContextEventSerializer(final ObjectWriter writer) { + public DefaultContextEventSerializer(final ObjectWriter writer, final ObjectReader reader) { this.writer_ = writer; + this.reader_ = reader; } @Override @@ -33,5 +41,17 @@ public byte[] serialize(@Nonnull final PublishEvent event) { } } + @Override + public PublishEvent deserialize(@Nonnull byte[] bytes, int offset, int length) { + try { + return reader_.readValue(bytes, offset, length); + } catch (IOException e) { + log.error("", e); + return null; + } + } + final private ObjectWriter writer_; + + private final ObjectReader reader_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/ResilienceConfig.java b/core-api/src/main/java/com/absmartly/sdk/ResilienceConfig.java new file mode 100644 index 0000000..bed8845 --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/ResilienceConfig.java @@ -0,0 +1,67 @@ +package com.absmartly.sdk; + +import java.util.Properties; + +import javax.annotation.Nonnull; + +import com.absmartly.sdk.cache.LocalCache; + +public class ResilienceConfig { + public static ResilienceConfig create(@Nonnull LocalCache localCache) { + return new ResilienceConfig(localCache); + } + + public static ResilienceConfig createFromProperties(Properties properties) { + return createFromProperties(properties, ""); + } + + public static ResilienceConfig createFromProperties(Properties properties, final String prefix) { + LocalCache localCache = null; + try { + Class localCacheImpl = Class.forName(properties.getProperty(prefix + "localCacheImplClass")); + localCache = (LocalCache) localCacheImpl.newInstance(); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } catch (InstantiationException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + + return create(localCache) + .setBackoffPeriodInMilliseconds( + Long.parseLong(properties.getProperty(prefix + "backoffPeriodInMilliseconds"))) + .setFailureRateThreshold(Float.valueOf(properties.getProperty(prefix + "failureRateThreshold"))); + } + + ResilienceConfig(LocalCache localCache) { + this.localCache = localCache; + } + + public LocalCache getLocalCache() { + return localCache; + } + + public float getFailureRateThreshold() { + return failureRateThreshold; + } + + public ResilienceConfig setFailureRateThreshold(float failureRateThreshold) { + this.failureRateThreshold = failureRateThreshold; + return this; + } + + public long getBackoffPeriodInMilliseconds() { + return backoffPeriodInMilliseconds; + } + + public ResilienceConfig setBackoffPeriodInMilliseconds(long backoffPeriodInMilliseconds) { + this.backoffPeriodInMilliseconds = backoffPeriodInMilliseconds; + return this; + } + + private LocalCache localCache; + private float failureRateThreshold = 20; + private long backoffPeriodInMilliseconds = 30000; + +} diff --git a/core-api/src/main/java/com/absmartly/sdk/ResilientContextDataProvider.java b/core-api/src/main/java/com/absmartly/sdk/ResilientContextDataProvider.java new file mode 100644 index 0000000..81e61ab --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/ResilientContextDataProvider.java @@ -0,0 +1,44 @@ +package com.absmartly.sdk; + +import java8.util.concurrent.CompletableFuture; +import java8.util.concurrent.CompletionException; +import java8.util.function.BiConsumer; +import java8.util.function.Function; + +import javax.annotation.Nonnull; + +import com.absmartly.sdk.cache.LocalCache; +import com.absmartly.sdk.json.ContextData; + +public class ResilientContextDataProvider extends DefaultContextDataProvider { + public ResilientContextDataProvider(@Nonnull final Client client, @Nonnull final LocalCache localCache) { + super(client); + this.localCache = localCache; + } + + @Override + public CompletableFuture getContextData() { + return super.getContextData() + .whenComplete(new BiConsumer() { + @Override + public void accept(ContextData contextData, Throwable throwable) { + if (throwable == null + && localCache != null) { + localCache.writeContextData(contextData); + } + } + }) + .exceptionally(new Function() { + @Override + public ContextData apply(Throwable throwable) { + ContextData contextData = localCache != null ? localCache.getContextData() : null; + if (contextData != null) + return contextData; + + throw (CompletionException) throwable; + } + }); + } + + private LocalCache localCache; +} diff --git a/core-api/src/main/java/com/absmartly/sdk/ResilientContextEventHandler.java b/core-api/src/main/java/com/absmartly/sdk/ResilientContextEventHandler.java new file mode 100644 index 0000000..ea5fcae --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/ResilientContextEventHandler.java @@ -0,0 +1,72 @@ +package com.absmartly.sdk; + +import java.util.List; +import java8.util.concurrent.CompletableFuture; +import java8.util.function.Supplier; + +import javax.annotation.Nonnull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.geckotechnology.simpleCircuitBreaker.BreakerStateEventListener; +import com.geckotechnology.simpleCircuitBreaker.BreakerStateType; +import com.geckotechnology.simpleCircuitBreaker.CircuitBreakerStateChangeEvent; + +import com.absmartly.sdk.cache.LocalCache; +import com.absmartly.sdk.circuitbreaker.CircuitBreakerHelper; +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.PublishEvent; + +public class ResilientContextEventHandler extends DefaultContextEventHandler { + + public ResilientContextEventHandler(@Nonnull final Client client, @Nonnull ResilienceConfig resilienceConfig) { + super(client); + this.localCache = resilienceConfig.getLocalCache(); + this.circuitBreakerHelper = new CircuitBreakerHelper(resilienceConfig, + new BreakerStateEventListener() { + @Override + public void onCircuitBreakerStateChangeEvent(CircuitBreakerStateChangeEvent event) { + ResilientContextEventHandler.this.onCircuitStateEventChange(event); + } + }); + } + + @Override + public CompletableFuture publish(final Context context, @Nonnull final PublishEvent event) { + CompletableFuture decoratedSupplier = this.circuitBreakerHelper + .decorateCompletionFuture(new Supplier>() { + @Override + public CompletableFuture get() { + return ResilientContextEventHandler.this.client_.publish(event); + } + }); + if (decoratedSupplier.isCompletedExceptionally()) { + localCache.writePublishEvent(event); + } + return decoratedSupplier; + } + + private void flushCache() { + List events = localCache.retrievePublishEvents(); + for (PublishEvent event : events) { + event.publishedAt = Clock.systemUTC().millis(); + this.publish(null, event); + } + } + + @Override + public void onContextReady() { + this.flushCache(); + } + + private void onCircuitStateEventChange(CircuitBreakerStateChangeEvent event) { + System.out.println(event); + if (event.getNewBreakerStateType().equals(BreakerStateType.CLOSED)) { + this.flushCache(); + } + } + + private final LocalCache localCache; + private final CircuitBreakerHelper circuitBreakerHelper; +} diff --git a/core-api/src/main/java/com/absmartly/sdk/cache/LocalCache.java b/core-api/src/main/java/com/absmartly/sdk/cache/LocalCache.java new file mode 100644 index 0000000..416bbc2 --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/cache/LocalCache.java @@ -0,0 +1,16 @@ +package com.absmartly.sdk.cache; + +import java.util.List; + +import com.absmartly.sdk.json.ContextData; +import com.absmartly.sdk.json.PublishEvent; + +public interface LocalCache { + void writePublishEvent(PublishEvent event); + + List retrievePublishEvents(); + + void writeContextData(ContextData event); + + ContextData getContextData(); +} diff --git a/core-api/src/main/java/com/absmartly/sdk/cache/MemoryCache.java b/core-api/src/main/java/com/absmartly/sdk/cache/MemoryCache.java new file mode 100644 index 0000000..3c6938f --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/cache/MemoryCache.java @@ -0,0 +1,39 @@ +package com.absmartly.sdk.cache; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +import com.absmartly.sdk.json.ContextData; +import com.absmartly.sdk.json.PublishEvent; + +public final class MemoryCache implements LocalCache { + + private final List eventCache = new ArrayList(); + private final ReentrantLock cacheLock = new ReentrantLock(); + private ContextData contextData; + + public void writePublishEvent(PublishEvent event) { + cacheLock.lock(); + eventCache.add(event); + cacheLock.unlock(); + } + + public List retrievePublishEvents() { + cacheLock.lock(); + List eventsToRetrieve = new ArrayList(eventCache); + eventCache.clear(); + cacheLock.unlock(); + return eventsToRetrieve; + } + + @Override + public void writeContextData(ContextData contextData) { + this.contextData = contextData; + } + + @Override + public ContextData getContextData() { + return this.contextData; + } +} diff --git a/core-api/src/main/java/com/absmartly/sdk/cache/SerializableCache.java b/core-api/src/main/java/com/absmartly/sdk/cache/SerializableCache.java new file mode 100644 index 0000000..23934b1 --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/cache/SerializableCache.java @@ -0,0 +1,17 @@ +package com.absmartly.sdk.cache; + +import com.absmartly.sdk.ContextDataSerializer; +import com.absmartly.sdk.ContextEventSerializer; + +public abstract class SerializableCache implements LocalCache { + + ContextDataSerializer contextDataSerializer; + ContextEventSerializer contextEventSerializer; + + public SerializableCache(final ContextDataSerializer contextDataSerializer, + final ContextEventSerializer contextEventSerializer) { + this.contextDataSerializer = contextDataSerializer; + this.contextEventSerializer = contextEventSerializer; + } + +} diff --git a/core-api/src/main/java/com/absmartly/sdk/cache/SqliteCache.java b/core-api/src/main/java/com/absmartly/sdk/cache/SqliteCache.java new file mode 100644 index 0000000..1d9f9aa --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/cache/SqliteCache.java @@ -0,0 +1,154 @@ +package com.absmartly.sdk.cache; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.commons.codec.binary.Base64; + +import com.absmartly.sdk.ContextDataSerializer; +import com.absmartly.sdk.ContextEventSerializer; +import com.absmartly.sdk.java.nio.charset.StandardCharsets; +import com.absmartly.sdk.json.ContextData; +import com.absmartly.sdk.json.PublishEvent; + +public final class SqliteCache extends SerializableCache { + + private Connection connection; + private final ReentrantLock cacheLock = new ReentrantLock(); + private final String databaseURL; + + public SqliteCache(ContextDataSerializer contextDataSerializer, + ContextEventSerializer contextEventSerializer) { + this( + contextDataSerializer, + contextEventSerializer, + "absmarly.db" + ); + } + + public SqliteCache(ContextDataSerializer contextDataSerializer, + ContextEventSerializer contextEventSerializer, + String databaseFileName) { + super(contextDataSerializer, contextEventSerializer); + this.databaseURL = "jdbc:sqlite:" + databaseFileName; + } + + public void writePublishEvent(PublishEvent event) { + cacheLock.lock(); + PreparedStatement statement = null; + try { + statement = getConnection().prepareStatement("insert into events (event) values (?)"); + String serilizedString = Base64.encodeBase64String(this.contextEventSerializer.serialize(event)); + statement.setString(1, serilizedString); + statement.execute(); + } catch (SQLException e) { + throw new RuntimeException(e); + } finally { + closeResources(null, statement, null); + } + cacheLock.unlock(); + } + + public List retrievePublishEvents() { + cacheLock.lock(); + Statement statement = null; + ResultSet rs = null; + List events = new ArrayList(); + try { + statement = getConnection().createStatement(); + rs = statement.executeQuery("select * from events"); + while (rs.next()) { + String eventStr = rs.getString("event"); + final byte[] bytes = Base64.decodeBase64(eventStr); + events.add(this.contextEventSerializer.deserialize(bytes, 0, bytes.length)); + } + + statement.execute("DELETE FROM events"); + } catch (SQLException exception) { + throw new RuntimeException(exception); + } finally { + closeResources(rs, statement, null); + } + cacheLock.unlock(); + return events; + } + + @Override + public void writeContextData(ContextData contextData) { + PreparedStatement statement = null; + try { + Statement deleteStatement = getConnection().createStatement(); + deleteStatement.execute("DELETE FROM context"); + deleteStatement.close(); + + statement = getConnection().prepareStatement("insert into context (context) values (?)"); + String serilizedString = String.valueOf(this.contextDataSerializer.serialize(contextData)); + statement.setString(1, serilizedString); + statement.execute(); + } catch (SQLException e) { + throw new RuntimeException(e); + } finally { + closeResources(null, statement, null); + } + } + + @Override + public ContextData getContextData() { + Statement statement = null; + ResultSet rs = null; + ContextData contextData = null; + try { + statement = getConnection().createStatement(); + rs = statement.executeQuery("select * from context"); + if (rs.next()) { + String eventStr = rs.getString("context"); + final byte[] bytes = eventStr.getBytes(StandardCharsets.UTF_8); + contextData = this.contextDataSerializer.deserialize(bytes, 0, bytes.length); + } + } catch (SQLException exception) { + throw new RuntimeException(exception); + } finally { + closeResources(rs, statement, null); + } + + return contextData; + } + + private Connection getConnection() throws SQLException { + if (this.connection == null) { + this.connection = DriverManager.getConnection(this.databaseURL); + setupDatabase(); + } + return this.connection; + } + + private void setupDatabase() throws SQLException { + Statement statement = null; + try { + statement = getConnection().createStatement(); + statement.executeUpdate( + "create table if not exists events (id INTEGER PRIMARY KEY AUTOINCREMENT, event text)"); + + statement.executeUpdate( + "create table if not exists context (id INTEGER PRIMARY KEY AUTOINCREMENT, context text)"); + } finally { + closeResources(null, statement, null); + } + } + + private void closeResources(ResultSet rs, Statement statement, Connection conn) { + try { + if (rs != null) + rs.close(); + if (statement != null) + statement.close(); + if (conn != null) + conn.close(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/core-api/src/main/java/com/absmartly/sdk/circuitbreaker/CircuitBreakerHelper.java b/core-api/src/main/java/com/absmartly/sdk/circuitbreaker/CircuitBreakerHelper.java new file mode 100644 index 0000000..983c266 --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/circuitbreaker/CircuitBreakerHelper.java @@ -0,0 +1,107 @@ +package com.absmartly.sdk.circuitbreaker; + +import java8.util.concurrent.CompletableFuture; +import java8.util.concurrent.CompletionStage; +import java8.util.function.BiConsumer; +import java8.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.geckotechnology.simpleCircuitBreaker.*; + +import com.absmartly.sdk.ResilienceConfig; + +public class CircuitBreakerHelper { + + private static final Logger log = LoggerFactory.getLogger(CircuitBreakerHelper.class); + private final CircuitBreaker circuitBreaker; + + public CircuitBreakerHelper(ResilienceConfig config, BreakerStateEventListener eventListener) { + this.circuitBreaker = new CircuitBreaker(getCircuitBreakerConfig(config)); + circuitBreaker.getBreakerStateEventManager().addBreakerStateEventListener(eventListener); + } + + private static CircuitBreakerConfig getCircuitBreakerConfig(ResilienceConfig resilienceConfig) { + CircuitBreakerConfig config = new CircuitBreakerConfig(); + config.setFailureRateThreshold(resilienceConfig.getFailureRateThreshold()); + config.setSlowCallDurationThreshold(0); + config.setWaitDurationInOpenState(resilienceConfig.getBackoffPeriodInMilliseconds()); + return config; + } + + private static void onEvent(CircuitBreakerStateChangeEvent event) { + System.out.println("StateTransition" + event); + } + + public Supplier> decorateCompletionStage( + final Supplier> supplier) { + return new Supplier>() { + @Override + public CompletionStage get() { + + final CompletableFuture promise = new CompletableFuture(); + + if (!circuitBreaker.isClosedForThisCall()) { + promise.completeExceptionally(new RuntimeException("Circuit Breaker is opened.")); + } else { + final long start = System.currentTimeMillis(); + try { + supplier.get().whenComplete(new BiConsumer() { + @Override + public void accept(T result, Throwable throwable) { + long duration = System.currentTimeMillis() - start; + if (throwable != null) { + circuitBreaker.callFailed(duration); + promise.completeExceptionally(throwable); + } else { + circuitBreaker.callSucceeded(duration); + } + } + }); + } catch (Exception exception) { + long duration = System.currentTimeMillis() - start; + circuitBreaker.callFailed(duration); + promise.completeExceptionally(exception); + } + } + + return promise; + } + }; + } + + public CompletableFuture decorateCompletionFuture( + Supplier> supplier) { + CompletableFuture future = null; + if (!circuitBreaker.isClosedForThisCall()) { + future = CompletableFuture.failedFuture(new RuntimeException("Circuit Breaker is opened.")); + } else { + final long start = System.currentTimeMillis(); + try { + future = supplier.get(); + future.whenComplete(new BiConsumer() { + @Override + public void accept(T result, Throwable throwable) { + long duration = System.currentTimeMillis() - start; + if (throwable != null) { + circuitBreaker.callFailed(duration); + } else { + circuitBreaker.callSucceeded(duration); + } + } + }); + } catch (Exception exception) { + long duration = System.currentTimeMillis() - start; + circuitBreaker.callFailed(duration); + return CompletableFuture.failedFuture(exception); + } + } + + return future; + } + + public CircuitBreaker getCircuitBreaker() { + return this.circuitBreaker; + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java index 3ad0b69..6bcbfb2 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java @@ -256,15 +256,21 @@ void createContextWithCustomImpls() { @Test void close() throws IOException, InterruptedException { final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + final ContextDataProvider contextDataProvider = mock(ContextDataProvider.class); final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client) - .setScheduler(scheduler); + .setScheduler(scheduler) + .setContextDataProvider(contextDataProvider); final ABSmartly absmartly = ABSmartly.create(config); try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); + + final CompletableFuture dataFuture = (CompletableFuture) mock( + CompletableFuture.class); + when(contextDataProvider.getContextData()).thenReturn(dataFuture); contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(contextMock); diff --git a/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java b/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java index d571c01..ecf9c6b 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java @@ -36,7 +36,7 @@ void setApplication() { @Test void setContextDataDeserializer() { - final ContextDataDeserializer deserializer = mock(ContextDataDeserializer.class); + final ContextDataSerializer deserializer = mock(ContextDataSerializer.class); final ClientConfig config = ClientConfig.create().setContextDataDeserializer(deserializer); assertEquals(deserializer, config.getContextDataDeserializer()); } @@ -58,7 +58,7 @@ void setExecutor() { @Test void setAll() { final ContextEventSerializer serializer = mock(ContextEventSerializer.class); - final ContextDataDeserializer deserializer = mock(ContextDataDeserializer.class); + final ContextDataSerializer deserializer = mock(ContextDataSerializer.class); final Executor executor = mock(Executor.class); final ClientConfig config = ClientConfig.create() .setEndpoint("https://test.endpoint.com") @@ -88,7 +88,7 @@ void createFromProperties() { "absmartly.application", "website")); final ContextEventSerializer serializer = mock(ContextEventSerializer.class); - final ContextDataDeserializer deserializer = mock(ContextDataDeserializer.class); + final ContextDataSerializer deserializer = mock(ContextDataSerializer.class); final Executor executor = mock(Executor.class); final ClientConfig config = ClientConfig.createFromProperties(props, "absmartly.") .setContextDataDeserializer(deserializer) diff --git a/core-api/src/test/java/com/absmartly/sdk/ClientTest.java b/core-api/src/test/java/com/absmartly/sdk/ClientTest.java index 66bcff9..c6c56ec 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ClientTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ClientTest.java @@ -71,8 +71,8 @@ void createWithDefaults() { final PublishEvent event = new PublishEvent(); final byte[] publishBytes = new byte[]{0}; - try (final MockedConstruction deserCtor = mockConstruction( - DefaultContextDataDeserializer.class, + try (final MockedConstruction deserCtor = mockConstruction( + DefaultContextDataSerializer.class, (mock, context) -> when(mock.deserialize(dataBytes, 0, dataBytes.length)).thenReturn(expected)); final MockedConstruction serCtor = mockConstruction( DefaultContextEventSerializer.class, @@ -119,7 +119,7 @@ void createWithDefaults() { @Test void getContextData() throws ExecutionException, InterruptedException { final HTTPClient httpClient = mock(HTTPClient.class); - final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final ContextDataSerializer deser = mock(ContextDataSerializer.class); final Client client = Client.create(ClientConfig.create() .setEndpoint("https://localhost/v1") .setAPIKey("test-api-key") @@ -173,7 +173,7 @@ public byte[] getContent() { @Test void getContextDataExceptionallyHTTP() { final HTTPClient httpClient = mock(HTTPClient.class); - final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final ContextDataSerializer deser = mock(ContextDataSerializer.class); final Client client = Client.create(ClientConfig.create() .setEndpoint("https://localhost/v1") .setAPIKey("test-api-key") @@ -201,7 +201,7 @@ void getContextDataExceptionallyHTTP() { @Test void getContextDataExceptionallyConnection() { final HTTPClient httpClient = mock(HTTPClient.class); - final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final ContextDataSerializer deser = mock(ContextDataSerializer.class); final Client client = Client.create(ClientConfig.create() .setEndpoint("https://localhost/v1") .setAPIKey("test-api-key") diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java index c6d58ff..37de60f 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -86,7 +86,7 @@ class ContextTest extends TestUtils { VariableParser variableParser; AudienceMatcher audienceMatcher; ScheduledExecutorService scheduler; - DefaultContextDataDeserializer deser = new DefaultContextDataDeserializer(); + DefaultContextDataSerializer deser = new DefaultContextDataSerializer(); Clock clock = Clock.fixed(1_620_000_000_000L); @BeforeEach diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataSerializerTest.java similarity index 88% rename from core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java rename to core-api/src/test/java/com/absmartly/sdk/DefaultContextDataSerializerTest.java index d1966d8..d11b8e8 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataSerializerTest.java @@ -9,13 +9,13 @@ import com.absmartly.sdk.json.ExperimentApplication; import com.absmartly.sdk.json.ExperimentVariant; -class DefaultContextDataDeserializerTest extends TestUtils { +class DefaultContextDataSerializerTest extends TestUtils { @Test - void deserialize() { + void serializeAndDeserialize() { final byte[] bytes = getResourceBytes("context.json"); - final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); - final ContextData data = deser.deserialize(bytes, 0, bytes.length); + final ContextDataSerializer ser = new DefaultContextDataSerializer(); + final ContextData data = ser.deserialize(bytes, 0, bytes.length); final Experiment experiment0 = new Experiment(); experiment0.id = 1; @@ -111,15 +111,19 @@ void deserialize() { assertNotNull(data); assertEquals(expected, data); + + final byte[] bytesSerialized = ser.serialize(expected); + final ContextData dataDeserialized = ser.deserialize(bytesSerialized, 0, bytes.length); + assertEquals(expected, dataDeserialized); } @Test void deserializeDoesNotThrow() { final byte[] bytes = getResourceBytes("context.json"); - final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + final ContextDataSerializer ser = new DefaultContextDataSerializer(); assertDoesNotThrow(() -> { - final ContextData data = deser.deserialize(bytes, 0, 14); + final ContextData data = ser.deserialize(bytes, 0, 14); assertNull(data); }); } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextEventSerializerTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextEventSerializerTest.java index 5c0dc59..c8d15bb 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextEventSerializerTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextEventSerializerTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.ObjectWriter; import com.absmartly.sdk.java.nio.charset.StandardCharsets; @@ -17,7 +18,7 @@ class DefaultContextEventSerializerTest extends TestUtils { @Test - void serialize() { + void serializeAndDeserialize() { final PublishEvent event = new PublishEvent(); event.hashed = true; event.publishedAt = 123456789L; @@ -56,15 +57,20 @@ void serialize() { assertEquals( "{\"hashed\":true,\"units\":[{\"type\":\"session_id\",\"uid\":\"pAE3a1i5Drs5mKRNq56adA\"},{\"type\":\"user_id\",\"uid\":\"JfnnlDI7RTiF9RgfG2JNCw\"}],\"publishedAt\":123456789,\"exposures\":[{\"id\":1,\"name\":\"exp_test_ab\",\"unit\":\"session_id\",\"variant\":1,\"exposedAt\":123470000,\"assigned\":true,\"eligible\":true,\"overridden\":false,\"fullOn\":false,\"custom\":false,\"audienceMismatch\":true}],\"goals\":[{\"name\":\"goal1\",\"achievedAt\":123456000,\"properties\":{\"amount\":6,\"nested\":{\"value\":5},\"nested_arr\":{\"nested\":[1,2,\"test\"]},\"tries\":1,\"value\":5.0}},{\"name\":\"goal2\",\"achievedAt\":123456789}],\"attributes\":[{\"name\":\"attr1\",\"value\":\"value1\",\"setAt\":123456000},{\"name\":\"attr2\",\"value\":\"value2\",\"setAt\":123456789},{\"name\":\"attr2\",\"setAt\":123450000},{\"name\":\"attr3\",\"value\":{\"nested\":{\"value\":5}},\"setAt\":123470000},{\"name\":\"attr4\",\"value\":{\"nested\":[1,2,\"test\"]},\"setAt\":123480000}]}", new String(bytes, StandardCharsets.UTF_8)); + + final PublishEvent dataDeserialized = ser.deserialize(bytes, 0, bytes.length); + + assertEquals(event, dataDeserialized); } @Test void serializeDoesNotThrow() throws JsonProcessingException { final PublishEvent event = new PublishEvent(); final ObjectWriter writer = mock(ObjectWriter.class); + final ObjectReader reader = mock(ObjectReader.class); when(writer.writeValueAsBytes(event)).thenThrow(mock(JsonProcessingException.class)); - final ContextEventSerializer ser = new DefaultContextEventSerializer(writer); + final ContextEventSerializer ser = new DefaultContextEventSerializer(writer, reader); assertDoesNotThrow(() -> { final byte[] bytes = ser.serialize(event); assertNull(bytes); diff --git a/core-api/src/test/java/com/absmartly/sdk/ResilientContextDataProviderTest.java b/core-api/src/test/java/com/absmartly/sdk/ResilientContextDataProviderTest.java new file mode 100644 index 0000000..81520d3 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ResilientContextDataProviderTest.java @@ -0,0 +1,55 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.concurrent.ExecutionException; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.absmartly.sdk.cache.MemoryCache; +import com.absmartly.sdk.json.ContextData; + +class ResilientContextDataProviderTest extends TestUtils { + @Test + void getContextData() throws ExecutionException, InterruptedException { + final Client client = mock(Client.class); + final ContextDataProvider provider = new ResilientContextDataProvider(client, new MemoryCache()); + + final ContextData expected = new ContextData(); + when(client.getContextData()).thenReturn(CompletableFuture.completedFuture(expected)); + + final CompletableFuture dataFuture = provider.getContextData(); + final ContextData actual = dataFuture.get(); + + assertEquals(expected, actual); + assertSame(expected, actual); + } + + @Test + void getContextDataExceptionally() throws ExecutionException, InterruptedException { + final Client client = mock(Client.class); + final ContextDataProvider provider = new ResilientContextDataProvider(client, null); + + final ContextData expected = new ContextData(); + when(client.getContextData()).thenReturn(CompletableFuture.completedFuture(expected)); + + final CompletableFuture dataFuture = provider.getContextData(); + final ContextData actual = dataFuture.get(); + assertSame(expected, actual); + + Mockito.reset(client); + + final Exception failure = new Exception("FAILED"); + final CompletableFuture failedFuture = failedFuture(failure); + when(client.getContextData()).thenReturn(failedFuture); + + final CompletableFuture dataFuture2 = provider.getContextData(); + final ContextData actual2 = dataFuture.get(); + assertSame(expected, actual2); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ResilientContextEventHandlerTest.java b/core-api/src/test/java/com/absmartly/sdk/ResilientContextEventHandlerTest.java new file mode 100644 index 0000000..cc62778 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ResilientContextEventHandlerTest.java @@ -0,0 +1,139 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import java8.util.concurrent.CompletableFuture; +import java8.util.concurrent.CompletionException; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.absmartly.sdk.cache.LocalCache; +import com.absmartly.sdk.json.PublishEvent; + +class ResilientContextEventHandlerTest extends TestUtils { + + @Test + void publishExceptionallyWithResilience() { + final Context context = mock(Context.class); + final Client client = mock(Client.class); + final LocalCache localCache = mock(LocalCache.class); + final ContextEventHandler eventHandler = new ResilientContextEventHandler(client, + ResilienceConfig.create(localCache)); + + final PublishEvent event = new PublishEvent(); + final Exception failure = new RuntimeException("FAILED"); + final CompletableFuture failedFuture = failedFuture(failure); + when(client.publish(event)).thenAnswer(invocation -> { + try { + Thread.sleep((int) (Math.random() * 15)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return failedFuture; + }); + + for (int i = 0; i < 300; i++) { + CompletableFuture publishFuture = eventHandler.publish(context, event); + CompletionException actual = assertThrows(CompletionException.class, publishFuture::join); + System.out.println(actual.getCause().getMessage()); + assertTrue(actual.getCause().getMessage().contains(failure.getMessage()) + || actual.getCause().getMessage().contains("is opened")); + } + } + + @Test + void shouldCallFlushCacheWhenStart() throws ExecutionException, InterruptedException { + final Context context = mock(Context.class); + final Client client = mock(Client.class); + final LocalCache localCache = spy(mock(LocalCache.class)); + final ContextEventHandler eventHandler = new ResilientContextEventHandler(client, + ResilienceConfig.create(localCache)); + final ContextEventHandler eventHandlerSpied = spy(eventHandler); + + PublishEvent cacheEvent = new PublishEvent(); + cacheEvent.publishedAt = 123L; + when(localCache.retrievePublishEvents()).thenReturn(Arrays.asList(cacheEvent)); + + verify(localCache, times(1)).retrievePublishEvents(); + } + + @Test + void shouldCallFlushCacheWhenCircuitChangeToClosed() throws ExecutionException, InterruptedException { + final Context context = mock(Context.class); + final Client client = mock(Client.class); + final LocalCache localCache = mock(LocalCache.class); + + final ContextEventHandler eventHandler = new ResilientContextEventHandler(client, + ResilienceConfig.create(localCache) + .setFailureRateThreshold(20) + .setBackoffPeriodInMilliseconds(2000)); + final ContextEventHandler eventHandlerSpied = spy(eventHandler); + + final AtomicInteger i = new AtomicInteger(0); + final AtomicInteger errorCount = new AtomicInteger(0); + final AtomicInteger recoveryCount = new AtomicInteger(0); + final AtomicInteger successCount = new AtomicInteger(0); + + final ArgumentCaptor eventArgumentCaptor = ArgumentCaptor + .forClass(PublishEvent.class); + + PublishEvent cacheEvent = new PublishEvent(); + cacheEvent.publishedAt = 123L; + when(localCache.retrievePublishEvents()).thenReturn(Arrays.asList(cacheEvent)); + + final PublishEvent event = new PublishEvent(); + ArrayList events = new ArrayList<>(); + + when(client.publish(any())).then(invocation -> { + CompletableFuture future = new CompletableFuture<>(); + + if (i.get() >= 80 && i.get() <= 140) { + errorCount.incrementAndGet(); + future.completeExceptionally(new RuntimeException("BAM!")); + } else { + try { + Thread.sleep((int) (Math.random() * 50)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + successCount.incrementAndGet(); + future.complete(null); + } + return future; + }); + + while (i.incrementAndGet() <= 300) { + PublishEvent pub = new PublishEvent(); + pub.publishedAt = Long.valueOf(i.get()); + CompletableFuture publishFuture = eventHandlerSpied.publish(context, pub); + publishFuture.whenComplete((unused, throwable) -> { + if (throwable != null) { + try { + Thread.sleep((int) (Math.random() * 200)); + } catch (InterruptedException e) {} + } + }); + } + + verify(localCache, atLeast(1)).writePublishEvent(eventArgumentCaptor.capture()); + List cachedEvents = eventArgumentCaptor.getAllValues(); + + System.out.println("Cached Events: " + cachedEvents.size()); + System.out.println("Success: " + successCount.get()); + System.out.println("Error in client.publish: " + errorCount.get()); + System.out.println("Error in open circuit: " + (300 - successCount.get() - errorCount.get())); + + assertEquals(301, successCount.get() + cachedEvents.size()); + assertTrue(errorCount.get() > 0); + + verify(localCache, atLeast(1)).retrievePublishEvents(); + + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/TestUtils.java b/core-api/src/test/java/com/absmartly/sdk/TestUtils.java index 0244c4e..460de88 100644 --- a/core-api/src/test/java/com/absmartly/sdk/TestUtils.java +++ b/core-api/src/test/java/com/absmartly/sdk/TestUtils.java @@ -10,7 +10,7 @@ public abstract class TestUtils { public static byte[] getResourceBytes(String resourceName) { - final ClassLoader classLoader = ContextDataDeserializer.class.getClassLoader(); + final ClassLoader classLoader = ContextDataSerializer.class.getClassLoader(); final File resource = new File(Objects.requireNonNull(classLoader.getResource(resourceName)).getFile()); try (final FileInputStream inputStream = new FileInputStream(resource.getAbsolutePath())) { final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); @@ -34,7 +34,7 @@ public static Map mapOf() { } public static Map mapOf(K k1, V v1) { - final HashMap hashMap = new HashMap<>(1); + final var hashMap = new HashMap(1); hashMap.put(k1, v1); return hashMap; } diff --git a/core-api/src/test/java/com/absmartly/sdk/cache/MemoryCacheTest.java b/core-api/src/test/java/com/absmartly/sdk/cache/MemoryCacheTest.java new file mode 100644 index 0000000..110f5e4 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/cache/MemoryCacheTest.java @@ -0,0 +1,47 @@ +package com.absmartly.sdk.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.absmartly.sdk.TestUtils; +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.Exposure; +import com.absmartly.sdk.json.PublishEvent; +import com.absmartly.sdk.json.Unit; + +public class MemoryCacheTest extends TestUtils { + @Test + void parseDoesNotThrow() { + MemoryCache cache = new MemoryCache(); + + Clock clock = Clock.fixed(1620000000000L); + final Unit[] publishUnits = new Unit[]{ + new Unit("user_id", "JfnnlDI7RTiF9RgfG2JNCw"), + new Unit("session_id", "pAE3a1i5Drs5mKRNq56adA"), + new Unit("email", "IuqYkNRfEx5yClel4j3NbA") + }; + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + expected.exposures = new Exposure[]{ + new Exposure(1, "exp_test_ab", "session_id", 1, clock.millis(), true, true, false, false, false, false), + }; + + cache.writePublishEvent(expected); + + List events = cache.retrievePublishEvents(); + + assertEquals(1, events.size()); + + assertEquals(expected, events.get(0)); + + List eventsInCache = cache.retrievePublishEvents(); + + assertEquals(0, eventsInCache.size()); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/cache/SqliteCacheTest.java b/core-api/src/test/java/com/absmartly/sdk/cache/SqliteCacheTest.java new file mode 100644 index 0000000..7a4a9fe --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/cache/SqliteCacheTest.java @@ -0,0 +1,45 @@ +package com.absmartly.sdk.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.absmartly.sdk.*; +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.Exposure; +import com.absmartly.sdk.json.PublishEvent; +import com.absmartly.sdk.json.Unit; + +public class SqliteCacheTest extends TestUtils { + @Test + void parseDoesNotThrow() { + SqliteCache cache = new SqliteCache( + new DefaultContextDataSerializer(), + new DefaultContextEventSerializer()); + + Clock clock = Clock.fixed(1620000000000L); + final Unit[] publishUnits = new Unit[]{ + new Unit("user_id", "JfnnlDI7RTiF9RgfG2JNCw"), + new Unit("session_id", "pAE3a1i5Drs5mKRNq56adA"), + new Unit("email", "IuqYkNRfEx5yClel4j3NbA") + }; + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + expected.exposures = new Exposure[]{ + new Exposure(1, "exp_test_ab", "session_id", 1, clock.millis(), true, true, false, false, false, false), + }; + + cache.writePublishEvent(expected); + + List events = cache.retrievePublishEvents(); + + assertEquals(1, events.size()); + + assertEquals(expected, events.get(0)); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/circuitbreaker/CircuitBreakerTest.java b/core-api/src/test/java/com/absmartly/sdk/circuitbreaker/CircuitBreakerTest.java new file mode 100644 index 0000000..43c47ad --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/circuitbreaker/CircuitBreakerTest.java @@ -0,0 +1,171 @@ +package com.absmartly.sdk.circuitbreaker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicInteger; +import java8.util.concurrent.CompletableFuture; +import java8.util.concurrent.CompletionStage; +import java8.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import com.geckotechnology.simpleCircuitBreaker.CircuitBreaker; +import com.geckotechnology.simpleCircuitBreaker.CircuitBreakerConfig; + +import com.absmartly.sdk.ResilienceConfig; +import com.absmartly.sdk.TestUtils; +import com.absmartly.sdk.cache.MemoryCache; + +public class CircuitBreakerTest extends TestUtils { + @Test + public void testSimpleCircuitBreakerLibrary() { + + CircuitBreakerConfig config = new CircuitBreakerConfig(); + config.setFailureRateThreshold(20); + config.setSlowCallDurationThreshold(10000); + config.setWaitDurationInOpenState(2000); + config.setMaxDurationOpenInHalfOpenState(1100); + + final AtomicInteger i = new AtomicInteger(0); + final AtomicInteger errorCount = new AtomicInteger(0); + final AtomicInteger recoveryCount = new AtomicInteger(0); + final AtomicInteger successCount = new AtomicInteger(0); + + CircuitBreaker circuitBreaker = new CircuitBreaker( + config); + + circuitBreaker.getBreakerStateEventManager() + .addBreakerStateEventListener(event -> System.out.println("CircuitBreaker state changed. " + event)); + + Supplier> supplier = () -> { + CompletableFuture future = new CompletableFuture<>(); + try { + Thread.sleep((int) (Math.random() * 200)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + if (i.get() >= 80 && i.get() <= 140) { + errorCount.incrementAndGet(); + future.completeExceptionally(new RuntimeException("BAM!")); + } else { + successCount.incrementAndGet(); + future.complete(null); + } + + return future; + }; + + Supplier> decoratedSupplier = decorateCompletionStage( + circuitBreaker, supplier); + + while (i.incrementAndGet() <= 300) { + CompletionStage result = decoratedSupplier + .get() + .whenComplete((unused, throwable) -> { + if (throwable != null) { + try { + Thread.sleep((int) (Math.random() * 100)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + recoveryCount.incrementAndGet(); + } + }); + } + + assertEquals(300, successCount.get() + recoveryCount.get()); + assertTrue(errorCount.get() > 0); + + } + + @Test + public void testSimpleCircuitBreakerWrappper() { + final AtomicInteger i = new AtomicInteger(0); + final AtomicInteger errorCount = new AtomicInteger(0); + final AtomicInteger recoveryCount = new AtomicInteger(0); + final AtomicInteger successCount = new AtomicInteger(0); + + CircuitBreakerHelper circuitBreakerHelper = new CircuitBreakerHelper(ResilienceConfig.create(new MemoryCache()) + .setBackoffPeriodInMilliseconds(2000) + .setFailureRateThreshold(20), event -> {}); + + Supplier> supplier = (Supplier) () -> { + CompletableFuture future = new CompletableFuture<>(); + try { + Thread.sleep((int) (Math.random() * 200)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + if (i.get() >= 80 && i.get() <= 140) { + errorCount.incrementAndGet(); + future.completeExceptionally(new RuntimeException("BAM!")); + } else { + successCount.incrementAndGet(); + future.complete(null); + } + return future; + }; + + Supplier> decoratedSupplier = circuitBreakerHelper.decorateCompletionStage(supplier); + + while (i.incrementAndGet() <= 300) { + CompletionStage result = decoratedSupplier + .get() + .whenComplete((unused, throwable) -> { + if (throwable != null) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + recoveryCount.incrementAndGet(); + } + }); + } + + assertEquals(300, successCount.get() + recoveryCount.get()); + assertTrue(errorCount.get() > 0); + + } + + private static Supplier> decorateCompletionStage( + CircuitBreaker circuitBreaker, + Supplier> supplier) { + return () -> { + + final CompletableFuture promise = new CompletableFuture<>(); + + if (!circuitBreaker.isClosedForThisCall()) { + //Call settimeout + try { + Thread.sleep((int) (Math.random() * 200)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + promise.completeExceptionally(new RuntimeException("Circuit Breaker is opened.")); + } else { + final long start = System.currentTimeMillis(); + try { + supplier.get().whenComplete((result, throwable) -> { + long duration = System.currentTimeMillis() - start; + if (throwable != null) { + if (throwable instanceof Exception) { + circuitBreaker.callFailed(duration); + } + promise.completeExceptionally(throwable); + } else { + circuitBreaker.callSucceeded(duration); + } + }); + } catch (Exception exception) { + long duration = System.currentTimeMillis() - start; + circuitBreaker.callFailed(duration); + } + } + + return promise; + }; + } + +} diff --git a/example/build.gradle b/example/build.gradle index 097b54d..1271556 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -8,7 +8,6 @@ description = """ABSmartly Java SDK Example""" dependencies { - implementation group: "com.absmartly.sdk", name: "core-api", version: "1.1.1" - - //implementation project(":core-api") + implementation project(":core-api") + implementation files('../libs/simpleCircuitBreaker-2.0.5.jar') } diff --git a/example/src/main/java/com/absmartly/sdk/ExampleResilience.java b/example/src/main/java/com/absmartly/sdk/ExampleResilience.java new file mode 100644 index 0000000..d090b47 --- /dev/null +++ b/example/src/main/java/com/absmartly/sdk/ExampleResilience.java @@ -0,0 +1,54 @@ +package com.absmartly.sdk; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import com.absmartly.sdk.cache.LocalCache; +import com.absmartly.sdk.cache.SqliteCache; + +public class ExampleResilience { + + static public void main(String[] args) throws IOException { + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint("https://acme.absmartly.io/v1") + .setAPIKey(System.getenv("ABSMARTLY_APIKEY")) + .setApplication(System.getenv("ABSMARTLY_APP")) + .setEnvironment(System.getenv("ABSMARTLY_ENV")); + + final LocalCache localCache = new SqliteCache( + new DefaultContextDataSerializer(), + new DefaultContextEventSerializer()); + + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() + .setClient(Client.create(clientConfig)) + .setResilienceConfig(ResilienceConfig.create(localCache)); + + final ABSmartly sdk = ABSmartly.create(sdkConfig); + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("user_id", Long.toString(123456)); + + final Context ctx = sdk.createContext(contextConfig).waitUntilReady(); + + final int treatment = ctx.getTreatment("exp_test_ab"); + System.out.println(treatment); + + final Map properties = new HashMap(); + properties.put("value", 125); + properties.put("fee", 125); + + for (int i = 0; i < 20000; i++) { + try { + double randomDouble = Math.random(); + Thread.sleep((int) (randomDouble * 250)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + ctx.track("payment", properties); + } + + ctx.close(); + sdk.close(); + } +} diff --git a/gradle/findbugs.gradle b/gradle/findbugs.gradle index 2d5cac8..8f51ad7 100644 --- a/gradle/findbugs.gradle +++ b/gradle/findbugs.gradle @@ -3,6 +3,7 @@ apply plugin: "findbugs" findbugs { toolVersion = "3.0.1" effort = "max" + findbugsTest.enabled = false } project.tasks.withType(FindBugs) { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c..ccebba7 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew index 4f906e0..79a61d4 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# 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. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# 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/HEAD/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 -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +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 -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # 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" +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 - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +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 @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + 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 @@ -106,80 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -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" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +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 - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + 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 - i=`expr $i + 1` + # 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 - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# 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 \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# 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. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +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/gradlew.bat b/gradlew.bat index ac1b06f..6689b85 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 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 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal