Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,15 @@ The max allowed delay for delayed delivery (in milliseconds). If the broker rece
)
private int scalableTopicEntryBucketBudget = 4;

@FieldContext(
dynamic = true,
category = CATEGORY_POLICIES,
doc = "Hard ceiling on a single segment's entry-bucket count (PIP-486). Bounds both the "
+ "manual rebucket operation and the controller's auto rebucket-up; a segment's "
+ "bucket count caps how many consumers can share it."
)
private int scalableTopicEntryBucketMaxPerSegment = 1024;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[BUG] the new per-segment bucket ceiling is enforced only on the rebucket path — merge and initial creation both bypass it, and above the 16-bit ring the layout persists before it fails

The doc calls this a "Hard ceiling on a single segment's entry-bucket count", and PIP-486 L136-137 states it as an absolute per-segment bound. It is consulted in exactly one place — ScalableTopicController.rebucketSegment (:749-755) plus the evaluator's Math.min(..., config.maxEntryBucketsPerSegment()). The other two producers of a bucket count do not:

  • SegmentLayout.mergeSegments sets the merged segment to seg1.bucketCount() + seg2.bucketCount() (SegmentLayout.java:227) with no clamp. Merging two segments that were each rebucketed to the default 1024 yields 2048 — above the documented hard ceiling.
  • ScalableTopicController.createInitialMetadata derives N from EntryBucketSplits.bucketsForBudget(entryBucketBudget, numInitialSegments) (:1524), also unclamped; the split-child path at :1576 does the same. scalableTopicEntryBucketBudget is itself a dynamic config, so a budget of 2048 on a single-segment topic creates N=2048 at creation time.

Above the ring size this stops being cosmetic. EntryBucketSplits.equalWidth(n) computes i * 65536 / n, so for n > 65536 consecutive split points collide; EntryBucketSplits.ranges then builds HashRange.of(start, split - 1) with split == start, and HashRange's compact constructor throws IllegalArgumentException ("end must be >= start"). That happens in SubscriptionCoordinator.computeAssignment — i.e. after the layout has already been committed by the metadata CAS at ScalableTopicController.java:770-774. The segment is persisted with boundaries no assignment can materialize, and stays that way until it is pruned.

Reaching it requires an operator to raise scalableTopicEntryBucketMaxPerSegment (or the budget) above 65536, both of which are dynamic = true and neither of which is validated — see the next comment. A bound at HashRange.MAX_HASH + 1, applied at every point that produces a split list rather than only on the rebucket path, would close all three.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed on all three fronts, fixed in 5cb4cf7. EntryBucketSplits now carries the absolute ring bound (one bucket per 16-bit hash) applied inside equalWidth/bucketsForBudget, so an over-ring split list can never persist boundaries that later fail HashRange construction at assignment time; merges clamp the recovered bucket sum to the configured ceiling, and topic creation clamps the budget-derived count to it (ceiling-aware overloads, with the production call sites passing the config). Both dynamic settings are validated now — see the validated() thread. Unit tests cover the ring clamp and the merge clamp.


@FieldContext(
dynamic = true,
category = CATEGORY_POLICIES,
Expand All @@ -1486,6 +1495,24 @@ The max allowed delay for delayed delivery (in milliseconds). If the broker rece
)
private int scalableTopicSplitCooldownSeconds = 60;

@FieldContext(
dynamic = true,
category = CATEGORY_POLICIES,
doc = "PIP-486 segments-vs-buckets lever: on consumer-driven scale-up, split only if the "
+ "busiest segment's inbound msg/s is at or above this floor; below it the "
+ "controller grows the segment's entry-buckets instead (a low-throughput topic "
+ "should not materialize physical segments just for consumer count)."
)
private double scalableTopicSplitVsRebucketMinMsgRateInThreshold = 1_000;

@FieldContext(
dynamic = true,
category = CATEGORY_POLICIES,
doc = "Minimum time (seconds) between automatic entry-bucket rollovers (rebuckets) on a "
+ "topic. Coalesces consumer-join bursts, like the split cooldown."
)
private int scalableTopicRebucketCooldownSeconds = 60;

@FieldContext(
dynamic = true,
category = CATEGORY_POLICIES,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ public void createScalableTopic(
ScalableTopicMetadata metadata = ScalableTopicController.createInitialMetadata(
numInitialSegments,
pulsar().getConfiguration().getScalableTopicEntryBucketBudget(),
pulsar().getConfiguration().getScalableTopicEntryBucketMaxPerSegment(),
props);
return resources().createScalableTopicAsync(tn, metadata)
.thenCompose(ignored -> createInitialSegmentTopicsAsync(tn, metadata));
Expand Down Expand Up @@ -935,6 +936,61 @@ public void splitSegment(
});
}

@POST
@Path("/{tenant}/{namespace}/{topic}/rebucket/{segmentId}")
@Operation(summary = "Rebucket a segment: roll it over to a same-range successor with a new "
+ "entry-bucket count.")
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Segment rebucketed successfully"),
@ApiResponse(responseCode = "404", description = "Scalable topic doesn't exist"),
@ApiResponse(responseCode = "412", description = "Segment is unknown, not active, "
+ "or the bucket count is invalid or unchanged"),
@ApiResponse(responseCode = "500", description = "Internal server error")})
public void rebucketSegment(
@Suspended final AsyncResponse asyncResponse,
@Parameter(description = "Specify the tenant", required = true)
@PathParam("tenant") String tenant,
@Parameter(description = "Specify the namespace", required = true)
@PathParam("namespace") String namespace,
@Parameter(description = "Specify topic name", required = true)
@PathParam("topic") @Encoded String encodedTopic,
@Parameter(description = "Segment ID to rebucket", required = true)
@PathParam("segmentId") long segmentId,
@Parameter(description = "Entry-bucket count for the successor segment", required = true)
@QueryParam("bucketCount") int bucketCount) {
validateNamespaceName(tenant, namespace);
TopicName tn = TopicName.get(TopicDomain.topic.value(), namespaceName, encodedTopic);

validateSuperUserAccessAsync()
.thenCompose(__ -> onControllerLeader(tn,
svc -> svc.rebucketSegment(tn, segmentId, bucketCount)))
.thenAccept(__ -> {
log.info().attr("clientAppId", clientAppId())
.attr("segmentId", segmentId).attr("bucketCount", bucketCount)
.attr("topic", tn)
.log("Rebucketed segment of scalable topic");
asyncResponse.resume(Response.noContent().build());
})
.exceptionally(ex -> {
Throwable cause = FutureUtil.unwrapCompletionException(ex);
if (cause instanceof IllegalArgumentException) {
// Segment-level validation (unknown, sealed, bad or unchanged bucket
// count): a client error, not a server one.
log.info().attr("clientAppId", clientAppId())
.attr("segmentId", segmentId).attr("topic", tn)
.attr("reason", cause.getMessage()).log("Rebucket rejected");
asyncResponse.resume(new RestException(
Response.Status.PRECONDITION_FAILED, cause.getMessage()));
return null;
}
log.error().attr("clientAppId", clientAppId())
.attr("segmentId", segmentId).attr("topic", tn)
.exception(ex).log("Failed to rebucket segment");
resumeAsyncResponseExceptionally(asyncResponse, ex);
return null;
});
}

@POST
@Path("/{tenant}/{namespace}/{topic}/merge/{segmentId1}/{segmentId2}")
@Operation(summary = "Merge two adjacent segments into one.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
* @param mergeWindow how long a segment must continuously stay below every merge threshold
* before it becomes merge-eligible (measured from the load record's
* metadata-store last-modified time)
* @param rebucketCooldown minimum interval between automatic entry-bucket rollovers
* @param splitVsRebucketMinMsgRateIn consumer-driven scale-up splits only at/above this inbound
* msg/s on the busiest segment; below it, entry-buckets grow instead
* @param maxEntryBucketsPerSegment hard ceiling on a single segment's entry-bucket count
* @param splitMsgRateIn inbound msg/s above which a segment is split
* @param splitBytesRateIn inbound bytes/s above which a segment is split
* @param splitMsgRateOut outbound (dispatched) msg/s above which a segment is split
Expand All @@ -62,8 +66,11 @@ public record AutoScaleConfig(
int minSegments,
int maxDagDepth,
Duration splitCooldown,
Duration rebucketCooldown,
Duration mergeCooldown,
Duration mergeWindow,
double splitVsRebucketMinMsgRateIn,
int maxEntryBucketsPerSegment,
double splitMsgRateIn,
double splitBytesRateIn,
double splitMsgRateOut,
Expand Down Expand Up @@ -112,6 +119,10 @@ private static AutoScaleConfig brokerDefaults(ServiceConfiguration conf) {
.minSegments(conf.getScalableTopicMinSegments())
.maxDagDepth(conf.getScalableTopicMaxDagDepth())
.splitCooldown(Duration.ofSeconds(conf.getScalableTopicSplitCooldownSeconds()))
.rebucketCooldown(Duration.ofSeconds(conf.getScalableTopicRebucketCooldownSeconds()))
.splitVsRebucketMinMsgRateIn(
conf.getScalableTopicSplitVsRebucketMinMsgRateInThreshold())
.maxEntryBucketsPerSegment(conf.getScalableTopicEntryBucketMaxPerSegment())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[BUG] the three new policy fields were not added to AutoScaleConfig.validated(), whose sibling invariants they mirror

validated() checks every other field on this record — !splitCooldown.isNegative(), !mergeCooldown.isNegative(), !mergeWindow.isNegative(), all four split thresholds > 0, all four merge thresholds >= 0, plus the hysteresis pairs. None of rebucketCooldown, splitVsRebucketMinMsgRateIn or maxEntryBucketsPerSegment was added.

public AutoScaleConfig validated() {
check(minSegments >= 1, "minSegments must be >= 1");
check(maxSegments >= minSegments, "maxSegments must be >= minSegments");
check(maxDagDepth >= 0, "maxDagDepth must be >= 0");
check(!splitCooldown.isNegative(), "splitCooldown must not be negative");
check(!mergeCooldown.isNegative(), "mergeCooldown must not be negative");
check(!mergeWindow.isNegative(), "mergeWindow must not be negative");
check(splitMsgRateIn > 0, "splitMsgRateInThreshold must be > 0");
check(splitBytesRateIn > 0, "splitBytesRateInThreshold must be > 0");
check(splitMsgRateOut > 0, "splitMsgRateOutThreshold must be > 0");
check(splitBytesRateOut > 0, "splitBytesRateOutThreshold must be > 0");
check(mergeMsgRateIn >= 0, "mergeMsgRateInThreshold must be >= 0");
check(mergeBytesRateIn >= 0, "mergeBytesRateInThreshold must be >= 0");
check(mergeMsgRateOut >= 0, "mergeMsgRateOutThreshold must be >= 0");
check(mergeBytesRateOut >= 0, "mergeBytesRateOutThreshold must be >= 0");
check(splitMsgRateIn > mergeMsgRateIn,
"splitMsgRateInThreshold must be > mergeMsgRateInThreshold (hysteresis)");
check(splitBytesRateIn > mergeBytesRateIn,
"splitBytesRateInThreshold must be > mergeBytesRateInThreshold (hysteresis)");
check(splitMsgRateOut > mergeMsgRateOut,
"splitMsgRateOutThreshold must be > mergeMsgRateOutThreshold (hysteresis)");
check(splitBytesRateOut > mergeBytesRateOut,
"splitBytesRateOutThreshold must be > mergeBytesRateOutThreshold (hysteresis)");
return this;
}

Concretely:

  • A negative rebucketCooldownSeconds makes withinCooldown always false — the rollover throttle silently disappears. Its two Duration siblings are both guarded.
  • A negative splitVsRebucketMinMsgRateIn puts every topic permanently in the split lane; NaN, reachable through the JSON override, makes both < and >= false, which lands in the bucket lane regardless of traffic.
  • maxEntryBucketsPerSegment has neither a lower bound (<= 0 makes rebucketSegment's [1, maxBuckets] range empty, so every rebucket including the automatic one is rejected) nor the upper bound against the ring discussed in the previous comment.

Two of the three are reachable from AutoScalePolicyOverride, whose own class javadoc promises the opposite: "The resolved policy must satisfy the same invariants as the broker configuration (positive split thresholds, split thresholds strictly above merge thresholds, minSegments <= maxSegments, non-negative cooldowns); an override that would violate them is rejected when it is set." rebucketCooldownSeconds is now a cooldown on that class that is not rejected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 5cb4cf7: non-negative rebucketCooldown; the split-vs-rebucket floor checked as >= 0 written in the NaN-rejecting form; and maxEntryBucketsPerSegment bounded to [1, ring size]. New rejection cases in AutoScaleConfigTest restore the AutoScalePolicyOverride contract you quoted.

.mergeCooldown(Duration.ofSeconds(conf.getScalableTopicMergeCooldownSeconds()))
.mergeWindow(Duration.ofSeconds(conf.getScalableTopicMergeWindowSeconds()))
.splitMsgRateIn(conf.getScalableTopicSplitMsgRateInThreshold())
Expand Down Expand Up @@ -145,6 +156,12 @@ private static AutoScaleConfig applyOverride(AutoScaleConfig base, AutoScalePoli
if (o.getSplitCooldownSeconds() != null) {
b.splitCooldown(Duration.ofSeconds(o.getSplitCooldownSeconds()));
}
if (o.getRebucketCooldownSeconds() != null) {
b.rebucketCooldown(Duration.ofSeconds(o.getRebucketCooldownSeconds()));
}
if (o.getSplitVsRebucketMinMsgRateInThreshold() != null) {
b.splitVsRebucketMinMsgRateIn(o.getSplitVsRebucketMinMsgRateInThreshold());
}
if (o.getMergeCooldownSeconds() != null) {
b.mergeCooldown(Duration.ofSeconds(o.getMergeCooldownSeconds()));
}
Expand Down Expand Up @@ -194,6 +211,7 @@ public AutoScaleConfig validated() {
check(maxSegments >= minSegments, "maxSegments must be >= minSegments");
check(maxDagDepth >= 0, "maxDagDepth must be >= 0");
check(!splitCooldown.isNegative(), "splitCooldown must not be negative");
check(!rebucketCooldown.isNegative(), "rebucketCooldown must not be negative");
check(!mergeCooldown.isNegative(), "mergeCooldown must not be negative");
check(!mergeWindow.isNegative(), "mergeWindow must not be negative");
check(splitMsgRateIn > 0, "splitMsgRateInThreshold must be > 0");
Expand All @@ -204,6 +222,12 @@ public AutoScaleConfig validated() {
check(mergeBytesRateIn >= 0, "mergeBytesRateInThreshold must be >= 0");
check(mergeMsgRateOut >= 0, "mergeMsgRateOutThreshold must be >= 0");
check(mergeBytesRateOut >= 0, "mergeBytesRateOutThreshold must be >= 0");
// Written as >= so a NaN (reachable via the JSON override) fails the check too.
check(splitVsRebucketMinMsgRateIn >= 0,
"splitVsRebucketMinMsgRateInThreshold must be >= 0");
check(maxEntryBucketsPerSegment >= 1 && maxEntryBucketsPerSegment
<= EntryBucketSplits.MAX_BUCKETS,
"maxEntryBucketsPerSegment must be in [1, " + EntryBucketSplits.MAX_BUCKETS + "]");
check(splitMsgRateIn > mergeMsgRateIn,
"splitMsgRateInThreshold must be > mergeMsgRateInThreshold (hysteresis)");
check(splitBytesRateIn > mergeBytesRateIn,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
* carries a short {@code reason} string used for logging and metrics.
*/
public sealed interface AutoScaleDecision
permits AutoScaleDecision.Split, AutoScaleDecision.Merge, AutoScaleDecision.NoAction {
permits AutoScaleDecision.Split, AutoScaleDecision.Merge, AutoScaleDecision.Rebucket,
AutoScaleDecision.NoAction {

/** Split {@code segmentId} at its midpoint. */
record Split(long segmentId, String reason) implements AutoScaleDecision {
Expand All @@ -34,6 +35,16 @@ record Split(long segmentId, String reason) implements AutoScaleDecision {
record Merge(long segmentId1, long segmentId2, String reason) implements AutoScaleDecision {
}

/**
* Roll every segment in {@code segmentIds} over to a same-range successor with
* {@code newBucketCount} entry-buckets (PIP-486): consumer scale-up served by buckets
* instead of a split. One decision carries the whole batch so a multi-segment topic
* converges to a uniform bucketing in a single evaluation, not one segment per cooldown.
*/
record Rebucket(java.util.List<Long> segmentIds, int newBucketCount, String reason)
implements AutoScaleDecision {
}

/** No action this evaluation. */
record NoAction() implements AutoScaleDecision {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,29 +68,115 @@ public static AutoScaleDecision decide(
AutoScaleConfig config,
long nowMs,
long lastSplitAtMs,
long lastMergeAtMs) {
long lastMergeAtMs,
long lastRebucketAtMs) {

if (!config.enabled()) {
return AutoScaleDecision.NONE;
}

List<SegmentInfo> active = new ArrayList<>(layout.getActiveSegments().values());

AutoScaleDecision split = trySplit(active, loadBySegment, streamConsumerCount,
config, nowMs, lastSplitAtMs);
AutoScaleDecision consumerScale = tryConsumerScale(active, loadBySegment,
streamConsumerCount, config, nowMs, lastSplitAtMs, lastRebucketAtMs);
if (!(consumerScale instanceof AutoScaleDecision.NoAction)) {
return consumerScale;
}

AutoScaleDecision split = trySplit(active, loadBySegment, config, nowMs, lastSplitAtMs);
if (!(split instanceof AutoScaleDecision.NoAction)) {
return split;
}

return tryMerge(active, layout, loadBySegment, config, nowMs, lastMergeAtMs);
}

// --- Consumer-driven scale-up: segments vs entry-buckets (PIP-486) ---

/**
* Serve surplus consumers (a subscription with more consumers than active segments) by
* adding capacity on one of two axes:
* <ul>
* <li><b>Split</b> when traffic justifies a physical segment: the busiest segment's
* inbound rate is at or above {@code splitVsRebucketMinMsgRateIn} and the topic is
* under {@code maxSegments} — today's "segments first" behavior.</li>
* <li><b>Rebucket-up</b> otherwise (a low-throughput topic, or the topic is at the
* segment cap): if the existing entry-bucket capacity cannot absorb the surplus,
* roll the smallest-bucketed segment over to the smallest power of two that lets
* every consumer own a bucket, capped at {@code maxEntryBucketsPerSegment}.
* Raising is fast (one rollover sized to the surplus); lowering is deliberately
* not automated here — spiky consumer counts must not flap the bucketing.</li>
* </ul>
*/
private static AutoScaleDecision tryConsumerScale(
List<SegmentInfo> active,
Map<Long, SegmentLoadSample> loadBySegment,
Map<String, Integer> streamConsumerCount,
AutoScaleConfig config,
long nowMs,
long lastSplitAtMs,
long lastRebucketAtMs) {

int consumers = streamConsumerCount.values().stream()
.mapToInt(Integer::intValue).max().orElse(0);
int segments = active.size();
if (consumers <= segments) {
return AutoScaleDecision.NONE;
}

SegmentInfo busiest = busiestByMsgRateIn(active, loadBySegment);
if (busiest == null) {
return AutoScaleDecision.NONE;
}
boolean atSegmentCap = segments >= config.maxSegments();
boolean belowSplitFloor = statsOf(busiest.segmentId(), loadBySegment).msgRateIn()
< config.splitVsRebucketMinMsgRateIn();

if (!atSegmentCap && !belowSplitFloor) {
// Traffic justifies a physical segment.
if (withinCooldown(nowMs, lastSplitAtMs, config.splitCooldown().toMillis())) {
return AutoScaleDecision.NONE;
}
return new AutoScaleDecision.Split(busiest.segmentId(), "consumer-count");
}

// Bucket lane: absorb the surplus with entry-buckets.
long capacity = 0;
for (SegmentInfo segment : active) {
capacity += segment.bucketCount();
}
if (consumers <= capacity) {
// The existing buckets already absorb the surplus (broker-side fan-out).
return AutoScaleDecision.NONE;
}
if (withinCooldown(nowMs, lastRebucketAtMs, config.rebucketCooldown().toMillis())) {
return AutoScaleDecision.NONE;
}
// One shot: bring every segment below the common per-segment target up to it in a
// single decision, so the topic converges to a uniform bucketing in one evaluation —
// never one segment per cooldown, and no arrival-history-dependent skew.
int target = Math.min(nextPowerOfTwo(ceilDiv(consumers, segments)),

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.

Thanks for working on this. I found a multi-segment case that I would like to confirm.

With four active N=1 segments at maxSegments and ten consumers, the target bucket count is calculated as N=4, but each decision rebuckets only one segment. The capacity therefore changes as follows:

[1,1,1,1] -> [4,1,1,1] -> [4,4,1,1]

After the first rollover, the total active capacity is seven, so three consumers remain unassigned until another evaluation after the topic-wide rebucket cooldown. Following the same behavior, the PIP example with 64 N=1 segments and 200 consumers appears to require about 46 cooldown-separated rollovers.

I would like to confirm whether this staged convergence is the intended behavior for a multi-segment topic, and whether the “one rollover” expectation applies only when there is a single active segment.

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.

One related point I wanted to clarify, following the convergence case above. Setting aside the drained-parent assignment issue from the other thread, the earlier example looks at how long it takes to reach enough active capacity; this example looks at the steady state left once that condition is met.

With four active N=1 segments, maxSegments=4, maxEntryBucketsPerSegment >= 8, and one STREAM subscription jumping directly to 17 consumers, the evaluator produces:

[1,1,1,1] -> [8,1,1,1] -> [8,8,1,1]

Aggregate capacity is then 18, so no further rebucket is triggered. Looking only at the active layout, the 17 owners can be distributed as [8,7,1,1]. With gradual consumer growth, the same final group size can instead leave [8,4,4,4], distributing the owners as [5,4,4,4].

I can see the trade-off here: the burst path reaches sufficient capacity in only two rollovers, reducing topic, cursor, metadata, and handoff work, while the resulting steady-state fan-out is more uneven and depends on the group’s arrival history.

I would like to confirm whether this is the intended policy boundary: prioritizing aggregate capacity and rollover convergence, while accepting uneven per-segment fan-out as the steady-state trade-off. Clarifying that boundary would also help separate the behavior intended in this change from possible follow-up policy work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not intended to stay that way — fixed in 332a920: one decision now carries every segment below the common per-segment target, and dispatch rolls the batch sequentially, so a multi-segment topic converges in a single evaluation with one cooldown for the whole batch. Your 4×N=1 / 10-consumer case goes [1,1,1,1] → [4,4,4,4] in one shot (added as an evaluator test), and the PIP's 64-segment / 200-consumer example likewise converges in one evaluation. A mid-batch failure aborts the remainder; the post-rollover follow-up evaluation retries it after the cooldown.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The one-shot change in 332a920 settles this too: every below-target segment reaches the same target in the same decision, so the steady state is uniform regardless of arrival history — the 17-consumer burst ends at [8,8,8,8] rather than [8,8,1,1] (the capacity overshoot from power-of-two rounding is the accepted cost). Added a test for a partially-rebucketed layout converging to the uniform target.

config.maxEntryBucketsPerSegment());
List<Long> below = new ArrayList<>();
for (SegmentInfo segment : active) {
if (segment.bucketCount() < target) {
below.add(segment.segmentId());
}
}
if (below.isEmpty()) {
// Bucket capacity is maxed out; the remaining surplus stays idle.
return AutoScaleDecision.NONE;
}
below.sort(Long::compareTo);
return new AutoScaleDecision.Rebucket(below, target,
atSegmentCap ? "at-max-segments" : "below-split-rate-floor");
}

// --- Split pass ---

private static AutoScaleDecision trySplit(
List<SegmentInfo> active,
Map<Long, SegmentLoadSample> loadBySegment,
Map<String, Integer> streamConsumerCount,
AutoScaleConfig config,
long nowMs,
long lastSplitAtMs) {
Expand All @@ -102,20 +188,7 @@ private static AutoScaleDecision trySplit(
return AutoScaleDecision.NONE;
}

// (a) Consumer-driven: per-subscription max. If any managed subscription has more
// consumers than there are active segments, add a segment so the 1:1 assignment can
// give the extra consumer its own segment. Split the busiest segment by msgRateIn so
// the new pair lands where it relieves the most ingest.
int requiredConsumers = streamConsumerCount.values().stream()
.mapToInt(Integer::intValue).max().orElse(0);
if (requiredConsumers > active.size()) {
SegmentInfo target = busiestByMsgRateIn(active, loadBySegment);
if (target != null) {
return new AutoScaleDecision.Split(target.segmentId(), "consumer-count");
}
}

// (b) Load-driven: split the segment with the highest overload score among those over
// Load-driven: split the segment with the highest overload score among those over
// at least one split threshold.
SegmentInfo hottest = null;
double hottestScore = 1.0; // strictly over threshold means a per-metric ratio > 1.0
Expand Down Expand Up @@ -239,6 +312,17 @@ private static double combinedRate(long segmentId, Map<Long, SegmentLoadSample>
return s.msgRateIn() + s.bytesRateIn() + s.msgRateOut() + s.bytesRateOut();
}

/** Ceiling integer division for positive operands. */
private static int ceilDiv(int a, int b) {
return (a + b - 1) / b;
}

/** The smallest power of two {@code >= v} (for {@code v >= 1}). */
private static int nextPowerOfTwo(int v) {
int highest = Integer.highestOneBit(v);
return highest == v ? v : highest << 1;
}

private static SegmentInfo busiestByMsgRateIn(List<SegmentInfo> active,
Map<Long, SegmentLoadSample> load) {
SegmentInfo best = null;
Expand Down
Loading
Loading