mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +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,
|
filteredTargets,
|
||||||
// #7270: normalize both wire shapes (.messages / Responses-API .input) so RR
|
// #7270: normalize both wire shapes (.messages / Responses-API .input) so RR
|
||||||
// stickiness engages on the /v1/responses surface, not just Chat Completions.
|
// 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(
|
const rrAffinity = applyPromptCacheAffinity(
|
||||||
filteredTargets,
|
filteredTargets,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
*
|
*
|
||||||
* Design
|
* 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
|
* Using only the first message gives a stable key that does not change as
|
||||||
* the conversation grows, yet still identifies the conversation reliably.
|
* the conversation grows, yet still identifies the conversation reliably.
|
||||||
* • Headroom gate: before reusing the sticky connection we re-check that its
|
* • 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);
|
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. */
|
/** Evict expired entries and enforce the hard cap. */
|
||||||
function evict(): void {
|
function evict(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -424,19 +442,22 @@ export interface ApplyStickinessResult {
|
|||||||
*
|
*
|
||||||
* @param orderedTargets Targets already ordered by the combo strategy.
|
* @param orderedTargets Targets already ordered by the combo strategy.
|
||||||
* @param messages Request body.messages.
|
* @param messages Request body.messages.
|
||||||
|
* @param namespace Combo identity that owns this sticky binding.
|
||||||
* @returns Result with (possibly reordered) targets.
|
* @returns Result with (possibly reordered) targets.
|
||||||
*/
|
*/
|
||||||
export async function applySessionStickiness(
|
export async function applySessionStickiness(
|
||||||
orderedTargets: ResolvedComboTarget[],
|
orderedTargets: ResolvedComboTarget[],
|
||||||
messages: Array<{ role?: string; content?: unknown }> | null | undefined
|
messages: Array<{ role?: string; content?: unknown }> | null | undefined,
|
||||||
|
namespace?: string
|
||||||
): Promise<ApplyStickinessResult> {
|
): Promise<ApplyStickinessResult> {
|
||||||
const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false };
|
const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (orderedTargets.length <= 1) return noOp;
|
if (orderedTargets.length <= 1) return noOp;
|
||||||
|
|
||||||
const messageHash = deriveMessageHash(messages);
|
const rawMessageHash = deriveMessageHash(messages);
|
||||||
if (!messageHash) return noOp;
|
if (!rawMessageHash) return noOp;
|
||||||
|
const messageHash = scopeMessageHash(rawMessageHash, namespace);
|
||||||
|
|
||||||
const existing = stickyMap.get(messageHash);
|
const existing = stickyMap.get(messageHash);
|
||||||
if (!existing) return { targets: orderedTargets, messageHash, stuck: false };
|
if (!existing) return { targets: orderedTargets, messageHash, stuck: false };
|
||||||
|
|||||||
@@ -498,7 +498,8 @@ async function applyContinuityFilters(
|
|||||||
initialOrderedTargets,
|
initialOrderedTargets,
|
||||||
// #7270: normalize both wire shapes (.messages / Responses-API .input) so the
|
// #7270: normalize both wire shapes (.messages / Responses-API .input) so the
|
||||||
// stickiness key is derivable on the /v1/responses surface, not just Chat Completions.
|
// 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;
|
let orderedTargets = sticky.targets;
|
||||||
if (!cacheStrategyAffinityApplied) {
|
if (!cacheStrategyAffinityApplied) {
|
||||||
|
|||||||
@@ -214,6 +214,36 @@ test("different message hashes can map to different connections", async () => {
|
|||||||
assert.equal(r2.targets[0].connectionId, "conn-Y");
|
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 () => {
|
test("saturation fetch error → fail-open (original order, no crash)", async () => {
|
||||||
__setStickinessHeadroomFetcherForTests(async (_id: string) => {
|
__setStickinessHeadroomFetcherForTests(async (_id: string) => {
|
||||||
throw new Error("network failure");
|
throw new Error("network failure");
|
||||||
|
|||||||
Reference in New Issue
Block a user