apache/pulsar · error · IllegalArgumentException

Positions must not be null

Error message

Positions must not be null

What it means

ManagedLedgerImpl.comparePositions() is a null-strict position comparator used by the entries-count/range APIs. It throws IllegalArgumentException when either Position argument is null. Unlike Position.compareTo, it validates inputs before comparing, since null positions would NPE deeper in the logic anyway.

Source

Thrown at managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerImpl.java:4020

            .whenComplete((result, exception) -> {
                    if (exception == null) {
                        log.info().attr("ledgerId", ledgerId).attr("uuid", uuid).log("End Offload");
                    } else {
                        log.warn().attr("ledgerId", ledgerId)
                                .attr("uuid", uuid)
                                .exception(exception).log("Failed to complete offload");
                    }
                });
    }

    /**
     * Compare two positions. It is different with {@link Position#compareTo(Position)} when the params are invalid.
     * For example: position-1 is "1:{latest entry}", and position-2 is "2:-1", they are the same position.
     */
    @VisibleForTesting
    int comparePositions(Position pos1, Position pos2) {
        if (pos1 == null || pos2 == null) {
            throw new IllegalArgumentException("Positions must not be null");
        }
        if (ledgers.isEmpty() || pos1.getLedgerId() < getFirstPosition().getLedgerId()
                || pos2.getLedgerId() < getFirstPosition().getLedgerId()
                || pos1.getLedgerId() > getLastPosition().getLedgerId()
                || pos2.getLedgerId() > getLastPosition().getLedgerId()) {
            return pos1.compareTo(pos2);
        }
        if (pos1.getLedgerId() == pos2.getLedgerId()) {
            return Long.compare(pos1.getEntryId(), pos2.getEntryId());
        }
        if (!isValidPosition(pos1) || !isValidPosition(pos2)) {
            return getNextValidPosition(pos1).compareTo(getNextValidPosition(pos2));
        }
        return pos1.compareTo(pos2);
    }

    /**
     * Get the number of entries between a contiguous range of two positions.

View on GitHub (pinned to 820761864e)

Solutions

  1. Null-check positions before invoking the range API and skip/return 0 entries for null input
  2. For a fresh cursor, initialize or wait until markDeletePosition/readPosition is established
  3. Fix caller code that produces null Positions (uninitialized fields, failed deserialization)

Example fix

// before
long n = ml.getNumberOfEntries(Range.open(fromPos, toPos)); // NPE risk
// after
long n = (fromPos == null || toPos == null) ? 0
        : ml.getNumberOfEntries(Range.open(fromPos, toPos));
Defensive patterns

Strategy: validation

Validate before calling

if (fromPos == null || toPos == null) {
    return 0L; // or skip the computation
}

Type guard

static boolean validPositions(Position a, Position b) {
    return a != null && b != null;
}

Try / catch

try {
    return ml.getNumberOfEntries(range);
} catch (IllegalArgumentException e) {
    log.warn("null/invalid positions for entry count", e);
    return 0L;
}

Prevention

When it happens

Trigger: Calling getNumberOfEntries(Range<Position>) or related range APIs with a null from/to position — typically a cursor with no mark-delete position yet, or caller code passing an uninitialized Position.

Common situations: Newly created subscription whose read position has never been initialized; application code computing stats ranges before a first consume; deserialization producing null positions.

Related errors


AI-assisted analysis of apache/pulsar@820761864e (2026-09-06). Data as JSON: /api/errors/76deef911a0e2a12. Report an issue: GitHub.