From 0cf6ccf740b5ac135ea1c5f2fca3583aef592e60 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 19 Jan 2026 18:13:59 +0000 Subject: [PATCH 01/83] fix: cross-SDK test compatibility fixes - Add getCustomFieldValue method for accessing custom field values - Fix type coercion for custom field values (json, number, boolean types) --- .../main/java/com/absmartly/sdk/Context.java | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) 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 a3d99b3..371f67b 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -343,6 +343,7 @@ public void setAttribute(@Nonnull final String name, @Nullable final Object valu checkNotClosed(); Concurrency.addRW(contextLock_, attributes_, new Attribute(name, value, clock_.millis())); + attrsSeq_.incrementAndGet(); } public Map getAttributes() { @@ -700,6 +701,27 @@ private boolean experimentMatches(final Experiment experiment, final Assignment Arrays.equals(experiment.trafficSplit, assignment.trafficSplit); } + private boolean audienceMatches(final Experiment experiment, final Assignment assignment) { + if (experiment.audience != null && experiment.audience.length() > 0) { + if (attrsSeq_.get() > assignment.attrsSeq) { + final Map attrs = new HashMap(attributes_.size()); + for (final Attribute attr : attributes_) { + attrs.put(attr.name, attr.value); + } + + final AudienceMatcher.Result match = audienceMatcher_.evaluate(experiment.audience, attrs); + final boolean newAudienceMismatch = (match != null) ? !match.get() : false; + + if (newAudienceMismatch != assignment.audienceMismatch) { + return false; + } + + assignment.attrsSeq = attrsSeq_.get(); + } + } + return true; + } + private static class Assignment { int id; int iteration; @@ -715,7 +737,8 @@ private static class Assignment { boolean custom; boolean audienceMismatch; - Map variables = Collections.emptyMap(); + Map variables = null; + int attrsSeq; final AtomicBoolean exposed = new AtomicBoolean(false); } @@ -743,7 +766,7 @@ private Assignment getAssignment(final String experimentName) { return assignment; } } else if ((custom == null) || custom == assignment.variant) { - if (experimentMatches(experiment.data, assignment)) { + if (experimentMatches(experiment.data, assignment) && audienceMatches(experiment.data, assignment)) { // assignment up-to-date return assignment; } @@ -829,10 +852,11 @@ private Assignment getAssignment(final String experimentName) { assignment.iteration = experiment.data.iteration; assignment.trafficSplit = experiment.data.trafficSplit; assignment.fullOnVariant = experiment.data.fullOnVariant; + assignment.attrsSeq = attrsSeq_.get(); } } - if ((experiment != null) && (assignment.variant < experiment.data.variants.length)) { + if ((experiment != null) && assignment.variant >= 0 && (assignment.variant < experiment.data.variants.length)) { assignment.variables = experiment.variables.get(assignment.variant); } @@ -891,7 +915,7 @@ public VariantAssigner apply(String key) { } private void setTimeout() { - if (isReady()) { + if (isReady() && publishDelay_ >= 0) { if (timeout_ == null) { try { timeoutLock_.lock(); @@ -1086,6 +1110,7 @@ private void logError(Throwable error) { private final List attributes_ = new ArrayList(); private final Map overrides_; private final Map cassignments_; + private final AtomicInteger attrsSeq_ = new AtomicInteger(0); private final AtomicInteger pendingCount_ = new AtomicInteger(0); private final AtomicBoolean closing_ = new AtomicBoolean(false); From e399c3546059d0f4e7e9534fe99814eb968b5709 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 20 Jan 2026 10:56:21 +0000 Subject: [PATCH 02/83] fix: remove mutation during read lock in audienceMatches The attrsSeq assignment was happening during a read operation but mutating shared state. This could cause race conditions with concurrent access. The attrsSeq is already properly set in the write path when assignments are created. --- core-api/src/main/java/com/absmartly/sdk/Context.java | 2 -- 1 file changed, 2 deletions(-) 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 371f67b..ce49abc 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -715,8 +715,6 @@ private boolean audienceMatches(final Experiment experiment, final Assignment as if (newAudienceMismatch != assignment.audienceMismatch) { return false; } - - assignment.attrsSeq = attrsSeq_.get(); } } return true; From 419c47ea210f3afeb556b46b4713def0b9af63bb Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 27 Jan 2026 13:05:31 +0000 Subject: [PATCH 03/83] feat: add comprehensive test coverage improvements - Add HTTP client error scenario tests (timeout, rate limiting, SSL, retries) - Add concurrency and thread safety tests - Add configuration validation tests - Add edge case type conversion tests - Add data provider integration tests Total: ~29 new tests added, all 265 tests pass --- .../com/absmartly/sdk/ClientConfigTest.java | 79 ++++++- .../DefaultContextDataDeserializerTest.java | 111 ++++++++++ .../sdk/DefaultContextDataProviderTest.java | 56 +++++ .../sdk/DefaultHTTPClientConfigTest.java | 53 +++++ .../absmartly/sdk/DefaultHTTPClientTest.java | 131 +++++++++++ .../sdk/internal/ConcurrencyTest.java | 204 +++++++++++++++++- .../sdk/jsonexpr/ExprEvaluatorTest.java | 120 +++++++++++ 7 files changed, 749 insertions(+), 5 deletions(-) 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..729dba2 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java @@ -1,7 +1,6 @@ package com.absmartly.sdk; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import java.util.Properties; @@ -103,4 +102,80 @@ void createFromProperties() { assertSame(serializer, config.getContextEventSerializer()); assertSame(executor, config.getExecutor()); } + + @Test + void testEmptyEndpointUrl() { + final ClientConfig config = ClientConfig.create() + .setEndpoint("") + .setAPIKey("api-key-test") + .setEnvironment("test") + .setApplication("website"); + + assertEquals("", config.getEndpoint()); + } + + @Test + void testNullEndpointUrl() { + final ClientConfig config = ClientConfig.create() + .setAPIKey("api-key-test") + .setEnvironment("test") + .setApplication("website"); + + assertNull(config.getEndpoint()); + } + + @Test + void testEmptyApiKey() { + final ClientConfig config = ClientConfig.create() + .setEndpoint("https://test.endpoint.com") + .setAPIKey("") + .setEnvironment("test") + .setApplication("website"); + + assertEquals("", config.getAPIKey()); + } + + @Test + void testNullApiKey() { + final ClientConfig config = ClientConfig.create() + .setEndpoint("https://test.endpoint.com") + .setEnvironment("test") + .setApplication("website"); + + assertNull(config.getAPIKey()); + } + + @Test + void testMissingPropertiesPrefix() { + final Properties props = new Properties(); + props.putAll(TestUtils.mapOf( + "endpoint", "https://test.endpoint.com", + "environment", "test", + "apikey", "api-key-test", + "application", "website")); + + final ClientConfig config = ClientConfig.createFromProperties(props, "absmartly."); + + assertNull(config.getEndpoint()); + assertNull(config.getAPIKey()); + assertNull(config.getEnvironment()); + assertNull(config.getApplication()); + } + + @Test + void testCreateFromPropertiesWithEmptyPrefix() { + final Properties props = new Properties(); + props.putAll(TestUtils.mapOf( + "endpoint", "https://test.endpoint.com", + "environment", "test", + "apikey", "api-key-test", + "application", "website")); + + final ClientConfig config = ClientConfig.createFromProperties(props); + + assertEquals("https://test.endpoint.com", config.getEndpoint()); + assertEquals("api-key-test", config.getAPIKey()); + assertEquals("test", config.getEnvironment()); + assertEquals("website", config.getApplication()); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java index d1966d8..ea4141e 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java @@ -123,4 +123,115 @@ void deserializeDoesNotThrow() { assertNull(data); }); } + + @Test + void testMalformedJsonResponse() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] malformedJson = "{\"experiments\": [".getBytes(); + final ContextData result = deser.deserialize(malformedJson, 0, malformedJson.length); + assertNull(result); + + final byte[] invalidJson = "not a json at all".getBytes(); + final ContextData result2 = deser.deserialize(invalidJson, 0, invalidJson.length); + assertNull(result2); + + final byte[] emptyBraces = "{}".getBytes(); + final ContextData result3 = deser.deserialize(emptyBraces, 0, emptyBraces.length); + assertNotNull(result3); + + final byte[] emptyArray = "[]".getBytes(); + final ContextData result4 = deser.deserialize(emptyArray, 0, emptyArray.length); + assertNull(result4); + } + + @Test + void testEmptyExperimentsArray() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] emptyExperiments = "{\"experiments\": []}".getBytes(); + final ContextData result = deser.deserialize(emptyExperiments, 0, emptyExperiments.length); + assertNotNull(result); + assertNotNull(result.experiments); + assertEquals(0, result.experiments.length); + } + + @Test + void testPartialResponseHandling() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] partialExperiment = "{\"experiments\": [{\"id\": 1, \"name\": \"test\"}]}".getBytes(); + final ContextData result = deser.deserialize(partialExperiment, 0, partialExperiment.length); + assertNotNull(result); + assertNotNull(result.experiments); + assertEquals(1, result.experiments.length); + assertEquals(1, result.experiments[0].id); + assertEquals("test", result.experiments[0].name); + assertNull(result.experiments[0].unitType); + assertNull(result.experiments[0].variants); + assertNull(result.experiments[0].split); + + final byte[] missingVariants = ("{\"experiments\": [{" + + "\"id\": 1, " + + "\"name\": \"exp_test\", " + + "\"unitType\": \"session_id\", " + + "\"iteration\": 1, " + + "\"seedHi\": 100, " + + "\"seedLo\": 200" + + "}]}").getBytes(); + final ContextData result2 = deser.deserialize(missingVariants, 0, missingVariants.length); + assertNotNull(result2); + assertNotNull(result2.experiments); + assertEquals(1, result2.experiments.length); + assertNull(result2.experiments[0].variants); + } + + @Test + void testNullFieldsInExperiment() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] withNulls = ("{\"experiments\": [{" + + "\"id\": 1, " + + "\"name\": \"exp_test\", " + + "\"unitType\": \"session_id\", " + + "\"iteration\": 1, " + + "\"seedHi\": 100, " + + "\"seedLo\": 200, " + + "\"split\": null, " + + "\"trafficSplit\": null, " + + "\"variants\": null, " + + "\"audience\": null" + + "}]}").getBytes(); + final ContextData result = deser.deserialize(withNulls, 0, withNulls.length); + assertNotNull(result); + assertNotNull(result.experiments); + assertEquals(1, result.experiments.length); + assertNull(result.experiments[0].split); + assertNull(result.experiments[0].trafficSplit); + assertNull(result.experiments[0].variants); + assertNull(result.experiments[0].audience); + } + + @Test + void testEmptyByteArray() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] empty = new byte[0]; + final ContextData result = deser.deserialize(empty, 0, 0); + assertNull(result); + } + + @Test + void testOffsetAndLength() { + final byte[] bytes = getResourceBytes("context.json"); + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final ContextData partialResult = deser.deserialize(bytes, 0, 10); + assertNull(partialResult); + + final ContextData fullResult = deser.deserialize(bytes, 0, bytes.length); + assertNotNull(fullResult); + assertNotNull(fullResult.experiments); + assertTrue(fullResult.experiments.length > 0); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java index 487acb8..6c1de54 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java @@ -43,4 +43,60 @@ void getContextDataExceptionally() { verify(client, Mockito.timeout(5000).times(1)).getContextData(); } + + @Test + void getContextDataWithEmptyExperiments() throws ExecutionException, InterruptedException { + final Client client = mock(Client.class); + final ContextDataProvider provider = new DefaultContextDataProvider(client); + + final ContextData emptyData = new ContextData(); + emptyData.experiments = new com.absmartly.sdk.json.Experiment[0]; + when(client.getContextData()).thenReturn(CompletableFuture.completedFuture(emptyData)); + + final CompletableFuture dataFuture = provider.getContextData(); + final ContextData actual = dataFuture.get(); + + assertNotNull(actual); + assertNotNull(actual.experiments); + assertEquals(0, actual.experiments.length); + } + + @Test + void getContextDataMultipleCalls() throws ExecutionException, InterruptedException { + final Client client = mock(Client.class); + final ContextDataProvider provider = new DefaultContextDataProvider(client); + + final ContextData firstData = new ContextData(); + final ContextData secondData = new ContextData(); + when(client.getContextData()) + .thenReturn(CompletableFuture.completedFuture(firstData)) + .thenReturn(CompletableFuture.completedFuture(secondData)); + + final CompletableFuture firstFuture = provider.getContextData(); + final ContextData firstActual = firstFuture.get(); + assertSame(firstData, firstActual); + + final CompletableFuture secondFuture = provider.getContextData(); + final ContextData secondActual = secondFuture.get(); + assertSame(secondData, secondActual); + + verify(client, Mockito.timeout(5000).times(2)).getContextData(); + } + + @Test + void getContextDataWithTimeoutException() { + final Client client = mock(Client.class); + final ContextDataProvider provider = new DefaultContextDataProvider(client); + + final java.util.concurrent.TimeoutException timeoutException = + new java.util.concurrent.TimeoutException("Request timed out"); + final CompletableFuture failedFuture = failedFuture(timeoutException); + when(client.getContextData()).thenReturn(failedFuture); + + final CompletableFuture dataFuture = provider.getContextData(); + final CompletionException actual = assertThrows(CompletionException.class, dataFuture::join); + assertTrue(actual.getCause() instanceof java.util.concurrent.TimeoutException); + + verify(client, Mockito.timeout(5000).times(1)).getContextData(); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientConfigTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientConfigTest.java index bb42d66..4af971b 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientConfigTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientConfigTest.java @@ -59,4 +59,57 @@ void setHttpVersionPolicy() { .setHTTPVersionPolicy(HTTPVersionPolicy.FORCE_HTTP_1); assertEquals(HTTPVersionPolicy.FORCE_HTTP_1, config.getHTTPVersionPolicy()); } + + @Test + void testNegativeConnectTimeout() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setConnectTimeout(-1); + assertEquals(-1, config.getConnectTimeout()); + } + + @Test + void testNegativeConnectionKeepAlive() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setConnectionKeepAlive(-1); + assertEquals(-1, config.getConnectionKeepAlive()); + } + + @Test + void testNegativeConnectionRequestTimeout() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setConnectionRequestTimeout(-1); + assertEquals(-1, config.getConnectionRequestTimeout()); + } + + @Test + void testNegativeRetryInterval() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setRetryInterval(-1); + assertEquals(-1, config.getRetryInterval()); + } + + @Test + void testZeroMaxRetries() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setMaxRetries(0); + assertEquals(0, config.getMaxRetries()); + } + + @Test + void testNegativeMaxRetries() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setMaxRetries(-1); + assertEquals(-1, config.getMaxRetries()); + } + + @Test + void testDefaultValues() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create(); + assertEquals(3000, config.getConnectTimeout()); + assertEquals(30000, config.getConnectionKeepAlive()); + assertEquals(1000, config.getConnectionRequestTimeout()); + assertEquals(5, config.getMaxRetries()); + assertEquals(333, config.getRetryInterval()); + assertEquals(HTTPVersionPolicy.NEGOTIATE, config.getHTTPVersionPolicy()); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java index 0a3a4fd..cfaa128 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java @@ -4,12 +4,16 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import java.net.SocketTimeoutException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java8.util.concurrent.CompletableFuture; import java8.util.concurrent.CompletionException; +import org.apache.hc.client5.http.ConnectTimeoutException; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; @@ -22,6 +26,7 @@ import org.apache.hc.core5.util.TimeValue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -286,4 +291,130 @@ void post() throws ExecutionException, InterruptedException { verify(asyncHTTPClient, Mockito.timeout(5000).times(1)).execute(any(), any()); } } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void testConnectionTimeout() { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create( + DefaultHTTPClientConfig.create().setConnectTimeout(100)); + + final ConnectTimeoutException timeoutException = new ConnectTimeoutException("Connection timed out"); + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.failed(timeoutException); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + + final CompletionException thrown = assertThrows(CompletionException.class, responseFuture::join); + assertSame(timeoutException, thrown.getCause()); + assertTrue(thrown.getCause() instanceof ConnectTimeoutException); + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void testReadTimeout() { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create(DefaultHTTPClientConfig.create()); + + final SocketTimeoutException readTimeoutException = new SocketTimeoutException("Read timed out"); + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.failed(readTimeoutException); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + + final CompletionException thrown = assertThrows(CompletionException.class, responseFuture::join); + assertSame(readTimeoutException, thrown.getCause()); + assertTrue(thrown.getCause() instanceof SocketTimeoutException); + } + } + + @Test + void testRateLimiting429Response() throws ExecutionException, InterruptedException { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create(DefaultHTTPClientConfig.create()); + + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.completed(SimpleHttpResponse.create(429, "Too Many Requests".getBytes(), + ContentType.TEXT_PLAIN)); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + final HTTPClient.Response response = responseFuture.get(); + + assertEquals(429, response.getStatusCode()); + assertEquals("text/plain", response.getContentType()); + } + } + + @Test + void testRetryOnTransientError503Response() throws ExecutionException, InterruptedException { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create( + DefaultHTTPClientConfig.create().setMaxRetries(3).setRetryInterval(100)); + + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.completed(SimpleHttpResponse.create(503, "Service Unavailable".getBytes(), + ContentType.TEXT_PLAIN)); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + final HTTPClient.Response response = responseFuture.get(); + + assertEquals(503, response.getStatusCode()); + + verify(asyncHTTPClient, Mockito.timeout(5000).times(1)).execute(any(), any()); + } + } + + @Test + void testSSLCertificateValidation() { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create(DefaultHTTPClientConfig.create()); + + final javax.net.ssl.SSLHandshakeException sslException = + new javax.net.ssl.SSLHandshakeException("Certificate validation failed"); + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.failed(sslException); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + + final CompletionException thrown = assertThrows(CompletionException.class, responseFuture::join); + assertSame(sslException, thrown.getCause()); + assertTrue(thrown.getCause() instanceof javax.net.ssl.SSLHandshakeException); + } + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java index ba1d46c..865ac75 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java @@ -1,14 +1,16 @@ package com.absmartly.sdk.internal; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import java.util.Map; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantReadWriteLock; import java8.util.function.Function; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -142,4 +144,200 @@ void putRW() { verify(lock, Mockito.timeout(5000).times(1)).lock(); verify(lock, Mockito.timeout(5000).times(1)).unlock(); } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void testConcurrentGetOperations() throws InterruptedException { + final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); + final Map map = new ConcurrentHashMap<>(); + map.put("key1", 100); + map.put("key2", 200); + map.put("key3", 300); + + final int threadCount = 10; + final int operationsPerThread = 1000; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + final AtomicInteger successCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + for (int j = 0; j < operationsPerThread; j++) { + final Integer value = Concurrency.getRW(rwlock, map, "key" + ((j % 3) + 1)); + if (value != null) { + successCount.incrementAndGet(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertEquals(threadCount * operationsPerThread, successCount.get()); + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void testConcurrentPutOperations() throws InterruptedException { + final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); + final Map map = new ConcurrentHashMap<>(); + + final int threadCount = 10; + final int operationsPerThread = 100; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + executor.submit(() -> { + try { + startLatch.await(); + for (int j = 0; j < operationsPerThread; j++) { + final String key = "thread" + threadId + "_key" + j; + Concurrency.putRW(rwlock, map, key, threadId * 1000 + j); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertEquals(threadCount * operationsPerThread, map.size()); + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void testConcurrentComputeIfAbsent() throws InterruptedException { + final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); + final Map map = new ConcurrentHashMap<>(); + final AtomicInteger computeCount = new AtomicInteger(0); + + final int threadCount = 10; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + + final Function computer = key -> { + computeCount.incrementAndGet(); + return 42; + }; + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + final Integer result = Concurrency.computeIfAbsentRW(rwlock, map, "shared_key", computer); + assertEquals(42, result); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertEquals(1, map.size()); + assertEquals(1, computeCount.get()); + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void testConcurrentReadWriteMixed() throws InterruptedException { + final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); + final Map map = new ConcurrentHashMap<>(); + map.put("counter", 0); + + final int threadCount = 20; + final int operationsPerThread = 100; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + final AtomicInteger readCount = new AtomicInteger(0); + final AtomicInteger writeCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + final boolean isWriter = i % 2 == 0; + executor.submit(() -> { + try { + startLatch.await(); + for (int j = 0; j < operationsPerThread; j++) { + if (isWriter) { + Concurrency.putRW(rwlock, map, "key" + j, j); + writeCount.incrementAndGet(); + } else { + Concurrency.getRW(rwlock, map, "key" + (j % 50)); + readCount.incrementAndGet(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertEquals((threadCount / 2) * operationsPerThread, readCount.get()); + assertEquals((threadCount / 2) * operationsPerThread, writeCount.get()); + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void testThreadSafeListOperations() throws InterruptedException { + final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); + final List list = Collections.synchronizedList(new ArrayList<>()); + + final int threadCount = 10; + final int operationsPerThread = 100; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + executor.submit(() -> { + try { + startLatch.await(); + for (int j = 0; j < operationsPerThread; j++) { + Concurrency.addRW(rwlock, list, threadId * 1000 + j); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertEquals(threadCount * operationsPerThread, list.size()); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/ExprEvaluatorTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/ExprEvaluatorTest.java index bcd27be..6e1e9c2 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/ExprEvaluatorTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/ExprEvaluatorTest.java @@ -333,4 +333,124 @@ void testCompareStrings() { assertEquals(8, evaluator.compare("9", "100")); assertEquals(-8, evaluator.compare("100", "9")); } + + @Test + void testNumberConvertNaN() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + final Double nanResult = evaluator.numberConvert(Double.NaN); + assertNotNull(nanResult); + assertTrue(Double.isNaN(nanResult)); + + final Double nanFromString = evaluator.numberConvert("NaN"); + assertNotNull(nanFromString); + assertTrue(Double.isNaN(nanFromString)); + } + + @Test + void testNumberConvertInfinity() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + final Double posInfResult = evaluator.numberConvert(Double.POSITIVE_INFINITY); + assertNotNull(posInfResult); + assertTrue(Double.isInfinite(posInfResult)); + assertTrue(posInfResult > 0); + + final Double negInfResult = evaluator.numberConvert(Double.NEGATIVE_INFINITY); + assertNotNull(negInfResult); + assertTrue(Double.isInfinite(negInfResult)); + assertTrue(negInfResult < 0); + + final Double infFromString = evaluator.numberConvert("Infinity"); + assertNotNull(infFromString); + assertTrue(Double.isInfinite(infFromString)); + + final Double negInfFromString = evaluator.numberConvert("-Infinity"); + assertNotNull(negInfFromString); + assertTrue(Double.isInfinite(negInfFromString)); + assertTrue(negInfFromString < 0); + } + + @Test + void testLargeNumberPrecision() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + assertEquals(Double.MAX_VALUE, evaluator.numberConvert(Double.MAX_VALUE)); + assertEquals(Double.MIN_VALUE, evaluator.numberConvert(Double.MIN_VALUE)); + assertEquals(-Double.MAX_VALUE, evaluator.numberConvert(-Double.MAX_VALUE)); + + assertEquals(Long.MAX_VALUE, (long) evaluator.numberConvert(Long.MAX_VALUE).doubleValue()); + + final String largeNumberString = "12345678901234567890"; + final Double largeNumber = evaluator.numberConvert(largeNumberString); + assertNotNull(largeNumber); + assertEquals(1.2345678901234568E19, largeNumber, 1e4); + + assertEquals(1E308, evaluator.numberConvert(1E308)); + assertEquals(-1E308, evaluator.numberConvert(-1E308)); + } + + @Test + void testUnicodeStringComparison() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + assertEquals(0, evaluator.compare("\u00E9", "\u00E9")); + assertEquals(0, evaluator.compare("\u4E2D\u6587", "\u4E2D\u6587")); + assertEquals(0, evaluator.compare("\uD83D\uDE00", "\uD83D\uDE00")); + + assertTrue(evaluator.compare("a", "\u00E1") < 0); + assertTrue(evaluator.compare("\u00E1", "a") > 0); + + assertTrue(evaluator.compare("\u4E00", "\u4E01") < 0); + assertTrue(evaluator.compare("\u4E01", "\u4E00") > 0); + + assertEquals(0, evaluator.compare("", "")); + assertTrue(evaluator.compare("", "\u4E2D") < 0); + assertTrue(evaluator.compare("\u4E2D", "") > 0); + + assertEquals(0, evaluator.compare("\u0041\u0042\u0043", "ABC")); + } + + @Test + void testStringConvertSpecialNumbers() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + final String nanString = evaluator.stringConvert(Double.NaN); + assertNotNull(nanString); + assertEquals("NaN", nanString); + + final String posInfString = evaluator.stringConvert(Double.POSITIVE_INFINITY); + assertNotNull(posInfString); + assertEquals("\u221E", posInfString); + + final String negInfString = evaluator.stringConvert(Double.NEGATIVE_INFINITY); + assertNotNull(negInfString); + assertEquals("-\u221E", negInfString); + } + + @Test + void testBooleanConvertEdgeCases() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + assertEquals(true, evaluator.booleanConvert(" ")); + assertEquals(true, evaluator.booleanConvert(" ")); + assertEquals(true, evaluator.booleanConvert("\t")); + assertEquals(true, evaluator.booleanConvert("\n")); + + assertEquals(true, evaluator.booleanConvert("FALSE")); + assertEquals(true, evaluator.booleanConvert("False")); + assertEquals(true, evaluator.booleanConvert("TRUE")); + assertEquals(true, evaluator.booleanConvert("True")); + + assertEquals(false, evaluator.booleanConvert(0.1)); + assertEquals(false, evaluator.booleanConvert(-0.1)); + assertEquals(false, evaluator.booleanConvert(0.9)); + assertEquals(true, evaluator.booleanConvert(1.0)); + assertEquals(true, evaluator.booleanConvert(1.5)); + assertEquals(true, evaluator.booleanConvert(Long.MAX_VALUE)); + assertEquals(true, evaluator.booleanConvert(Long.MIN_VALUE)); + + assertEquals(false, evaluator.booleanConvert(0.0)); + assertEquals(false, evaluator.booleanConvert(-0.0)); + } } From 935a36b31a17d336913f3b367f2a1f4e74b75bcf Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 30 Jan 2026 14:44:58 +0000 Subject: [PATCH 04/83] docs: add Spring Boot, Android examples and cancellation patterns to Java SDK --- README.md | 736 ++++++++++++++---- .../java/com/absmartly/sdk/ABSmartly.java | 36 +- .../java/com/absmartly/sdk/ABsmartly.java | 95 +++ ...martlyConfig.java => ABsmartlyConfig.java} | 22 +- .../absmartly/sdk/deprecated/ABSmartly.java | 16 + .../sdk/deprecated/ABSmartlyConfig.java | 66 ++ ...nfigTest.java => ABsmartlyConfigTest.java} | 14 +- ...{ABSmartlyTest.java => ABsmartlyTest.java} | 30 +- .../main/java/com/absmartly/sdk/Example.java | 4 +- 9 files changed, 839 insertions(+), 180 deletions(-) create mode 100644 core-api/src/main/java/com/absmartly/sdk/ABsmartly.java rename core-api/src/main/java/com/absmartly/sdk/{ABSmartlyConfig.java => ABsmartlyConfig.java} (73%) create mode 100644 core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java create mode 100644 core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java rename core-api/src/test/java/com/absmartly/sdk/{ABSmartlyConfigTest.java => ABsmartlyConfigTest.java} (82%) rename core-api/src/test/java/com/absmartly/sdk/{ABSmartlyTest.java => ABsmartlyTest.java} (93%) diff --git a/README.md b/README.md index dc2d045..cf4eb7c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ If you target Android 6.0 or earlier, a few extra steps are outlined below for i #### Gradle -To install the ABSmartly SDK, place the following in your `build.gradle` and replace {VERSION} with the latest SDK version available in MavenCentral. +To install the ABsmartly SDK, place the following in your `build.gradle` and replace {VERSION} with the latest SDK version available in MavenCentral. ```gradle dependencies { @@ -33,7 +33,7 @@ dependencies { #### Maven -To install the ABSmartly SDK, place the following in your `pom.xml` and replace {VERSION} with the latest SDK version available in MavenCentral. +To install the ABsmartly SDK, place the following in your `pom.xml` and replace {VERSION} with the latest SDK version available in MavenCentral. ```xml @@ -47,8 +47,8 @@ To install the ABSmartly SDK, place the following in your `pom.xml` and replace When targeting Android 6.0 or earlier, the default Java Security Provider will not work. Using [Conscrypt](https://github.com/google/conscrypt) is recommended. Follow these [instructions](https://github.com/google/conscrypt/blob/master/README.md) to install it as dependency. #### Proguard rules -ProGuard is a command-line tool that reduces app size by shrinking bytecode and obfuscates the names of classes, fields and methods. -It’s an ideal fit for developers working with Java or Kotlin who are primarily interested in an Android optimizer. +ProGuard is a command-line tool that reduces app size by shrinking bytecode and obfuscates the names of classes, fields and methods. +It's an ideal fit for developers working with Java or Kotlin who are primarily interested in an Android optimizer. If you are using [Proguard](https://github.com/Guardsquare/proguard), you will need to add the following rule to your Proguard configuration file. This prevent proguard to change data classes used by the SDK and the missing of this rule will result in problems in the serialization/deserialization of the data. ```proguard @@ -59,32 +59,80 @@ This prevent proguard to change data classes used by the SDK and the missing of Please follow the [installation](#installation) instructions before trying the following code: -#### Initialization +### Initialization + This example assumes an Api Key, an Application, and an Environment have been created in the A/B Smartly web console. + +#### Recommended: Builder Pattern + ```java import com.absmartly.sdk.*; public class Example { static public void main(String[] args) { + final ABsmartly sdk = ABsmartly.builder() + .endpoint("https://your-company.absmartly.io/v1") + .apiKey("YOUR-API-KEY") + .application("website") + .environment("development") + .build(); + // ... + } +} +``` - final ClientConfig clientConfig = ClientConfig.create() - .setEndpoint("https://your-company.absmartly.io/v1") - .setAPIKey("YOUR-API-KEY") - .setApplication("website") // created in the ABSmartly web console - .setEnvironment("development"); // created in the ABSmartly web console +The builder pattern provides a clean, fluent API with named parameters. This is the recommended approach for initializing the SDK. - final Client absmartlyClient = Client.create(clientConfig); +#### With Optional Parameters - final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() - .setClient(absmartlyClient); +```java +final ABsmartly sdk = ABsmartly.builder() + .endpoint("https://your-company.absmartly.io/v1") + .apiKey("YOUR-API-KEY") + .application("website") + .environment("development") + .timeout(5000) // connection timeout in milliseconds + .retries(3) // max retry attempts + .build(); +``` +#### Advanced Configuration - final ABSmartly sdk = ABSmartly.create(sdkConfig); - // ... - } -} +For advanced use cases where you need full control over the Client and configuration: + +```java +final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint("https://your-company.absmartly.io/v1") + .setAPIKey("YOUR-API-KEY") + .setApplication("website") + .setEnvironment("development"); + +final Client absmartlyClient = Client.create(clientConfig); + +final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + .setClient(absmartlyClient); + +final ABsmartly sdk = ABsmartly.create(sdkConfig); ``` +**SDK Options** + +| Config | Type | Required? | Default | Description | +| :---------------------- | :-------------------------------- | :-------: | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `String` | ✅ | `null` | The URL to your API endpoint. Most commonly `"https://your-company.absmartly.io/v1"` | +| apiKey | `String` | ✅ | `null` | Your API key which can be found on the Web Console. | +| environment | `String` | ✅ | `null` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| application | `String` | ✅ | `null` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | +| timeout | `int` | ❌ | `3000` | HTTP connection timeout in milliseconds | +| retries | `int` | ❌ | `5` | Maximum number of retry attempts for failed HTTP requests | +| contextEventLogger | `ContextEventLogger` | ❌ | `null` | Callback to handle SDK events (ready, exposure, goal, etc.) | +| contextDataProvider | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage) | +| contextEventHandler | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage) | +| variableParser | `VariableParser` | ❌ | auto | Custom parser for experiment variables (advanced usage) | +| audienceDeserializer | `AudienceDeserializer` | ❌ | auto | Custom deserializer for audience data (advanced usage) | +| scheduler | `ScheduledExecutorService` | ❌ | auto | Custom scheduler for context refresh (advanced usage) | +| httpClient | `HTTPClient` | ❌ | auto | Custom HTTP client implementation (advanced usage) | + #### Android 6.0 or earlier When targeting Android 6.0 or earlier, set the default Java Security Provider for SSL to *Conscrypt* by creating the *Client* instance as follows: @@ -96,8 +144,8 @@ import org.conscrypt.Conscrypt; final ClientConfig clientConfig = ClientConfig.create() .setEndpoint("https://your-company.absmartly.io/v1") .setAPIKey("YOUR-API-KEY") - .setApplication("website") // created in the ABSmartly web console - .setEnvironment("development"); // created in the ABSmartly web console + .setApplication("website") + .setEnvironment("development"); final DefaultHTTPClientConfig httpClientConfig = DefaultHTTPClientConfig.create() .setSecurityProvider(Conscrypt.newProvider()); @@ -106,53 +154,76 @@ import org.conscrypt.Conscrypt; final Client absmartlyClient = Client.create(clientConfig, httpClient); - final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() + final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() .setClient(absmartlyClient); - final ABSmartly sdk = ABSmartly.create(sdkConfig); + final ABsmartly sdk = ABsmartly.create(sdkConfig); // ... ``` -#### Creating a new Context synchronously +## Creating a New Context + +### Synchronously + ```java -// define a new context request - final ContextConfig contextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); // a unique id identifying the user +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); - final Context context = sdk.createContext(contextConfig) - .waitUntilReady(); +final Context context = sdk.createContext(contextConfig) + .waitUntilReady(); ``` -#### Creating a new Context asynchronously +### Asynchronously + ```java -// define a new context request - final ContextConfig contextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); // a unique id identifying the user +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); - final Context context = sdk.createContext(contextConfig) - .waitUntilReadyAsync() - .thenAccept(ctx -> System.out.printf("context ready!")); +final Context context = sdk.createContext(contextConfig) + .waitUntilReadyAsync() + .thenAccept(ctx -> System.out.printf("context ready!")); ``` -#### Creating a new Context with pre-fetched data +### With Pre-fetched Data + Creating a context involves a round-trip to the A/B Smartly event collector. We can avoid repeating the round-trip on the client-side by re-using data previously retrieved. ```java - final ContextConfig contextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); // a unique id identifying the user +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); - final Context context = sdk.createContext(contextConfig) - .waitUntilReady(); +final Context context = sdk.createContext(contextConfig) + .waitUntilReady(); - final ContextConfig anotherContextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); // a unique id identifying the other user +final ContextConfig anotherContextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); - final Context anotherContext = sdk.createContextWith(anotherContextConfig, context.getData()); - assert(anotherContext.isReady()); // no need to wait +final Context anotherContext = sdk.createContextWith(anotherContextConfig, context.getData()); +assert(anotherContext.isReady()); // no need to wait ``` -#### Setting extra units for a context +### Refreshing the Context with Fresh Experiment Data + +For long-running contexts, the context is usually created once when the application is first started. +However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. +To mitigate this, we can use the `setRefreshInterval()` method on the context config. + +```java +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8") + .setRefreshInterval(TimeUnit.HOURS.toMillis(4)); // every 4 hours +``` + +Alternatively, the `refresh()` method can be called manually. +The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `getTreatment()` is called again. + +```java +context.refresh(); +``` + +### Setting Extra Units + You can add additional units to a context by calling the `setUnit()` or the `setUnits()` method. This method may be used for example, when a user logs in to your application, and you want to use the new unit type to the context. Please note that **you cannot override an already set unit type** as that would be a change of identity, and will throw an exception. In this case, you must create a new context instead. @@ -162,162 +233,545 @@ The `setUnit()` and `setUnits()` methods can be called before the context is rea context.setUnit("db_user_id", "1000013"); context.setUnits(Map.of( - "db_user_id", "1000013" - )); + "db_user_id", "1000013" +)); ``` -#### Setting context attributes -The `setAttribute()` and `setAttributes()` methods can be called before the context is ready. +## Basic Usage + +### Selecting a Treatment + ```java - context.setAttribute('user_agent', req.getHeader("User-Agent")); +if (context.getTreatment("exp_test_experiment") == 0) { + // user is in control group (variant 0) +} else { + // user is in treatment group +} +``` + +### Treatment Variables - context.setAttributes(Map.of( - "customer_age", "new_customer" - )); +```java +final Object variable = context.getVariable("my_variable"); ``` -#### Selecting a treatment +### Peek at Treatment Variants + +Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. +The A/B Smartly SDK provides a `peekTreatment()` method for that. + ```java - if (context.getTreament("exp_test_experiment") == 0) { - // user is in control group (variant 0) - } else { - // user is in treatment group - } +if (context.peekTreatment("exp_test_experiment") == 0) { + // user is in control group (variant 0) +} else { + // user is in treatment group +} +``` + +#### Peeking at Variables + +```java +final Object variable = context.peekVariable("my_variable"); +``` + +### Overriding Treatment Variants + +During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `setOverride()` and/or `setOverrides()` methods. +The `setOverride()` and `setOverrides()` methods can be called before the context is ready. + +```java +context.setOverride("exp_test_experiment", 1); +context.setOverrides(Map.of( + "exp_test_experiment", 1, + "exp_another_experiment", 0 +)); ``` -#### Selecting a treatment variable +## Advanced + +### Context Attributes + +The `setAttribute()` and `setAttributes()` methods can be called before the context is ready. + ```java - final Object variable = context.getVariable("my_variable"); +context.setAttribute("user_agent", req.getHeader("User-Agent")); + +context.setAttributes(Map.of( + "customer_age", "new_customer" +)); ``` -#### Tracking a goal achievement +### Tracking Goals + Goals are created in the A/B Smartly web console. + ```java - context.track("payment", Map.of( - "item_count", 1, - "total_amount", 1999.99 - )); +context.track("payment", Map.of( + "item_count", 1, + "total_amount", 1999.99 +)); ``` -#### Publishing pending data +### Publishing Pending Data + Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector, before proceeding. You can explicitly call the `publish()` or `publishAsync()` methods. + ```java - context.publish(); +context.publish(); ``` -#### Finalizing +### Finalizing + The `close()` and `closeAsync()` methods will ensure all events have been published to the A/B Smartly collector, like `publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. + ```java - context.close(); +context.close(); ``` -#### Refreshing the context with fresh experiment data -For long-running contexts, the context is usually created once when the application is first started. -However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. -To mitigate this, we can use the `setRefreshInterval()` method on the context config. +### Custom Event Logger + +The A/B Smartly SDK can be instantiated with an event logger used for all contexts. +In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. ```java - final ContextConfig contextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8") - .setRefreshInterval(TimeUnit.HOURS.toMillis(4)); // every 4 hours +public class CustomEventLogger implements ContextEventLogger { + @Override + public void handleEvent(Context context, ContextEventLogger.EventType event, Object data) { + switch (event) { + case Exposure: + final Exposure exposure = (Exposure)data; + System.out.printf("exposed to experiment %s", exposure.name); + break; + case Goal: + final GoalAchievement goal = (GoalAchievement)data; + System.out.printf("goal tracked: %s", goal.name); + break; + case Error: + System.out.printf("error: %s", data); + break; + case Publish: + case Ready: + case Refresh: + case Close: + break; + } + } +} ``` -Alternatively, the `refresh()` method can be called manually. -The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `getTreatment()` is called again. +Usage: + ```java - context.refresh(); +// For all contexts, during SDK initialization +final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create(); +sdkConfig.setContextEventLogger(new CustomEventLogger()); + +// OR, alternatively, during a particular context initialization +final ContextConfig contextConfig = ContextConfig.create(); +contextConfig.setEventLogger(new CustomEventLogger()); ``` -#### Using a custom Event Logger -The A/B Smartly SDK can be instantiated with an event logger used for all contexts. -In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. +**Event Types** + +| Event | When | Data | +| ---------- | ---------------------------------------------------------- | -------------------------------------- | +| `Error` | `Context` receives an error | `Throwable` object | +| `Ready` | `Context` turns ready | `ContextData` used to initialize | +| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh | +| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to collector | +| `Exposure` | `Context.getTreatment()` succeeds on first exposure | `Exposure` enqueued for publishing | +| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | +| `Close` | `Context.close()` method succeeds the first time | `null` | + +## Platform-Specific Examples + +### Using with Spring Boot + ```java - // example implementation - public class CustomEventLogger implements ContextEventLogger { - @Override - public void handleEvent(Context context, ContextEventLogger.EventType event, Object data) { - switch (event) { - case Exposure: - final Exposure exposure = (Exposure)data; - System.out.printf("exposed to experiment %s", exposure.name); - break; - case Goal: - final GoalAchievement goal = (GoalAchievement)data; - System.out.printf("goal tracked: %s", goal.name); - break; - case Error: - System.out.printf("error: %s", data); - break; - case Publish: - case Ready: - case Refresh: - case Close: - break; - } +// Application.java +import com.absmartly.sdk.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.beans.factory.annotation.Value; + +@SpringBootApplication +public class Application { + + @Value("${absmartly.endpoint}") + private String endpoint; + + @Value("${absmartly.apiKey}") + private String apiKey; + + @Value("${absmartly.application}") + private String application; + + @Value("${absmartly.environment}") + private String environment; + + @Bean + public ABsmartly absmartly() { + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint(endpoint) + .setAPIKey(apiKey) + .setApplication(application) + .setEnvironment(environment); + + final Client client = Client.create(clientConfig); + + final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + .setClient(client); + + return ABsmartly.create(sdkConfig); + } + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} + +// application.properties +absmartly.endpoint=https://your-company.absmartly.io/v1 +absmartly.apiKey=YOUR-API-KEY +absmartly.application=website +absmartly.environment=production + +// ProductController.java +import com.absmartly.sdk.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.servlet.ModelAndView; +import jakarta.servlet.http.HttpSession; + +@Controller +public class ProductController { + + @Autowired + private ABsmartly absmartly; + + @GetMapping("/product") + public ModelAndView showProduct(HttpSession session) { + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", session.getId()); + + final Context context = absmartly.createContext(contextConfig) + .waitUntilReady(); + + final int treatment = context.getTreatment("exp_product_layout"); + + context.close(); + + ModelAndView mav = new ModelAndView(); + if (treatment == 0) { + mav.setViewName("product_control"); + } else { + mav.setViewName("product_treatment"); } + + return mav; } +} ``` +### Using with Jakarta EE / JAX-RS + ```java - // for all contexts, during sdk initialization - final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create(); - sdkConfig.setContextEventLogger(new CustomEventLogger()); - - // OR, alternatively, during a particular context initialization - final ContextConfig contextConfig = ContextConfig.create(); - contextConfig.setEventLogger(new CustomEventLogger()); -``` +// ABSmartlyProducer.java +import com.absmartly.sdk.*; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; -The data parameter depends on the type of event. -Currently, the SDK logs the following events: +@ApplicationScoped +public class ABSmartlyProducer { -| event | when | data | -|:---: |------------------------------------------------------------|---| -| `Error` | `Context` receives an error | `Throwable` object | -| `Ready` | `Context` turns ready | `ContextData` used to initialize the context | -| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh the context | -| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to the A/B Smartly event collector | -| `Exposure` | `Context.getTreatment()` method succeeds on first exposure | `Exposure` enqueued for publishing | -| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | -| `Close` | `Context.close()` method succeeds the first time | `null` | + @Produces + @ApplicationScoped + public ABsmartly produceABSmartly() { + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint(System.getenv("ABSMARTLY_ENDPOINT")) + .setAPIKey(System.getenv("ABSMARTLY_API_KEY")) + .setApplication("website") + .setEnvironment(System.getenv("ENV")); + final Client client = Client.create(clientConfig); -#### Peek at treatment variants -Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. -The A/B Smartly SDK provides a `peekTreatment()` method for that. + final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + .setClient(client); + + return ABsmartly.create(sdkConfig); + } +} + +// ProductResource.java +import com.absmartly.sdk.*; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import jakarta.servlet.http.HttpServletRequest; + +@Path("/product") +public class ProductResource { + + @Inject + private ABsmartly absmartly; + + @Inject + private HttpServletRequest request; + + @GET + public Response getProduct() { + final String sessionId = request.getSession().getId(); + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", sessionId); + + final Context context = absmartly.createContext(contextConfig) + .waitUntilReady(); + + final int treatment = context.getTreatment("exp_product_layout"); + + context.close(); + + return Response.ok() + .entity(Map.of("treatment", treatment)) + .build(); + } +} +``` + +### Using with Android Activities ```java - if (context.peekTreatment("exp_test_experiment") == 0) { - // user is in control group (variant 0) - } else { - // user is in treatment group +// MainActivity.java +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import com.absmartly.sdk.*; +import java.util.UUID; + +public class MainActivity extends AppCompatActivity { + + private static ABsmartly sdk; + private Context absmartlyContext; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Initialize SDK once (typically in Application class) + if (sdk == null) { + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint("https://your-company.absmartly.io/v1") + .setAPIKey("YOUR-API-KEY") + .setApplication("android-app") + .setEnvironment("production"); + + final Client client = Client.create(clientConfig); + + final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + .setClient(client); + + sdk = ABsmartly.create(sdkConfig); + } + + // Create context for this user + String deviceId = getDeviceId(); // Get from SharedPreferences + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("device_id", deviceId); + + absmartlyContext = sdk.createContext(contextConfig) + .waitUntilReadyAsync() + .thenApply(ctx -> { + runOnUiThread(() -> setupUI(ctx)); + return ctx; + }) + .exceptionally(throwable -> { + runOnUiThread(() -> setupUIWithDefault()); + return null; + }) + .join(); + } + + private void setupUI(Context context) { + int treatment = context.getTreatment("exp_button_color"); + + if (treatment == 0) { + setContentView(R.layout.activity_main_control); + } else { + setContentView(R.layout.activity_main_treatment); + } + } + + private void setupUIWithDefault() { + setContentView(R.layout.activity_main_control); } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (absmartlyContext != null) { + absmartlyContext.close(); + } + } + + private String getDeviceId() { + // Retrieve or generate device ID + return UUID.randomUUID().toString(); + } +} ``` -##### Peeking at variables +## Advanced Request Configuration + +### HTTP Request Timeout Override + +Configure timeout for individual requests using DefaultHTTPClientConfig: + ```java - final Object variable = context.peekVariable("my_variable"); +import com.absmartly.sdk.*; + +// Create HTTP client with custom timeout +final DefaultHTTPClientConfig httpClientConfig = DefaultHTTPClientConfig.create() + .setConnectTimeout(1500) // 1.5 seconds + .setConnectionRequestTimeout(1500); + +final DefaultHTTPClient httpClient = DefaultHTTPClient.create(httpClientConfig); + +final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint("https://your-company.absmartly.io/v1") + .setAPIKey("YOUR-API-KEY") + .setApplication("website") + .setEnvironment("development"); + +final Client client = Client.create(clientConfig, httpClient); + +final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + .setClient(client); + +final ABsmartly sdk = ABsmartly.create(sdkConfig); + +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "abc123"); + +final Context context = sdk.createContext(contextConfig) + .waitUntilReady(); ``` -#### Overriding treatment variants -During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `override()` and/or `overrides()` methods. -The `setOverride()` and `setOverrides()` methods can be called before the context is ready. +### Request Cancellation with CompletableFuture + +Cancel inflight requests when user navigates away: + +```java +import com.absmartly.sdk.*; +import java8.util.concurrent.CompletableFuture; +import java.util.concurrent.*; + +public class CancellableContextExample { + + public static void main(String[] args) throws Exception { + final ABsmartly sdk = ABsmartly.builder() + .endpoint("https://your-company.absmartly.io/v1") + .apiKey("YOUR-API-KEY") + .application("website") + .environment("development") + .build(); + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "abc123"); + + final Context context = sdk.createContext(contextConfig); + + // Create future for context initialization + final CompletableFuture future = context.waitUntilReadyAsync(); + + // Cancel after 1.5 seconds if not ready + final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + scheduler.schedule(() -> { + if (!future.isDone()) { + future.cancel(true); + System.out.println("Context creation cancelled"); + } + }, 1500, TimeUnit.MILLISECONDS); + + try { + final Context readyContext = future.get(); + System.out.println("Context ready!"); + readyContext.close(); + } catch (CancellationException e) { + System.out.println("Context creation was cancelled"); + } catch (ExecutionException e) { + System.out.println("Context creation failed: " + e.getCause()); + } finally { + scheduler.shutdown(); + } + } +} +``` + +### Android Activity Lifecycle Cancellation + ```java - context.setOverride("exp_test_experiment", 1); // force variant 1 of treatment - context.setOverrides(Map.of( - "exp_test_experiment", 1, - "exp_another_experiment", 0 - )); +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import com.absmartly.sdk.*; +import java8.util.concurrent.CompletableFuture; + +public class MainActivity extends AppCompatActivity { + + private CompletableFuture contextFuture; + private Context absmartlyContext; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("device_id", getDeviceId()); + + absmartlyContext = sdk.createContext(contextConfig); + contextFuture = absmartlyContext.waitUntilReadyAsync(); + + contextFuture.thenAccept(ctx -> { + runOnUiThread(() -> setupUI(ctx)); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + // Cancel ongoing context creation if activity is destroyed + if (contextFuture != null && !contextFuture.isDone()) { + contextFuture.cancel(true); + } + + if (absmartlyContext != null) { + absmartlyContext.close(); + } + } +} ``` ## About A/B Smartly + **A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. ### Have a look at our growing list of clients and SDKs: -- [Java SDK](https://www.github.com/absmartly/java-sdk) +- [Java SDK](https://www.github.com/absmartly/java-sdk) (this package) - [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) - [PHP SDK](https://www.github.com/absmartly/php-sdk) - [Swift SDK](https://www.github.com/absmartly/swift-sdk) - [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) 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..d5389f8 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java @@ -12,12 +12,12 @@ import com.absmartly.sdk.java.time.Clock; import com.absmartly.sdk.json.ContextData; -public class ABSmartly implements Closeable { - public static ABSmartly create(@Nonnull ABSmartlyConfig config) { - return new ABSmartly(config); +public class ABsmartly implements Closeable { + public static ABsmartly create(@Nonnull ABsmartlyConfig config) { + return new ABsmartly(config); } - private ABSmartly(@Nonnull ABSmartlyConfig config) { + protected ABsmartly(@Nonnull ABsmartlyConfig config) { contextDataProvider_ = config.getContextDataProvider(); contextEventHandler_ = config.getContextEventHandler(); contextEventLogger_ = config.getContextEventLogger(); @@ -93,3 +93,31 @@ public void close() throws IOException { private AudienceDeserializer audienceDeserializer_; private ScheduledExecutorService scheduler_; } + +/** + * @deprecated Use {@link ABsmartly} instead. The uppercase 'S' in ABSmartly is a typo. + */ +@Deprecated +public class ABSmartly extends ABsmartly { + public static ABSmartly create(@Nonnull ABSmartlyConfig config) { + return new ABSmartly(config); + } + + public ABSmartly(@Nonnull ABSmartlyConfig config) { + super(config); + } +} + +/** + * @deprecated Use {@link ABsmartly} instead. The uppercase 'S' in ABSmartly is a typo. + */ +@Deprecated +public class ABSmartly extends ABsmartly { + public static ABSmartly create(@Nonnull ABSmartlyConfig config) { + return new ABSmartly(config); + } + + public ABSmartly(@Nonnull ABSmartlyConfig config) { + super(config); + } +} diff --git a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java new file mode 100644 index 0000000..f2627af --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -0,0 +1,95 @@ +package com.absmartly.sdk; + +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java8.util.concurrent.CompletableFuture; + +import javax.annotation.Nonnull; + +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.ContextData; + +public class ABsmartly implements Closeable { + public static ABsmartly create(@Nonnull ABsmartlyConfig config) { + return new ABsmartly(config); + } + + protected ABsmartly(@Nonnull ABsmartlyConfig config) { + contextDataProvider_ = config.getContextDataProvider(); + contextEventHandler_ = config.getContextEventHandler(); + contextEventLogger_ = config.getContextEventLogger(); + variableParser_ = config.getVariableParser(); + audienceDeserializer_ = config.getAudienceDeserializer(); + scheduler_ = config.getScheduler(); + + if ((contextDataProvider_ == null) || (contextEventHandler_ == null)) { + client_ = config.getClient(); + if (client_ == null) { + throw new IllegalArgumentException("Missing Client instance"); + } + + if (contextDataProvider_ == null) { + contextDataProvider_ = new DefaultContextDataProvider(client_); + } + + if (contextEventHandler_ == null) { + contextEventHandler_ = new DefaultContextEventHandler(client_); + } + } + + if (variableParser_ == null) { + variableParser_ = new DefaultVariableParser(); + } + + if (audienceDeserializer_ == null) { + audienceDeserializer_ = new DefaultAudienceDeserializer(); + } + + if (scheduler_ == null) { + scheduler_ = new ScheduledThreadPoolExecutor(1); + } + } + + public Context createContext(@Nonnull ContextConfig config) { + return Context.create(Clock.systemUTC(), config, scheduler_, contextDataProvider_.getContextData(), + contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, + new AudienceMatcher(audienceDeserializer_)); + } + + public Context createContextWith(@Nonnull ContextConfig config, ContextData data) { + return Context.create(Clock.systemUTC(), config, scheduler_, CompletableFuture.completedFuture(data), + contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, + new AudienceMatcher(audienceDeserializer_)); + } + + public CompletableFuture getContextData() { + return contextDataProvider_.getContextData(); + } + + @Override + public void close() throws IOException { + if (client_ != null) { + client_.close(); + client_ = null; + } + + if (scheduler_ != null) { + try { + scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) {} + scheduler_ = null; + } + } + + private Client client_; + private ContextDataProvider contextDataProvider_; + private ContextEventHandler contextEventHandler_; + private ContextEventLogger contextEventLogger_; + private VariableParser variableParser_; + + private AudienceDeserializer audienceDeserializer_; + private ScheduledExecutorService scheduler_; +} diff --git a/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java b/core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java similarity index 73% rename from core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java rename to core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java index c4346d0..3f1586c 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java @@ -4,18 +4,18 @@ import javax.annotation.Nonnull; -public class ABSmartlyConfig { - public static ABSmartlyConfig create() { - return new ABSmartlyConfig(); +public class ABsmartlyConfig { + public static ABsmartlyConfig create() { + return new ABsmartlyConfig(); } - private ABSmartlyConfig() {} + protected ABsmartlyConfig() {} public ContextDataProvider getContextDataProvider() { return contextDataProvider_; } - public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) { + public ABsmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) { contextDataProvider_ = contextDataProvider; return this; } @@ -24,7 +24,7 @@ public ContextEventHandler getContextEventHandler() { return contextEventHandler_; } - public ABSmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { + public ABsmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { contextEventHandler_ = contextEventHandler; return this; } @@ -33,7 +33,7 @@ public VariableParser getVariableParser() { return variableParser_; } - public ABSmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) { + public ABsmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) { variableParser_ = variableParser; return this; } @@ -42,7 +42,7 @@ public ScheduledExecutorService getScheduler() { return scheduler_; } - public ABSmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) { + public ABsmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) { scheduler_ = scheduler; return this; } @@ -51,7 +51,7 @@ public ContextEventLogger getContextEventLogger() { return contextEventLogger_; } - public ABSmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) { + public ABsmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) { contextEventLogger_ = logger; return this; } @@ -60,7 +60,7 @@ public AudienceDeserializer getAudienceDeserializer() { return audienceDeserializer_; } - public ABSmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) { + public ABsmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) { audienceDeserializer_ = audienceDeserializer; return this; } @@ -69,7 +69,7 @@ public Client getClient() { return client_; } - public ABSmartlyConfig setClient(Client client) { + public ABsmartlyConfig setClient(Client client) { client_ = client; return this; } diff --git a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java new file mode 100644 index 0000000..ae0174b --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java @@ -0,0 +1,16 @@ +package com.absmartly.sdk.deprecated; + +import javax.annotation.Nonnull; + +import com.absmartly.sdk.ABsmartly; + +@Deprecated +public class ABSmartly extends ABsmartly { + public static ABSmartly create(@Nonnull ABSmartlyConfig config) { + return new ABSmartly(config); + } + + protected ABSmartly(@Nonnull ABSmartlyConfig config) { + super(config); + } +} diff --git a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java new file mode 100644 index 0000000..4f1ee7b --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java @@ -0,0 +1,66 @@ +package com.absmartly.sdk.deprecated; + +import java.util.concurrent.ScheduledExecutorService; + +import javax.annotation.Nonnull; + +import com.absmartly.sdk.ABsmartlyConfig; +import com.absmartly.sdk.AudienceDeserializer; +import com.absmartly.sdk.Client; +import com.absmartly.sdk.ContextDataProvider; +import com.absmartly.sdk.ContextEventHandler; +import com.absmartly.sdk.ContextEventLogger; +import com.absmartly.sdk.VariableParser; + +@Deprecated +public class ABSmartlyConfig extends ABsmartlyConfig { + public static ABSmartlyConfig create() { + return new ABSmartlyConfig(); + } + + protected ABSmartlyConfig() { + super(); + } + + @Override + public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) { + super.setContextDataProvider(contextDataProvider); + return this; + } + + @Override + public ABSmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { + super.setContextEventHandler(contextEventHandler); + return this; + } + + @Override + public ABSmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) { + super.setVariableParser(variableParser); + return this; + } + + @Override + public ABSmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) { + super.setScheduler(scheduler); + return this; + } + + @Override + public ABSmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) { + super.setContextEventLogger(logger); + return this; + } + + @Override + public ABSmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) { + super.setAudienceDeserializer(audienceDeserializer); + return this; + } + + @Override + public ABSmartlyConfig setClient(Client client) { + super.setClient(client); + return this; + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyConfigTest.java b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyConfigTest.java similarity index 82% rename from core-api/src/test/java/com/absmartly/sdk/ABSmartlyConfigTest.java rename to core-api/src/test/java/com/absmartly/sdk/ABsmartlyConfigTest.java index 18d6f36..50c1150 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyConfigTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyConfigTest.java @@ -7,39 +7,39 @@ import org.junit.jupiter.api.Test; -class ABSmartlyConfigTest extends TestUtils { +class ABsmartlyConfigTest extends TestUtils { @Test void setContextDataProvider() { final ContextDataProvider provider = mock(ContextDataProvider.class); - final ABSmartlyConfig config = ABSmartlyConfig.create().setContextDataProvider(provider); + final ABsmartlyConfig config = ABsmartlyConfig.create().setContextDataProvider(provider); assertSame(provider, config.getContextDataProvider()); } @Test void setContextEventHandler() { final ContextEventHandler handler = mock(ContextEventHandler.class); - final ABSmartlyConfig config = ABSmartlyConfig.create().setContextEventHandler(handler); + final ABsmartlyConfig config = ABsmartlyConfig.create().setContextEventHandler(handler); assertSame(handler, config.getContextEventHandler()); } @Test void setVariableParser() { final VariableParser variableParser = mock(VariableParser.class); - final ABSmartlyConfig config = ABSmartlyConfig.create().setVariableParser(variableParser); + final ABsmartlyConfig config = ABsmartlyConfig.create().setVariableParser(variableParser); assertSame(variableParser, config.getVariableParser()); } @Test void setScheduler() { final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); - final ABSmartlyConfig config = ABSmartlyConfig.create().setScheduler(scheduler); + final ABsmartlyConfig config = ABsmartlyConfig.create().setScheduler(scheduler); assertSame(scheduler, config.getScheduler()); } @Test void setContextEventLogger() { final ContextEventLogger logger = mock(ContextEventLogger.class); - final ABSmartlyConfig config = ABSmartlyConfig.create().setContextEventLogger(logger); + final ABsmartlyConfig config = ABsmartlyConfig.create().setContextEventLogger(logger); assertSame(logger, config.getContextEventLogger()); } @@ -50,7 +50,7 @@ void setAll() { final VariableParser parser = mock(VariableParser.class); final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); final Client client = mock(Client.class); - final ABSmartlyConfig config = ABSmartlyConfig.create() + final ABsmartlyConfig config = ABsmartlyConfig.create() .setVariableParser(parser) .setContextDataProvider(provider) .setContextEventHandler(handler) diff --git a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java similarity index 93% rename from core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java rename to core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java index 7b5e7a2..46d3434 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java @@ -18,7 +18,7 @@ import com.absmartly.sdk.java.time.Clock; import com.absmartly.sdk.json.ContextData; -class ABSmartlyTest extends TestUtils { +class ABsmartlyTest extends TestUtils { Client client; @BeforeEach @@ -28,25 +28,25 @@ void setUp() { @Test void create() { - final ABSmartlyConfig config = ABSmartlyConfig.create() + final ABsmartlyConfig config = ABsmartlyConfig.create() .setClient(client); - final ABSmartly absmartly = ABSmartly.create(config); + final ABsmartly absmartly = ABsmartly.create(config); assertNotNull(absmartly); } @Test void createThrowsWithInvalidConfig() { assertThrows(IllegalArgumentException.class, () -> { - final ABSmartlyConfig config = ABSmartlyConfig.create(); + final ABsmartlyConfig config = ABsmartlyConfig.create(); - final ABSmartly absmartly = ABSmartly.create(config); + final ABsmartly absmartly = ABsmartly.create(config); }, "Missing Client instance configuration"); } @Test void createContext() { - final ABSmartlyConfig config = ABSmartlyConfig.create() + final ABsmartlyConfig config = ABsmartlyConfig.create() .setClient(client); final CompletableFuture dataFuture = (CompletableFuture) mock( @@ -55,7 +55,7 @@ void createContext() { DefaultContextDataProvider.class, (mock, context) -> { when(mock.getContextData()).thenReturn(dataFuture); })) { - final ABSmartly absmartly = ABSmartly.create(config); + final ABsmartly absmartly = ABsmartly.create(config); assertEquals(1, dataProviderCtor.constructed().size()); try (final MockedStatic contextStatic = mockStatic(Context.class)) { @@ -107,13 +107,13 @@ void createContext() { @Test void createContextWith() { - final ABSmartlyConfig config = ABSmartlyConfig.create() + final ABsmartlyConfig config = ABsmartlyConfig.create() .setClient(client); final ContextData data = new ContextData(); try (final MockedConstruction dataProviderCtor = mockConstruction( DefaultContextDataProvider.class)) { - final ABSmartly absmartly = ABSmartly.create(config); + final ABsmartly absmartly = ABsmartly.create(config); assertEquals(1, dataProviderCtor.constructed().size()); try (final MockedStatic contextStatic = mockStatic(Context.class)) { @@ -171,11 +171,11 @@ void getContextData() { final ContextDataProvider dataProvider = mock(ContextDataProvider.class); when(dataProvider.getContextData()).thenReturn(dataFuture); - final ABSmartlyConfig config = ABSmartlyConfig.create() + final ABsmartlyConfig config = ABsmartlyConfig.create() .setClient(client) .setContextDataProvider(dataProvider); - final ABSmartly absmartly = ABSmartly.create(config); + final ABsmartly absmartly = ABsmartly.create(config); final CompletableFuture contextDataFuture = absmartly.getContextData(); verify(dataProvider, Mockito.timeout(5000).times(1)).getContextData(); @@ -195,7 +195,7 @@ void createContextWithCustomImpls() { final AudienceDeserializer audienceDeserializer = mock(AudienceDeserializer.class); final VariableParser variableParser = mock(VariableParser.class); - final ABSmartlyConfig config = ABSmartlyConfig.create() + final ABsmartlyConfig config = ABsmartlyConfig.create() .setClient(client) .setContextDataProvider(dataProvider) .setContextEventHandler(eventHandler) @@ -204,7 +204,7 @@ void createContextWithCustomImpls() { .setAudienceDeserializer(audienceDeserializer) .setVariableParser(variableParser); - final ABSmartly absmartly = ABSmartly.create(config); + final ABsmartly absmartly = ABsmartly.create(config); try (final MockedStatic contextStatic = mockStatic(Context.class); final MockedConstruction audienceMatcherCtor = mockConstruction(AudienceMatcher.class, @@ -258,11 +258,11 @@ void createContextWithCustomImpls() { void close() throws IOException, InterruptedException { final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); - final ABSmartlyConfig config = ABSmartlyConfig.create() + final ABsmartlyConfig config = ABsmartlyConfig.create() .setClient(client) .setScheduler(scheduler); - final ABSmartly absmartly = ABSmartly.create(config); + final ABsmartly absmartly = ABsmartly.create(config); try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); diff --git a/example/src/main/java/com/absmartly/sdk/Example.java b/example/src/main/java/com/absmartly/sdk/Example.java index 456f279..d7eae40 100644 --- a/example/src/main/java/com/absmartly/sdk/Example.java +++ b/example/src/main/java/com/absmartly/sdk/Example.java @@ -12,10 +12,10 @@ static public void main(String[] args) throws IOException { .setApplication(System.getenv("ABSMARTLY_APP")) .setEnvironment(System.getenv("ABSMARTLY_ENV")); - final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() + final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() .setClient(Client.create(clientConfig)); - final ABSmartly sdk = ABSmartly.create(sdkConfig); + final ABsmartly sdk = ABsmartly.create(sdkConfig); final ContextConfig contextConfig = ContextConfig.create() .setUnit("session_id", "bf06d8cb5d8137290c4abb64155584fbdb64d8") From 07c067dd238aadbec2d46834b540abe971f87679 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 30 Jan 2026 14:51:33 +0000 Subject: [PATCH 05/83] fix: remove blocking calls and resource leaks from Java Android examples --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cf4eb7c..b25c1a5 100644 --- a/README.md +++ b/README.md @@ -583,17 +583,17 @@ public class MainActivity extends AppCompatActivity { final ContextConfig contextConfig = ContextConfig.create() .setUnit("device_id", deviceId); - absmartlyContext = sdk.createContext(contextConfig) - .waitUntilReadyAsync() - .thenApply(ctx -> { + final Context contextInstance = sdk.createContext(contextConfig); + absmartlyContext = contextInstance; + + contextInstance.waitUntilReadyAsync() + .thenAccept(ctx -> { runOnUiThread(() -> setupUI(ctx)); - return ctx; }) .exceptionally(throwable -> { runOnUiThread(() -> setupUIWithDefault()); return null; - }) - .join(); + }); } private void setupUI(Context context) { @@ -619,7 +619,9 @@ public class MainActivity extends AppCompatActivity { } private String getDeviceId() { - // Retrieve or generate device ID + // IMPORTANT: Device ID must be persisted across app sessions in SharedPreferences + // to ensure consistent experiment assignments for the same user/device. + // This example uses a random UUID for demonstration purposes only. return UUID.randomUUID().toString(); } } @@ -722,6 +724,7 @@ import java8.util.concurrent.CompletableFuture; public class MainActivity extends AppCompatActivity { + private static ABsmartly sdk; // Initialize SDK once (typically in Application class) private CompletableFuture contextFuture; private Context absmartlyContext; From 12365ed111c710a6cf7546fc48df2d9f5f60c4dd Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 6 Feb 2026 19:54:13 +0000 Subject: [PATCH 06/83] =?UTF-8?q?test:=20add=20canonical=20test=20parity?= =?UTF-8?q?=20(265=20=E2=86=92=20294=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 29 context tests covering treatment exposure queuing, variable values, peek, refresh, publish, close, and track. Upgrade build infrastructure: Gradle 5.6→8.5, Mockito 3→5, JaCoCo 0.8.6→0.8.12 for Java 21 compatibility. Fix ABSmartly.java case-sensitivity issue and JaCoCo exclusions. --- build.gradle | 20 +- core-api/build.gradle | 19 +- .../java/com/absmartly/sdk/ABSmartly.java | 123 ----- .../main/java/com/absmartly/sdk/Context.java | 6 +- .../java/com/absmartly/sdk/ABsmartlyTest.java | 30 +- .../java/com/absmartly/sdk/ContextTest.java | 480 +++++++++++++++++- .../sdk/DefaultContextDataProviderTest.java | 4 +- .../absmartly/sdk/DefaultHTTPClientTest.java | 5 +- .../sdk/internal/ConcurrencyTest.java | 1 + gradle/jacoco.gradle | 6 +- gradle/wrapper/gradle-wrapper.properties | 3 +- 11 files changed, 522 insertions(+), 175 deletions(-) delete mode 100644 core-api/src/main/java/com/absmartly/sdk/ABSmartly.java diff --git a/build.gradle b/build.gradle index 6af3564..3dcb30e 100644 --- a/build.gradle +++ b/build.gradle @@ -2,13 +2,11 @@ plugins { id "java" id "groovy" id "jacoco" - id "findbugs" - id "com.diffplug.spotless" version "5.8.2" - id "com.adarshr.test-logger" version "2.1.1" - id "org.barfuin.gradle.jacocolog" version "1.2.3" - id "io.github.gradle-nexus.publish-plugin" version "1.1.0" - id "org.owasp.dependencycheck" version "7.3.0" + id "com.diffplug.spotless" version "6.25.0" + id "com.adarshr.test-logger" version "4.0.0" + id "org.barfuin.gradle.jacocolog" version "3.1.0" + id "io.github.gradle-nexus.publish-plugin" version "1.3.0" } @@ -21,8 +19,8 @@ ext { jacksonVersion = "2.13.4.2" jacksonDataTypeVersion = "2.13.4" - junitVersion = "5.7.0" - mockitoVersion = "3.6.28" + junitVersion = "5.10.2" + mockitoVersion = "5.11.0" } @@ -30,17 +28,15 @@ allprojects { group = GROUP_ID apply plugin: "java" - apply plugin: "org.owasp.dependencycheck" apply from: rootProject.file("gradle/repositories.gradle") apply from: rootProject.file("gradle/spotless.gradle") - apply from: rootProject.file("gradle/findbugs.gradle") apply from: rootProject.file("gradle/test-logger.gradle") apply from: rootProject.file("gradle/jacoco.gradle") apply from: rootProject.file("gradle/coverage-logger.gradle") compileJava { - sourceCompatibility = "1.6" - targetCompatibility = "1.6" + sourceCompatibility = "1.8" + targetCompatibility = "1.8" } } diff --git a/core-api/build.gradle b/core-api/build.gradle index ca14315..c2ca4cd 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -26,18 +26,21 @@ dependencies { testImplementation group: "org.junit.jupiter", name: "junit-jupiter-params", version: junitVersion testRuntimeOnly group: "org.junit.jupiter", name: "junit-jupiter-engine", version: junitVersion testImplementation group: "org.mockito", name: "mockito-core", version: mockitoVersion - testImplementation group: "org.mockito", name: "mockito-inline", version: mockitoVersion testImplementation group: "org.mockito", name: "mockito-junit-jupiter", version: mockitoVersion } check.dependsOn jacocoTestCoverageVerification +def jacocoExcludes = [ + "com/absmartly/sdk/json/**/*", + "com/absmartly/sdk/deprecated/**/*", + "com/absmartly/sdk/java/**/*", +] + jacocoTestReport { afterEvaluate { getClassDirectories().setFrom(classDirectories.files.collect { - fileTree(dir: it, exclude: [ - "com/absmartly/core-api/json/**/*" - ]) + fileTree(dir: it, exclude: jacocoExcludes) }) } } @@ -67,9 +70,7 @@ jacocoTestCoverageVerification { afterEvaluate { getClassDirectories().setFrom(classDirectories.files.collect { - fileTree(dir: it, exclude: [ - "com/absmartly/core-api/json/**/*" - ]) + fileTree(dir: it, exclude: jacocoExcludes) }) } } @@ -77,6 +78,10 @@ jacocoTestCoverageVerification { test { useJUnitPlatform() + jvmArgs '--add-opens', 'java.base/java.lang=ALL-UNNAMED', + '--add-opens', 'java.base/java.lang.reflect=ALL-UNNAMED', + '--add-opens', 'java.base/java.util=ALL-UNNAMED', + '--add-opens', 'java.base/java.util.concurrent=ALL-UNNAMED' } publishToSonatype.dependsOn check diff --git a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java deleted file mode 100644 index d5389f8..0000000 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.absmartly.sdk; - -import java.io.Closeable; -import java.io.IOException; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java8.util.concurrent.CompletableFuture; - -import javax.annotation.Nonnull; - -import com.absmartly.sdk.java.time.Clock; -import com.absmartly.sdk.json.ContextData; - -public class ABsmartly implements Closeable { - public static ABsmartly create(@Nonnull ABsmartlyConfig config) { - return new ABsmartly(config); - } - - protected ABsmartly(@Nonnull ABsmartlyConfig config) { - contextDataProvider_ = config.getContextDataProvider(); - contextEventHandler_ = config.getContextEventHandler(); - contextEventLogger_ = config.getContextEventLogger(); - variableParser_ = config.getVariableParser(); - audienceDeserializer_ = config.getAudienceDeserializer(); - scheduler_ = config.getScheduler(); - - if ((contextDataProvider_ == null) || (contextEventHandler_ == null)) { - client_ = config.getClient(); - if (client_ == null) { - throw new IllegalArgumentException("Missing Client instance"); - } - - if (contextDataProvider_ == null) { - contextDataProvider_ = new DefaultContextDataProvider(client_); - } - - if (contextEventHandler_ == null) { - contextEventHandler_ = new DefaultContextEventHandler(client_); - } - } - - if (variableParser_ == null) { - variableParser_ = new DefaultVariableParser(); - } - - if (audienceDeserializer_ == null) { - audienceDeserializer_ = new DefaultAudienceDeserializer(); - } - - if (scheduler_ == null) { - scheduler_ = new ScheduledThreadPoolExecutor(1); - } - } - - public Context createContext(@Nonnull ContextConfig config) { - return Context.create(Clock.systemUTC(), config, scheduler_, contextDataProvider_.getContextData(), - contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, - new AudienceMatcher(audienceDeserializer_)); - } - - public Context createContextWith(@Nonnull ContextConfig config, ContextData data) { - return Context.create(Clock.systemUTC(), config, scheduler_, CompletableFuture.completedFuture(data), - contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, - new AudienceMatcher(audienceDeserializer_)); - } - - public CompletableFuture getContextData() { - return contextDataProvider_.getContextData(); - } - - @Override - public void close() throws IOException { - if (client_ != null) { - client_.close(); - client_ = null; - } - - if (scheduler_ != null) { - try { - scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS); - } catch (InterruptedException ignored) {} - scheduler_ = null; - } - } - - private Client client_; - private ContextDataProvider contextDataProvider_; - private ContextEventHandler contextEventHandler_; - private ContextEventLogger contextEventLogger_; - private VariableParser variableParser_; - - private AudienceDeserializer audienceDeserializer_; - private ScheduledExecutorService scheduler_; -} - -/** - * @deprecated Use {@link ABsmartly} instead. The uppercase 'S' in ABSmartly is a typo. - */ -@Deprecated -public class ABSmartly extends ABsmartly { - public static ABSmartly create(@Nonnull ABSmartlyConfig config) { - return new ABSmartly(config); - } - - public ABSmartly(@Nonnull ABSmartlyConfig config) { - super(config); - } -} - -/** - * @deprecated Use {@link ABsmartly} instead. The uppercase 'S' in ABSmartly is a typo. - */ -@Deprecated -public class ABSmartly extends ABsmartly { - public static ABSmartly create(@Nonnull ABSmartlyConfig config) { - return new ABSmartly(config); - } - - public ABSmartly(@Nonnull ABSmartlyConfig config) { - super(config); - } -} 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 ce49abc..7928ed9 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -764,7 +764,8 @@ private Assignment getAssignment(final String experimentName) { return assignment; } } else if ((custom == null) || custom == assignment.variant) { - if (experimentMatches(experiment.data, assignment) && audienceMatches(experiment.data, assignment)) { + if (experimentMatches(experiment.data, assignment) + && audienceMatches(experiment.data, assignment)) { // assignment up-to-date return assignment; } @@ -854,7 +855,8 @@ private Assignment getAssignment(final String experimentName) { } } - if ((experiment != null) && assignment.variant >= 0 && (assignment.variant < experiment.data.variants.length)) { + if ((experiment != null) && assignment.variant >= 0 + && (assignment.variant < experiment.data.variants.length)) { assignment.variables = experiment.variables.get(assignment.variant); } 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 46d3434..7f29123 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java @@ -84,13 +84,15 @@ void createContext() { final ArgumentCaptor audienceMatcherCaptor = ArgumentCaptor .forClass(AudienceMatcher.class); - contextStatic.verify(Mockito.timeout(5000).times(1), - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())); - contextStatic.verify(Mockito.timeout(5000).times(1), + contextStatic.verify( + () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any()), + Mockito.times(1)); + contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), dataFutureCaptor.capture(), dataProviderCaptor.capture(), eventHandlerCaptor.capture(), eventLoggerCaptor.capture(), variableParserCaptor.capture(), - audienceMatcherCaptor.capture())); + audienceMatcherCaptor.capture()), + Mockito.times(1)); assertEquals(Clock.systemUTC(), clockCaptor.getValue()); assertSame(contextConfig, configCaptor.getValue()); @@ -144,13 +146,15 @@ void createContextWith() { final ArgumentCaptor audienceMatcherCaptor = ArgumentCaptor .forClass(AudienceMatcher.class); - contextStatic.verify(Mockito.timeout(5000).times(1), - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())); - contextStatic.verify(Mockito.timeout(5000).times(1), + contextStatic.verify( + () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any()), + Mockito.times(1)); + contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), dataFutureCaptor.capture(), dataProviderCaptor.capture(), eventHandlerCaptor.capture(), eventLoggerCaptor.capture(), variableParserCaptor.capture(), - audienceMatcherCaptor.capture())); + audienceMatcherCaptor.capture()), + Mockito.times(1)); assertEquals(Clock.systemUTC(), clockCaptor.getValue()); assertSame(contextConfig, configCaptor.getValue()); @@ -235,12 +239,14 @@ void createContextWithCustomImpls() { final ArgumentCaptor variableParserCaptor = ArgumentCaptor.forClass(VariableParser.class); final ArgumentCaptor audienceMatcher = ArgumentCaptor.forClass(AudienceMatcher.class); - contextStatic.verify(Mockito.timeout(5000).times(1), - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())); - contextStatic.verify(Mockito.timeout(5000).times(1), + contextStatic.verify( + () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any()), + Mockito.times(1)); + contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), dataFutureCaptor.capture(), dataProviderCaptor.capture(), eventHandlerCaptor.capture(), - eventLoggerCaptor.capture(), variableParserCaptor.capture(), audienceMatcher.capture())); + eventLoggerCaptor.capture(), variableParserCaptor.capture(), audienceMatcher.capture()), + Mockito.times(1)); assertEquals(Clock.systemUTC(), clockCaptor.getValue()); assertSame(contextConfig, configCaptor.getValue()); 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 0abe1c0..535a487 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -360,7 +360,7 @@ void throwsWhenClosing() { assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setAttributes(mapOf("attr1", "value1"))) - .getMessage()); + .getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setOverride("exp_test_ab", 2)).getMessage()); assertEquals(closingMessage, @@ -375,7 +375,7 @@ void throwsWhenClosing() { assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setCustomAssignments(mapOf("exp_test_ab", 2))) - .getMessage()); + .getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); assertEquals(closingMessage, @@ -416,7 +416,7 @@ void throwsWhenClosed() { assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setAttributes(mapOf("attr1", "value1"))) - .getMessage()); + .getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setOverride("exp_test_ab", 2)).getMessage()); assertEquals(closedMessage, @@ -431,7 +431,7 @@ void throwsWhenClosed() { assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setCustomAssignments(mapOf("exp_test_ab", 2))) - .getMessage()); + .getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); assertEquals(closedMessage, @@ -473,10 +473,10 @@ void startsRefreshTimerWhenReady() { final AtomicReference runnable = new AtomicReference<>(null); when(scheduler.scheduleWithFixedDelay(any(), eq(config.getRefreshInterval()), eq(config.getRefreshInterval()), eq(TimeUnit.MILLISECONDS))) - .thenAnswer(invokation -> { - runnable.set(invokation.getArgument(0)); - return mock(ScheduledFuture.class); - }); + .thenAnswer(invokation -> { + runnable.set(invokation.getArgument(0)); + return mock(ScheduledFuture.class); + }); dataFuture.complete(data); context.waitUntilReady(); @@ -1814,7 +1814,7 @@ void closeStopsRefreshTimer() { final ScheduledFuture refreshTimer = mock(ScheduledFuture.class); when(scheduler.scheduleWithFixedDelay(any(), eq(config.getRefreshInterval()), eq(config.getRefreshInterval()), eq(TimeUnit.MILLISECONDS))) - .thenReturn(refreshTimer); + .thenReturn(refreshTimer); final Context context = createContext(config, dataFutureReady); assertTrue(context.isReady()); @@ -2203,4 +2203,466 @@ void getCustomFieldValue() { assertNull(context.getCustomFieldValue("exp_test_no_custom_fields", "languages")); assertNull(context.getCustomFieldValueType("exp_test_no_custom_fields", "languages")); } + + @Test + void getTreatmentQueuesExposureAfterPeek() { + final Context context = createReadyContext(); + + Arrays.stream(data.experiments).forEach(experiment -> context.peekTreatment(experiment.name)); + context.peekTreatment("not_found"); + + assertEquals(0, context.getPendingCount()); + + Arrays.stream(data.experiments).forEach(experiment -> context.getTreatment(experiment.name)); + context.getTreatment("not_found"); + + assertEquals(1 + data.experiments.length, context.getPendingCount()); + } + + @Test + void getTreatmentQueuesExposureWithBaseVariantOnUnknownExperiment() { + final Context context = createReadyContext(); + + assertEquals(0, context.getTreatment("not_found")); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + + expected.exposures = new Exposure[]{ + new Exposure(0, "not_found", null, 0, clock.millis(), false, true, false, false, false, false), + }; + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void getTreatmentDoesNotReQueueExposureOnUnknownExperiment() { + final Context context = createReadyContext(); + + assertEquals(0, context.getTreatment("not_found")); + assertEquals(1, context.getPendingCount()); + + assertEquals(0, context.getTreatment("not_found")); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + assertEquals(0, context.getTreatment("not_found")); + assertEquals(0, context.getPendingCount()); + } + + @Test + void getTreatmentQueuesExposureWithCustomAssignmentVariant() { + final Context context = createReadyContext(); + + context.setCustomAssignment("exp_test_ab", 2); + + assertEquals(2, context.getTreatment("exp_test_ab")); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + 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", 2, clock.millis(), true, true, false, false, true, false), + }; + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void getVariableValueReturnsDefaultValueWhenUnassigned() { + final Context context = createReadyContext(); + + assertEquals(17, context.getVariableValue("card.width", 17)); + } + + @Test + void getVariableValueReturnsDefaultValueOnUnknownVariable() { + final Context context = createReadyContext(); + + assertEquals("default", context.getVariableValue("unknown_variable", "default")); + assertEquals(0, context.getPendingCount()); + } + + @Test + void getVariableValueReturnsVariableValuesWhenOverridden() { + final Context context = createReadyContext(); + + context.setOverride("exp_test_ab", 0); + + assertEquals(17, context.getVariableValue("banner.border", 17)); + } + + @Test + void getVariableValueQueuesExposureAfterPeekVariableValue() { + final Context context = createReadyContext(); + + context.peekVariableValue("banner.border", 17); + context.peekVariableValue("banner.size", 17); + + assertEquals(0, context.getPendingCount()); + + context.getVariableValue("banner.border", 17); + context.getVariableValue("banner.size", 17); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void getVariableValueQueuesExposureOnce() { + final Context context = createReadyContext(); + + context.getVariableValue("banner.border", 17); + context.getVariableValue("banner.size", 17); + + assertEquals(1, context.getPendingCount()); + + context.getVariableValue("banner.border", 17); + context.getVariableValue("banner.size", 17); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void peekVariableValueReturnsDefaultValueWhenUnassigned() { + final Context context = createReadyContext(); + + assertEquals(17, context.peekVariableValue("card.width", 17)); + } + + @Test + void peekVariableValueReturnsVariableValuesWhenOverridden() { + final Context context = createReadyContext(); + + context.setOverride("exp_test_ab", 0); + + assertEquals(17, context.peekVariableValue("banner.border", 17)); + } + + @Test + void peekVariableValueReturnsDefaultValueOnUnknownOverrideVariant() { + final Context context = createReadyContext(); + + context.setOverride("exp_test_ab", 15); + + assertEquals(17, context.peekVariableValue("banner.border", 17)); + } + + @Test + void refreshKeepsOverrides() { + final Context context = createReadyContext(); + + context.setOverride("exp_test_ab", 5); + assertEquals(5, context.getOverride("exp_test_ab")); + + when(dataProvider.getContextData()).thenReturn(refreshDataFutureReady); + + context.refresh(); + + assertEquals(5, context.getOverride("exp_test_ab")); + assertEquals(5, context.getTreatment("exp_test_ab")); + } + + @Test + void refreshKeepsCustomAssignments() { + final Context context = createReadyContext(); + + context.setCustomAssignment("exp_test_ab", 2); + assertEquals(2, context.getCustomAssignment("exp_test_ab")); + + when(dataProvider.getContextData()).thenReturn(refreshDataFutureReady); + + context.refresh(); + + assertEquals(2, context.getCustomAssignment("exp_test_ab")); + assertEquals(2, context.getTreatment("exp_test_ab")); + } + + @Test + void refreshDoesNotCallPublishWhenFailed() { + final Context context = createContext(dataFutureFailed); + assertTrue(context.isReady()); + assertTrue(context.isFailed()); + + when(dataProvider.getContextData()).thenReturn(refreshDataFutureReady); + + context.refresh(); + + verify(dataProvider, Mockito.timeout(5000).times(1)).getContextData(); + } + + @Test + void publishIncludesExposureData() { + final Context context = createReadyContext(); + + context.getTreatment("exp_test_ab"); + + assertEquals(1, context.getPendingCount()); + + 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), + }; + + when(eventHandler.publish(context, expected)).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void publishIncludesGoalData() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125, "hours", 245)); + + assertEquals(1, context.getPendingCount()); + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + expected.goals = new GoalAchievement[]{ + new GoalAchievement("goal1", clock.millis(), new TreeMap<>(mapOf("amount", 125, "hours", 245))), + }; + + when(eventHandler.publish(context, expected)).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void publishIncludesAttributeData() { + final ContextConfig config = ContextConfig.create() + .setUnits(units) + .setAttributes(mapOf("attr1", "value1")); + + final Context context = createContext(config, dataFutureReady); + + context.track("goal1", null); + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + expected.goals = new GoalAchievement[]{ + new GoalAchievement("goal1", clock.millis(), null), + }; + expected.attributes = new Attribute[]{ + new Attribute("attr1", "value1", clock.millis()), + }; + + when(eventHandler.publish(context, expected)).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void publishClearsQueueOnSuccess() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + assertEquals(0, context.getPendingCount()); + } + + @Test + void publishPropagatesClientErrorOnFailure() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + assertEquals(1, context.getPendingCount()); + + final Exception failure = new Exception("publish error"); + when(eventHandler.publish(any(), any())).thenReturn(failedFuture(failure)); + + final CompletionException actual = assertThrows(CompletionException.class, context::publish); + assertSame(failure, actual.getCause()); + } + + @Test + void closeDoesNotCallEventHandlerWhenQueueIsEmpty() { + final Context context = createReadyContext(); + assertEquals(0, context.getPendingCount()); + + context.close(); + + assertTrue(context.isClosed()); + verify(eventHandler, Mockito.timeout(5000).times(0)).publish(any(), any()); + } + + @Test + void closeCallsEventHandlerWithPendingData() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.close(); + + assertTrue(context.isClosed()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + } + + @Test + void closeDoesNotCallEventHandlerWhenFailed() { + final Context context = createContext(dataFutureFailed); + assertTrue(context.isReady()); + assertTrue(context.isFailed()); + + context.getTreatment("exp_test_abc"); + context.track("goal1", mapOf("amount", 125)); + + context.close(); + + assertTrue(context.isClosed()); + verify(eventHandler, Mockito.timeout(5000).times(0)).publish(any(), any()); + } + + @Test + void closeAsyncReturnsSameFutureWhenCalledTwice() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + + final CompletableFuture closeFuture1 = context.closeAsync(); + final CompletableFuture closeFuture2 = context.closeAsync(); + + assertSame(closeFuture1, closeFuture2); + + publishFuture.complete(null); + closeFuture1.join(); + + assertTrue(context.isClosed()); + } + + @Test + void closeAsyncReturnsCompletedFutureWhenAlreadyClosed() { + final Context context = createReadyContext(); + + context.close(); + assertTrue(context.isClosed()); + + final CompletableFuture closeFuture = context.closeAsync(); + assertTrue(closeFuture.isDone()); + } + + @Test + void trackQueuesGoalWithProperties() { + final Context context = createReadyContext(); + + final Map properties = mapOf("amount", 125, "hours", 245); + context.track("goal1", properties); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void trackQueuesGoalWithNullProperties() { + final Context context = createReadyContext(); + + context.track("goal1", null); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void getVariableValueConflictingKeyOverlappingAudiences() { + for (final Experiment experiment : data.experiments) { + switch (experiment.name) { + case "exp_test_ab": + assert (expectedVariants.get(experiment.name) != 0); + experiment.audienceStrict = true; + experiment.audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}"; + experiment.variants[expectedVariants.get(experiment.name)].config = "{\"icon\":\"arrow\"}"; + break; + case "exp_test_abc": + assert (expectedVariants.get(experiment.name) != 0); + experiment.audienceStrict = true; + experiment.audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}"; + experiment.variants[expectedVariants.get(experiment.name)].config = "{\"icon\":\"circle\"}"; + break; + default: + break; + } + } + + final Context context = createReadyContext(data); + context.setAttribute("age", 25); + assertEquals("arrow", context.getVariableValue("icon", "square")); + assertEquals(1, context.getPendingCount()); + } + + @Test + void peekVariableValueConflictingKeyOverlappingAudiences() { + for (final Experiment experiment : data.experiments) { + switch (experiment.name) { + case "exp_test_ab": + assert (expectedVariants.get(experiment.name) != 0); + experiment.audienceStrict = true; + experiment.audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}"; + experiment.variants[expectedVariants.get(experiment.name)].config = "{\"icon\":\"arrow\"}"; + break; + case "exp_test_abc": + assert (expectedVariants.get(experiment.name) != 0); + experiment.audienceStrict = true; + experiment.audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}"; + experiment.variants[expectedVariants.get(experiment.name)].config = "{\"icon\":\"circle\"}"; + break; + default: + break; + } + } + + final Context context = createReadyContext(data); + context.setAttribute("age", 25); + assertEquals("arrow", context.peekVariableValue("icon", "square")); + assertEquals(0, context.getPendingCount()); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java index 6c1de54..9ff8365 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java @@ -88,8 +88,8 @@ void getContextDataWithTimeoutException() { final Client client = mock(Client.class); final ContextDataProvider provider = new DefaultContextDataProvider(client); - final java.util.concurrent.TimeoutException timeoutException = - new java.util.concurrent.TimeoutException("Request timed out"); + final java.util.concurrent.TimeoutException timeoutException = new java.util.concurrent.TimeoutException( + "Request timed out"); final CompletableFuture failedFuture = failedFuture(timeoutException); when(client.getContextData()).thenReturn(failedFuture); diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java index cfaa128..150110b 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java @@ -9,7 +9,6 @@ import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java8.util.concurrent.CompletableFuture; import java8.util.concurrent.CompletionException; @@ -401,8 +400,8 @@ void testSSLCertificateValidation() { final DefaultHTTPClient httpClient = DefaultHTTPClient.create(DefaultHTTPClientConfig.create()); - final javax.net.ssl.SSLHandshakeException sslException = - new javax.net.ssl.SSLHandshakeException("Certificate validation failed"); + final javax.net.ssl.SSLHandshakeException sslException = new javax.net.ssl.SSLHandshakeException( + "Certificate validation failed"); when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { final FutureCallback callback = invocation.getArgument(1); callback.failed(sslException); diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java index 865ac75..5b246c8 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java @@ -16,6 +16,7 @@ import org.mockito.stubbing.Answer; import com.absmartly.sdk.TestUtils; + import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @SuppressFBWarnings(value = "RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index db9a55a..4601f05 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -2,7 +2,7 @@ apply plugin: "jacoco" jacoco { - toolVersion = "0.8.6" + toolVersion = "0.8.12" } @@ -10,7 +10,7 @@ project.tasks.withType(JacocoReport) { group "Verification" reports { - xml.enabled = true - html.enabled = true + xml.required = true + html.required = true } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 790bdbf..c84565a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,4 @@ -#Thu May 28 18:38:14 CEST 2020 -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists From 38327cb8792bccec8c264f9a30be73082a3139fd Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sat, 21 Feb 2026 20:41:04 +0000 Subject: [PATCH 07/83] fix: resolve critical bugs for 100% test pass rate - Fix deadlock in Context.flush() (read lock upgrade to write lock) - Fix AudienceMatcher.evaluate() crash on empty/null audience strings - Fix deserializers to return null instead of throwing IllegalStateException - Fix double error logging in closeAsync() - Add JsonMapperUtils for consistent ObjectMapper configuration - Fix scheduler shutdown, Client HTTPS handling, MatchOperator patterns --- .../java/com/absmartly/sdk/ABsmartly.java | 10 +- .../com/absmartly/sdk/AudienceMatcher.java | 3 + .../main/java/com/absmartly/sdk/Client.java | 19 +- .../main/java/com/absmartly/sdk/Context.java | 204 +++++++++++------- .../java/com/absmartly/sdk/ContextConfig.java | 24 +-- .../sdk/DefaultAudienceDeserializer.java | 10 +- .../sdk/DefaultContextDataDeserializer.java | 9 +- .../sdk/DefaultContextEventSerializer.java | 2 +- .../absmartly/sdk/DefaultVariableParser.java | 12 +- .../com/absmartly/sdk/internal/Algorithm.java | 1 + .../sdk/internal/JsonMapperUtils.java | 14 ++ .../com/absmartly/sdk/java/time/Clock.java | 8 +- .../absmartly/sdk/jsonexpr/ExprEvaluator.java | 13 +- .../sdk/jsonexpr/operators/InOperator.java | 3 +- .../sdk/jsonexpr/operators/MatchOperator.java | 49 ++++- 15 files changed, 261 insertions(+), 120 deletions(-) create mode 100644 core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java 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 f2627af..4fea3e8 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -77,9 +77,15 @@ public void close() throws IOException { } if (scheduler_ != null) { + scheduler_.shutdown(); try { - scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS); - } catch (InterruptedException ignored) {} + if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) { + scheduler_.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + scheduler_.shutdownNow(); + } scheduler_ = null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java b/core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java index f3b9a7a..3fb3aea 100644 --- a/core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java +++ b/core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java @@ -25,6 +25,9 @@ public boolean get() { } public Result evaluate(String audience, Map attributes) { + if (audience == null || audience.isEmpty()) { + return null; + } final byte[] bytes = audience.getBytes(StandardCharsets.UTF_8); final Map audienceMap = deserializer_.deserialize(bytes, 0, bytes.length); if (audienceMap != null) { 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..2a8c877 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Client.java +++ b/core-api/src/main/java/com/absmartly/sdk/Client.java @@ -31,6 +31,16 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H throw new IllegalArgumentException("Missing Endpoint configuration"); } + if (!endpoint.startsWith("https://")) { + if (endpoint.startsWith("http://")) { + System.err.println( + "WARNING: ABSmartly SDK endpoint is not using HTTPS. API keys will be transmitted in plaintext: " + + endpoint); + } else { + throw new IllegalArgumentException("Endpoint must use http:// or https:// protocol: " + endpoint); + } + } + final String apiKey = config.getAPIKey(); if ((apiKey == null) || apiKey.isEmpty()) { throw new IllegalArgumentException("Missing APIKey configuration"); @@ -86,8 +96,13 @@ public void accept(HTTPClient.Response response) { final int code = response.getStatusCode(); if ((code / 100) == 2) { final byte[] content = response.getContent(); - dataFuture.complete( - deserializer_.deserialize(response.getContent(), 0, content.length)); + if (content == null || content.length == 0) { + dataFuture.completeExceptionally(new IllegalStateException( + "Empty response body from context data endpoint")); + } else { + dataFuture.complete( + deserializer_.deserialize(content, 0, content.length)); + } } else { dataFuture.completeExceptionally(new Exception(response.getStatusMessage())); } 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 7928ed9..a3673a2 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -7,6 +7,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java8.util.concurrent.CompletableFuture; @@ -17,6 +18,9 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.absmartly.sdk.internal.Algorithm; import com.absmartly.sdk.internal.Concurrency; import com.absmartly.sdk.internal.VariantAssigner; @@ -26,6 +30,8 @@ import com.absmartly.sdk.json.*; public class Context implements Closeable { + private static final Logger log = LoggerFactory.getLogger(Context.class); + public static Context create(@Nonnull final Clock clock, @Nonnull final ContextConfig config, @Nonnull final ScheduledExecutorService scheduler, @Nonnull final CompletableFuture dataFuture, @Nonnull final ContextDataProvider dataProvider, @@ -87,13 +93,16 @@ public Void apply(Throwable exception) { } }); } else { - readyFuture_ = new CompletableFuture(); + final CompletableFuture newReadyFuture = new CompletableFuture(); + readyFuture_.set(newReadyFuture); dataFuture.thenAccept(new Consumer() { @Override public void accept(ContextData data) { Context.this.setData(data); - readyFuture_.complete(null); - readyFuture_ = null; + final CompletableFuture rf = readyFuture_.getAndSet(COMPLETED_VOID_FUTURE); + if (rf != null) { + rf.complete(null); + } Context.this.logEvent(ContextEventLogger.EventType.Ready, data); @@ -105,8 +114,10 @@ public void accept(ContextData data) { @Override public Void apply(Throwable exception) { Context.this.setDataFailed(exception); - readyFuture_.complete(null); - readyFuture_ = null; + final CompletableFuture rf = readyFuture_.getAndSet(COMPLETED_VOID_FUTURE); + if (rf != null) { + rf.complete(null); + } Context.this.logError(exception); @@ -136,18 +147,22 @@ public CompletableFuture waitUntilReadyAsync() { if (data_ != null) { return CompletableFuture.completedFuture(this); } else { - return readyFuture_.thenApply(new Function() { - @Override - public Context apply(Void k) { - return Context.this; - } - }); + final CompletableFuture rf = readyFuture_.get(); + if (rf != null) { + return rf.thenApply(new Function() { + @Override + public Context apply(Void k) { + return Context.this; + } + }); + } + return CompletableFuture.completedFuture(this); } } public Context waitUntilReady() { if (data_ == null) { - final CompletableFuture future = readyFuture_; // cache here to avoid locking + final CompletableFuture future = readyFuture_.get(); // cache here to avoid locking if (future != null && !future.isDone()) { future.join(); } @@ -508,14 +523,15 @@ public CompletableFuture refreshAsync() { checkNotClosed(); if (refreshing_.compareAndSet(false, true)) { - refreshFuture_ = new CompletableFuture(); + final CompletableFuture newRefreshFuture = new CompletableFuture(); + refreshFuture_.set(newRefreshFuture); dataProvider_.getContextData().thenAccept(new Consumer() { @Override public void accept(ContextData data) { Context.this.setData(data); refreshing_.set(false); - refreshFuture_.complete(null); + newRefreshFuture.complete(null); Context.this.logEvent(ContextEventLogger.EventType.Refresh, data); } @@ -523,7 +539,7 @@ public void accept(ContextData data) { @Override public Void apply(Throwable exception) { refreshing_.set(false); - refreshFuture_.completeExceptionally(exception); + newRefreshFuture.completeExceptionally(exception); Context.this.logError(exception); return null; @@ -531,7 +547,7 @@ public Void apply(Throwable exception) { }); } - final CompletableFuture future = refreshFuture_; + final CompletableFuture future = refreshFuture_.get(); if (future != null) { return future; } @@ -549,14 +565,15 @@ public CompletableFuture closeAsync() { clearRefreshTimer(); if (pendingCount_.get() > 0) { - closingFuture_ = new CompletableFuture(); + final CompletableFuture newClosingFuture = new CompletableFuture(); + closingFuture_.set(newClosingFuture); flush().thenAccept(new Consumer() { @Override public void accept(Void x) { closed_.set(true); closing_.set(false); - closingFuture_.complete(null); + newClosingFuture.complete(null); Context.this.logEvent(ContextEventLogger.EventType.Close, null); } @@ -565,14 +582,13 @@ public void accept(Void x) { public Void apply(Throwable exception) { closed_.set(true); closing_.set(false); - closingFuture_.completeExceptionally(exception); - // event logger gets this error during publish + newClosingFuture.completeExceptionally(exception); return null; } }); - return closingFuture_; + return newClosingFuture; } else { closed_.set(true); closing_.set(false); @@ -581,7 +597,7 @@ public Void apply(Throwable exception) { } } - final CompletableFuture future = closingFuture_; + final CompletableFuture future = closingFuture_.get(); if (future != null) { return future; } @@ -629,16 +645,22 @@ private CompletableFuture flush() { final PublishEvent event = new PublishEvent(); event.hashed = true; event.publishedAt = clock_.millis(); - event.units = Algorithm.mapSetToArray(units_.entrySet(), new Unit[0], - new Function, Unit>() { - @Override - public Unit apply(Map.Entry entry) { - return new Unit(entry.getKey(), - new String(getUnitHash(entry.getKey(), entry.getValue()), - StandardCharsets.US_ASCII)); - } - }); - event.attributes = attributes_.isEmpty() ? null : attributes_.toArray(new Attribute[0]); + + try { + contextLock_.writeLock().lock(); + event.units = Algorithm.mapSetToArray(units_.entrySet(), new Unit[0], + new Function, Unit>() { + @Override + public Unit apply(Map.Entry entry) { + return new Unit(entry.getKey(), + new String(getUnitHash(entry.getKey(), entry.getValue()), + StandardCharsets.US_ASCII)); + } + }); + event.attributes = attributes_.isEmpty() ? null : attributes_.toArray(new Attribute[0]); + } finally { + contextLock_.writeLock().unlock(); + } event.exposures = exposures; event.goals = achievements; @@ -695,7 +717,7 @@ private void checkReady(final boolean expectNotClosed) { private boolean experimentMatches(final Experiment experiment, final Assignment assignment) { return experiment.id == assignment.id && - experiment.unitType.equals(assignment.unitType) && + (experiment.unitType != null && experiment.unitType.equals(assignment.unitType)) && experiment.iteration == assignment.iteration && experiment.fullOnVariant == assignment.fullOnVariant && Arrays.equals(experiment.trafficSplit, assignment.trafficSplit); @@ -704,10 +726,7 @@ private boolean experimentMatches(final Experiment experiment, final Assignment private boolean audienceMatches(final Experiment experiment, final Assignment assignment) { if (experiment.audience != null && experiment.audience.length() > 0) { if (attrsSeq_.get() > assignment.attrsSeq) { - final Map attrs = new HashMap(attributes_.size()); - for (final Attribute attr : attributes_) { - attrs.put(attr.name, attr.value); - } + final Map attrs = buildAttributesMap(); final AudienceMatcher.Result match = audienceMatcher_.evaluate(experiment.audience, attrs); final boolean newAudienceMismatch = (match != null) ? !match.get() : false; @@ -801,10 +820,7 @@ && audienceMatches(experiment.data, assignment)) { final String unitType = experiment.data.unitType; if (experiment.data.audience != null && experiment.data.audience.length() > 0) { - final Map attrs = new HashMap(attributes_.size()); - for (final Attribute attr : attributes_) { - attrs.put(attr.name, attr.value); - } + final Map attrs = buildAttributesMap(); final AudienceMatcher.Result match = audienceMatcher_ .evaluate(experiment.data.audience, attrs); @@ -923,7 +939,13 @@ private void setTimeout() { timeout_ = scheduler_.schedule(new Runnable() { @Override public void run() { - Context.this.flush(); + Context.this.flush().exceptionally(new Function() { + @Override + public Void apply(Throwable exception) { + Context.this.logError(exception); + return null; + } + }); } }, publishDelay_, TimeUnit.MILLISECONDS); } @@ -978,6 +1000,10 @@ private static class ContextCustomFieldValue { } private void setData(final ContextData data) { + if (data == null) { + throw new IllegalArgumentException("Context data cannot be null"); + } + final Map index = new HashMap(); final Map> indexVariables = new HashMap>(); @@ -988,30 +1014,40 @@ private void setData(final ContextData data) { for (final ExperimentVariant variant : experiment.variants) { if ((variant.config != null) && !variant.config.isEmpty()) { - final Map variables = variableParser_.parse(this, experiment.name, variant.name, - variant.config); - for (final String key : variables.keySet()) { - List keyExperimentVariables = indexVariables.get(key); - if (keyExperimentVariables == null) { - keyExperimentVariables = new ArrayList(); - indexVariables.put(key, keyExperimentVariables); - } + try { + final Map variables = variableParser_.parse(this, experiment.name, variant.name, + variant.config); + if (variables != null) { + for (final String key : variables.keySet()) { + List keyExperimentVariables = indexVariables.get(key); + if (keyExperimentVariables == null) { + keyExperimentVariables = new ArrayList(); + indexVariables.put(key, keyExperimentVariables); + } - int at = Collections.binarySearch(keyExperimentVariables, contextExperiment, - new Comparator() { - @Override - public int compare(ContextExperiment a, ContextExperiment b) { - return Integer.valueOf(a.data.id).compareTo(b.data.id); - } - }); + int at = Collections.binarySearch(keyExperimentVariables, contextExperiment, + new Comparator() { + @Override + public int compare(ContextExperiment a, ContextExperiment b) { + return Integer.valueOf(a.data.id).compareTo(b.data.id); + } + }); + + if (at < 0) { + at = -at - 1; + keyExperimentVariables.add(at, contextExperiment); + } + } - if (at < 0) { - at = -at - 1; - keyExperimentVariables.add(at, contextExperiment); + contextExperiment.variables.add(variables); + } else { + contextExperiment.variables.add(Collections. emptyMap()); } + } catch (Exception e) { + log.error("Failed to parse variant config for experiment '{}', variant '{}': {}", + experiment.name, variant.name, e.getMessage()); + contextExperiment.variables.add(Collections. emptyMap()); } - - contextExperiment.variables.add(variables); } else { contextExperiment.variables.add(Collections. emptyMap()); } @@ -1025,13 +1061,24 @@ public int compare(ContextExperiment a, ContextExperiment b) { value.type = customFieldValue.getType(); if (customFieldValue.getValue() != null) { - if (customFieldValue.getType().startsWith("json")) { - value.value = variableParser_.parse(this, experiment.name, customFieldValue.getValue()); - } else if (customFieldValue.getType().equals("boolean")) { - value.value = Boolean.parseBoolean(customFieldValue.getValue()); - } else if (customFieldValue.getType().equals("number")) { - value.value = Double.parseDouble(customFieldValue.getValue()); - } else { + try { + final String type = customFieldValue.getType(); + if (type != null && type.startsWith("json")) { + value.value = variableParser_.parse(this, experiment.name, customFieldValue.getValue()); + } else if (type != null && type.equals("boolean")) { + value.value = Boolean.parseBoolean(customFieldValue.getValue()); + } else if (type != null && type.equals("number")) { + value.value = Double.parseDouble(customFieldValue.getValue()); + } else { + value.value = customFieldValue.getValue(); + } + } catch (NumberFormatException e) { + log.warn("Failed to parse custom field number value for experiment '{}': {}", + experiment.name, e.getMessage()); + value.value = customFieldValue.getValue(); + } catch (Exception e) { + log.warn("Failed to parse custom field value for experiment '{}': {}", experiment.name, + e.getMessage()); value.value = customFieldValue.getValue(); } } @@ -1081,6 +1128,14 @@ private void logError(Throwable error) { } } + private Map buildAttributesMap() { + final Map attrs = new HashMap(attributes_.size()); + for (final Attribute attr : attributes_) { + attrs.put(attr.name, attr.value); + } + return attrs; + } + private final Clock clock_; private final long publishDelay_; private final long refreshInterval_; @@ -1091,10 +1146,10 @@ private void logError(Throwable error) { private final AudienceMatcher audienceMatcher_; private final ScheduledExecutorService scheduler_; private final Map units_; - private boolean failed_; + private volatile boolean failed_; private final ReentrantReadWriteLock dataLock_ = new ReentrantReadWriteLock(); - private ContextData data_; + private volatile ContextData data_; private Map index_; private Map> indexVariables_; private final ReentrantReadWriteLock contextLock_ = new ReentrantReadWriteLock(); @@ -1117,9 +1172,10 @@ private void logError(Throwable error) { private final AtomicBoolean closed_ = new AtomicBoolean(false); private final AtomicBoolean refreshing_ = new AtomicBoolean(false); - private volatile CompletableFuture readyFuture_; - private volatile CompletableFuture closingFuture_; - private volatile CompletableFuture refreshFuture_; + private static final CompletableFuture COMPLETED_VOID_FUTURE = CompletableFuture.completedFuture(null); + private final AtomicReference> readyFuture_ = new AtomicReference>(); + private final AtomicReference> closingFuture_ = new AtomicReference>(); + private final AtomicReference> refreshFuture_ = new AtomicReference>(); private final ReentrantLock timeoutLock_ = new ReentrantLock(); private volatile ScheduledFuture timeout_ = null; diff --git a/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java b/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java index 9bdd6ab..a6ecbfc 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java @@ -29,7 +29,7 @@ public ContextConfig setUnits(@Nonnull final Map units) { } public String getUnit(@Nonnull final String unitType) { - return units_.get(unitType); + return units_ != null ? units_.get(unitType) : null; } public Map getUnits() { @@ -53,7 +53,7 @@ public ContextConfig setAttributes(@Nonnull final Map attributes } public Object getAttribute(@Nonnull final String name) { - return this.attributes_.get(name); + return this.attributes_ != null ? this.attributes_.get(name) : null; } public Map getAttributes() { @@ -77,7 +77,7 @@ public ContextConfig setOverrides(@Nonnull final Map overrides) } public Object getOverride(@Nonnull final String experimentName) { - return this.overrides_.get(experimentName); + return this.overrides_ != null ? this.overrides_.get(experimentName) : null; } public Map getOverrides() { @@ -85,27 +85,27 @@ public Map getOverrides() { } public ContextConfig setCustomAssignment(@Nonnull final String experimentName, int variant) { - if (cassigmnents_ == null) { - cassigmnents_ = new HashMap(); + if (cassignments_ == null) { + cassignments_ = new HashMap(); } - cassigmnents_.put(experimentName, variant); + cassignments_.put(experimentName, variant); return this; } public ContextConfig setCustomAssignments(@Nonnull final Map customAssignments) { - if (cassigmnents_ == null) { - cassigmnents_ = new HashMap(customAssignments.size()); + if (cassignments_ == null) { + cassignments_ = new HashMap(customAssignments.size()); } - cassigmnents_.putAll(customAssignments); + cassignments_.putAll(customAssignments); return this; } public Object getCustomAssignment(@Nonnull final String experimentName) { - return this.cassigmnents_.get(experimentName); + return this.cassignments_ != null ? this.cassignments_.get(experimentName) : null; } public Map getCustomAssignments() { - return this.cassigmnents_; + return this.cassignments_; } public ContextEventLogger getEventLogger() { @@ -138,7 +138,7 @@ public long getRefreshInterval() { private Map units_; private Map attributes_; private Map overrides_; - private Map cassigmnents_; + private Map cassignments_; private ContextEventLogger eventLogger_; diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultAudienceDeserializer.java b/core-api/src/main/java/com/absmartly/sdk/DefaultAudienceDeserializer.java index a93c0b6..465f6cb 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultAudienceDeserializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultAudienceDeserializer.java @@ -8,19 +8,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; +import com.absmartly.sdk.internal.JsonMapperUtils; + public class DefaultAudienceDeserializer implements AudienceDeserializer { private static final Logger log = LoggerFactory.getLogger(DefaultAudienceDeserializer.class); private final ObjectReader reader_; public DefaultAudienceDeserializer() { - final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(MapperFeature.USE_STATIC_TYPING); - this.reader_ = objectMapper.readerForMapOf(Object.class); + this.reader_ = JsonMapperUtils.createStandardObjectMapper().readerForMapOf(Object.class); } @Override @@ -28,7 +26,7 @@ public Map deserialize(@Nonnull byte[] bytes, int offset, int le try { return reader_.readValue(bytes, offset, length); } catch (IOException e) { - log.error("", e); + log.error("Failed to deserialize audience data: {}", e.getMessage(), e); return null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java b/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java index b28eb79..0d49e49 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java @@ -7,26 +7,23 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; +import com.absmartly.sdk.internal.JsonMapperUtils; import com.absmartly.sdk.json.ContextData; public class DefaultContextDataDeserializer implements ContextDataDeserializer { private static final Logger log = LoggerFactory.getLogger(DefaultContextDataDeserializer.class); public DefaultContextDataDeserializer() { - final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(MapperFeature.USE_STATIC_TYPING); - this.reader_ = objectMapper.readerFor(ContextData.class); + this.reader_ = JsonMapperUtils.createStandardObjectMapper().readerFor(ContextData.class); } public ContextData deserialize(@Nonnull final byte[] bytes, final int offset, final int length) { try { return reader_.readValue(bytes, offset, length); } catch (IOException e) { - log.error("", e); + log.error("Failed to deserialize context data: {}", e.getMessage(), e); return null; } } 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..f729520 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java @@ -28,7 +28,7 @@ public byte[] serialize(@Nonnull final PublishEvent event) { try { return writer_.writeValueAsBytes(event); } catch (JsonProcessingException e) { - log.error("", e); + log.error("Failed to serialize publish event: {}", e.getMessage(), e); return null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultVariableParser.java b/core-api/src/main/java/com/absmartly/sdk/DefaultVariableParser.java index 16375d8..9b6f67f 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultVariableParser.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultVariableParser.java @@ -9,17 +9,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.type.TypeFactory; +import com.absmartly.sdk.internal.JsonMapperUtils; + public class DefaultVariableParser implements VariableParser { private static final Logger log = LoggerFactory.getLogger(DefaultVariableParser.class); public DefaultVariableParser() { - final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(MapperFeature.USE_STATIC_TYPING); + final ObjectMapper objectMapper = JsonMapperUtils.createStandardObjectMapper(); + this.reader_ = objectMapper .readerFor(TypeFactory.defaultInstance().constructMapType(HashMap.class, String.class, Object.class)); this.readerGeneric_ = objectMapper.readerFor(Object.class); @@ -30,7 +31,8 @@ public Map parse(@Nonnull final Context context, @Nonnull final try { return reader_.readValue(variableValues); } catch (IOException e) { - log.error("", e); + log.error("Failed to parse variable values for experiment '{}', variant '{}': {}", experimentName, + variantName, e.getMessage(), e); return null; } } @@ -40,7 +42,7 @@ public Object parse(@Nonnull final Context context, @Nonnull final String experi try { return readerGeneric_.readValue(variableValue); } catch (IOException e) { - log.error("", e); + log.error("Failed to parse custom field value for experiment '{}': {}", experimentName, e.getMessage(), e); return null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java b/core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java index cf91a5b..33d3241 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java @@ -4,6 +4,7 @@ import java8.util.function.Function; public class Algorithm { + @SuppressWarnings("unchecked") public static R[] mapSetToArray(Set set, R[] array, Function mapper) { final int size = set.size(); if (array.length < size) { diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java b/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java new file mode 100644 index 0000000..2eef87b --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java @@ -0,0 +1,14 @@ +package com.absmartly.sdk.internal; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class JsonMapperUtils { + public static ObjectMapper createStandardObjectMapper() { + final ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.enable(MapperFeature.USE_STATIC_TYPING); + objectMapper.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY); + return objectMapper; + } +} diff --git a/core-api/src/main/java/com/absmartly/sdk/java/time/Clock.java b/core-api/src/main/java/com/absmartly/sdk/java/time/Clock.java index aea0a45..2c9a972 100644 --- a/core-api/src/main/java/com/absmartly/sdk/java/time/Clock.java +++ b/core-api/src/main/java/com/absmartly/sdk/java/time/Clock.java @@ -11,12 +11,8 @@ static public Clock fixed(long millis) { } static public Clock systemUTC() { - if (utc_ != null) { - return utc_; - } - - return utc_ = new SystemClockUTC(); + return utc_; } - static SystemClockUTC utc_; + static final SystemClockUTC utc_ = new SystemClockUTC(); } diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java index 119a7e9..975e400 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java @@ -6,7 +6,12 @@ import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + public class ExprEvaluator implements Evaluator { + private static final Logger log = LoggerFactory.getLogger(ExprEvaluator.class); + final static ThreadLocal formatter = new ThreadLocal() { @Override public DecimalFormat initialValue() { @@ -34,6 +39,10 @@ public Object evaluate(Object expr) { final Operator op = operators.get(entry.getKey()); if (op != null) { return op.evaluate(this, entry.getValue()); + } else { + log.warn( + "Unknown operator in audience expression: '{}'. This may be a forward compatibility issue with newer server versions.", + entry.getKey()); } break; } @@ -63,7 +72,7 @@ public Double numberConvert(Object x) { } else if (x instanceof String) { try { return Double.parseDouble((String) x); // use javascript semantics: numbers are doubles - } catch (Throwable ignored) {} + } catch (NumberFormatException ignored) {} } return null; @@ -93,7 +102,7 @@ public Object extractVar(String path) { final List list = (List) target; try { value = list.get(Integer.parseInt(frag)); - } catch (Throwable ignored) {} + } catch (NumberFormatException ignored) {} catch (IndexOutOfBoundsException ignored) {} } else if (target instanceof Map) { final Map map = (Map) target; value = map.get(frag); diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java index 6f4fdf2..7207200 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java @@ -10,7 +10,8 @@ public class InOperator extends BinaryOperator { public Object binary(Evaluator evaluator, Object haystack, Object needle) { if (haystack instanceof List) { for (final Object item : (List) haystack) { - if (evaluator.compare(item, needle) == 0) { + final Integer cmp = evaluator.compare(item, needle); + if (cmp != null && cmp == 0) { return true; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index 4f05858..cc0f6f1 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -1,22 +1,65 @@ package com.absmartly.sdk.jsonexpr.operators; +import java.util.concurrent.*; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.absmartly.sdk.jsonexpr.Evaluator; public class MatchOperator extends BinaryOperator { + private static final Logger log = LoggerFactory.getLogger(MatchOperator.class); + private static final int MAX_PATTERN_LENGTH = 1000; + private static final int REGEX_TIMEOUT_MS = 100; + private static final ExecutorService executor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r); + t.setDaemon(true); + return t; + }); + @Override public Object binary(Evaluator evaluator, Object lhs, Object rhs) { final String text = evaluator.stringConvert(lhs); if (text != null) { final String pattern = evaluator.stringConvert(rhs); if (pattern != null) { + // Validate pattern length to prevent ReDoS + if (pattern.length() > MAX_PATTERN_LENGTH) { + log.warn("Regex pattern exceeds maximum length of {}: {}", MAX_PATTERN_LENGTH, + pattern.substring(0, 50) + "..."); + return null; + } + try { final Pattern compiled = Pattern.compile(pattern); - final Matcher matcher = compiled.matcher(text); - return matcher.find(); - } catch (Throwable ignored) {} + + // Execute regex matching with timeout to prevent ReDoS + Future future = executor.submit(() -> { + final Matcher matcher = compiled.matcher(text); + return matcher.find(); + }); + + try { + return future.get(REGEX_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + future.cancel(true); + log.warn("Regex pattern timed out after {}ms, possible ReDoS attack: {}", REGEX_TIMEOUT_MS, + pattern); + return null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (ExecutionException e) { + log.warn("Regex execution failed: {}", e.getCause().getMessage()); + return null; + } + } catch (PatternSyntaxException e) { + log.warn("Invalid regex pattern from server: {}", e.getMessage()); + return null; + } } } return null; From a7a36048c55455c8a9d30b15d02516cb3579afdf Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 22 Feb 2026 00:39:24 +0000 Subject: [PATCH 08/83] fix: correct IN operator arg order and null equality handling - Fix InOperator: args are [needle, haystack] not [haystack, needle] - Fix EqualsOperator: handle null==null comparison (returns true) - Update unit tests to match corrected argument ordering --- .../jsonexpr/operators/EqualsOperator.java | 23 ++++- .../sdk/jsonexpr/operators/InOperator.java | 2 +- .../operators/EqualsOperatorTest.java | 3 +- .../jsonexpr/operators/InOperatorTest.java | 93 ++++--------------- 4 files changed, 40 insertions(+), 81 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java index 7406df1..5a3972e 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java @@ -1,11 +1,26 @@ package com.absmartly.sdk.jsonexpr.operators; +import java.util.List; + import com.absmartly.sdk.jsonexpr.Evaluator; +import com.absmartly.sdk.jsonexpr.Operator; -public class EqualsOperator extends BinaryOperator { +public class EqualsOperator implements Operator { @Override - public Object binary(Evaluator evaluator, Object lhs, Object rhs) { - final Integer result = evaluator.compare(lhs, rhs); - return (result != null) ? (result == 0) : null; + public Object evaluate(Evaluator evaluator, Object args) { + if (args instanceof List) { + final List argsList = (List) args; + final Object lhs = argsList.size() > 0 ? evaluator.evaluate(argsList.get(0)) : null; + final Object rhs = argsList.size() > 1 ? evaluator.evaluate(argsList.get(1)) : null; + if (lhs == null && rhs == null) { + return true; + } + if (lhs == null || rhs == null) { + return null; + } + final Integer result = evaluator.compare(lhs, rhs); + return (result != null) ? (result == 0) : null; + } + return null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java index 7207200..ff3208f 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java @@ -7,7 +7,7 @@ public class InOperator extends BinaryOperator { @Override - public Object binary(Evaluator evaluator, Object haystack, Object needle) { + public Object binary(Evaluator evaluator, Object needle, Object haystack) { if (haystack instanceof List) { for (final Object item : (List) haystack) { final Integer cmp = evaluator.compare(item, needle); diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperatorTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperatorTest.java index 428eef9..88cd6cf 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperatorTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperatorTest.java @@ -35,8 +35,7 @@ void testEvaluate() { Mockito.clearInvocations(evaluator); - assertNull(operator.evaluate(evaluator, listOf(null, null))); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(any()); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(null, null))); verify(evaluator, Mockito.timeout(5000).times(0)).compare(any(), any()); Mockito.clearInvocations(evaluator); diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/InOperatorTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/InOperatorTest.java index 9c07cde..85cb3e7 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/InOperatorTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/InOperatorTest.java @@ -15,34 +15,20 @@ class InOperatorTest extends OperatorTest { @Test void testString() { - assertTrue((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "abc"))); - assertTrue((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "def"))); - assertFalse((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "xxx"))); - assertNull(operator.evaluate(evaluator, listOf("abcdefghijk", null))); - assertNull(operator.evaluate(evaluator, listOf(null, "abc"))); - - verify(evaluator, Mockito.timeout(5000).times(4)).evaluate("abcdefghijk"); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("abc"); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("def"); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("xxx"); - - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("abc"); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("def"); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("xxx"); + assertTrue((Boolean) operator.evaluate(evaluator, listOf("abc", "abcdefghijk"))); + assertTrue((Boolean) operator.evaluate(evaluator, listOf("def", "abcdefghijk"))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf("xxx", "abcdefghijk"))); + assertNull(operator.evaluate(evaluator, listOf(null, "abcdefghijk"))); + assertNull(operator.evaluate(evaluator, listOf("abc", null))); } @Test void testArrayEmpty() { - assertFalse((Boolean) operator.evaluate(evaluator, listOf(listOf(), 1))); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(listOf(), "1"))); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(listOf(), true))); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(listOf(), false))); - assertNull(operator.evaluate(evaluator, listOf(listOf(), null))); - - verify(evaluator, Mockito.timeout(5000).times(0)).booleanConvert(any()); - verify(evaluator, Mockito.timeout(5000).times(0)).numberConvert(any()); - verify(evaluator, Mockito.timeout(5000).times(0)).stringConvert(any()); - verify(evaluator, Mockito.timeout(5000).times(0)).compare(any(), any()); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(1, listOf()))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf("1", listOf()))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(true, listOf()))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(false, listOf()))); + assertNull(operator.evaluate(evaluator, listOf(null, listOf()))); } @Test @@ -50,35 +36,19 @@ void testArrayCompares() { final List haystack01 = listOf(0, 1); final List haystack12 = listOf(1, 2); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(haystack01, 2))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystack01); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(2); - verify(evaluator, Mockito.timeout(5000).times(2)).compare(anyInt(), ArgumentMatchers.eq(2)); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(2, haystack01))); Mockito.clearInvocations(evaluator); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(haystack12, 0))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystack12); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(0); - verify(evaluator, Mockito.timeout(5000).times(2)).compare(anyInt(), ArgumentMatchers.eq(0)); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(0, haystack12))); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystack12, 1))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystack12); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(1); - verify(evaluator, Mockito.timeout(5000).times(1)).compare(anyInt(), ArgumentMatchers.eq(1)); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(1, haystack12))); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystack12, 2))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystack12); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(2); - verify(evaluator, Mockito.timeout(5000).times(2)).compare(anyInt(), ArgumentMatchers.eq(2)); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(2, haystack12))); Mockito.clearInvocations(evaluator); } @@ -88,44 +58,19 @@ void testObject() { final Map haystackab = mapOf("a", 1, "b", 2); final Map haystackbc = mapOf("b", 2, "c", 3, "0", 100); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(haystackab, "c"))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackab); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("c"); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("c"); + assertFalse((Boolean) operator.evaluate(evaluator, listOf("c", haystackab))); Mockito.clearInvocations(evaluator); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(haystackbc, "a"))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackbc); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("a"); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("a"); + assertFalse((Boolean) operator.evaluate(evaluator, listOf("a", haystackbc))); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystackbc, "b"))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackbc); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("b"); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("b"); + assertTrue((Boolean) operator.evaluate(evaluator, listOf("b", haystackbc))); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystackbc, "c"))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackbc); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("c"); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("c"); + assertTrue((Boolean) operator.evaluate(evaluator, listOf("c", haystackbc))); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystackbc, 0))); - verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackbc); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); - verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(0); - verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(0); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(0, haystackbc))); Mockito.clearInvocations(evaluator); } } From 76e0293b55c8b526a8ab0d303eeb88eb57555da3 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 26 Feb 2026 22:49:58 +0000 Subject: [PATCH 09/83] feat: add convenience ABsmartly.create(endpoint, apiKey, app, env) factory Allows creating a fully configured SDK instance in a single call instead of manually creating ClientConfig + ABsmartlyConfig + Client. --- README.md | 33 ++++--------------- .../java/com/absmartly/sdk/ABsmartly.java | 14 ++++++++ .../java/com/absmartly/sdk/ABsmartlyTest.java | 22 +++++++++++++ 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b25c1a5..a6fa759 100644 --- a/README.md +++ b/README.md @@ -63,38 +63,19 @@ Please follow the [installation](#installation) instructions before trying the f This example assumes an Api Key, an Application, and an Environment have been created in the A/B Smartly web console. -#### Recommended: Builder Pattern +#### Quickstart ```java import com.absmartly.sdk.*; -public class Example { - static public void main(String[] args) { - final ABsmartly sdk = ABsmartly.builder() - .endpoint("https://your-company.absmartly.io/v1") - .apiKey("YOUR-API-KEY") - .application("website") - .environment("development") - .build(); - // ... - } -} +final ABsmartly sdk = ABsmartly.create( + "https://your-company.absmartly.io/v1", + System.getenv("ABSMARTLY_APIKEY"), + "website", + "production"); ``` -The builder pattern provides a clean, fluent API with named parameters. This is the recommended approach for initializing the SDK. - -#### With Optional Parameters - -```java -final ABsmartly sdk = ABsmartly.builder() - .endpoint("https://your-company.absmartly.io/v1") - .apiKey("YOUR-API-KEY") - .application("website") - .environment("development") - .timeout(5000) // connection timeout in milliseconds - .retries(3) // max retry attempts - .build(); -``` +This convenience factory accepts `endpoint`, `apiKey`, `application`, and `environment` directly, removing the need to configure `ClientConfig` and `ABsmartlyConfig` manually. #### Advanced Configuration 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 4fea3e8..c905c76 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -17,6 +17,20 @@ public static ABsmartly create(@Nonnull ABsmartlyConfig config) { return new ABsmartly(config); } + public static ABsmartly create(@Nonnull String endpoint, @Nonnull String apiKey, + @Nonnull String application, @Nonnull String environment) { + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint(endpoint) + .setAPIKey(apiKey) + .setApplication(application) + .setEnvironment(environment); + + final ABsmartlyConfig config = ABsmartlyConfig.create() + .setClient(Client.create(clientConfig)); + + return create(config); + } + protected ABsmartly(@Nonnull ABsmartlyConfig config) { contextDataProvider_ = config.getContextDataProvider(); contextEventHandler_ = config.getContextEventHandler(); 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 7f29123..2a4091c 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java @@ -44,6 +44,28 @@ void createThrowsWithInvalidConfig() { }, "Missing Client instance configuration"); } + @Test + void createWithConnectionParams() { + try (final MockedStatic clientStatic = mockStatic(Client.class); + final MockedConstruction dataProviderCtor = mockConstruction( + DefaultContextDataProvider.class)) { + clientStatic.when(() -> Client.create(any(ClientConfig.class))).thenReturn(client); + + final ABsmartly absmartly = ABsmartly.create("https://test.absmartly.io/v1", "test-api-key", "website", + "production"); + assertNotNull(absmartly); + + final ArgumentCaptor clientConfigCaptor = ArgumentCaptor.forClass(ClientConfig.class); + clientStatic.verify(() -> Client.create(clientConfigCaptor.capture()), Mockito.times(1)); + + final ClientConfig capturedClientConfig = clientConfigCaptor.getValue(); + assertEquals("https://test.absmartly.io/v1", capturedClientConfig.getEndpoint()); + assertEquals("test-api-key", capturedClientConfig.getAPIKey()); + assertEquals("website", capturedClientConfig.getApplication()); + assertEquals("production", capturedClientConfig.getEnvironment()); + } + } + @Test void createContext() { final ABsmartlyConfig config = ABsmartlyConfig.create() From e4b79c0f662d9abe5940206cb8024a437061e0be Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 27 Feb 2026 08:48:17 +0000 Subject: [PATCH 10/83] refactor: replace positional create() factory with Builder pattern The positional create(endpoint, apiKey, app, env) required users to know parameter order. Builder pattern provides named parameters: ABsmartly.builder().endpoint(...).apiKey(...).application(...).environment(...).build() --- README.md | 13 ++-- .../java/com/absmartly/sdk/ABsmartly.java | 71 +++++++++++++++---- .../java/com/absmartly/sdk/ABsmartlyTest.java | 54 +++++++++++++- 3 files changed, 117 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a6fa759..c7c7fb1 100644 --- a/README.md +++ b/README.md @@ -68,14 +68,15 @@ This example assumes an Api Key, an Application, and an Environment have been cr ```java import com.absmartly.sdk.*; -final ABsmartly sdk = ABsmartly.create( - "https://your-company.absmartly.io/v1", - System.getenv("ABSMARTLY_APIKEY"), - "website", - "production"); +final ABsmartly sdk = ABsmartly.builder() + .endpoint("https://your-company.absmartly.io/v1") + .apiKey(System.getenv("ABSMARTLY_APIKEY")) + .application("website") + .environment("production") + .build(); ``` -This convenience factory accepts `endpoint`, `apiKey`, `application`, and `environment` directly, removing the need to configure `ClientConfig` and `ABsmartlyConfig` manually. +The builder pattern lets you configure the SDK with named parameters, removing the need to configure `ClientConfig` and `ABsmartlyConfig` manually. #### Advanced Configuration 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 c905c76..e08ef0f 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -17,18 +17,65 @@ public static ABsmartly create(@Nonnull ABsmartlyConfig config) { return new ABsmartly(config); } - public static ABsmartly create(@Nonnull String endpoint, @Nonnull String apiKey, - @Nonnull String application, @Nonnull String environment) { - final ClientConfig clientConfig = ClientConfig.create() - .setEndpoint(endpoint) - .setAPIKey(apiKey) - .setApplication(application) - .setEnvironment(environment); - - final ABsmartlyConfig config = ABsmartlyConfig.create() - .setClient(Client.create(clientConfig)); - - return create(config); + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String endpoint; + private String apiKey; + private String application; + private String environment; + private ContextEventLogger eventLogger; + + Builder() {} + + public Builder endpoint(@Nonnull String endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder apiKey(@Nonnull String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder application(@Nonnull String application) { + this.application = application; + return this; + } + + public Builder environment(@Nonnull String environment) { + this.environment = environment; + return this; + } + + public Builder eventLogger(@Nonnull ContextEventLogger eventLogger) { + this.eventLogger = eventLogger; + return this; + } + + public ABsmartly build() { + if (endpoint == null) throw new IllegalArgumentException("endpoint is required"); + if (apiKey == null) throw new IllegalArgumentException("apiKey is required"); + if (application == null) throw new IllegalArgumentException("application is required"); + if (environment == null) throw new IllegalArgumentException("environment is required"); + + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint(endpoint) + .setAPIKey(apiKey) + .setApplication(application) + .setEnvironment(environment); + + final ABsmartlyConfig config = ABsmartlyConfig.create() + .setClient(Client.create(clientConfig)); + + if (eventLogger != null) { + config.setContextEventLogger(eventLogger); + } + + return create(config); + } } protected ABsmartly(@Nonnull ABsmartlyConfig config) { 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 2a4091c..dccc9f4 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java @@ -45,14 +45,18 @@ void createThrowsWithInvalidConfig() { } @Test - void createWithConnectionParams() { + void builderCreatesWithConnectionParams() { try (final MockedStatic clientStatic = mockStatic(Client.class); final MockedConstruction dataProviderCtor = mockConstruction( DefaultContextDataProvider.class)) { clientStatic.when(() -> Client.create(any(ClientConfig.class))).thenReturn(client); - final ABsmartly absmartly = ABsmartly.create("https://test.absmartly.io/v1", "test-api-key", "website", - "production"); + final ABsmartly absmartly = ABsmartly.builder() + .endpoint("https://test.absmartly.io/v1") + .apiKey("test-api-key") + .application("website") + .environment("production") + .build(); assertNotNull(absmartly); final ArgumentCaptor clientConfigCaptor = ArgumentCaptor.forClass(ClientConfig.class); @@ -66,6 +70,50 @@ void createWithConnectionParams() { } } + @Test + void builderThrowsWithMissingEndpoint() { + assertThrows(IllegalArgumentException.class, () -> { + ABsmartly.builder() + .apiKey("test-api-key") + .application("website") + .environment("production") + .build(); + }); + } + + @Test + void builderThrowsWithMissingApiKey() { + assertThrows(IllegalArgumentException.class, () -> { + ABsmartly.builder() + .endpoint("https://test.absmartly.io/v1") + .application("website") + .environment("production") + .build(); + }); + } + + @Test + void builderThrowsWithMissingApplication() { + assertThrows(IllegalArgumentException.class, () -> { + ABsmartly.builder() + .endpoint("https://test.absmartly.io/v1") + .apiKey("test-api-key") + .environment("production") + .build(); + }); + } + + @Test + void builderThrowsWithMissingEnvironment() { + assertThrows(IllegalArgumentException.class, () -> { + ABsmartly.builder() + .endpoint("https://test.absmartly.io/v1") + .apiKey("test-api-key") + .application("website") + .build(); + }); + } + @Test void createContext() { final ABsmartlyConfig config = ABsmartlyConfig.create() From 05a8039a7736173e920adecad4976df0384bac4c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 9 Mar 2026 23:21:09 +0000 Subject: [PATCH 11/83] docs: update README documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c7c7fb1..1ba01c5 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,9 @@ final ABsmartly sdk = ABsmartly.builder() The builder pattern lets you configure the SDK with named parameters, removing the need to configure `ClientConfig` and `ABsmartlyConfig` manually. -#### Advanced Configuration +#### Alternative: Using Configuration Objects -For advanced use cases where you need full control over the Client and configuration: +For use cases where you need full control over the Client and configuration: ```java final ClientConfig clientConfig = ClientConfig.create() From 61dd56cb928a8a9e9199e22d82843c7677bf3051 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 16:21:32 +0000 Subject: [PATCH 12/83] fix: restore events on publish failure and allow override after close - Restore exposures and achievements to queues when publish HTTP call fails, preserving pending count so events are not lost on transient failures - Remove checkNotClosed() from setOverride() and setOverrides() to match JS SDK behavior where overrides can be set after finalization --- .../main/java/com/absmartly/sdk/Context.java | 35 +++++++++--- .../java/com/absmartly/sdk/ContextTest.java | 54 ++++++++++++++----- 2 files changed, 69 insertions(+), 20 deletions(-) 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 a3673a2..d06cec0 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -251,8 +251,6 @@ public ContextData getData() { } public void setOverride(@Nonnull final String experimentName, final int variant) { - checkNotClosed(); - Concurrency.putRW(contextLock_, overrides_, experimentName, variant); } @@ -666,6 +664,10 @@ public Unit apply(Map.Entry entry) { final CompletableFuture result = new CompletableFuture(); + final Exposure[] finalExposures = exposures; + final GoalAchievement[] finalAchievements = achievements; + final int finalEventCount = eventCount; + eventHandler_.publish(this, event).thenRunAsync(new Runnable() { @Override public void run() { @@ -675,6 +677,23 @@ public void run() { }).exceptionally(new Function() { @Override public Void apply(Throwable throwable) { + try { + eventLock_.lock(); + if (finalExposures != null) { + for (int i = finalExposures.length - 1; i >= 0; i--) { + exposures_.add(0, finalExposures[i]); + } + } + if (finalAchievements != null) { + for (int i = finalAchievements.length - 1; i >= 0; i--) { + achievements_.add(0, finalAchievements[i]); + } + } + pendingCount_.set(finalEventCount); + } finally { + eventLock_.unlock(); + } + Context.this.logError(throwable); result.completeExceptionally(throwable); @@ -701,15 +720,15 @@ public Void apply(Throwable throwable) { private void checkNotClosed() { if (closed_.get()) { - throw new IllegalStateException("ABSmartly Context is closed"); + throw new IllegalStateException("ABsmartly Context is closed"); } else if (closing_.get()) { - throw new IllegalStateException("ABSmartly Context is closing"); + throw new IllegalStateException("ABsmartly Context is closing"); } } private void checkReady(final boolean expectNotClosed) { if (!isReady()) { - throw new IllegalStateException("ABSmartly Context is not yet ready"); + throw new IllegalStateException("ABsmartly Context is not yet ready"); } else if (expectNotClosed) { checkNotClosed(); } @@ -871,7 +890,7 @@ && audienceMatches(experiment.data, assignment)) { } } - if ((experiment != null) && assignment.variant >= 0 + if ((experiment != null) && experiment.data.variants != null && assignment.variant >= 0 && (assignment.variant < experiment.data.variants.length)) { assignment.variables = experiment.variables.get(assignment.variant); } @@ -1010,8 +1029,10 @@ private void setData(final ContextData data) { for (final Experiment experiment : data.experiments) { final ContextExperiment contextExperiment = new ContextExperiment(); contextExperiment.data = experiment; - contextExperiment.variables = new ArrayList>(experiment.variants.length); + contextExperiment.variables = new ArrayList>( + experiment.variants != null ? experiment.variants.length : 0); + if (experiment.variants != null) for (final ExperimentVariant variant : experiment.variants) { if ((variant.config != null) && !variant.config.isEmpty()) { try { 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 535a487..d8c4c35 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -321,7 +321,7 @@ void throwsWhenNotReady() { assertFalse(context.isReady()); assertFalse(context.isFailed()); - final String notReadyMessage = "ABSmartly Context is not yet ready"; + final String notReadyMessage = "ABsmartly Context is not yet ready"; assertEquals(notReadyMessage, assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); assertEquals(notReadyMessage, @@ -354,18 +354,13 @@ void throwsWhenClosing() { assertTrue(context.isClosing()); assertFalse(context.isClosed()); - final String closingMessage = "ABSmartly Context is closing"; + final String closingMessage = "ABsmartly Context is closing"; assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setAttributes(mapOf("attr1", "value1"))) .getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.setOverride("exp_test_ab", 2)).getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.setOverrides(mapOf("exp_test_ab", 2))) - .getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setUnit("test", "test")) .getMessage()); @@ -410,18 +405,13 @@ void throwsWhenClosed() { assertFalse(context.isClosing()); assertTrue(context.isClosed()); - final String closedMessage = "ABSmartly Context is closed"; + final String closedMessage = "ABsmartly Context is closed"; assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setAttributes(mapOf("attr1", "value1"))) .getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.setOverride("exp_test_ab", 2)).getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.setOverrides(mapOf("exp_test_ab", 2))) - .getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setUnit("test", "test")) .getMessage()); @@ -2639,6 +2629,44 @@ void getVariableValueConflictingKeyOverlappingAudiences() { assertEquals(1, context.getPendingCount()); } + @Test + void publishKeepsEventsPendingOnFailure() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + assertEquals(1, context.getPendingCount()); + + final Exception failure = new Exception("PUBLISH_FAILED"); + when(eventHandler.publish(any(), any())).thenReturn(failedFuture(failure)); + + assertThrows(CompletionException.class, context::publish); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void setOverrideSucceedsAfterClose() { + final Context context = createReadyContext(); + + context.close(); + assertTrue(context.isClosed()); + + context.setOverride("exp_test_ab", 2); + assertEquals(2, context.getOverride("exp_test_ab")); + } + + @Test + void setOverridesSucceedsAfterClose() { + final Context context = createReadyContext(); + + context.close(); + assertTrue(context.isClosed()); + + context.setOverrides(mapOf("exp_test_ab", 2, "exp_test_abc", 1)); + assertEquals(2, context.getOverride("exp_test_ab")); + assertEquals(1, context.getOverride("exp_test_abc")); + } + @Test void peekVariableValueConflictingKeyOverlappingAudiences() { for (final Experiment experiment : data.experiments) { From b9d48f6a83875e472bace021ccec4c30e1985dcf Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 16:52:35 +0000 Subject: [PATCH 13/83] feat(java-sdk): add readyError() method to Context --- .../main/java/com/absmartly/sdk/Context.java | 6 +++++ .../java/com/absmartly/sdk/ContextTest.java | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) 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 d06cec0..b5f5443 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1122,6 +1122,10 @@ public int compare(ContextExperiment a, ContextExperiment b) { } } + public Throwable readyError() { + return readyError_; + } + private void setDataFailed(final Throwable exception) { try { dataLock_.writeLock().lock(); @@ -1129,6 +1133,7 @@ private void setDataFailed(final Throwable exception) { indexVariables_ = new HashMap>(); data_ = new ContextData(); failed_ = true; + readyError_ = exception; } finally { dataLock_.writeLock().unlock(); } @@ -1168,6 +1173,7 @@ private Map buildAttributesMap() { private final ScheduledExecutorService scheduler_; private final Map units_; private volatile boolean failed_; + private volatile Throwable readyError_; private final ReentrantReadWriteLock dataLock_ = new ReentrantReadWriteLock(); private volatile ContextData data_; 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 d8c4c35..7f80d6f 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -229,6 +230,29 @@ void becomesReadyAndFailedWithException() { assertTrue(context.isFailed()); } + @Test + void readyErrorReturnsNullOnSuccess() { + final Context context = createReadyContext(); + assertNull(context.readyError()); + } + + @Test + void readyErrorReturnsExceptionOnFailedFuture() { + final Context context = createContext(dataFutureFailed); + assertTrue(context.isFailed()); + assertNotNull(context.readyError()); + } + + @Test + void readyErrorReturnsExceptionOnAsyncFailure() { + final Context context = createContext(dataFuture); + final Exception error = new Exception("FAILED"); + dataFuture.completeExceptionally(error); + context.waitUntilReady(); + assertTrue(context.isFailed()); + assertNotNull(context.readyError()); + } + @Test void callsEventLoggerWhenReady() { final Context context = createContext(dataFuture); From f66822a62d88682c91a258228b173642bff097d4 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 17:58:17 +0000 Subject: [PATCH 14/83] feat(java-sdk): add @Deprecated finalize aliases and standardize error messages - Add isFinalized() alias for isClosed() - Add isFinalizing() alias for isClosing() - Add @Deprecated finalize() alias for close() - Add @Deprecated finalizeAsync() alias for closeAsync() - Standardize error messages: "ABsmartly Context is finalized.", "ABsmartly Context is not yet ready.", "Unit '{unitType}' UID already set." - Update tests to reflect new error messages and verify finalize aliases --- .../main/java/com/absmartly/sdk/Context.java | 26 ++++++++++-- .../java/com/absmartly/sdk/ContextTest.java | 41 +++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) 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 b5f5443..9b126bb 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -143,6 +143,14 @@ public boolean isClosing() { return !closed_.get() && closing_.get(); } + public boolean isFinalized() { + return isClosed(); + } + + public boolean isFinalizing() { + return isClosing(); + } + public CompletableFuture waitUntilReadyAsync() { if (data_ != null) { return CompletableFuture.completedFuture(this); @@ -303,7 +311,7 @@ public void setUnit(@Nonnull final String unitType, @Nonnull final String uid) { final String previous = units_.get(unitType); if ((previous != null) && !previous.equals(uid)) { - throw new IllegalArgumentException(String.format("Unit '%s' already set.", unitType)); + throw new IllegalArgumentException(String.format("Unit '%s' UID already set.", unitType)); } final String trimmed = uid.trim(); @@ -609,6 +617,16 @@ public void close() { closeAsync().join(); } + @Deprecated + public CompletableFuture finalizeAsync() { + return closeAsync(); + } + + @Deprecated + public void finalize() { + close(); + } + private CompletableFuture flush() { clearTimeout(); @@ -720,15 +738,15 @@ public Void apply(Throwable throwable) { private void checkNotClosed() { if (closed_.get()) { - throw new IllegalStateException("ABsmartly Context is closed"); + throw new IllegalStateException("ABsmartly Context is finalized."); } else if (closing_.get()) { - throw new IllegalStateException("ABsmartly Context is closing"); + throw new IllegalStateException("ABsmartly Context is closing."); } } private void checkReady(final boolean expectNotClosed) { if (!isReady()) { - throw new IllegalStateException("ABsmartly Context is not yet ready"); + throw new IllegalStateException("ABsmartly Context is not yet ready."); } else if (expectNotClosed) { checkNotClosed(); } 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 7f80d6f..d6d28bf 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -345,7 +345,7 @@ void throwsWhenNotReady() { assertFalse(context.isReady()); assertFalse(context.isFailed()); - final String notReadyMessage = "ABsmartly Context is not yet ready"; + final String notReadyMessage = "ABsmartly Context is not yet ready."; assertEquals(notReadyMessage, assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); assertEquals(notReadyMessage, @@ -378,7 +378,7 @@ void throwsWhenClosing() { assertTrue(context.isClosing()); assertFalse(context.isClosed()); - final String closingMessage = "ABsmartly Context is closing"; + final String closingMessage = "ABsmartly Context is closing."; assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closingMessage, @@ -429,7 +429,7 @@ void throwsWhenClosed() { assertFalse(context.isClosing()); assertTrue(context.isClosed()); - final String closedMessage = "ABsmartly Context is closed"; + final String closedMessage = "ABsmartly Context is finalized."; assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closedMessage, @@ -465,6 +465,41 @@ void throwsWhenClosed() { assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); } + @Test + void finalizeIsAliasForClose() { + final Context context = createReadyContext(); + assertFalse(context.isFinalized()); + assertFalse(context.isFinalizing()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.finalize(); + + assertTrue(context.isFinalized()); + assertTrue(context.isClosed()); + } + + @Test + void finalizeAsyncIsAliasForCloseAsync() { + final Context context = createReadyContext(); + assertFalse(context.isFinalized()); + + context.track("goal1", mapOf("amount", 125, "hours", 245)); + + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + + final CompletableFuture finalizeFuture = context.finalizeAsync(); + assertTrue(context.isFinalizing()); + assertFalse(context.isFinalized()); + + publishFuture.complete(null); + finalizeFuture.join(); + + assertTrue(context.isFinalized()); + assertFalse(context.isFinalizing()); + } + @Test void getExperiments() { final Context context = createReadyContext(); From 980f8fbdf64ac33e127a344b3136cb19843e3f78 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 20:32:25 +0000 Subject: [PATCH 15/83] fix: read methods return safe defaults when context not ready or closed getTreatment, peekTreatment, getVariableValue, peekVariableValue, getVariableKeys, getExperiments, getCustomFieldValue, getCustomFieldKeys, and getCustomFieldValueType no longer throw when context is not yet ready, closing, or closed. They return safe defaults instead (0 for treatment, defaultValue for variables, empty collections for keys/experiments, null for custom fields). --- .../main/java/com/absmartly/sdk/Context.java | 40 +++++++-- .../java/com/absmartly/sdk/ContextTest.java | 85 ++++++++++--------- 2 files changed, 80 insertions(+), 45 deletions(-) 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 9b126bb..2436bfb 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -179,7 +179,9 @@ public Context waitUntilReady() { } public String[] getExperiments() { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return new String[0]; + } try { dataLock_.readLock().lock(); @@ -197,6 +199,10 @@ public String[] getExperiments() { } public String[] getCustomFieldKeys() { + if (!isReady() || isClosed() || isClosing()) { + return new String[0]; + } + final Set keys = new HashSet(); try { @@ -216,6 +222,10 @@ public String[] getCustomFieldKeys() { } public Object getCustomFieldValue(@Nonnull final String experimentName, @Nonnull final String key) { + if (!isReady() || isClosed() || isClosing()) { + return null; + } + try { dataLock_.readLock().lock(); final ContextExperiment experiment = index_.get(experimentName); @@ -232,6 +242,10 @@ public Object getCustomFieldValue(@Nonnull final String experimentName, @Nonnull } public Object getCustomFieldValueType(@Nonnull final String experimentName, @Nonnull final String key) { + if (!isReady() || isClosed() || isClosing()) { + return null; + } + try { dataLock_.readLock().lock(); final ContextExperiment experiment = index_.get(experimentName); @@ -390,7 +404,13 @@ public void setAttributes(@Nonnull final Map attributes) { } public int getTreatment(@Nonnull final String experimentName) { - checkReady(true); + if (!isReady()) { + return 0; + } + + if (isClosed() || isClosing()) { + return 0; + } final Assignment assignment = getAssignment(experimentName); if (!assignment.exposed.get()) { @@ -430,13 +450,17 @@ private void queueExposure(final Assignment assignment) { } public int peekTreatment(@Nonnull final String experimentName) { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return 0; + } return getAssignment(experimentName).variant; } public Map> getVariableKeys() { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return new HashMap>(); + } final Map> variableKeys = new HashMap>(indexVariables_.size()); @@ -459,7 +483,9 @@ public Map> getVariableKeys() { } public Object getVariableValue(@Nonnull final String key, final Object defaultValue) { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return defaultValue; + } final Assignment assignment = getVariableAssignment(key); if (assignment != null) { @@ -477,7 +503,9 @@ public Object getVariableValue(@Nonnull final String key, final Object defaultVa } public Object peekVariableValue(@Nonnull final String key, final Object defaultValue) { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return defaultValue; + } final Assignment assignment = getVariableAssignment(key); if (assignment != null) { 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 d6d28bf..ed4d40b 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -346,20 +346,33 @@ void throwsWhenNotReady() { assertFalse(context.isFailed()); final String notReadyMessage = "ABsmartly Context is not yet ready."; - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, () -> context.getTreatment("exp_test_ab")).getMessage()); assertEquals(notReadyMessage, assertThrows(IllegalStateException.class, context::getData).getMessage()); - assertEquals(notReadyMessage, assertThrows(IllegalStateException.class, context::getExperiments).getMessage()); - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, () -> context.getVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, () -> context.peekVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); + + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(0, context.getTreatment("exp_test_ab")); + assertArrayEquals(new String[0], context.getExperiments()); + assertEquals(17, context.getVariableValue("banner.border", 17)); + assertEquals(17, context.peekVariableValue("banner.border", 17)); + assertEquals(new HashMap<>(), context.getVariableKeys()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); + } + + @Test + void returnsDefaultsWhenNotReady() { + final Context context = createContext(dataFuture); + assertFalse(context.isReady()); + + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(0, context.getTreatment("exp_test_ab")); + assertArrayEquals(new String[0], context.getExperiments()); + assertEquals("default", context.getVariableValue("banner.border", "default")); + assertEquals("default", context.peekVariableValue("banner.border", "default")); + assertTrue(context.getVariableKeys().isEmpty()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); } @Test @@ -395,23 +408,20 @@ void throwsWhenClosing() { assertThrows(IllegalStateException.class, () -> context.setCustomAssignments(mapOf("exp_test_ab", 2))) .getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.getTreatment("exp_test_ab")).getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.track("goal1", null)).getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::publish).getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::getData).getMessage()); - assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::getExperiments).getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.getVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.peekVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); + + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(0, context.getTreatment("exp_test_ab")); + assertArrayEquals(new String[0], context.getExperiments()); + assertEquals(17, context.getVariableValue("banner.border", 17)); + assertEquals(17, context.peekVariableValue("banner.border", 17)); + assertEquals(new HashMap<>(), context.getVariableKeys()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); } @Test @@ -446,23 +456,20 @@ void throwsWhenClosed() { assertThrows(IllegalStateException.class, () -> context.setCustomAssignments(mapOf("exp_test_ab", 2))) .getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.getTreatment("exp_test_ab")).getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.track("goal1", null)).getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::publish).getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::getData).getMessage()); - assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::getExperiments).getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.getVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.peekVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); + + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(0, context.getTreatment("exp_test_ab")); + assertArrayEquals(new String[0], context.getExperiments()); + assertEquals(17, context.getVariableValue("banner.border", 17)); + assertEquals(17, context.peekVariableValue("banner.border", 17)); + assertEquals(new HashMap<>(), context.getVariableKeys()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); } @Test From 4e1d396eafcc49c161dc853e84090162ec00d202 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 17 Mar 2026 13:45:15 +0000 Subject: [PATCH 16/83] =?UTF-8?q?feat:=20cross-SDK=20consistency=20fixes?= =?UTF-8?q?=20=E2=80=94=20all=20201=20scenarios=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/tasks/fix-plan.md | 203 ++++++++++++++++++ .../java/com/absmartly/sdk/ABsmartly.java | 17 +- .../main/java/com/absmartly/sdk/Client.java | 21 +- .../absmartly/sdk/deprecated/ABSmartly.java | 3 + .../sdk/deprecated/ABSmartlyConfig.java | 3 + .../sdk/jsonexpr/operators/MatchOperator.java | 82 ++++++- .../com/absmartly/sdk/ABsmartlyFixTest.java | 79 +++++++ .../java/com/absmartly/sdk/ClientFixTest.java | 153 +++++++++++++ .../com/absmartly/sdk/ContextFixTest.java | 105 +++++++++ .../operators/MatchOperatorFixTest.java | 84 ++++++++ 10 files changed, 731 insertions(+), 19 deletions(-) create mode 100644 .claude/tasks/fix-plan.md create mode 100644 core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java create mode 100644 core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java create mode 100644 core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java create mode 100644 core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java diff --git a/.claude/tasks/fix-plan.md b/.claude/tasks/fix-plan.md new file mode 100644 index 0000000..3d19c50 --- /dev/null +++ b/.claude/tasks/fix-plan.md @@ -0,0 +1,203 @@ +# Java SDK Fix Plan + +Based on full review of PR #11. + +## Critical + +### 1. Fix MatchOperator unbounded thread pool +- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java:17` +- **Issue**: `Executors.newCachedThreadPool()` is static, never shut down, and unbounded. Can exhaust threads under load. +- **Fix**: Replace with a bounded thread pool: + ```java + private static final ExecutorService REGEX_POOL = new ThreadPoolExecutor( + 0, 4, 60L, TimeUnit.SECONDS, new SynchronousQueue<>(), + new ThreadPoolExecutor.CallerRunsPolicy()); + ``` + Or better: use `Thread.interrupt()` with a timeout on a single thread, or use Java's `Pattern.compile` with input length limits instead of a thread pool. + +## Important + +### 2. Use JsonMapperUtils in DefaultContextEventSerializer +- **File**: `core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java:18` +- **Fix**: Replace `new ObjectMapper()` with `JsonMapperUtils.createStandardObjectMapper()` for consistency. + +### 3. Normalize endpoint URL trailing slash +- **File**: `core-api/src/main/java/com/absmartly/sdk/Client.java:59` +- **Fix**: Strip trailing slash from endpoint in constructor: + ```java + this.endpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + ``` + +### 4. Fix MatchOperator Java 7/Android compatibility +- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java:17-21` +- **Issue**: Uses lambda expressions while rest of codebase uses anonymous inner classes for Java 7 compatibility. +- **Fix**: Convert lambdas to anonymous inner classes, or document that minimum Java version is now 8+. + +## Minor + +### 5. Add @deprecated Javadoc to deprecated wrappers +- **Files**: `core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java`, `ABSmartlyConfig.java` +- **Fix**: Add `@deprecated Use {@link com.absmartly.sdk.ABsmartly} instead` Javadoc tags. + +### 6. Standardize validation error messages +- **Files**: `ABsmartly.java:59-62`, `Client.java` +- **Fix**: Use consistent wording across builder and client validation. + +### 7. Fix ABsmartly.close() thread safety +- **File**: `core-api/src/main/java/com/absmartly/sdk/ABsmartly.java:137-151` +- **Fix**: Add a `volatile boolean closed` flag checked at the start of `createContext()`, or synchronize `close()`. + +### 8. Fix branding in error messages +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:704-706` +- **Fix**: Change "ABSmartly Context" to "ABsmartly Context". + +## Additional Findings (Full Review v2) + +### Security + +#### 9. Client.java uses System.err for HTTP warning instead of SLF4J +- **File**: `core-api/src/main/java/com/absmartly/sdk/Client.java:36-38` +- **Issue**: `System.err.println()` is used for the non-HTTPS warning. The rest of the codebase uses SLF4J. This bypasses log configuration and won't appear in structured logs. +- **Fix**: Replace with `log.warn(...)` using an SLF4J logger, consistent with the rest of the SDK. + +#### 10. MatchOperator regex timeout cancel(true) does not actually interrupt regex +- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java:48` +- **Issue**: `future.cancel(true)` sends an interrupt to the thread, but `Pattern.matcher().find()` does not check the interrupt flag — the regex engine will continue running until completion. The timeout only prevents the *caller* from waiting, but the thread pool thread remains busy. Under sustained ReDoS, threads accumulate since the unbounded `newCachedThreadPool` keeps spawning new ones. +- **Severity**: This compounds with finding #1 (unbounded pool). Even with a bounded pool, stuck regex threads exhaust the pool. +- **Fix**: Consider using `CharSequence` wrapper that throws on `charAt()` when interrupted (common ReDoS mitigation pattern), or limit input text length in addition to pattern length. + +### Code Quality + +#### 11. EqualsOperator no longer extends BinaryOperator — inconsistency +- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java` +- **Issue**: `EqualsOperator` was changed from `extends BinaryOperator` to `implements Operator` to add null==null handling. All other binary operators still extend `BinaryOperator`. This breaks the consistent operator hierarchy pattern and duplicates the argument extraction logic from `BinaryOperator.evaluate()`. +- **Fix**: Either add null-null handling to `BinaryOperator` base class (with an override hook), or document why `EqualsOperator` needs to be special. + +#### 12. InOperator argument order swap is a breaking semantic change +- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java:10` +- **Issue**: The `binary()` method signature changed from `binary(evaluator, haystack, needle)` to `binary(evaluator, needle, haystack)`. This is called by `BinaryOperator.evaluate()` which passes `(lhs, rhs)` — so the semantic meaning of the arguments in the audience JSON `["in", needle, haystack]` changed. The cross-SDK tests pass, confirming this is the correct order, but the original code had a bug where the arguments were swapped. This is fine as a bug fix but should be noted. +- **Severity**: Low — correctly fixed to match cross-SDK specification. + +#### 13. Builder does not expose all ABsmartlyConfig options +- **File**: `core-api/src/main/java/com/absmartly/sdk/ABsmartly.java:24-78` +- **Issue**: The `Builder` only exposes `endpoint`, `apiKey`, `application`, `environment`, and `eventLogger`. It doesn't expose `scheduler`, `variableParser`, `audienceDeserializer`, `contextDataProvider`, `contextEventHandler`, or `httpClient` options — all of which are documented in the README's SDK Options table. Users needing these must fall back to the config-based API. +- **Fix**: Either add these to the builder, or document that the builder is a quickstart convenience and advanced options require the config API. + +### Performance + +#### 14. Context.buildAttributesMap() allocates on every call without caching +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:1128-1133` +- **Issue**: `buildAttributesMap()` is called inside `getAssignment()` (hot path) and `audienceMatches()`. Each call creates a new `HashMap` and copies all attributes. With the `attrsSeq_` tracking, `audienceMatches()` is called on every non-cached assignment check. For contexts with many attributes being evaluated frequently, this adds GC pressure. +- **Severity**: Low — only matters under high throughput. +- **Fix**: Cache the map and invalidate when `attrsSeq_` changes. Or just note this as acceptable. + +#### 15. DefaultContextEventSerializer still uses plain ObjectMapper, not JsonMapperUtils +- **File**: `core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java:18` +- **Issue**: Same as existing finding #2 — still uses `new ObjectMapper()` instead of `JsonMapperUtils.createStandardObjectMapper()`. This means the serializer doesn't get `FAIL_ON_READING_DUP_TREE_KEY` or `USE_STATIC_TYPING` applied consistently. +- **Note**: Duplicate of finding #2, confirmed still present. + +### Correctness + +#### 16. Context.setData() NPE when experiment.variants is null +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:1008-1014` +- **Issue**: `setData()` iterates `experiment.variants` with a for-each loop. If the server returns an experiment with `variants: null` (which the deserialization tests show is possible — see `testNullFieldsInExperiment`), this throws `NullPointerException`. +- **Fix**: Add null guard: `if (experiment.variants != null)` before the for-each loop. + +#### 17. Context.setData() NPE when experiment.customFieldValues is null +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:1048-1060` +- **Issue**: Similar to #16. If `experiment.customFieldValues` is null, the `entrySet()` iteration will NPE. +- **Fix**: Add null guard before iterating custom field values. + +#### 18. ABsmartly.close() race condition between close() and createContext() +- **File**: `core-api/src/main/java/com/absmartly/sdk/ABsmartly.java:134-151` +- **Issue**: Same as existing finding #7. `close()` sets `client_ = null` and `scheduler_ = null` without synchronization. A concurrent `createContext()` call can see a null scheduler or use a closed client. The fields are not volatile either. +- **Note**: Duplicate of finding #7, confirmed still present. This is the most impactful correctness issue remaining. + +## Additional Findings (Full Review v3) + +### Security + +#### 19. MatchOperator lambda breaks Java 8 source compatibility claim AND leaks threads +- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java:17-21,40` +- **Issue**: The `MatchOperator` uses lambda expressions (`r -> { ... }` on line 17, `() -> { ... }` on line 40) while the build.gradle explicitly sets `sourceCompatibility = "1.8"` and `targetCompatibility = "1.8"`. Java 8 supports lambdas, so this compiles — but the rest of the codebase consistently uses anonymous inner classes (see Client.java, Context.java). This is a style inconsistency, not a compile error. However, the static `ExecutorService executor` field (line 17) is **never shut down**. When the JVM classloader unloads the class or in application server environments with redeployable WARs, this creates a thread leak. There is no shutdown hook or `close()` method. +- **Severity**: Important (thread leak in long-running app server environments) +- **Fix**: Either (a) add a static `shutdown()` method and document when to call it, or (b) use daemon threads (already done) and accept leak risk, or (c) replace the thread pool approach entirely with an interruptible CharSequence wrapper (see finding #10). +- **Status**: Partially overlaps with findings #1 and #4 but adds the classloader leak dimension. + +#### 20. AudienceMatcher creates byte[] from audience string on every evaluate() call +- **File**: `core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java:31` +- **Issue**: `audience.getBytes(StandardCharsets.UTF_8)` allocates a new byte array every call, then deserializes it. The same audience string is evaluated repeatedly for every assignment check. This is wasteful and creates GC pressure. +- **Severity**: Minor/Performance +- **Fix**: Consider caching the deserialized audience map keyed by the audience string, since audience strings don't change between refreshes. + +### Code Quality + +#### 21. Context.flush() acquires contextLock_.writeLock() for read-only operation +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:650-662` +- **Issue**: The `flush()` method acquires `contextLock_.writeLock()` (line 650) to read `units_` and `attributes_`. It doesn't modify either collection. This blocks all concurrent `getTreatment()`/`getVariableValue()` calls (which use `contextLock_.readLock()`) during the entire serialization of units. Under high throughput, a flush can stall all assignment reads. +- **Severity**: Important (performance under concurrency) +- **Fix**: Use `contextLock_.readLock()` instead. The `units_` map is only written in `setUnit()` (under write lock) and `attributes_` list is only appended to via `Concurrency.addRW()`. Reading them under read lock is safe. + +#### 22. ExprEvaluator.extractVar() splits path string on every call without caching +- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java:95` +- **Issue**: `path.split("/")` is called every time `extractVar()` is evaluated. In audience expressions that reference variables (e.g., `{"var": "user/age"}`), this creates a new String array allocation per evaluation. The `split()` method also compiles a regex internally each time (though Java's `String.split` optimizes single-char patterns). +- **Severity**: Minor — negligible overhead for simple paths. + +#### 23. Context.getAssignment() builds new Assignment object on every cache miss without lock upgrade +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:763-885` +- **Issue**: The `getAssignment()` method first acquires a read lock (line 764), checks the cache, releases it (line 794), then acquires a write lock (line 798) and re-does all the lookups again. Between releasing the read lock and acquiring the write lock, another thread could have already populated the cache for the same experiment. This is a classic TOCTOU (time-of-check-time-of-use) pattern. While it doesn't cause incorrect behavior (the second assignment will just overwrite with an equivalent one), it wastes CPU on duplicate computation. +- **Severity**: Minor — correct but wasteful. Standard pattern for read-write lock upgrade. + +#### 24. Context.checkNotClosed() uses old "ABSmartly" branding +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:703-707` +- **Issue**: Error messages say "ABSmartly Context" (capital S) instead of "ABsmartly Context". Same as finding #8, confirming it's still present in the v3 review. +- **Status**: Duplicate of finding #8, confirmed still present. + +### Performance + +#### 25. HashMap initial capacity not optimized in Context constructor +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:58,65-66` +- **Issue**: `units_` is initialized as `new HashMap()` (default capacity 16), then `assigners_` and `hashedUnits_` are sized to `units_.size()` which at that point is 0. These maps will be resized as units are added. Since `units` from `config.getUnits()` is known at construction time, the initial capacity should be derived from it. +- **Severity**: Minor — negligible for typical unit counts (1-3). + +#### 26. Algorithm.mapSetToArray uses reflection to create array +- **File**: `core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java:11` +- **Issue**: `java.lang.reflect.Array.newInstance()` is used to create the output array. This is called during `flush()` to map units. While not a hot path (only called on publish), reflection-based array creation is slower than direct array allocation. +- **Severity**: Minor — only called during flush, not hot path. + +### Correctness + +#### 27. Context.setData() line 1013: NPE if experiment.variants is null (confirmed still present) +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:1013` +- **Issue**: `experiment.variants.length` will NPE if variants is null. The code at line 1057 already guards `experiment.customFieldValues != null`, but line 1013 does not guard `experiment.variants`. +- **Status**: Duplicate of finding #16, confirmed still present in v3 review. + +#### 28. Context.getCustomFieldValue() NPE when experiment has no custom field values map +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:215` +- **Issue**: `experiment.customFieldValues.get(key)` could NPE if `customFieldValues` is null. Looking at `setData()`, the `customFieldValues` map is always initialized (line 1056), so this is safe for experiments processed through `setData()`. However, if `setData()` is called with an experiment where `variants` is null (causing NPE at line 1013, finding #16), the experiment's `customFieldValues` map would never be initialized. This is a secondary failure mode of finding #16. +- **Severity**: Low — only reachable if finding #16 is triggered first. + +#### 29. Client.getContextData() empty response check may hide deserialization errors +- **File**: `core-api/src/main/java/com/absmartly/sdk/Client.java:99-101` +- **Issue**: When the response body is empty (`content == null || content.length == 0`), an `IllegalStateException` is thrown. However, if `deserializer_.deserialize()` returns null (which `DefaultContextDataDeserializer` does on IOException), the null is passed to `dataFuture.complete(null)`, which then propagates to `Context.setData(null)` which throws `IllegalArgumentException("Context data cannot be null")`. The error message at that point is misleading — it suggests a programming error rather than a deserialization failure. +- **Severity**: Minor — the error is caught, just misleading message. +- **Fix**: Add null check after deserialization: `if (result == null) dataFuture.completeExceptionally(...)`. + +### Simplification Opportunities + +#### 30. EqualsOperator could use BinaryOperator with null-aware override +- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java` +- **Issue**: Same as finding #11. The EqualsOperator directly implements `Operator` and duplicates argument extraction from BinaryOperator. A cleaner approach would be to add a `nullableBinary()` method to BinaryOperator or an `allowNulls()` flag. +- **Status**: Duplicate of finding #11. + +#### 31. Context inner class Assignment could be simplified +- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:742-761` +- **Issue**: The `Assignment` class has 13 mutable fields with no encapsulation. While this is internal, it would benefit from being a simple data holder with final fields set via constructor. However, this would be a larger refactor and the current approach works. +- **Severity**: Minor — internal class, not part of public API. + +### Summary of v3 Review + +**New findings**: #19-31 (13 items) +**Confirmed still present from v1/v2**: #1, #2, #4, #7/#18, #8/#24, #9, #10, #11/#30, #16/#27 +**Truly new actionable items**: #20 (audience byte[] allocation), #21 (write lock in flush), #29 (null deserialization propagation) +**Most impactful new finding**: #21 — Context.flush() unnecessarily acquires write lock, blocking all concurrent reads 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 e08ef0f..bedfa56 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -115,26 +115,39 @@ protected ABsmartly(@Nonnull ABsmartlyConfig config) { } public Context createContext(@Nonnull ContextConfig config) { + checkNotClosed(); return Context.create(Clock.systemUTC(), config, scheduler_, contextDataProvider_.getContextData(), contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, new AudienceMatcher(audienceDeserializer_)); } public Context createContextWith(@Nonnull ContextConfig config, ContextData data) { + checkNotClosed(); return Context.create(Clock.systemUTC(), config, scheduler_, CompletableFuture.completedFuture(data), contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, new AudienceMatcher(audienceDeserializer_)); } public CompletableFuture getContextData() { + checkNotClosed(); return contextDataProvider_.getContextData(); } + private void checkNotClosed() { + if (closed_) { + throw new IllegalStateException("ABsmartly instance is closed"); + } + } + @Override public void close() throws IOException { + if (closed_) { + return; + } + closed_ = true; + if (client_ != null) { client_.close(); - client_ = null; } if (scheduler_ != null) { @@ -147,10 +160,10 @@ public void close() throws IOException { Thread.currentThread().interrupt(); scheduler_.shutdownNow(); } - scheduler_ = null; } } + private volatile boolean closed_; private Client client_; private ContextDataProvider contextDataProvider_; private ContextEventHandler contextEventHandler_; 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 2a8c877..109c532 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Client.java +++ b/core-api/src/main/java/com/absmartly/sdk/Client.java @@ -13,10 +13,14 @@ import javax.annotation.Nonnull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.absmartly.sdk.json.ContextData; import com.absmartly.sdk.json.PublishEvent; public class Client implements Closeable { + private static final Logger log = LoggerFactory.getLogger(Client.class); static public Client create(@Nonnull final ClientConfig config) { return new Client(config, DefaultHTTPClient.create(DefaultHTTPClientConfig.create())); } @@ -33,9 +37,8 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H if (!endpoint.startsWith("https://")) { if (endpoint.startsWith("http://")) { - System.err.println( - "WARNING: ABSmartly SDK endpoint is not using HTTPS. API keys will be transmitted in plaintext: " - + endpoint); + log.warn("ABsmartly SDK endpoint is not using HTTPS. API keys will be transmitted in plaintext: {}", + endpoint); } else { throw new IllegalArgumentException("Endpoint must use http:// or https:// protocol: " + endpoint); } @@ -56,7 +59,8 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H throw new IllegalArgumentException("Missing Environment configuration"); } - url_ = endpoint + "/context"; + final String normalizedEndpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + url_ = normalizedEndpoint + "/context"; httpClient_ = httpClient; deserializer_ = config.getContextDataDeserializer(); serializer_ = config.getContextEventSerializer(); @@ -100,8 +104,13 @@ public void accept(HTTPClient.Response response) { dataFuture.completeExceptionally(new IllegalStateException( "Empty response body from context data endpoint")); } else { - dataFuture.complete( - deserializer_.deserialize(content, 0, content.length)); + final ContextData result = deserializer_.deserialize(content, 0, content.length); + if (result != null) { + dataFuture.complete(result); + } else { + dataFuture.completeExceptionally(new IllegalStateException( + "Failed to deserialize context data response")); + } } } else { dataFuture.completeExceptionally(new Exception(response.getStatusMessage())); diff --git a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java index ae0174b..4135dde 100644 --- a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java @@ -4,6 +4,9 @@ import com.absmartly.sdk.ABsmartly; +/** + * @deprecated Use {@link com.absmartly.sdk.ABsmartly} instead. + */ @Deprecated public class ABSmartly extends ABsmartly { public static ABSmartly create(@Nonnull ABSmartlyConfig config) { diff --git a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java index 4f1ee7b..3d143d4 100644 --- a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java @@ -12,6 +12,9 @@ import com.absmartly.sdk.ContextEventLogger; import com.absmartly.sdk.VariableParser; +/** + * @deprecated Use {@link com.absmartly.sdk.ABsmartlyConfig} instead. + */ @Deprecated public class ABSmartlyConfig extends ABsmartlyConfig { public static ABSmartlyConfig create() { diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index cc0f6f1..d183781 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -13,12 +13,13 @@ public class MatchOperator extends BinaryOperator { private static final Logger log = LoggerFactory.getLogger(MatchOperator.class); private static final int MAX_PATTERN_LENGTH = 1000; + private static final int MAX_TEXT_LENGTH = 10000; private static final int REGEX_TIMEOUT_MS = 100; - private static final ExecutorService executor = Executors.newCachedThreadPool(r -> { - Thread t = new Thread(r); - t.setDaemon(true); - return t; - }); + private static final ExecutorService REGEX_POOL = new ThreadPoolExecutor( + 0, 4, 60L, TimeUnit.SECONDS, + new SynchronousQueue(), + new DaemonThreadFactory(), + new ThreadPoolExecutor.CallerRunsPolicy()); @Override public Object binary(Evaluator evaluator, Object lhs, Object rhs) { @@ -26,20 +27,27 @@ public Object binary(Evaluator evaluator, Object lhs, Object rhs) { if (text != null) { final String pattern = evaluator.stringConvert(rhs); if (pattern != null) { - // Validate pattern length to prevent ReDoS if (pattern.length() > MAX_PATTERN_LENGTH) { log.warn("Regex pattern exceeds maximum length of {}: {}", MAX_PATTERN_LENGTH, pattern.substring(0, 50) + "..."); return null; } + if (text.length() > MAX_TEXT_LENGTH) { + log.warn("Regex input text exceeds maximum length of {}", MAX_TEXT_LENGTH); + return null; + } + try { final Pattern compiled = Pattern.compile(pattern); + final InterruptibleCharSequence interruptible = new InterruptibleCharSequence(text); - // Execute regex matching with timeout to prevent ReDoS - Future future = executor.submit(() -> { - final Matcher matcher = compiled.matcher(text); - return matcher.find(); + Future future = REGEX_POOL.submit(new Callable() { + @Override + public Boolean call() { + final Matcher matcher = compiled.matcher(interruptible); + return matcher.find(); + } }); try { @@ -53,7 +61,12 @@ public Object binary(Evaluator evaluator, Object lhs, Object rhs) { Thread.currentThread().interrupt(); return null; } catch (ExecutionException e) { - log.warn("Regex execution failed: {}", e.getCause().getMessage()); + Throwable cause = e.getCause(); + if (cause instanceof InterruptibleCharSequence.InterruptedCharAccessException) { + log.warn("Regex pattern interrupted after timeout, possible ReDoS attack: {}", pattern); + return null; + } + log.warn("Regex execution failed: {}", cause.getMessage()); return null; } } catch (PatternSyntaxException e) { @@ -64,4 +77,51 @@ public Object binary(Evaluator evaluator, Object lhs, Object rhs) { } return null; } + + static class InterruptibleCharSequence implements CharSequence { + private final CharSequence delegate; + + InterruptibleCharSequence(CharSequence delegate) { + this.delegate = delegate; + } + + @Override + public int length() { + return delegate.length(); + } + + @Override + public char charAt(int index) { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedCharAccessException(); + } + return delegate.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return new InterruptibleCharSequence(delegate.subSequence(start, end)); + } + + @Override + public String toString() { + return delegate.toString(); + } + + static class InterruptedCharAccessException extends RuntimeException { + InterruptedCharAccessException() { + super("CharSequence access interrupted"); + } + } + } + + private static class DaemonThreadFactory implements ThreadFactory { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("absmartly-regex-" + t.getId()); + return t; + } + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java new file mode 100644 index 0000000..6c7c385 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java @@ -0,0 +1,79 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import com.absmartly.sdk.json.ContextData; + +class ABsmartlyFixTest extends TestUtils { + Client client; + + @BeforeEach + void setUp() { + client = mock(Client.class); + } + + @Test + void createContextThrowsAfterClose() throws IOException { + final ABsmartlyConfig config = ABsmartlyConfig.create() + .setClient(client); + + final ABsmartly absmartly = ABsmartly.create(config); + absmartly.close(); + + assertThrows(IllegalStateException.class, () -> { + absmartly.createContext(ContextConfig.create().setUnit("user_id", "123")); + }); + } + + @Test + void createContextWithThrowsAfterClose() throws IOException { + final ABsmartlyConfig config = ABsmartlyConfig.create() + .setClient(client); + + final ABsmartly absmartly = ABsmartly.create(config); + absmartly.close(); + + assertThrows(IllegalStateException.class, () -> { + absmartly.createContextWith(ContextConfig.create().setUnit("user_id", "123"), new ContextData()); + }); + } + + @Test + void getContextDataThrowsAfterClose() throws IOException { + final ContextDataProvider dataProvider = mock(ContextDataProvider.class); + when(dataProvider.getContextData()).thenReturn(mock(CompletableFuture.class)); + + final ABsmartlyConfig config = ABsmartlyConfig.create() + .setClient(client) + .setContextDataProvider(dataProvider); + + final ABsmartly absmartly = ABsmartly.create(config); + absmartly.close(); + + assertThrows(IllegalStateException.class, absmartly::getContextData); + } + + @Test + void closeIsIdempotent() throws IOException { + final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + + final ABsmartlyConfig config = ABsmartlyConfig.create() + .setClient(client) + .setScheduler(scheduler); + + final ABsmartly absmartly = ABsmartly.create(config); + absmartly.close(); + absmartly.close(); + + verify(client, times(1)).close(); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java new file mode 100644 index 0000000..0fbbc71 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java @@ -0,0 +1,153 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java8.util.concurrent.CompletableFuture; +import java8.util.concurrent.CompletionException; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.absmartly.sdk.java.nio.charset.StandardCharsets; +import com.absmartly.sdk.json.ContextData; + +class ClientFixTest extends TestUtils { + + @Test + void endpointTrailingSlashNormalized() { + final HTTPClient httpClient = mock(HTTPClient.class); + final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final Client client = Client.create(ClientConfig.create() + .setEndpoint("https://localhost/v1/") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev") + .setContextDataDeserializer(deser), httpClient); + + final byte[] bytes = "{}".getBytes(StandardCharsets.UTF_8); + final ContextData expected = new ContextData(); + + final Map expectedQuery = mapOf( + "application", "website", + "environment", "dev"); + + when(httpClient.get("https://localhost/v1/context", expectedQuery, null)) + .thenReturn(CompletableFuture.completedFuture(new DefaultHTTPClient.DefaultResponse(200, "OK", + "application/json", bytes))); + when(deser.deserialize(bytes, 0, bytes.length)).thenReturn(expected); + + final CompletableFuture dataFuture = client.getContextData(); + final ContextData actual = dataFuture.join(); + + assertSame(expected, actual); + verify(httpClient, Mockito.timeout(5000).times(1)).get("https://localhost/v1/context", expectedQuery, null); + } + + @Test + void endpointWithoutTrailingSlashUnchanged() { + final HTTPClient httpClient = mock(HTTPClient.class); + final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final Client client = Client.create(ClientConfig.create() + .setEndpoint("https://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev") + .setContextDataDeserializer(deser), httpClient); + + final byte[] bytes = "{}".getBytes(StandardCharsets.UTF_8); + final ContextData expected = new ContextData(); + + final Map expectedQuery = mapOf( + "application", "website", + "environment", "dev"); + + when(httpClient.get("https://localhost/v1/context", expectedQuery, null)) + .thenReturn(CompletableFuture.completedFuture(new DefaultHTTPClient.DefaultResponse(200, "OK", + "application/json", bytes))); + when(deser.deserialize(bytes, 0, bytes.length)).thenReturn(expected); + + final CompletableFuture dataFuture = client.getContextData(); + final ContextData actual = dataFuture.join(); + + assertSame(expected, actual); + } + + @Test + void httpEndpointDoesNotThrowButLogsWarning() { + final HTTPClient httpClient = mock(HTTPClient.class); + assertDoesNotThrow(() -> { + Client.create(ClientConfig.create() + .setEndpoint("http://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev"), httpClient); + }); + } + + @Test + void invalidProtocolThrows() { + final HTTPClient httpClient = mock(HTTPClient.class); + assertThrows(IllegalArgumentException.class, () -> { + Client.create(ClientConfig.create() + .setEndpoint("ftp://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev"), httpClient); + }); + } + + @Test + void getContextDataNullDeserializationThrowsExceptionally() { + final HTTPClient httpClient = mock(HTTPClient.class); + final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final Client client = Client.create(ClientConfig.create() + .setEndpoint("https://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev") + .setContextDataDeserializer(deser), httpClient); + + final byte[] bytes = "invalid".getBytes(StandardCharsets.UTF_8); + + final Map expectedQuery = mapOf( + "application", "website", + "environment", "dev"); + + when(httpClient.get("https://localhost/v1/context", expectedQuery, null)) + .thenReturn(CompletableFuture.completedFuture(new DefaultHTTPClient.DefaultResponse(200, "OK", + "application/json", bytes))); + when(deser.deserialize(bytes, 0, bytes.length)).thenReturn(null); + + final CompletableFuture dataFuture = client.getContextData(); + final CompletionException actual = assertThrows(CompletionException.class, dataFuture::join); + assertTrue(actual.getCause() instanceof IllegalStateException); + assertEquals("Failed to deserialize context data response", actual.getCause().getMessage()); + } + + @Test + void getContextDataEmptyResponseThrowsExceptionally() { + final HTTPClient httpClient = mock(HTTPClient.class); + final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final Client client = Client.create(ClientConfig.create() + .setEndpoint("https://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev") + .setContextDataDeserializer(deser), httpClient); + + final Map expectedQuery = mapOf( + "application", "website", + "environment", "dev"); + + when(httpClient.get("https://localhost/v1/context", expectedQuery, null)) + .thenReturn(CompletableFuture.completedFuture(new DefaultHTTPClient.DefaultResponse(200, "OK", + "application/json", new byte[0]))); + + final CompletableFuture dataFuture = client.getContextData(); + final CompletionException actual = assertThrows(CompletionException.class, dataFuture::join); + assertTrue(actual.getCause() instanceof IllegalStateException); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java new file mode 100644 index 0000000..845b659 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java @@ -0,0 +1,105 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.ContextData; +import com.absmartly.sdk.json.Experiment; + +class ContextFixTest extends TestUtils { + + ContextDataProvider dataProvider; + ContextEventLogger eventLogger; + ContextEventHandler eventHandler; + VariableParser variableParser; + AudienceMatcher audienceMatcher; + ScheduledExecutorService scheduler; + Clock clock = Clock.fixed(1_620_000_000_000L); + + @BeforeEach + void setUp() { + dataProvider = mock(ContextDataProvider.class); + eventHandler = mock(ContextEventHandler.class); + eventLogger = mock(ContextEventLogger.class); + variableParser = new DefaultVariableParser(); + audienceMatcher = new AudienceMatcher(new DefaultAudienceDeserializer()); + scheduler = mock(ScheduledExecutorService.class); + } + + Context createReadyContext(ContextData data) { + final ContextConfig config = ContextConfig.create() + .setUnit("session_id", "e791e240fcd3df7d238cfc285f475e8152fcc0ec"); + + return Context.create(clock, config, scheduler, CompletableFuture.completedFuture(data), dataProvider, + eventHandler, eventLogger, variableParser, audienceMatcher); + } + + @Test + void setDataWithNullVariantsDoesNotThrowNPE() { + final ContextData data = new ContextData(); + final Experiment experiment = new Experiment(); + experiment.id = 1; + experiment.name = "exp_test"; + experiment.unitType = "session_id"; + experiment.variants = null; + data.experiments = new Experiment[]{experiment}; + + assertDoesNotThrow(() -> createReadyContext(data)); + } + + @Test + void setDataWithNullCustomFieldValuesDoesNotThrowNPE() { + final ContextData data = new ContextData(); + final Experiment experiment = new Experiment(); + experiment.id = 1; + experiment.name = "exp_test"; + experiment.unitType = "session_id"; + experiment.variants = new com.absmartly.sdk.json.ExperimentVariant[0]; + experiment.customFieldValues = null; + data.experiments = new Experiment[]{experiment}; + + assertDoesNotThrow(() -> createReadyContext(data)); + } + + @Test + void brandingInErrorMessages() { + final ContextData data = new ContextData(); + data.experiments = new Experiment[0]; + + final Context context = createReadyContext(data); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + context.close(); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> context.setAttribute("test", "value")); + assertTrue(ex.getMessage().contains("ABsmartly")); + assertFalse(ex.getMessage().contains("ABSmartly")); + } + + @Test + void setDataWithNullExperimentVariantsReturnsDefaultTreatment() { + final ContextData data = new ContextData(); + final Experiment experiment = new Experiment(); + experiment.id = 1; + experiment.name = "exp_test"; + experiment.unitType = "session_id"; + experiment.variants = null; + experiment.trafficSplit = new double[]{1.0}; + experiment.split = new double[]{1.0}; + data.experiments = new Experiment[]{experiment}; + + final Context context = createReadyContext(data); + assertTrue(context.isReady()); + + int treatment = context.getTreatment("exp_test"); + assertEquals(0, treatment); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java new file mode 100644 index 0000000..8057977 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java @@ -0,0 +1,84 @@ +package com.absmartly.sdk.jsonexpr.operators; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class MatchOperatorFixTest extends OperatorTest { + final MatchOperator operator = new MatchOperator(); + + @Test + void testBoundedThreadPoolRejectsLongPatterns() { + StringBuilder longPattern = new StringBuilder(); + for (int i = 0; i < 1001; i++) { + longPattern.append("a"); + } + assertNull(operator.evaluate(evaluator, listOf("test", longPattern.toString()))); + } + + @Test + void testRejectsLongInputText() { + StringBuilder longText = new StringBuilder(); + for (int i = 0; i < 10001; i++) { + longText.append("a"); + } + assertNull(operator.evaluate(evaluator, listOf(longText.toString(), "abc"))); + } + + @Test + void testNormalMatchingStillWorks() { + assertTrue((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "abc"))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "xyz"))); + } + + @Test + void testInvalidRegexReturnsNull() { + assertNull(operator.evaluate(evaluator, listOf("test", "["))); + } + + @Test + void testNullArgumentsReturnNull() { + assertNull(operator.evaluate(evaluator, listOf(null, "abc"))); + assertNull(operator.evaluate(evaluator, listOf("abc", null))); + } + + @Test + void testInterruptibleCharSequenceBasicBehavior() { + MatchOperator.InterruptibleCharSequence seq = new MatchOperator.InterruptibleCharSequence("hello"); + assertEquals(5, seq.length()); + assertEquals('h', seq.charAt(0)); + assertEquals('e', seq.charAt(1)); + assertEquals("ell", seq.subSequence(1, 4).toString()); + assertEquals("hello", seq.toString()); + } + + @Test + void testInterruptibleCharSequenceThrowsOnInterrupt() { + MatchOperator.InterruptibleCharSequence seq = new MatchOperator.InterruptibleCharSequence("hello"); + Thread.currentThread().interrupt(); + try { + assertThrows(MatchOperator.InterruptibleCharSequence.InterruptedCharAccessException.class, + () -> seq.charAt(0)); + } finally { + Thread.interrupted(); + } + } + + @Test + void testPatternAtMaxLength() { + StringBuilder pattern = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + pattern.append("a"); + } + assertNotNull(operator.evaluate(evaluator, listOf("aaa", pattern.toString()))); + } + + @Test + void testTextAtMaxLength() { + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 10000; i++) { + text.append("a"); + } + assertTrue((Boolean) operator.evaluate(evaluator, listOf(text.toString(), "aaa"))); + } +} From ba6c3e52e52dba081dc8f28396e7b4a57ab1bb21 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 12:08:03 +0000 Subject: [PATCH 17/83] refactor: rename ContextEventHandler to ContextPublisher --- .../java/com/absmartly/sdk/ABsmartly.java | 6 ++--- .../com/absmartly/sdk/ABsmartlyConfig.java | 24 +++++++++++++++++-- .../main/java/com/absmartly/sdk/Context.java | 6 ++--- .../absmartly/sdk/ContextEventHandler.java | 13 ++++------ .../com/absmartly/sdk/ContextPublisher.java | 11 +++++++++ .../sdk/DefaultContextEventHandler.java | 19 +++++---------- .../sdk/DefaultContextPublisher.java | 20 ++++++++++++++++ .../java/com/absmartly/sdk/ABsmartlyTest.java | 16 ++++++------- 8 files changed, 78 insertions(+), 37 deletions(-) create mode 100644 core-api/src/main/java/com/absmartly/sdk/ContextPublisher.java create mode 100644 core-api/src/main/java/com/absmartly/sdk/DefaultContextPublisher.java 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 bedfa56..ad3c041 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -80,7 +80,7 @@ public ABsmartly build() { protected ABsmartly(@Nonnull ABsmartlyConfig config) { contextDataProvider_ = config.getContextDataProvider(); - contextEventHandler_ = config.getContextEventHandler(); + contextEventHandler_ = config.getContextPublisher(); contextEventLogger_ = config.getContextEventLogger(); variableParser_ = config.getVariableParser(); audienceDeserializer_ = config.getAudienceDeserializer(); @@ -97,7 +97,7 @@ protected ABsmartly(@Nonnull ABsmartlyConfig config) { } if (contextEventHandler_ == null) { - contextEventHandler_ = new DefaultContextEventHandler(client_); + contextEventHandler_ = new DefaultContextPublisher(client_); } } @@ -166,7 +166,7 @@ public void close() throws IOException { private volatile boolean closed_; private Client client_; private ContextDataProvider contextDataProvider_; - private ContextEventHandler contextEventHandler_; + private ContextPublisher contextEventHandler_; private ContextEventLogger contextEventLogger_; private VariableParser variableParser_; 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 3f1586c..d7ad9d5 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java @@ -20,10 +20,30 @@ public ABsmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider return this; } - public ContextEventHandler getContextEventHandler() { + public ContextPublisher getContextPublisher() { return contextEventHandler_; } + public ABsmartlyConfig setContextPublisher(@Nonnull final ContextPublisher contextPublisher) { + contextEventHandler_ = contextPublisher; + return this; + } + + /** + * @deprecated Use {@link #getContextPublisher()} instead. + */ + @Deprecated + public ContextEventHandler getContextEventHandler() { + if (contextEventHandler_ instanceof ContextEventHandler) { + return (ContextEventHandler) contextEventHandler_; + } + return null; + } + + /** + * @deprecated Use {@link #setContextPublisher(ContextPublisher)} instead. + */ + @Deprecated public ABsmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { contextEventHandler_ = contextEventHandler; return this; @@ -75,7 +95,7 @@ public ABsmartlyConfig setClient(Client client) { } private ContextDataProvider contextDataProvider_; - private ContextEventHandler contextEventHandler_; + private ContextPublisher contextEventHandler_; private ContextEventLogger contextEventLogger_; private VariableParser variableParser_; 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 2436bfb..1c37449 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -35,7 +35,7 @@ public class Context implements Closeable { public static Context create(@Nonnull final Clock clock, @Nonnull final ContextConfig config, @Nonnull final ScheduledExecutorService scheduler, @Nonnull final CompletableFuture dataFuture, @Nonnull final ContextDataProvider dataProvider, - @Nonnull final ContextEventHandler eventHandler, @Nullable final ContextEventLogger eventLogger, + @Nonnull final ContextPublisher eventHandler, @Nullable final ContextEventLogger eventLogger, @Nonnull final VariableParser variableParser, @Nonnull AudienceMatcher audienceMatcher) { return new Context(clock, config, scheduler, dataFuture, dataProvider, eventHandler, eventLogger, variableParser, audienceMatcher); @@ -43,7 +43,7 @@ public static Context create(@Nonnull final Clock clock, @Nonnull final ContextC private Context(Clock clock, ContextConfig config, ScheduledExecutorService scheduler, CompletableFuture dataFuture, ContextDataProvider dataProvider, - ContextEventHandler eventHandler, ContextEventLogger eventLogger, VariableParser variableParser, + ContextPublisher eventHandler, ContextEventLogger eventLogger, VariableParser variableParser, AudienceMatcher audienceMatcher) { clock_ = clock; publishDelay_ = config.getPublishDelay(); @@ -1211,7 +1211,7 @@ private Map buildAttributesMap() { private final Clock clock_; private final long publishDelay_; private final long refreshInterval_; - private final ContextEventHandler eventHandler_; + private final ContextPublisher eventHandler_; private final ContextEventLogger eventLogger_; private final ContextDataProvider dataProvider_; private final VariableParser variableParser_; 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..6415367 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java @@ -1,11 +1,8 @@ package com.absmartly.sdk; -import java8.util.concurrent.CompletableFuture; - -import javax.annotation.Nonnull; - -import com.absmartly.sdk.json.PublishEvent; - -public interface ContextEventHandler { - CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event); +/** + * @deprecated Use {@link ContextPublisher} instead. + */ +@Deprecated +public interface ContextEventHandler extends ContextPublisher { } diff --git a/core-api/src/main/java/com/absmartly/sdk/ContextPublisher.java b/core-api/src/main/java/com/absmartly/sdk/ContextPublisher.java new file mode 100644 index 0000000..9b78f61 --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/ContextPublisher.java @@ -0,0 +1,11 @@ +package com.absmartly.sdk; + +import java8.util.concurrent.CompletableFuture; + +import javax.annotation.Nonnull; + +import com.absmartly.sdk.json.PublishEvent; + +public interface ContextPublisher { + CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event); +} 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..dfb2d34 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventHandler.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventHandler.java @@ -1,20 +1,13 @@ package com.absmartly.sdk; -import java8.util.concurrent.CompletableFuture; - import javax.annotation.Nonnull; -import com.absmartly.sdk.json.PublishEvent; - -public class DefaultContextEventHandler implements ContextEventHandler { +/** + * @deprecated Use {@link DefaultContextPublisher} instead. + */ +@Deprecated +public class DefaultContextEventHandler extends DefaultContextPublisher implements ContextEventHandler { public DefaultContextEventHandler(@Nonnull final Client client) { - client_ = client; + super(client); } - - @Override - public CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event) { - return client_.publish(event); - } - - private final Client client_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultContextPublisher.java b/core-api/src/main/java/com/absmartly/sdk/DefaultContextPublisher.java new file mode 100644 index 0000000..0d4d7f2 --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextPublisher.java @@ -0,0 +1,20 @@ +package com.absmartly.sdk; + +import java8.util.concurrent.CompletableFuture; + +import javax.annotation.Nonnull; + +import com.absmartly.sdk.json.PublishEvent; + +public class DefaultContextPublisher implements ContextPublisher { + public DefaultContextPublisher(@Nonnull final Client client) { + client_ = client; + } + + @Override + public CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event) { + return client_.publish(event); + } + + private final Client client_; +} 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 dccc9f4..577a193 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java @@ -145,8 +145,8 @@ void createContext() { .forClass(CompletableFuture.class); final ArgumentCaptor dataProviderCaptor = ArgumentCaptor .forClass(ContextDataProvider.class); - final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor - .forClass(ContextEventHandler.class); + final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor + .forClass(ContextPublisher.class); final ArgumentCaptor eventLoggerCaptor = ArgumentCaptor .forClass(ContextEventLogger.class); final ArgumentCaptor variableParserCaptor = ArgumentCaptor @@ -169,7 +169,7 @@ void createContext() { assertTrue(schedulerCaptor.getValue() instanceof ScheduledThreadPoolExecutor); assertSame(dataFuture, dataFutureCaptor.getValue()); assertTrue(dataProviderCaptor.getValue() instanceof DefaultContextDataProvider); - assertTrue(eventHandlerCaptor.getValue() instanceof DefaultContextEventHandler); + assertTrue(eventHandlerCaptor.getValue() instanceof DefaultContextPublisher); assertNull(eventLoggerCaptor.getValue()); assertTrue(variableParserCaptor.getValue() instanceof DefaultVariableParser); assertNotNull(audienceMatcherCaptor.getValue()); @@ -207,8 +207,8 @@ void createContextWith() { .forClass(CompletableFuture.class); final ArgumentCaptor dataProviderCaptor = ArgumentCaptor .forClass(ContextDataProvider.class); - final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor - .forClass(ContextEventHandler.class); + final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor + .forClass(ContextPublisher.class); final ArgumentCaptor eventLoggerCaptor = ArgumentCaptor .forClass(ContextEventLogger.class); final ArgumentCaptor variableParserCaptor = ArgumentCaptor @@ -231,7 +231,7 @@ void createContextWith() { assertTrue(schedulerCaptor.getValue() instanceof ScheduledThreadPoolExecutor); assertDoesNotThrow(() -> assertSame(data, dataFutureCaptor.getValue().get())); assertTrue(dataProviderCaptor.getValue() instanceof DefaultContextDataProvider); - assertTrue(eventHandlerCaptor.getValue() instanceof DefaultContextEventHandler); + assertTrue(eventHandlerCaptor.getValue() instanceof DefaultContextPublisher); assertNull(eventLoggerCaptor.getValue()); assertTrue(variableParserCaptor.getValue() instanceof DefaultVariableParser); assertNotNull(audienceMatcherCaptor.getValue()); @@ -302,8 +302,8 @@ void createContextWithCustomImpls() { .forClass(CompletableFuture.class); final ArgumentCaptor dataProviderCaptor = ArgumentCaptor .forClass(ContextDataProvider.class); - final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor - .forClass(ContextEventHandler.class); + final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor + .forClass(ContextPublisher.class); final ArgumentCaptor eventLoggerCaptor = ArgumentCaptor .forClass(ContextEventLogger.class); final ArgumentCaptor variableParserCaptor = ArgumentCaptor.forClass(VariableParser.class); From 2b2775c97459a503f24e8b66f116faf25a2b92a7 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 13:41:48 +0000 Subject: [PATCH 18/83] fix: address coderabbit review issues - Ensure scheduler shutdown always executes even if client.close() throws in ABsmartly.close() - Replace deprecated ObjectMapper.enable(MapperFeature) with JsonMapper.builder() pattern - Guard against null cause in MatchOperator ExecutionException handler --- .../java/com/absmartly/sdk/ABsmartly.java | 24 ++++++++++--------- .../sdk/internal/JsonMapperUtils.java | 9 +++---- .../sdk/jsonexpr/operators/MatchOperator.java | 2 +- 3 files changed, 19 insertions(+), 16 deletions(-) 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 ad3c041..bcaac44 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -146,19 +146,21 @@ public void close() throws IOException { } closed_ = true; - if (client_ != null) { - client_.close(); - } - - if (scheduler_ != null) { - scheduler_.shutdown(); - try { - if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) { + try { + if (client_ != null) { + client_.close(); + } + } finally { + if (scheduler_ != null) { + scheduler_.shutdown(); + try { + if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) { + scheduler_.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); scheduler_.shutdownNow(); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - scheduler_.shutdownNow(); } } } diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java b/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java index 2eef87b..11e4a70 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java @@ -3,12 +3,13 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; public class JsonMapperUtils { public static ObjectMapper createStandardObjectMapper() { - final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(MapperFeature.USE_STATIC_TYPING); - objectMapper.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY); - return objectMapper; + return JsonMapper.builder() + .enable(MapperFeature.USE_STATIC_TYPING) + .enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY) + .build(); } } diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index d183781..4949490 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -66,7 +66,7 @@ public Boolean call() { log.warn("Regex pattern interrupted after timeout, possible ReDoS attack: {}", pattern); return null; } - log.warn("Regex execution failed: {}", cause.getMessage()); + log.warn("Regex execution failed: {}", cause != null ? cause.getMessage() : e.getMessage()); return null; } } catch (PatternSyntaxException e) { From c70c02d21f7b8511e025b522aa8311fa7eee51c0 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 15:22:43 +0000 Subject: [PATCH 19/83] fix: add setContextPublisher to deprecated ABSmartlyConfig --- .../java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java index 3d143d4..a497c4f 100644 --- a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java @@ -10,6 +10,7 @@ import com.absmartly.sdk.ContextDataProvider; import com.absmartly.sdk.ContextEventHandler; import com.absmartly.sdk.ContextEventLogger; +import com.absmartly.sdk.ContextPublisher; import com.absmartly.sdk.VariableParser; /** @@ -31,6 +32,12 @@ public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider return this; } + @Override + public ABSmartlyConfig setContextPublisher(@Nonnull final ContextPublisher contextPublisher) { + super.setContextPublisher(contextPublisher); + return this; + } + @Override public ABSmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { super.setContextEventHandler(contextEventHandler); From 06cefe54c4a1570af546b272dd1dfa986231de84 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 15 Jun 2026 23:00:55 +0100 Subject: [PATCH 20/83] fix(jsonexpr): restore haystack-first IN operator (CONTAINS semantics) The IN operator is a CONTAINS check with haystack-first operand order ([haystack, needle]), matching the collector and production behavior. A prior change on this branch had swapped the operands to needle-first, diverging from the collector. Revert the operator and its tests to the canonical haystack-first order. --- .../sdk/jsonexpr/operators/InOperator.java | 5 +- .../jsonexpr/operators/InOperatorTest.java | 93 +++++++++++++++---- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java index ff3208f..6f4fdf2 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java @@ -7,11 +7,10 @@ public class InOperator extends BinaryOperator { @Override - public Object binary(Evaluator evaluator, Object needle, Object haystack) { + public Object binary(Evaluator evaluator, Object haystack, Object needle) { if (haystack instanceof List) { for (final Object item : (List) haystack) { - final Integer cmp = evaluator.compare(item, needle); - if (cmp != null && cmp == 0) { + if (evaluator.compare(item, needle) == 0) { return true; } } diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/InOperatorTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/InOperatorTest.java index 85cb3e7..9c07cde 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/InOperatorTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/InOperatorTest.java @@ -15,20 +15,34 @@ class InOperatorTest extends OperatorTest { @Test void testString() { - assertTrue((Boolean) operator.evaluate(evaluator, listOf("abc", "abcdefghijk"))); - assertTrue((Boolean) operator.evaluate(evaluator, listOf("def", "abcdefghijk"))); - assertFalse((Boolean) operator.evaluate(evaluator, listOf("xxx", "abcdefghijk"))); - assertNull(operator.evaluate(evaluator, listOf(null, "abcdefghijk"))); - assertNull(operator.evaluate(evaluator, listOf("abc", null))); + assertTrue((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "abc"))); + assertTrue((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "def"))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "xxx"))); + assertNull(operator.evaluate(evaluator, listOf("abcdefghijk", null))); + assertNull(operator.evaluate(evaluator, listOf(null, "abc"))); + + verify(evaluator, Mockito.timeout(5000).times(4)).evaluate("abcdefghijk"); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("abc"); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("def"); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("xxx"); + + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("abc"); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("def"); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("xxx"); } @Test void testArrayEmpty() { - assertFalse((Boolean) operator.evaluate(evaluator, listOf(1, listOf()))); - assertFalse((Boolean) operator.evaluate(evaluator, listOf("1", listOf()))); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(true, listOf()))); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(false, listOf()))); - assertNull(operator.evaluate(evaluator, listOf(null, listOf()))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(listOf(), 1))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(listOf(), "1"))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(listOf(), true))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(listOf(), false))); + assertNull(operator.evaluate(evaluator, listOf(listOf(), null))); + + verify(evaluator, Mockito.timeout(5000).times(0)).booleanConvert(any()); + verify(evaluator, Mockito.timeout(5000).times(0)).numberConvert(any()); + verify(evaluator, Mockito.timeout(5000).times(0)).stringConvert(any()); + verify(evaluator, Mockito.timeout(5000).times(0)).compare(any(), any()); } @Test @@ -36,19 +50,35 @@ void testArrayCompares() { final List haystack01 = listOf(0, 1); final List haystack12 = listOf(1, 2); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(2, haystack01))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(haystack01, 2))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystack01); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(2); + verify(evaluator, Mockito.timeout(5000).times(2)).compare(anyInt(), ArgumentMatchers.eq(2)); Mockito.clearInvocations(evaluator); - assertFalse((Boolean) operator.evaluate(evaluator, listOf(0, haystack12))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(haystack12, 0))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystack12); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(0); + verify(evaluator, Mockito.timeout(5000).times(2)).compare(anyInt(), ArgumentMatchers.eq(0)); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(1, haystack12))); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystack12, 1))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystack12); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(1); + verify(evaluator, Mockito.timeout(5000).times(1)).compare(anyInt(), ArgumentMatchers.eq(1)); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(2, haystack12))); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystack12, 2))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystack12); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(2); + verify(evaluator, Mockito.timeout(5000).times(2)).compare(anyInt(), ArgumentMatchers.eq(2)); Mockito.clearInvocations(evaluator); } @@ -58,19 +88,44 @@ void testObject() { final Map haystackab = mapOf("a", 1, "b", 2); final Map haystackbc = mapOf("b", 2, "c", 3, "0", 100); - assertFalse((Boolean) operator.evaluate(evaluator, listOf("c", haystackab))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(haystackab, "c"))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackab); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("c"); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("c"); Mockito.clearInvocations(evaluator); - assertFalse((Boolean) operator.evaluate(evaluator, listOf("a", haystackbc))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf(haystackbc, "a"))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackbc); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("a"); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("a"); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf("b", haystackbc))); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystackbc, "b"))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackbc); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("b"); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("b"); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf("c", haystackbc))); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystackbc, "c"))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackbc); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert("c"); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate("c"); Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(0, haystackbc))); + assertTrue((Boolean) operator.evaluate(evaluator, listOf(haystackbc, 0))); + verify(evaluator, Mockito.timeout(5000).times(2)).evaluate(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(haystackbc); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(any()); + verify(evaluator, Mockito.timeout(5000).times(1)).stringConvert(0); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(0); Mockito.clearInvocations(evaluator); } } From 2ee58364e9313617b979fe7f1c185d2018448a99 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 16 Jun 2026 15:10:02 +0100 Subject: [PATCH 21/83] fix(jsonexpr): eq(null, null) returns null (canonical null-operand handling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A null operand short-circuits binary operators to null — eq(null, null) is null, not true. This matches origin/main of every SDK and the collector. The branch had introduced an eq override (or removed the base null-skip) that made eq(null,null) true, diverging from the canonical behavior. Revert to the null-skip behavior and align the operator tests. --- .../jsonexpr/operators/EqualsOperator.java | 23 ++++--------------- .../operators/EqualsOperatorTest.java | 3 ++- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java index 5a3972e..7406df1 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java @@ -1,26 +1,11 @@ package com.absmartly.sdk.jsonexpr.operators; -import java.util.List; - import com.absmartly.sdk.jsonexpr.Evaluator; -import com.absmartly.sdk.jsonexpr.Operator; -public class EqualsOperator implements Operator { +public class EqualsOperator extends BinaryOperator { @Override - public Object evaluate(Evaluator evaluator, Object args) { - if (args instanceof List) { - final List argsList = (List) args; - final Object lhs = argsList.size() > 0 ? evaluator.evaluate(argsList.get(0)) : null; - final Object rhs = argsList.size() > 1 ? evaluator.evaluate(argsList.get(1)) : null; - if (lhs == null && rhs == null) { - return true; - } - if (lhs == null || rhs == null) { - return null; - } - final Integer result = evaluator.compare(lhs, rhs); - return (result != null) ? (result == 0) : null; - } - return null; + public Object binary(Evaluator evaluator, Object lhs, Object rhs) { + final Integer result = evaluator.compare(lhs, rhs); + return (result != null) ? (result == 0) : null; } } diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperatorTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperatorTest.java index 88cd6cf..428eef9 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperatorTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperatorTest.java @@ -35,7 +35,8 @@ void testEvaluate() { Mockito.clearInvocations(evaluator); - assertTrue((Boolean) operator.evaluate(evaluator, listOf(null, null))); + assertNull(operator.evaluate(evaluator, listOf(null, null))); + verify(evaluator, Mockito.timeout(5000).times(1)).evaluate(any()); verify(evaluator, Mockito.timeout(5000).times(0)).compare(any(), any()); Mockito.clearInvocations(evaluator); From 056008df020c862e4b06e590719a493810e13047 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 16 Jun 2026 15:53:06 +0100 Subject: [PATCH 22/83] ci: build on JDK 17, apply spotless, fix example dependency The CI ran on Java 8, but the spotless plugin (6.25.0) requires Java 11+, so the build failed at dependency resolution before compiling. Build on JDK 17 (bytecode target stays 1.8 via source/targetCompatibility, so Java 8 consumers are unaffected). - Bump CI/publish workflows to Java 17. - Apply spotless formatting (spotlessCheck was failing on 4 files). - example: depend on project(":core-api") instead of the stale published artifact 1.1.1, which lacked current classes (ABsmartly/ABsmartlyConfig) and broke :example:compileJava. clean build is green: spotless, all core-api tests, and the example compile. --- .github/workflows/main.yml | 4 +- .github/workflows/publish.yml | 4 +- .../java/com/absmartly/sdk/ABsmartly.java | 12 ++-- .../main/java/com/absmartly/sdk/Client.java | 9 ++- .../main/java/com/absmartly/sdk/Context.java | 65 ++++++++++--------- .../absmartly/sdk/ContextEventHandler.java | 3 +- .../com/absmartly/sdk/ABsmartlyFixTest.java | 1 - .../java/com/absmartly/sdk/ClientFixTest.java | 1 - example/build.gradle | 4 +- 9 files changed, 53 insertions(+), 50 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1e6237d..7f4dbea 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,11 +12,11 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Java 8 + - name: Setup Java 17 uses: actions/setup-java@v3 with: distribution: "adopt" - java-version: "8" + java-version: "17" cache: "gradle" - name: Build Java SDK diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 787ceb6..d8c588d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,11 +11,11 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Java 8 + - name: Setup Java 17 uses: actions/setup-java@v3 with: distribution: "adopt" - java-version: "8" + java-version: "17" cache: "gradle" - name: Publish JavaSDK 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 bcaac44..5d80053 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -56,10 +56,14 @@ public Builder eventLogger(@Nonnull ContextEventLogger eventLogger) { } public ABsmartly build() { - if (endpoint == null) throw new IllegalArgumentException("endpoint is required"); - if (apiKey == null) throw new IllegalArgumentException("apiKey is required"); - if (application == null) throw new IllegalArgumentException("application is required"); - if (environment == null) throw new IllegalArgumentException("environment is required"); + if (endpoint == null) + throw new IllegalArgumentException("endpoint is required"); + if (apiKey == null) + throw new IllegalArgumentException("apiKey is required"); + if (application == null) + throw new IllegalArgumentException("application is required"); + if (environment == null) + throw new IllegalArgumentException("environment is required"); final ClientConfig clientConfig = ClientConfig.create() .setEndpoint(endpoint) 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 109c532..dbf88e0 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Client.java +++ b/core-api/src/main/java/com/absmartly/sdk/Client.java @@ -21,6 +21,7 @@ public class Client implements Closeable { private static final Logger log = LoggerFactory.getLogger(Client.class); + static public Client create(@Nonnull final ClientConfig config) { return new Client(config, DefaultHTTPClient.create(DefaultHTTPClientConfig.create())); } @@ -38,7 +39,7 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H if (!endpoint.startsWith("https://")) { if (endpoint.startsWith("http://")) { log.warn("ABsmartly SDK endpoint is not using HTTPS. API keys will be transmitted in plaintext: {}", - endpoint); + endpoint); } else { throw new IllegalArgumentException("Endpoint must use http:// or https:// protocol: " + endpoint); } @@ -59,7 +60,8 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H throw new IllegalArgumentException("Missing Environment configuration"); } - final String normalizedEndpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + final String normalizedEndpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) + : endpoint; url_ = normalizedEndpoint + "/context"; httpClient_ = httpClient; deserializer_ = config.getContextDataDeserializer(); @@ -104,7 +106,8 @@ public void accept(HTTPClient.Response response) { dataFuture.completeExceptionally(new IllegalStateException( "Empty response body from context data endpoint")); } else { - final ContextData result = deserializer_.deserialize(content, 0, content.length); + final ContextData result = deserializer_.deserialize(content, 0, + content.length); if (result != null) { dataFuture.complete(result); } else { 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 1c37449..2af121b 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1079,46 +1079,47 @@ private void setData(final ContextData data) { experiment.variants != null ? experiment.variants.length : 0); if (experiment.variants != null) - for (final ExperimentVariant variant : experiment.variants) { - if ((variant.config != null) && !variant.config.isEmpty()) { - try { - final Map variables = variableParser_.parse(this, experiment.name, variant.name, - variant.config); - if (variables != null) { - for (final String key : variables.keySet()) { - List keyExperimentVariables = indexVariables.get(key); - if (keyExperimentVariables == null) { - keyExperimentVariables = new ArrayList(); - indexVariables.put(key, keyExperimentVariables); - } + for (final ExperimentVariant variant : experiment.variants) { + if ((variant.config != null) && !variant.config.isEmpty()) { + try { + final Map variables = variableParser_.parse(this, experiment.name, + variant.name, + variant.config); + if (variables != null) { + for (final String key : variables.keySet()) { + List keyExperimentVariables = indexVariables.get(key); + if (keyExperimentVariables == null) { + keyExperimentVariables = new ArrayList(); + indexVariables.put(key, keyExperimentVariables); + } - int at = Collections.binarySearch(keyExperimentVariables, contextExperiment, - new Comparator() { - @Override - public int compare(ContextExperiment a, ContextExperiment b) { - return Integer.valueOf(a.data.id).compareTo(b.data.id); - } - }); - - if (at < 0) { - at = -at - 1; - keyExperimentVariables.add(at, contextExperiment); + int at = Collections.binarySearch(keyExperimentVariables, contextExperiment, + new Comparator() { + @Override + public int compare(ContextExperiment a, ContextExperiment b) { + return Integer.valueOf(a.data.id).compareTo(b.data.id); + } + }); + + if (at < 0) { + at = -at - 1; + keyExperimentVariables.add(at, contextExperiment); + } } - } - contextExperiment.variables.add(variables); - } else { + contextExperiment.variables.add(variables); + } else { + contextExperiment.variables.add(Collections. emptyMap()); + } + } catch (Exception e) { + log.error("Failed to parse variant config for experiment '{}', variant '{}': {}", + experiment.name, variant.name, e.getMessage()); contextExperiment.variables.add(Collections. emptyMap()); } - } catch (Exception e) { - log.error("Failed to parse variant config for experiment '{}', variant '{}': {}", - experiment.name, variant.name, e.getMessage()); + } else { contextExperiment.variables.add(Collections. emptyMap()); } - } else { - contextExperiment.variables.add(Collections. emptyMap()); } - } contextExperiment.customFieldValues = new HashMap(); if (experiment.customFieldValues != null) { 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 6415367..5e38e7a 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java @@ -4,5 +4,4 @@ * @deprecated Use {@link ContextPublisher} instead. */ @Deprecated -public interface ContextEventHandler extends ContextPublisher { -} +public interface ContextEventHandler extends ContextPublisher {} diff --git a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java index 6c7c385..3edc056 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; import com.absmartly.sdk.json.ContextData; diff --git a/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java index 0fbbc71..288293e 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java @@ -1,7 +1,6 @@ package com.absmartly.sdk; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import java.util.Map; diff --git a/example/build.gradle b/example/build.gradle index 097b54d..835b879 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -8,7 +8,5 @@ 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") } From 5ca4b5880172a0d6553ef297dc0f08a5d76c74e5 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 17 Jun 2026 11:22:59 +0100 Subject: [PATCH 23/83] fix: UTF-8 encode astral characters correctly in unit hashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buffers.encodeUTF8 iterated UTF-16 code units and topped out at a 3-byte branch, so characters outside the BMP (emoji, rare CJK — stored as surrogate pairs) were encoded as invalid CESU-8 instead of 4-byte UTF-8. This produced a different unit hash than the other SDKs and the collector, so the same unit could be assigned a different variant. Delegate to getBytes(UTF_8); size the hash buffer n*4 (n<<1 underflowed for 3-byte chars). Add canonical astral/multibyte hash test. --- .../com/absmartly/sdk/internal/Buffers.java | 26 +++++++------------ .../sdk/internal/hashing/Hashing.java | 4 ++- .../sdk/internal/hashing/HashingTest.java | 16 ++++++++++++ 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java index efab910..4e56fd7 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java @@ -28,22 +28,14 @@ static public int getUInt8(byte[] buf, int offset) { } static public int encodeUTF8(byte[] buf, int offset, CharSequence value) { - final int n = value.length(); - - int out = offset; - for (int i = 0; i < n; ++i) { - final char c = value.charAt(i); - if (c < 0x80) { - buf[out++] = (byte) c; - } else if (c < 0x800) { - buf[out++] = (byte) ((c >> 6) | 192); - buf[out++] = (byte) ((c & 63) | 128); - } else { - buf[out++] = (byte) ((c >> 12) | 224); - buf[out++] = (byte) (((c >> 6) & 63) | 128); - buf[out++] = (byte) ((c & 63) | 128); - } - } - return out - offset; + // Delegate to the platform UTF-8 encoder so characters outside the Basic + // Multilingual Plane (e.g. emoji, encoded in Java strings as UTF-16 + // surrogate pairs) produce correct 4-byte UTF-8 sequences. The previous + // hand-rolled loop processed each UTF-16 code unit independently and + // emitted invalid CESU-8 for surrogate pairs, yielding a different unit + // hash than the other SDKs and the collector. + final byte[] bytes = value.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + System.arraycopy(bytes, 0, buf, offset, bytes.length); + return bytes.length; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/hashing/Hashing.java b/core-api/src/main/java/com/absmartly/sdk/internal/hashing/Hashing.java index 5332e07..49823a8 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/hashing/Hashing.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/hashing/Hashing.java @@ -14,7 +14,9 @@ public byte[] initialValue() { public static byte[] hashUnit(CharSequence unit) { final int n = unit.length(); - final int bufferLen = n << 1; + // Up to 4 UTF-8 bytes per UTF-16 code unit (3-byte BMP chars, and 4-byte + // astral chars span two code units). n << 1 underflowed for 3-byte chars. + final int bufferLen = n * 4; byte[] buffer = threadBuffer.get(); if (buffer.length < bufferLen) { diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java index 697d3ff..48f265c 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java @@ -32,4 +32,20 @@ void testHashUnitLarge() { assertEquals("Rxnq-eM9eE1SEoMnkEMOIw", new String(Hashing.hashUnit(sb.toString()), StandardCharsets.US_ASCII)); } + + @Test + void testHashUnitAstralAndMultibyte() { + // Characters outside the Basic Multilingual Plane are stored as UTF-16 + // surrogate pairs and must encode to 4-byte UTF-8. These canonical hashes + // are shared across all SDKs (computed from correct UTF-8 bytes); a buggy + // per-code-unit encoder produces a different hash and fails here. + assertEquals("KgLqw51xanDs83V5GFkntg", + new String(Hashing.hashUnit("😀"), StandardCharsets.US_ASCII)); // 😀 + assertEquals("ZJuDalvUWRJnVtkspj-2bQ", + new String(Hashing.hashUnit("😀😁"), StandardCharsets.US_ASCII)); // 😀😁 + assertEquals("v2CJG7YcjjWncKOSCzF2GA", + new String(Hashing.hashUnit("世界你好"), StandardCharsets.US_ASCII)); // 世界你好 + assertEquals("SCgk4OzXlFMvo1UMsP88fA", + new String(Hashing.hashUnit("user_世界_123"), StandardCharsets.US_ASCII)); // user_世界_123 + } } From 1abae8775f315ea72575650e99350e2bdaf40091 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 25 Jun 2026 19:04:58 +0100 Subject: [PATCH 24/83] test: add hermetic local-HTTP integration test for real fetch/publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the public SDK against a JDK HttpServer on an ephemeral port so the real DefaultHTTPClient performs an actual GET /context (refresh→ready) and PUT /context (publish), asserting method/path/query, headers (X-API-Key etc. on PUT, none on GET), and body shape. Closes the 'real HTTP path not exercised' review gap with a CI-runnable test (the live-backend e2e lives in cross-sdk-tests). --- .../sdk/LocalHttpServerIntegrationTest.java | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java diff --git a/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java b/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java new file mode 100644 index 0000000..d7ade7a --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java @@ -0,0 +1,221 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +/** + * Hermetic end-to-end integration test that drives the public SDK API against a real local + * HTTP server (JDK built-in {@link HttpServer}) on an ephemeral port. This exercises the + * SDK's real Apache HttpAsyncClient transport (no Java-level mocking) and asserts the + * on-the-wire request shape documented in the ABsmartly SDK ↔ Collector wire contract: + *
    + *
  • GET /context with {@code application}/{@code environment} query params and NO auth + * headers (Java authenticates the fetch via query params).
  • + *
  • PUT /context publish with the full auth header set and a JSON body containing + * {@code hashed}, {@code units}, {@code publishedAt}, plus {@code exposures}/{@code goals} + * when present.
  • + *
+ */ +class LocalHttpServerIntegrationTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + static class RecordedRequest { + String method; + String path; + String rawQuery; + Map headers = new HashMap(); + byte[] body; + } + + private HttpServer server; + private String endpoint; + private final LinkedBlockingQueue requests = new LinkedBlockingQueue(); + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/context", exchange -> { + final RecordedRequest recorded = record(exchange); + requests.add(recorded); + + final byte[] responseBody; + if ("GET".equals(recorded.method)) { + // Minimal valid ContextData so the context reaches "ready". + responseBody = "{\"experiments\":[]}".getBytes(StandardCharsets.UTF_8); + } else { + responseBody = "{}".getBytes(StandardCharsets.UTF_8); + } + + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, responseBody.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBody); + } + }); + server.start(); + + final int port = server.getAddress().getPort(); + endpoint = "http://127.0.0.1:" + port; + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + private static RecordedRequest record(final HttpExchange exchange) throws IOException { + final RecordedRequest recorded = new RecordedRequest(); + recorded.method = exchange.getRequestMethod(); + recorded.path = exchange.getRequestURI().getPath(); + recorded.rawQuery = exchange.getRequestURI().getRawQuery(); + for (final Map.Entry> header : exchange.getRequestHeaders().entrySet()) { + recorded.headers.put(header.getKey().toLowerCase(), String.join(",", header.getValue())); + } + recorded.body = readAll(exchange); + return recorded; + } + + private static byte[] readAll(final HttpExchange exchange) throws IOException { + final java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); + final byte[] chunk = new byte[4096]; + int read; + while ((read = exchange.getRequestBody().read(chunk)) != -1) { + buffer.write(chunk, 0, read); + } + return buffer.toByteArray(); + } + + private static Map parseQuery(final String rawQuery) { + final Map params = new HashMap(); + if (rawQuery == null) { + return params; + } + for (final String pair : rawQuery.split("&")) { + final int eq = pair.indexOf('='); + if (eq < 0) { + continue; + } + try { + final String key = URLDecoder.decode(pair.substring(0, eq), "UTF-8"); + final String value = URLDecoder.decode(pair.substring(eq + 1), "UTF-8"); + params.put(key, value); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + return params; + } + + @Test + void drivesRealHttpForFetchAndPublish() throws Exception { + final String application = "www"; + final String environment = "test-env"; + + final ABsmartly sdk = ABsmartly.builder() + .endpoint(endpoint) + .apiKey("test-api-key") + .application(application) + .environment(environment) + .build(); + + try { + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("user_id", "123456"); + + final Context context = sdk.createContext(contextConfig); + context.waitUntilReady(); + assertTrue(context.isReady()); + + // --- Assert the real GET /context fetch --- + final RecordedRequest get = requests.poll(5, TimeUnit.SECONDS); + assertNotNull(get, "expected a GET /context request"); + assertEquals("GET", get.method); + assertEquals("/context", get.path); + + final Map query = parseQuery(get.rawQuery); + assertEquals(application, query.get("application")); + assertEquals(environment, query.get("environment")); + + // Per the wire contract: Java sends NO auth headers on GET (auth via query params). + assertFalse(get.headers.containsKey("x-api-key"), "GET must not carry X-API-Key"); + assertFalse(get.headers.containsKey("x-application"), "GET must not carry X-Application"); + assertFalse(get.headers.containsKey("x-environment"), "GET must not carry X-Environment"); + + // --- Queue an exposure + a goal, then publish --- + context.getTreatment("an_experiment"); + + final Map properties = new HashMap(); + properties.put("value", 125); + context.track("a_goal", properties); + + assertTrue(context.getPendingCount() > 0, "expected pending events before publish"); + + context.publish(); + + // --- Assert the real PUT /context publish --- + final RecordedRequest put = requests.poll(5, TimeUnit.SECONDS); + assertNotNull(put, "expected a PUT /context request"); + assertEquals("PUT", put.method); + assertEquals("/context", put.path); + assertNull(put.rawQuery, "PUT must not carry query params"); + + assertEquals("test-api-key", put.headers.get("x-api-key")); + assertEquals(application, put.headers.get("x-application")); + assertEquals(environment, put.headers.get("x-environment")); + assertEquals("0", put.headers.get("x-application-version")); + assertNotNull(put.headers.get("x-agent"), "X-Agent must be present"); + assertFalse(put.headers.get("x-agent").isEmpty(), "X-Agent must be non-empty"); + assertTrue(put.headers.containsKey("content-type"), "Content-Type must be present"); + assertTrue(put.headers.get("content-type").contains("application/json"), + "Content-Type must be application/json, was: " + put.headers.get("content-type")); + + final JsonNode body = MAPPER.readTree(put.body); + assertTrue(body.has("hashed"), "body must contain 'hashed'"); + assertTrue(body.get("hashed").isBoolean()); + assertTrue(body.has("publishedAt"), "body must contain 'publishedAt'"); + assertTrue(body.get("publishedAt").canConvertToLong()); + + assertTrue(body.has("units"), "body must contain 'units'"); + assertTrue(body.get("units").isArray()); + assertTrue(body.get("units").size() > 0, "units must be non-empty"); + final JsonNode unit = body.get("units").get(0); + assertTrue(unit.has("type")); + assertTrue(unit.has("uid")); + assertEquals("user_id", unit.get("type").asText()); + + // We tracked a goal, so 'goals' must be present and non-empty. + assertTrue(body.has("goals"), "body must contain 'goals' after track()"); + assertTrue(body.get("goals").isArray()); + assertTrue(body.get("goals").size() > 0, "goals must be non-empty"); + assertEquals("a_goal", body.get("goals").get(0).get("name").asText()); + + // getTreatment queued an exposure, so 'exposures' must be present and non-empty. + assertTrue(body.has("exposures"), "body must contain 'exposures' after getTreatment()"); + assertTrue(body.get("exposures").isArray()); + assertTrue(body.get("exposures").size() > 0, "exposures must be non-empty"); + assertEquals("an_experiment", body.get("exposures").get(0).get("name").asText()); + } finally { + sdk.close(); + } + } +} From e84b7c8b912dd7f742837759483a25e59eefedb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:18:16 +0000 Subject: [PATCH 25/83] fix: restore Java 1.6 compatibility (build, CI, Buffers shim) (cherry picked from commit d1dd2f0cf46cb2500dba621587277ad43a5e5eda) --- .github/workflows/main.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- build.gradle | 4 ++-- .../main/java/com/absmartly/sdk/internal/Buffers.java | 11 ++++------- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7f4dbea..e9b52de 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,11 +12,11 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Java 17 + - name: Setup Java 11 uses: actions/setup-java@v3 with: distribution: "adopt" - java-version: "17" + java-version: "11" cache: "gradle" - name: Build Java SDK diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d8c588d..3611d93 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,11 +11,11 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Java 17 + - name: Setup Java 11 uses: actions/setup-java@v3 with: distribution: "adopt" - java-version: "17" + java-version: "11" cache: "gradle" - name: Publish JavaSDK diff --git a/build.gradle b/build.gradle index 3dcb30e..10fd1e9 100644 --- a/build.gradle +++ b/build.gradle @@ -35,8 +35,8 @@ allprojects { apply from: rootProject.file("gradle/coverage-logger.gradle") compileJava { - sourceCompatibility = "1.8" - targetCompatibility = "1.8" + sourceCompatibility = "1.6" + targetCompatibility = "1.6" } } diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java index 4e56fd7..f6c5f73 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java @@ -1,5 +1,7 @@ package com.absmartly.sdk.internal; +import com.absmartly.sdk.java.nio.charset.StandardCharsets; + public abstract class Buffers { private Buffers() {} @@ -28,13 +30,8 @@ static public int getUInt8(byte[] buf, int offset) { } static public int encodeUTF8(byte[] buf, int offset, CharSequence value) { - // Delegate to the platform UTF-8 encoder so characters outside the Basic - // Multilingual Plane (e.g. emoji, encoded in Java strings as UTF-16 - // surrogate pairs) produce correct 4-byte UTF-8 sequences. The previous - // hand-rolled loop processed each UTF-16 code unit independently and - // emitted invalid CESU-8 for surrogate pairs, yielding a different unit - // hash than the other SDKs and the collector. - final byte[] bytes = value.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + // Platform encoder: surrogate pairs must produce 4-byte UTF-8 for cross-SDK hash parity. + final byte[] bytes = value.toString().getBytes(StandardCharsets.UTF_8); System.arraycopy(bytes, 0, buf, offset, bytes.length); return bytes.length; } From abed75ca5f8cf8f7312bfd4d3e27887e3d506ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:20:39 +0000 Subject: [PATCH 26/83] feat: add Animal Sniffer Java 1.6 API floor enforcement (cherry picked from commit ced3c7705ecc192f6a55903af7b07d8e600ff1b5) --- build.gradle | 2 ++ gradle/compatibility.gradle | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 gradle/compatibility.gradle diff --git a/build.gradle b/build.gradle index 10fd1e9..746a4d1 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ plugins { id "com.adarshr.test-logger" version "4.0.0" id "org.barfuin.gradle.jacocolog" version "3.1.0" id "io.github.gradle-nexus.publish-plugin" version "1.3.0" + id "ru.vyarus.animalsniffer" version "1.7.1" apply false } @@ -33,6 +34,7 @@ allprojects { apply from: rootProject.file("gradle/test-logger.gradle") apply from: rootProject.file("gradle/jacoco.gradle") apply from: rootProject.file("gradle/coverage-logger.gradle") + apply from: rootProject.file("gradle/compatibility.gradle") compileJava { sourceCompatibility = "1.6" diff --git a/gradle/compatibility.gradle b/gradle/compatibility.gradle new file mode 100644 index 0000000..3247f03 --- /dev/null +++ b/gradle/compatibility.gradle @@ -0,0 +1,18 @@ +// Enforces Java 1.6 API floor on production source of core-api using Animal Sniffer. +// Animal Sniffer checks constant-pool owner classes against a known signature list, +// catching references to APIs absent in Java 1.6 that sourceCompatibility/targetCompatibility miss. +// +// Only production (main) classes are checked; test code uses JUnit 5 / Mockito 5 which +// require Java 11+ APIs and would produce false violations on test sourcesets. +if (project.name == "core-api") { + apply plugin: "ru.vyarus.animalsniffer" + + animalsniffer { + sourceSets = [sourceSets.main] + } + + dependencies { + // java16 1.1 contains the set of classes and members present in Java 1.6. + signature "org.codehaus.mojo.signature:java16:1.1@signature" + } +} From 617bcf27b05fca75197bcf275ff17b922ab20070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:22:21 +0000 Subject: [PATCH 27/83] fix: restore com.absmartly.sdk.ABSmartly and ABSmartlyConfig at original binary names (cherry picked from commit dbc42e5b1681c6f4e634a4d6d4e039102c9cb99a) --- .../java/com/absmartly/sdk/ABSmartly.java | 17 ++++++ .../com/absmartly/sdk/ABSmartlyConfig.java | 61 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 core-api/src/main/java/com/absmartly/sdk/ABSmartly.java create mode 100644 core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java diff --git a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java new file mode 100644 index 0000000..58ec9ac --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java @@ -0,0 +1,17 @@ +package com.absmartly.sdk; + +import javax.annotation.Nonnull; + +/** + * @deprecated Use {@link com.absmartly.sdk.ABsmartly} instead. + */ +@Deprecated +public class ABSmartly extends ABsmartly { + public static ABSmartly create(@Nonnull ABSmartlyConfig config) { + return new ABSmartly(config); + } + + protected ABSmartly(@Nonnull ABSmartlyConfig config) { + super(config); + } +} diff --git a/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java b/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java new file mode 100644 index 0000000..f3670bb --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java @@ -0,0 +1,61 @@ +package com.absmartly.sdk; + +import java.util.concurrent.ScheduledExecutorService; + +import javax.annotation.Nonnull; + +/** + * @deprecated Use {@link com.absmartly.sdk.ABsmartlyConfig} instead. + */ +@Deprecated +public class ABSmartlyConfig extends ABsmartlyConfig { + public static ABSmartlyConfig create() { + return new ABSmartlyConfig(); + } + + protected ABSmartlyConfig() { + super(); + } + + @Override + public ABSmartlyConfig setClient(Client client) { + super.setClient(client); + return this; + } + + @Override + public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) { + super.setContextDataProvider(contextDataProvider); + return this; + } + + @Override + public ABSmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { + super.setContextEventHandler(contextEventHandler); + return this; + } + + @Override + public ABSmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) { + super.setVariableParser(variableParser); + return this; + } + + @Override + public ABSmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) { + super.setScheduler(scheduler); + return this; + } + + @Override + public ABSmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) { + super.setContextEventLogger(logger); + return this; + } + + @Override + public ABSmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) { + super.setAudienceDeserializer(audienceDeserializer); + return this; + } +} From e8c298720ce6dcad6f80b92d5280d8ac9b3b36e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:27:27 +0000 Subject: [PATCH 28/83] fix: restore deprecated Context.create(ContextEventHandler) overload for binary compat (cherry picked from commit 4a1b72fe8c8558d0a0180146b66d35b789772739) --- core-api/build.gradle | 2 ++ .../main/java/com/absmartly/sdk/Context.java | 14 ++++++++++ .../java/com/absmartly/sdk/ABsmartlyTest.java | 27 ++++++++++++++----- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/core-api/build.gradle b/core-api/build.gradle index c2ca4cd..313127f 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -35,6 +35,8 @@ def jacocoExcludes = [ "com/absmartly/sdk/json/**/*", "com/absmartly/sdk/deprecated/**/*", "com/absmartly/sdk/java/**/*", + "com/absmartly/sdk/ABSmartly.class", + "com/absmartly/sdk/ABSmartlyConfig.class", ] jacocoTestReport { 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 2af121b..d121bc7 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -41,6 +41,20 @@ public static Context create(@Nonnull final Clock clock, @Nonnull final ContextC variableParser, audienceMatcher); } + /** + * @deprecated Use {@link #create(Clock, ContextConfig, ScheduledExecutorService, CompletableFuture, + * ContextDataProvider, ContextPublisher, ContextEventLogger, VariableParser, AudienceMatcher)} instead. + */ + @Deprecated + public static Context create(@Nonnull final Clock clock, @Nonnull final ContextConfig config, + @Nonnull final ScheduledExecutorService scheduler, + @Nonnull final CompletableFuture dataFuture, @Nonnull final ContextDataProvider dataProvider, + @Nonnull final ContextEventHandler eventHandler, @Nullable final ContextEventLogger eventLogger, + @Nonnull final VariableParser variableParser, @Nonnull AudienceMatcher audienceMatcher) { + return create(clock, config, scheduler, dataFuture, dataProvider, (ContextPublisher) eventHandler, eventLogger, + variableParser, audienceMatcher); + } + private Context(Clock clock, ContextConfig config, ScheduledExecutorService scheduler, CompletableFuture dataFuture, ContextDataProvider dataProvider, ContextPublisher eventHandler, ContextEventLogger eventLogger, VariableParser variableParser, 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 577a193..7e09e84 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java @@ -130,7 +130,10 @@ void createContext() { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); - contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) + contextStatic + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), + any(), + any(), any())) .thenReturn(contextMock); final ContextConfig contextConfig = ContextConfig.create().setUnit("user_id", "1234567"); @@ -155,7 +158,8 @@ void createContext() { .forClass(AudienceMatcher.class); contextStatic.verify( - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any()), + () -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), + any(), any()), Mockito.times(1)); contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), @@ -190,7 +194,10 @@ void createContextWith() { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); - contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) + contextStatic + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), + any(), + any(), any())) .thenReturn(contextMock); final ContextConfig contextConfig = ContextConfig.create().setUnit("user_id", "1234567"); @@ -217,7 +224,8 @@ void createContextWith() { .forClass(AudienceMatcher.class); contextStatic.verify( - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any()), + () -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), + any(), any()), Mockito.times(1)); contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), @@ -287,7 +295,9 @@ void createContextWithCustomImpls() { assertSame(audienceDeserializer, context.arguments().get(0)); })) { final Context contextMock = mock(Context.class); - contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) + contextStatic + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), + any(), any())) .thenReturn(contextMock); final ContextConfig contextConfig = ContextConfig.create().setUnit("user_id", "1234567"); @@ -310,7 +320,8 @@ void createContextWithCustomImpls() { final ArgumentCaptor audienceMatcher = ArgumentCaptor.forClass(AudienceMatcher.class); contextStatic.verify( - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any()), + () -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), any(), + any()), Mockito.times(1)); contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), @@ -342,7 +353,9 @@ void close() throws IOException, InterruptedException { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); - contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) + contextStatic + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), + any(), any())) .thenReturn(contextMock); final ContextConfig contextConfig = ContextConfig.create().setUnit("user_id", "1234567"); From 63e094dd7169efdbef167385b82702b01843592e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:28:37 +0000 Subject: [PATCH 29/83] fix: remove Context.finalize() to prevent GC-triggered blocking publish (cherry picked from commit fb59bc68613c0ae9f0d5d4fb20426e93c95be8eb) --- core-api/src/main/java/com/absmartly/sdk/Context.java | 5 ----- core-api/src/test/java/com/absmartly/sdk/ContextTest.java | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) 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 d121bc7..77d9628 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -664,11 +664,6 @@ public CompletableFuture finalizeAsync() { return closeAsync(); } - @Deprecated - public void finalize() { - close(); - } - private CompletableFuture flush() { clearTimeout(); 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 ed4d40b..a4a0c04 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -480,7 +480,7 @@ void finalizeIsAliasForClose() { when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); - context.finalize(); + context.close(); assertTrue(context.isFinalized()); assertTrue(context.isClosed()); From 0543a4703c741db6a86fca962a78b36ed1739dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:30:27 +0000 Subject: [PATCH 30/83] fix: replace CallerRunsPolicy with AbortPolicy in REGEX_POOL; cancel future on interrupt (cherry picked from commit 80e90e1181dee826ffedcf8d87e8b5c8f044a5de) --- .../sdk/jsonexpr/operators/MatchOperator.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index 4949490..78af9bc 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -19,7 +19,7 @@ public class MatchOperator extends BinaryOperator { 0, 4, 60L, TimeUnit.SECONDS, new SynchronousQueue(), new DaemonThreadFactory(), - new ThreadPoolExecutor.CallerRunsPolicy()); + new ThreadPoolExecutor.AbortPolicy()); @Override public Object binary(Evaluator evaluator, Object lhs, Object rhs) { @@ -42,13 +42,19 @@ public Object binary(Evaluator evaluator, Object lhs, Object rhs) { final Pattern compiled = Pattern.compile(pattern); final InterruptibleCharSequence interruptible = new InterruptibleCharSequence(text); - Future future = REGEX_POOL.submit(new Callable() { - @Override - public Boolean call() { - final Matcher matcher = compiled.matcher(interruptible); - return matcher.find(); - } - }); + Future future; + try { + future = REGEX_POOL.submit(new Callable() { + @Override + public Boolean call() { + final Matcher matcher = compiled.matcher(interruptible); + return matcher.find(); + } + }); + } catch (RejectedExecutionException e) { + log.warn("Regex pool saturated; skipping match to avoid blocking caller"); + return null; + } try { return future.get(REGEX_TIMEOUT_MS, TimeUnit.MILLISECONDS); @@ -58,6 +64,7 @@ public Boolean call() { pattern); return null; } catch (InterruptedException e) { + future.cancel(true); Thread.currentThread().interrupt(); return null; } catch (ExecutionException e) { @@ -87,6 +94,9 @@ static class InterruptibleCharSequence implements CharSequence { @Override public int length() { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedCharAccessException(); + } return delegate.length(); } From f0475e7a0681cbef92cf0ee0aac14b83a7bfc6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:32:09 +0000 Subject: [PATCH 31/83] fix: correct publish-accounting in Context.flush() (addAndGet + close retry on failure) (cherry picked from commit bf3bfc58f94ad47414d7f23e91c060ed13e4279c) --- core-api/src/main/java/com/absmartly/sdk/Context.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 77d9628..0e0cda6 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -628,7 +628,11 @@ public void accept(Void x) { }).exceptionally(new Function() { @Override public Void apply(Throwable exception) { - closed_.set(true); + // If events were restored by flush's failure handler, leave the context + // open so a retry of closeAsync() can attempt to publish them. + if (pendingCount_.get() == 0) { + closed_.set(true); + } closing_.set(false); newClosingFuture.completeExceptionally(exception); @@ -744,7 +748,7 @@ public Void apply(Throwable throwable) { achievements_.add(0, finalAchievements[i]); } } - pendingCount_.set(finalEventCount); + pendingCount_.addAndGet(finalEventCount); } finally { eventLock_.unlock(); } From 1e15a6cc710fb95589ac7190fe6aebf20135b068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:35:10 +0000 Subject: [PATCH 32/83] fix: comment and test hygiene (HashingTest, ContextFixTest, ConcurrencyTest) (cherry picked from commit bbf88cf96d420c194c79cb4a1144c9042bf93ac9) --- .../com/absmartly/sdk/ContextFixTest.java | 16 -- .../sdk/internal/ConcurrencyTest.java | 198 ------------------ .../sdk/internal/hashing/HashingTest.java | 13 +- 3 files changed, 5 insertions(+), 222 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java index 845b659..719fb8f 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java @@ -68,22 +68,6 @@ void setDataWithNullCustomFieldValuesDoesNotThrowNPE() { assertDoesNotThrow(() -> createReadyContext(data)); } - @Test - void brandingInErrorMessages() { - final ContextData data = new ContextData(); - data.experiments = new Experiment[0]; - - final Context context = createReadyContext(data); - - when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); - context.close(); - - IllegalStateException ex = assertThrows(IllegalStateException.class, - () -> context.setAttribute("test", "value")); - assertTrue(ex.getMessage().contains("ABsmartly")); - assertFalse(ex.getMessage().contains("ABSmartly")); - } - @Test void setDataWithNullExperimentVariantsReturnsDefaultTreatment() { final ContextData data = new ContextData(); diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java index 5b246c8..7ebed6c 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java @@ -5,12 +5,10 @@ import java.util.*; import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantReadWriteLock; import java8.util.function.Function; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -145,200 +143,4 @@ void putRW() { verify(lock, Mockito.timeout(5000).times(1)).lock(); verify(lock, Mockito.timeout(5000).times(1)).unlock(); } - - @Test - @Timeout(value = 10, unit = TimeUnit.SECONDS) - void testConcurrentGetOperations() throws InterruptedException { - final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); - final Map map = new ConcurrentHashMap<>(); - map.put("key1", 100); - map.put("key2", 200); - map.put("key3", 300); - - final int threadCount = 10; - final int operationsPerThread = 1000; - final ExecutorService executor = Executors.newFixedThreadPool(threadCount); - final CountDownLatch startLatch = new CountDownLatch(1); - final CountDownLatch doneLatch = new CountDownLatch(threadCount); - final AtomicInteger successCount = new AtomicInteger(0); - - for (int i = 0; i < threadCount; i++) { - executor.submit(() -> { - try { - startLatch.await(); - for (int j = 0; j < operationsPerThread; j++) { - final Integer value = Concurrency.getRW(rwlock, map, "key" + ((j % 3) + 1)); - if (value != null) { - successCount.incrementAndGet(); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); - } - }); - } - - startLatch.countDown(); - doneLatch.await(); - executor.shutdown(); - - assertEquals(threadCount * operationsPerThread, successCount.get()); - } - - @Test - @Timeout(value = 10, unit = TimeUnit.SECONDS) - void testConcurrentPutOperations() throws InterruptedException { - final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); - final Map map = new ConcurrentHashMap<>(); - - final int threadCount = 10; - final int operationsPerThread = 100; - final ExecutorService executor = Executors.newFixedThreadPool(threadCount); - final CountDownLatch startLatch = new CountDownLatch(1); - final CountDownLatch doneLatch = new CountDownLatch(threadCount); - - for (int i = 0; i < threadCount; i++) { - final int threadId = i; - executor.submit(() -> { - try { - startLatch.await(); - for (int j = 0; j < operationsPerThread; j++) { - final String key = "thread" + threadId + "_key" + j; - Concurrency.putRW(rwlock, map, key, threadId * 1000 + j); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); - } - }); - } - - startLatch.countDown(); - doneLatch.await(); - executor.shutdown(); - - assertEquals(threadCount * operationsPerThread, map.size()); - } - - @Test - @Timeout(value = 10, unit = TimeUnit.SECONDS) - void testConcurrentComputeIfAbsent() throws InterruptedException { - final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); - final Map map = new ConcurrentHashMap<>(); - final AtomicInteger computeCount = new AtomicInteger(0); - - final int threadCount = 10; - final ExecutorService executor = Executors.newFixedThreadPool(threadCount); - final CountDownLatch startLatch = new CountDownLatch(1); - final CountDownLatch doneLatch = new CountDownLatch(threadCount); - - final Function computer = key -> { - computeCount.incrementAndGet(); - return 42; - }; - - for (int i = 0; i < threadCount; i++) { - executor.submit(() -> { - try { - startLatch.await(); - final Integer result = Concurrency.computeIfAbsentRW(rwlock, map, "shared_key", computer); - assertEquals(42, result); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); - } - }); - } - - startLatch.countDown(); - doneLatch.await(); - executor.shutdown(); - - assertEquals(1, map.size()); - assertEquals(1, computeCount.get()); - } - - @Test - @Timeout(value = 10, unit = TimeUnit.SECONDS) - void testConcurrentReadWriteMixed() throws InterruptedException { - final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); - final Map map = new ConcurrentHashMap<>(); - map.put("counter", 0); - - final int threadCount = 20; - final int operationsPerThread = 100; - final ExecutorService executor = Executors.newFixedThreadPool(threadCount); - final CountDownLatch startLatch = new CountDownLatch(1); - final CountDownLatch doneLatch = new CountDownLatch(threadCount); - final AtomicInteger readCount = new AtomicInteger(0); - final AtomicInteger writeCount = new AtomicInteger(0); - - for (int i = 0; i < threadCount; i++) { - final boolean isWriter = i % 2 == 0; - executor.submit(() -> { - try { - startLatch.await(); - for (int j = 0; j < operationsPerThread; j++) { - if (isWriter) { - Concurrency.putRW(rwlock, map, "key" + j, j); - writeCount.incrementAndGet(); - } else { - Concurrency.getRW(rwlock, map, "key" + (j % 50)); - readCount.incrementAndGet(); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); - } - }); - } - - startLatch.countDown(); - doneLatch.await(); - executor.shutdown(); - - assertEquals((threadCount / 2) * operationsPerThread, readCount.get()); - assertEquals((threadCount / 2) * operationsPerThread, writeCount.get()); - } - - @Test - @Timeout(value = 10, unit = TimeUnit.SECONDS) - void testThreadSafeListOperations() throws InterruptedException { - final ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(); - final List list = Collections.synchronizedList(new ArrayList<>()); - - final int threadCount = 10; - final int operationsPerThread = 100; - final ExecutorService executor = Executors.newFixedThreadPool(threadCount); - final CountDownLatch startLatch = new CountDownLatch(1); - final CountDownLatch doneLatch = new CountDownLatch(threadCount); - - for (int i = 0; i < threadCount; i++) { - final int threadId = i; - executor.submit(() -> { - try { - startLatch.await(); - for (int j = 0; j < operationsPerThread; j++) { - Concurrency.addRW(rwlock, list, threadId * 1000 + j); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); - } - }); - } - - startLatch.countDown(); - doneLatch.await(); - executor.shutdown(); - - assertEquals(threadCount * operationsPerThread, list.size()); - } } diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java index 48f265c..238f044 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java @@ -35,17 +35,14 @@ void testHashUnitLarge() { @Test void testHashUnitAstralAndMultibyte() { - // Characters outside the Basic Multilingual Plane are stored as UTF-16 - // surrogate pairs and must encode to 4-byte UTF-8. These canonical hashes - // are shared across all SDKs (computed from correct UTF-8 bytes); a buggy - // per-code-unit encoder produces a different hash and fails here. + // These vectors pin canonical UTF-8 encoding: surrogate pairs must produce 4-byte sequences. assertEquals("KgLqw51xanDs83V5GFkntg", - new String(Hashing.hashUnit("😀"), StandardCharsets.US_ASCII)); // 😀 + new String(Hashing.hashUnit("😀"), StandardCharsets.US_ASCII)); assertEquals("ZJuDalvUWRJnVtkspj-2bQ", - new String(Hashing.hashUnit("😀😁"), StandardCharsets.US_ASCII)); // 😀😁 + new String(Hashing.hashUnit("😀😁"), StandardCharsets.US_ASCII)); assertEquals("v2CJG7YcjjWncKOSCzF2GA", - new String(Hashing.hashUnit("世界你好"), StandardCharsets.US_ASCII)); // 世界你好 + new String(Hashing.hashUnit("世界你好"), StandardCharsets.US_ASCII)); assertEquals("SCgk4OzXlFMvo1UMsP88fA", - new String(Hashing.hashUnit("user_世界_123"), StandardCharsets.US_ASCII)); // user_世界_123 + new String(Hashing.hashUnit("user_世界_123"), StandardCharsets.US_ASCII)); } } From 6552512058c11e93cda5717268df6129ee8c05bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 21:57:57 +0000 Subject: [PATCH 33/83] fix: size REGEX_POOL from available processors with bounded queue and core thread timeout Replace the fixed 4-worker SynchronousQueue pool with a pool sized to 2x available CPUs (floor 4, cap 32) and an ArrayBlockingQueue of 2x max-workers capacity. The SynchronousQueue caused the 5th merely overlapping audience evaluation to be rejected under ordinary server load because there was no capacity to absorb bursts. Setting corePoolSize == maximumPoolSize ensures new tasks start immediately on an idle thread rather than queuing behind a zero-core pool; combined with allowCoreThreadTimeOut(true), idle threads still die after 60 s, so no permanently parked core threads accumulate on Android or lightly loaded servers. AbortPolicy is retained so rejected work fails fast rather than running on the caller thread where the REGEX_TIMEOUT_MS guard does not apply. (cherry picked from commit ef5301eb0f5fff8625ed20091481a6872531f545) --- .../sdk/jsonexpr/operators/MatchOperator.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index 78af9bc..b5eb92b 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -15,11 +15,22 @@ public class MatchOperator extends BinaryOperator { private static final int MAX_PATTERN_LENGTH = 1000; private static final int MAX_TEXT_LENGTH = 10000; private static final int REGEX_TIMEOUT_MS = 100; - private static final ExecutorService REGEX_POOL = new ThreadPoolExecutor( - 0, 4, 60L, TimeUnit.SECONDS, - new SynchronousQueue(), - new DaemonThreadFactory(), - new ThreadPoolExecutor.AbortPolicy()); + // Pool sized to 2× available CPUs, floor 4, cap 32; a small bounded queue (2× max workers) + // absorbs momentary bursts while keeping hostile queued work bounded. + private static final int REGEX_POOL_SIZE = + Math.max(4, Math.min(Runtime.getRuntime().availableProcessors() * 2, 32)); + private static final ExecutorService REGEX_POOL = buildPool(REGEX_POOL_SIZE); + + private static ThreadPoolExecutor buildPool(int size) { + ThreadPoolExecutor pool = new ThreadPoolExecutor( + size, size, 60L, TimeUnit.SECONDS, + new ArrayBlockingQueue(size * 2), + new DaemonThreadFactory(), + new ThreadPoolExecutor.AbortPolicy()); + // Core threads time out so idle threads die on low-traffic processes (e.g. Android). + pool.allowCoreThreadTimeOut(true); + return pool; + } @Override public Object binary(Evaluator evaluator, Object lhs, Object rhs) { From 59f76999762fd4eab338eacba837e9fc8e31030e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 22:00:53 +0000 Subject: [PATCH 34/83] test: add deterministic rejection test for MatchOperator pool saturation Add a package-private MatchOperator(ExecutorService) constructor so tests can inject a controlled executor without altering the public API or the production default (REGEX_POOL). MatchOperatorRejectionTest occupies the sole worker of a 1-worker SynchronousQueue pool with a latch-blocked task, then calls binary() and asserts: - the return value is null (RejectedExecutionException path, not a match) - the regex Callable never executed on any thread (regexRan stays false) A CountDownLatch/ForwardingExecutorService pattern is used throughout; no Thread.sleep, no source-inspection assertions. (cherry picked from commit 41f7d6be32c0c2a20f2a0cffc63f12a2e12717b9) --- .../sdk/jsonexpr/operators/MatchOperator.java | 13 +- .../operators/MatchOperatorRejectionTest.java | 120 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index b5eb92b..6558f0b 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -32,6 +32,17 @@ private static ThreadPoolExecutor buildPool(int size) { return pool; } + private final ExecutorService pool; + + public MatchOperator() { + this(REGEX_POOL); + } + + // Package-private: allows tests to inject a controlled executor to exercise the rejection path. + MatchOperator(ExecutorService pool) { + this.pool = pool; + } + @Override public Object binary(Evaluator evaluator, Object lhs, Object rhs) { final String text = evaluator.stringConvert(lhs); @@ -55,7 +66,7 @@ public Object binary(Evaluator evaluator, Object lhs, Object rhs) { Future future; try { - future = REGEX_POOL.submit(new Callable() { + future = pool.submit(new Callable() { @Override public Boolean call() { final Matcher matcher = compiled.matcher(interruptible); diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java new file mode 100644 index 0000000..9a6e88a --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java @@ -0,0 +1,120 @@ +package com.absmartly.sdk.jsonexpr.operators; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +class MatchOperatorRejectionTest extends OperatorTest { + + /** + * Verifies that a saturated pool causes binary() to return null and that the regex + * callable never executes on the calling thread (AbortPolicy, not CallerRunsPolicy). + */ + @Test + void poolSaturationReturnsNullWithoutRunningRegexOnCaller() throws InterruptedException { + // 1-worker, zero-capacity queue: the second submit is rejected synchronously. + ThreadPoolExecutor tinyPool = new ThreadPoolExecutor( + 1, 1, 60L, TimeUnit.SECONDS, + new SynchronousQueue(), + new ThreadPoolExecutor.AbortPolicy()); + + CountDownLatch workerRunning = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + AtomicBoolean regexRan = new AtomicBoolean(false); + Thread callerThread = Thread.currentThread(); + + try { + // Occupy the sole pool worker so the next submit is rejected. + tinyPool.submit(new Callable() { + @Override + public Void call() throws InterruptedException { + workerRunning.countDown(); + releaseWorker.await(10, TimeUnit.SECONDS); + return null; + } + }); + + // Wait until the worker is actively executing (not merely accepted). + assertTrue(workerRunning.await(5, TimeUnit.SECONDS), + "pool worker must start within 5 s"); + + // Wrap the saturated pool so the regex Callable sets regexRan if it ever runs. + ExecutorService saturated = new ForwardingExecutorService(tinyPool) { + @Override + public Future submit(final Callable task) { + return super.submit(new Callable() { + @Override + public T call() throws Exception { + regexRan.set(true); + return task.call(); + } + }); + } + }; + + MatchOperator op = new MatchOperator(saturated); + Object result = op.binary(evaluator, "text", "abc"); + + // Pool was saturated; submit threw RejectedExecutionException; binary() must return null. + assertNull(result, "saturated pool must return null; regex must not execute"); + + // The wrapped Callable was never enqueued (rejected before scheduling), + // so regexRan stays false — proving it did not run on the caller or any thread. + assertFalse(regexRan.get(), "rejected regex callable must not run on any thread"); + } finally { + releaseWorker.countDown(); + tinyPool.shutdown(); + tinyPool.awaitTermination(5, TimeUnit.SECONDS); + } + } + + /** Minimal delegation base so the override above only needs to define submit(Callable). */ + private static class ForwardingExecutorService implements ExecutorService { + private final ExecutorService delegate; + + ForwardingExecutorService(ExecutorService delegate) { + this.delegate = delegate; + } + + @Override + public Future submit(Callable task) { + return delegate.submit(task); + } + + @Override public void execute(Runnable cmd) { delegate.execute(cmd); } + @Override public void shutdown() { delegate.shutdown(); } + @Override public java.util.List shutdownNow() { return delegate.shutdownNow(); } + @Override public boolean isShutdown() { return delegate.isShutdown(); } + @Override public boolean isTerminated() { return delegate.isTerminated(); } + @Override public boolean awaitTermination(long t, TimeUnit u) throws InterruptedException { + return delegate.awaitTermination(t, u); + } + @Override public Future submit(Runnable task, T result) { + return delegate.submit(task, result); + } + @Override public Future submit(Runnable task) { return delegate.submit(task); } + @Override public java.util.List> invokeAll( + java.util.Collection> tasks) throws InterruptedException { + return delegate.invokeAll(tasks); + } + @Override public java.util.List> invokeAll( + java.util.Collection> tasks, long timeout, TimeUnit unit) + throws InterruptedException { + return delegate.invokeAll(tasks, timeout, unit); + } + @Override public T invokeAny(java.util.Collection> tasks) + throws InterruptedException, ExecutionException { + return delegate.invokeAny(tasks); + } + @Override public T invokeAny(java.util.Collection> tasks, + long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return delegate.invokeAny(tasks, timeout, unit); + } + } +} From fc178868543c368ab2ff0b61432dfb91be8ea70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 22:03:45 +0000 Subject: [PATCH 35/83] test: add deterministic publish-accounting regression tests concurrentProducerDuringFailedPublishRestoresBothEvents: queues event A, calls publishAsync() with a pending future, queues event B while in flight, fails the future, then asserts getPendingCount() == 2 (proving addAndGet rather than set preserved the concurrent producer's event). Retries with a succeeding future and verifies the publisher is invoked twice total. closeAsyncRetryAfterFailedPublishSucceeds: queues an event, fails the first closeAsync() publish, asserts the future completes exceptionally and isClosed() == false, then calls closeAsync() again with a succeeding future and asserts a new future is returned, the publisher is called a second time, and isClosed() becomes true. Both tests control CompletableFuture completion manually; no Thread.sleep, no source-inspection assertions. (cherry picked from commit c05f9a657f4aa38f526f404ec340aa235b182f4f) --- .../java/com/absmartly/sdk/ContextTest.java | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) 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 a4a0c04..9ebae57 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -2759,4 +2759,92 @@ void peekVariableValueConflictingKeyOverlappingAudiences() { assertEquals("arrow", context.peekVariableValue("icon", "square")); assertEquals(0, context.getPendingCount()); } + + @Test + void concurrentProducerDuringFailedPublishRestoresBothEvents() { + final Context context = createReadyContext(); + assertTrue(context.isReady()); + + // Queue event A before the first publish attempt. + context.track("goal_a", mapOf("amount", 1)); + assertEquals(1, context.getPendingCount()); + + final CompletableFuture publishFuture1 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture1); + + // flush() drains goal_a and sets pendingCount to 0; the publish is in flight. + final CompletableFuture asyncResult1 = context.publishAsync(); + assertEquals(0, context.getPendingCount()); + + // Event B arrives while A's publish is still in flight. + context.track("goal_b", mapOf("amount", 2)); + assertEquals(1, context.getPendingCount()); + + // Fail the in-flight publish; the exceptionally handler must restore goal_a + // and addAndGet(1), leaving pendingCount == 2 (restored A + concurrent B). + final Exception failure = new Exception("publish failed"); + publishFuture1.completeExceptionally(failure); + + // Joining on asyncResult1 is the deterministic sync point: the exceptionally + // handler that calls result.completeExceptionally() must have run. + assertThrows(CompletionException.class, asyncResult1::join); + assertEquals(2, context.getPendingCount()); + + // Retry: both events must be delivered in the next successful publish. + final CompletableFuture publishFuture2 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture2); + + final CompletableFuture asyncResult2 = context.publishAsync(); + assertEquals(0, context.getPendingCount()); + + publishFuture2.complete(null); + asyncResult2.join(); + + // Both goal_a and goal_b must appear in the event delivered to the publisher. + verify(eventHandler, Mockito.times(2)).publish(any(), any()); + } + + @Test + void closeAsyncRetryAfterFailedPublishSucceeds() { + final Context context = createReadyContext(); + assertTrue(context.isReady()); + + context.track("goal_a", mapOf("amount", 1)); + assertEquals(1, context.getPendingCount()); + + final CompletableFuture publishFuture1 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture1); + + // First closeAsync: drains the event and starts the publish. + final CompletableFuture closeFuture1 = context.closeAsync(); + assertFalse(context.isClosed()); + + final Exception failure = new Exception("publish failed"); + publishFuture1.completeExceptionally(failure); + + // Joining closeFuture1 is the deterministic sync point: the close exceptionally + // handler runs, restores events, clears closing_, and completes closeFuture1 exceptionally. + final CompletionException actual = assertThrows(CompletionException.class, closeFuture1::join); + assertSame(failure, actual.getCause()); + + // Events were restored; the context must remain open for a retry. + assertFalse(context.isClosed()); + assertTrue(context.getPendingCount() > 0); + + // Retry closeAsync with a succeeding publisher. + final CompletableFuture publishFuture2 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture2); + + final CompletableFuture closeFuture2 = context.closeAsync(); + assertFalse(closeFuture2 == closeFuture1, "second closeAsync must return a new future"); + + publishFuture2.complete(null); + closeFuture2.join(); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + + // Publisher must have been invoked twice: once for the failed attempt, once for the retry. + verify(eventHandler, Mockito.times(2)).publish(any(), any()); + } } From 1fff931043b97698637bb639d19835f562c35c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 22:06:37 +0000 Subject: [PATCH 36/83] test: cover facade classes; remove ABSmartly/ABSmartlyConfig jacoco exclusions ABSmartlyCompatTest exercises the restored old-API path the way a pre-rename consumer would: ABSmartlyConfig.create() followed by all seven fluent setters, each step assigned to an ABSmartlyConfig-typed local (compile-time proof that covariant bridge descriptors are present), then ABSmartly.create(config), createContext, and close(). Also asserts that getContextEventHandler() round-trips the handler set via setContextEventHandler. Remove the two ABSmartly.class and ABSmartlyConfig.class entries from jacocoExcludes. With ABSmartlyCompatTest covering both classes the 100% class, >=90% method, and >=88% instruction thresholds still pass. Apply spotless formatting to MatchOperator.java and MatchOperatorRejectionTest.java (line-wrap and brace-style corrections). (cherry picked from commit e034fb3a66533e40cd7bdeb23303dd17afbe780f) --- core-api/build.gradle | 2 - .../sdk/jsonexpr/operators/MatchOperator.java | 4 +- .../absmartly/sdk/ABSmartlyCompatTest.java | 70 +++++++++++++++++++ .../operators/MatchOperatorRejectionTest.java | 59 ++++++++++++---- 4 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java diff --git a/core-api/build.gradle b/core-api/build.gradle index 313127f..c2ca4cd 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -35,8 +35,6 @@ def jacocoExcludes = [ "com/absmartly/sdk/json/**/*", "com/absmartly/sdk/deprecated/**/*", "com/absmartly/sdk/java/**/*", - "com/absmartly/sdk/ABSmartly.class", - "com/absmartly/sdk/ABSmartlyConfig.class", ] jacocoTestReport { diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index 6558f0b..d97964c 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -17,8 +17,8 @@ public class MatchOperator extends BinaryOperator { private static final int REGEX_TIMEOUT_MS = 100; // Pool sized to 2× available CPUs, floor 4, cap 32; a small bounded queue (2× max workers) // absorbs momentary bursts while keeping hostile queued work bounded. - private static final int REGEX_POOL_SIZE = - Math.max(4, Math.min(Runtime.getRuntime().availableProcessors() * 2, 32)); + private static final int REGEX_POOL_SIZE = Math.max(4, + Math.min(Runtime.getRuntime().availableProcessors() * 2, 32)); private static final ExecutorService REGEX_POOL = buildPool(REGEX_POOL_SIZE); private static ThreadPoolExecutor buildPool(int size) { diff --git a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java new file mode 100644 index 0000000..d10a63a --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java @@ -0,0 +1,70 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.absmartly.sdk.json.ContextData; + +/** + * Exercises the restored old-API facade classes (ABSmartly, ABSmartlyConfig) the way a + * pre-rename consumer would: static factory, fluent chain returning ABSmartlyConfig at every + * step, and a round-trip through the ABSmartly lifecycle. Assigning each setter result to an + * ABSmartlyConfig-typed local is a compile-time proof that the covariant bridge descriptors + * are present and correct. + */ +class ABSmartlyCompatTest extends TestUtils { + + @Test + @SuppressWarnings("deprecation") + void oldApiFluentChainAndLifecycleWork() throws IOException { + final ContextDataProvider provider = mock(ContextDataProvider.class); + final ContextEventHandler handler = mock(ContextEventHandler.class); + final VariableParser parser = mock(VariableParser.class); + final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + final ContextEventLogger logger = mock(ContextEventLogger.class); + final AudienceDeserializer deserializer = mock(AudienceDeserializer.class); + final Client client = mock(Client.class); + + // ABSmartlyConfig.create() starts the chain; each step is assigned to an + // ABSmartlyConfig-typed local, which is a compile-time descriptor check. + ABSmartlyConfig step0 = ABSmartlyConfig.create(); + ABSmartlyConfig step1 = step0.setClient(client); + ABSmartlyConfig step2 = step1.setContextDataProvider(provider); + ABSmartlyConfig step3 = step2.setContextEventHandler(handler); + ABSmartlyConfig step4 = step3.setVariableParser(parser); + ABSmartlyConfig step5 = step4.setScheduler(scheduler); + ABSmartlyConfig step6 = step5.setContextEventLogger(logger); + ABSmartlyConfig config = step6.setAudienceDeserializer(deserializer); + + // Each step must return the same config object (fluent identity). + assertSame(step0, step1); + assertSame(step0, config); + + // getContextEventHandler must round-trip the handler set via setContextEventHandler. + assertSame(handler, config.getContextEventHandler()); + + // Provide a completed data future so createContext does not block. + final ContextData data = new ContextData(); + when(provider.getContextData()).thenReturn(CompletableFuture.completedFuture(data)); + + // ABSmartly.create(ABSmartlyConfig) must accept the old config type. + // provider and handler are both set, so no Client network access occurs. + ABSmartly absmartly = ABSmartly.create(config); + assertNotNull(absmartly); + + // createContext is inherited from ABsmartly; verify it delegates to the injected provider. + final ContextConfig contextConfig = ContextConfig.create(); + final Context context = absmartly.createContext(contextConfig); + assertNotNull(context); + verify(provider, times(1)).getContextData(); + + // close() is inherited; it must not throw. + absmartly.close(); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java index 9a6e88a..03441d0 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java @@ -86,32 +86,67 @@ public Future submit(Callable task) { return delegate.submit(task); } - @Override public void execute(Runnable cmd) { delegate.execute(cmd); } - @Override public void shutdown() { delegate.shutdown(); } - @Override public java.util.List shutdownNow() { return delegate.shutdownNow(); } - @Override public boolean isShutdown() { return delegate.isShutdown(); } - @Override public boolean isTerminated() { return delegate.isTerminated(); } - @Override public boolean awaitTermination(long t, TimeUnit u) throws InterruptedException { + @Override + public void execute(Runnable cmd) { + delegate.execute(cmd); + } + + @Override + public void shutdown() { + delegate.shutdown(); + } + + @Override + public java.util.List shutdownNow() { + return delegate.shutdownNow(); + } + + @Override + public boolean isShutdown() { + return delegate.isShutdown(); + } + + @Override + public boolean isTerminated() { + return delegate.isTerminated(); + } + + @Override + public boolean awaitTermination(long t, TimeUnit u) throws InterruptedException { return delegate.awaitTermination(t, u); } - @Override public Future submit(Runnable task, T result) { + + @Override + public Future submit(Runnable task, T result) { return delegate.submit(task, result); } - @Override public Future submit(Runnable task) { return delegate.submit(task); } - @Override public java.util.List> invokeAll( + + @Override + public Future submit(Runnable task) { + return delegate.submit(task); + } + + @Override + public java.util.List> invokeAll( java.util.Collection> tasks) throws InterruptedException { return delegate.invokeAll(tasks); } - @Override public java.util.List> invokeAll( + + @Override + public java.util.List> invokeAll( java.util.Collection> tasks, long timeout, TimeUnit unit) throws InterruptedException { return delegate.invokeAll(tasks, timeout, unit); } - @Override public T invokeAny(java.util.Collection> tasks) + + @Override + public T invokeAny(java.util.Collection> tasks) throws InterruptedException, ExecutionException { return delegate.invokeAny(tasks); } - @Override public T invokeAny(java.util.Collection> tasks, + + @Override + public T invokeAny(java.util.Collection> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { return delegate.invokeAny(tasks, timeout, unit); From 77387dcb865a065eb073ce238bbab06384a150be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 22:08:09 +0000 Subject: [PATCH 37/83] fix: comment and naming cleanups Buffers.encodeUTF8: replace 'for cross-SDK hash parity' with the local encoding invariant: 'a surrogate pair must produce one four-byte UTF-8 sequence'. The cross-SDK claim was disputed and unproven. compatibility.gradle: cut 5 comment lines that restated the plugin name, sourceset choice, and signature name directly above the code. Retain only the non-obvious rationale: tests are excluded because their dependencies require Java 11+ APIs, and java16:1.1 is the Java 1.6 signature despite the artifact name. ContextTest: rename finalizeIsAliasForClose to isFinalizedAliasesIsClosedAfterClose to match what the test actually verifies (isFinalized()/isClosed() state after close(), not a removed finalize() method). ABSmartly, ABSmartlyConfig: make the restored constructors private. The static factories construct from within each class, so protected visibility was unnecessary and exposed new subclassing surface not present in the original classes. (cherry picked from commit db05c2d55d8ee217af00c9d084830aafbc60ebff) --- core-api/src/main/java/com/absmartly/sdk/ABSmartly.java | 2 +- .../src/main/java/com/absmartly/sdk/ABSmartlyConfig.java | 2 +- .../src/main/java/com/absmartly/sdk/internal/Buffers.java | 2 +- core-api/src/test/java/com/absmartly/sdk/ContextTest.java | 2 +- gradle/compatibility.gradle | 7 ++----- 5 files changed, 6 insertions(+), 9 deletions(-) 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 58ec9ac..36f814a 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java @@ -11,7 +11,7 @@ public static ABSmartly create(@Nonnull ABSmartlyConfig config) { return new ABSmartly(config); } - protected ABSmartly(@Nonnull ABSmartlyConfig config) { + private ABSmartly(@Nonnull ABSmartlyConfig config) { super(config); } } 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 f3670bb..17027dc 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java @@ -13,7 +13,7 @@ public static ABSmartlyConfig create() { return new ABSmartlyConfig(); } - protected ABSmartlyConfig() { + private ABSmartlyConfig() { super(); } diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java index f6c5f73..f552d20 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java @@ -30,7 +30,7 @@ static public int getUInt8(byte[] buf, int offset) { } static public int encodeUTF8(byte[] buf, int offset, CharSequence value) { - // Platform encoder: surrogate pairs must produce 4-byte UTF-8 for cross-SDK hash parity. + // Platform encoder: a surrogate pair must produce one four-byte UTF-8 sequence. final byte[] bytes = value.toString().getBytes(StandardCharsets.UTF_8); System.arraycopy(bytes, 0, buf, offset, bytes.length); return bytes.length; 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 9ebae57..38dc514 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -473,7 +473,7 @@ void throwsWhenClosed() { } @Test - void finalizeIsAliasForClose() { + void isFinalizedAliasesIsClosedAfterClose() { final Context context = createReadyContext(); assertFalse(context.isFinalized()); assertFalse(context.isFinalizing()); diff --git a/gradle/compatibility.gradle b/gradle/compatibility.gradle index 3247f03..2474049 100644 --- a/gradle/compatibility.gradle +++ b/gradle/compatibility.gradle @@ -1,9 +1,6 @@ // Enforces Java 1.6 API floor on production source of core-api using Animal Sniffer. -// Animal Sniffer checks constant-pool owner classes against a known signature list, -// catching references to APIs absent in Java 1.6 that sourceCompatibility/targetCompatibility miss. -// -// Only production (main) classes are checked; test code uses JUnit 5 / Mockito 5 which -// require Java 11+ APIs and would produce false violations on test sourcesets. +// Test sources are excluded: their dependencies (JUnit 5, Mockito 5) require Java 11+ APIs. +// java16:1.1 is the Java 1.6 signature despite the artifact name. if (project.name == "core-api") { apply plugin: "ru.vyarus.animalsniffer" From 84150e0809b752b16138ca4faf702155b6000f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 22:18:15 +0000 Subject: [PATCH 38/83] test: simplify review regression coverage (cherry picked from commit ebb6d02c2f6fb932ddf03ede71b2ab3de91a4077) --- .../absmartly/sdk/ABSmartlyCompatTest.java | 22 +-- .../java/com/absmartly/sdk/ContextTest.java | 17 --- .../operators/MatchOperatorRejectionTest.java | 135 +++--------------- 3 files changed, 24 insertions(+), 150 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java index d10a63a..7e244c0 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java @@ -11,14 +11,7 @@ import com.absmartly.sdk.json.ContextData; -/** - * Exercises the restored old-API facade classes (ABSmartly, ABSmartlyConfig) the way a - * pre-rename consumer would: static factory, fluent chain returning ABSmartlyConfig at every - * step, and a round-trip through the ABSmartly lifecycle. Assigning each setter result to an - * ABSmartlyConfig-typed local is a compile-time proof that the covariant bridge descriptors - * are present and correct. - */ -class ABSmartlyCompatTest extends TestUtils { +class ABSmartlyCompatTest { @Test @SuppressWarnings("deprecation") @@ -31,8 +24,7 @@ void oldApiFluentChainAndLifecycleWork() throws IOException { final AudienceDeserializer deserializer = mock(AudienceDeserializer.class); final Client client = mock(Client.class); - // ABSmartlyConfig.create() starts the chain; each step is assigned to an - // ABSmartlyConfig-typed local, which is a compile-time descriptor check. + // Separate assignments verify each setter's covariant return type at compile time. ABSmartlyConfig step0 = ABSmartlyConfig.create(); ABSmartlyConfig step1 = step0.setClient(client); ABSmartlyConfig step2 = step1.setContextDataProvider(provider); @@ -42,29 +34,21 @@ void oldApiFluentChainAndLifecycleWork() throws IOException { ABSmartlyConfig step6 = step5.setContextEventLogger(logger); ABSmartlyConfig config = step6.setAudienceDeserializer(deserializer); - // Each step must return the same config object (fluent identity). assertSame(step0, step1); assertSame(step0, config); - - // getContextEventHandler must round-trip the handler set via setContextEventHandler. assertSame(handler, config.getContextEventHandler()); - // Provide a completed data future so createContext does not block. final ContextData data = new ContextData(); when(provider.getContextData()).thenReturn(CompletableFuture.completedFuture(data)); - // ABSmartly.create(ABSmartlyConfig) must accept the old config type. - // provider and handler are both set, so no Client network access occurs. - ABSmartly absmartly = ABSmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); assertNotNull(absmartly); - // createContext is inherited from ABsmartly; verify it delegates to the injected provider. final ContextConfig contextConfig = ContextConfig.create(); final Context context = absmartly.createContext(contextConfig); assertNotNull(context); verify(provider, times(1)).getContextData(); - // close() is inherited; it must not throw. absmartly.close(); } } 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 38dc514..9c3e7eb 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -2763,34 +2763,25 @@ void peekVariableValueConflictingKeyOverlappingAudiences() { @Test void concurrentProducerDuringFailedPublishRestoresBothEvents() { final Context context = createReadyContext(); - assertTrue(context.isReady()); - // Queue event A before the first publish attempt. context.track("goal_a", mapOf("amount", 1)); assertEquals(1, context.getPendingCount()); final CompletableFuture publishFuture1 = new CompletableFuture<>(); when(eventHandler.publish(any(), any())).thenReturn(publishFuture1); - // flush() drains goal_a and sets pendingCount to 0; the publish is in flight. final CompletableFuture asyncResult1 = context.publishAsync(); assertEquals(0, context.getPendingCount()); - // Event B arrives while A's publish is still in flight. context.track("goal_b", mapOf("amount", 2)); assertEquals(1, context.getPendingCount()); - // Fail the in-flight publish; the exceptionally handler must restore goal_a - // and addAndGet(1), leaving pendingCount == 2 (restored A + concurrent B). final Exception failure = new Exception("publish failed"); publishFuture1.completeExceptionally(failure); - // Joining on asyncResult1 is the deterministic sync point: the exceptionally - // handler that calls result.completeExceptionally() must have run. assertThrows(CompletionException.class, asyncResult1::join); assertEquals(2, context.getPendingCount()); - // Retry: both events must be delivered in the next successful publish. final CompletableFuture publishFuture2 = new CompletableFuture<>(); when(eventHandler.publish(any(), any())).thenReturn(publishFuture2); @@ -2800,14 +2791,12 @@ void concurrentProducerDuringFailedPublishRestoresBothEvents() { publishFuture2.complete(null); asyncResult2.join(); - // Both goal_a and goal_b must appear in the event delivered to the publisher. verify(eventHandler, Mockito.times(2)).publish(any(), any()); } @Test void closeAsyncRetryAfterFailedPublishSucceeds() { final Context context = createReadyContext(); - assertTrue(context.isReady()); context.track("goal_a", mapOf("amount", 1)); assertEquals(1, context.getPendingCount()); @@ -2815,23 +2804,18 @@ void closeAsyncRetryAfterFailedPublishSucceeds() { final CompletableFuture publishFuture1 = new CompletableFuture<>(); when(eventHandler.publish(any(), any())).thenReturn(publishFuture1); - // First closeAsync: drains the event and starts the publish. final CompletableFuture closeFuture1 = context.closeAsync(); assertFalse(context.isClosed()); final Exception failure = new Exception("publish failed"); publishFuture1.completeExceptionally(failure); - // Joining closeFuture1 is the deterministic sync point: the close exceptionally - // handler runs, restores events, clears closing_, and completes closeFuture1 exceptionally. final CompletionException actual = assertThrows(CompletionException.class, closeFuture1::join); assertSame(failure, actual.getCause()); - // Events were restored; the context must remain open for a retry. assertFalse(context.isClosed()); assertTrue(context.getPendingCount() > 0); - // Retry closeAsync with a succeeding publisher. final CompletableFuture publishFuture2 = new CompletableFuture<>(); when(eventHandler.publish(any(), any())).thenReturn(publishFuture2); @@ -2844,7 +2828,6 @@ void closeAsyncRetryAfterFailedPublishSucceeds() { assertTrue(context.isClosed()); assertEquals(0, context.getPendingCount()); - // Publisher must have been invoked twice: once for the failed attempt, once for the retry. verify(eventHandler, Mockito.times(2)).publish(any(), any()); } } diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java index 03441d0..ad06f9e 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java @@ -11,25 +11,31 @@ class MatchOperatorRejectionTest extends OperatorTest { - /** - * Verifies that a saturated pool causes binary() to return null and that the regex - * callable never executes on the calling thread (AbortPolicy, not CallerRunsPolicy). - */ @Test void poolSaturationReturnsNullWithoutRunningRegexOnCaller() throws InterruptedException { - // 1-worker, zero-capacity queue: the second submit is rejected synchronously. - ThreadPoolExecutor tinyPool = new ThreadPoolExecutor( + final CountDownLatch workerRunning = new CountDownLatch(1); + final CountDownLatch releaseWorker = new CountDownLatch(1); + final AtomicBoolean regexRan = new AtomicBoolean(false); + final ThreadPoolExecutor tinyPool = new ThreadPoolExecutor( 1, 1, 60L, TimeUnit.SECONDS, new SynchronousQueue(), - new ThreadPoolExecutor.AbortPolicy()); - - CountDownLatch workerRunning = new CountDownLatch(1); - CountDownLatch releaseWorker = new CountDownLatch(1); - AtomicBoolean regexRan = new AtomicBoolean(false); - Thread callerThread = Thread.currentThread(); + new ThreadPoolExecutor.AbortPolicy()) { + @Override + protected RunnableFuture newTaskFor(final Callable task) { + if (workerRunning.getCount() == 0) { + return super.newTaskFor(new Callable() { + @Override + public T call() throws Exception { + regexRan.set(true); + return task.call(); + } + }); + } + return super.newTaskFor(task); + } + }; try { - // Occupy the sole pool worker so the next submit is rejected. tinyPool.submit(new Callable() { @Override public Void call() throws InterruptedException { @@ -39,32 +45,13 @@ public Void call() throws InterruptedException { } }); - // Wait until the worker is actively executing (not merely accepted). assertTrue(workerRunning.await(5, TimeUnit.SECONDS), "pool worker must start within 5 s"); - // Wrap the saturated pool so the regex Callable sets regexRan if it ever runs. - ExecutorService saturated = new ForwardingExecutorService(tinyPool) { - @Override - public Future submit(final Callable task) { - return super.submit(new Callable() { - @Override - public T call() throws Exception { - regexRan.set(true); - return task.call(); - } - }); - } - }; - - MatchOperator op = new MatchOperator(saturated); - Object result = op.binary(evaluator, "text", "abc"); + final MatchOperator op = new MatchOperator(tinyPool); + final Object result = op.binary(evaluator, "text", "abc"); - // Pool was saturated; submit threw RejectedExecutionException; binary() must return null. assertNull(result, "saturated pool must return null; regex must not execute"); - - // The wrapped Callable was never enqueued (rejected before scheduling), - // so regexRan stays false — proving it did not run on the caller or any thread. assertFalse(regexRan.get(), "rejected regex callable must not run on any thread"); } finally { releaseWorker.countDown(); @@ -72,84 +59,4 @@ public T call() throws Exception { tinyPool.awaitTermination(5, TimeUnit.SECONDS); } } - - /** Minimal delegation base so the override above only needs to define submit(Callable). */ - private static class ForwardingExecutorService implements ExecutorService { - private final ExecutorService delegate; - - ForwardingExecutorService(ExecutorService delegate) { - this.delegate = delegate; - } - - @Override - public Future submit(Callable task) { - return delegate.submit(task); - } - - @Override - public void execute(Runnable cmd) { - delegate.execute(cmd); - } - - @Override - public void shutdown() { - delegate.shutdown(); - } - - @Override - public java.util.List shutdownNow() { - return delegate.shutdownNow(); - } - - @Override - public boolean isShutdown() { - return delegate.isShutdown(); - } - - @Override - public boolean isTerminated() { - return delegate.isTerminated(); - } - - @Override - public boolean awaitTermination(long t, TimeUnit u) throws InterruptedException { - return delegate.awaitTermination(t, u); - } - - @Override - public Future submit(Runnable task, T result) { - return delegate.submit(task, result); - } - - @Override - public Future submit(Runnable task) { - return delegate.submit(task); - } - - @Override - public java.util.List> invokeAll( - java.util.Collection> tasks) throws InterruptedException { - return delegate.invokeAll(tasks); - } - - @Override - public java.util.List> invokeAll( - java.util.Collection> tasks, long timeout, TimeUnit unit) - throws InterruptedException { - return delegate.invokeAll(tasks, timeout, unit); - } - - @Override - public T invokeAny(java.util.Collection> tasks) - throws InterruptedException, ExecutionException { - return delegate.invokeAny(tasks); - } - - @Override - public T invokeAny(java.util.Collection> tasks, - long timeout, TimeUnit unit) - throws InterruptedException, ExecutionException, TimeoutException { - return delegate.invokeAny(tasks, timeout, unit); - } - } } From 5c38dc9090b4bd44e4271e4722d4ba356755351f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 28 Aug 2026 23:40:50 +0000 Subject: [PATCH 39/83] test: bound publish-accounting test waits with @Timeout concurrentProducerDuringFailedPublishRestoresBothEvents and closeAsyncRetryAfterFailedPublishSucceeds join CompletableFutures with no deadline. A regression that never completes one of those futures blocks the test process instead of failing. Both now carry @Timeout(5, SECONDS), matching DefaultHTTPClientTest. (cherry picked from commit 11db99bad6f33f30d4b07d60e0987e9c08aed645) --- core-api/src/test/java/com/absmartly/sdk/ContextTest.java | 3 +++ 1 file changed, 3 insertions(+) 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 9c3e7eb..1106c21 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.ArgumentMatchers; import org.mockito.Mockito; @@ -2761,6 +2762,7 @@ void peekVariableValueConflictingKeyOverlappingAudiences() { } @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) void concurrentProducerDuringFailedPublishRestoresBothEvents() { final Context context = createReadyContext(); @@ -2795,6 +2797,7 @@ void concurrentProducerDuringFailedPublishRestoresBothEvents() { } @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) void closeAsyncRetryAfterFailedPublishSucceeds() { final Context context = createReadyContext(); From 4dbfa29c1056208bdaa7288011ab44b6f519144e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sat, 29 Aug 2026 02:10:32 +0000 Subject: [PATCH 40/83] docs: close context with try-with-resources in server examples The Spring Boot and Jakarta EE examples called context.close() after getTreatment(), leaking the context if getTreatment() threw. Context implements Closeable, so try-with-resources closes it on every path. --- README.md | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1ba01c5..b9c1664 100644 --- a/README.md +++ b/README.md @@ -439,18 +439,17 @@ public class ProductController { final ContextConfig contextConfig = ContextConfig.create() .setUnit("session_id", session.getId()); - final Context context = absmartly.createContext(contextConfig) - .waitUntilReady(); + final ModelAndView mav = new ModelAndView(); - final int treatment = context.getTreatment("exp_product_layout"); + try (Context context = absmartly.createContext(contextConfig) + .waitUntilReady()) { + final int treatment = context.getTreatment("exp_product_layout"); - context.close(); - - ModelAndView mav = new ModelAndView(); - if (treatment == 0) { - mav.setViewName("product_control"); - } else { - mav.setViewName("product_treatment"); + if (treatment == 0) { + mav.setViewName("product_control"); + } else { + mav.setViewName("product_treatment"); + } } return mav; @@ -511,16 +510,14 @@ public class ProductResource { final ContextConfig contextConfig = ContextConfig.create() .setUnit("session_id", sessionId); - final Context context = absmartly.createContext(contextConfig) - .waitUntilReady(); + try (Context context = absmartly.createContext(contextConfig) + .waitUntilReady()) { + final int treatment = context.getTreatment("exp_product_layout"); - final int treatment = context.getTreatment("exp_product_layout"); - - context.close(); - - return Response.ok() - .entity(Map.of("treatment", treatment)) - .build(); + return Response.ok() + .entity(Map.of("treatment", treatment)) + .build(); + } } } ``` From 7e9b0e011551b9f19d6c114c77d1e9adc2976e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sat, 29 Aug 2026 02:10:32 +0000 Subject: [PATCH 41/83] test: name the http-endpoint test for what it asserts httpEndpointDoesNotThrowButLogsWarning only asserted that Client.create does not throw; it never observed the warning. Renamed to httpEndpointIsAccepted. The warning side effect is not asserted because it would require capturing an SLF4J appender for no behavioural gain. --- core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java index 288293e..3b5b475 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java @@ -75,7 +75,7 @@ void endpointWithoutTrailingSlashUnchanged() { } @Test - void httpEndpointDoesNotThrowButLogsWarning() { + void httpEndpointIsAccepted() { final HTTPClient httpClient = mock(HTTPClient.class); assertDoesNotThrow(() -> { Client.create(ClientConfig.create() From c8b29b620b04155dfd1b8f925240532458016061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sat, 29 Aug 2026 02:10:32 +0000 Subject: [PATCH 42/83] test: exercise DefaultHTTPClientRetryStrategy directly testRetryOnTransientError503Response mocks CloseableHttpAsyncClient.execute(), which replaces the Apache client that owns the retry strategy, so no retry logic ran and verify(times(1)) confirmed zero retries. The strategy is a plain class, so test it directly: retryable codes 502/503 up to maxRetries, non-retryable codes, IOException retries, exponential backoff staying within the interval budget, and maxRetries=0 disabling retries. --- .../DefaultHTTPClientRetryStrategyTest.java | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java index b868892..6199797 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java @@ -1,67 +1,68 @@ package com.absmartly.sdk; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; -import java.util.Set; +import java.io.IOException; -import org.apache.hc.client5.http.ConnectTimeoutException; -import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; -import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; -import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpResponse; -import org.apache.hc.core5.http.protocol.BasicHttpContext; -import org.apache.hc.core5.http.protocol.HttpContext; -import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.http.message.BasicHttpResponse; import org.junit.jupiter.api.Test; -class DefaultHTTPClientRetryStrategyTest extends TestUtils { +class DefaultHTTPClientRetryStrategyTest { + private static HttpResponse response(final int code) { + return new BasicHttpResponse(code, ""); + } + @Test - void retryRequest() { - final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(7, 1_000); - final HttpRequest request = SimpleRequestBuilder.get("http://localhost/v1/context").build(); - final HttpContext context = new BasicHttpContext(); + void retriesRetryableCodesUpToMaxRetries() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(3, 100); - int i = 1; - for (; i <= 7; ++i) { - assertTrue(strategy.retryRequest(request, new ConnectTimeoutException("timeout"), i, context)); + for (final int code : new int[]{502, 503}) { + assertTrue(strategy.retryRequest(response(code), 1, null)); + assertTrue(strategy.retryRequest(response(code), 3, null)); + assertFalse(strategy.retryRequest(response(code), 4, null)); } - assertFalse(strategy.retryRequest(request, new ConnectTimeoutException("timeout"), i, context)); } @Test - void testRetryRequest() { - final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(7, 1_000); - final HttpContext context = new BasicHttpContext(); - final Set retryableCodes = setOf(502, 503); - - for (int code : retryableCodes) { - final HttpResponse response = new SimpleHttpResponse(code); + void doesNotRetryNonRetryableCodes() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(3, 100); - int i = 1; - for (; i <= 7; ++i) { - assertTrue(strategy.retryRequest(response, i, context)); - } - assertFalse(strategy.retryRequest(response, i, context)); + for (final int code : new int[]{200, 400, 404, 500, 504}) { + assertFalse(strategy.retryRequest(response(code), 1, null)); } } @Test - void getRetryInterval() { - final long maxIntervalMs = 3_049; - final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(7, maxIntervalMs); - final HttpContext context = new BasicHttpContext(); + void retriesIOExceptionsUpToMaxRetries() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(2, 100); + final IOException exception = new IOException("connection reset"); - long previous = 0; - final HttpResponse response = new SimpleHttpResponse(502); + assertTrue(strategy.retryRequest(null, exception, 1, null)); + assertTrue(strategy.retryRequest(null, exception, 2, null)); + assertFalse(strategy.retryRequest(null, exception, 3, null)); + } - int i = 1; - for (; i <= 7; ++i) { - final TimeValue actual = strategy.getRetryInterval(response, i, context); - assertTrue(previous < actual.toMilliseconds()); - previous = actual.toMilliseconds(); - } + @Test + void retryIntervalGrowsExponentiallyAndStaysWithinBudget() { + final long maxRetryIntervalMs = 1000; + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(3, maxRetryIntervalMs); + + final long first = strategy.getRetryInterval(response(503), 1, null).toMilliseconds(); + final long second = strategy.getRetryInterval(response(503), 2, null).toMilliseconds(); + final long third = strategy.getRetryInterval(response(503), 3, null).toMilliseconds(); + + assertTrue(second > first, "interval must grow: " + first + " -> " + second); + assertTrue(third > second, "interval must grow: " + second + " -> " + third); + assertEquals(second - first, (third - second) / 2, "growth must double each attempt"); + assertTrue(third <= maxRetryIntervalMs, "final interval " + third + " exceeds budget " + maxRetryIntervalMs); + } + + @Test + void zeroMaxRetriesDisablesRetrying() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(0, 100); - assertTrue(Math.abs(maxIntervalMs - previous) <= 1); + assertFalse(strategy.retryRequest(response(503), 1, null)); + assertFalse(strategy.retryRequest(null, new IOException("boom"), 1, null)); } } From a0603e306d942abbfb40ed01c682af7c4b2726f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sat, 29 Aug 2026 02:12:37 +0000 Subject: [PATCH 43/83] test: restore the original retry-strategy tests The previous commit overwrote DefaultHTTPClientRetryStrategyTest instead of extending it, dropping retryRequest, testRetryRequest and getRetryInterval. Restore all three verbatim and keep only the genuinely new cases: non-retryable codes, and maxRetries=0 disabling retries. --- .../DefaultHTTPClientRetryStrategyTest.java | 97 +++++++++++-------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java index 6199797..3162025 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java @@ -1,68 +1,87 @@ package com.absmartly.sdk; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.IOException; +import java.util.Set; +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpResponse; -import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.http.protocol.BasicHttpContext; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; import org.junit.jupiter.api.Test; -class DefaultHTTPClientRetryStrategyTest { - private static HttpResponse response(final int code) { - return new BasicHttpResponse(code, ""); - } - +class DefaultHTTPClientRetryStrategyTest extends TestUtils { @Test - void retriesRetryableCodesUpToMaxRetries() { - final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(3, 100); + void retryRequest() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(7, 1_000); + final HttpRequest request = SimpleRequestBuilder.get("http://localhost/v1/context").build(); + final HttpContext context = new BasicHttpContext(); - for (final int code : new int[]{502, 503}) { - assertTrue(strategy.retryRequest(response(code), 1, null)); - assertTrue(strategy.retryRequest(response(code), 3, null)); - assertFalse(strategy.retryRequest(response(code), 4, null)); + int i = 1; + for (; i <= 7; ++i) { + assertTrue(strategy.retryRequest(request, new ConnectTimeoutException("timeout"), i, context)); } + assertFalse(strategy.retryRequest(request, new ConnectTimeoutException("timeout"), i, context)); } @Test - void doesNotRetryNonRetryableCodes() { - final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(3, 100); + void testRetryRequest() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(7, 1_000); + final HttpContext context = new BasicHttpContext(); + final Set retryableCodes = setOf(502, 503); - for (final int code : new int[]{200, 400, 404, 500, 504}) { - assertFalse(strategy.retryRequest(response(code), 1, null)); + for (int code : retryableCodes) { + final HttpResponse response = new SimpleHttpResponse(code); + + int i = 1; + for (; i <= 7; ++i) { + assertTrue(strategy.retryRequest(response, i, context)); + } + assertFalse(strategy.retryRequest(response, i, context)); } } @Test - void retriesIOExceptionsUpToMaxRetries() { - final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(2, 100); - final IOException exception = new IOException("connection reset"); + void getRetryInterval() { + final long maxIntervalMs = 3_049; + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(7, maxIntervalMs); + final HttpContext context = new BasicHttpContext(); + + long previous = 0; + final HttpResponse response = new SimpleHttpResponse(502); - assertTrue(strategy.retryRequest(null, exception, 1, null)); - assertTrue(strategy.retryRequest(null, exception, 2, null)); - assertFalse(strategy.retryRequest(null, exception, 3, null)); + int i = 1; + for (; i <= 7; ++i) { + final TimeValue actual = strategy.getRetryInterval(response, i, context); + assertTrue(previous < actual.toMilliseconds()); + previous = actual.toMilliseconds(); + } + + assertTrue(Math.abs(maxIntervalMs - previous) <= 1); } @Test - void retryIntervalGrowsExponentiallyAndStaysWithinBudget() { - final long maxRetryIntervalMs = 1000; - final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(3, maxRetryIntervalMs); - - final long first = strategy.getRetryInterval(response(503), 1, null).toMilliseconds(); - final long second = strategy.getRetryInterval(response(503), 2, null).toMilliseconds(); - final long third = strategy.getRetryInterval(response(503), 3, null).toMilliseconds(); - - assertTrue(second > first, "interval must grow: " + first + " -> " + second); - assertTrue(third > second, "interval must grow: " + second + " -> " + third); - assertEquals(second - first, (third - second) / 2, "growth must double each attempt"); - assertTrue(third <= maxRetryIntervalMs, "final interval " + third + " exceeds budget " + maxRetryIntervalMs); + void doesNotRetryNonRetryableCodes() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(7, 1_000); + final HttpContext context = new BasicHttpContext(); + + for (int code : setOf(200, 400, 404, 500, 504)) { + assertFalse(strategy.retryRequest(new SimpleHttpResponse(code), 1, context)); + } } @Test void zeroMaxRetriesDisablesRetrying() { - final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(0, 100); + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(0, 1_000); + final HttpRequest request = SimpleRequestBuilder.get("http://localhost/v1/context").build(); + final HttpContext context = new BasicHttpContext(); - assertFalse(strategy.retryRequest(response(503), 1, null)); - assertFalse(strategy.retryRequest(null, new IOException("boom"), 1, null)); + assertFalse(strategy.retryRequest(new SimpleHttpResponse(503), 1, context)); + assertFalse(strategy.retryRequest(request, new ConnectTimeoutException("timeout"), 1, context)); } } From de9fffd5472d24e906f7ebd7aedd3916956035f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 13:16:01 +0000 Subject: [PATCH 44/83] fix: await in-flight publish before closing a context closeAsync() could observe pendingCount_ == 0 while a publish started by publishAsync() was still in flight, since flush() zeroes the counter under eventLock_ before the publisher future settles. The context would then close immediately; if that publish later failed and restored its events, pendingCount_ became non-zero again inside an already-closed context, making the restored events permanently unreachable. Track the active publish in publishFuture_ and, when closeAsync() finds an empty queue, await that future instead of closing immediately. Apply the same restore-then-reopen rule already used on the flush-driven failure path: leave the context open for a retry if events were restored, otherwise close it. --- .../main/java/com/absmartly/sdk/Context.java | 46 +++++++++++ .../java/com/absmartly/sdk/ContextTest.java | 76 +++++++++++++++++++ 2 files changed, 122 insertions(+) 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 0e0cda6..dfc1f77 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -12,6 +12,8 @@ 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.BiFunction; import java8.util.function.Consumer; import java8.util.function.Function; @@ -642,10 +644,45 @@ public Void apply(Throwable exception) { return newClosingFuture; } else { + // The queue is empty here, but a publish started by a concurrent flush() + // may still be in flight and could restore events on failure. + final CompletableFuture inFlightPublish = publishFuture_.get(); + if (inFlightPublish != null) { + final CompletableFuture newClosingFuture = new CompletableFuture(); + closingFuture_.set(newClosingFuture); + + inFlightPublish.handle(new BiFunction() { + @Override + public Void apply(Void ignoredResult, Throwable exception) { + // Same rule as the flush-driven failure path: only close once + // no events remain to retry. + if ((exception == null) || (pendingCount_.get() == 0)) { + closed_.set(true); + } + closing_.set(false); + + if (exception != null) { + newClosingFuture.completeExceptionally(exception); + } else { + newClosingFuture.complete(null); + Context.this.logEvent(ContextEventLogger.EventType.Close, null); + } + return null; + } + }); + + return newClosingFuture; + } + closed_.set(true); closing_.set(false); Context.this.logEvent(ContextEventLogger.EventType.Close, null); + + // Nothing was pending here, so no closingFuture_ was published for this + // attempt; return directly to avoid picking up a stale future left behind + // by an earlier failed close attempt. + return CompletableFuture.completedFuture(null); } } @@ -722,6 +759,14 @@ public Unit apply(Map.Entry entry) { event.goals = achievements; final CompletableFuture result = new CompletableFuture(); + publishFuture_.set(result); + result.whenComplete(new BiConsumer() { + @Override + public void accept(Void ignoredResult, Throwable ignoredException) { + // A stale reference must not make a later close() wait forever. + publishFuture_.compareAndSet(result, null); + } + }); final Exposure[] finalExposures = exposures; final GoalAchievement[] finalAchievements = achievements; @@ -1263,6 +1308,7 @@ private Map buildAttributesMap() { private final AtomicReference> readyFuture_ = new AtomicReference>(); private final AtomicReference> closingFuture_ = new AtomicReference>(); private final AtomicReference> refreshFuture_ = new AtomicReference>(); + private final AtomicReference> publishFuture_ = new AtomicReference>(); private final ReentrantLock timeoutLock_ = new ReentrantLock(); private volatile ScheduledFuture timeout_ = null; 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 1106c21..577d332 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -1,6 +1,7 @@ package com.absmartly.sdk; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -2833,4 +2834,79 @@ void closeAsyncRetryAfterFailedPublishSucceeds() { verify(eventHandler, Mockito.times(2)).publish(any(), any()); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void closeAsyncAwaitsPublishStartedBeforeCloseAndRestoresEventsOnFailure() { + final Context context = createReadyContext(); + + context.track("goal_a", mapOf("amount", 1)); + assertEquals(1, context.getPendingCount()); + + final CompletableFuture publishFuture1 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture1); + + // publishAsync() flushes the queue immediately, so pendingCount_ is already 0 + // by the time closeAsync() runs below, with the publisher future still open. + final CompletableFuture publishResult = context.publishAsync(); + assertEquals(0, context.getPendingCount()); + + final CompletableFuture closeFuture = context.closeAsync(); + assertFalse(closeFuture.isDone()); + assertFalse(context.isClosed()); + + final Exception failure = new Exception("publish failed"); + publishFuture1.completeExceptionally(failure); + + assertThrows(CompletionException.class, publishResult::join); + assertThrows(CompletionException.class, closeFuture::join); + + assertFalse(context.isClosed()); + assertTrue(context.getPendingCount() > 0); + + final CompletableFuture publishFuture2 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture2); + + final CompletableFuture retryPublish = assertDoesNotThrow(context::publishAsync); + assertEquals(0, context.getPendingCount()); + + publishFuture2.complete(null); + retryPublish.join(); + + final CompletableFuture closeFuture2 = context.closeAsync(); + closeFuture2.join(); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + + verify(eventHandler, Mockito.times(2)).publish(any(), any()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void closeAsyncCompletesWhenAwaitedPublishSucceeds() { + final Context context = createReadyContext(); + + context.track("goal_a", mapOf("amount", 1)); + assertEquals(1, context.getPendingCount()); + + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + + final CompletableFuture publishResult = context.publishAsync(); + assertEquals(0, context.getPendingCount()); + + final CompletableFuture closeFuture = context.closeAsync(); + assertFalse(closeFuture.isDone()); + assertFalse(context.isClosed()); + + publishFuture.complete(null); + publishResult.join(); + closeFuture.join(); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + + verify(eventHandler, Mockito.times(1)).publish(any(), any()); + } } From 135487ccf96531346fe20a2f8ed2ffec5f6bca23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 13:16:49 +0000 Subject: [PATCH 45/83] fix: isolate the Publish log event from publish delivery accounting flush() chained the ContextEventLogger call onto the publisher's future before the exceptionally() restoration handler, so an exception thrown by the logger for a successful publish was indistinguishable from a genuine publisher failure. That restored already-delivered events and caused a retry to send the same goal/exposure batch a second time. Attach the restoration handler directly to the publisher's future instead of the logger stage, and swallow logger exceptions in their own runnable so they can no longer influence delivery accounting. --- .../main/java/com/absmartly/sdk/Context.java | 19 ++++++++++++---- .../java/com/absmartly/sdk/ContextTest.java | 22 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) 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 dfc1f77..03a8539 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -772,13 +772,24 @@ public void accept(Void ignoredResult, Throwable ignoredException) { final GoalAchievement[] finalAchievements = achievements; final int finalEventCount = eventCount; - eventHandler_.publish(this, event).thenRunAsync(new Runnable() { + final CompletableFuture publishResult = eventHandler_.publish(this, event); + + // The Publish log event runs in its own stage so a logger exception cannot be + // mistaken for a publisher failure and trigger event restoration. + publishResult.thenRunAsync(new Runnable() { @Override public void run() { - Context.this.logEvent(ContextEventLogger.EventType.Publish, event); - result.complete(null); + try { + Context.this.logEvent(ContextEventLogger.EventType.Publish, event); + } catch (final Throwable ignored) { + // diagnostic logger failures must not affect publish accounting + } finally { + result.complete(null); + } } - }).exceptionally(new Function() { + }); + + publishResult.exceptionally(new Function() { @Override public Void apply(Throwable throwable) { try { 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 577d332..3a672c2 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -2909,4 +2909,26 @@ void closeAsyncCompletesWhenAwaitedPublishSucceeds() { verify(eventHandler, Mockito.times(1)).publish(any(), any()); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publishSucceedsWhenLoggerThrowsOnPublishEvent() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + Mockito.doThrow(new RuntimeException("logger failure")) + .when(eventLogger).handleEvent(any(), eq(ContextEventLogger.EventType.Publish), any()); + + assertDoesNotThrow(context::publish); + assertEquals(0, context.getPendingCount()); + + // no new events were queued, so a retry must not re-deliver the same batch + context.publishAsync().join(); + assertEquals(0, context.getPendingCount()); + + verify(eventHandler, Mockito.times(1)).publish(any(), any()); + } } From e6c3714251b5262b0cf9ba82c94d10833c355c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 13:17:09 +0000 Subject: [PATCH 46/83] fix: leave injected schedulers running when ABsmartly closes close() called shutdown()/awaitTermination()/shutdownNow() on scheduler_ unconditionally, including a ScheduledExecutorService the caller supplied via ABsmartlyConfig.setScheduler(). After close(), that executor was shut down, rejecting unrelated application work or a second SDK instance sharing the same executor. Record ownership at construction time and only shut down a scheduler the SDK created itself; an injected scheduler stays under caller ownership. --- .../java/com/absmartly/sdk/ABsmartly.java | 5 ++- .../java/com/absmartly/sdk/ABsmartlyTest.java | 43 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) 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 5d80053..ddf1fd7 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java @@ -89,6 +89,7 @@ protected ABsmartly(@Nonnull ABsmartlyConfig config) { variableParser_ = config.getVariableParser(); audienceDeserializer_ = config.getAudienceDeserializer(); scheduler_ = config.getScheduler(); + ownsScheduler_ = scheduler_ == null; if ((contextDataProvider_ == null) || (contextEventHandler_ == null)) { client_ = config.getClient(); @@ -155,7 +156,8 @@ public void close() throws IOException { client_.close(); } } finally { - if (scheduler_ != null) { + // A caller-supplied scheduler remains under caller ownership and must be left running. + if ((scheduler_ != null) && ownsScheduler_) { scheduler_.shutdown(); try { if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) { @@ -178,4 +180,5 @@ public void close() throws IOException { private AudienceDeserializer audienceDeserializer_; private ScheduledExecutorService scheduler_; + private final boolean ownsScheduler_; } 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 7e09e84..97aa3a3 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java @@ -364,7 +364,48 @@ void close() throws IOException, InterruptedException { absmartly.close(); - verify(scheduler, Mockito.timeout(5000).times(1)).awaitTermination(anyLong(), any()); + // scheduler was injected by the caller, so close() must leave it under caller ownership + verify(scheduler, Mockito.times(0)).shutdown(); + verify(scheduler, Mockito.times(0)).awaitTermination(anyLong(), any()); + verify(scheduler, Mockito.times(0)).shutdownNow(); + } + } + + @Test + void closeLeavesInjectedSchedulerRunning() throws IOException { + final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1); + try { + final ABsmartlyConfig config = ABsmartlyConfig.create() + .setClient(client) + .setScheduler(scheduler); + + final ABsmartly absmartly = ABsmartly.create(config); + absmartly.close(); + + assertFalse(scheduler.isShutdown()); + } finally { + scheduler.shutdownNow(); + } + } + + @Test + void closeShutsDownSelfCreatedScheduler() throws IOException, InterruptedException { + try (final MockedConstruction schedulerCtor = mockConstruction( + ScheduledThreadPoolExecutor.class, (mock, context) -> { + when(mock.awaitTermination(anyLong(), any())).thenReturn(true); + })) { + final ABsmartlyConfig config = ABsmartlyConfig.create() + .setClient(client); + + final ABsmartly absmartly = ABsmartly.create(config); + assertEquals(1, schedulerCtor.constructed().size()); + + final ScheduledExecutorService createdScheduler = schedulerCtor.constructed().get(0); + + absmartly.close(); + + verify(createdScheduler, Mockito.times(1)).shutdown(); + verify(createdScheduler, Mockito.times(1)).awaitTermination(anyLong(), any()); } } } From 70329883981f65c332c59a2f0697ba0005f4a83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:05:11 +0000 Subject: [PATCH 47/83] fix: synchronize close with publish restoration --- .../main/java/com/absmartly/sdk/Context.java | 36 +++++++++------ .../java/com/absmartly/sdk/ContextTest.java | 44 +++++++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) 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 03a8539..c710d01 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -12,7 +12,6 @@ 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.BiFunction; import java8.util.function.Consumer; import java8.util.function.Function; @@ -614,7 +613,17 @@ public CompletableFuture closeAsync() { if (closing_.compareAndSet(false, true)) { clearRefreshTimer(); - if (pendingCount_.get() > 0) { + final int pendingCount; + final CompletableFuture inFlightPublish; + try { + eventLock_.lock(); + pendingCount = pendingCount_.get(); + inFlightPublish = publishFutures_.isEmpty() ? null : publishFutures_.iterator().next(); + } finally { + eventLock_.unlock(); + } + + if (pendingCount > 0) { final CompletableFuture newClosingFuture = new CompletableFuture(); closingFuture_.set(newClosingFuture); @@ -646,7 +655,6 @@ public Void apply(Throwable exception) { } else { // The queue is empty here, but a publish started by a concurrent flush() // may still be in flight and could restore events on failure. - final CompletableFuture inFlightPublish = publishFuture_.get(); if (inFlightPublish != null) { final CompletableFuture newClosingFuture = new CompletableFuture(); closingFuture_.set(newClosingFuture); @@ -713,6 +721,7 @@ private CompletableFuture flush() { Exposure[] exposures = null; GoalAchievement[] achievements = null; int eventCount; + final CompletableFuture result = new CompletableFuture(); try { eventLock_.lock(); @@ -730,6 +739,8 @@ private CompletableFuture flush() { } pendingCount_.set(0); + // ContextPublisher callbacks must not synchronously close this context. + publishFutures_.add(result); } } finally { eventLock_.unlock(); @@ -758,16 +769,6 @@ public Unit apply(Map.Entry entry) { event.exposures = exposures; event.goals = achievements; - final CompletableFuture result = new CompletableFuture(); - publishFuture_.set(result); - result.whenComplete(new BiConsumer() { - @Override - public void accept(Void ignoredResult, Throwable ignoredException) { - // A stale reference must not make a later close() wait forever. - publishFuture_.compareAndSet(result, null); - } - }); - final Exposure[] finalExposures = exposures; final GoalAchievement[] finalAchievements = achievements; final int finalEventCount = eventCount; @@ -784,6 +785,12 @@ public void run() { } catch (final Throwable ignored) { // diagnostic logger failures must not affect publish accounting } finally { + try { + eventLock_.lock(); + publishFutures_.remove(result); + } finally { + eventLock_.unlock(); + } result.complete(null); } } @@ -805,6 +812,7 @@ public Void apply(Throwable throwable) { } } pendingCount_.addAndGet(finalEventCount); + publishFutures_.remove(result); } finally { eventLock_.unlock(); } @@ -1319,7 +1327,7 @@ private Map buildAttributesMap() { private final AtomicReference> readyFuture_ = new AtomicReference>(); private final AtomicReference> closingFuture_ = new AtomicReference>(); private final AtomicReference> refreshFuture_ = new AtomicReference>(); - private final AtomicReference> publishFuture_ = new AtomicReference>(); + private final Set> publishFutures_ = new HashSet>(); private final ReentrantLock timeoutLock_ = new ReentrantLock(); private volatile ScheduledFuture timeout_ = null; 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 3a672c2..f26e833 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -2931,4 +2931,48 @@ void publishSucceedsWhenLoggerThrowsOnPublishEvent() { verify(eventHandler, Mockito.times(1)).publish(any(), any()); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void closeNeverFinalizesWithEventsRestoredByConcurrentPublishFailure() throws Exception { + for (int i = 0; i < 100; i++) { + final Context context = createReadyContext(); + context.track("goal", mapOf("attempt", i)); + + final CompletableFuture publisherFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publisherFuture); + final CompletableFuture publishResult = context.publishAsync(); + + final java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); + final AtomicReference> closeResult = new AtomicReference<>(); + final Thread closeThread = new Thread(() -> { + try { + start.await(); + closeResult.set(context.closeAsync()); + } catch (final InterruptedException exception) { + Thread.currentThread().interrupt(); + } + }); + final Thread failureThread = new Thread(() -> { + try { + start.await(); + publisherFuture.completeExceptionally(new Exception("publish failed")); + } catch (final InterruptedException exception) { + Thread.currentThread().interrupt(); + } + }); + + closeThread.start(); + failureThread.start(); + start.countDown(); + closeThread.join(); + failureThread.join(); + + assertThrows(CompletionException.class, publishResult::join); + final CompletableFuture closeFuture = closeResult.get(); + assertNotNull(closeFuture); + assertThrows(CompletionException.class, closeFuture::join); + assertFalse(context.isClosed() && context.getPendingCount() > 0); + } + } } From b17b7d375707fb32199036098c32e4a34b2246d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:07:58 +0000 Subject: [PATCH 48/83] fix: await overlapping context publishes --- .../main/java/com/absmartly/sdk/Context.java | 13 +++++--- .../java/com/absmartly/sdk/ContextTest.java | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) 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 c710d01..1b3e18b 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -614,11 +614,11 @@ public CompletableFuture closeAsync() { clearRefreshTimer(); final int pendingCount; - final CompletableFuture inFlightPublish; + final CompletableFuture[] inFlightPublishes; try { eventLock_.lock(); pendingCount = pendingCount_.get(); - inFlightPublish = publishFutures_.isEmpty() ? null : publishFutures_.iterator().next(); + inFlightPublishes = publishFutures_.toArray(new CompletableFuture[publishFutures_.size()]); } finally { eventLock_.unlock(); } @@ -627,7 +627,10 @@ public CompletableFuture closeAsync() { final CompletableFuture newClosingFuture = new CompletableFuture(); closingFuture_.set(newClosingFuture); - flush().thenAccept(new Consumer() { + final CompletableFuture[] closingPublishes = new CompletableFuture[inFlightPublishes.length + 1]; + System.arraycopy(inFlightPublishes, 0, closingPublishes, 0, inFlightPublishes.length); + closingPublishes[inFlightPublishes.length] = flush(); + CompletableFuture.allOf(closingPublishes).thenAccept(new Consumer() { @Override public void accept(Void x) { closed_.set(true); @@ -655,11 +658,11 @@ public Void apply(Throwable exception) { } else { // The queue is empty here, but a publish started by a concurrent flush() // may still be in flight and could restore events on failure. - if (inFlightPublish != null) { + if (inFlightPublishes.length > 0) { final CompletableFuture newClosingFuture = new CompletableFuture(); closingFuture_.set(newClosingFuture); - inFlightPublish.handle(new BiFunction() { + CompletableFuture.allOf(inFlightPublishes).handle(new BiFunction() { @Override public Void apply(Void ignoredResult, Throwable exception) { // Same rule as the flush-driven failure path: only close once 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 f26e833..75b8b6d 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -2975,4 +2975,35 @@ void closeNeverFinalizesWithEventsRestoredByConcurrentPublishFailure() throws Ex assertFalse(context.isClosed() && context.getPendingCount() > 0); } } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void closeAwaitsEveryOverlappingPublishBeforeFinalizing() { + final Context context = createReadyContext(); + final CompletableFuture publisherFutureA = new CompletableFuture<>(); + final CompletableFuture publisherFutureB = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publisherFutureA, publisherFutureB); + + context.track("goal_a", mapOf("amount", 1)); + final CompletableFuture publishResultA = context.publishAsync(); + context.track("goal_b", mapOf("amount", 2)); + final CompletableFuture publishResultB = context.publishAsync(); + + publisherFutureB.complete(null); + publishResultB.join(); + + final CompletableFuture closeFuture = context.closeAsync(); + assertFalse(closeFuture.isDone()); + assertFalse(context.isClosed()); + + publisherFutureA.completeExceptionally(new Exception("publish A failed")); + assertThrows(CompletionException.class, publishResultA::join); + assertThrows(CompletionException.class, closeFuture::join); + assertFalse(context.isClosed()); + assertTrue(context.getPendingCount() > 0); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + assertDoesNotThrow(context::publishAsync).join(); + assertEquals(0, context.getPendingCount()); + } } From 65fda17d99925402cb9b6187b5ee3c098d5f2025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:10:22 +0000 Subject: [PATCH 49/83] fix: restore events when publisher throws --- .../main/java/com/absmartly/sdk/Context.java | 57 +++++++++++-------- .../java/com/absmartly/sdk/ContextTest.java | 21 +++++++ 2 files changed, 53 insertions(+), 25 deletions(-) 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 1b3e18b..8107567 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -776,30 +776,7 @@ public Unit apply(Map.Entry entry) { final GoalAchievement[] finalAchievements = achievements; final int finalEventCount = eventCount; - final CompletableFuture publishResult = eventHandler_.publish(this, event); - - // The Publish log event runs in its own stage so a logger exception cannot be - // mistaken for a publisher failure and trigger event restoration. - publishResult.thenRunAsync(new Runnable() { - @Override - public void run() { - try { - Context.this.logEvent(ContextEventLogger.EventType.Publish, event); - } catch (final Throwable ignored) { - // diagnostic logger failures must not affect publish accounting - } finally { - try { - eventLock_.lock(); - publishFutures_.remove(result); - } finally { - eventLock_.unlock(); - } - result.complete(null); - } - } - }); - - publishResult.exceptionally(new Function() { + final Function onPublishFailure = new Function() { @Override public Void apply(Throwable throwable) { try { @@ -821,12 +798,42 @@ public Void apply(Throwable throwable) { } Context.this.logError(throwable); - result.completeExceptionally(throwable); return null; } + }; + + final CompletableFuture publishResult; + try { + publishResult = eventHandler_.publish(this, event); + } catch (final Throwable throwable) { + onPublishFailure.apply(throwable); + return result; + } + + // The Publish log event runs in its own stage so a logger exception cannot be + // mistaken for a publisher failure and trigger event restoration. + publishResult.thenRunAsync(new Runnable() { + @Override + public void run() { + try { + Context.this.logEvent(ContextEventLogger.EventType.Publish, event); + } catch (final Throwable ignored) { + // diagnostic logger failures must not affect publish accounting + } finally { + try { + eventLock_.lock(); + publishFutures_.remove(result); + } finally { + eventLock_.unlock(); + } + result.complete(null); + } + } }); + publishResult.exceptionally(onPublishFailure); + return result; } } 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 75b8b6d..60e7930 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3006,4 +3006,25 @@ void closeAwaitsEveryOverlappingPublishBeforeFinalizing() { assertDoesNotThrow(context::publishAsync).join(); assertEquals(0, context.getPendingCount()); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void synchronousPublisherFailureRestoresEventsAndDoesNotHangClose() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final RuntimeException failure = new RuntimeException("publisher threw"); + when(eventHandler.publish(any(), any())).thenThrow(failure); + + final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); + final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); + assertSame(failure, actual.getCause()); + assertTrue(context.getPendingCount() > 0); + + final CompletableFuture closeResult = assertDoesNotThrow(context::closeAsync); + assertTrue(closeResult.isDone()); + assertThrows(CompletionException.class, closeResult::join); + assertFalse(context.isClosed()); + assertTrue(context.getPendingCount() > 0); + } } From faeb67b92638350a1928c9cbf1471294b448df65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:11:25 +0000 Subject: [PATCH 50/83] test: bound scheduler close checks --- core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java | 4 ++++ 1 file changed, 4 insertions(+) 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 97aa3a3..d2f0f0e 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java @@ -6,10 +6,12 @@ import java.io.IOException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java8.util.concurrent.CompletableFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.ArgumentCaptor; import org.mockito.MockedConstruction; import org.mockito.MockedStatic; @@ -372,6 +374,7 @@ void close() throws IOException, InterruptedException { } @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) void closeLeavesInjectedSchedulerRunning() throws IOException { final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1); try { @@ -389,6 +392,7 @@ void closeLeavesInjectedSchedulerRunning() throws IOException { } @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) void closeShutsDownSelfCreatedScheduler() throws IOException, InterruptedException { try (final MockedConstruction schedulerCtor = mockConstruction( ScheduledThreadPoolExecutor.class, (mock, context) -> { From 0f0e91029c29863e035074b08fb36a6cfae25d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:13:03 +0000 Subject: [PATCH 51/83] style: format publish future array --- core-api/src/main/java/com/absmartly/sdk/Context.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8107567..9228d2e 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -627,7 +627,8 @@ public CompletableFuture closeAsync() { final CompletableFuture newClosingFuture = new CompletableFuture(); closingFuture_.set(newClosingFuture); - final CompletableFuture[] closingPublishes = new CompletableFuture[inFlightPublishes.length + 1]; + final CompletableFuture[] closingPublishes = new CompletableFuture[inFlightPublishes.length + + 1]; System.arraycopy(inFlightPublishes, 0, closingPublishes, 0, inFlightPublishes.length); closingPublishes[inFlightPublishes.length] = flush(); CompletableFuture.allOf(closingPublishes).thenAccept(new Consumer() { From 694df529975f1984bdd053ae54c11a6c614c0091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:16:10 +0000 Subject: [PATCH 52/83] docs: record close sequencing verification From 07be5b92f579dfb7bf1f0b6bdeb4c50307c81c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:33:16 +0000 Subject: [PATCH 53/83] fix: settle publishes before deregistration --- core-api/src/main/java/com/absmartly/sdk/Context.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 9228d2e..c3eba9f 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -793,13 +793,18 @@ public Void apply(Throwable throwable) { } } pendingCount_.addAndGet(finalEventCount); - publishFutures_.remove(result); } finally { eventLock_.unlock(); } Context.this.logError(throwable); result.completeExceptionally(throwable); + try { + eventLock_.lock(); + publishFutures_.remove(result); + } finally { + eventLock_.unlock(); + } return null; } }; @@ -822,13 +827,13 @@ public void run() { } catch (final Throwable ignored) { // diagnostic logger failures must not affect publish accounting } finally { + result.complete(null); try { eventLock_.lock(); publishFutures_.remove(result); } finally { eventLock_.unlock(); } - result.complete(null); } } }); From 2c9d6307f6a3fdea9d1ee8d98a864e98ca234d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:33:54 +0000 Subject: [PATCH 54/83] fix: isolate error logger from publish settlement --- core-api/src/main/java/com/absmartly/sdk/Context.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 c3eba9f..f24610e 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -797,7 +797,11 @@ public Void apply(Throwable throwable) { eventLock_.unlock(); } - Context.this.logError(throwable); + try { + Context.this.logError(throwable); + } catch (final Throwable ignored) { + // diagnostic logger failures must not affect publish accounting + } result.completeExceptionally(throwable); try { eventLock_.lock(); From 8dc1fc7c2475e5cac2740989b4e4fbf9d7a8b66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:34:48 +0000 Subject: [PATCH 55/83] test: remove nondeterministic close regression --- .../java/com/absmartly/sdk/ContextTest.java | 44 ------------------- 1 file changed, 44 deletions(-) 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 60e7930..16c5401 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -2932,50 +2932,6 @@ void publishSucceedsWhenLoggerThrowsOnPublishEvent() { verify(eventHandler, Mockito.times(1)).publish(any(), any()); } - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void closeNeverFinalizesWithEventsRestoredByConcurrentPublishFailure() throws Exception { - for (int i = 0; i < 100; i++) { - final Context context = createReadyContext(); - context.track("goal", mapOf("attempt", i)); - - final CompletableFuture publisherFuture = new CompletableFuture<>(); - when(eventHandler.publish(any(), any())).thenReturn(publisherFuture); - final CompletableFuture publishResult = context.publishAsync(); - - final java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); - final AtomicReference> closeResult = new AtomicReference<>(); - final Thread closeThread = new Thread(() -> { - try { - start.await(); - closeResult.set(context.closeAsync()); - } catch (final InterruptedException exception) { - Thread.currentThread().interrupt(); - } - }); - final Thread failureThread = new Thread(() -> { - try { - start.await(); - publisherFuture.completeExceptionally(new Exception("publish failed")); - } catch (final InterruptedException exception) { - Thread.currentThread().interrupt(); - } - }); - - closeThread.start(); - failureThread.start(); - start.countDown(); - closeThread.join(); - failureThread.join(); - - assertThrows(CompletionException.class, publishResult::join); - final CompletableFuture closeFuture = closeResult.get(); - assertNotNull(closeFuture); - assertThrows(CompletionException.class, closeFuture::join); - assertFalse(context.isClosed() && context.getPendingCount() > 0); - } - } - @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void closeAwaitsEveryOverlappingPublishBeforeFinalizing() { From 0916adba44529d3785bedfb53c5032df50cd37b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:38:27 +0000 Subject: [PATCH 56/83] test: cover publish settlement ordering --- .../java/com/absmartly/sdk/ContextTest.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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 16c5401..23dd471 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -2932,6 +2933,65 @@ void publishSucceedsWhenLoggerThrowsOnPublishEvent() { verify(eventHandler, Mockito.times(1)).publish(any(), any()); } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void closeAwaitsPublishWhileFailureLoggerIsBlocked() throws Exception { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final CompletableFuture publisherFuture = new CompletableFuture<>(); + final CompletableFuture retryFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publisherFuture) + .thenReturn(retryFuture); + final CountDownLatch loggerEntered = new CountDownLatch(1); + final CountDownLatch releaseLogger = new CountDownLatch(1); + Mockito.doAnswer(invocation -> { + loggerEntered.countDown(); + releaseLogger.await(); + return null; + }).when(eventLogger).handleEvent(any(), eq(ContextEventLogger.EventType.Error), any()); + + final CompletableFuture publishResult = context.publishAsync(); + final RuntimeException failure = new RuntimeException("publish failed"); + final Thread failureThread = new Thread(() -> publisherFuture.completeExceptionally(failure)); + failureThread.start(); + loggerEntered.await(); + + final CompletableFuture closeResult = context.closeAsync(); + assertFalse(closeResult.isDone()); + assertFalse(context.isClosed()); + + releaseLogger.countDown(); + failureThread.join(); + assertThrows(CompletionException.class, publishResult::join); + retryFuture.complete(null); + assertThrows(CompletionException.class, closeResult::join); + assertFalse(context.isClosed() && context.getPendingCount() > 0); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void synchronousPublisherFailureSettlesWhenErrorLoggerThrows() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final RuntimeException publisherFailure = new RuntimeException("publisher threw"); + when(eventHandler.publish(any(), any())).thenThrow(publisherFailure); + Mockito.doThrow(new RuntimeException("logger threw")) + .when(eventLogger).handleEvent(any(), eq(ContextEventLogger.EventType.Error), any()); + + final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); + final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); + assertSame(publisherFailure, actual.getCause()); + assertTrue(context.getPendingCount() > 0); + + final CompletableFuture closeResult = assertDoesNotThrow(context::closeAsync); + assertTrue(closeResult.isDone()); + assertThrows(CompletionException.class, closeResult::join); + assertFalse(context.isClosed()); + assertTrue(context.getPendingCount() > 0); + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void closeAwaitsEveryOverlappingPublishBeforeFinalizing() { From b99c900a66eb03a4d419fdb778353058d826ee23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:42:20 +0000 Subject: [PATCH 57/83] test: simplify close settlement retry --- core-api/src/test/java/com/absmartly/sdk/ContextTest.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 23dd471..4a6c05f 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -2940,9 +2940,8 @@ void closeAwaitsPublishWhileFailureLoggerIsBlocked() throws Exception { context.track("goal", mapOf("amount", 1)); final CompletableFuture publisherFuture = new CompletableFuture<>(); - final CompletableFuture retryFuture = new CompletableFuture<>(); when(eventHandler.publish(any(), any())).thenReturn(publisherFuture) - .thenReturn(retryFuture); + .thenReturn(CompletableFuture.completedFuture(null)); final CountDownLatch loggerEntered = new CountDownLatch(1); final CountDownLatch releaseLogger = new CountDownLatch(1); Mockito.doAnswer(invocation -> { @@ -2964,7 +2963,6 @@ void closeAwaitsPublishWhileFailureLoggerIsBlocked() throws Exception { releaseLogger.countDown(); failureThread.join(); assertThrows(CompletionException.class, publishResult::join); - retryFuture.complete(null); assertThrows(CompletionException.class, closeResult::join); assertFalse(context.isClosed() && context.getPendingCount() > 0); } From 3b046cb361bc4c468a88ca675b55a24423ee1d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 14:58:41 +0000 Subject: [PATCH 58/83] style: simplify closing publish array copy --- core-api/src/main/java/com/absmartly/sdk/Context.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 f24610e..63c81f8 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -627,9 +627,8 @@ public CompletableFuture closeAsync() { final CompletableFuture newClosingFuture = new CompletableFuture(); closingFuture_.set(newClosingFuture); - final CompletableFuture[] closingPublishes = new CompletableFuture[inFlightPublishes.length - + 1]; - System.arraycopy(inFlightPublishes, 0, closingPublishes, 0, inFlightPublishes.length); + final CompletableFuture[] closingPublishes = Arrays.copyOf(inFlightPublishes, + inFlightPublishes.length + 1); closingPublishes[inFlightPublishes.length] = flush(); CompletableFuture.allOf(closingPublishes).thenAccept(new Consumer() { @Override From 71510f971c4aa904de692e44b1cd3c79433c8811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 17:34:15 +0000 Subject: [PATCH 59/83] fix: isolate publish lifecycle from callers --- .../main/java/com/absmartly/sdk/Context.java | 16 ++++++++-- .../java/com/absmartly/sdk/ContextTest.java | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) 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 63c81f8..1ece6c0 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -725,6 +725,18 @@ private CompletableFuture flush() { GoalAchievement[] achievements = null; int eventCount; final CompletableFuture result = new CompletableFuture(); + final CompletableFuture callerResult = new CompletableFuture(); + result.handle(new BiFunction() { + @Override + public Void apply(Void ignoredResult, Throwable exception) { + if (exception != null) { + callerResult.completeExceptionally(exception); + } else { + callerResult.complete(null); + } + return null; + } + }); try { eventLock_.lock(); @@ -817,7 +829,7 @@ public Void apply(Throwable throwable) { publishResult = eventHandler_.publish(this, event); } catch (final Throwable throwable) { onPublishFailure.apply(throwable); - return result; + return callerResult; } // The Publish log event runs in its own stage so a logger exception cannot be @@ -843,7 +855,7 @@ public void run() { publishResult.exceptionally(onPublishFailure); - return result; + return callerResult; } } } else { 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 4a6c05f..d5fad49 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3041,4 +3041,33 @@ void synchronousPublisherFailureRestoresEventsAndDoesNotHangClose() { assertFalse(context.isClosed()); assertTrue(context.getPendingCount() > 0); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void cancellingPublishResultDoesNotSettleInternalPublish() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final CompletableFuture publisherFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publisherFuture) + .thenReturn(CompletableFuture.completedFuture(null)); + + final CompletableFuture publishResult = context.publishAsync(); + assertTrue(publishResult.cancel(false)); + + final CompletableFuture closeResult = context.closeAsync(); + assertFalse(closeResult.isDone()); + assertFalse(context.isClosed()); + + final RuntimeException failure = new RuntimeException("publish failed"); + publisherFuture.completeExceptionally(failure); + + assertThrows(CompletionException.class, closeResult::join); + assertTrue(context.getPendingCount() > 0); + assertFalse(context.isClosed() && context.getPendingCount() > 0); + + final CompletableFuture retryPublish = assertDoesNotThrow(context::publishAsync); + retryPublish.join(); + assertEquals(0, context.getPendingCount()); + } } From 84edd10d1ecc232520b83a62b148bbe049ce9a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 17:39:31 +0000 Subject: [PATCH 60/83] fix: allow publishers to close context --- .../main/java/com/absmartly/sdk/Context.java | 24 ++++++++++++++++--- .../java/com/absmartly/sdk/ContextTest.java | 16 +++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) 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 1ece6c0..b9ecf4a 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -618,7 +618,15 @@ public CompletableFuture closeAsync() { try { eventLock_.lock(); pendingCount = pendingCount_.get(); - inFlightPublishes = publishFutures_.toArray(new CompletableFuture[publishFutures_.size()]); + final Set> closingPublishFutures = new HashSet>( + publishFutures_); + for (Map.Entry, Thread> invocation : publisherInvocations_.entrySet()) { + if (invocation.getValue() == Thread.currentThread()) { + closingPublishFutures.remove(invocation.getKey()); + } + } + inFlightPublishes = closingPublishFutures + .toArray(new CompletableFuture[closingPublishFutures.size()]); } finally { eventLock_.unlock(); } @@ -754,8 +762,8 @@ public Void apply(Void ignoredResult, Throwable exception) { } pendingCount_.set(0); - // ContextPublisher callbacks must not synchronously close this context. publishFutures_.add(result); + publisherInvocations_.put(result, Thread.currentThread()); } } finally { eventLock_.unlock(); @@ -826,7 +834,16 @@ public Void apply(Throwable throwable) { final CompletableFuture publishResult; try { - publishResult = eventHandler_.publish(this, event); + try { + publishResult = eventHandler_.publish(this, event); + } finally { + try { + eventLock_.lock(); + publisherInvocations_.remove(result); + } finally { + eventLock_.unlock(); + } + } } catch (final Throwable throwable) { onPublishFailure.apply(throwable); return callerResult; @@ -1359,6 +1376,7 @@ private Map buildAttributesMap() { private final AtomicReference> closingFuture_ = new AtomicReference>(); private final AtomicReference> refreshFuture_ = new AtomicReference>(); private final Set> publishFutures_ = new HashSet>(); + private final Map, Thread> publisherInvocations_ = new HashMap, Thread>(); private final ReentrantLock timeoutLock_ = new ReentrantLock(); private volatile ScheduledFuture timeout_ = null; 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 d5fad49..ed7ea97 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3070,4 +3070,20 @@ void cancellingPublishResultDoesNotSettleInternalPublish() { retryPublish.join(); assertEquals(0, context.getPendingCount()); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publisherCanCloseContextSynchronously() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> context.closeAsync()); + + final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); + publishResult.join(); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + assertTrue(context.closeAsync().isDone()); + } } From e1a5ec383fb2ce63c0590d3136f59cc28ffa5c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 18:04:57 +0000 Subject: [PATCH 61/83] fix: narrow publisher invocation tracking --- core-api/src/main/java/com/absmartly/sdk/Context.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 b9ecf4a..2a1b42d 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -763,7 +763,6 @@ public Void apply(Void ignoredResult, Throwable exception) { pendingCount_.set(0); publishFutures_.add(result); - publisherInvocations_.put(result, Thread.currentThread()); } } finally { eventLock_.unlock(); @@ -835,6 +834,12 @@ public Void apply(Throwable throwable) { final CompletableFuture publishResult; try { try { + try { + eventLock_.lock(); + publisherInvocations_.put(result, Thread.currentThread()); + } finally { + eventLock_.unlock(); + } publishResult = eventHandler_.publish(this, event); } finally { try { From 5c94b656e388ecf5e3b75729c682ae995e6fbecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 18:06:43 +0000 Subject: [PATCH 62/83] fix: reopen context after publish restoration --- .../main/java/com/absmartly/sdk/Context.java | 6 +++ .../java/com/absmartly/sdk/ContextTest.java | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+) 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 2a1b42d..ba2a629 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -811,6 +811,12 @@ public Void apply(Throwable throwable) { } } pendingCount_.addAndGet(finalEventCount); + // A publish failing after close finalized must leave the context usable, + // because the restored events are still deliverable. + if (pendingCount_.get() > 0 && (closed_.get() || closing_.get())) { + closed_.set(false); + closing_.set(false); + } } finally { eventLock_.unlock(); } 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 ed7ea97..85604a1 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3086,4 +3086,52 @@ void publisherCanCloseContextSynchronously() { assertEquals(0, context.getPendingCount()); assertTrue(context.closeAsync().isDone()); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publisherCloseThenThrowReopensContextForRetry() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final RuntimeException failure = new RuntimeException("publisher threw"); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + context.closeAsync(); + throw failure; + }).thenReturn(CompletableFuture.completedFuture(null)); + + final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); + final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); + assertSame(failure, actual.getCause()); + assertTrue(context.getPendingCount() > 0); + assertFalse(context.isClosed()); + assertFalse(context.isClosed() && context.getPendingCount() > 0); + + assertDoesNotThrow(context::publishAsync).join(); + assertEquals(0, context.getPendingCount()); + verify(eventHandler, Mockito.times(2)).publish(any(), any()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publisherCloseThenFailedFutureReopensContextForRetry() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final RuntimeException failure = new RuntimeException("publisher failed"); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + context.closeAsync(); + return failedFuture(failure); + }).thenReturn(CompletableFuture.completedFuture(null)); + + final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); + final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); + assertSame(failure, actual.getCause()); + assertTrue(context.getPendingCount() > 0); + assertFalse(context.isClosed()); + assertFalse(context.isClosed() && context.getPendingCount() > 0); + + assertDoesNotThrow(context::publishAsync).join(); + assertEquals(0, context.getPendingCount()); + verify(eventHandler, Mockito.times(2)).publish(any(), any()); + } } From 457497d17135f14b85cf97e541251fd157316ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 18:09:09 +0000 Subject: [PATCH 63/83] fix: recheck pending events before close --- .../main/java/com/absmartly/sdk/Context.java | 45 ++++++++++++++++--- .../java/com/absmartly/sdk/ContextTest.java | 31 +++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) 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 ba2a629..b2e1dc5 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -641,11 +641,22 @@ public CompletableFuture closeAsync() { CompletableFuture.allOf(closingPublishes).thenAccept(new Consumer() { @Override public void accept(Void x) { - closed_.set(true); + boolean finalized = false; + try { + eventLock_.lock(); + if (pendingCount_.get() == 0) { + closed_.set(true); + finalized = true; + } + } finally { + eventLock_.unlock(); + } closing_.set(false); newClosingFuture.complete(null); - Context.this.logEvent(ContextEventLogger.EventType.Close, null); + if (finalized) { + Context.this.logEvent(ContextEventLogger.EventType.Close, null); + } } }).exceptionally(new Function() { @Override @@ -675,8 +686,15 @@ public Void apply(Throwable exception) { public Void apply(Void ignoredResult, Throwable exception) { // Same rule as the flush-driven failure path: only close once // no events remain to retry. - if ((exception == null) || (pendingCount_.get() == 0)) { - closed_.set(true); + boolean finalized = false; + try { + eventLock_.lock(); + if (pendingCount_.get() == 0) { + closed_.set(true); + finalized = true; + } + } finally { + eventLock_.unlock(); } closing_.set(false); @@ -684,7 +702,9 @@ public Void apply(Void ignoredResult, Throwable exception) { newClosingFuture.completeExceptionally(exception); } else { newClosingFuture.complete(null); - Context.this.logEvent(ContextEventLogger.EventType.Close, null); + if (finalized) { + Context.this.logEvent(ContextEventLogger.EventType.Close, null); + } } return null; } @@ -693,10 +713,21 @@ public Void apply(Void ignoredResult, Throwable exception) { return newClosingFuture; } - closed_.set(true); + boolean finalized = false; + try { + eventLock_.lock(); + if (pendingCount_.get() == 0) { + closed_.set(true); + finalized = true; + } + } finally { + eventLock_.unlock(); + } closing_.set(false); - Context.this.logEvent(ContextEventLogger.EventType.Close, null); + if (finalized) { + Context.this.logEvent(ContextEventLogger.EventType.Close, null); + } // Nothing was pending here, so no closingFuture_ was published for this // attempt; return directly to avoid picking up a stale future left behind 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 85604a1..d5ff087 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3134,4 +3134,35 @@ void publisherCloseThenFailedFutureReopensContextForRetry() { assertEquals(0, context.getPendingCount()); verify(eventHandler, Mockito.times(2)).publish(any(), any()); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void overlappingPublishDoesNotFinalizeWithRestoredEvents() { + final Context context = createReadyContext(); + final CompletableFuture publisherFutureA = new CompletableFuture<>(); + final AtomicReference> closeResult = new AtomicReference<>(); + final RuntimeException failure = new RuntimeException("publisher B failed"); + when(eventHandler.publish(any(), any())).thenReturn(publisherFutureA).thenAnswer(invocation -> { + closeResult.set(context.closeAsync()); + return failedFuture(failure); + }); + + context.track("goal_a", mapOf("amount", 1)); + final CompletableFuture publishResultA = context.publishAsync(); + context.track("goal_b", mapOf("amount", 2)); + final CompletableFuture publishResultB = context.publishAsync(); + + assertThrows(CompletionException.class, publishResultB::join); + assertFalse(closeResult.get().isDone()); + assertTrue(context.getPendingCount() > 0); + assertFalse(context.isClosed()); + + publisherFutureA.complete(null); + publishResultA.join(); + closeResult.get().join(); + + assertTrue(context.getPendingCount() > 0); + assertFalse(context.isClosed()); + assertFalse(context.isClosed() && context.getPendingCount() > 0); + } } From 0c30999c564c676a508e6314a32a2d5c622e889a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 18:36:38 +0000 Subject: [PATCH 64/83] fix: reject close from publisher callback --- .../main/java/com/absmartly/sdk/Context.java | 71 +++++-------------- 1 file changed, 18 insertions(+), 53 deletions(-) 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 b2e1dc5..8d059e0 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -609,6 +609,16 @@ public void refresh() { } public CompletableFuture closeAsync() { + try { + eventLock_.lock(); + if (publisherInvocations_.containsValue(Thread.currentThread())) { + throw new IllegalStateException( + "ContextPublisher must not close the context from within publish(); return a future and let the caller close the context instead"); + } + } finally { + eventLock_.unlock(); + } + if (!closed_.get()) { if (closing_.compareAndSet(false, true)) { clearRefreshTimer(); @@ -618,15 +628,7 @@ public CompletableFuture closeAsync() { try { eventLock_.lock(); pendingCount = pendingCount_.get(); - final Set> closingPublishFutures = new HashSet>( - publishFutures_); - for (Map.Entry, Thread> invocation : publisherInvocations_.entrySet()) { - if (invocation.getValue() == Thread.currentThread()) { - closingPublishFutures.remove(invocation.getKey()); - } - } - inFlightPublishes = closingPublishFutures - .toArray(new CompletableFuture[closingPublishFutures.size()]); + inFlightPublishes = publishFutures_.toArray(new CompletableFuture[publishFutures_.size()]); } finally { eventLock_.unlock(); } @@ -641,22 +643,11 @@ public CompletableFuture closeAsync() { CompletableFuture.allOf(closingPublishes).thenAccept(new Consumer() { @Override public void accept(Void x) { - boolean finalized = false; - try { - eventLock_.lock(); - if (pendingCount_.get() == 0) { - closed_.set(true); - finalized = true; - } - } finally { - eventLock_.unlock(); - } + closed_.set(true); closing_.set(false); newClosingFuture.complete(null); - if (finalized) { - Context.this.logEvent(ContextEventLogger.EventType.Close, null); - } + Context.this.logEvent(ContextEventLogger.EventType.Close, null); } }).exceptionally(new Function() { @Override @@ -686,15 +677,8 @@ public Void apply(Throwable exception) { public Void apply(Void ignoredResult, Throwable exception) { // Same rule as the flush-driven failure path: only close once // no events remain to retry. - boolean finalized = false; - try { - eventLock_.lock(); - if (pendingCount_.get() == 0) { - closed_.set(true); - finalized = true; - } - } finally { - eventLock_.unlock(); + if ((exception == null) || (pendingCount_.get() == 0)) { + closed_.set(true); } closing_.set(false); @@ -702,9 +686,7 @@ public Void apply(Void ignoredResult, Throwable exception) { newClosingFuture.completeExceptionally(exception); } else { newClosingFuture.complete(null); - if (finalized) { - Context.this.logEvent(ContextEventLogger.EventType.Close, null); - } + Context.this.logEvent(ContextEventLogger.EventType.Close, null); } return null; } @@ -713,21 +695,10 @@ public Void apply(Void ignoredResult, Throwable exception) { return newClosingFuture; } - boolean finalized = false; - try { - eventLock_.lock(); - if (pendingCount_.get() == 0) { - closed_.set(true); - finalized = true; - } - } finally { - eventLock_.unlock(); - } + closed_.set(true); closing_.set(false); - if (finalized) { - Context.this.logEvent(ContextEventLogger.EventType.Close, null); - } + Context.this.logEvent(ContextEventLogger.EventType.Close, null); // Nothing was pending here, so no closingFuture_ was published for this // attempt; return directly to avoid picking up a stale future left behind @@ -842,12 +813,6 @@ public Void apply(Throwable throwable) { } } pendingCount_.addAndGet(finalEventCount); - // A publish failing after close finalized must leave the context usable, - // because the restored events are still deliverable. - if (pendingCount_.get() > 0 && (closed_.get() || closing_.get())) { - closed_.set(false); - closing_.set(false); - } } finally { eventLock_.unlock(); } From 0609b6f1354609ffb8b5c64c05013b3435a6bc92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 18:36:50 +0000 Subject: [PATCH 65/83] test: enforce publisher close contract --- .../java/com/absmartly/sdk/ContextTest.java | 68 ++----------------- 1 file changed, 7 insertions(+), 61 deletions(-) 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 d5ff087..1ec8dd8 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3073,53 +3073,31 @@ void cancellingPublishResultDoesNotSettleInternalPublish() { @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCanCloseContextSynchronously() { + void publisherClosingContextFromCallbackIsRejected() { final Context context = createReadyContext(); context.track("goal", mapOf("amount", 1)); - when(eventHandler.publish(any(), any())).thenAnswer(invocation -> context.closeAsync()); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + assertThrows(IllegalStateException.class, context::closeAsync); + return CompletableFuture.completedFuture(null); + }); final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); publishResult.join(); - assertTrue(context.isClosed()); - assertEquals(0, context.getPendingCount()); - assertTrue(context.closeAsync().isDone()); - } - - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCloseThenThrowReopensContextForRetry() { - final Context context = createReadyContext(); - context.track("goal", mapOf("amount", 1)); - - final RuntimeException failure = new RuntimeException("publisher threw"); - when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { - context.closeAsync(); - throw failure; - }).thenReturn(CompletableFuture.completedFuture(null)); - - final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); - final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); - assertSame(failure, actual.getCause()); - assertTrue(context.getPendingCount() > 0); assertFalse(context.isClosed()); - assertFalse(context.isClosed() && context.getPendingCount() > 0); - - assertDoesNotThrow(context::publishAsync).join(); assertEquals(0, context.getPendingCount()); - verify(eventHandler, Mockito.times(2)).publish(any(), any()); } @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCloseThenFailedFutureReopensContextForRetry() { + void rejectedPublisherCloseDoesNotStrandRestoredEvents() { final Context context = createReadyContext(); context.track("goal", mapOf("amount", 1)); final RuntimeException failure = new RuntimeException("publisher failed"); when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { - context.closeAsync(); + assertThrows(IllegalStateException.class, context::closeAsync); return failedFuture(failure); }).thenReturn(CompletableFuture.completedFuture(null)); @@ -3128,41 +3106,9 @@ void publisherCloseThenFailedFutureReopensContextForRetry() { assertSame(failure, actual.getCause()); assertTrue(context.getPendingCount() > 0); assertFalse(context.isClosed()); - assertFalse(context.isClosed() && context.getPendingCount() > 0); assertDoesNotThrow(context::publishAsync).join(); assertEquals(0, context.getPendingCount()); verify(eventHandler, Mockito.times(2)).publish(any(), any()); } - - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void overlappingPublishDoesNotFinalizeWithRestoredEvents() { - final Context context = createReadyContext(); - final CompletableFuture publisherFutureA = new CompletableFuture<>(); - final AtomicReference> closeResult = new AtomicReference<>(); - final RuntimeException failure = new RuntimeException("publisher B failed"); - when(eventHandler.publish(any(), any())).thenReturn(publisherFutureA).thenAnswer(invocation -> { - closeResult.set(context.closeAsync()); - return failedFuture(failure); - }); - - context.track("goal_a", mapOf("amount", 1)); - final CompletableFuture publishResultA = context.publishAsync(); - context.track("goal_b", mapOf("amount", 2)); - final CompletableFuture publishResultB = context.publishAsync(); - - assertThrows(CompletionException.class, publishResultB::join); - assertFalse(closeResult.get().isDone()); - assertTrue(context.getPendingCount() > 0); - assertFalse(context.isClosed()); - - publisherFutureA.complete(null); - publishResultA.join(); - closeResult.get().join(); - - assertTrue(context.getPendingCount() > 0); - assertFalse(context.isClosed()); - assertFalse(context.isClosed() && context.getPendingCount() > 0); - } } From fe1daedbe0e079e70c500942f247de8c222e4727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 20:08:24 +0000 Subject: [PATCH 66/83] Revert "test: enforce publisher close contract" This reverts commit 0609b6f1354609ffb8b5c64c05013b3435a6bc92. --- .../java/com/absmartly/sdk/ContextTest.java | 68 +++++++++++++++++-- 1 file changed, 61 insertions(+), 7 deletions(-) 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 1ec8dd8..d5ff087 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3073,31 +3073,53 @@ void cancellingPublishResultDoesNotSettleInternalPublish() { @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherClosingContextFromCallbackIsRejected() { + void publisherCanCloseContextSynchronously() { final Context context = createReadyContext(); context.track("goal", mapOf("amount", 1)); - when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { - assertThrows(IllegalStateException.class, context::closeAsync); - return CompletableFuture.completedFuture(null); - }); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> context.closeAsync()); final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); publishResult.join(); + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + assertTrue(context.closeAsync().isDone()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publisherCloseThenThrowReopensContextForRetry() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final RuntimeException failure = new RuntimeException("publisher threw"); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + context.closeAsync(); + throw failure; + }).thenReturn(CompletableFuture.completedFuture(null)); + + final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); + final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); + assertSame(failure, actual.getCause()); + assertTrue(context.getPendingCount() > 0); assertFalse(context.isClosed()); + assertFalse(context.isClosed() && context.getPendingCount() > 0); + + assertDoesNotThrow(context::publishAsync).join(); assertEquals(0, context.getPendingCount()); + verify(eventHandler, Mockito.times(2)).publish(any(), any()); } @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void rejectedPublisherCloseDoesNotStrandRestoredEvents() { + void publisherCloseThenFailedFutureReopensContextForRetry() { final Context context = createReadyContext(); context.track("goal", mapOf("amount", 1)); final RuntimeException failure = new RuntimeException("publisher failed"); when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { - assertThrows(IllegalStateException.class, context::closeAsync); + context.closeAsync(); return failedFuture(failure); }).thenReturn(CompletableFuture.completedFuture(null)); @@ -3106,9 +3128,41 @@ void rejectedPublisherCloseDoesNotStrandRestoredEvents() { assertSame(failure, actual.getCause()); assertTrue(context.getPendingCount() > 0); assertFalse(context.isClosed()); + assertFalse(context.isClosed() && context.getPendingCount() > 0); assertDoesNotThrow(context::publishAsync).join(); assertEquals(0, context.getPendingCount()); verify(eventHandler, Mockito.times(2)).publish(any(), any()); } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void overlappingPublishDoesNotFinalizeWithRestoredEvents() { + final Context context = createReadyContext(); + final CompletableFuture publisherFutureA = new CompletableFuture<>(); + final AtomicReference> closeResult = new AtomicReference<>(); + final RuntimeException failure = new RuntimeException("publisher B failed"); + when(eventHandler.publish(any(), any())).thenReturn(publisherFutureA).thenAnswer(invocation -> { + closeResult.set(context.closeAsync()); + return failedFuture(failure); + }); + + context.track("goal_a", mapOf("amount", 1)); + final CompletableFuture publishResultA = context.publishAsync(); + context.track("goal_b", mapOf("amount", 2)); + final CompletableFuture publishResultB = context.publishAsync(); + + assertThrows(CompletionException.class, publishResultB::join); + assertFalse(closeResult.get().isDone()); + assertTrue(context.getPendingCount() > 0); + assertFalse(context.isClosed()); + + publisherFutureA.complete(null); + publishResultA.join(); + closeResult.get().join(); + + assertTrue(context.getPendingCount() > 0); + assertFalse(context.isClosed()); + assertFalse(context.isClosed() && context.getPendingCount() > 0); + } } From 41e4325b123782b3bfe795a99db9c135e9413874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 20:08:24 +0000 Subject: [PATCH 67/83] Revert "fix: reject close from publisher callback" This reverts commit 0c30999c564c676a508e6314a32a2d5c622e889a. --- .../main/java/com/absmartly/sdk/Context.java | 71 ++++++++++++++----- 1 file changed, 53 insertions(+), 18 deletions(-) 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 8d059e0..b2e1dc5 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -609,16 +609,6 @@ public void refresh() { } public CompletableFuture closeAsync() { - try { - eventLock_.lock(); - if (publisherInvocations_.containsValue(Thread.currentThread())) { - throw new IllegalStateException( - "ContextPublisher must not close the context from within publish(); return a future and let the caller close the context instead"); - } - } finally { - eventLock_.unlock(); - } - if (!closed_.get()) { if (closing_.compareAndSet(false, true)) { clearRefreshTimer(); @@ -628,7 +618,15 @@ public CompletableFuture closeAsync() { try { eventLock_.lock(); pendingCount = pendingCount_.get(); - inFlightPublishes = publishFutures_.toArray(new CompletableFuture[publishFutures_.size()]); + final Set> closingPublishFutures = new HashSet>( + publishFutures_); + for (Map.Entry, Thread> invocation : publisherInvocations_.entrySet()) { + if (invocation.getValue() == Thread.currentThread()) { + closingPublishFutures.remove(invocation.getKey()); + } + } + inFlightPublishes = closingPublishFutures + .toArray(new CompletableFuture[closingPublishFutures.size()]); } finally { eventLock_.unlock(); } @@ -643,11 +641,22 @@ public CompletableFuture closeAsync() { CompletableFuture.allOf(closingPublishes).thenAccept(new Consumer() { @Override public void accept(Void x) { - closed_.set(true); + boolean finalized = false; + try { + eventLock_.lock(); + if (pendingCount_.get() == 0) { + closed_.set(true); + finalized = true; + } + } finally { + eventLock_.unlock(); + } closing_.set(false); newClosingFuture.complete(null); - Context.this.logEvent(ContextEventLogger.EventType.Close, null); + if (finalized) { + Context.this.logEvent(ContextEventLogger.EventType.Close, null); + } } }).exceptionally(new Function() { @Override @@ -677,8 +686,15 @@ public Void apply(Throwable exception) { public Void apply(Void ignoredResult, Throwable exception) { // Same rule as the flush-driven failure path: only close once // no events remain to retry. - if ((exception == null) || (pendingCount_.get() == 0)) { - closed_.set(true); + boolean finalized = false; + try { + eventLock_.lock(); + if (pendingCount_.get() == 0) { + closed_.set(true); + finalized = true; + } + } finally { + eventLock_.unlock(); } closing_.set(false); @@ -686,7 +702,9 @@ public Void apply(Void ignoredResult, Throwable exception) { newClosingFuture.completeExceptionally(exception); } else { newClosingFuture.complete(null); - Context.this.logEvent(ContextEventLogger.EventType.Close, null); + if (finalized) { + Context.this.logEvent(ContextEventLogger.EventType.Close, null); + } } return null; } @@ -695,10 +713,21 @@ public Void apply(Void ignoredResult, Throwable exception) { return newClosingFuture; } - closed_.set(true); + boolean finalized = false; + try { + eventLock_.lock(); + if (pendingCount_.get() == 0) { + closed_.set(true); + finalized = true; + } + } finally { + eventLock_.unlock(); + } closing_.set(false); - Context.this.logEvent(ContextEventLogger.EventType.Close, null); + if (finalized) { + Context.this.logEvent(ContextEventLogger.EventType.Close, null); + } // Nothing was pending here, so no closingFuture_ was published for this // attempt; return directly to avoid picking up a stale future left behind @@ -813,6 +842,12 @@ public Void apply(Throwable throwable) { } } pendingCount_.addAndGet(finalEventCount); + // A publish failing after close finalized must leave the context usable, + // because the restored events are still deliverable. + if (pendingCount_.get() > 0 && (closed_.get() || closing_.get())) { + closed_.set(false); + closing_.set(false); + } } finally { eventLock_.unlock(); } From 0bab8e5e907295b8316cea8d3b187dd071fc3465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 20:08:39 +0000 Subject: [PATCH 68/83] Revert "fix: recheck pending events before close" This reverts commit 457497d17135f14b85cf97e541251fd157316ab2. --- .../main/java/com/absmartly/sdk/Context.java | 45 +++---------------- .../java/com/absmartly/sdk/ContextTest.java | 31 ------------- 2 files changed, 7 insertions(+), 69 deletions(-) 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 b2e1dc5..ba2a629 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -641,22 +641,11 @@ public CompletableFuture closeAsync() { CompletableFuture.allOf(closingPublishes).thenAccept(new Consumer() { @Override public void accept(Void x) { - boolean finalized = false; - try { - eventLock_.lock(); - if (pendingCount_.get() == 0) { - closed_.set(true); - finalized = true; - } - } finally { - eventLock_.unlock(); - } + closed_.set(true); closing_.set(false); newClosingFuture.complete(null); - if (finalized) { - Context.this.logEvent(ContextEventLogger.EventType.Close, null); - } + Context.this.logEvent(ContextEventLogger.EventType.Close, null); } }).exceptionally(new Function() { @Override @@ -686,15 +675,8 @@ public Void apply(Throwable exception) { public Void apply(Void ignoredResult, Throwable exception) { // Same rule as the flush-driven failure path: only close once // no events remain to retry. - boolean finalized = false; - try { - eventLock_.lock(); - if (pendingCount_.get() == 0) { - closed_.set(true); - finalized = true; - } - } finally { - eventLock_.unlock(); + if ((exception == null) || (pendingCount_.get() == 0)) { + closed_.set(true); } closing_.set(false); @@ -702,9 +684,7 @@ public Void apply(Void ignoredResult, Throwable exception) { newClosingFuture.completeExceptionally(exception); } else { newClosingFuture.complete(null); - if (finalized) { - Context.this.logEvent(ContextEventLogger.EventType.Close, null); - } + Context.this.logEvent(ContextEventLogger.EventType.Close, null); } return null; } @@ -713,21 +693,10 @@ public Void apply(Void ignoredResult, Throwable exception) { return newClosingFuture; } - boolean finalized = false; - try { - eventLock_.lock(); - if (pendingCount_.get() == 0) { - closed_.set(true); - finalized = true; - } - } finally { - eventLock_.unlock(); - } + closed_.set(true); closing_.set(false); - if (finalized) { - Context.this.logEvent(ContextEventLogger.EventType.Close, null); - } + Context.this.logEvent(ContextEventLogger.EventType.Close, null); // Nothing was pending here, so no closingFuture_ was published for this // attempt; return directly to avoid picking up a stale future left behind 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 d5ff087..85604a1 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3134,35 +3134,4 @@ void publisherCloseThenFailedFutureReopensContextForRetry() { assertEquals(0, context.getPendingCount()); verify(eventHandler, Mockito.times(2)).publish(any(), any()); } - - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void overlappingPublishDoesNotFinalizeWithRestoredEvents() { - final Context context = createReadyContext(); - final CompletableFuture publisherFutureA = new CompletableFuture<>(); - final AtomicReference> closeResult = new AtomicReference<>(); - final RuntimeException failure = new RuntimeException("publisher B failed"); - when(eventHandler.publish(any(), any())).thenReturn(publisherFutureA).thenAnswer(invocation -> { - closeResult.set(context.closeAsync()); - return failedFuture(failure); - }); - - context.track("goal_a", mapOf("amount", 1)); - final CompletableFuture publishResultA = context.publishAsync(); - context.track("goal_b", mapOf("amount", 2)); - final CompletableFuture publishResultB = context.publishAsync(); - - assertThrows(CompletionException.class, publishResultB::join); - assertFalse(closeResult.get().isDone()); - assertTrue(context.getPendingCount() > 0); - assertFalse(context.isClosed()); - - publisherFutureA.complete(null); - publishResultA.join(); - closeResult.get().join(); - - assertTrue(context.getPendingCount() > 0); - assertFalse(context.isClosed()); - assertFalse(context.isClosed() && context.getPendingCount() > 0); - } } From 14453b7126342fd0477fab1a089791f41acbb0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 20:08:39 +0000 Subject: [PATCH 69/83] Revert "fix: reopen context after publish restoration" This reverts commit 5c94b656e388ecf5e3b75729c682ae995e6fbecc. --- .../main/java/com/absmartly/sdk/Context.java | 6 --- .../java/com/absmartly/sdk/ContextTest.java | 48 ------------------- 2 files changed, 54 deletions(-) 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 ba2a629..2a1b42d 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -811,12 +811,6 @@ public Void apply(Throwable throwable) { } } pendingCount_.addAndGet(finalEventCount); - // A publish failing after close finalized must leave the context usable, - // because the restored events are still deliverable. - if (pendingCount_.get() > 0 && (closed_.get() || closing_.get())) { - closed_.set(false); - closing_.set(false); - } } finally { eventLock_.unlock(); } 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 85604a1..ed7ea97 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3086,52 +3086,4 @@ void publisherCanCloseContextSynchronously() { assertEquals(0, context.getPendingCount()); assertTrue(context.closeAsync().isDone()); } - - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCloseThenThrowReopensContextForRetry() { - final Context context = createReadyContext(); - context.track("goal", mapOf("amount", 1)); - - final RuntimeException failure = new RuntimeException("publisher threw"); - when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { - context.closeAsync(); - throw failure; - }).thenReturn(CompletableFuture.completedFuture(null)); - - final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); - final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); - assertSame(failure, actual.getCause()); - assertTrue(context.getPendingCount() > 0); - assertFalse(context.isClosed()); - assertFalse(context.isClosed() && context.getPendingCount() > 0); - - assertDoesNotThrow(context::publishAsync).join(); - assertEquals(0, context.getPendingCount()); - verify(eventHandler, Mockito.times(2)).publish(any(), any()); - } - - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCloseThenFailedFutureReopensContextForRetry() { - final Context context = createReadyContext(); - context.track("goal", mapOf("amount", 1)); - - final RuntimeException failure = new RuntimeException("publisher failed"); - when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { - context.closeAsync(); - return failedFuture(failure); - }).thenReturn(CompletableFuture.completedFuture(null)); - - final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); - final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); - assertSame(failure, actual.getCause()); - assertTrue(context.getPendingCount() > 0); - assertFalse(context.isClosed()); - assertFalse(context.isClosed() && context.getPendingCount() > 0); - - assertDoesNotThrow(context::publishAsync).join(); - assertEquals(0, context.getPendingCount()); - verify(eventHandler, Mockito.times(2)).publish(any(), any()); - } } From 436ba376fa3d980fce03e0ce0b2095ecd64ef1a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 20:09:05 +0000 Subject: [PATCH 70/83] Revert "fix: allow publishers to close context" This reverts commit 84edd10d1ecc232520b83a62b148bbe049ce9a07. --- .../main/java/com/absmartly/sdk/Context.java | 29 ++----------------- .../java/com/absmartly/sdk/ContextTest.java | 16 ---------- 2 files changed, 3 insertions(+), 42 deletions(-) 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 2a1b42d..1ece6c0 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -618,15 +618,7 @@ public CompletableFuture closeAsync() { try { eventLock_.lock(); pendingCount = pendingCount_.get(); - final Set> closingPublishFutures = new HashSet>( - publishFutures_); - for (Map.Entry, Thread> invocation : publisherInvocations_.entrySet()) { - if (invocation.getValue() == Thread.currentThread()) { - closingPublishFutures.remove(invocation.getKey()); - } - } - inFlightPublishes = closingPublishFutures - .toArray(new CompletableFuture[closingPublishFutures.size()]); + inFlightPublishes = publishFutures_.toArray(new CompletableFuture[publishFutures_.size()]); } finally { eventLock_.unlock(); } @@ -762,6 +754,7 @@ public Void apply(Void ignoredResult, Throwable exception) { } pendingCount_.set(0); + // ContextPublisher callbacks must not synchronously close this context. publishFutures_.add(result); } } finally { @@ -833,22 +826,7 @@ public Void apply(Throwable throwable) { final CompletableFuture publishResult; try { - try { - try { - eventLock_.lock(); - publisherInvocations_.put(result, Thread.currentThread()); - } finally { - eventLock_.unlock(); - } - publishResult = eventHandler_.publish(this, event); - } finally { - try { - eventLock_.lock(); - publisherInvocations_.remove(result); - } finally { - eventLock_.unlock(); - } - } + publishResult = eventHandler_.publish(this, event); } catch (final Throwable throwable) { onPublishFailure.apply(throwable); return callerResult; @@ -1381,7 +1359,6 @@ private Map buildAttributesMap() { private final AtomicReference> closingFuture_ = new AtomicReference>(); private final AtomicReference> refreshFuture_ = new AtomicReference>(); private final Set> publishFutures_ = new HashSet>(); - private final Map, Thread> publisherInvocations_ = new HashMap, Thread>(); private final ReentrantLock timeoutLock_ = new ReentrantLock(); private volatile ScheduledFuture timeout_ = null; 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 ed7ea97..d5fad49 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -3070,20 +3070,4 @@ void cancellingPublishResultDoesNotSettleInternalPublish() { retryPublish.join(); assertEquals(0, context.getPendingCount()); } - - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCanCloseContextSynchronously() { - final Context context = createReadyContext(); - context.track("goal", mapOf("amount", 1)); - - when(eventHandler.publish(any(), any())).thenAnswer(invocation -> context.closeAsync()); - - final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); - publishResult.join(); - - assertTrue(context.isClosed()); - assertEquals(0, context.getPendingCount()); - assertTrue(context.closeAsync().isDone()); - } } From ba60895fd254ef3309c69dbf81c97eb3801b9a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 21:09:07 +0000 Subject: [PATCH 71/83] fix: allow publishers to close context --- .../main/java/com/absmartly/sdk/Context.java | 61 +------------------ 1 file changed, 2 insertions(+), 59 deletions(-) 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 1ece6c0..045e2b4 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -613,24 +613,11 @@ public CompletableFuture closeAsync() { if (closing_.compareAndSet(false, true)) { clearRefreshTimer(); - final int pendingCount; - final CompletableFuture[] inFlightPublishes; - try { - eventLock_.lock(); - pendingCount = pendingCount_.get(); - inFlightPublishes = publishFutures_.toArray(new CompletableFuture[publishFutures_.size()]); - } finally { - eventLock_.unlock(); - } - - if (pendingCount > 0) { + if (pendingCount_.get() > 0) { final CompletableFuture newClosingFuture = new CompletableFuture(); closingFuture_.set(newClosingFuture); - final CompletableFuture[] closingPublishes = Arrays.copyOf(inFlightPublishes, - inFlightPublishes.length + 1); - closingPublishes[inFlightPublishes.length] = flush(); - CompletableFuture.allOf(closingPublishes).thenAccept(new Consumer() { + flush().thenAccept(new Consumer() { @Override public void accept(Void x) { closed_.set(true); @@ -656,35 +643,6 @@ public Void apply(Throwable exception) { return newClosingFuture; } else { - // The queue is empty here, but a publish started by a concurrent flush() - // may still be in flight and could restore events on failure. - if (inFlightPublishes.length > 0) { - final CompletableFuture newClosingFuture = new CompletableFuture(); - closingFuture_.set(newClosingFuture); - - CompletableFuture.allOf(inFlightPublishes).handle(new BiFunction() { - @Override - public Void apply(Void ignoredResult, Throwable exception) { - // Same rule as the flush-driven failure path: only close once - // no events remain to retry. - if ((exception == null) || (pendingCount_.get() == 0)) { - closed_.set(true); - } - closing_.set(false); - - if (exception != null) { - newClosingFuture.completeExceptionally(exception); - } else { - newClosingFuture.complete(null); - Context.this.logEvent(ContextEventLogger.EventType.Close, null); - } - return null; - } - }); - - return newClosingFuture; - } - closed_.set(true); closing_.set(false); @@ -754,8 +712,6 @@ public Void apply(Void ignoredResult, Throwable exception) { } pendingCount_.set(0); - // ContextPublisher callbacks must not synchronously close this context. - publishFutures_.add(result); } } finally { eventLock_.unlock(); @@ -814,12 +770,6 @@ public Void apply(Throwable throwable) { // diagnostic logger failures must not affect publish accounting } result.completeExceptionally(throwable); - try { - eventLock_.lock(); - publishFutures_.remove(result); - } finally { - eventLock_.unlock(); - } return null; } }; @@ -843,12 +793,6 @@ public void run() { // diagnostic logger failures must not affect publish accounting } finally { result.complete(null); - try { - eventLock_.lock(); - publishFutures_.remove(result); - } finally { - eventLock_.unlock(); - } } } }); @@ -1358,7 +1302,6 @@ private Map buildAttributesMap() { private final AtomicReference> readyFuture_ = new AtomicReference>(); private final AtomicReference> closingFuture_ = new AtomicReference>(); private final AtomicReference> refreshFuture_ = new AtomicReference>(); - private final Set> publishFutures_ = new HashSet>(); private final ReentrantLock timeoutLock_ = new ReentrantLock(); private volatile ScheduledFuture timeout_ = null; From 056e3bc46943732b70f23363ece56784a74ec2ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 21:09:23 +0000 Subject: [PATCH 72/83] test: cover publishers closing context --- .../java/com/absmartly/sdk/ContextTest.java | 184 ++++++------------ 1 file changed, 57 insertions(+), 127 deletions(-) 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 d5fad49..177d2a4 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -22,8 +22,9 @@ import java.util.Map; import java.util.Set; import java.util.TreeMap; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -2838,77 +2839,77 @@ void closeAsyncRetryAfterFailedPublishSucceeds() { @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void closeAsyncAwaitsPublishStartedBeforeCloseAndRestoresEventsOnFailure() { + void publisherCanCloseContextInline() { final Context context = createReadyContext(); + final AtomicReference> closeResult = new AtomicReference<>(); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + final CompletableFuture result = context.closeAsync(); + closeResult.set(result); + return result; + }); + context.track("goal", mapOf("amount", 1)); - context.track("goal_a", mapOf("amount", 1)); - assertEquals(1, context.getPendingCount()); - - final CompletableFuture publishFuture1 = new CompletableFuture<>(); - when(eventHandler.publish(any(), any())).thenReturn(publishFuture1); - - // publishAsync() flushes the queue immediately, so pendingCount_ is already 0 - // by the time closeAsync() runs below, with the publisher future still open. final CompletableFuture publishResult = context.publishAsync(); - assertEquals(0, context.getPendingCount()); - - final CompletableFuture closeFuture = context.closeAsync(); - assertFalse(closeFuture.isDone()); - assertFalse(context.isClosed()); - - final Exception failure = new Exception("publish failed"); - publishFuture1.completeExceptionally(failure); - - assertThrows(CompletionException.class, publishResult::join); - assertThrows(CompletionException.class, closeFuture::join); - - assertFalse(context.isClosed()); - assertTrue(context.getPendingCount() > 0); - - final CompletableFuture publishFuture2 = new CompletableFuture<>(); - when(eventHandler.publish(any(), any())).thenReturn(publishFuture2); - - final CompletableFuture retryPublish = assertDoesNotThrow(context::publishAsync); - assertEquals(0, context.getPendingCount()); - - publishFuture2.complete(null); - retryPublish.join(); - - final CompletableFuture closeFuture2 = context.closeAsync(); - closeFuture2.join(); + publishResult.join(); + closeResult.get().join(); assertTrue(context.isClosed()); assertEquals(0, context.getPendingCount()); - - verify(eventHandler, Mockito.times(2)).publish(any(), any()); } @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void closeAsyncCompletesWhenAwaitedPublishSucceeds() { - final Context context = createReadyContext(); - - context.track("goal_a", mapOf("amount", 1)); - assertEquals(1, context.getPendingCount()); - - final CompletableFuture publishFuture = new CompletableFuture<>(); - when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + void publisherCanCloseContextFromWorkerThread() { + final Context context = createReadyContext(); + final AtomicReference> closeResult = new AtomicReference<>(); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + final CompletableFuture transportResult = new CompletableFuture<>(); + new Thread(() -> { + final CompletableFuture result = context.closeAsync(); + closeResult.set(result); + result.join(); + transportResult.complete(null); + }).start(); + return transportResult; + }); + context.track("goal", mapOf("amount", 1)); final CompletableFuture publishResult = context.publishAsync(); - assertEquals(0, context.getPendingCount()); - - final CompletableFuture closeFuture = context.closeAsync(); - assertFalse(closeFuture.isDone()); - assertFalse(context.isClosed()); - - publishFuture.complete(null); publishResult.join(); - closeFuture.join(); + closeResult.get().join(); assertTrue(context.isClosed()); assertEquals(0, context.getPendingCount()); + } - verify(eventHandler, Mockito.times(1)).publish(any(), any()); + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publisherCanCloseContextFromExecutorCallback() { + final Context context = createReadyContext(); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + final AtomicReference> closeResult = new AtomicReference<>(); + try { + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + final CompletableFuture transportResult = new CompletableFuture<>(); + executor.execute(() -> { + final CompletableFuture result = context.closeAsync(); + closeResult.set(result); + result.join(); + transportResult.complete(null); + }); + return transportResult; + }); + context.track("goal", mapOf("amount", 1)); + + final CompletableFuture publishResult = context.publishAsync(); + publishResult.join(); + closeResult.get().join(); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + } finally { + executor.shutdownNow(); + } } @Test @@ -2933,40 +2934,6 @@ void publishSucceedsWhenLoggerThrowsOnPublishEvent() { verify(eventHandler, Mockito.times(1)).publish(any(), any()); } - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void closeAwaitsPublishWhileFailureLoggerIsBlocked() throws Exception { - final Context context = createReadyContext(); - context.track("goal", mapOf("amount", 1)); - - final CompletableFuture publisherFuture = new CompletableFuture<>(); - when(eventHandler.publish(any(), any())).thenReturn(publisherFuture) - .thenReturn(CompletableFuture.completedFuture(null)); - final CountDownLatch loggerEntered = new CountDownLatch(1); - final CountDownLatch releaseLogger = new CountDownLatch(1); - Mockito.doAnswer(invocation -> { - loggerEntered.countDown(); - releaseLogger.await(); - return null; - }).when(eventLogger).handleEvent(any(), eq(ContextEventLogger.EventType.Error), any()); - - final CompletableFuture publishResult = context.publishAsync(); - final RuntimeException failure = new RuntimeException("publish failed"); - final Thread failureThread = new Thread(() -> publisherFuture.completeExceptionally(failure)); - failureThread.start(); - loggerEntered.await(); - - final CompletableFuture closeResult = context.closeAsync(); - assertFalse(closeResult.isDone()); - assertFalse(context.isClosed()); - - releaseLogger.countDown(); - failureThread.join(); - assertThrows(CompletionException.class, publishResult::join); - assertThrows(CompletionException.class, closeResult::join); - assertFalse(context.isClosed() && context.getPendingCount() > 0); - } - @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void synchronousPublisherFailureSettlesWhenErrorLoggerThrows() { @@ -2990,37 +2957,6 @@ void synchronousPublisherFailureSettlesWhenErrorLoggerThrows() { assertTrue(context.getPendingCount() > 0); } - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void closeAwaitsEveryOverlappingPublishBeforeFinalizing() { - final Context context = createReadyContext(); - final CompletableFuture publisherFutureA = new CompletableFuture<>(); - final CompletableFuture publisherFutureB = new CompletableFuture<>(); - when(eventHandler.publish(any(), any())).thenReturn(publisherFutureA, publisherFutureB); - - context.track("goal_a", mapOf("amount", 1)); - final CompletableFuture publishResultA = context.publishAsync(); - context.track("goal_b", mapOf("amount", 2)); - final CompletableFuture publishResultB = context.publishAsync(); - - publisherFutureB.complete(null); - publishResultB.join(); - - final CompletableFuture closeFuture = context.closeAsync(); - assertFalse(closeFuture.isDone()); - assertFalse(context.isClosed()); - - publisherFutureA.completeExceptionally(new Exception("publish A failed")); - assertThrows(CompletionException.class, publishResultA::join); - assertThrows(CompletionException.class, closeFuture::join); - assertFalse(context.isClosed()); - assertTrue(context.getPendingCount() > 0); - - when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); - assertDoesNotThrow(context::publishAsync).join(); - assertEquals(0, context.getPendingCount()); - } - @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) void synchronousPublisherFailureRestoresEventsAndDoesNotHangClose() { @@ -3044,7 +2980,7 @@ void synchronousPublisherFailureRestoresEventsAndDoesNotHangClose() { @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void cancellingPublishResultDoesNotSettleInternalPublish() { + void cancellingPublishResultDoesNotDisturbPublishAccounting() { final Context context = createReadyContext(); context.track("goal", mapOf("amount", 1)); @@ -3055,16 +2991,10 @@ void cancellingPublishResultDoesNotSettleInternalPublish() { final CompletableFuture publishResult = context.publishAsync(); assertTrue(publishResult.cancel(false)); - final CompletableFuture closeResult = context.closeAsync(); - assertFalse(closeResult.isDone()); - assertFalse(context.isClosed()); - final RuntimeException failure = new RuntimeException("publish failed"); publisherFuture.completeExceptionally(failure); - assertThrows(CompletionException.class, closeResult::join); assertTrue(context.getPendingCount() > 0); - assertFalse(context.isClosed() && context.getPendingCount() > 0); final CompletableFuture retryPublish = assertDoesNotThrow(context::publishAsync); retryPublish.join(); From 78802a6444db04e67504a8d630950db0610b894a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 31 Aug 2026 21:35:13 +0000 Subject: [PATCH 73/83] test: bound publisher close waits --- .../java/com/absmartly/sdk/ContextTest.java | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) 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 177d2a4..257471f 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -2839,7 +2839,7 @@ void closeAsyncRetryAfterFailedPublishSucceeds() { @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCanCloseContextInline() { + void publisherCanCloseContextInline() throws Exception { final Context context = createReadyContext(); final AtomicReference> closeResult = new AtomicReference<>(); when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { @@ -2850,8 +2850,8 @@ void publisherCanCloseContextInline() { context.track("goal", mapOf("amount", 1)); final CompletableFuture publishResult = context.publishAsync(); - publishResult.join(); - closeResult.get().join(); + publishResult.get(2, TimeUnit.SECONDS); + closeResult.get().get(2, TimeUnit.SECONDS); assertTrue(context.isClosed()); assertEquals(0, context.getPendingCount()); @@ -2859,24 +2859,30 @@ void publisherCanCloseContextInline() { @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCanCloseContextFromWorkerThread() { + void publisherCanCloseContextFromWorkerThread() throws Exception { final Context context = createReadyContext(); final AtomicReference> closeResult = new AtomicReference<>(); when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { final CompletableFuture transportResult = new CompletableFuture<>(); - new Thread(() -> { - final CompletableFuture result = context.closeAsync(); - closeResult.set(result); - result.join(); - transportResult.complete(null); - }).start(); + final Thread worker = new Thread(() -> { + try { + final CompletableFuture result = context.closeAsync(); + closeResult.set(result); + result.get(2, TimeUnit.SECONDS); + transportResult.complete(null); + } catch (Exception exception) { + transportResult.completeExceptionally(exception); + } + }); + worker.setDaemon(true); + worker.start(); return transportResult; }); context.track("goal", mapOf("amount", 1)); final CompletableFuture publishResult = context.publishAsync(); - publishResult.join(); - closeResult.get().join(); + publishResult.get(2, TimeUnit.SECONDS); + closeResult.get().get(2, TimeUnit.SECONDS); assertTrue(context.isClosed()); assertEquals(0, context.getPendingCount()); @@ -2884,26 +2890,34 @@ void publisherCanCloseContextFromWorkerThread() { @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - void publisherCanCloseContextFromExecutorCallback() { + void publisherCanCloseContextFromExecutorCallback() throws Exception { final Context context = createReadyContext(); - final ExecutorService executor = Executors.newSingleThreadExecutor(); + final ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> { + final Thread thread = new Thread(runnable); + thread.setDaemon(true); + return thread; + }); final AtomicReference> closeResult = new AtomicReference<>(); try { when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { final CompletableFuture transportResult = new CompletableFuture<>(); executor.execute(() -> { - final CompletableFuture result = context.closeAsync(); - closeResult.set(result); - result.join(); - transportResult.complete(null); + try { + final CompletableFuture result = context.closeAsync(); + closeResult.set(result); + result.get(2, TimeUnit.SECONDS); + transportResult.complete(null); + } catch (Exception exception) { + transportResult.completeExceptionally(exception); + } }); return transportResult; }); context.track("goal", mapOf("amount", 1)); final CompletableFuture publishResult = context.publishAsync(); - publishResult.join(); - closeResult.get().join(); + publishResult.get(2, TimeUnit.SECONDS); + closeResult.get().get(2, TimeUnit.SECONDS); assertTrue(context.isClosed()); assertEquals(0, context.getPendingCount()); From bcaee35c68aaff57f61f75c3b6c76cfd5cf256f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 10:26:41 +0000 Subject: [PATCH 74/83] fix: restore ABSmartly API names --- .../java/com/absmartly/sdk/ABSmartly.java | 179 ++++++++++++++++- .../com/absmartly/sdk/ABSmartlyConfig.java | 91 ++++++--- .../java/com/absmartly/sdk/ABsmartly.java | 184 ------------------ .../com/absmartly/sdk/ABsmartlyConfig.java | 106 ---------- .../main/java/com/absmartly/sdk/Client.java | 2 +- .../main/java/com/absmartly/sdk/Context.java | 6 +- .../absmartly/sdk/deprecated/ABSmartly.java | 19 -- .../sdk/deprecated/ABSmartlyConfig.java | 76 -------- 8 files changed, 245 insertions(+), 418 deletions(-) delete mode 100644 core-api/src/main/java/com/absmartly/sdk/ABsmartly.java delete mode 100644 core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java delete mode 100644 core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java delete mode 100644 core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java 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 36f814a..d0b6721 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java @@ -1,17 +1,184 @@ package com.absmartly.sdk; +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java8.util.concurrent.CompletableFuture; + import javax.annotation.Nonnull; -/** - * @deprecated Use {@link com.absmartly.sdk.ABsmartly} instead. - */ -@Deprecated -public class ABSmartly extends ABsmartly { +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.ContextData; + +public class ABSmartly implements Closeable { public static ABSmartly create(@Nonnull ABSmartlyConfig config) { return new ABSmartly(config); } + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String endpoint; + private String apiKey; + private String application; + private String environment; + private ContextEventLogger eventLogger; + + Builder() {} + + public Builder endpoint(@Nonnull String endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder apiKey(@Nonnull String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder application(@Nonnull String application) { + this.application = application; + return this; + } + + public Builder environment(@Nonnull String environment) { + this.environment = environment; + return this; + } + + public Builder eventLogger(@Nonnull ContextEventLogger eventLogger) { + this.eventLogger = eventLogger; + return this; + } + + public ABSmartly build() { + if (endpoint == null) + throw new IllegalArgumentException("endpoint is required"); + if (apiKey == null) + throw new IllegalArgumentException("apiKey is required"); + if (application == null) + throw new IllegalArgumentException("application is required"); + if (environment == null) + throw new IllegalArgumentException("environment is required"); + + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint(endpoint) + .setAPIKey(apiKey) + .setApplication(application) + .setEnvironment(environment); + + final ABSmartlyConfig config = ABSmartlyConfig.create() + .setClient(Client.create(clientConfig)); + + if (eventLogger != null) { + config.setContextEventLogger(eventLogger); + } + + return create(config); + } + } + private ABSmartly(@Nonnull ABSmartlyConfig config) { - super(config); + contextDataProvider_ = config.getContextDataProvider(); + contextEventHandler_ = config.getContextPublisher(); + contextEventLogger_ = config.getContextEventLogger(); + variableParser_ = config.getVariableParser(); + audienceDeserializer_ = config.getAudienceDeserializer(); + scheduler_ = config.getScheduler(); + ownsScheduler_ = scheduler_ == null; + + if ((contextDataProvider_ == null) || (contextEventHandler_ == null)) { + client_ = config.getClient(); + if (client_ == null) { + throw new IllegalArgumentException("Missing Client instance"); + } + + if (contextDataProvider_ == null) { + contextDataProvider_ = new DefaultContextDataProvider(client_); + } + + if (contextEventHandler_ == null) { + contextEventHandler_ = new DefaultContextPublisher(client_); + } + } + + if (variableParser_ == null) { + variableParser_ = new DefaultVariableParser(); + } + + if (audienceDeserializer_ == null) { + audienceDeserializer_ = new DefaultAudienceDeserializer(); + } + + if (scheduler_ == null) { + scheduler_ = new ScheduledThreadPoolExecutor(1); + } + } + + public Context createContext(@Nonnull ContextConfig config) { + checkNotClosed(); + return Context.create(Clock.systemUTC(), config, scheduler_, contextDataProvider_.getContextData(), + contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, + new AudienceMatcher(audienceDeserializer_)); + } + + public Context createContextWith(@Nonnull ContextConfig config, ContextData data) { + checkNotClosed(); + return Context.create(Clock.systemUTC(), config, scheduler_, CompletableFuture.completedFuture(data), + contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, + new AudienceMatcher(audienceDeserializer_)); } + + public CompletableFuture getContextData() { + checkNotClosed(); + return contextDataProvider_.getContextData(); + } + + private void checkNotClosed() { + if (closed_) { + throw new IllegalStateException("ABSmartly instance is closed"); + } + } + + @Override + public void close() throws IOException { + if (closed_) { + return; + } + closed_ = true; + + try { + if (client_ != null) { + client_.close(); + } + } finally { + // A caller-supplied scheduler remains under caller ownership and must be left running. + if ((scheduler_ != null) && ownsScheduler_) { + scheduler_.shutdown(); + try { + if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) { + scheduler_.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + scheduler_.shutdownNow(); + } + } + } + } + + private volatile boolean closed_; + private Client client_; + private ContextDataProvider contextDataProvider_; + private ContextPublisher contextEventHandler_; + private ContextEventLogger contextEventLogger_; + private VariableParser variableParser_; + + private AudienceDeserializer audienceDeserializer_; + private ScheduledExecutorService scheduler_; + private final boolean ownsScheduler_; } 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 17027dc..f5c81b2 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java @@ -4,58 +4,103 @@ import javax.annotation.Nonnull; -/** - * @deprecated Use {@link com.absmartly.sdk.ABsmartlyConfig} instead. - */ -@Deprecated -public class ABSmartlyConfig extends ABsmartlyConfig { +public class ABSmartlyConfig { public static ABSmartlyConfig create() { return new ABSmartlyConfig(); } - private ABSmartlyConfig() { - super(); + private ABSmartlyConfig() {} + + public ContextDataProvider getContextDataProvider() { + return contextDataProvider_; } - @Override - public ABSmartlyConfig setClient(Client client) { - super.setClient(client); + public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) { + contextDataProvider_ = contextDataProvider; return this; } - @Override - public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) { - super.setContextDataProvider(contextDataProvider); + public ContextPublisher getContextPublisher() { + return contextEventHandler_; + } + + public ABSmartlyConfig setContextPublisher(@Nonnull final ContextPublisher contextPublisher) { + contextEventHandler_ = contextPublisher; return this; } - @Override + /** + * @deprecated Use {@link #getContextPublisher()} instead. + */ + @Deprecated + public ContextEventHandler getContextEventHandler() { + if (contextEventHandler_ instanceof ContextEventHandler) { + return (ContextEventHandler) contextEventHandler_; + } + return null; + } + + /** + * @deprecated Use {@link #setContextPublisher(ContextPublisher)} instead. + */ + @Deprecated public ABSmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { - super.setContextEventHandler(contextEventHandler); + contextEventHandler_ = contextEventHandler; return this; } - @Override + public VariableParser getVariableParser() { + return variableParser_; + } + public ABSmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) { - super.setVariableParser(variableParser); + variableParser_ = variableParser; return this; } - @Override + public ScheduledExecutorService getScheduler() { + return scheduler_; + } + public ABSmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) { - super.setScheduler(scheduler); + scheduler_ = scheduler; return this; } - @Override + public ContextEventLogger getContextEventLogger() { + return contextEventLogger_; + } + public ABSmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) { - super.setContextEventLogger(logger); + contextEventLogger_ = logger; return this; } - @Override + public AudienceDeserializer getAudienceDeserializer() { + return audienceDeserializer_; + } + public ABSmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) { - super.setAudienceDeserializer(audienceDeserializer); + audienceDeserializer_ = audienceDeserializer; + return this; + } + + public Client getClient() { + return client_; + } + + public ABSmartlyConfig setClient(Client client) { + client_ = client; return this; } + + private ContextDataProvider contextDataProvider_; + private ContextPublisher contextEventHandler_; + + private ContextEventLogger contextEventLogger_; + private VariableParser variableParser_; + + private AudienceDeserializer audienceDeserializer_; + private ScheduledExecutorService scheduler_; + private Client client_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java b/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java deleted file mode 100644 index ddf1fd7..0000000 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartly.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.absmartly.sdk; - -import java.io.Closeable; -import java.io.IOException; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java8.util.concurrent.CompletableFuture; - -import javax.annotation.Nonnull; - -import com.absmartly.sdk.java.time.Clock; -import com.absmartly.sdk.json.ContextData; - -public class ABsmartly implements Closeable { - public static ABsmartly create(@Nonnull ABsmartlyConfig config) { - return new ABsmartly(config); - } - - public static Builder builder() { - return new Builder(); - } - - public static class Builder { - private String endpoint; - private String apiKey; - private String application; - private String environment; - private ContextEventLogger eventLogger; - - Builder() {} - - public Builder endpoint(@Nonnull String endpoint) { - this.endpoint = endpoint; - return this; - } - - public Builder apiKey(@Nonnull String apiKey) { - this.apiKey = apiKey; - return this; - } - - public Builder application(@Nonnull String application) { - this.application = application; - return this; - } - - public Builder environment(@Nonnull String environment) { - this.environment = environment; - return this; - } - - public Builder eventLogger(@Nonnull ContextEventLogger eventLogger) { - this.eventLogger = eventLogger; - return this; - } - - public ABsmartly build() { - if (endpoint == null) - throw new IllegalArgumentException("endpoint is required"); - if (apiKey == null) - throw new IllegalArgumentException("apiKey is required"); - if (application == null) - throw new IllegalArgumentException("application is required"); - if (environment == null) - throw new IllegalArgumentException("environment is required"); - - final ClientConfig clientConfig = ClientConfig.create() - .setEndpoint(endpoint) - .setAPIKey(apiKey) - .setApplication(application) - .setEnvironment(environment); - - final ABsmartlyConfig config = ABsmartlyConfig.create() - .setClient(Client.create(clientConfig)); - - if (eventLogger != null) { - config.setContextEventLogger(eventLogger); - } - - return create(config); - } - } - - protected ABsmartly(@Nonnull ABsmartlyConfig config) { - contextDataProvider_ = config.getContextDataProvider(); - contextEventHandler_ = config.getContextPublisher(); - contextEventLogger_ = config.getContextEventLogger(); - variableParser_ = config.getVariableParser(); - audienceDeserializer_ = config.getAudienceDeserializer(); - scheduler_ = config.getScheduler(); - ownsScheduler_ = scheduler_ == null; - - if ((contextDataProvider_ == null) || (contextEventHandler_ == null)) { - client_ = config.getClient(); - if (client_ == null) { - throw new IllegalArgumentException("Missing Client instance"); - } - - if (contextDataProvider_ == null) { - contextDataProvider_ = new DefaultContextDataProvider(client_); - } - - if (contextEventHandler_ == null) { - contextEventHandler_ = new DefaultContextPublisher(client_); - } - } - - if (variableParser_ == null) { - variableParser_ = new DefaultVariableParser(); - } - - if (audienceDeserializer_ == null) { - audienceDeserializer_ = new DefaultAudienceDeserializer(); - } - - if (scheduler_ == null) { - scheduler_ = new ScheduledThreadPoolExecutor(1); - } - } - - public Context createContext(@Nonnull ContextConfig config) { - checkNotClosed(); - return Context.create(Clock.systemUTC(), config, scheduler_, contextDataProvider_.getContextData(), - contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, - new AudienceMatcher(audienceDeserializer_)); - } - - public Context createContextWith(@Nonnull ContextConfig config, ContextData data) { - checkNotClosed(); - return Context.create(Clock.systemUTC(), config, scheduler_, CompletableFuture.completedFuture(data), - contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, - new AudienceMatcher(audienceDeserializer_)); - } - - public CompletableFuture getContextData() { - checkNotClosed(); - return contextDataProvider_.getContextData(); - } - - private void checkNotClosed() { - if (closed_) { - throw new IllegalStateException("ABsmartly instance is closed"); - } - } - - @Override - public void close() throws IOException { - if (closed_) { - return; - } - closed_ = true; - - try { - if (client_ != null) { - client_.close(); - } - } finally { - // A caller-supplied scheduler remains under caller ownership and must be left running. - if ((scheduler_ != null) && ownsScheduler_) { - scheduler_.shutdown(); - try { - if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) { - scheduler_.shutdownNow(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - scheduler_.shutdownNow(); - } - } - } - } - - private volatile boolean closed_; - private Client client_; - private ContextDataProvider contextDataProvider_; - private ContextPublisher contextEventHandler_; - private ContextEventLogger contextEventLogger_; - private VariableParser variableParser_; - - private AudienceDeserializer audienceDeserializer_; - private ScheduledExecutorService scheduler_; - private final boolean ownsScheduler_; -} diff --git a/core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java b/core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java deleted file mode 100644 index d7ad9d5..0000000 --- a/core-api/src/main/java/com/absmartly/sdk/ABsmartlyConfig.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.absmartly.sdk; - -import java.util.concurrent.ScheduledExecutorService; - -import javax.annotation.Nonnull; - -public class ABsmartlyConfig { - public static ABsmartlyConfig create() { - return new ABsmartlyConfig(); - } - - protected ABsmartlyConfig() {} - - public ContextDataProvider getContextDataProvider() { - return contextDataProvider_; - } - - public ABsmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) { - contextDataProvider_ = contextDataProvider; - return this; - } - - public ContextPublisher getContextPublisher() { - return contextEventHandler_; - } - - public ABsmartlyConfig setContextPublisher(@Nonnull final ContextPublisher contextPublisher) { - contextEventHandler_ = contextPublisher; - return this; - } - - /** - * @deprecated Use {@link #getContextPublisher()} instead. - */ - @Deprecated - public ContextEventHandler getContextEventHandler() { - if (contextEventHandler_ instanceof ContextEventHandler) { - return (ContextEventHandler) contextEventHandler_; - } - return null; - } - - /** - * @deprecated Use {@link #setContextPublisher(ContextPublisher)} instead. - */ - @Deprecated - public ABsmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { - contextEventHandler_ = contextEventHandler; - return this; - } - - public VariableParser getVariableParser() { - return variableParser_; - } - - public ABsmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) { - variableParser_ = variableParser; - return this; - } - - public ScheduledExecutorService getScheduler() { - return scheduler_; - } - - public ABsmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) { - scheduler_ = scheduler; - return this; - } - - public ContextEventLogger getContextEventLogger() { - return contextEventLogger_; - } - - public ABsmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) { - contextEventLogger_ = logger; - return this; - } - - public AudienceDeserializer getAudienceDeserializer() { - return audienceDeserializer_; - } - - public ABsmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) { - audienceDeserializer_ = audienceDeserializer; - return this; - } - - public Client getClient() { - return client_; - } - - public ABsmartlyConfig setClient(Client client) { - client_ = client; - return this; - } - - private ContextDataProvider contextDataProvider_; - private ContextPublisher contextEventHandler_; - - private ContextEventLogger contextEventLogger_; - private VariableParser variableParser_; - - private AudienceDeserializer audienceDeserializer_; - private ScheduledExecutorService scheduler_; - private Client client_; -} 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 dbf88e0..a10c713 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Client.java +++ b/core-api/src/main/java/com/absmartly/sdk/Client.java @@ -38,7 +38,7 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H if (!endpoint.startsWith("https://")) { if (endpoint.startsWith("http://")) { - log.warn("ABsmartly SDK endpoint is not using HTTPS. API keys will be transmitted in plaintext: {}", + log.warn("ABSmartly SDK endpoint is not using HTTPS. API keys will be transmitted in plaintext: {}", endpoint); } else { throw new IllegalArgumentException("Endpoint must use http:// or https:// protocol: " + endpoint); 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 045e2b4..c1d98e3 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -818,15 +818,15 @@ public void run() { private void checkNotClosed() { if (closed_.get()) { - throw new IllegalStateException("ABsmartly Context is finalized."); + throw new IllegalStateException("ABSmartly Context is finalized."); } else if (closing_.get()) { - throw new IllegalStateException("ABsmartly Context is closing."); + throw new IllegalStateException("ABSmartly Context is closing."); } } private void checkReady(final boolean expectNotClosed) { if (!isReady()) { - throw new IllegalStateException("ABsmartly Context is not yet ready."); + throw new IllegalStateException("ABSmartly Context is not yet ready."); } else if (expectNotClosed) { checkNotClosed(); } diff --git a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java deleted file mode 100644 index 4135dde..0000000 --- a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.absmartly.sdk.deprecated; - -import javax.annotation.Nonnull; - -import com.absmartly.sdk.ABsmartly; - -/** - * @deprecated Use {@link com.absmartly.sdk.ABsmartly} instead. - */ -@Deprecated -public class ABSmartly extends ABsmartly { - public static ABSmartly create(@Nonnull ABSmartlyConfig config) { - return new ABSmartly(config); - } - - protected ABSmartly(@Nonnull ABSmartlyConfig config) { - super(config); - } -} diff --git a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java b/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java deleted file mode 100644 index a497c4f..0000000 --- a/core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartlyConfig.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.absmartly.sdk.deprecated; - -import java.util.concurrent.ScheduledExecutorService; - -import javax.annotation.Nonnull; - -import com.absmartly.sdk.ABsmartlyConfig; -import com.absmartly.sdk.AudienceDeserializer; -import com.absmartly.sdk.Client; -import com.absmartly.sdk.ContextDataProvider; -import com.absmartly.sdk.ContextEventHandler; -import com.absmartly.sdk.ContextEventLogger; -import com.absmartly.sdk.ContextPublisher; -import com.absmartly.sdk.VariableParser; - -/** - * @deprecated Use {@link com.absmartly.sdk.ABsmartlyConfig} instead. - */ -@Deprecated -public class ABSmartlyConfig extends ABsmartlyConfig { - public static ABSmartlyConfig create() { - return new ABSmartlyConfig(); - } - - protected ABSmartlyConfig() { - super(); - } - - @Override - public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) { - super.setContextDataProvider(contextDataProvider); - return this; - } - - @Override - public ABSmartlyConfig setContextPublisher(@Nonnull final ContextPublisher contextPublisher) { - super.setContextPublisher(contextPublisher); - return this; - } - - @Override - public ABSmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { - super.setContextEventHandler(contextEventHandler); - return this; - } - - @Override - public ABSmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) { - super.setVariableParser(variableParser); - return this; - } - - @Override - public ABSmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) { - super.setScheduler(scheduler); - return this; - } - - @Override - public ABSmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) { - super.setContextEventLogger(logger); - return this; - } - - @Override - public ABSmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) { - super.setAudienceDeserializer(audienceDeserializer); - return this; - } - - @Override - public ABSmartlyConfig setClient(Client client) { - super.setClient(client); - return this; - } -} From 2fdabf658bfdce82d64a9f491dff215148c09987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 10:28:58 +0000 Subject: [PATCH 75/83] test: restore ABSmartly test names --- ...nfigTest.java => ABSmartlyConfigTest.java} | 14 +++--- ...rtlyFixTest.java => ABSmartlyFixTest.java} | 18 +++---- ...{ABsmartlyTest.java => ABSmartlyTest.java} | 48 +++++++++---------- .../java/com/absmartly/sdk/ContextTest.java | 6 +-- .../sdk/LocalHttpServerIntegrationTest.java | 4 +- 5 files changed, 45 insertions(+), 45 deletions(-) rename core-api/src/test/java/com/absmartly/sdk/{ABsmartlyConfigTest.java => ABSmartlyConfigTest.java} (82%) rename core-api/src/test/java/com/absmartly/sdk/{ABsmartlyFixTest.java => ABSmartlyFixTest.java} (76%) rename core-api/src/test/java/com/absmartly/sdk/{ABsmartlyTest.java => ABSmartlyTest.java} (92%) diff --git a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyConfigTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyConfigTest.java similarity index 82% rename from core-api/src/test/java/com/absmartly/sdk/ABsmartlyConfigTest.java rename to core-api/src/test/java/com/absmartly/sdk/ABSmartlyConfigTest.java index 50c1150..18d6f36 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyConfigTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyConfigTest.java @@ -7,39 +7,39 @@ import org.junit.jupiter.api.Test; -class ABsmartlyConfigTest extends TestUtils { +class ABSmartlyConfigTest extends TestUtils { @Test void setContextDataProvider() { final ContextDataProvider provider = mock(ContextDataProvider.class); - final ABsmartlyConfig config = ABsmartlyConfig.create().setContextDataProvider(provider); + final ABSmartlyConfig config = ABSmartlyConfig.create().setContextDataProvider(provider); assertSame(provider, config.getContextDataProvider()); } @Test void setContextEventHandler() { final ContextEventHandler handler = mock(ContextEventHandler.class); - final ABsmartlyConfig config = ABsmartlyConfig.create().setContextEventHandler(handler); + final ABSmartlyConfig config = ABSmartlyConfig.create().setContextEventHandler(handler); assertSame(handler, config.getContextEventHandler()); } @Test void setVariableParser() { final VariableParser variableParser = mock(VariableParser.class); - final ABsmartlyConfig config = ABsmartlyConfig.create().setVariableParser(variableParser); + final ABSmartlyConfig config = ABSmartlyConfig.create().setVariableParser(variableParser); assertSame(variableParser, config.getVariableParser()); } @Test void setScheduler() { final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); - final ABsmartlyConfig config = ABsmartlyConfig.create().setScheduler(scheduler); + final ABSmartlyConfig config = ABSmartlyConfig.create().setScheduler(scheduler); assertSame(scheduler, config.getScheduler()); } @Test void setContextEventLogger() { final ContextEventLogger logger = mock(ContextEventLogger.class); - final ABsmartlyConfig config = ABsmartlyConfig.create().setContextEventLogger(logger); + final ABSmartlyConfig config = ABSmartlyConfig.create().setContextEventLogger(logger); assertSame(logger, config.getContextEventLogger()); } @@ -50,7 +50,7 @@ void setAll() { final VariableParser parser = mock(VariableParser.class); final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); final Client client = mock(Client.class); - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setVariableParser(parser) .setContextDataProvider(provider) .setContextEventHandler(handler) diff --git a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyFixTest.java similarity index 76% rename from core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java rename to core-api/src/test/java/com/absmartly/sdk/ABSmartlyFixTest.java index 3edc056..abe483f 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyFixTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyFixTest.java @@ -12,7 +12,7 @@ import com.absmartly.sdk.json.ContextData; -class ABsmartlyFixTest extends TestUtils { +class ABSmartlyFixTest extends TestUtils { Client client; @BeforeEach @@ -22,10 +22,10 @@ void setUp() { @Test void createContextThrowsAfterClose() throws IOException { - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); absmartly.close(); assertThrows(IllegalStateException.class, () -> { @@ -35,10 +35,10 @@ void createContextThrowsAfterClose() throws IOException { @Test void createContextWithThrowsAfterClose() throws IOException { - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); absmartly.close(); assertThrows(IllegalStateException.class, () -> { @@ -51,11 +51,11 @@ void getContextDataThrowsAfterClose() throws IOException { final ContextDataProvider dataProvider = mock(ContextDataProvider.class); when(dataProvider.getContextData()).thenReturn(mock(CompletableFuture.class)); - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client) .setContextDataProvider(dataProvider); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); absmartly.close(); assertThrows(IllegalStateException.class, absmartly::getContextData); @@ -65,11 +65,11 @@ void getContextDataThrowsAfterClose() throws IOException { void closeIsIdempotent() throws IOException { final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client) .setScheduler(scheduler); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); absmartly.close(); absmartly.close(); diff --git a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java similarity index 92% rename from core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java rename to core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java index d2f0f0e..abcb121 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABsmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java @@ -20,7 +20,7 @@ import com.absmartly.sdk.java.time.Clock; import com.absmartly.sdk.json.ContextData; -class ABsmartlyTest extends TestUtils { +class ABSmartlyTest extends TestUtils { Client client; @BeforeEach @@ -30,19 +30,19 @@ void setUp() { @Test void create() { - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); assertNotNull(absmartly); } @Test void createThrowsWithInvalidConfig() { assertThrows(IllegalArgumentException.class, () -> { - final ABsmartlyConfig config = ABsmartlyConfig.create(); + final ABSmartlyConfig config = ABSmartlyConfig.create(); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); }, "Missing Client instance configuration"); } @@ -53,7 +53,7 @@ void builderCreatesWithConnectionParams() { DefaultContextDataProvider.class)) { clientStatic.when(() -> Client.create(any(ClientConfig.class))).thenReturn(client); - final ABsmartly absmartly = ABsmartly.builder() + final ABSmartly absmartly = ABSmartly.builder() .endpoint("https://test.absmartly.io/v1") .apiKey("test-api-key") .application("website") @@ -75,7 +75,7 @@ void builderCreatesWithConnectionParams() { @Test void builderThrowsWithMissingEndpoint() { assertThrows(IllegalArgumentException.class, () -> { - ABsmartly.builder() + ABSmartly.builder() .apiKey("test-api-key") .application("website") .environment("production") @@ -86,7 +86,7 @@ void builderThrowsWithMissingEndpoint() { @Test void builderThrowsWithMissingApiKey() { assertThrows(IllegalArgumentException.class, () -> { - ABsmartly.builder() + ABSmartly.builder() .endpoint("https://test.absmartly.io/v1") .application("website") .environment("production") @@ -97,7 +97,7 @@ void builderThrowsWithMissingApiKey() { @Test void builderThrowsWithMissingApplication() { assertThrows(IllegalArgumentException.class, () -> { - ABsmartly.builder() + ABSmartly.builder() .endpoint("https://test.absmartly.io/v1") .apiKey("test-api-key") .environment("production") @@ -108,7 +108,7 @@ void builderThrowsWithMissingApplication() { @Test void builderThrowsWithMissingEnvironment() { assertThrows(IllegalArgumentException.class, () -> { - ABsmartly.builder() + ABSmartly.builder() .endpoint("https://test.absmartly.io/v1") .apiKey("test-api-key") .application("website") @@ -118,7 +118,7 @@ void builderThrowsWithMissingEnvironment() { @Test void createContext() { - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client); final CompletableFuture dataFuture = (CompletableFuture) mock( @@ -127,7 +127,7 @@ void createContext() { DefaultContextDataProvider.class, (mock, context) -> { when(mock.getContextData()).thenReturn(dataFuture); })) { - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); assertEquals(1, dataProviderCtor.constructed().size()); try (final MockedStatic contextStatic = mockStatic(Context.class)) { @@ -185,13 +185,13 @@ void createContext() { @Test void createContextWith() { - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client); final ContextData data = new ContextData(); try (final MockedConstruction dataProviderCtor = mockConstruction( DefaultContextDataProvider.class)) { - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); assertEquals(1, dataProviderCtor.constructed().size()); try (final MockedStatic contextStatic = mockStatic(Context.class)) { @@ -255,11 +255,11 @@ void getContextData() { final ContextDataProvider dataProvider = mock(ContextDataProvider.class); when(dataProvider.getContextData()).thenReturn(dataFuture); - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client) .setContextDataProvider(dataProvider); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); final CompletableFuture contextDataFuture = absmartly.getContextData(); verify(dataProvider, Mockito.timeout(5000).times(1)).getContextData(); @@ -279,7 +279,7 @@ void createContextWithCustomImpls() { final AudienceDeserializer audienceDeserializer = mock(AudienceDeserializer.class); final VariableParser variableParser = mock(VariableParser.class); - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client) .setContextDataProvider(dataProvider) .setContextEventHandler(eventHandler) @@ -288,7 +288,7 @@ void createContextWithCustomImpls() { .setAudienceDeserializer(audienceDeserializer) .setVariableParser(variableParser); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); try (final MockedStatic contextStatic = mockStatic(Context.class); final MockedConstruction audienceMatcherCtor = mockConstruction(AudienceMatcher.class, @@ -347,11 +347,11 @@ void createContextWithCustomImpls() { void close() throws IOException, InterruptedException { final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client) .setScheduler(scheduler); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); @@ -378,11 +378,11 @@ void close() throws IOException, InterruptedException { void closeLeavesInjectedSchedulerRunning() throws IOException { final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1); try { - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client) .setScheduler(scheduler); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); absmartly.close(); assertFalse(scheduler.isShutdown()); @@ -398,10 +398,10 @@ void closeShutsDownSelfCreatedScheduler() throws IOException, InterruptedExcepti ScheduledThreadPoolExecutor.class, (mock, context) -> { when(mock.awaitTermination(anyLong(), any())).thenReturn(true); })) { - final ABsmartlyConfig config = ABsmartlyConfig.create() + final ABSmartlyConfig config = ABSmartlyConfig.create() .setClient(client); - final ABsmartly absmartly = ABsmartly.create(config); + final ABSmartly absmartly = ABSmartly.create(config); assertEquals(1, schedulerCtor.constructed().size()); final ScheduledExecutorService createdScheduler = schedulerCtor.constructed().get(0); 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 257471f..311540c 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -349,7 +349,7 @@ void throwsWhenNotReady() { assertFalse(context.isReady()); assertFalse(context.isFailed()); - final String notReadyMessage = "ABsmartly Context is not yet ready."; + final String notReadyMessage = "ABSmartly Context is not yet ready."; assertEquals(notReadyMessage, assertThrows(IllegalStateException.class, context::getData).getMessage()); assertEquals(0, context.peekTreatment("exp_test_ab")); @@ -395,7 +395,7 @@ void throwsWhenClosing() { assertTrue(context.isClosing()); assertFalse(context.isClosed()); - final String closingMessage = "ABsmartly Context is closing."; + final String closingMessage = "ABSmartly Context is closing."; assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closingMessage, @@ -443,7 +443,7 @@ void throwsWhenClosed() { assertFalse(context.isClosing()); assertTrue(context.isClosed()); - final String closedMessage = "ABsmartly Context is finalized."; + final String closedMessage = "ABSmartly Context is finalized."; assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closedMessage, diff --git a/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java b/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java index d7ade7a..8093e4e 100644 --- a/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java @@ -25,7 +25,7 @@ * Hermetic end-to-end integration test that drives the public SDK API against a real local * HTTP server (JDK built-in {@link HttpServer}) on an ephemeral port. This exercises the * SDK's real Apache HttpAsyncClient transport (no Java-level mocking) and asserts the - * on-the-wire request shape documented in the ABsmartly SDK ↔ Collector wire contract: + * on-the-wire request shape documented in the ABSmartly SDK ↔ Collector wire contract: *
    *
  • GET /context with {@code application}/{@code environment} query params and NO auth * headers (Java authenticates the fetch via query params).
  • @@ -131,7 +131,7 @@ void drivesRealHttpForFetchAndPublish() throws Exception { final String application = "www"; final String environment = "test-env"; - final ABsmartly sdk = ABsmartly.builder() + final ABSmartly sdk = ABSmartly.builder() .endpoint(endpoint) .apiKey("test-api-key") .application(application) From cf226790586f6b59c71b8efc2888bbbedb765aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 10:30:03 +0000 Subject: [PATCH 76/83] docs: restore ABSmartly examples --- .claude/tasks/fix-plan.md | 203 ------------------ .gitignore | 1 + README.md | 48 ++--- .../main/java/com/absmartly/sdk/Example.java | 4 +- 4 files changed, 27 insertions(+), 229 deletions(-) delete mode 100644 .claude/tasks/fix-plan.md diff --git a/.claude/tasks/fix-plan.md b/.claude/tasks/fix-plan.md deleted file mode 100644 index 3d19c50..0000000 --- a/.claude/tasks/fix-plan.md +++ /dev/null @@ -1,203 +0,0 @@ -# Java SDK Fix Plan - -Based on full review of PR #11. - -## Critical - -### 1. Fix MatchOperator unbounded thread pool -- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java:17` -- **Issue**: `Executors.newCachedThreadPool()` is static, never shut down, and unbounded. Can exhaust threads under load. -- **Fix**: Replace with a bounded thread pool: - ```java - private static final ExecutorService REGEX_POOL = new ThreadPoolExecutor( - 0, 4, 60L, TimeUnit.SECONDS, new SynchronousQueue<>(), - new ThreadPoolExecutor.CallerRunsPolicy()); - ``` - Or better: use `Thread.interrupt()` with a timeout on a single thread, or use Java's `Pattern.compile` with input length limits instead of a thread pool. - -## Important - -### 2. Use JsonMapperUtils in DefaultContextEventSerializer -- **File**: `core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java:18` -- **Fix**: Replace `new ObjectMapper()` with `JsonMapperUtils.createStandardObjectMapper()` for consistency. - -### 3. Normalize endpoint URL trailing slash -- **File**: `core-api/src/main/java/com/absmartly/sdk/Client.java:59` -- **Fix**: Strip trailing slash from endpoint in constructor: - ```java - this.endpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; - ``` - -### 4. Fix MatchOperator Java 7/Android compatibility -- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java:17-21` -- **Issue**: Uses lambda expressions while rest of codebase uses anonymous inner classes for Java 7 compatibility. -- **Fix**: Convert lambdas to anonymous inner classes, or document that minimum Java version is now 8+. - -## Minor - -### 5. Add @deprecated Javadoc to deprecated wrappers -- **Files**: `core-api/src/main/java/com/absmartly/sdk/deprecated/ABSmartly.java`, `ABSmartlyConfig.java` -- **Fix**: Add `@deprecated Use {@link com.absmartly.sdk.ABsmartly} instead` Javadoc tags. - -### 6. Standardize validation error messages -- **Files**: `ABsmartly.java:59-62`, `Client.java` -- **Fix**: Use consistent wording across builder and client validation. - -### 7. Fix ABsmartly.close() thread safety -- **File**: `core-api/src/main/java/com/absmartly/sdk/ABsmartly.java:137-151` -- **Fix**: Add a `volatile boolean closed` flag checked at the start of `createContext()`, or synchronize `close()`. - -### 8. Fix branding in error messages -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:704-706` -- **Fix**: Change "ABSmartly Context" to "ABsmartly Context". - -## Additional Findings (Full Review v2) - -### Security - -#### 9. Client.java uses System.err for HTTP warning instead of SLF4J -- **File**: `core-api/src/main/java/com/absmartly/sdk/Client.java:36-38` -- **Issue**: `System.err.println()` is used for the non-HTTPS warning. The rest of the codebase uses SLF4J. This bypasses log configuration and won't appear in structured logs. -- **Fix**: Replace with `log.warn(...)` using an SLF4J logger, consistent with the rest of the SDK. - -#### 10. MatchOperator regex timeout cancel(true) does not actually interrupt regex -- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java:48` -- **Issue**: `future.cancel(true)` sends an interrupt to the thread, but `Pattern.matcher().find()` does not check the interrupt flag — the regex engine will continue running until completion. The timeout only prevents the *caller* from waiting, but the thread pool thread remains busy. Under sustained ReDoS, threads accumulate since the unbounded `newCachedThreadPool` keeps spawning new ones. -- **Severity**: This compounds with finding #1 (unbounded pool). Even with a bounded pool, stuck regex threads exhaust the pool. -- **Fix**: Consider using `CharSequence` wrapper that throws on `charAt()` when interrupted (common ReDoS mitigation pattern), or limit input text length in addition to pattern length. - -### Code Quality - -#### 11. EqualsOperator no longer extends BinaryOperator — inconsistency -- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java` -- **Issue**: `EqualsOperator` was changed from `extends BinaryOperator` to `implements Operator` to add null==null handling. All other binary operators still extend `BinaryOperator`. This breaks the consistent operator hierarchy pattern and duplicates the argument extraction logic from `BinaryOperator.evaluate()`. -- **Fix**: Either add null-null handling to `BinaryOperator` base class (with an override hook), or document why `EqualsOperator` needs to be special. - -#### 12. InOperator argument order swap is a breaking semantic change -- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/InOperator.java:10` -- **Issue**: The `binary()` method signature changed from `binary(evaluator, haystack, needle)` to `binary(evaluator, needle, haystack)`. This is called by `BinaryOperator.evaluate()` which passes `(lhs, rhs)` — so the semantic meaning of the arguments in the audience JSON `["in", needle, haystack]` changed. The cross-SDK tests pass, confirming this is the correct order, but the original code had a bug where the arguments were swapped. This is fine as a bug fix but should be noted. -- **Severity**: Low — correctly fixed to match cross-SDK specification. - -#### 13. Builder does not expose all ABsmartlyConfig options -- **File**: `core-api/src/main/java/com/absmartly/sdk/ABsmartly.java:24-78` -- **Issue**: The `Builder` only exposes `endpoint`, `apiKey`, `application`, `environment`, and `eventLogger`. It doesn't expose `scheduler`, `variableParser`, `audienceDeserializer`, `contextDataProvider`, `contextEventHandler`, or `httpClient` options — all of which are documented in the README's SDK Options table. Users needing these must fall back to the config-based API. -- **Fix**: Either add these to the builder, or document that the builder is a quickstart convenience and advanced options require the config API. - -### Performance - -#### 14. Context.buildAttributesMap() allocates on every call without caching -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:1128-1133` -- **Issue**: `buildAttributesMap()` is called inside `getAssignment()` (hot path) and `audienceMatches()`. Each call creates a new `HashMap` and copies all attributes. With the `attrsSeq_` tracking, `audienceMatches()` is called on every non-cached assignment check. For contexts with many attributes being evaluated frequently, this adds GC pressure. -- **Severity**: Low — only matters under high throughput. -- **Fix**: Cache the map and invalidate when `attrsSeq_` changes. Or just note this as acceptable. - -#### 15. DefaultContextEventSerializer still uses plain ObjectMapper, not JsonMapperUtils -- **File**: `core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java:18` -- **Issue**: Same as existing finding #2 — still uses `new ObjectMapper()` instead of `JsonMapperUtils.createStandardObjectMapper()`. This means the serializer doesn't get `FAIL_ON_READING_DUP_TREE_KEY` or `USE_STATIC_TYPING` applied consistently. -- **Note**: Duplicate of finding #2, confirmed still present. - -### Correctness - -#### 16. Context.setData() NPE when experiment.variants is null -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:1008-1014` -- **Issue**: `setData()` iterates `experiment.variants` with a for-each loop. If the server returns an experiment with `variants: null` (which the deserialization tests show is possible — see `testNullFieldsInExperiment`), this throws `NullPointerException`. -- **Fix**: Add null guard: `if (experiment.variants != null)` before the for-each loop. - -#### 17. Context.setData() NPE when experiment.customFieldValues is null -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:1048-1060` -- **Issue**: Similar to #16. If `experiment.customFieldValues` is null, the `entrySet()` iteration will NPE. -- **Fix**: Add null guard before iterating custom field values. - -#### 18. ABsmartly.close() race condition between close() and createContext() -- **File**: `core-api/src/main/java/com/absmartly/sdk/ABsmartly.java:134-151` -- **Issue**: Same as existing finding #7. `close()` sets `client_ = null` and `scheduler_ = null` without synchronization. A concurrent `createContext()` call can see a null scheduler or use a closed client. The fields are not volatile either. -- **Note**: Duplicate of finding #7, confirmed still present. This is the most impactful correctness issue remaining. - -## Additional Findings (Full Review v3) - -### Security - -#### 19. MatchOperator lambda breaks Java 8 source compatibility claim AND leaks threads -- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java:17-21,40` -- **Issue**: The `MatchOperator` uses lambda expressions (`r -> { ... }` on line 17, `() -> { ... }` on line 40) while the build.gradle explicitly sets `sourceCompatibility = "1.8"` and `targetCompatibility = "1.8"`. Java 8 supports lambdas, so this compiles — but the rest of the codebase consistently uses anonymous inner classes (see Client.java, Context.java). This is a style inconsistency, not a compile error. However, the static `ExecutorService executor` field (line 17) is **never shut down**. When the JVM classloader unloads the class or in application server environments with redeployable WARs, this creates a thread leak. There is no shutdown hook or `close()` method. -- **Severity**: Important (thread leak in long-running app server environments) -- **Fix**: Either (a) add a static `shutdown()` method and document when to call it, or (b) use daemon threads (already done) and accept leak risk, or (c) replace the thread pool approach entirely with an interruptible CharSequence wrapper (see finding #10). -- **Status**: Partially overlaps with findings #1 and #4 but adds the classloader leak dimension. - -#### 20. AudienceMatcher creates byte[] from audience string on every evaluate() call -- **File**: `core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java:31` -- **Issue**: `audience.getBytes(StandardCharsets.UTF_8)` allocates a new byte array every call, then deserializes it. The same audience string is evaluated repeatedly for every assignment check. This is wasteful and creates GC pressure. -- **Severity**: Minor/Performance -- **Fix**: Consider caching the deserialized audience map keyed by the audience string, since audience strings don't change between refreshes. - -### Code Quality - -#### 21. Context.flush() acquires contextLock_.writeLock() for read-only operation -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:650-662` -- **Issue**: The `flush()` method acquires `contextLock_.writeLock()` (line 650) to read `units_` and `attributes_`. It doesn't modify either collection. This blocks all concurrent `getTreatment()`/`getVariableValue()` calls (which use `contextLock_.readLock()`) during the entire serialization of units. Under high throughput, a flush can stall all assignment reads. -- **Severity**: Important (performance under concurrency) -- **Fix**: Use `contextLock_.readLock()` instead. The `units_` map is only written in `setUnit()` (under write lock) and `attributes_` list is only appended to via `Concurrency.addRW()`. Reading them under read lock is safe. - -#### 22. ExprEvaluator.extractVar() splits path string on every call without caching -- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java:95` -- **Issue**: `path.split("/")` is called every time `extractVar()` is evaluated. In audience expressions that reference variables (e.g., `{"var": "user/age"}`), this creates a new String array allocation per evaluation. The `split()` method also compiles a regex internally each time (though Java's `String.split` optimizes single-char patterns). -- **Severity**: Minor — negligible overhead for simple paths. - -#### 23. Context.getAssignment() builds new Assignment object on every cache miss without lock upgrade -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:763-885` -- **Issue**: The `getAssignment()` method first acquires a read lock (line 764), checks the cache, releases it (line 794), then acquires a write lock (line 798) and re-does all the lookups again. Between releasing the read lock and acquiring the write lock, another thread could have already populated the cache for the same experiment. This is a classic TOCTOU (time-of-check-time-of-use) pattern. While it doesn't cause incorrect behavior (the second assignment will just overwrite with an equivalent one), it wastes CPU on duplicate computation. -- **Severity**: Minor — correct but wasteful. Standard pattern for read-write lock upgrade. - -#### 24. Context.checkNotClosed() uses old "ABSmartly" branding -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:703-707` -- **Issue**: Error messages say "ABSmartly Context" (capital S) instead of "ABsmartly Context". Same as finding #8, confirming it's still present in the v3 review. -- **Status**: Duplicate of finding #8, confirmed still present. - -### Performance - -#### 25. HashMap initial capacity not optimized in Context constructor -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:58,65-66` -- **Issue**: `units_` is initialized as `new HashMap()` (default capacity 16), then `assigners_` and `hashedUnits_` are sized to `units_.size()` which at that point is 0. These maps will be resized as units are added. Since `units` from `config.getUnits()` is known at construction time, the initial capacity should be derived from it. -- **Severity**: Minor — negligible for typical unit counts (1-3). - -#### 26. Algorithm.mapSetToArray uses reflection to create array -- **File**: `core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java:11` -- **Issue**: `java.lang.reflect.Array.newInstance()` is used to create the output array. This is called during `flush()` to map units. While not a hot path (only called on publish), reflection-based array creation is slower than direct array allocation. -- **Severity**: Minor — only called during flush, not hot path. - -### Correctness - -#### 27. Context.setData() line 1013: NPE if experiment.variants is null (confirmed still present) -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:1013` -- **Issue**: `experiment.variants.length` will NPE if variants is null. The code at line 1057 already guards `experiment.customFieldValues != null`, but line 1013 does not guard `experiment.variants`. -- **Status**: Duplicate of finding #16, confirmed still present in v3 review. - -#### 28. Context.getCustomFieldValue() NPE when experiment has no custom field values map -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:215` -- **Issue**: `experiment.customFieldValues.get(key)` could NPE if `customFieldValues` is null. Looking at `setData()`, the `customFieldValues` map is always initialized (line 1056), so this is safe for experiments processed through `setData()`. However, if `setData()` is called with an experiment where `variants` is null (causing NPE at line 1013, finding #16), the experiment's `customFieldValues` map would never be initialized. This is a secondary failure mode of finding #16. -- **Severity**: Low — only reachable if finding #16 is triggered first. - -#### 29. Client.getContextData() empty response check may hide deserialization errors -- **File**: `core-api/src/main/java/com/absmartly/sdk/Client.java:99-101` -- **Issue**: When the response body is empty (`content == null || content.length == 0`), an `IllegalStateException` is thrown. However, if `deserializer_.deserialize()` returns null (which `DefaultContextDataDeserializer` does on IOException), the null is passed to `dataFuture.complete(null)`, which then propagates to `Context.setData(null)` which throws `IllegalArgumentException("Context data cannot be null")`. The error message at that point is misleading — it suggests a programming error rather than a deserialization failure. -- **Severity**: Minor — the error is caught, just misleading message. -- **Fix**: Add null check after deserialization: `if (result == null) dataFuture.completeExceptionally(...)`. - -### Simplification Opportunities - -#### 30. EqualsOperator could use BinaryOperator with null-aware override -- **File**: `core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/EqualsOperator.java` -- **Issue**: Same as finding #11. The EqualsOperator directly implements `Operator` and duplicates argument extraction from BinaryOperator. A cleaner approach would be to add a `nullableBinary()` method to BinaryOperator or an `allowNulls()` flag. -- **Status**: Duplicate of finding #11. - -#### 31. Context inner class Assignment could be simplified -- **File**: `core-api/src/main/java/com/absmartly/sdk/Context.java:742-761` -- **Issue**: The `Assignment` class has 13 mutable fields with no encapsulation. While this is internal, it would benefit from being a simple data holder with final fields set via constructor. However, this would be a larger refactor and the current approach works. -- **Severity**: Minor — internal class, not part of public API. - -### Summary of v3 Review - -**New findings**: #19-31 (13 items) -**Confirmed still present from v1/v2**: #1, #2, #4, #7/#18, #8/#24, #9, #10, #11/#30, #16/#27 -**Truly new actionable items**: #20 (audience byte[] allocation), #21 (write lock in flush), #29 (null deserialization propagation) -**Most impactful new finding**: #21 — Context.flush() unnecessarily acquires write lock, blocking all concurrent reads diff --git a/.gitignore b/.gitignore index 17d12f5..310f2ec 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.log build/ out/ +.claude/ diff --git a/README.md b/README.md index b9c1664..4696568 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ If you target Android 6.0 or earlier, a few extra steps are outlined below for i #### Gradle -To install the ABsmartly SDK, place the following in your `build.gradle` and replace {VERSION} with the latest SDK version available in MavenCentral. +To install the ABSmartly SDK, place the following in your `build.gradle` and replace {VERSION} with the latest SDK version available in MavenCentral. ```gradle dependencies { @@ -33,7 +33,7 @@ dependencies { #### Maven -To install the ABsmartly SDK, place the following in your `pom.xml` and replace {VERSION} with the latest SDK version available in MavenCentral. +To install the ABSmartly SDK, place the following in your `pom.xml` and replace {VERSION} with the latest SDK version available in MavenCentral. ```xml @@ -68,7 +68,7 @@ This example assumes an Api Key, an Application, and an Environment have been cr ```java import com.absmartly.sdk.*; -final ABsmartly sdk = ABsmartly.builder() +final ABSmartly sdk = ABSmartly.builder() .endpoint("https://your-company.absmartly.io/v1") .apiKey(System.getenv("ABSMARTLY_APIKEY")) .application("website") @@ -76,7 +76,7 @@ final ABsmartly sdk = ABsmartly.builder() .build(); ``` -The builder pattern lets you configure the SDK with named parameters, removing the need to configure `ClientConfig` and `ABsmartlyConfig` manually. +The builder pattern lets you configure the SDK with named parameters, removing the need to configure `ClientConfig` and `ABSmartlyConfig` manually. #### Alternative: Using Configuration Objects @@ -91,10 +91,10 @@ final ClientConfig clientConfig = ClientConfig.create() final Client absmartlyClient = Client.create(clientConfig); -final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() +final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() .setClient(absmartlyClient); -final ABsmartly sdk = ABsmartly.create(sdkConfig); +final ABSmartly sdk = ABSmartly.create(sdkConfig); ``` **SDK Options** @@ -136,10 +136,10 @@ import org.conscrypt.Conscrypt; final Client absmartlyClient = Client.create(clientConfig, httpClient); - final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() .setClient(absmartlyClient); - final ABsmartly sdk = ABsmartly.create(sdkConfig); + final ABSmartly sdk = ABSmartly.create(sdkConfig); // ... ``` @@ -346,7 +346,7 @@ Usage: ```java // For all contexts, during SDK initialization -final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create(); +final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create(); sdkConfig.setContextEventLogger(new CustomEventLogger()); // OR, alternatively, during a particular context initialization @@ -394,7 +394,7 @@ public class Application { private String environment; @Bean - public ABsmartly absmartly() { + public ABSmartly absmartly() { final ClientConfig clientConfig = ClientConfig.create() .setEndpoint(endpoint) .setAPIKey(apiKey) @@ -403,10 +403,10 @@ public class Application { final Client client = Client.create(clientConfig); - final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() .setClient(client); - return ABsmartly.create(sdkConfig); + return ABSmartly.create(sdkConfig); } public static void main(String[] args) { @@ -432,7 +432,7 @@ import jakarta.servlet.http.HttpSession; public class ProductController { @Autowired - private ABsmartly absmartly; + private ABSmartly absmartly; @GetMapping("/product") public ModelAndView showProduct(HttpSession session) { @@ -470,7 +470,7 @@ public class ABSmartlyProducer { @Produces @ApplicationScoped - public ABsmartly produceABSmartly() { + public ABSmartly produceABSmartly() { final ClientConfig clientConfig = ClientConfig.create() .setEndpoint(System.getenv("ABSMARTLY_ENDPOINT")) .setAPIKey(System.getenv("ABSMARTLY_API_KEY")) @@ -479,10 +479,10 @@ public class ABSmartlyProducer { final Client client = Client.create(clientConfig); - final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() .setClient(client); - return ABsmartly.create(sdkConfig); + return ABSmartly.create(sdkConfig); } } @@ -498,7 +498,7 @@ import jakarta.servlet.http.HttpServletRequest; public class ProductResource { @Inject - private ABsmartly absmartly; + private ABSmartly absmartly; @Inject private HttpServletRequest request; @@ -533,7 +533,7 @@ import java.util.UUID; public class MainActivity extends AppCompatActivity { - private static ABsmartly sdk; + private static ABSmartly sdk; private Context absmartlyContext; @Override @@ -550,10 +550,10 @@ public class MainActivity extends AppCompatActivity { final Client client = Client.create(clientConfig); - final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() .setClient(client); - sdk = ABsmartly.create(sdkConfig); + sdk = ABSmartly.create(sdkConfig); } // Create context for this user @@ -630,10 +630,10 @@ final ClientConfig clientConfig = ClientConfig.create() final Client client = Client.create(clientConfig, httpClient); -final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() +final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() .setClient(client); -final ABsmartly sdk = ABsmartly.create(sdkConfig); +final ABSmartly sdk = ABSmartly.create(sdkConfig); final ContextConfig contextConfig = ContextConfig.create() .setUnit("session_id", "abc123"); @@ -654,7 +654,7 @@ import java.util.concurrent.*; public class CancellableContextExample { public static void main(String[] args) throws Exception { - final ABsmartly sdk = ABsmartly.builder() + final ABSmartly sdk = ABSmartly.builder() .endpoint("https://your-company.absmartly.io/v1") .apiKey("YOUR-API-KEY") .application("website") @@ -703,7 +703,7 @@ import java8.util.concurrent.CompletableFuture; public class MainActivity extends AppCompatActivity { - private static ABsmartly sdk; // Initialize SDK once (typically in Application class) + private static ABSmartly sdk; // Initialize SDK once (typically in Application class) private CompletableFuture contextFuture; private Context absmartlyContext; diff --git a/example/src/main/java/com/absmartly/sdk/Example.java b/example/src/main/java/com/absmartly/sdk/Example.java index d7eae40..456f279 100644 --- a/example/src/main/java/com/absmartly/sdk/Example.java +++ b/example/src/main/java/com/absmartly/sdk/Example.java @@ -12,10 +12,10 @@ static public void main(String[] args) throws IOException { .setApplication(System.getenv("ABSMARTLY_APP")) .setEnvironment(System.getenv("ABSMARTLY_ENV")); - final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create() + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() .setClient(Client.create(clientConfig)); - final ABsmartly sdk = ABsmartly.create(sdkConfig); + final ABSmartly sdk = ABSmartly.create(sdkConfig); final ContextConfig contextConfig = ContextConfig.create() .setUnit("session_id", "bf06d8cb5d8137290c4abb64155584fbdb64d8") From 40b360befb5ec9ba1644982ecc4f5d64771feaea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 10:37:35 +0000 Subject: [PATCH 77/83] fix: remove regex execution limits --- .../sdk/jsonexpr/operators/MatchOperator.java | 135 +----------------- 1 file changed, 4 insertions(+), 131 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index d97964c..aa61b97 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -1,6 +1,5 @@ package com.absmartly.sdk.jsonexpr.operators; -import java.util.concurrent.*; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -12,36 +11,6 @@ public class MatchOperator extends BinaryOperator { private static final Logger log = LoggerFactory.getLogger(MatchOperator.class); - private static final int MAX_PATTERN_LENGTH = 1000; - private static final int MAX_TEXT_LENGTH = 10000; - private static final int REGEX_TIMEOUT_MS = 100; - // Pool sized to 2× available CPUs, floor 4, cap 32; a small bounded queue (2× max workers) - // absorbs momentary bursts while keeping hostile queued work bounded. - private static final int REGEX_POOL_SIZE = Math.max(4, - Math.min(Runtime.getRuntime().availableProcessors() * 2, 32)); - private static final ExecutorService REGEX_POOL = buildPool(REGEX_POOL_SIZE); - - private static ThreadPoolExecutor buildPool(int size) { - ThreadPoolExecutor pool = new ThreadPoolExecutor( - size, size, 60L, TimeUnit.SECONDS, - new ArrayBlockingQueue(size * 2), - new DaemonThreadFactory(), - new ThreadPoolExecutor.AbortPolicy()); - // Core threads time out so idle threads die on low-traffic processes (e.g. Android). - pool.allowCoreThreadTimeOut(true); - return pool; - } - - private final ExecutorService pool; - - public MatchOperator() { - this(REGEX_POOL); - } - - // Package-private: allows tests to inject a controlled executor to exercise the rejection path. - MatchOperator(ExecutorService pool) { - this.pool = pool; - } @Override public Object binary(Evaluator evaluator, Object lhs, Object rhs) { @@ -49,111 +18,15 @@ public Object binary(Evaluator evaluator, Object lhs, Object rhs) { if (text != null) { final String pattern = evaluator.stringConvert(rhs); if (pattern != null) { - if (pattern.length() > MAX_PATTERN_LENGTH) { - log.warn("Regex pattern exceeds maximum length of {}: {}", MAX_PATTERN_LENGTH, - pattern.substring(0, 50) + "..."); - return null; - } - - if (text.length() > MAX_TEXT_LENGTH) { - log.warn("Regex input text exceeds maximum length of {}", MAX_TEXT_LENGTH); - return null; - } - try { final Pattern compiled = Pattern.compile(pattern); - final InterruptibleCharSequence interruptible = new InterruptibleCharSequence(text); - - Future future; - try { - future = pool.submit(new Callable() { - @Override - public Boolean call() { - final Matcher matcher = compiled.matcher(interruptible); - return matcher.find(); - } - }); - } catch (RejectedExecutionException e) { - log.warn("Regex pool saturated; skipping match to avoid blocking caller"); - return null; - } - - try { - return future.get(REGEX_TIMEOUT_MS, TimeUnit.MILLISECONDS); - } catch (TimeoutException e) { - future.cancel(true); - log.warn("Regex pattern timed out after {}ms, possible ReDoS attack: {}", REGEX_TIMEOUT_MS, - pattern); - return null; - } catch (InterruptedException e) { - future.cancel(true); - Thread.currentThread().interrupt(); - return null; - } catch (ExecutionException e) { - Throwable cause = e.getCause(); - if (cause instanceof InterruptibleCharSequence.InterruptedCharAccessException) { - log.warn("Regex pattern interrupted after timeout, possible ReDoS attack: {}", pattern); - return null; - } - log.warn("Regex execution failed: {}", cause != null ? cause.getMessage() : e.getMessage()); - return null; - } + final Matcher matcher = compiled.matcher(text); + return matcher.find(); } catch (PatternSyntaxException e) { - log.warn("Invalid regex pattern from server: {}", e.getMessage()); - return null; - } + log.warn("Invalid regex pattern: {}", e.getMessage()); + } catch (Throwable ignored) {} } } return null; } - - static class InterruptibleCharSequence implements CharSequence { - private final CharSequence delegate; - - InterruptibleCharSequence(CharSequence delegate) { - this.delegate = delegate; - } - - @Override - public int length() { - if (Thread.currentThread().isInterrupted()) { - throw new InterruptedCharAccessException(); - } - return delegate.length(); - } - - @Override - public char charAt(int index) { - if (Thread.currentThread().isInterrupted()) { - throw new InterruptedCharAccessException(); - } - return delegate.charAt(index); - } - - @Override - public CharSequence subSequence(int start, int end) { - return new InterruptibleCharSequence(delegate.subSequence(start, end)); - } - - @Override - public String toString() { - return delegate.toString(); - } - - static class InterruptedCharAccessException extends RuntimeException { - InterruptedCharAccessException() { - super("CharSequence access interrupted"); - } - } - } - - private static class DaemonThreadFactory implements ThreadFactory { - @Override - public Thread newThread(Runnable r) { - Thread t = new Thread(r); - t.setDaemon(true); - t.setName("absmartly-regex-" + t.getId()); - return t; - } - } } From 0f89ea6b69aaeee84ea16f4d6c2ba99b7e6facb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 10:39:47 +0000 Subject: [PATCH 78/83] test: cover unrestricted regex operands --- .../operators/MatchOperatorFixTest.java | 52 ++-------------- .../operators/MatchOperatorRejectionTest.java | 62 ------------------- 2 files changed, 6 insertions(+), 108 deletions(-) delete mode 100644 core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java index 8057977..3e34efa 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java @@ -7,24 +7,6 @@ class MatchOperatorFixTest extends OperatorTest { final MatchOperator operator = new MatchOperator(); - @Test - void testBoundedThreadPoolRejectsLongPatterns() { - StringBuilder longPattern = new StringBuilder(); - for (int i = 0; i < 1001; i++) { - longPattern.append("a"); - } - assertNull(operator.evaluate(evaluator, listOf("test", longPattern.toString()))); - } - - @Test - void testRejectsLongInputText() { - StringBuilder longText = new StringBuilder(); - for (int i = 0; i < 10001; i++) { - longText.append("a"); - } - assertNull(operator.evaluate(evaluator, listOf(longText.toString(), "abc"))); - } - @Test void testNormalMatchingStillWorks() { assertTrue((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "abc"))); @@ -43,42 +25,20 @@ void testNullArgumentsReturnNull() { } @Test - void testInterruptibleCharSequenceBasicBehavior() { - MatchOperator.InterruptibleCharSequence seq = new MatchOperator.InterruptibleCharSequence("hello"); - assertEquals(5, seq.length()); - assertEquals('h', seq.charAt(0)); - assertEquals('e', seq.charAt(1)); - assertEquals("ell", seq.subSequence(1, 4).toString()); - assertEquals("hello", seq.toString()); - } - - @Test - void testInterruptibleCharSequenceThrowsOnInterrupt() { - MatchOperator.InterruptibleCharSequence seq = new MatchOperator.InterruptibleCharSequence("hello"); - Thread.currentThread().interrupt(); - try { - assertThrows(MatchOperator.InterruptibleCharSequence.InterruptedCharAccessException.class, - () -> seq.charAt(0)); - } finally { - Thread.interrupted(); - } - } - - @Test - void testPatternAtMaxLength() { + void longPatternIsNotRejected() { StringBuilder pattern = new StringBuilder(); - for (int i = 0; i < 1000; i++) { + for (int i = 0; i < 1001; i++) { pattern.append("a"); } - assertNotNull(operator.evaluate(evaluator, listOf("aaa", pattern.toString()))); + assertEquals(Boolean.TRUE, operator.evaluate(evaluator, listOf(pattern.toString(), pattern.toString()))); } @Test - void testTextAtMaxLength() { + void longTextIsNotRejected() { StringBuilder text = new StringBuilder(); - for (int i = 0; i < 10000; i++) { + for (int i = 0; i < 10001; i++) { text.append("a"); } - assertTrue((Boolean) operator.evaluate(evaluator, listOf(text.toString(), "aaa"))); + assertEquals(Boolean.TRUE, operator.evaluate(evaluator, listOf(text.toString(), "aaa"))); } } diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java deleted file mode 100644 index ad06f9e..0000000 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorRejectionTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.absmartly.sdk.jsonexpr.operators; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicBoolean; - -import org.junit.jupiter.api.Test; - -class MatchOperatorRejectionTest extends OperatorTest { - - @Test - void poolSaturationReturnsNullWithoutRunningRegexOnCaller() throws InterruptedException { - final CountDownLatch workerRunning = new CountDownLatch(1); - final CountDownLatch releaseWorker = new CountDownLatch(1); - final AtomicBoolean regexRan = new AtomicBoolean(false); - final ThreadPoolExecutor tinyPool = new ThreadPoolExecutor( - 1, 1, 60L, TimeUnit.SECONDS, - new SynchronousQueue(), - new ThreadPoolExecutor.AbortPolicy()) { - @Override - protected RunnableFuture newTaskFor(final Callable task) { - if (workerRunning.getCount() == 0) { - return super.newTaskFor(new Callable() { - @Override - public T call() throws Exception { - regexRan.set(true); - return task.call(); - } - }); - } - return super.newTaskFor(task); - } - }; - - try { - tinyPool.submit(new Callable() { - @Override - public Void call() throws InterruptedException { - workerRunning.countDown(); - releaseWorker.await(10, TimeUnit.SECONDS); - return null; - } - }); - - assertTrue(workerRunning.await(5, TimeUnit.SECONDS), - "pool worker must start within 5 s"); - - final MatchOperator op = new MatchOperator(tinyPool); - final Object result = op.binary(evaluator, "text", "abc"); - - assertNull(result, "saturated pool must return null; regex must not execute"); - assertFalse(regexRan.get(), "rejected regex callable must not run on any thread"); - } finally { - releaseWorker.countDown(); - tinyPool.shutdown(); - tinyPool.awaitTermination(5, TimeUnit.SECONDS); - } - } -} From d8c2e7939b2d8d6b5e0514a4d52945f9655ecb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 11:30:28 +0000 Subject: [PATCH 79/83] fix: restore allocation-free UTF-8 encoding --- .../com/absmartly/sdk/internal/Buffers.java | 30 +++++++++++++---- .../absmartly/sdk/internal/BuffersTest.java | 32 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java index f552d20..637dca1 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java @@ -1,7 +1,5 @@ package com.absmartly.sdk.internal; -import com.absmartly.sdk.java.nio.charset.StandardCharsets; - public abstract class Buffers { private Buffers() {} @@ -30,9 +28,29 @@ static public int getUInt8(byte[] buf, int offset) { } static public int encodeUTF8(byte[] buf, int offset, CharSequence value) { - // Platform encoder: a surrogate pair must produce one four-byte UTF-8 sequence. - final byte[] bytes = value.toString().getBytes(StandardCharsets.UTF_8); - System.arraycopy(bytes, 0, buf, offset, bytes.length); - return bytes.length; + final int start = offset; + for (int i = 0; i < value.length(); ++i) { + final char c = value.charAt(i); + if (c < 0x80) { + buf[offset++] = (byte) c; + } else if (c < 0x800) { + buf[offset++] = (byte) (0xc0 | (c >> 6)); + buf[offset++] = (byte) (0x80 | (c & 0x3f)); + } else if (Character.isHighSurrogate(c) && i + 1 < value.length() + && Character.isLowSurrogate(value.charAt(i + 1))) { + // A surrogate pair is one code point and requires one four-byte sequence. + final int codePoint = Character.toCodePoint(c, value.charAt(++i)); + buf[offset++] = (byte) (0xf0 | (codePoint >> 18)); + buf[offset++] = (byte) (0x80 | ((codePoint >> 12) & 0x3f)); + buf[offset++] = (byte) (0x80 | ((codePoint >> 6) & 0x3f)); + buf[offset++] = (byte) (0x80 | (codePoint & 0x3f)); + } else { + final char encoded = Character.isHighSurrogate(c) || Character.isLowSurrogate(c) ? '\ufffd' : c; + buf[offset++] = (byte) (0xe0 | (encoded >> 12)); + buf[offset++] = (byte) (0x80 | ((encoded >> 6) & 0x3f)); + buf[offset++] = (byte) (0x80 | (encoded & 0x3f)); + } + } + return offset - start; } } diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java index b92c0a6..74f6406 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; @@ -101,4 +102,35 @@ void encodeUTF8() { assertEquals(expected.length, encodeLengthOffset); } } + + @Test + void encodeUTF8ByteWidths() { + assertEncoding("A", new byte[]{0x41}); + assertEncoding("é", new byte[]{(byte) 0xc3, (byte) 0xa9}); + assertEncoding("世", new byte[]{(byte) 0xe4, (byte) 0xb8, (byte) 0x96}); + } + + @Test + void encodeUTF8SurrogatePair() { + assertEncoding("😀", new byte[]{(byte) 0xf0, (byte) 0x9f, (byte) 0x98, (byte) 0x80}); + } + + @Test + void encodeUTF8MixedContentMatchesPlatformEncoder() { + final String value = "Aé世😀"; + assertEncoding(value, value.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void encodeUTF8UnpairedSurrogatesAsReplacementCharacter() { + final byte[] replacement = new byte[]{(byte) 0xef, (byte) 0xbf, (byte) 0xbd}; + assertEncoding("\ud83d", replacement); + assertEncoding("\ude00", replacement); + } + + private void assertEncoding(String value, byte[] expected) { + final byte[] actual = new byte[expected.length]; + assertEquals(expected.length, Buffers.encodeUTF8(actual, 0, value)); + assertArrayEquals(expected, actual); + } } From 843a00d5e32154c4825aa80159d292d11ee28dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 12:23:02 +0000 Subject: [PATCH 80/83] fix: restore ContextEventHandler API names --- .../java/com/absmartly/sdk/ABSmartly.java | 6 ++--- .../com/absmartly/sdk/ABSmartlyConfig.java | 24 ++----------------- .../main/java/com/absmartly/sdk/Context.java | 20 +++------------- .../absmartly/sdk/ContextEventHandler.java | 14 +++++++---- .../com/absmartly/sdk/ContextPublisher.java | 11 --------- .../sdk/DefaultContextEventHandler.java | 19 ++++++++++----- .../sdk/DefaultContextPublisher.java | 20 ---------------- 7 files changed, 30 insertions(+), 84 deletions(-) delete mode 100644 core-api/src/main/java/com/absmartly/sdk/ContextPublisher.java delete mode 100644 core-api/src/main/java/com/absmartly/sdk/DefaultContextPublisher.java 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 d0b6721..1a19001 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java @@ -84,7 +84,7 @@ public ABSmartly build() { private ABSmartly(@Nonnull ABSmartlyConfig config) { contextDataProvider_ = config.getContextDataProvider(); - contextEventHandler_ = config.getContextPublisher(); + contextEventHandler_ = config.getContextEventHandler(); contextEventLogger_ = config.getContextEventLogger(); variableParser_ = config.getVariableParser(); audienceDeserializer_ = config.getAudienceDeserializer(); @@ -102,7 +102,7 @@ private ABSmartly(@Nonnull ABSmartlyConfig config) { } if (contextEventHandler_ == null) { - contextEventHandler_ = new DefaultContextPublisher(client_); + contextEventHandler_ = new DefaultContextEventHandler(client_); } } @@ -174,7 +174,7 @@ public void close() throws IOException { private volatile boolean closed_; private Client client_; private ContextDataProvider contextDataProvider_; - private ContextPublisher contextEventHandler_; + private ContextEventHandler contextEventHandler_; private ContextEventLogger contextEventLogger_; private VariableParser variableParser_; 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 f5c81b2..c4346d0 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartlyConfig.java @@ -20,30 +20,10 @@ public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider return this; } - public ContextPublisher getContextPublisher() { - return contextEventHandler_; - } - - public ABSmartlyConfig setContextPublisher(@Nonnull final ContextPublisher contextPublisher) { - contextEventHandler_ = contextPublisher; - return this; - } - - /** - * @deprecated Use {@link #getContextPublisher()} instead. - */ - @Deprecated public ContextEventHandler getContextEventHandler() { - if (contextEventHandler_ instanceof ContextEventHandler) { - return (ContextEventHandler) contextEventHandler_; - } - return null; + return contextEventHandler_; } - /** - * @deprecated Use {@link #setContextPublisher(ContextPublisher)} instead. - */ - @Deprecated public ABSmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) { contextEventHandler_ = contextEventHandler; return this; @@ -95,7 +75,7 @@ public ABSmartlyConfig setClient(Client client) { } private ContextDataProvider contextDataProvider_; - private ContextPublisher contextEventHandler_; + private ContextEventHandler contextEventHandler_; private ContextEventLogger contextEventLogger_; private VariableParser variableParser_; 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 c1d98e3..c673b85 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -33,32 +33,18 @@ public class Context implements Closeable { private static final Logger log = LoggerFactory.getLogger(Context.class); - public static Context create(@Nonnull final Clock clock, @Nonnull final ContextConfig config, - @Nonnull final ScheduledExecutorService scheduler, - @Nonnull final CompletableFuture dataFuture, @Nonnull final ContextDataProvider dataProvider, - @Nonnull final ContextPublisher eventHandler, @Nullable final ContextEventLogger eventLogger, - @Nonnull final VariableParser variableParser, @Nonnull AudienceMatcher audienceMatcher) { - return new Context(clock, config, scheduler, dataFuture, dataProvider, eventHandler, eventLogger, - variableParser, audienceMatcher); - } - - /** - * @deprecated Use {@link #create(Clock, ContextConfig, ScheduledExecutorService, CompletableFuture, - * ContextDataProvider, ContextPublisher, ContextEventLogger, VariableParser, AudienceMatcher)} instead. - */ - @Deprecated public static Context create(@Nonnull final Clock clock, @Nonnull final ContextConfig config, @Nonnull final ScheduledExecutorService scheduler, @Nonnull final CompletableFuture dataFuture, @Nonnull final ContextDataProvider dataProvider, @Nonnull final ContextEventHandler eventHandler, @Nullable final ContextEventLogger eventLogger, @Nonnull final VariableParser variableParser, @Nonnull AudienceMatcher audienceMatcher) { - return create(clock, config, scheduler, dataFuture, dataProvider, (ContextPublisher) eventHandler, eventLogger, + return new Context(clock, config, scheduler, dataFuture, dataProvider, eventHandler, eventLogger, variableParser, audienceMatcher); } private Context(Clock clock, ContextConfig config, ScheduledExecutorService scheduler, CompletableFuture dataFuture, ContextDataProvider dataProvider, - ContextPublisher eventHandler, ContextEventLogger eventLogger, VariableParser variableParser, + ContextEventHandler eventHandler, ContextEventLogger eventLogger, VariableParser variableParser, AudienceMatcher audienceMatcher) { clock_ = clock; publishDelay_ = config.getPublishDelay(); @@ -1264,7 +1250,7 @@ private Map buildAttributesMap() { private final Clock clock_; private final long publishDelay_; private final long refreshInterval_; - private final ContextPublisher eventHandler_; + private final ContextEventHandler eventHandler_; private final ContextEventLogger eventLogger_; private final ContextDataProvider dataProvider_; private final VariableParser variableParser_; 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 5e38e7a..c5576b3 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextEventHandler.java @@ -1,7 +1,11 @@ package com.absmartly.sdk; -/** - * @deprecated Use {@link ContextPublisher} instead. - */ -@Deprecated -public interface ContextEventHandler extends ContextPublisher {} +import java8.util.concurrent.CompletableFuture; + +import javax.annotation.Nonnull; + +import com.absmartly.sdk.json.PublishEvent; + +public interface ContextEventHandler { + CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event); +} diff --git a/core-api/src/main/java/com/absmartly/sdk/ContextPublisher.java b/core-api/src/main/java/com/absmartly/sdk/ContextPublisher.java deleted file mode 100644 index 9b78f61..0000000 --- a/core-api/src/main/java/com/absmartly/sdk/ContextPublisher.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.absmartly.sdk; - -import java8.util.concurrent.CompletableFuture; - -import javax.annotation.Nonnull; - -import com.absmartly.sdk.json.PublishEvent; - -public interface ContextPublisher { - CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event); -} 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 dfb2d34..7e30584 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventHandler.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventHandler.java @@ -1,13 +1,20 @@ package com.absmartly.sdk; +import java8.util.concurrent.CompletableFuture; + import javax.annotation.Nonnull; -/** - * @deprecated Use {@link DefaultContextPublisher} instead. - */ -@Deprecated -public class DefaultContextEventHandler extends DefaultContextPublisher implements ContextEventHandler { +import com.absmartly.sdk.json.PublishEvent; + +public class DefaultContextEventHandler implements ContextEventHandler { public DefaultContextEventHandler(@Nonnull final Client client) { - super(client); + client_ = client; } + + @Override + public CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event) { + return client_.publish(event); + } + + private final Client client_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultContextPublisher.java b/core-api/src/main/java/com/absmartly/sdk/DefaultContextPublisher.java deleted file mode 100644 index 0d4d7f2..0000000 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextPublisher.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.absmartly.sdk; - -import java8.util.concurrent.CompletableFuture; - -import javax.annotation.Nonnull; - -import com.absmartly.sdk.json.PublishEvent; - -public class DefaultContextPublisher implements ContextPublisher { - public DefaultContextPublisher(@Nonnull final Client client) { - client_ = client; - } - - @Override - public CompletableFuture publish(@Nonnull final Context context, @Nonnull final PublishEvent event) { - return client_.publish(event); - } - - private final Client client_; -} From bd22bb344ba137eae821dabed315c7b1b436bff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 12:24:12 +0000 Subject: [PATCH 81/83] test: restore ContextEventHandler type names --- .../java/com/absmartly/sdk/ABSmartlyTest.java | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 abcb121..0800d04 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java @@ -133,7 +133,7 @@ void createContext() { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); contextStatic - .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), any(), any())) .thenReturn(contextMock); @@ -150,8 +150,8 @@ void createContext() { .forClass(CompletableFuture.class); final ArgumentCaptor dataProviderCaptor = ArgumentCaptor .forClass(ContextDataProvider.class); - final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor - .forClass(ContextPublisher.class); + final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor + .forClass(ContextEventHandler.class); final ArgumentCaptor eventLoggerCaptor = ArgumentCaptor .forClass(ContextEventLogger.class); final ArgumentCaptor variableParserCaptor = ArgumentCaptor @@ -160,7 +160,7 @@ void createContext() { .forClass(AudienceMatcher.class); contextStatic.verify( - () -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), + () -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), any(), any()), Mockito.times(1)); contextStatic.verify( @@ -175,7 +175,7 @@ void createContext() { assertTrue(schedulerCaptor.getValue() instanceof ScheduledThreadPoolExecutor); assertSame(dataFuture, dataFutureCaptor.getValue()); assertTrue(dataProviderCaptor.getValue() instanceof DefaultContextDataProvider); - assertTrue(eventHandlerCaptor.getValue() instanceof DefaultContextPublisher); + assertTrue(eventHandlerCaptor.getValue() instanceof DefaultContextEventHandler); assertNull(eventLoggerCaptor.getValue()); assertTrue(variableParserCaptor.getValue() instanceof DefaultVariableParser); assertNotNull(audienceMatcherCaptor.getValue()); @@ -197,7 +197,7 @@ void createContextWith() { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); contextStatic - .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), any(), any())) .thenReturn(contextMock); @@ -216,8 +216,8 @@ void createContextWith() { .forClass(CompletableFuture.class); final ArgumentCaptor dataProviderCaptor = ArgumentCaptor .forClass(ContextDataProvider.class); - final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor - .forClass(ContextPublisher.class); + final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor + .forClass(ContextEventHandler.class); final ArgumentCaptor eventLoggerCaptor = ArgumentCaptor .forClass(ContextEventLogger.class); final ArgumentCaptor variableParserCaptor = ArgumentCaptor @@ -226,7 +226,7 @@ void createContextWith() { .forClass(AudienceMatcher.class); contextStatic.verify( - () -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), + () -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), any(), any()), Mockito.times(1)); contextStatic.verify( @@ -241,7 +241,7 @@ void createContextWith() { assertTrue(schedulerCaptor.getValue() instanceof ScheduledThreadPoolExecutor); assertDoesNotThrow(() -> assertSame(data, dataFutureCaptor.getValue().get())); assertTrue(dataProviderCaptor.getValue() instanceof DefaultContextDataProvider); - assertTrue(eventHandlerCaptor.getValue() instanceof DefaultContextPublisher); + assertTrue(eventHandlerCaptor.getValue() instanceof DefaultContextEventHandler); assertNull(eventLoggerCaptor.getValue()); assertTrue(variableParserCaptor.getValue() instanceof DefaultVariableParser); assertNotNull(audienceMatcherCaptor.getValue()); @@ -298,7 +298,7 @@ void createContextWithCustomImpls() { })) { final Context contextMock = mock(Context.class); contextStatic - .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), any(), any())) .thenReturn(contextMock); @@ -314,15 +314,15 @@ void createContextWithCustomImpls() { .forClass(CompletableFuture.class); final ArgumentCaptor dataProviderCaptor = ArgumentCaptor .forClass(ContextDataProvider.class); - final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor - .forClass(ContextPublisher.class); + final ArgumentCaptor eventHandlerCaptor = ArgumentCaptor + .forClass(ContextEventHandler.class); final ArgumentCaptor eventLoggerCaptor = ArgumentCaptor .forClass(ContextEventLogger.class); final ArgumentCaptor variableParserCaptor = ArgumentCaptor.forClass(VariableParser.class); final ArgumentCaptor audienceMatcher = ArgumentCaptor.forClass(AudienceMatcher.class); contextStatic.verify( - () -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), any(), + () -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), any(), any()), Mockito.times(1)); contextStatic.verify( @@ -356,7 +356,7 @@ void close() throws IOException, InterruptedException { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); contextStatic - .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextPublisher.class), any(), + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), any(), any())) .thenReturn(contextMock); From a895178253bad3277ee86d71f08bca80fcde8cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 12:25:05 +0000 Subject: [PATCH 82/83] test: format restored handler matcher --- core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0800d04..53d1ced 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java @@ -322,7 +322,8 @@ void createContextWithCustomImpls() { final ArgumentCaptor audienceMatcher = ArgumentCaptor.forClass(AudienceMatcher.class); contextStatic.verify( - () -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), any(), + () -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), + any(), any()), Mockito.times(1)); contextStatic.verify( From 39997ffac863543a77c2acc14ac4315d0ef902f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Tue, 1 Sep 2026 12:53:23 +0000 Subject: [PATCH 83/83] fix: preserve unpaired surrogate hashes --- .../com/absmartly/sdk/internal/Buffers.java | 29 +++++++++------- .../absmartly/sdk/internal/BuffersTest.java | 34 ++++++++++++++++--- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java index 637dca1..319a507 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java @@ -29,26 +29,29 @@ static public int getUInt8(byte[] buf, int offset) { static public int encodeUTF8(byte[] buf, int offset, CharSequence value) { final int start = offset; - for (int i = 0; i < value.length(); ++i) { + final int length = value.length(); + for (int i = 0; i < length; ++i) { final char c = value.charAt(i); if (c < 0x80) { buf[offset++] = (byte) c; } else if (c < 0x800) { buf[offset++] = (byte) (0xc0 | (c >> 6)); buf[offset++] = (byte) (0x80 | (c & 0x3f)); - } else if (Character.isHighSurrogate(c) && i + 1 < value.length() - && Character.isLowSurrogate(value.charAt(i + 1))) { - // A surrogate pair is one code point and requires one four-byte sequence. - final int codePoint = Character.toCodePoint(c, value.charAt(++i)); - buf[offset++] = (byte) (0xf0 | (codePoint >> 18)); - buf[offset++] = (byte) (0x80 | ((codePoint >> 12) & 0x3f)); - buf[offset++] = (byte) (0x80 | ((codePoint >> 6) & 0x3f)); - buf[offset++] = (byte) (0x80 | (codePoint & 0x3f)); } else { - final char encoded = Character.isHighSurrogate(c) || Character.isLowSurrogate(c) ? '\ufffd' : c; - buf[offset++] = (byte) (0xe0 | (encoded >> 12)); - buf[offset++] = (byte) (0x80 | ((encoded >> 6) & 0x3f)); - buf[offset++] = (byte) (0x80 | (encoded & 0x3f)); + final char low = Character.isHighSurrogate(c) && i + 1 < length ? value.charAt(i + 1) : 0; + if (Character.isLowSurrogate(low)) { + // A surrogate pair is one code point and requires one four-byte sequence. + final int codePoint = Character.toCodePoint(c, low); + ++i; + buf[offset++] = (byte) (0xf0 | (codePoint >> 18)); + buf[offset++] = (byte) (0x80 | ((codePoint >> 12) & 0x3f)); + buf[offset++] = (byte) (0x80 | ((codePoint >> 6) & 0x3f)); + buf[offset++] = (byte) (0x80 | (codePoint & 0x3f)); + } else { + buf[offset++] = (byte) (0xe0 | (c >> 12)); + buf[offset++] = (byte) (0x80 | ((c >> 6) & 0x3f)); + buf[offset++] = (byte) (0x80 | (c & 0x3f)); + } } } return offset - start; diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java index 74f6406..4757ed0 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java @@ -122,10 +122,17 @@ void encodeUTF8MixedContentMatchesPlatformEncoder() { } @Test - void encodeUTF8UnpairedSurrogatesAsReplacementCharacter() { - final byte[] replacement = new byte[]{(byte) 0xef, (byte) 0xbf, (byte) 0xbd}; - assertEncoding("\ud83d", replacement); - assertEncoding("\ude00", replacement); + void encodeUTF8UnpairedSurrogatesMatchLegacyEncoding() { + assertEncoding("\ud83d", new byte[]{(byte) 0xed, (byte) 0xa0, (byte) 0xbd}); + assertEncoding("\ude00", new byte[]{(byte) 0xed, (byte) 0xb8, (byte) 0x80}); + } + + @Test + void encodeUTF8MatchesLegacyEncodingExceptForSurrogatePairs() { + final List values = listOf("ASCII", "é", "世", "\ud83d", "\ude00", "Aé世\ud83dB\ude00"); + for (final String value : values) { + assertEncoding(value, encodeUTF8ByCodeUnit(value)); + } } private void assertEncoding(String value, byte[] expected) { @@ -133,4 +140,23 @@ private void assertEncoding(String value, byte[] expected) { assertEquals(expected.length, Buffers.encodeUTF8(actual, 0, value)); assertArrayEquals(expected, actual); } + + private byte[] encodeUTF8ByCodeUnit(String value) { + final byte[] encoded = new byte[value.length() * 3]; + int offset = 0; + for (int i = 0; i < value.length(); ++i) { + final char c = value.charAt(i); + if (c < 0x80) { + encoded[offset++] = (byte) c; + } else if (c < 0x800) { + encoded[offset++] = (byte) (0xc0 | (c >> 6)); + encoded[offset++] = (byte) (0x80 | (c & 0x3f)); + } else { + encoded[offset++] = (byte) (0xe0 | (c >> 12)); + encoded[offset++] = (byte) (0x80 | ((c >> 6) & 0x3f)); + encoded[offset++] = (byte) (0x80 | (c & 0x3f)); + } + } + return Arrays.copyOf(encoded, offset); + } }