Skip to content

Fix TraceStateBuilder.remove double-counting a repeated removal - #8763

Closed
TimurRakhmatullin86 wants to merge 1 commit into
open-telemetry:mainfrom
TimurRakhmatullin86:fix/tracestate-builder-double-remove
Closed

Fix TraceStateBuilder.remove double-counting a repeated removal#8763
TimurRakhmatullin86 wants to merge 1 commit into
open-telemetry:mainfrom
TimurRakhmatullin86:fix/tracestate-builder-double-remove

Conversation

@TimurRakhmatullin86

Copy link
Copy Markdown
Contributor

Problem

ArrayBasedTraceStateBuilder.remove(key) decrements numEntries unconditionally, even when the key's slot is already a null tombstone from a previous remove of the same key. put() guards the symmetric update (if (currentValue == null) { numEntries++; }); remove() has no mirror guard, so a repeated remove of an already-removed key double-decrements the count.

Because build() trusts numEntries, a repeated removal corrupts the result three different ways:

// 1) silent data loss — a valid entry disappears
TraceState.builder().put("a","1").put("b","2").remove("a").remove("a").build();
// numEntries: 2 -> 1 -> 0  =>  build() returns empty();  get("b") == null

// 2) null-valued entry — violates the non-null value contract
TraceState.builder().put("a","1").remove("a").remove("a").build();
// numEntries: 1 -> 0 -> -1 => size()==2 fast path returns [a, null]; size()==1, get("a")==null

// 3) crash
TraceState.builder().put("a","1").put("b","2").put("c","3").remove("a").remove("a").build();
// numEntries under-counts => entries[] is too small => ArrayIndexOutOfBoundsException

The remove javadoc says it removes the entry "if it is present", so a second remove of a key that is no longer present must be a no-op — the same as removing a key that was never added (which removeNotPresent already 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 TraceStateTest for the data-loss, null-value, and ArrayIndexOutOfBoundsException paths. All three fail against the current code and pass with the fix; the existing TraceStateTest cases stay green.

@TimurRakhmatullin86
TimurRakhmatullin86 requested a review from a team as a code owner September 1, 2026 14:31
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>
@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Sep 1, 2026

Copy link
Copy Markdown

Pull request dashboard status

Waiting on reviewers · refreshed 2026-09-02 14:36 UTC

Review the latest changes.

Also blocked by: Merge conflicts.

Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Anything look wrong? Report it with what you expected; it helps us improve the dashboard.

Comment on lines +327 to +338
void removeAlreadyRemovedKeyWithMultipleEntriesDoesNotThrow() {
assertThatCode(
() ->
TraceState.builder()
.put("a", "1")
.put("b", "2")
.put("c", "3")
.remove("a")
.remove("a")
.build())
.doesNotThrowAnyException();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is largely redundant with removeAlreadyRemovedKeyKeepsOtherEntries. Let's drop it.

Comment on lines +95 to +97
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@TimurRakhmatullin86

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @jack-berg! Going to address your comments I realized this is a duplicate: the same if (reversedEntries.get(i + 1) != null) guard in remove() already landed in #8613 back in July, along with the removeSameKeyTwice* regression tests that cover the same scenarios as the ones here — my branch predated that and I didn't catch the overlap. So there's nothing left for this to add. Closing as redundant; sorry for the noise, and thanks for taking the time to look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants