From e02ea26d6679b9b6cd8d13f02a927e678120b7bb Mon Sep 17 00:00:00 2001 From: Matteo Merli Date: Mon, 31 Aug 2026 18:07:37 -0700 Subject: [PATCH 1/2] Release data references in clear() for messages above CLEAR_RETAIN_MAX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #11, clear() is O(1): it resets counts and presence bits but leaves data references in place (cached Strings in StringHolders, ByteBuf refs in BytesHolders, singular string/bytes values). A reused message instance — one per connection in Pulsar's PulsarDecoder, thread-locals in Commands — therefore pins the last message's data until the same field is overwritten. For multi-MB messages this is a leak-shaped retention: in the proxy back-pressure test every connection that ever parsed the ~4.6 MB topic-list response kept it on the decoder's BaseCommand, ~900 MB across 200 connections, OOMing the run even with the write-side scratch fix in place. clear() now gates on the previous message's size, which it already knows in O(1): _cachedSize is maintained by parseFrom() and getSerializedSize(). At or below CLEAR_RETAIN_MAX (64 KiB) nothing changes — the O(1) clear runs bit for bit as before, retaining at most that much per instance. Above it (or at -1: mutated since, unknown fields on the wire, or already cleared — over cleared fields the walk touches nothing), clear() takes a generated _clearAndRelease() path that nulls the retained references and recurses into nested messages. The recursion is forced — children do not re-check their own size gate — so a large message spread across many small children (each below the threshold) still releases everything. The release walk costs O(element count), which is noise for any message large enough to trigger it. The gate sits on the whole-message size rather than per field or per holder: per-holder gating misses many-small-elements aggregates (8192 x 560-byte topic names), and per-node gating misses fan-out shapes. ClearReleaseTest covers both sides via WeakReferences: release of built, parsed-and-materialized, fan-out-nested and bytes-payload data above the threshold; retention (the deliberate O(1) behavior) below it; the conservative -1 path; and byte-identical reuse after a deep clear. --- .../generator/LightProtoBytesField.java | 5 + .../lightproto/generator/LightProtoField.java | 11 ++ .../generator/LightProtoMapField.java | 25 +++ .../generator/LightProtoMessage.java | 35 ++++ .../generator/LightProtoMessageField.java | 9 + .../LightProtoRepeatedBytesField.java | 8 + .../LightProtoRepeatedMessageField.java | 9 + .../LightProtoRepeatedStringField.java | 8 + .../generator/LightProtoStringField.java | 5 + .../lightproto/generator/LightProtoCodec.java | 7 + .../lightproto/tests/ClearReleaseTest.java | 177 ++++++++++++++++++ 11 files changed, 299 insertions(+) create mode 100644 tests/src/test/java/io/streamnative/lightproto/tests/ClearReleaseTest.java diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java index d4c41f9..9988ee9 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java @@ -103,6 +103,11 @@ public void clear(PrintWriter w) { // parse() invalidates the stale buffer reference for fields present on the wire. } + @Override + public void clearRelease(PrintWriter w) { + w.format("%s = null;\n", ccName); + } + @Override public void serializedSize(PrintWriter w) { w.format("_size += %s_SIZE;\n", tagName()); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java index 5f0e843..f10c737 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java @@ -129,6 +129,17 @@ public void has(PrintWriter w) { abstract public void clear(PrintWriter w); + /** + * Emit this field's contribution to the generated {@code _clearAndRelease()}: + * like {@link #clear(PrintWriter)} but also releasing retained data references + * (cached Strings, ByteBuf refs), recursing into nested messages via their + * {@code _clearAndRelease()}. Fields that retain no references inherit this + * default, which emits the plain clear code. + */ + public void clearRelease(PrintWriter w) { + clear(w); + } + public void fieldClear(PrintWriter w, String enclosingType) { w.format(" /** Clear the {@code %s} field. */\n", field.getName()); w.format(" public %s %s() {\n", enclosingType, Util.camelCase("clear", field.getName())); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java index 95da500..bbd288e 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java @@ -971,6 +971,31 @@ public void clear(PrintWriter w) { w.format("_%sIndex = null;\n", ccName); } + @Override + public void clearRelease(PrintWriter w) { + if (isStringKey()) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", ccName); + w.format(" _%sKeys[i].s = null;\n", ccName); + w.format("}\n"); + } + if (isStringValue()) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", ccName); + w.format(" _%sValues[i].s = null;\n", ccName); + w.format("}\n"); + } else if (isBytesValue()) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", ccName); + w.format(" _%sValues[i].b = null;\n", ccName); + w.format("}\n"); + } else if (isMessageValue()) { + // Forced recursion — see LightProtoMessageField#clearRelease. + w.format("for (int i = 0; i < _%sCount; i++) {\n", ccName); + w.format(" _%sValues[i]._clearAndRelease();\n", ccName); + w.format("}\n"); + } + w.format("_%sCount = 0;\n", ccName); + w.format("_%sIndex = null;\n", ccName); + } + @Override public void materialize(PrintWriter w) { if (isStringKey()) { diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java index 1829821..07298d4 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java @@ -96,6 +96,7 @@ public void generate(PrintWriter w) { generateParseFrom(w); generateCheckRequiredFields(w); generateClear(w); + generateClearAndRelease(w); generateCopyFrom(w); if (generateJson) { @@ -285,6 +286,13 @@ private void emitBitDrivenTraversal(PrintWriter w, java.util.function.Consumer LightProtoCodec.CLEAR_RETAIN_MAX || _cachedSize < 0) {\n"); + w.format(" return _clearAndRelease();\n"); + w.format(" }\n"); boolean bitDriven = useBitDrivenClear(); for (LightProtoField f : fields) { if (bitDriven && f instanceof LightProtoMessageField && !f.isOneofMember()) { @@ -335,6 +343,33 @@ private void generateClear(PrintWriter w) { w.format(" }\n"); } + private void generateClearAndRelease(PrintWriter w) { + w.println(" /**"); + w.println(" * clear() variant for messages above CLEAR_RETAIN_MAX (or of unknown"); + w.println(" * size) that also releases the data references the O(1) clear() leaves"); + w.println(" * in place, so a reused (pooled or per-connection) instance doesn't pin"); + w.println(" * the last message's data. Recursion into nested messages is forced —"); + w.println(" * children don't re-check the size gate, or a large message spread over"); + w.println(" * many small children would release nothing. Public only so that"); + w.println(" * generated messages in other packages can release nested fields of"); + w.println(" * this type."); + w.println(" */"); + w.format(" public %s _clearAndRelease() {\n", message.getName()); + for (LightProtoField f : fields) { + f.clearRelease(w); + } + w.format(" _parsedBuffer = null;\n"); + w.format(" _cachedSize = -1;\n"); + for (int i = 0; i < bitFieldsCount(); i++) { + w.format(" _bitField%d = 0;\n", i); + } + for (ProtoOneofDescriptor oneof : oneofs) { + w.format(" _%sCase = 0;\n", Util.camelCase(oneof.getName())); + } + w.format(" return this;\n"); + w.format(" }\n"); + } + private void generateCopyFrom(PrintWriter w) { w.println(" /** Copy all fields from another message of the same type. */"); w.format("public %s copyFrom(%s _other) {\n", message.getName(), message.getName()); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java index 218711b..48a7290 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java @@ -111,6 +111,15 @@ public void clear(PrintWriter w) { w.format("}\n"); } + @Override + public void clearRelease(PrintWriter w) { + // Forced recursion: the child must not re-check its own size gate, or a + // large parent spread across many small children would release nothing. + w.format("if (%s()){\n", Util.camelCase("has", ccName)); + w.format(" %s._clearAndRelease();\n", ccName); + w.format("}\n"); + } + @Override public void materialize(PrintWriter w) { w.format("if (%s()) {\n", Util.camelCase("has", ccName)); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java index 9700531..48e54af 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java @@ -211,6 +211,14 @@ public void clear(PrintWriter w) { w.format("_%sCount = 0;\n", pluralName); } + @Override + public void clearRelease(PrintWriter w) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); + w.format(" %s[i].b = null;\n", pluralName); + w.format("}\n"); + w.format("_%sCount = 0;\n", pluralName); + } + @Override public void materialize(PrintWriter w) { w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java index 16a4049..87648f0 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java @@ -183,6 +183,15 @@ public void clear(PrintWriter w) { w.format("_%sCount = 0;\n", pluralName); } + @Override + public void clearRelease(PrintWriter w) { + // Forced recursion — see LightProtoMessageField#clearRelease. + w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); + w.format(" %s[i]._clearAndRelease();\n", pluralName); + w.format("}\n"); + w.format("_%sCount = 0;\n", pluralName); + } + @Override public void materialize(PrintWriter w) { w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java index a0477b8..6a1a67f 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java @@ -203,6 +203,14 @@ public void clear(PrintWriter w) { w.format("_%sCount = 0;\n", pluralName); } + @Override + public void clearRelease(PrintWriter w) { + w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); + w.format(" %s[i].s = null;\n", pluralName); + w.format("}\n"); + w.format("_%sCount = 0;\n", pluralName); + } + @Override public void materialize(PrintWriter w) { w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java index ea37d75..bfefae8 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java @@ -83,6 +83,11 @@ public void clear(PrintWriter w) { // parse() invalidates the cached decoded String for fields present on the wire. } + @Override + public void clearRelease(PrintWriter w) { + w.format("%s = null;\n", ccName); + } + @Override public void serializedSize(PrintWriter w) { w.format("_size += %s_SIZE;\n", tagName()); diff --git a/code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java b/code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java index 77eb322..00fb353 100644 --- a/code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java +++ b/code-generator/src/main/resources/io/streamnative/lightproto/generator/LightProtoCodec.java @@ -402,6 +402,13 @@ static void writeString(ByteBuf b, String s, int bytesCount) { // so outlier messages don't pin large allocations. static final int SCRATCH_RETAIN_MAX = 1024 * 1024; + // clear() of a message larger than this (or of unknown size) releases the + // data references retained by the O(1) clear design, so a reused (pooled or + // per-connection) instance pins at most this much of the last message's + // data. The release walk costs O(element count), which is noise for any + // message this large; below the threshold the walk is skipped entirely. + static final int CLEAR_RETAIN_MAX = 64 * 1024; + /** Returns current if it can hold size bytes, otherwise a larger replacement. */ static byte[] scratchFor(byte[] current, int size) { if (current != null && current.length >= size) { diff --git a/tests/src/test/java/io/streamnative/lightproto/tests/ClearReleaseTest.java b/tests/src/test/java/io/streamnative/lightproto/tests/ClearReleaseTest.java new file mode 100644 index 0000000..0762c2d --- /dev/null +++ b/tests/src/test/java/io/streamnative/lightproto/tests/ClearReleaseTest.java @@ -0,0 +1,177 @@ +/** + * Copyright 2026 StreamNative + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.streamnative.lightproto.tests; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.ref.WeakReference; + +import org.junit.jupiter.api.Test; + +/** + * clear() of a message above {@code CLEAR_RETAIN_MAX} (or of unknown size) must + * release the data references the O(1) clear leaves in place, so a reused + * (pooled or per-connection) instance doesn't pin the last message's data — + * the 0.8.0 regression that kept multi-MB topic lists alive on every Pulsar + * connection decoder. Below the threshold the O(1) clear is unchanged and may + * retain. + */ +public class ClearReleaseTest { + + /** Unique, non-interned marker string so a WeakReference observes liveness. */ + private static String marker() { + return new String("marker-" + System.nanoTime() + "-x".repeat(64)); + } + + private static void assertEventuallyCollected(WeakReference ref) throws InterruptedException { + for (int i = 0; i < 100 && ref.get() != null; i++) { + System.gc(); + Thread.sleep(10); + } + assertNull(ref.get(), "reference should have been released by clear()"); + } + + /** Adds ~size bytes of repeated names plus one weakly-tracked marker. */ + private static WeakReference fillNames(S s, int size) { + String m = marker(); + s.addName(m); + int chunk = 500; + for (int i = 0; i < size / chunk; i++) { + s.addName("n".repeat(chunk)); + } + return new WeakReference<>(m); + } + + @Test + public void testLargeBuiltMessageReleasesStringsOnClear() throws Exception { + S s = new S().setId("big"); + WeakReference ref = fillNames(s, 100 * 1024); + assertTrue(s.getSerializedSize() > LightProtoCodec.CLEAR_RETAIN_MAX); + + s.clear(); + assertEventuallyCollected(ref); + } + + @Test + public void testSmallMessageKeepsO1ClearRetention() { + S s = new S().setId("small"); + WeakReference ref = fillNames(s, 8 * 1024); + assertTrue(s.getSerializedSize() <= LightProtoCodec.CLEAR_RETAIN_MAX); + + s.clear(); + System.gc(); + // The O(1) clear path deliberately retains: the holder still references + // the string, so it must survive GC. + assertNotNull(ref.get()); + } + + @Test + public void testUnknownSizeReleasesConservatively() throws Exception { + // Never serialized nor cleanly parsed: _cachedSize is -1, so clear() + // cannot know the message was small and must take the release path. + S s = new S().setId("unsized"); + WeakReference ref = fillNames(s, 8 * 1024); + + s.clear(); + assertEventuallyCollected(ref); + } + + /** + * The Pulsar decoder shape: one reused message per connection, parseFrom() + * per command (which invokes clear() on the previous contents), getters + * materializing the strings. The next parse must release them. + */ + @Test + public void testParseReuseReleasesMaterializedStrings() throws Exception { + S big = new S().setId("big"); + fillNames(big, 100 * 1024); + byte[] bigBytes = big.toByteArray(); + byte[] smallBytes = new S().setId("small").toByteArray(); + + S reused = new S(); + reused.parseFrom(bigBytes); + WeakReference ref = new WeakReference<>(reused.getNameAt(0)); + + reused.parseFrom(smallBytes); + assertEquals("small", reused.getId()); + assertEventuallyCollected(ref); + } + + /** + * A large message spread over many small children: every child is far below + * the threshold, so the release must recurse unconditionally once the + * top-level gate triggers. + */ + @Test + public void testFanOutReleasesThroughForcedRecursion() throws Exception { + M m = new M(); + String v = marker(); + WeakReference ref = new WeakReference<>(v); + for (int i = 0; i < 2000; i++) { + M.KV kv = m.addItem(); + kv.setK("key-" + "k".repeat(40) + i); + kv.setV(i == 0 ? v : "val-" + "v".repeat(40) + i); + } + v = null; + assertTrue(m.getSerializedSize() > LightProtoCodec.CLEAR_RETAIN_MAX); + + m.clear(); + assertEventuallyCollected(ref); + } + + @Test + public void testLargeBytesPayloadReleasedOnClear() throws Exception { + byte[] payload = new byte[2 * 1024 * 1024]; + WeakReference ref = new WeakReference<>(payload); + B b = new B().setPayload(payload); + payload = null; + assertTrue(b.getSerializedSize() > LightProtoCodec.CLEAR_RETAIN_MAX); + + b.clear(); + assertEventuallyCollected(ref); + } + + /** Deep clear must leave the instance as reusable as the O(1) clear does. */ + @Test + public void testReuseAfterDeepClear() { + S s = new S().setId("big"); + fillNames(s, 100 * 1024); + assertTrue(s.getSerializedSize() > LightProtoCodec.CLEAR_RETAIN_MAX); + s.clear(); + + s.setId("after"); + s.addName("one"); + s.addName("two"); + byte[] reused = s.toByteArray(); + + S freshMsg = new S().setId("after"); + freshMsg.addName("one"); + freshMsg.addName("two"); + byte[] fresh = freshMsg.toByteArray(); + assertArrayEquals(fresh, reused); + + S parsed = new S(); + parsed.parseFrom(reused); + assertEquals("after", parsed.getId()); + assertEquals(2, parsed.getNamesCount()); + assertEquals("one", parsed.getNameAt(0)); + assertEquals("two", parsed.getNameAt(1)); + } +} From 754f17f44f8cb42d1d949d44f751499ae775ecd4 Mon Sep 17 00:00:00 2001 From: Matteo Merli Date: Mon, 31 Aug 2026 19:04:46 -0700 Subject: [PATCH 2/2] Tighten the clear() release gate to a single branch, only where needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Messages with no reference-bearing fields (numbers/enums/bools only, e.g. nested coordinate/child types) skip the gate entirely — their release path is behaviorally identical to the plain clear, so their clear() compiles byte-identical to the pre-gate version. This matters because nested-child clear() runs once per child per parse on hot paths. Where the gate remains, the two comparisons (_cachedSize > CLEAR_RETAIN_MAX || _cachedSize < 0) fold into one unsigned compare: -1 is huge unsigned, so Integer.compareUnsigned covers the conservative unknown-size case in the same branch. Interleaved JMH (3 alternating rounds vs 0.8.0) showed the two-branch gate costing 2-5% on ~15-75 ns parse loops; this recovers most of it. --- .../generator/LightProtoBytesField.java | 5 +++++ .../lightproto/generator/LightProtoField.java | 9 +++++++++ .../generator/LightProtoMapField.java | 5 +++++ .../generator/LightProtoMessage.java | 19 ++++++++++++------- .../generator/LightProtoMessageField.java | 5 +++++ .../LightProtoRepeatedBytesField.java | 5 +++++ .../LightProtoRepeatedMessageField.java | 5 +++++ .../LightProtoRepeatedStringField.java | 5 +++++ .../generator/LightProtoStringField.java | 5 +++++ 9 files changed, 56 insertions(+), 7 deletions(-) diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java index 9988ee9..8200554 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java @@ -108,6 +108,11 @@ public void clearRelease(PrintWriter w) { w.format("%s = null;\n", ccName); } + @Override + public boolean needsRelease() { + return true; + } + @Override public void serializedSize(PrintWriter w) { w.format("_size += %s_SIZE;\n", tagName()); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java index f10c737..2edf5c9 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java @@ -140,6 +140,15 @@ public void clearRelease(PrintWriter w) { clear(w); } + /** + * Whether this field retains data references that {@link #clearRelease(PrintWriter)} + * must drop. Messages where no field does skip the clear() size gate entirely: + * their release path is behaviorally identical to the plain clear. + */ + public boolean needsRelease() { + return false; + } + public void fieldClear(PrintWriter w, String enclosingType) { w.format(" /** Clear the {@code %s} field. */\n", field.getName()); w.format(" public %s %s() {\n", enclosingType, Util.camelCase("clear", field.getName())); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java index bbd288e..1db77c9 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java @@ -996,6 +996,11 @@ public void clearRelease(PrintWriter w) { w.format("_%sIndex = null;\n", ccName); } + @Override + public boolean needsRelease() { + return true; + } + @Override public void materialize(PrintWriter w) { if (isStringKey()) { diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java index 07298d4..8d1bf13 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java @@ -286,13 +286,18 @@ private void emitBitDrivenTraversal(PrintWriter w, java.util.function.Consumer LightProtoCodec.CLEAR_RETAIN_MAX || _cachedSize < 0) {\n"); - w.format(" return _clearAndRelease();\n"); - w.format(" }\n"); + // Only messages that can retain data references need the release gate; + // for the rest _clearAndRelease() is behaviorally identical to clear(). + if (fields.stream().anyMatch(LightProtoField::needsRelease)) { + // _cachedSize is the previous message's size at this point (parseFrom() + // and getSerializedSize() maintain it), so the gate is O(1) — a single + // unsigned compare: -1 (mutated since, unknown fields on the wire, or + // already cleared) is huge unsigned, taking the release path + // conservatively; over cleared fields it walks nothing. + w.format(" if (Integer.compareUnsigned(_cachedSize, LightProtoCodec.CLEAR_RETAIN_MAX) > 0) {\n"); + w.format(" return _clearAndRelease();\n"); + w.format(" }\n"); + } boolean bitDriven = useBitDrivenClear(); for (LightProtoField f : fields) { if (bitDriven && f instanceof LightProtoMessageField && !f.isOneofMember()) { diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java index 48a7290..3d241ef 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java @@ -120,6 +120,11 @@ public void clearRelease(PrintWriter w) { w.format("}\n"); } + @Override + public boolean needsRelease() { + return true; + } + @Override public void materialize(PrintWriter w) { w.format("if (%s()) {\n", Util.camelCase("has", ccName)); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java index 48e54af..7ee3128 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java @@ -219,6 +219,11 @@ public void clearRelease(PrintWriter w) { w.format("_%sCount = 0;\n", pluralName); } + @Override + public boolean needsRelease() { + return true; + } + @Override public void materialize(PrintWriter w) { w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java index 87648f0..79e9e1d 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java @@ -192,6 +192,11 @@ public void clearRelease(PrintWriter w) { w.format("_%sCount = 0;\n", pluralName); } + @Override + public boolean needsRelease() { + return true; + } + @Override public void materialize(PrintWriter w) { w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java index 6a1a67f..4fab1da 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java @@ -211,6 +211,11 @@ public void clearRelease(PrintWriter w) { w.format("_%sCount = 0;\n", pluralName); } + @Override + public boolean needsRelease() { + return true; + } + @Override public void materialize(PrintWriter w) { w.format("for (int i = 0; i < _%sCount; i++) {\n", pluralName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java index bfefae8..2ef9795 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java @@ -88,6 +88,11 @@ public void clearRelease(PrintWriter w) { w.format("%s = null;\n", ccName); } + @Override + public boolean needsRelease() { + return true; + } + @Override public void serializedSize(PrintWriter w) { w.format("_size += %s_SIZE;\n", tagName());