{"record":{"id":"47968d6fd04bf694","repo":"apache/pulsar","slug":"both-segments-must-be-active","errorCode":null,"errorMessage":"Both segments must be active","messagePattern":"Both segments must be active","errorType":"validation","errorClass":"IllegalArgumentException","httpStatus":null,"severity":"error","filePath":"pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/SegmentLayout.java","lineNumber":223,"sourceCode":"     */\n    public SegmentLayout mergeSegments(long segmentId1, long segmentId2, long nowMs) {\n        return mergeSegments(segmentId1, segmentId2, nowMs, EntryBucketSplits.MAX_BUCKETS);\n    }\n\n    /**\n     * As {@link #mergeSegments(long, long, long)}, clamping the merged segment's entry-bucket\n     * count to {@code maxBucketsPerSegment} (the configured per-segment ceiling): the merged\n     * segment recovers the parents' buckets, but never past the hard ceiling.\n     */\n    public SegmentLayout mergeSegments(long segmentId1, long segmentId2, long nowMs,\n                                       int maxBucketsPerSegment) {\n        SegmentInfo seg1 = allSegments.get(segmentId1);\n        SegmentInfo seg2 = allSegments.get(segmentId2);\n        if (seg1 == null || seg2 == null) {\n            throw new IllegalArgumentException(\"Segment not found\");\n        }\n        if (!seg1.isActive() || !seg2.isActive()) {\n            throw new IllegalArgumentException(\"Both segments must be active\");\n        }\n        if (!seg1.hashRange().isAdjacentTo(seg2.hashRange())) {\n            throw new IllegalArgumentException(\"Segments are not adjacent: \"\n                    + seg1.hashRange() + \" and \" + seg2.hashRange());\n        }\n\n        long newEpoch = epoch + 1;\n        long mergedId = nextSegmentId;\n        HashRange mergedRange = seg1.hashRange().merge(seg2.hashRange());\n\n        // PIP-486: a merge is the inverse of a split — the merged segment recovers both parents' buckets\n        // (N1 + N2), so the topic's total entry-bucket count stays ≈ the budget as segments coalesce.\n        List<Integer> mergedEntryBucketSplits = EntryBucketSplits.equalWidth(\n                Math.min(seg1.bucketCount() + seg2.bucketCount(), maxBucketsPerSegment));\n        SegmentInfo sealed1 = seg1.sealed(newEpoch, nowMs, List.of(mergedId));\n        SegmentInfo sealed2 = seg2.sealed(newEpoch, nowMs, List.of(mergedId));\n        SegmentInfo merged = SegmentInfo.active(mergedId, mergedRange,\n                List.of(segmentId1, segmentId2), newEpoch, nowMs)","sourceCodeStart":205,"sourceCodeEnd":241,"githubUrl":"https://github.com/apache/pulsar/blob/820761864ed8e2a7d2e52dd9763ad2ae117c1395/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/SegmentLayout.java#L205-L241","documentation":"mergeSegments requires both operands to be ACTIVE segments in the current epoch. If either segment has already been sealed (by a split, merge, rebucket rollover, or expiration) the method throws IllegalArgumentException(\"Both segments must be active\"). Sealed segments are historical DAG nodes that can no longer take part in topology mutations.","triggerScenarios":"Calling mergeSegments on a pair where at least one ID refers to a sealed segment — e.g. merging a segment that was just split, merging a segment already consumed by a previous merge, or merging after a rebucket rollover sealed it.","commonSituations":"Auto-scale evaluator iterating a stale candidate list while a concurrent rebalance sealed the segments; retrying a merge after a failed CAS without re-reading the layout (the first attempt actually succeeded, sealing both parents); tests merging historical segments from the DAG lineage.","solutions":["Re-fetch the layout and confirm both segments are active (getAllSegments().get(id).isActive()) before merging","Pick merge candidates only from layout.getActiveSegments(), never from allSegments","On CAS failure, rebuild the layout from fresh ScalableTopicMetadata and recompute the merge pair — the previous merge may have succeeded","Pair merges by adjacency among currently active segments rather than caching segment IDs across operations"],"exampleFix":"// before\nlayout.mergeSegments(idA, idB, nowMs);\n// after\nSegmentInfo a = layout.getAllSegments().get(idA);\nSegmentInfo b = layout.getAllSegments().get(idB);\nif (a != null && b != null && a.isActive() && b.isActive()) {\n    layout.mergeSegments(idA, idB, nowMs);\n}","handlingStrategy":"validation","validationCode":"boolean bothActive(SegmentLayout layout, long id1, long id2) {\n    var segs = layout.getAllSegments();\n    var a = segs.get(id1); var b = segs.get(id2);\n    return a != null && b != null && a.isActive() && b.isActive();\n}","typeGuard":"boolean isActiveSegment(SegmentLayout layout, long id) {\n    return layout.getActiveSegments().containsKey(id);\n}","tryCatchPattern":"try {\n    newLayout = layout.mergeSegments(id1, id2, nowMs);\n} catch (IllegalArgumentException e) {\n    if (e.getMessage().equals(\"Both segments must be active\")) {\n        // likely already merged or sealed: reload layout, check if merge already applied, skip or retry\n        layout = SegmentLayout.fromMetadata(refreshMetadata());\n    } else {\n        throw e;\n    }\n}","preventionTips":["Only source segment pairs from getActiveSegments()","After a CAS failure, reload the layout before retrying — the prior attempt may have sealed the segments","Track epoch numbers so stale layouts are detected immediately","Never merge segments from lineage/history collections"],"tags":["java","illegal-argument","segment-state","segment-layout"],"backgroundTag":"segment-not-active","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"}