fix(combo): isolate session stickiness by combo (#10137)

Co-authored-by: Bryan Nathan <bryan@users.noreply.github.com>
This commit is contained in:
Nathan
2026-08-13 18:53:02 +08:00
committed by GitHub
parent a2e5bd1dfc
commit 06f41cda63
5 changed files with 60 additions and 6 deletions

View File

@@ -0,0 +1 @@
- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136)

View File

@@ -2644,7 +2644,8 @@ async function handleRoundRobinCombo({
filteredTargets,
// #7270: normalize both wire shapes (.messages / Responses-API .input) so RR
// stickiness engages on the /v1/responses surface, not just Chat Completions.
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
combo.name
);
const rrAffinity = applyPromptCacheAffinity(
filteredTargets,

View File

@@ -8,7 +8,8 @@
*
* Design
* ──────
* • Hash key: SHA-256 of the FIRST user message → first 16 hex chars.
* • Hash key: SHA-256 of the FIRST user message, namespaced by Combo identity
* at production call sites → first 16 hex chars.
* Using only the first message gives a stable key that does not change as
* the conversation grows, yet still identifies the conversation reliably.
* • Headroom gate: before reusing the sticky connection we re-check that its
@@ -317,6 +318,23 @@ export function deriveMessageHash(
return createHash("sha256").update(text).digest("hex").slice(0, 16);
}
/**
* Keep one conversation's prompt-cache affinity local to the Combo that learned
* it. Without this namespace, two different Combos receiving the same first
* user message share a binding and can silently reorder each other's targets.
* The unscoped form remains available for direct callers and backwards-compatible
* unit seams; production dispatchers always provide their Combo name.
*/
function scopeMessageHash(messageHash: string, namespace?: string): string {
if (!namespace) return messageHash;
return createHash("sha256")
.update(namespace)
.update("\0")
.update(messageHash)
.digest("hex")
.slice(0, 16);
}
/** Evict expired entries and enforce the hard cap. */
function evict(): void {
const now = Date.now();
@@ -424,19 +442,22 @@ export interface ApplyStickinessResult {
*
* @param orderedTargets Targets already ordered by the combo strategy.
* @param messages Request body.messages.
* @param namespace Combo identity that owns this sticky binding.
* @returns Result with (possibly reordered) targets.
*/
export async function applySessionStickiness(
orderedTargets: ResolvedComboTarget[],
messages: Array<{ role?: string; content?: unknown }> | null | undefined
messages: Array<{ role?: string; content?: unknown }> | null | undefined,
namespace?: string
): Promise<ApplyStickinessResult> {
const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false };
try {
if (orderedTargets.length <= 1) return noOp;
const messageHash = deriveMessageHash(messages);
if (!messageHash) return noOp;
const rawMessageHash = deriveMessageHash(messages);
if (!rawMessageHash) return noOp;
const messageHash = scopeMessageHash(rawMessageHash, namespace);
const existing = stickyMap.get(messageHash);
if (!existing) return { targets: orderedTargets, messageHash, stuck: false };

View File

@@ -498,7 +498,8 @@ async function applyContinuityFilters(
initialOrderedTargets,
// #7270: normalize both wire shapes (.messages / Responses-API .input) so the
// stickiness key is derivable on the /v1/responses surface, not just Chat Completions.
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
combo.name
);
let orderedTargets = sticky.targets;
if (!cacheStrategyAffinityApplied) {

View File

@@ -214,6 +214,36 @@ test("different message hashes can map to different connections", async () => {
assert.equal(r2.targets[0].connectionId, "conn-Y");
});
test("identical first messages do not leak sticky targets across combos", async () => {
injectSat({ util5h: 0.0, util7d: 0.0 });
const messages = [{ role: "user", content: "Shared control prompt" }];
const targets = [
makeTarget("conn-primary"),
makeTarget("conn-intermediate"),
makeTarget("conn-last"),
];
const firstCombo = await applySessionStickiness(targets, messages, "combo-with-last-success");
assert.ok(firstCombo.messageHash);
recordStickyBinding(firstCombo.messageHash, "conn-last");
const repeatedFirstCombo = await applySessionStickiness(
targets,
messages,
"combo-with-last-success"
);
assert.equal(repeatedFirstCombo.stuck, true);
assert.equal(repeatedFirstCombo.targets[0].connectionId, "conn-last");
const secondCombo = await applySessionStickiness(targets, messages, "fresh-priority-combo");
assert.equal(secondCombo.stuck, false);
assert.deepEqual(
secondCombo.targets.map((target) => target.connectionId),
["conn-primary", "conn-intermediate", "conn-last"],
"a binding learned by another combo must not reorder this combo's configured priority"
);
});
test("saturation fetch error → fail-open (original order, no crash)", async () => {
__setStickinessHeadroomFetcherForTests(async (_id: string) => {
throw new Error("network failure");