diff --git a/changelog.d/fixes/12732-quota-share-unweighted-drr.md b/changelog.d/fixes/12732-quota-share-unweighted-drr.md new file mode 100644 index 0000000000..6406d7fbe3 --- /dev/null +++ b/changelog.d/fixes/12732-quota-share-unweighted-drr.md @@ -0,0 +1 @@ +- **fix(combo):** a `quota-share` combo whose steps carry no weight now rotates across its targets again instead of sending every request to the first one — the resolver turns an unset weight into 0 and #10881 made 0 mean "disabled", so an all-unweighted combo had no quanta and fell back to definition order; an explicit 0 still disables a target next to weighted siblings ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/open-sse/services/combo/quotaShareStrategy.ts b/open-sse/services/combo/quotaShareStrategy.ts index e12958042c..8be29743aa 100644 --- a/open-sse/services/combo/quotaShareStrategy.ts +++ b/open-sse/services/combo/quotaShareStrategy.ts @@ -180,12 +180,17 @@ function applyDrr(targets: ResolvedComboTarget[], comboName: string): ResolvedCo if (targets.length <= 1) return targets.slice(); const deficits = getDrrDeficits(comboName); - const totalWeight = targets.reduce((sum, t) => sum + normalizeWeight(t.weight), 0); - if (totalWeight <= 0) return targets.slice(); + const weights = targets.map((t) => normalizeWeight(t.weight)); + const weightedTotal = weights.reduce((sum, w) => sum + w, 0); + // A 0 disables a target only relative to weighted siblings. The combo resolver turns an + // unset step weight into 0, so an all-zero set is an unweighted combo, not an all-disabled + // one: share evenly instead of returning definition order, which pinned the first target. + const unweighted = weightedTotal <= 0; + const totalWeight = unweighted ? targets.length : weightedTotal; // Add each target's quantum (weight share) to its deficit. - for (const target of targets) { - const quantum = normalizeWeight(target.weight) / totalWeight; + for (const [index, target] of targets.entries()) { + const quantum = (unweighted ? 1 : weights[index]) / totalWeight; deficits.set(target.executionKey, (deficits.get(target.executionKey) ?? 0) + quantum); } diff --git a/tests/unit/quota-share-strategy.test.ts b/tests/unit/quota-share-strategy.test.ts index 57481c41a2..970ec2bd88 100644 --- a/tests/unit/quota-share-strategy.test.ts +++ b/tests/unit/quota-share-strategy.test.ts @@ -282,6 +282,48 @@ describe("DRR: deficit round robin", () => { ); }); + test("unweighted steps (weight 0 from the resolver) still alternate instead of pinning the first", () => { + // comboStructure resolves a step with no weight to 0, and #10881 made 0 mean "disabled". + // With every target at 0 the total weight is 0 and DRR returned definition order, so a + // quota-share combo without explicit weights sent every request to its first target. + const t1 = makeTarget("ek-unweighted-1", "conn-unweighted-1", 0); + const t2 = makeTarget("ek-unweighted-2", "conn-unweighted-2", 0); + + const selected: Array = []; + for (let i = 0; i < 4; i++) { + const r = selectQuotaShareTarget( + [t1, t2], + "combo-unweighted", + "anthropic/claude-sonnet-4-5", + NOW + ); + selected.push(r.target?.executionKey); + r.decrementInflight(); + } + assert.deepEqual(selected, [ + "ek-unweighted-1", + "ek-unweighted-2", + "ek-unweighted-1", + "ek-unweighted-2", + ]); + }); + + test("an explicit weight 0 still disables that target while another target is weighted", () => { + const weighted = makeTarget("ek-on", "conn-on", 100); + const disabled = makeTarget("ek-off", "conn-off", 0); + + for (let i = 0; i < 4; i++) { + const r = selectQuotaShareTarget( + [disabled, weighted], + "combo-disabled", + "anthropic/claude-sonnet-4-5", + NOW + ); + assert.equal(r.target?.executionKey, "ek-on"); + r.decrementInflight(); + } + }); + test("DRR state is isolated per comboName", () => { const t = makeTarget("ek-shared", "conn-shared", 100); selectQuotaShareTarget([t], "combo-A", "anthropic/claude-sonnet-4-5", NOW);