diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4d2df92 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 1.7.0 (unreleased) + +### Changed + +- Unit UIDs can be strings or numbers. `ContextConfig.setUnit` and `Context.setUnit` accept a `Number` in addition to a `String`. New `getUnitValue` and `getUnitValues` accessors retain the original numeric type; the existing `getUnit` and `getUnits` accessors remain source- and binary-compatible and return numeric UIDs in their string form. A numeric UID is hashed as its value rounded to an integer (`125.75` hashes as `"126"`), including `"-0"` and exponential forms for large values. Mutable `Number` types such as `AtomicInteger` are rejected. +- `refresh()` on a context whose initial load failed is a successful no-op: it does not fetch and logs no event. + +### Fixed + +- Unit UIDs containing supplementary characters (emoji and other code points outside the Basic Multilingual Plane) are now hashed as standard UTF-8, matching the other SDKs. 1.6.3 encoded each half of a surrogate pair separately, so such UIDs hash differently and may be assigned a different variant after upgrading. UIDs made only of ASCII or Basic Multilingual Plane characters are unaffected. +- If the publish during `close()` fails and the events are restored, the context stays open so that `close()` can be called again to retry, and the returned future fails. 1.6.3 marked the context closed in that case. + +### Compatibility + +- Read methods retain the `IllegalStateException` lifecycle behavior of the released 1.6.3 SDK. Custom-field reads continue to work after a context is ready, including while it is closing and after it has closed. +- Event loggers may wait on the matching readiness, refresh, or close future from `handleEvent`. Those futures complete before the associated lifecycle event is logged, as in 1.6.3. +- `IllegalStateException` messages retain the 1.6.3 wording: `ABSmartly Context is not yet ready`, `ABSmartly Context is closing`, and `ABSmartly Context is closed`. diff --git a/README.md b/README.md index 4696568..f055977 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ The `android.permission.INTERNET` permission is required. To add this permission If you target Android 6.0 or earlier, a few extra steps are outlined below for installation and initialization. +## Upgrading from 1.6.3 + +The public unit getters remain compatible with 1.6.3: `getUnit` returns a `String` and `getUnits` returns `Map`. Unit UIDs may now also be numeric; numeric values are returned by the new `getUnitValue` and `getUnitValues` accessors, while the original getters return their string form. + +Context reads retain the 1.6.3 lifecycle behavior. Before ready they throw `"ABSmartly Context is not yet ready"`; while closing and after close they throw `"ABSmartly Context is closing"` and `"ABSmartly Context is closed"`, respectively. Custom-field reads remain available once the context is ready, including while closing and after close. + ## Installation #### Gradle diff --git a/build.gradle b/build.gradle index 746a4d1..736614d 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ plugins { ext { - VERSION = "1.6.3" + VERSION = "1.7.0" GROUP_ID = "com.absmartly.sdk" slf4jVersion = "1.7.30" 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 389719b..046af46 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1,6 +1,8 @@ package com.absmartly.sdk; import java.io.Closeable; +import java.math.BigDecimal; +import java.math.BigInteger; import java.util.*; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -61,9 +63,9 @@ private Context(Clock clock, ContextConfig config, ScheduledExecutorService sche audienceMatcher_ = audienceMatcher; scheduler_ = scheduler; - units_ = new HashMap(); + units_ = new HashMap(); - final Map units = config.getUnits(); + final Map units = config.getUnitValues(); if (units != null) { setUnits(units); } @@ -158,36 +160,28 @@ public boolean isFinalizing() { } public CompletableFuture waitUntilReadyAsync() { - if (data_ != null) { - return CompletableFuture.completedFuture(this); - } else { - 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); + 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_.get(); // cache here to avoid locking - if (future != null && !future.isDone()) { - future.join(); - } + final CompletableFuture future = readyFuture_.get(); // cache here to avoid locking + if (future != null && !future.isDone()) { + future.join(); } return this; } public String[] getExperiments() { - if (!isReady() || isClosed() || isClosing()) { - return new String[0]; - } + checkReady(false); try { dataLock_.readLock().lock(); @@ -205,9 +199,7 @@ public String[] getExperiments() { } public String[] getCustomFieldKeys() { - if (!isReady() || isClosed() || isClosing()) { - return new String[0]; - } + checkReady(false); final Set keys = new HashSet(); @@ -228,9 +220,7 @@ public String[] getCustomFieldKeys() { } public Object getCustomFieldValue(@Nonnull final String experimentName, @Nonnull final String key) { - if (!isReady() || isClosed() || isClosing()) { - return null; - } + checkReady(false); try { dataLock_.readLock().lock(); @@ -248,9 +238,7 @@ 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; - } + checkReady(false); try { dataLock_.readLock().lock(); @@ -313,6 +301,10 @@ public void setCustomAssignments(@Nonnull final Map customAssig } public String getUnit(@Nonnull final String unitType) { + return ContextConfig.unitString(getUnitValue(unitType)); + } + + public Object getUnitValue(@Nonnull final String unitType) { final ReentrantReadWriteLock.ReadLock readLock = contextLock_.readLock(); try { readLock.lock(); @@ -323,23 +315,39 @@ public String getUnit(@Nonnull final String unitType) { } public void setUnit(@Nonnull final String unitType, @Nonnull final String uid) { + putUnit(unitType, uid); + } + + public void setUnit(@Nonnull final String unitType, @Nonnull final Number uid) { + putUnit(unitType, uid); + } + + private void putUnit(final String unitType, final Object value) { checkNotClosed(); + final Object uid; + if (value instanceof String) { + final String trimmed = ((String) value).trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException(String.format("Unit '%s' UID must not be blank.", unitType)); + } + uid = trimmed; + } else if (isImmutableNumber(value)) { + uid = value; + } else { + throw new IllegalArgumentException(String.format("Unit '%s' must be a string or a number.", unitType)); + } + final ReentrantReadWriteLock.WriteLock writeLock = contextLock_.writeLock(); try { writeLock.lock(); - final String previous = units_.get(unitType); - if ((previous != null) && !previous.equals(uid)) { + final Object previous = units_.get(unitType); + if ((previous != null) && !sameUid(previous, uid)) { throw new IllegalArgumentException(String.format("Unit '%s' UID already set.", unitType)); } - final String trimmed = uid.trim(); - if (trimmed.isEmpty()) { - throw new IllegalArgumentException(String.format("Unit '%s' UID must not be blank.", unitType)); - } - - units_.put(unitType, trimmed); + units_.put(unitType, uid); invalidateAssignmentsPinnedWithMissingUnit(unitType); } finally { @@ -347,6 +355,40 @@ public void setUnit(@Nonnull final String unitType, @Nonnull final String uid) { } } + // Mutable Number implementations (AtomicInteger, ...) are rejected: the context caches the unit's + // hash, so a UID that changes after it is set would be published under its old identity. + private static boolean isImmutableNumber(final Object uid) { + return (uid instanceof Integer) || (uid instanceof Long) || (uid instanceof Short) || (uid instanceof Byte) + || (uid instanceof Double) || (uid instanceof Float) || (uid instanceof BigInteger) + || (uid instanceof BigDecimal); + } + + private static boolean sameUid(final Object a, final Object b) { + if ((a instanceof Number) && (b instanceof Number)) { + final BigDecimal x = toBigDecimal((Number) a); + final BigDecimal y = toBigDecimal((Number) b); + if ((x == null) || (y == null)) { + return Double.valueOf(((Number) a).doubleValue()).equals(((Number) b).doubleValue()); + } + return x.compareTo(y) == 0; + } + return a.equals(b); + } + + private static BigDecimal toBigDecimal(final Number value) { + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof BigInteger) { + return new BigDecimal((BigInteger) value); + } + if ((value instanceof Double) || (value instanceof Float)) { + final double d = value.doubleValue(); + return (Double.isNaN(d) || Double.isInfinite(d)) ? null : new BigDecimal(d); + } + return BigDecimal.valueOf(value.longValue()); + } + // holdoutAssignments pins the holdout decisions used to compute suppression. A null entry means // the covered experiment's unit was unavailable; resolving it live later could publish a holdout // verdict inconsistent with the cached experiment decision, so setUnit evicts the assignment and @@ -381,20 +423,22 @@ private void invalidateAssignmentsPinnedWithMissingUnit(final String unitType) { } public Map getUnits() { + return ContextConfig.unitStrings(getUnitValues()); + } + + public Map getUnitValues() { final ReentrantReadWriteLock.ReadLock readLock = contextLock_.readLock(); try { readLock.lock(); - return new HashMap(units_); + return new HashMap(units_); } finally { readLock.unlock(); } } - public void setUnits(@Nonnull final Map units) { - for (Map.Entry entry : units.entrySet()) { - String key = entry.getKey(); - String value = entry.getValue(); - setUnit(key, value); + public void setUnits(@Nonnull final Map units) { + for (Map.Entry entry : units.entrySet()) { + putUnit(entry.getKey(), entry.getValue()); } } @@ -445,13 +489,7 @@ public void setAttributes(@Nonnull final Map attributes) { } public int getTreatment(@Nonnull final String experimentName) { - if (!isReady()) { - return 0; - } - - if (isClosed() || isClosing()) { - return 0; - } + checkReady(true); return getExposedTreatmentVariant(getAssignment(experimentName)); } @@ -584,17 +622,13 @@ private void enqueueExposure(final Assignment assignment) { } public int peekTreatment(@Nonnull final String experimentName) { - if (!isReady() || isClosed() || isClosing()) { - return 0; - } + checkReady(true); return getAssignment(experimentName).variant; } public Map> getVariableKeys() { - if (!isReady() || isClosed() || isClosing()) { - return new HashMap>(); - } + checkReady(true); final Map> variableKeys = new HashMap>(indexVariables_.size()); @@ -617,9 +651,7 @@ public Map> getVariableKeys() { } public Object getVariableValue(@Nonnull final String key, final Object defaultValue) { - if (!isReady() || isClosed() || isClosing()) { - return defaultValue; - } + checkReady(true); final Assignment assignment = exposeVariableAssignment(key, getVariableAssignment(key, false)); if (assignment != null) { @@ -657,9 +689,7 @@ private Assignment settleExposure(final Assignment assignment, final Function refreshAsync() { checkNotClosed(); + if (failed_) { + return CompletableFuture.completedFuture(null); + } + if (refreshing_.compareAndSet(false, true)) { final CompletableFuture newRefreshFuture = new CompletableFuture(); refreshFuture_.set(newRefreshFuture); @@ -748,59 +782,71 @@ public void refresh() { } public CompletableFuture closeAsync() { - if (!closed_.get()) { - if (closing_.compareAndSet(false, true)) { - clearRefreshTimer(); + final CompletableFuture attempt; + // The decision to start a close and the publication of its future happen under one lock, so + // every other caller gets that attempt's future. + synchronized (closeLock_) { + // Read closing_ before closed_: a finishing attempt sets closed_ and then clears closing_ + // outside this lock, so the reverse order can see both false and start a second close. + if (closing_.get() || closed_.get()) { + final CompletableFuture current = closingFuture_; + if ((current == null) || (closed_.get() && current.isCompletedExceptionally())) { + return CompletableFuture.completedFuture(null); + } + return closeCallerFuture(current); + } - if (pendingCount_.get() > 0) { - final CompletableFuture newClosingFuture = new CompletableFuture(); - closingFuture_.set(newClosingFuture); + closing_.set(true); + attempt = new CompletableFuture(); + closingFuture_ = attempt; + } - flush().thenAccept(new Consumer() { - @Override - public void accept(Void x) { - closed_.set(true); - closing_.set(false); - newClosingFuture.complete(null); + clearRefreshTimer(); - Context.this.logEvent(ContextEventLogger.EventType.Close, null); - } - }).exceptionally(new Function() { - @Override - public Void apply(Throwable exception) { - // 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); + if (pendingCount_.get() > 0) { + flush().thenAccept(new Consumer() { + @Override + public void accept(Void x) { + Context.this.completeClose(attempt); + } + }).exceptionally(new Function() { + @Override + public Void apply(Throwable exception) { + // 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); + attempt.completeExceptionally(exception); - return null; - } - }); + return null; + } + }); + } else { + completeClose(attempt); + } - return newClosingFuture; - } else { - closed_.set(true); - closing_.set(false); + return closeCallerFuture(attempt); + } - Context.this.logEvent(ContextEventLogger.EventType.Close, null); + private void completeClose(final CompletableFuture attempt) { + closed_.set(true); + closing_.set(false); + attempt.complete(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); - } - } + logEvent(ContextEventLogger.EventType.Close, null); + } - final CompletableFuture future = closingFuture_.get(); - if (future != null) { - return future; + // Do not expose the internal completion gate: completing or cancelling a caller's dependent future + // must not release any other close caller. + private static CompletableFuture closeCallerFuture(final CompletableFuture attempt) { + return attempt.thenApply(new Function() { + @Override + public Void apply(Void value) { + return null; } - } - - return CompletableFuture.completedFuture(null); + }); } @Override @@ -864,9 +910,9 @@ public Void apply(Void ignoredResult, Throwable exception) { try { contextLock_.writeLock().lock(); event.units = Algorithm.mapSetToArray(units_.entrySet(), new Unit[0], - new Function, Unit>() { + new Function, Unit>() { @Override - public Unit apply(Map.Entry entry) { + public Unit apply(Map.Entry entry) { return new Unit(entry.getKey(), new String(getUnitHash(entry.getKey(), entry.getValue()), StandardCharsets.US_ASCII)); @@ -957,15 +1003,15 @@ public void run() { private void checkNotClosed() { if (closed_.get()) { - throw new IllegalStateException("ABSmartly Context is finalized."); + 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(); } @@ -1252,7 +1298,7 @@ && audienceMatches(experiment.data, assignment)) { if (experiment.data.audienceStrict && assignment.audienceMismatch) { assignment.variant = 0; } else if (experiment.data.fullOnVariant == 0) { - final String uid = units_.get(unitType); + final Object uid = units_.get(unitType); if (uid != null) { final byte[] unitHash = Context.this.getUnitHash(unitType, uid); @@ -1414,7 +1460,7 @@ private Assignment getHoldoutAssignment(final Experiment holdout, final String u try { readLock.lock(); - final String uid = units_.get(unitType); + final Object uid = units_.get(unitType); if (uid == null) { return null; } @@ -1434,7 +1480,7 @@ private Assignment getHoldoutAssignment(final Experiment holdout, final String u try { writeLock.lock(); - final String uid = units_.get(unitType); + final Object uid = units_.get(unitType); if (uid == null) { return null; } @@ -1479,7 +1525,7 @@ private List getVariableExperiments(final String key) { return Concurrency.getRW(dataLock_, indexVariables_, key); } - private byte[] getUnitHash(final String unitType, final String unitUID) { + private byte[] getUnitHash(final String unitType, final Object unitUID) { return Concurrency.computeIfAbsentRW(contextLock_, hashedUnits_, unitType, new Function() { @Override public byte[] apply(String key) { @@ -1764,7 +1810,7 @@ private Map buildAttributesMap() { private final VariableParser variableParser_; private final AudienceMatcher audienceMatcher_; private final ScheduledExecutorService scheduler_; - private final Map units_; + private final Map units_; private volatile boolean failed_; private volatile Throwable readyError_; @@ -1812,7 +1858,9 @@ private Map buildAttributesMap() { private static final CompletableFuture COMPLETED_VOID_FUTURE = CompletableFuture.completedFuture(null); private final AtomicReference> readyFuture_ = new AtomicReference>(); - private final AtomicReference> closingFuture_ = new AtomicReference>(); + private final Object closeLock_ = new Object(); + // Guarded by closeLock_. + private CompletableFuture closingFuture_; private final AtomicReference> refreshFuture_ = new AtomicReference>(); private final ReentrantLock timeoutLock_ = new ReentrantLock(); 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 a6ecbfc..03d9c54 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java @@ -5,6 +5,8 @@ import javax.annotation.Nonnull; +import com.absmartly.sdk.internal.hashing.Hashing; + public class ContextConfig { public static ContextConfig create() { return new ContextConfig(); @@ -13,29 +15,59 @@ public static ContextConfig create() { private ContextConfig() {} public ContextConfig setUnit(@Nonnull final String unitType, @Nonnull final String uid) { - if (units_ == null) { - units_ = new HashMap(); - } + return putUnit(unitType, uid); + } - units_.put(unitType, uid); - return this; + public ContextConfig setUnit(@Nonnull final String unitType, @Nonnull final Number uid) { + return putUnit(unitType, uid); } - public ContextConfig setUnits(@Nonnull final Map units) { - for (final Map.Entry entry : units.entrySet()) { - setUnit(entry.getKey(), entry.getValue()); + public ContextConfig setUnits(@Nonnull final Map units) { + for (final Map.Entry entry : units.entrySet()) { + putUnit(entry.getKey(), entry.getValue()); } return this; } public String getUnit(@Nonnull final String unitType) { + return unitString(getUnitValue(unitType)); + } + + public Object getUnitValue(@Nonnull final String unitType) { return units_ != null ? units_.get(unitType) : null; } public Map getUnits() { + return units_ != null ? unitStrings(units_) : null; + } + + // Shared with Context: a numeric UID is returned in its canonical string form, the same string that + // is hashed. + static String unitString(final Object uid) { + return uid != null ? Hashing.unitToString(uid) : null; + } + + static Map unitStrings(final Map uids) { + final Map units = new HashMap(uids.size()); + for (final Map.Entry entry : uids.entrySet()) { + units.put(entry.getKey(), unitString(entry.getValue())); + } + return units; + } + + public Map getUnitValues() { return units_; } + private ContextConfig putUnit(final String unitType, final Object uid) { + if (units_ == null) { + units_ = new HashMap(); + } + + units_.put(unitType, uid); + return this; + } + public ContextConfig setAttribute(@Nonnull final String name, @Nonnull final Object value) { if (attributes_ == null) { attributes_ = new HashMap(); @@ -135,7 +167,7 @@ public long getRefreshInterval() { return this.refreshInterval_; } - private Map units_; + private Map units_; private Map attributes_; private Map overrides_; private Map cassignments_; 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 49823a8..77e014f 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 @@ -1,5 +1,9 @@ package com.absmartly.sdk.internal.hashing; +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; + import com.absmartly.sdk.internal.Buffers; public abstract class Hashing { @@ -12,6 +16,60 @@ public byte[] initialValue() { } }; + public static byte[] hashUnit(Object unit) { + return hashUnit(unitToString(unit)); + } + + // Numeric unit UIDs hash like the JavaScript SDK's value.toFixed(0). JavaScript numbers are doubles, + // so every numeric type is converted to double first (a long beyond 2^53 rounds as it would in + // JavaScript), negative values that round to zero keep their sign ("-0"), and magnitudes of 1e21 + // or more use JavaScript's exponent notation ("1e+21"). + public static String unitToString(final Object uid) { + if (uid instanceof String) { + return (String) uid; + } + final double value = ((Number) uid).doubleValue(); + if (Double.isNaN(value)) { + return "NaN"; + } + if (Double.isInfinite(value)) { + return value > 0 ? "Infinity" : "-Infinity"; + } + final double magnitude = Math.abs(value); + final String sign = value < 0 ? "-" : ""; + if (magnitude >= 1e21) { + // At least 1e21 with at most 17 significant digits, so the scale is negative and + // BigDecimal.toString() always uses scientific notation ("1.2345E+22"). + return sign + shortestRoundTrip(magnitude).stripTrailingZeros().toString().replace('E', 'e'); + } + return sign + new BigDecimal(magnitude).setScale(0, RoundingMode.HALF_UP).toPlainString(); + } + + // The fewest significant digits that read back as the same double, closest to the exact value when + // two qualify, as in JavaScript's Number-to-String conversion (ECMA-262 Number::toString). Both + // neighbours are tried at each precision: the shortest round-trip decimal is not always the nearest + // one (2^89). Double.toString is not shortest on every JDK (JDK 11 gives 9.999999999999999E22). + private static BigDecimal shortestRoundTrip(final double value) { + final BigDecimal exact = new BigDecimal(value); + for (int precision = 1; precision <= 17; ++precision) { + final BigDecimal down = exact.round(new MathContext(precision, RoundingMode.FLOOR)); + final BigDecimal up = exact.round(new MathContext(precision, RoundingMode.CEILING)); + final boolean downOk = down.doubleValue() == value; + final boolean upOk = up.doubleValue() == value; + if (downOk && upOk) { + final int cmp = exact.subtract(down).compareTo(up.subtract(exact)); + return cmp <= 0 ? down : up; + } + if (downOk) { + return down; + } + if (upOk) { + return up; + } + } + return exact; + } + public static byte[] hashUnit(CharSequence unit) { final int n = unit.length(); // Up to 4 UTF-8 bytes per UTF-16 code unit (3-byte BMP chars, and 4-byte 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 a37287c..d34c6d4 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -6,6 +6,7 @@ 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.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -39,6 +40,7 @@ import org.mockito.ArgumentMatchers; import org.mockito.Mockito; +import com.absmartly.sdk.internal.hashing.Hashing; import com.absmartly.sdk.java.time.Clock; import com.absmartly.sdk.json.Attribute; import com.absmartly.sdk.json.ContextData; @@ -234,6 +236,23 @@ void becomesReadyAndFailedWithException() { assertTrue(context.isFailed()); } + @Test + void readsReturnDefaultsWhenLoadFailed() { + final Context context = createContext(dataFutureFailed); + assertTrue(context.isReady()); + assertTrue(context.isFailed()); + + assertEquals(0, context.getTreatment("exp_test_ab")); + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(17, context.getVariableValue("banner.border", 17)); + assertEquals(17, context.peekVariableValue("banner.border", 17)); + assertTrue(context.getVariableKeys().isEmpty()); + assertArrayEquals(new String[0], context.getExperiments()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); + } + @Test void readyErrorReturnsNullOnSuccess() { final Context context = createReadyContext(); @@ -349,34 +368,29 @@ 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")); - 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")); + 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::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(notReadyMessage, + assertThrows(IllegalStateException.class, context::getCustomFieldKeys).getMessage()); + assertEquals(notReadyMessage, + assertThrows(IllegalStateException.class, () -> context.getCustomFieldValue("exp_test_ab", "key")) + .getMessage()); + assertEquals(notReadyMessage, + assertThrows(IllegalStateException.class, () -> context.getCustomFieldValueType("exp_test_ab", "key")) + .getMessage()); } @Test @@ -395,7 +409,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, @@ -417,15 +431,22 @@ void throwsWhenClosing() { assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::publish).getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::getData).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")); + 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.getVariableValue("banner.border", 17)) + .getMessage()); + assertEquals(closingMessage, + assertThrows(IllegalStateException.class, () -> context.peekVariableValue("banner.border", 17)) + .getMessage()); + assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); + // custom field reads have no lifecycle guard after ready, as in 1.6.3 + assertDoesNotThrow(context::getCustomFieldKeys); + assertDoesNotThrow(() -> context.getCustomFieldValue("exp_test_ab", "key")); + assertDoesNotThrow(() -> context.getCustomFieldValueType("exp_test_ab", "key")); + assertEquals(data.experiments.length, context.getExperiments().length); } @Test @@ -443,7 +464,7 @@ void throwsWhenClosed() { assertFalse(context.isClosing()); assertTrue(context.isClosed()); - final String closedMessage = "ABSmartly Context is finalized."; + final String closedMessage = "ABSmartly Context is closed"; assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closedMessage, @@ -465,15 +486,22 @@ void throwsWhenClosed() { assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::publish).getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::getData).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")); + 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.getVariableValue("banner.border", 17)) + .getMessage()); + assertEquals(closedMessage, + assertThrows(IllegalStateException.class, () -> context.peekVariableValue("banner.border", 17)) + .getMessage()); + assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); + // custom field reads have no lifecycle guard after ready, as in 1.6.3 + assertDoesNotThrow(context::getCustomFieldKeys); + assertDoesNotThrow(() -> context.getCustomFieldValue("exp_test_ab", "key")); + assertDoesNotThrow(() -> context.getCustomFieldValueType("exp_test_ab", "key")); + assertEquals(data.experiments.length, context.getExperiments().length); } @Test @@ -1754,7 +1782,7 @@ void closeAsync() { final CompletableFuture closingFuture = context.closeAsync(); final CompletableFuture closingFutureNext = context.closeAsync(); - assertSame(closingFuture, closingFutureNext); + assertNotSame(closingFuture, closingFutureNext); assertTrue(context.isClosing()); assertFalse(context.isClosed()); @@ -2489,19 +2517,6 @@ void refreshKeepsCustomAssignments() { 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(); @@ -2648,7 +2663,7 @@ void closeDoesNotCallEventHandlerWhenFailed() { } @Test - void closeAsyncReturnsSameFutureWhenCalledTwice() { + void closeAsyncReturnsIndependentFuturesForTheSameAttempt() { final Context context = createReadyContext(); context.track("goal1", mapOf("amount", 125)); @@ -2659,7 +2674,7 @@ void closeAsyncReturnsSameFutureWhenCalledTwice() { final CompletableFuture closeFuture1 = context.closeAsync(); final CompletableFuture closeFuture2 = context.closeAsync(); - assertSame(closeFuture1, closeFuture2); + assertNotSame(closeFuture1, closeFuture2); publishFuture.complete(null); closeFuture1.join(); @@ -3039,4 +3054,399 @@ void cancellingPublishResultDoesNotDisturbPublishAccounting() { retryPublish.join(); assertEquals(0, context.getPendingCount()); } + + // With pending events the Close event is logged from the publish stage, after closeAsync() settles. + private static T awaitValue(final AtomicReference ref) throws InterruptedException { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (ref.get() == null && System.nanoTime() < deadline) { + Thread.sleep(5); + } + return ref.get(); + } + + private void recordFutureStateOnEvent(final ContextEventLogger.EventType type, + final AtomicReference> future, final AtomicReference doneWhenLogged) { + Mockito.doAnswer(invocation -> { + if (invocation.getArgument(1) == type && future.get() != null) { + doneWhenLogged.set(future.get().isDone()); + } + return null; + }).when(eventLogger).handleEvent(any(), any(), any()); + } + + @Test + void readyFutureSettlesBeforeReadyEventIsLogged() { + final AtomicReference> future = new AtomicReference<>(); + final AtomicReference doneWhenLogged = new AtomicReference<>(); + recordFutureStateOnEvent(ContextEventLogger.EventType.Ready, future, doneWhenLogged); + + final Context context = createContext(dataFuture); + future.set(context.waitUntilReadyAsync()); + dataFuture.complete(data); + + assertEquals(Boolean.TRUE, doneWhenLogged.get()); + assertTrue(future.get().isDone()); + } + + @Test + void readyFutureSettlesBeforeErrorEventIsLoggedOnFailedLoad() { + final AtomicReference> future = new AtomicReference<>(); + final AtomicReference doneWhenLogged = new AtomicReference<>(); + recordFutureStateOnEvent(ContextEventLogger.EventType.Error, future, doneWhenLogged); + + final Context context = createContext(dataFuture); + future.set(context.waitUntilReadyAsync()); + dataFuture.completeExceptionally(new Exception("FAILED")); + + assertEquals(Boolean.TRUE, doneWhenLogged.get()); + assertTrue(context.isFailed()); + } + + @Test + void refreshFutureSettlesBeforeRefreshEventIsLogged() { + final AtomicReference> future = new AtomicReference<>(); + final AtomicReference doneWhenLogged = new AtomicReference<>(); + recordFutureStateOnEvent(ContextEventLogger.EventType.Refresh, future, doneWhenLogged); + + final Context context = createReadyContext(); + when(dataProvider.getContextData()).thenReturn(refreshDataFuture); + future.set(context.refreshAsync()); + refreshDataFuture.complete(refreshData); + + assertEquals(Boolean.TRUE, doneWhenLogged.get()); + assertTrue(future.get().isDone()); + } + + @Test + void refreshFutureSettlesBeforeErrorEventIsLoggedOnFailure() { + final AtomicReference> future = new AtomicReference<>(); + final AtomicReference doneWhenLogged = new AtomicReference<>(); + recordFutureStateOnEvent(ContextEventLogger.EventType.Error, future, doneWhenLogged); + + final Context context = createReadyContext(); + when(dataProvider.getContextData()).thenReturn(refreshDataFuture); + future.set(context.refreshAsync()); + refreshDataFuture.completeExceptionally(new Exception("FAILED")); + + assertEquals(Boolean.TRUE, doneWhenLogged.get()); + assertTrue(future.get().isCompletedExceptionally()); + } + + @Test + void closeFutureSettlesBeforeCloseEventIsLogged() throws InterruptedException { + final AtomicReference> future = new AtomicReference<>(); + final AtomicReference doneWhenLogged = new AtomicReference<>(); + recordFutureStateOnEvent(ContextEventLogger.EventType.Close, future, doneWhenLogged); + + final Context context = createReadyContext(); + context.track("goal1", null); + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + + future.set(context.closeAsync()); + publishFuture.complete(null); + future.get().join(); + + assertEquals(Boolean.TRUE, awaitValue(doneWhenLogged)); + assertTrue(context.isClosed()); + } + + @Test + void numericUnitsKeepTheirType() { + final ContextConfig config = ContextConfig.create() + .setUnit("session_id", "abc") + .setUnit("user_id", 125) + .setUnit("float_id", 125.75); + final Context context = createContext(config, dataFutureReady); + context.setUnit("org_id", 99L); + + assertEquals(125, context.getUnitValue("user_id")); + assertEquals(125.75, context.getUnitValue("float_id")); + assertEquals(99L, context.getUnitValue("org_id")); + assertEquals("abc", context.getUnitValue("session_id")); + assertEquals(mapOf("session_id", "abc", "user_id", 125, "float_id", 125.75, "org_id", 99L), + context.getUnitValues()); + + // the 1.6.3 getters keep their String types and return the string form + assertEquals("125", context.getUnit("user_id")); + assertEquals("126", context.getUnit("float_id")); + assertEquals("99", context.getUnit("org_id")); + assertEquals("abc", context.getUnit("session_id")); + assertNull(context.getUnit("missing")); + final Map strings = context.getUnits(); + assertEquals(mapOf("session_id", "abc", "user_id", "125", "float_id", "126", "org_id", "99"), strings); + assertEquals("125", config.getUnit("user_id")); + assertEquals(125, config.getUnitValue("user_id")); + assertEquals(mapOf("session_id", "abc", "user_id", "125", "float_id", "126"), config.getUnits()); + assertEquals(mapOf("session_id", "abc", "user_id", 125, "float_id", 125.75), config.getUnitValues()); + } + + @Test + void numericUnitHashesLikeIntegerString() { + assertEquals("126", Hashing.unitToString(125.75)); + assertEquals("125", Hashing.unitToString(125)); + assertEquals("-3", Hashing.unitToString(-2.5)); + assertEquals("-0", Hashing.unitToString(-0.1)); + assertEquals("0", Hashing.unitToString(-0.0)); + assertEquals("123456789", Hashing.unitToString(123456789L)); + assertEquals("9007199254740992", Hashing.unitToString(9007199254740993L)); + assertEquals("1e+21", Hashing.unitToString(1e21)); + assertEquals("1e+23", Hashing.unitToString(1e23)); + assertEquals("6.189700196426902e+26", Hashing.unitToString(Math.scalb(1.0, 89))); + // Expected strings from Node's toFixed(0). + assertEquals("1.1805916207174113e+21", Hashing.unitToString(Math.scalb(1.0, 70))); + assertEquals("3.6267774588438875e+24", Hashing.unitToString(3 * Math.scalb(1.0, 80))); + assertEquals("1.2345678901234569e+23", Hashing.unitToString(123456789012345680000000.0)); + assertEquals("1.7976931348623157e+308", Hashing.unitToString(Double.MAX_VALUE)); + assertEquals("1.2345e+22", Hashing.unitToString(1.2345e22)); + assertEquals("-1e+21", Hashing.unitToString(-1e21)); + assertEquals("999999999999999868928", Hashing.unitToString(999999999999999900000.0)); + + final Context numeric = createContext(ContextConfig.create().setUnit("user_id", 125.75), dataFutureReady); + final Context text = createContext(ContextConfig.create().setUnit("user_id", "126"), dataFutureReady); + numeric.track("goal", null); + text.track("goal", null); + final List events = new ArrayList<>(); + Mockito.doAnswer(invocation -> { + events.add(invocation.getArgument(1)); + return CompletableFuture.completedFuture(null); + }).when(eventHandler).publish(any(), any()); + numeric.publish(); + text.publish(); + + assertEquals(2, events.size()); + assertEquals(events.get(1).units[0].uid, events.get(0).units[0].uid); + } + + @Test + void setUnitRejectsSameNumericValueOnlyWhenDifferent() { + final Context context = createContext(ContextConfig.create().setUnit("user_id", 12345), dataFutureReady); + assertDoesNotThrow(() -> context.setUnit("user_id", 12345)); + final IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> context.setUnit("user_id", 54321)); + assertEquals("Unit 'user_id' UID already set.", error.getMessage()); + } + + @Test + void setUnitChecksBlankBeforeAlreadySet() { + final Context context = createReadyContext(); + final IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> context.setUnit("user_id", " ")); + assertEquals("Unit 'user_id' UID must not be blank.", error.getMessage()); + } + + @Test + void refreshOnFailedContextIsANoOp() { + final Context context = createContext(dataFutureFailed); + assertTrue(context.isFailed()); + Mockito.clearInvocations(eventLogger); + + context.refresh(); + + verify(dataProvider, Mockito.never()).getContextData(); + Mockito.verifyNoInteractions(eventLogger); + assertTrue(context.isFailed()); + } + + @Test + void setUnitComparesLargeNumericUidsExactly() { + final Context context = createContext(ContextConfig.create().setUnit("user_id", 9007199254740992L), + dataFutureReady); + final IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> context.setUnit("user_id", 9007199254740993L)); + assertEquals("Unit 'user_id' UID already set.", error.getMessage()); + assertDoesNotThrow(() -> context.setUnit("user_id", 9007199254740992.0)); + assertEquals(9007199254740992.0, context.getUnitValue("user_id")); + } + + @Test + void setUnitRejectsMutableNumbers() { + final Context context = createReadyContext(); + final IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> context.setUnit("account_id", new java.util.concurrent.atomic.AtomicInteger(125))); + assertEquals("Unit 'account_id' must be a string or a number.", error.getMessage()); + assertNull(context.getUnit("account_id")); + + assertThrows(IllegalArgumentException.class, () -> createContext( + ContextConfig.create().setUnit("account_id", new java.util.concurrent.atomic.AtomicLong(1)), + dataFutureReady)); + } + + @Test + void lateReadinessWaitersInsideReadyEventSeeSettledFuture() { + final AtomicReference lateWaiterDone = new AtomicReference<>(); + final AtomicReference contextRef = new AtomicReference<>(); + Mockito.doAnswer(invocation -> { + if (invocation.getArgument(1) == ContextEventLogger.EventType.Ready) { + lateWaiterDone.set(contextRef.get().waitUntilReadyAsync().isDone()); + } + return null; + }).when(eventLogger).handleEvent(any(), any(), any()); + + final Context context = createContext(dataFuture); + contextRef.set(context); + dataFuture.complete(data); + + assertEquals(Boolean.TRUE, lateWaiterDone.get()); + assertTrue(context.waitUntilReadyAsync().isDone()); + } + + @Test + void lateCloseCallersInsideCloseEventSeeSettledFuture() throws InterruptedException { + final AtomicReference lateCloseDone = new AtomicReference<>(); + final AtomicReference contextRef = new AtomicReference<>(); + Mockito.doAnswer(invocation -> { + if (invocation.getArgument(1) == ContextEventLogger.EventType.Close) { + lateCloseDone.set(contextRef.get().closeAsync().isDone()); + } + return null; + }).when(eventLogger).handleEvent(any(), any(), any()); + + final Context context = createReadyContext(); + contextRef.set(context); + context.track("goal1", null); + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + + final CompletableFuture closing = context.closeAsync(); + publishFuture.complete(null); + closing.join(); + + assertEquals(Boolean.TRUE, awaitValue(lateCloseDone)); + assertTrue(context.closeAsync().isDone()); + } + + @Test + void lateCloseCallersInsideCloseEventSeeSettledFutureWithNothingPending() { + final AtomicReference lateCloseDone = new AtomicReference<>(); + final AtomicReference contextRef = new AtomicReference<>(); + Mockito.doAnswer(invocation -> { + if (invocation.getArgument(1) == ContextEventLogger.EventType.Close) { + lateCloseDone.set(contextRef.get().closeAsync().isDone()); + } + return null; + }).when(eventLogger).handleEvent(any(), any(), any()); + + final Context context = createReadyContext(); + contextRef.set(context); + context.close(); + + assertEquals(Boolean.TRUE, lateCloseDone.get()); + assertTrue(context.closeAsync().isDone()); + } + + @Test + @Timeout(60) + void concurrentClosesLogOneCloseEvent() throws Exception { + final ExecutorService pool = Executors.newFixedThreadPool(3); + try { + for (int i = 0; i < 3000; ++i) { + final java.util.concurrent.atomic.AtomicInteger closeEvents = new java.util.concurrent.atomic.AtomicInteger(); + final ContextEventLogger logger = (ctx, type, data) -> { + if (type == ContextEventLogger.EventType.Close) { + closeEvents.incrementAndGet(); + } + }; + final Context context = Context.create(clock, ContextConfig.create().setUnits(units), scheduler, + dataFutureReady, dataProvider, eventHandler, logger, variableParser, audienceMatcher); + final java.util.concurrent.CyclicBarrier start = new java.util.concurrent.CyclicBarrier(3); + final List> results = new ArrayList<>(); + for (int t = 0; t < 3; ++t) { + results.add(pool.submit(() -> { + start.await(); + context.closeAsync().join(); + return true; + })); + } + for (final java.util.concurrent.Future result : results) { + assertTrue(result.get()); + } + assertEquals(1, closeEvents.get(), "Close event logged more than once"); + } + } finally { + pool.shutdownNow(); + } + } + + @Test + void cancellingOneCloseFutureWhilePublishingDoesNotDisturbOtherCallers() { + final Context context = createReadyContext(); + context.track("goal1", null); + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + + final CompletableFuture first = context.closeAsync(); + final CompletableFuture second = context.closeAsync(); + assertTrue(first.cancel(false)); + + assertFalse(second.isDone()); + final CompletableFuture third = context.closeAsync(); + assertFalse(third.isDone()); + assertTrue(context.isClosing()); + + publishFuture.complete(null); + second.join(); + third.join(); + + assertFalse(second.isCancelled()); + assertFalse(third.isCancelled()); + assertTrue(context.isClosed()); + final CompletableFuture after = context.closeAsync(); + assertTrue(after.isDone()); + assertFalse(after.isCompletedExceptionally()); + } + + @Test + void externallyCompletingCloseFutureDoesNotReleaseOtherCallers() { + final Context context = createReadyContext(); + context.track("goal1", null); + final CompletableFuture publish = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publish); + final CompletableFuture first = context.closeAsync(); + final CompletableFuture second = context.closeAsync(); + first.completeAsync(() -> null).join(); + assertFalse(second.isDone()); + assertFalse(context.closeAsync().isDone()); + publish.complete(null); + second.join(); + assertTrue(context.isClosed()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void eventLoggerMayWaitForReadinessInsideReadyEvent() { + final AtomicReference readyInLogger = new AtomicReference<>(); + final AtomicReference contextRef = new AtomicReference<>(); + Mockito.doAnswer(invocation -> { + if (invocation.getArgument(1) == ContextEventLogger.EventType.Ready) { + contextRef.get().waitUntilReady(); + contextRef.get().waitUntilReadyAsync().join(); + readyInLogger.set(contextRef.get().isReady()); + } + return null; + }).when(eventLogger).handleEvent(any(), any(), any()); + + final Context context = createContext(dataFuture); + contextRef.set(context); + dataFuture.complete(data); + + assertEquals(Boolean.TRUE, readyInLogger.get()); + } + + @Test + void customFieldReadsStillWorkWhileClosingAndAfterClose() { + final Context context = createReadyContext(); + final String[] keys = context.getCustomFieldKeys(); + context.track("goal1", null); + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + final CompletableFuture closing = context.closeAsync(); + assertTrue(context.isClosing()); + assertArrayEquals(keys, context.getCustomFieldKeys()); + + publishFuture.complete(null); + closing.join(); + assertTrue(context.isClosed()); + assertArrayEquals(keys, context.getCustomFieldKeys()); + } }