From 2e7a8d80fb5c836311e7db3fe574e6e3b5d6474c Mon Sep 17 00:00:00 2001 From: Jamie Sinn Date: Wed, 19 Aug 2026 15:16:58 -0400 Subject: [PATCH 1/2] ICP-8826: Workaround for allocation issue in wasmtime-java wasmtime-java has a leak whenever any linker function is retrieved, thus resulting in a massive leak every iteration when it's fetched as part of the variable evaluation hot path. This adopts the same premise as the C# SDK where storing this reduces, but does not resolve the leak entirely. This minimizes the cost, but it still has a large initial RSS leak that cannot be fixed as each function is initialized/mapped. Due to the EOL of DVC coming - this is the only fix we can reasonably make with acceptable turnarounds. --- .github/workflows/release.yml | 2 +- .../local/bucketing/LocalBucketing.java | 143 ++++++++++-------- 2 files changed, 78 insertions(+), 67 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62eded8..99761dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,7 @@ jobs: - name: Commit version change run: | - git config --global user.email "github-tracker-bot@taplytics.com" + git config --global user.email "foundation-admin@devcycle.com" git config --global user.name "DevCycle Automation" git add ./build.gradle git add ./src/main/java/com/devcycle/sdk/server/common/model/PlatformData.java diff --git a/src/main/java/com/devcycle/sdk/server/local/bucketing/LocalBucketing.java b/src/main/java/com/devcycle/sdk/server/local/bucketing/LocalBucketing.java index 8c7505e..af0a8c7 100644 --- a/src/main/java/com/devcycle/sdk/server/local/bucketing/LocalBucketing.java +++ b/src/main/java/com/devcycle/sdk/server/local/bucketing/LocalBucketing.java @@ -40,6 +40,23 @@ public class LocalBucketing { private final Logger logger = Logger.getLogger(LocalBucketing.class.getName()); private final Map configMetadataCache = new HashMap<>(); + private final WasmFunctions.Function2 newFn; + private final WasmFunctions.Consumer1 pinFn; + private final WasmFunctions.Consumer1 unpinFn; + private final WasmFunctions.Function1 variableForUserPBFn; + private final WasmFunctions.Consumer2 setConfigDataFn; + private final WasmFunctions.Consumer1 setPlatformDataFn; + private final WasmFunctions.Consumer2 setClientCustomDataFn; + private final WasmFunctions.Function2 generateBucketedConfigFn; + private final WasmFunctions.Consumer3 initEventQueueFn; + private final WasmFunctions.Consumer3 queueEventFn; + private final WasmFunctions.Consumer3 queueAggregateEventFn; + private final WasmFunctions.Function1 flushEventQueueFn; + private final WasmFunctions.Consumer3 onPayloadFailureFn; + private final WasmFunctions.Consumer2 onPayloadSuccessFn; + private final WasmFunctions.Function1 eventQueueSizeFn; + private final WasmFunctions.Function1 getConfigMetadataFn; + public LocalBucketing() { OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); @@ -64,6 +81,24 @@ public LocalBucketing() { Memory mem = linker.get(store, "", "memory").get().memory(); memRef.set(mem); + newFn = WasmFunctions.func(store, export("__new"), I32, I32, I32); + pinFn = WasmFunctions.consumer(store, export("__pin"), I32); + unpinFn = WasmFunctions.consumer(store, export("__unpin"), I32); + variableForUserPBFn = WasmFunctions.func(store, export("variableForUser_PB"), I32, I32); + setConfigDataFn = WasmFunctions.consumer(store, export("setConfigDataUTF8"), I32, I32); + setPlatformDataFn = WasmFunctions.consumer(store, export("setPlatformDataUTF8"), I32); + setClientCustomDataFn = WasmFunctions.consumer(store, export("setClientCustomDataUTF8"), I32, I32); + generateBucketedConfigFn = + WasmFunctions.func(store, export("generateBucketedConfigForUserUTF8"), I32, I32, I32); + initEventQueueFn = WasmFunctions.consumer(store, export("initEventQueue"), I32, I32, I32); + queueEventFn = WasmFunctions.consumer(store, export("queueEvent"), I32, I32, I32); + queueAggregateEventFn = WasmFunctions.consumer(store, export("queueAggregateEvent"), I32, I32, I32); + flushEventQueueFn = WasmFunctions.func(store, export("flushEventQueue"), I32, I32); + onPayloadFailureFn = WasmFunctions.consumer(store, export("onPayloadFailure"), I32, I32, I32); + onPayloadSuccessFn = WasmFunctions.consumer(store, export("onPayloadSuccess"), I32, I32); + eventQueueSizeFn = WasmFunctions.func(store, export("eventQueueSize"), I32, I32); + getConfigMetadataFn = WasmFunctions.func(store, export("getConfigMetadata"), I32, I32); + // WASM time seems problematic for getting global values so we'll just hardcode them variableTypeMap.put(Variable.TypeEnum.BOOLEAN, 0); variableTypeMap.put(Variable.TypeEnum.NUMBER, 1); @@ -71,6 +106,30 @@ public LocalBucketing() { variableTypeMap.put(Variable.TypeEnum.JSON, 3); } + /** + * Resolves a WASM export once, at construction time. + * + *

The returned {@link Func} owns a native handle that is only released by + * {@code Func.dispose()}, so these must not be fetched per call. + * + *

Resolving here rather than per call moves a missing-export failure from first use to + * construction. That is deliberate: {@code DevCycleLocalClient.variable} catches + * {@link Throwable} and falls back to the default value, so a missing export used to be + * swallowed into silently-defaulted flags for the lifetime of the process. The bundled + * {@code bucketing-lib.release.wasm} is pinned by the build, so a missing export means a broken + * build and should fail loudly and immediately. + * + *

Runtime error behaviour is otherwise unchanged: WASM traps still surface as + * {@code WasmtimeException} from the calling method, and a trap does not invalidate these + * bindings. See {@code LocalBucketingErrorHandlingTest}. + */ + private Func export(String name) { + return linker.get(store, "", name) + .orElseThrow(() -> new IllegalStateException( + "bucketing-lib.release.wasm is missing the '" + name + "' export")) + .func(); + } + private Collection setImportsOnLinker() { Func dateNowFn = WasmFunctions.wrap(store, F64, () -> { return (double) System.currentTimeMillis(); @@ -102,12 +161,8 @@ private int newWasmString(String param) { int objectIdString = 1; // id 1 represents string class in wasm - Func __newPtr = linker.get(store, "", "__new").get().func(); // get pointer to __new function - WasmFunctions.Function2 __new = WasmFunctions.func( - store, __newPtr, I32, I32, I32); // load __new function - byte[] paramBytes = param.getBytes(StandardCharsets.UTF_8); - int paramAddress = __new.call(paramBytes.length * 2, objectIdString); // allocate memory in store for a string with this length and get start address + int paramAddress = newFn.call(paramBytes.length * 2, objectIdString); // allocate memory in store for a string with this length and get start address ByteBuffer buf = memRef.get().buffer(store); for (int i = 0; i < paramBytes.length; i++) { @@ -136,14 +191,10 @@ private String readWasmString(int startAddress) { private int newUint8ArrayParameter(byte[] paramData) { int length = paramData.length; - Func __newPtr = linker.get(store, "", "__new").get().func(); // get pointer to __new function - WasmFunctions.Function2 __new = WasmFunctions.func( - store, __newPtr, I32, I32, I32); // load __new function - - int headerAddr = __new.call(12, WASM_OBJECT_ID_UINT8ARRAY); + int headerAddr = newFn.call(12, WASM_OBJECT_ID_UINT8ARRAY); try { pinParameter(headerAddr); - int dataBufferAddr = __new.call(length, WASM_OBJECT_ID_STRING); + int dataBufferAddr = newFn.call(length, WASM_OBJECT_ID_STRING); byte[] headerData = new byte[12]; byte[] bufferAddrBytes = ByteConversionUtils.intToBytesLittleEndian(dataBufferAddr); @@ -202,27 +253,21 @@ public synchronized void storeConfig(String sdkKey, String config) { int sdkKeyAddress = getSDKKeyAddress(sdkKey); int configAddress = newUint8ArrayParameter(config.getBytes(StandardCharsets.UTF_8)); - Func setConfigDataPtr = linker.get(store, "", "setConfigDataUTF8").get().func(); - WasmFunctions.Consumer2 fn = WasmFunctions.consumer(store, setConfigDataPtr, I32, I32); - fn.accept(sdkKeyAddress, configAddress); + setConfigDataFn.accept(sdkKeyAddress, configAddress); configMetadataCache.put(sdkKey, internalGetConfigMetadata(sdkKeyAddress)); } public synchronized void setPlatformData(String platformData) { unPinAllEventIds(); int platformDataAddress = newUint8ArrayParameter(platformData.getBytes(StandardCharsets.UTF_8)); - Func setPlatformDataPtr = linker.get(store, "", "setPlatformDataUTF8").get().func(); - WasmFunctions.Consumer1 fn = WasmFunctions.consumer(store, setPlatformDataPtr, I32); - fn.accept(platformDataAddress); + setPlatformDataFn.accept(platformDataAddress); } public synchronized void setClientCustomData(String sdkKey, String customData) { unPinAllEventIds(); int sdkKeyAddress = getSDKKeyAddress(sdkKey); int customDataAddress = newUint8ArrayParameter(customData.getBytes(StandardCharsets.UTF_8)); - Func setCustomClientDataPtr = linker.get(store, "", "setClientCustomDataUTF8").get().func(); - WasmFunctions.Consumer2 fn = WasmFunctions.consumer(store, setCustomClientDataPtr, I32, I32); - fn.accept(sdkKeyAddress, customDataAddress); + setClientCustomDataFn.accept(sdkKeyAddress, customDataAddress); } public synchronized BucketedUserConfig generateBucketedConfig(String sdkKey, DevCycleUser user) throws JsonProcessingException { @@ -232,11 +277,7 @@ public synchronized BucketedUserConfig generateBucketedConfig(String sdkKey, Dev int sdkKeyAddress = getSDKKeyAddress(sdkKey); int userAddress = newUint8ArrayParameter(userString.getBytes(StandardCharsets.UTF_8)); - Func generateBucketedConfigForUserPtr = linker.get(store, "", "generateBucketedConfigForUserUTF8").get().func(); - WasmFunctions.Function2 generateBucketedConfigForUser = WasmFunctions.func( - store, generateBucketedConfigForUserPtr, I32, I32, I32); - - int resultAddress = generateBucketedConfigForUser.call(sdkKeyAddress, userAddress); + int resultAddress = generateBucketedConfigFn.call(sdkKeyAddress, userAddress); byte[] bucketConfigBytes = readAssemblyScriptUint8Array(resultAddress); String bucketedConfigString = new String(bucketConfigBytes, StandardCharsets.UTF_8); @@ -250,11 +291,7 @@ public synchronized BucketedUserConfig generateBucketedConfig(String sdkKey, Dev public synchronized byte[] getVariableForUserProtobuf(byte[] serializedParams) { int paramsAddr = newUint8ArrayParameter(serializedParams); - Func getVariablePtr = linker.get(store, "", "variableForUser_PB").get().func(); - WasmFunctions.Function1 variableForUserPB = WasmFunctions.func( - store, getVariablePtr, I32, I32); - - int variableAddress = variableForUserPB.call(paramsAddr); + int variableAddress = variableForUserPBFn.call(paramsAddr); byte[] varBytes = null; if (variableAddress > 0) { @@ -270,9 +307,7 @@ public synchronized void initEventQueue(String sdkKey, String clientUUID, String int clientUUIDAddress = newWasmString(clientUUID); int optionsAddress = newWasmString(options); - Func initEventQueuePtr = linker.get(store, "", "initEventQueue").get().func(); - WasmFunctions.Consumer3 fn = WasmFunctions.consumer(store, initEventQueuePtr, I32, I32, I32); - fn.accept(sdkKeyAddress, clientUUIDAddress, optionsAddress); + initEventQueueFn.accept(sdkKeyAddress, clientUUIDAddress, optionsAddress); } public synchronized void queueEvent(String sdkKey, String user, String event) { @@ -281,9 +316,7 @@ public synchronized void queueEvent(String sdkKey, String user, String event) { int userAddress = getPinnedEventId(user); int eventAddress = newWasmString(event); - Func queueEventPtr = linker.get(store, "", "queueEvent").get().func(); - WasmFunctions.Consumer3 fn = WasmFunctions.consumer(store, queueEventPtr, I32, I32, I32); - fn.accept(sdkKeyAddress, userAddress, eventAddress); + queueEventFn.accept(sdkKeyAddress, userAddress, eventAddress); } public synchronized void queueAggregateEvent(String sdkKey, String event, String variableVariationMap) { @@ -292,20 +325,14 @@ public synchronized void queueAggregateEvent(String sdkKey, String event, String int eventAddress = getPinnedEventId(event); int variableVariationMapAddress = newWasmString(variableVariationMap); - Func queueAggregateEventPtr = linker.get(store, "", "queueAggregateEvent").get().func(); - WasmFunctions.Consumer3 fn = WasmFunctions.consumer(store, queueAggregateEventPtr, I32, I32, I32); - fn.accept(sdkKeyAddress, eventAddress, variableVariationMapAddress); + queueAggregateEventFn.accept(sdkKeyAddress, eventAddress, variableVariationMapAddress); } public synchronized FlushPayload[] flushEventQueue(String sdkKey) throws JsonProcessingException { unPinAllEventIds(); int sdkKeyAddress = getSDKKeyAddress(sdkKey); - Func flushEventQueuePtr = linker.get(store, "", "flushEventQueue").get().func(); - WasmFunctions.Function1 fn = WasmFunctions.func( - store, flushEventQueuePtr, I32, I32); - - int resultAddress = fn.call(sdkKeyAddress); + int resultAddress = flushEventQueueFn.call(sdkKeyAddress); String flushPayloadsStr = readWasmString(resultAddress); ObjectMapper objectMapper = new ObjectMapper(); @@ -325,9 +352,7 @@ public synchronized void onPayloadFailure(String sdkKey, String payloadId, boole int sdkKeyAddress = getSDKKeyAddress(sdkKey); int payloadIdAddress = newWasmString(payloadId); - Func onPayloadFailurePtr = linker.get(store, "", "onPayloadFailure").get().func(); - WasmFunctions.Consumer3 fn = WasmFunctions.consumer(store, onPayloadFailurePtr, I32, I32, I32); - fn.accept(sdkKeyAddress, payloadIdAddress, retryable ? 1 : 0); + onPayloadFailureFn.accept(sdkKeyAddress, payloadIdAddress, retryable ? 1 : 0); } public synchronized void onPayloadSuccess(String sdkKey, String payloadId) { @@ -335,32 +360,22 @@ public synchronized void onPayloadSuccess(String sdkKey, String payloadId) { int sdkKeyAddress = getSDKKeyAddress(sdkKey); int payloadIdAddress = newWasmString(payloadId); - Func onPayloadSuccessPtr = linker.get(store, "", "onPayloadSuccess").get().func(); - WasmFunctions.Consumer2 fn = WasmFunctions.consumer(store, onPayloadSuccessPtr, I32, I32); - fn.accept(sdkKeyAddress, payloadIdAddress); + onPayloadSuccessFn.accept(sdkKeyAddress, payloadIdAddress); } public synchronized int getEventQueueSize(String sdkKey) { unPinAllEventIds(); int sdkKeyAddress = getSDKKeyAddress(sdkKey); - Func getEventQueueSizePtr = linker.get(store, "", "eventQueueSize").get().func(); - WasmFunctions.Function1 getEventQueueSize = WasmFunctions.func( - store, getEventQueueSizePtr, I32, I32); - - return getEventQueueSize.call(sdkKeyAddress); + return eventQueueSizeFn.call(sdkKeyAddress); } private void pinParameter(int address) { - Func pinPtr = linker.get(store, "", "__pin").get().func(); - WasmFunctions.Consumer1 pin = WasmFunctions.consumer(store, pinPtr, I32); - pin.accept(address); + pinFn.accept(address); } private void unpinParameter(int address) { - Func unpinPtr = linker.get(store, "", "__unpin").get().func(); - WasmFunctions.Consumer1 unpin = WasmFunctions.consumer(store, unpinPtr, I32); - unpin.accept(address); + unpinFn.accept(address); } private void unPinAllEventIds() { @@ -417,11 +432,7 @@ public String getConfigMetadata(String sdkKey) { } private String internalGetConfigMetadata(int sdkKeyAddress) { - Func getConfigMetadataPtr = linker.get(store, "", "getConfigMetadata").get().func(); - WasmFunctions.Function1 getConfigMetadata = WasmFunctions.func( - store, getConfigMetadataPtr, I32, I32); - - int resultAddress = getConfigMetadata.call(sdkKeyAddress); + int resultAddress = getConfigMetadataFn.call(sdkKeyAddress); return readWasmString(resultAddress); } } From 038d694d4e0a54d0e7c0b6dacbe3d689cae0d7e1 Mon Sep 17 00:00:00 2001 From: Jamie Sinn Date: Wed, 19 Aug 2026 15:34:13 -0400 Subject: [PATCH 2/2] ICP-8826: remove local test reference --- .../devcycle/sdk/server/local/bucketing/LocalBucketing.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/devcycle/sdk/server/local/bucketing/LocalBucketing.java b/src/main/java/com/devcycle/sdk/server/local/bucketing/LocalBucketing.java index af0a8c7..689473c 100644 --- a/src/main/java/com/devcycle/sdk/server/local/bucketing/LocalBucketing.java +++ b/src/main/java/com/devcycle/sdk/server/local/bucketing/LocalBucketing.java @@ -119,9 +119,6 @@ public LocalBucketing() { * {@code bucketing-lib.release.wasm} is pinned by the build, so a missing export means a broken * build and should fail loudly and immediately. * - *

Runtime error behaviour is otherwise unchanged: WASM traps still surface as - * {@code WasmtimeException} from the calling method, and a trap does not invalidate these - * bindings. See {@code LocalBucketingErrorHandlingTest}. */ private Func export(String name) { return linker.get(store, "", name)