Skip to content

Commit 074a67d

Browse files
ajcasagrandeYAMY1234claude
committed
feat(routing): lineage-scoped affinity for dynamo_nvext
Adds scope={conversation,lineage} to the dynamo_nvext mode (--session-routing-opt scope=lineage; default conversation, unchanged behavior). Under lineage, every session in an agent tree binds nvext.session_control with the tree ROOT's correlation ID instead of its own, so fork/spawn children co-locate on the worker (and data-parallel rank) already holding the shared parent prefix -- an explicit client-side affinity for Dynamo deployments without KV-event prefix indexing, where an unbound child request is otherwise placed arbitrarily and re-prefills the shared parent prefix once per child. Close discipline: a shared affinity key must never be torn down while sibling sessions may still run (a straggler's next bind would re-place it arbitrarily and lose co-location), so under lineage the close fires only on a request the credit issuer stamped is_tree_final -- provably the last request of the whole tree, available under agentic replay, where the has_branches gate guarantees a branch-declaring final turn never reads as tree-final while children are pending. Under indeterminate modes (reactive dag_jsonl) nothing closes and the session_control TTL reclaims the key. Lineage-scope design and motivation from #14 (YAMY1234), rebased onto the session-routing plugin layer: the flag surface there (--dynamo-session-affinity-scope + EndpointDefaults + EndpointInfo plumbing) collapses into this one Options field, and the root-final-turn close is replaced by the is_tree_final discipline. Verified with back-to-back --export-level raw mock-server runs of --scenario inferencex-agentx-mvp against the PR #14 implementation: child binds are wire-identical (keyed on the tree-root ID in both), and the lineage close fired exactly once -- on the provably-last wire request of a fully-drained tree. Co-Authored-By: YAMY1234 <yamy1234@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
1 parent 205b4dd commit 074a67d

4 files changed

Lines changed: 83 additions & 6 deletions

File tree

docs/plugins/plugin-system.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ the request-serialization chokepoint. The base class is `SessionRoutingBase`
146146
| Name | Class | Description |
147147
|------|-------|-------------|
148148
| `dynamo_headers` | `DynamoHeadersRouting` | Dynamo session affinity via `X-Dynamo-Session-ID`, plus `X-Dynamo-Parent-Session-ID` on subagent children. No options. |
149-
| `dynamo_nvext` | `DynamoNvextRouting` | Dynamo session affinity via `nvext.session_control` request-body metadata (bind on non-final turns, close on the final turn). Only for Dynamo builds that implement `session_control`. Option: `timeout_seconds` (default 300). |
149+
| `dynamo_nvext` | `DynamoNvextRouting` | Dynamo session affinity via `nvext.session_control` request-body metadata (bind on non-final turns, close on the final turn). Only for Dynamo builds that implement `session_control`. Options: `timeout_seconds` (default 300); `scope` (default `conversation`; `lineage` binds every session in an agent tree with the tree root's correlation ID for whole-lineage co-location on deployments without KV-event prefix indexing, closing the shared key only on a request stamped provably-last for the whole tree). |
150150
| `smg_routing_key` | `SmgRoutingKeyRouting` | SGLang Model Gateway `manual`-policy stickiness via `X-SMG-Routing-Key`. No options. |
151151
| `session_id_header` | `SessionIdHeaderRouting` | Preset: additive `X-Session-ID` header carrying the session's correlation ID. No options. |
152152
| `identity_headers` | `IdentityHeadersRouting` | Fully generic tiered identity headers: emits exactly what you configure, nothing by default. Options (each a comma-separable name list): `session` (this session's ID), `parent` (immediate parent's ID; omitted for roots), `root` (session-tree root's ID, for whole-tree affinity). Requires at least one name across tiers; names must be unique (case-insensitive) across all tiers combined, including within a single tier. |

src/aiperf/plugin/plugins.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,12 @@ session_routing:
449449
Only for Dynamo builds that implement session_control (current
450450
upstream main does not; use dynamo_headers there). Mutates request
451451
bodies, so it is incompatible with the PAYLOAD_BYTES mmap fast path.
452-
Option: timeout_seconds (default 300).
452+
Options: timeout_seconds (default 300); scope (default
453+
conversation) -- scope=lineage binds every session in an agent
454+
tree with the tree ROOT's correlation ID so the whole lineage
455+
co-locates with the shared parent prefix (for deployments without
456+
KV-event prefix indexing), closing the shared key only on a
457+
request stamped provably-last for the whole tree.
453458
454459
smg_routing_key:
455460
class: aiperf.workers.session_routing:SmgRoutingKeyRouting

src/aiperf/workers/session_routing.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
import re
2929
from abc import ABC
30-
from typing import Annotated, Any, ClassVar, Generic, TypeVar
30+
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar
3131

3232
from msgspec import Struct
3333
from pydantic import BeforeValidator, ConfigDict, Field, model_validator
@@ -141,6 +141,17 @@ class DynamoNvextOptions(AIPerfBaseModel):
141141
ge=1,
142142
description="Dynamo session_control inactivity timeout carried on every bind.",
143143
)
144+
scope: Literal["conversation", "lineage"] = Field(
145+
default="conversation",
146+
description="Affinity-key scope. 'conversation' (default) binds each "
147+
"session with its own correlation ID and closes on its final turn. "
148+
"'lineage' binds every session in an agent tree with the tree ROOT's "
149+
"correlation ID so the whole lineage co-locates on the worker holding "
150+
"the shared parent prefix (for Dynamo deployments without KV-event "
151+
"prefix indexing); the shared key is closed only on a request stamped "
152+
"provably-last for the whole tree (is_tree_final, agentic replay) -- "
153+
"otherwise the session_control TTL reclaims it.",
154+
)
144155

145156

146157
class DynamoNvextRouting(SessionRoutingBase[DynamoNvextOptions]):
@@ -150,6 +161,14 @@ class DynamoNvextRouting(SessionRoutingBase[DynamoNvextOptions]):
150161
router, refreshes the TTL), 'close' on the final turn. Targets Dynamo
151162
builds that implement session_control; current upstream Dynamo main does
152163
not (use dynamo_headers there).
164+
165+
Under ``scope=lineage`` the affinity key is the tree root's correlation ID
166+
and the close discipline changes: a shared key must never be torn down
167+
while sibling sessions may still run (a later bind would re-place the
168+
straggler arbitrarily and lose co-location), so close fires only on a
169+
request the issuer stamped ``is_tree_final`` -- conservative-exact under
170+
agentic replay, never under indeterminate modes, where the TTL reaper
171+
reclaims the key instead.
153172
"""
154173

155174
mutates_body: ClassVar[bool] = True
@@ -160,18 +179,25 @@ def __init__(self, options: DynamoNvextOptions) -> None:
160179
# Hot path: one attribute hop per request instead of two through the
161180
# pydantic options model.
162181
self._timeout = options.timeout_seconds
182+
self._lineage = options.scope == "lineage"
163183

164184
def transform_body(
165185
self, payload: dict[str, Any], ctx: RoutingContext
166186
) -> dict[str, Any]:
167-
if ctx.is_final_turn:
187+
if self._lineage:
188+
session_id = ctx.root_correlation_id or ctx.x_correlation_id
189+
is_close = ctx.is_tree_final
190+
else:
191+
session_id = ctx.x_correlation_id
192+
is_close = ctx.is_final_turn
193+
if is_close:
168194
session_control: dict[str, Any] = {
169-
"session_id": ctx.x_correlation_id,
195+
"session_id": session_id,
170196
"action": "close",
171197
}
172198
else:
173199
session_control = {
174-
"session_id": ctx.x_correlation_id,
200+
"session_id": session_id,
175201
"action": "bind",
176202
"timeout": self._timeout,
177203
}

tests/unit/workers/test_session_routing.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,52 @@ def test_non_dict_nvext_replaced(self):
129129
merged = plugin.transform_body({"nvext": "bogus"}, _ctx())
130130
assert merged["nvext"]["session_control"]["session_id"] == "corr-1"
131131

132+
def test_lineage_scope_child_binds_with_root_id(self):
133+
"""Under scope=lineage, every session in the tree binds the ROOT's
134+
correlation ID so the whole lineage co-locates."""
135+
plugin = DynamoNvextRouting(
136+
DynamoNvextOptions(scope="lineage", timeout_seconds=77)
137+
)
138+
ctx = _ctx(
139+
parent_correlation_id="root-1",
140+
root_correlation_id="root-1",
141+
is_final_turn=True, # child's own final turn must NOT close
142+
)
143+
sc = plugin.transform_body({}, ctx)["nvext"]["session_control"]
144+
assert sc == {"session_id": "root-1", "action": "bind", "timeout": 77}
145+
146+
def test_lineage_scope_root_final_turn_binds_not_closes(self):
147+
"""A shared key must never be torn down while siblings may still run:
148+
even the root's own final turn only binds unless the issuer stamped
149+
the request provably-last for the whole tree."""
150+
plugin = DynamoNvextRouting(DynamoNvextOptions(scope="lineage"))
151+
sc = plugin.transform_body({}, _ctx(is_final_turn=True))["nvext"][
152+
"session_control"
153+
]
154+
assert sc["action"] == "bind"
155+
assert sc["session_id"] == "corr-1"
156+
157+
def test_lineage_scope_closes_only_on_tree_final(self):
158+
plugin = DynamoNvextRouting(DynamoNvextOptions(scope="lineage"))
159+
ctx = _ctx(
160+
root_correlation_id="root-1",
161+
is_final_turn=True,
162+
is_tree_final=True,
163+
)
164+
sc = plugin.transform_body({}, ctx)["nvext"]["session_control"]
165+
assert sc == {"session_id": "root-1", "action": "close"}
166+
167+
def test_conversation_scope_default_unchanged(self):
168+
"""Default scope keeps per-conversation identity and final-turn close."""
169+
plugin = DynamoNvextRouting(DynamoNvextOptions())
170+
assert plugin.transform_body({}, _ctx(is_final_turn=True))["nvext"][
171+
"session_control"
172+
] == {"session_id": "corr-1", "action": "close"}
173+
174+
def test_invalid_scope_rejected(self):
175+
with pytest.raises(ValidationError):
176+
DynamoNvextOptions(scope="tree")
177+
132178

133179
class TestSmgRoutingKeyRouting:
134180
def test_emits_routing_key(self):

0 commit comments

Comments
 (0)