Repository navigation
[fix][broker] Fix NPE in ManagedLedgerInterceptorImpl when AppendIndexMetadataInterceptor isn't configured - #26497
Merged
Conversation
…xMetadataInterceptor isn't configured onManagedLedgerLastLedgerInitialize dereferenced the nullable appendIndexMetadataInterceptor field without a null check, so loading a topic whose last entry carries a BrokerEntryMetadata index failed with ManagedLedgerInterceptException. Restore the guard that apache#23311 dropped by returning early before reading the last entry: there is no index generator to recover into when AppendIndexMetadataInterceptor isn't configured.
lhotari
requested review from
Technoboy-,
dao-jun,
david-streamlio,
merlimat and
nodece
September 8, 2026 12:50
david-streamlio
approved these changes
Sep 8, 2026
david-streamlio
left a comment
Contributor
There was a problem hiding this comment.
LGTM. I checked the claims rather than the prose, and all of them hold.
Verified
- The field really is nullable and this really was the only gap: the constructor leaves
appendIndexMetadataInterceptornull when the set has noAppendIndexMetadataInterceptor, and
getIndex,afterFailedAddEntry,onManagedLedgerPropertiesInitializeand
onUpdateManagedLedgerInfoeach null-check it.onManagedLedgerLastLedgerInitializewas the only
one that did not. - The regression attribution is exactly right, and the claim is falsifiable, so I ran it:
git log -S hasAppendIndexMetadataInterceptor -- .../ManagedLedgerInterceptorImpl.javareturns
precisely two commits —f355272504(#10706, introduced the guard) and9ebd97941d(#23311,
removed it). Thelh.getLastAddConfirmed() >= 0half is indeed preserved in
ManagedLedgerImpl.createLastEntryHandle:528. - Returning early rather than guarding inside the callback is safe: the only effect of the
thenAcceptbody isrecoveryIndexGenerator(...)plus the matchinglastEntry.release(), and
createLastEntryHandleis a pure read. With a null interceptor there is nothing the read could
accomplish, and skipping it releases nothing that was ever acquired. - The tests pin the placement of the guard, not merely the absence of an NPE — the
lastEntryRead
flag assertsreadLastEntryAsync()is never called, which a guard inside the callback would fail.
The positive control asserts both that the read happens and that the index recovers to 99, and the
data provider covers both null-field configurations. - ByteBuf accounting in the tests is correct:
EntryImpl.createretains (EntryImpl.java:123), so
the productionlastEntry.release()and the test'sfinallyrelease balance to zero.
No findings. The @Nullable annotation on the field is a good touch — it is the thing that would have
caught this statically.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #26496
Motivation
ManagedLedgerInterceptorImpl.onManagedLedgerLastLedgerInitialize()dereferences the nullable fieldappendIndexMetadataInterceptorwithout a null check:pulsar/pulsar-broker/src/main/java/org/apache/pulsar/broker/intercept/ManagedLedgerInterceptorImpl.java
Lines 113 to 128 in 4998cd9
The field is null whenever the constructor's interceptor set contains no
AppendIndexMetadataInterceptor. Every other method of the class that touches the field (getIndex,afterFailedAddEntry,onManagedLedgerPropertiesInitialize,onUpdateManagedLedgerInfo) null-checks it first; this one does not.BrokerServiceinstallsManagedLedgerInterceptorImplon every persistent topic whenisBrokerEntryMetadataEnabled() || isBrokerPayloadProcessorEnabled(), and those predicates are just!brokerEntryMetadataInterceptors.isEmpty()/!brokerEntryPayloadProcessors.isEmpty(). So the field is null while the interceptor is still installed in two supported configurations:brokerEntryMetadataInterceptorscontains onlyAppendBrokerTimestampMetadataInterceptor.brokerEntryMetadataInterceptorsis empty andbrokerEntryPayloadProcessorsis configured — the interceptor set passed to the constructor is then empty.In either configuration, loading a topic whose last ledger's last entry still carries a
BrokerEntryMetadatawithhasIndex() == truethrows an NPE inside thethenAcceptcallback.ManagedLedgerImpl.initialize()wraps it inManagedLedgerInterceptExceptionand callscallback.initializeFailed(...), so the topic fails to load:The failure is deterministic and self-repeating rather than transient: the topic cannot be loaded, so no broker can write a non-indexed entry to it, and every retry re-reads the same last entry. The topic stays unavailable until the configuration is reverted.
The realistic trigger is a configuration change over time: an operator removes
AppendIndexMetadataInterceptorfrombrokerEntryMetadataInterceptors(keeping the timestamp interceptor, or having payload processors configured) and restarts. Every topic written under the old configuration then fails to load. The same happens transiently during a rolling restart, or with a heterogeneousbroker.confacross the broker pool, where brokers still on the old configuration keep writing indexed entries while topics move to brokers on the new configuration.This is a regression. The guard existed from #10706 and was preserved by #20112 as
boolean hasAppendIndexMetadataInterceptor = appendIndexMetadataInterceptor != null;. It was dropped by #23311 (commit 9ebd979), which rewrote the method onto the newLastEntryHandle.readLastEntryAsync()API: thelh.getLastAddConfirmed() >= 0half of the old condition was relocated intoManagedLedgerImpl.createLastEntryHandle(where it is preserved asOptional.isPresent()), but theappendIndexMetadataInterceptor != nullhalf was relocated nowhere.git log -S hasAppendIndexMetadataInterceptoron the file returns exactly two commits: the one that introduced the guard and #23311 which removed it.Affected branches, verified against the source on GitHub:
master,branch-4.2,branch-4.1andbranch-4.0are unguarded;branch-3.3andbranch-3.0still carry the guard, so no 3.x backport is needed. The first release containing #23311 is 4.0.0.Modifications
ManagedLedgerInterceptorImpl.onManagedLedgerLastLedgerInitializereturns a completed future immediately whenappendIndexMetadataInterceptoris null, restoring the pre-[improve][broker] Decouple ManagedLedger interfaces from the current implementation #23311 behaviour. Reading the last entry is skipped entirely rather than guarded inside the callback: with no index generator to recover into, the read has no possible effect, and skipping it also removes a pointless last-entry read from the topic recovery path for every topic on brokers configured that way. The path where the interceptor is present is untouched.@Nullableto document the invariant that was violated.The same guard also covers the shadow-topic path:
ShadowManagedLedgerImpl.doInitializecalls the same method on the source ledger.Verifying this change
This change added tests and can be verified as follows:
Three tests were added to
ManagedLedgerInterceptorImplTest:testLastLedgerInitializeSkipsIndexRecoveryWithoutIndexInterceptor— a@DataProvidercovers both null-field configurations (timestamp interceptor only, and an empty interceptor set). It stubsLastEntryHandlewith an entry carrying aBrokerEntryMetadataindex and asserts that the returned future completes normally and that the last entry was not read at all, which pins the guard's placement beforereadLastEntryAsync()rather than merely the absence of an NPE.testLastLedgerInitializeRecoversIndexWithIndexInterceptor— the positive control: withAppendIndexMetadataInterceptorconfigured, the last entry is still read and the index is still recovered.testRecoveryIndexAfterIndexInterceptorRemovedFromConfiguration— reproduces the production scenario end to end throughManagedLedgerImpl.initialize: write entries with the index interceptor configured, close, then reopen with it removed.All three fail on the unpatched code for the real reason. Reverting only the guard makes
testRecoveryIndexAfterIndexInterceptorRemovedFromConfigurationfail with theManagedLedgerInterceptExceptionshown above, and both data-provider cases oftestLastLedgerInitializeSkipsIndexRecoveryWithoutIndexInterceptorfail with the NPE.With the fix,
ManagedLedgerInterceptorImplTest(12 tests),MangedLedgerInterceptorImpl2TestandBrokerEntryMetadataE2ETest(15 tests) all pass.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes