Skip to content

[fix][broker] Fix NPE in ManagedLedgerInterceptorImpl when AppendIndexMetadataInterceptor isn't configured - #26497

Merged
lhotari merged 1 commit into
apache:masterfrom
lhotari:lh-fix-ml-interceptor-npe
Sep 8, 2026
Merged

lhotari merged 1 commit into
apache:masterfrom
lhotari:lh-fix-ml-interceptor-npe

Conversation

@lhotari

@lhotari lhotari commented Sep 8, 2026

Copy link
Copy Markdown
Member

Fixes #26496

Motivation

ManagedLedgerInterceptorImpl.onManagedLedgerLastLedgerInitialize() dereferences the nullable field appendIndexMetadataInterceptor without a null check:

public CompletableFuture<Void> onManagedLedgerLastLedgerInitialize(String name, LastEntryHandle lh) {
return lh.readLastEntryAsync().thenAccept(lastEntryOptional -> {
if (lastEntryOptional.isPresent()) {
Entry lastEntry = lastEntryOptional.get();
try {
Commands.peekBrokerEntryMetadataAndConsume(lastEntry.getDataBuffer(), brokerEntryMetadata -> {
if (brokerEntryMetadata != null && brokerEntryMetadata.hasIndex()) {
appendIndexMetadataInterceptor.recoveryIndexGenerator(brokerEntryMetadata.getIndex());
}
});
} finally {
lastEntry.release();
}
}
});
}

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.

BrokerService installs ManagedLedgerInterceptorImpl on every persistent topic when isBrokerEntryMetadataEnabled() || 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:

  1. brokerEntryMetadataInterceptors contains only AppendBrokerTimestampMetadataInterceptor.
  2. brokerEntryMetadataInterceptors is empty and brokerEntryPayloadProcessors is 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 BrokerEntryMetadata with hasIndex() == true throws an NPE inside the thenAccept callback. ManagedLedgerImpl.initialize() wraps it in ManagedLedgerInterceptException and calls callback.initializeFailed(...), so the topic fails to load:

org.apache.bookkeeper.mledger.ManagedLedgerException$ManagedLedgerInterceptException: java.lang.NullPointerException: Cannot invoke "org.apache.pulsar.common.intercept.AppendIndexMetadataInterceptor.recoveryIndexGenerator(long)" because "this.appendIndexMetadataInterceptor" is null
Caused by: java.lang.NullPointerException: Cannot invoke "org.apache.pulsar.common.intercept.AppendIndexMetadataInterceptor.recoveryIndexGenerator(long)" because "this.appendIndexMetadataInterceptor" is null
	at org.apache.pulsar.broker.intercept.ManagedLedgerInterceptorImpl.lambda$onManagedLedgerLastLedgerInitialize$0(ManagedLedgerInterceptorImpl.java:121)
	at org.apache.pulsar.common.protocol.Commands.peekBrokerEntryMetadataAndConsume(Commands.java:2244)
	at org.apache.pulsar.broker.intercept.ManagedLedgerInterceptorImpl.lambda$onManagedLedgerLastLedgerInitialize$1(ManagedLedgerInterceptorImpl.java:119)

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 AppendIndexMetadataInterceptor from brokerEntryMetadataInterceptors (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 heterogeneous broker.conf across 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 new LastEntryHandle.readLastEntryAsync() API: the lh.getLastAddConfirmed() >= 0 half of the old condition was relocated into ManagedLedgerImpl.createLastEntryHandle (where it is preserved as Optional.isPresent()), but the appendIndexMetadataInterceptor != null half was relocated nowhere. git log -S hasAppendIndexMetadataInterceptor on 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.1 and branch-4.0 are unguarded; branch-3.3 and branch-3.0 still carry the guard, so no 3.x backport is needed. The first release containing #23311 is 4.0.0.

Modifications

  • ManagedLedgerInterceptorImpl.onManagedLedgerLastLedgerInitialize returns a completed future immediately when appendIndexMetadataInterceptor is 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.
  • Annotated the field with JSpecify @Nullable to document the invariant that was violated.

The same guard also covers the shadow-topic path: ShadowManagedLedgerImpl.doInitialize calls the same method on the source ledger.

Verifying this change

  • Make sure that the change passes the CI checks.

This change added tests and can be verified as follows:

Three tests were added to ManagedLedgerInterceptorImplTest:

  • testLastLedgerInitializeSkipsIndexRecoveryWithoutIndexInterceptor — a @DataProvider covers both null-field configurations (timestamp interceptor only, and an empty interceptor set). It stubs LastEntryHandle with an entry carrying a BrokerEntryMetadata index and asserts that the returned future completes normally and that the last entry was not read at all, which pins the guard's placement before readLastEntryAsync() rather than merely the absence of an NPE.
  • testLastLedgerInitializeRecoversIndexWithIndexInterceptor — the positive control: with AppendIndexMetadataInterceptor configured, the last entry is still read and the index is still recovered.
  • testRecoveryIndexAfterIndexInterceptorRemovedFromConfiguration — reproduces the production scenario end to end through ManagedLedgerImpl.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 testRecoveryIndexAfterIndexInterceptorRemovedFromConfiguration fail with the ManagedLedgerInterceptException shown above, and both data-provider cases of testLastLedgerInitializeSkipsIndexRecoveryWithoutIndexInterceptor fail with the NPE.

With the fix, ManagedLedgerInterceptorImplTest (12 tests), MangedLedgerInterceptorImpl2Test and BrokerEntryMetadataE2ETest (15 tests) all pass.

Does this pull request potentially affect one of the following parts:

If the box was checked, please highlight the changes

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

…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.

@void-ptr974 void-ptr974 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@david-streamlio david-streamlio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
    appendIndexMetadataInterceptor null when the set has no AppendIndexMetadataInterceptor, and
    getIndex, afterFailedAddEntry, onManagedLedgerPropertiesInitialize and
    onUpdateManagedLedgerInfo each null-check it. onManagedLedgerLastLedgerInitialize was 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.java returns
    precisely two commits — f355272504 (#10706, introduced the guard) and 9ebd97941d (#23311,
    removed it). The lh.getLastAddConfirmed() >= 0 half is indeed preserved in
    ManagedLedgerImpl.createLastEntryHandle:528.
  • Returning early rather than guarding inside the callback is safe: the only effect of the
    thenAccept body is recoveryIndexGenerator(...) plus the matching lastEntry.release(), and
    createLastEntryHandle is 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 asserts readLastEntryAsync() 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.create retains (EntryImpl.java:123), so
    the production lastEntry.release() and the test's finally release 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.

@lhotari
lhotari merged commit bef7b8f into apache:master Sep 8, 2026
44 checks passed
lhotari added a commit that referenced this pull request Sep 9, 2026
…xMetadataInterceptor isn't configured (#26497)

(cherry picked from commit bef7b8f)
lhotari added a commit that referenced this pull request Sep 9, 2026
…xMetadataInterceptor isn't configured (#26497)

(cherry picked from commit bef7b8f)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] NPE in ManagedLedgerInterceptorImpl.onManagedLedgerLastLedgerInitialize fails topic load when AppendIndexMetadataInterceptor isn't configured

3 participants