Skip to content

[fix][ml] Reset the lazily-cached position when reusing a recycled EntryImpl - #26707

Merged
dao-jun merged 1 commit into
apache:masterfrom
dao-jun:fix/entryimpl-recycled-position
Sep 25, 2026
Merged

dao-jun merged 1 commit into
apache:masterfrom
dao-jun:fix/entryimpl-recycled-position

Conversation

@dao-jun

@dao-jun dao-jun commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Motivation

EntryImpl caches its Position lazily: getPosition() materializes PositionFactory.create(ledgerId, entryId) into the position field on first call, and deallocate() nulls the field before handing the object back to the recycler.

However, a getPosition() call that slips in after the refcount has hit zero (a use-after-release on any consumer side) re-materializes the field from the already-reset ids as (-1, -1) and leaves that poisoned value cached inside the pooled object. When the recycler later hands that object to one of the create(...) variants that rely on lazy materialization instead of assigning the field explicitly — create(LedgerEntry, int), create(long, long, byte[], int), create(long, long, ByteBuf, int); all other RECYCLER.get() sites do set it — the new entry is born with a stale (-1, -1) (or previously-used) position while getLedgerId()/getEntryId() report fresh legitimate ids.

This is not theoretical: Netty's recycler is LIFO on the releasing thread (claim() → pollLast()), so a single late getPosition() after release poisons the object that the very next create() on that thread reuses. On a BookKeeper-backed downstream stack we observed exactly this poison-and-reuse cycle under read pressure: every successfully consumed batch immediately poisoned the object reused by the next batch, delivering entries whose getPosition() was (-1, -1) while their ids were perfectly valid — a nasty corruption family (ghost positions, wrong cursor advancement, silently skipped data) because every other field of the recycled object is legitimately initialized.

A library recycler should not amplify a consumer-side use-after-release into persistent delivery corruption of subsequent, unrelated entries.

Modifications

  • Reset entry.position = null in the three create(...) variants that relied on lazy materialization. The reset is placed after the id assignments so any racy lazy rebuild always observes the fresh ids.
  • Add a regression test that reproduces the poisoning (create → release → late getPosition() → create again) through both the byte[] and ByteBuf variants and asserts the recycled entry reports its own position; removing either reset line turns the test red.

Verifying this change

  • ./gradlew :managed-ledger:test --tests "org.apache.bookkeeper.mledger.impl.EntryImplTest" — passes (12 tests, including the new regression pin).

This change is a trivial rework: no code that was covered by tests has been modified beyond the three reset lines.

@dao-jun dao-jun self-assigned this Sep 25, 2026
@dao-jun dao-jun added type/bug The PR fixed a bug or issue reported a bug area/ML release/4.0.14 release/4.2.5 labels Sep 25, 2026
@dao-jun dao-jun changed the title [fix][managed-ledger] Reset the lazily-cached position when reusing a recycled EntryImpl [fix][ml] Reset the lazily-cached position when reusing a recycled EntryImpl Sep 25, 2026
… recycled EntryImpl

EntryImpl caches its Position lazily: getPosition() materializes it from the
ledger/entry ids on first call and deallocate() nulls it before recycling.
A getPosition() call that slips in after the refcount has hit zero
re-materializes the field from the already-reset ids as (-1,-1) and leaves
the poisoned value cached inside the pooled object. Three create() variants
relied on lazy materialization instead of assigning the field explicitly,
so a recycled object could be born with a stale (-1,-1) position while its
getLedgerId()/getEntryId() report fresh legitimate ids — and since the
recycler hands back the most recently released object on the releasing
thread, one late getPosition() after release poisons the very next create()
on that thread.

Reset the lazy field in all three variants, placed after the id assignments
so any racy lazy rebuild observes the fresh ids, and pin the behavior with a
regression test (create -> release -> late getPosition() -> create again)
that fails if either reset line is removed.
@dao-jun
dao-jun force-pushed the fix/entryimpl-recycled-position branch from 847c39b to 89af538 Compare September 25, 2026 00:23
@dao-jun
dao-jun merged commit b27d3ad into apache:master Sep 25, 2026
44 checks passed
@dao-jun
dao-jun deleted the fix/entryimpl-recycled-position branch September 25, 2026 12:56
@void-ptr974

Copy link
Copy Markdown
Contributor

I took a closer look at this after the merge. I think the reset is useful as best-effort hardening for the sequential poison-and-reuse case, but it does not fully address the underlying use-after-release/ownership issue.

The reported reproduction requires calling getPosition() after the entry's reference count has reached zero. At that point, the object has already been released and may be recycled or reused. Resetting position during create() cannot make that access safe.

In particular, the reset does not close the concurrent race. The following interleaving is still possible:

Step T1: stale caller T2: recycler/new owner Object state
1 Calls getPosition() after final release and observes position == null IDs are -1/-1
2 Reads the reset IDs and constructs Position(-1, -1), but pauses before assigning it position == null
3 Obtains the same recycled instance and assigns the new IDs IDs are now valid
4 Executes the new position = null and sets refCnt for the new generation New entry appears initialized
5 Resumes and assigns the previously constructed Position(-1, -1) Valid IDs, but stale cached position
6 The new owner calls getPosition() and receives (-1, -1) Entry remains poisoned

Since position, ledgerId, and entryId are unsynchronized and there is no generation check, placing the reset after the ID assignments does not prevent this stale publication.

Fixing the use-after-release/ownership handoff therefore seems like the more appropriate root fix—for example, capture required values before releasing, or retain the entry across an asynchronous handoff and release it when that work completes. The resets can remain as defensive hardening, but should not be considered a complete fix for concurrent post-release access.

There is also a regression-test issue: the test does not prove that the same object was recycled. With the current Netty recycler defaults, an ordinary test worker can receive a no-op recycler handle. I verified that this test still passes against the base implementation with the production reset lines absent. The test should run the scenario on a FastThreadLocalThread, assert identity with assertSame, and also cover the changed create(LedgerEntry, int) path.

dao-jun added a commit to dao-jun/pulsar that referenced this pull request Sep 26, 2026
… recycle

Follow-up to apache#26707 addressing the post-merge review: the merged test ran
on a plain test worker thread, which receives a no-op recycler handle on
Netty 4.2 (pooling is FastThreadLocalThread-only), so every create()
returned a fresh instance and the assertions held with or without the fix.

- Run the scenario on a FastThreadLocalThread and prove instance identity
  with assertSame, guarded by a bounded warm-up that fails the test if the
  pool never hands the same instance back.
- Cover create(LedgerEntry, int), the variant the managed-ledger read path
  actually uses, in addition to the byte[] and ByteBuf variants.
- Reword the reset comment in EntryImpl.create(LedgerEntry, int): the reset
  closes the sequential poison-and-reuse case; it cannot protect against a
  getPosition() racing concurrently with create() (unsynchronized fields,
  no generation check) - post-release access remains a caller bug to fix
  at the call site.

Red/green verified: removing the three position resets turns the test red
(first failure on the create(LedgerEntry, int) variant); restoring them is
green.
@lhotari lhotari added this to the 5.0.0 milestone Oct 1, 2026
ascentstream-bot pushed a commit to ascentstream/pulsar that referenced this pull request Oct 1, 2026
ascentstream-bot pushed a commit to ascentstream/pulsar that referenced this pull request Oct 2, 2026
dao-jun added a commit to ascentstream/pulsar that referenced this pull request Oct 5, 2026
Radiancebobo pushed a commit to Radiancebobo/pulsar that referenced this pull request Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ML release/4.0.14 release/4.2.5 type/bug The PR fixed a bug or issue reported a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants