Fix TraceStateBuilder.remove double-counting a repeated removal - #8763
Conversation
ArrayBasedTraceStateBuilder.remove decremented numEntries unconditionally, even when the entry was already a null tombstone from a previous remove of the same key. put() guards the symmetric update with if (currentValue == null); remove() lacked the mirror guard. So removing an already-removed key drove numEntries below the real count and corrupted build(): it either returned empty() and dropped unrelated valid entries, emitted a TraceState with a null value via the size()==2 fast path, or threw ArrayIndexOutOfBoundsException from the undersized entries[] array. The remove javadoc says it removes the entry 'if it is present', so a repeated remove must be a no-op. Only decrement when the entry is still present, and add tests for the three corruption paths. Signed-off-by: Timur Rakhmatullin <174210871+TimurRakhmatullin86@users.noreply.github.com>
Pull request dashboard statusWaiting on reviewers · refreshed 2026-09-02 14:36 UTC Review the latest changes. Also blocked by: Merge conflicts. Status above doesn't look right?
|
| void removeAlreadyRemovedKeyWithMultipleEntriesDoesNotThrow() { | ||
| assertThatCode( | ||
| () -> | ||
| TraceState.builder() | ||
| .put("a", "1") | ||
| .put("b", "2") | ||
| .put("c", "3") | ||
| .remove("a") | ||
| .remove("a") | ||
| .build()) | ||
| .doesNotThrowAnyException(); | ||
| } |
There was a problem hiding this comment.
This test is largely redundant with removeAlreadyRemovedKeyKeepsOtherEntries. Let's drop it.
| // Only account for the removal if the entry is still present. A repeated remove of an | ||
| // already-removed key must be a no-op, mirroring the guard in put(); otherwise numEntries | ||
| // is decremented twice and build() drops unrelated entries or emits a null-valued entry. |
There was a problem hiding this comment.
Nit: this comment can probably be tightened. The symmetry with put() is visible a few lines up, and the failure modes are documented in the PR description / tests. Something like:
// Mirror the tombstone guard in put(): skip already-removed entries so numEntries stays accurate.|
Thanks for the review, @jack-berg! Going to address your comments I realized this is a duplicate: the same |
Problem
ArrayBasedTraceStateBuilder.remove(key)decrementsnumEntriesunconditionally, even when the key's slot is already anulltombstone from a previousremoveof the same key.put()guards the symmetric update (if (currentValue == null) { numEntries++; });remove()has no mirror guard, so a repeatedremoveof an already-removed key double-decrements the count.Because
build()trustsnumEntries, a repeated removal corrupts the result three different ways:The
removejavadoc says it removes the entry "if it is present", so a secondremoveof a key that is no longer present must be a no-op — the same as removing a key that was never added (whichremoveNotPresentalready covers).Fix
Only account for the removal (set the tombstone and decrement) when the entry is still present, mirroring the guard in
put().Tests
Added three cases in
TraceStateTestfor the data-loss, null-value, andArrayIndexOutOfBoundsExceptionpaths. All three fail against the current code and pass with the fix; the existingTraceStateTestcases stay green.