mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
fix(combo): isolate session stickiness by combo (#10137)
Co-authored-by: Bryan Nathan <bryan@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user