{"record":{"id":"1f332ad26bac38db","repo":"apache/pulsar","slug":"entry-bucket-boundaries-must-tile-the-16-bit-ring","errorCode":null,"errorMessage":"Entry-bucket boundaries must tile the 16-bit ring: last range ends at ${lastEnd}","messagePattern":"Entry-bucket boundaries must tile the 16-bit ring: last range ends at (.+?)","errorType":"validation","errorClass":"java.lang.IllegalArgumentException","httpStatus":null,"severity":"error","filePath":"pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentEntryBucketDispatcherMultipleConsumers.java","lineNumber":97,"sourceCode":"        int count = ksm.getHashRangesCount();\n        if (count == 0) {\n            throw new IllegalArgumentException(\n                    \"Entry-bucket subscription must declare the segment's bucket boundaries\");\n        }\n        List<Range> ranges = new ArrayList<>(count);\n        int expectedStart = 0;\n        for (int i = 0; i < count; i++) {\n            IntRange r = ksm.getHashRangeAt(i);\n            if (r.getStart() != expectedStart || r.getEnd() < r.getStart()) {\n                throw new IllegalArgumentException(\"Entry-bucket boundaries must be ascending, \"\n                        + \"contiguous and start at 0: found [\" + r.getStart() + \",\" + r.getEnd()\n                        + \"] where start \" + expectedStart + \" was expected\");\n            }\n            ranges.add(Range.of(r.getStart(), r.getEnd()));\n            expectedStart = r.getEnd() + 1;\n        }\n        if (expectedStart != EntryBucketConsumerSelector.DEFAULT_RANGE_SIZE) {\n            throw new IllegalArgumentException(\"Entry-bucket boundaries must tile the 16-bit ring: \"\n                    + \"last range ends at \" + (expectedStart - 1));\n        }\n        if (ranges.get(0).getEnd() < 1) {\n            throw new IllegalArgumentException(\n                    \"Entry-bucket 0 must span at least [0,1] to hold the canonical hash\");\n        }\n        return ranges;\n    }\n\n    @Override\n    public synchronized CompletableFuture<Void> addConsumer(Consumer consumer) {\n        // A segment's bucketing is immutable, so every consumer must declare the boundaries the\n        // dispatcher was created with — a mismatch is a client bug or a stale layout, not a race.\n        try {\n            List<Range> declared = validateBucketBoundaries(consumer.getKeySharedMeta());\n            if (!declared.equals(bucketSelector.getBucketRanges())) {\n                return CompletableFuture.failedFuture(new BrokerServiceException.ConsumerAssignException(\n                        \"Consumer declares different entry-bucket boundaries than the subscription: \"","sourceCodeStart":79,"sourceCodeEnd":115,"githubUrl":"https://github.com/apache/pulsar/blob/820761864ed8e2a7d2e52dd9763ad2ae117c1395/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentEntryBucketDispatcherMultipleConsumers.java#L79-L115","documentation":"The declared boundary ranges must exactly tile the entire 16-bit entry-bucket ring: after the last range, the running expectedStart must equal EntryBucketConsumerSelector.DEFAULT_RANGE_SIZE (65536). If the ranges stop short (or exceed) the ring, bucket hashes in the uncovered tail have no owning consumer, so validateBucketBoundaries rejects the subscription and reports where the last range actually ended.","triggerScenarios":"hashRanges covering only part of 0..65535 — e.g. 4 ranges of 16000 each ending at 63999, or a final segment whose end is below 65535 (equivalently above it, caught by the same check since expectedStart would exceed DEFAULT_RANGE_SIZE).","commonSituations":"Dividing 65536 unevenly and letting the remainder drop (16000*4 != 65536); constructing N equal segments with integer division that truncates the last segment; boundary lists produced for a different ring size and reused here.","solutions":["Make the final range end exactly at 65535 so the cumulative end + 1 equals 65536 (DEFAULT_RANGE_SIZE).","Compute boundaries with the last segment absorbing the remainder, e.g. end = (i == segments-1) ? 65535 : (i+1)*size - 1.","Validate locally that summing range widths equals 65536 before subscribing."],"exampleFix":"// before (ends short of the ring)\nksm.addHashRange().setStart(0).setEnd(15999);\nksm.addHashRange().setStart(16000).setEnd(31999);\nksm.addHashRange().setStart(32000).setEnd(47999);\nksm.addHashRange().setStart(48000).setEnd(63999); // last end 63999 != 65535\n// after\nksm.addHashRange().setStart(0).setEnd(16383);\nksm.addHashRange().setStart(16384).setEnd(32767);\nksm.addHashRange().setStart(32768).setEnd(49151);\nksm.addHashRange().setStart(49152).setEnd(65535);","handlingStrategy":"validation","validationCode":"static void assertTilingFullRing(KeySharedMeta ksm) {\n    int expectedStart = 0;\n    for (int i = 0; i < ksm.getHashRangesCount(); i++) {\n        expectedStart = ksm.getHashRangeAt(i).getEnd() + 1;\n    }\n    if (expectedStart != 65536) {\n        throw new IllegalArgumentException(\"ranges end at \" + (expectedStart - 1)\n            + \", must tile 0..65535\");\n    }\n}","typeGuard":"static boolean tilesFullRing(KeySharedMeta ksm) {\n    int expectedStart = 0;\n    for (int i = 0; i < ksm.getHashRangesCount(); i++) {\n        expectedStart = ksm.getHashRangeAt(i).getEnd() + 1;\n    }\n    return expectedStart == 65536;\n}","tryCatchPattern":null,"preventionTips":["Compute the last segment's end explicitly as 65535, letting it absorb any integer-division remainder.","Add a sum-of-widths == 65536 assertion in the boundary generator's tests.","Reuse one canonical boundary-definition module across producer and all consumers."],"tags":["pulsar","key-shared","entry-bucket","validation"],"backgroundTag":"invalid-hash-range-partition","analyzedSha":"820761864ed8e2a7d2e52dd9763ad2ae117c1395","analyzedAt":"2026-09-06T00:14:20.138Z","contentChangedAt":"2026-09-06T00:14:20.138Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}