diff --git a/.gitignore b/.gitignore
index d198634..fa0d6cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,3 +60,6 @@ bench_suite.lock
deployment/local/
setup_and_pack/pack_fluxonkv_pylib_env.yaml
fluxon_rs/moka/
+fluxon_rs/*.btr
+fluxon_rs/moka.bak_non_git_*/
+build_*.rc
diff --git "a/fluxon_doc_cn/design/sglang_fluxon_kv_PegaFlow\345\257\271\347\205\247\344\270\216Get\350\267\257\345\276\204\347\256\200\345\214\226\345\210\206\346\236\220.md" "b/fluxon_doc_cn/design/sglang_fluxon_kv_PegaFlow\345\257\271\347\205\247\344\270\216Get\350\267\257\345\276\204\347\256\200\345\214\226\345\210\206\346\236\220.md"
new file mode 100644
index 0000000..95e9aab
--- /dev/null
+++ "b/fluxon_doc_cn/design/sglang_fluxon_kv_PegaFlow\345\257\271\347\205\247\344\270\216Get\350\267\257\345\276\204\347\256\200\345\214\226\345\210\206\346\236\220.md"
@@ -0,0 +1,469 @@
+# Fluxon Batch Get / singleflight:PegaFlow 对照与路径简化分析
+
+本文评审当前工作区新增的 owner 侧 Batch Get / per-key singleflight 路径,并与 PegaFlow 的 prefix query、prefetch、lease 和批量传输实现对照。评审范围从 `ExternalBatchGetStartReq` 进入 owner 开始,到 master `BatchGetStart`、payload transfer、`BatchGetDone` / `BatchGetRevoke` 以及 external handle 消费结束。
+
+原始评审基于 2026-07-16 的 Fluxon 工作区和 PegaFlow `master` 的提交
+`939363f198a0ffded9e3f30d8af9cdd74439f16c`。2026-07-17 已按当前工作树回填实现状态;
+没有把尚未执行的性能对比写成实测结论。
+
+## 2026-07-17 实现回填(当前权威状态)
+
+| 原问题 | 当前实现 | 状态 |
+| --- | --- | --- |
+| exact-batch 与 per-key 两层 shared op | exact-batch phase/waiter/result cache 已删除;`ExternalGetStartEntry` 直接持请求自己的 `keys + Local/Interest items` | 已解决 |
+| `decision_registered` 手工归还 | `ExternalGetKeyInterest` 在 Drop 中归还 `undecided`,prefix decision 由 guard ownership 表达 | 已解决 |
+| process-wide `owner_key_control` 扫完整 batch/global states | 改为 256-shard `OwnerKeyControlTable`;local/join/leader 每个 key 独立线性化,任何同步锁均不跨 batch 或 `.await`;metrics 使用独立 Weak flight index | 已解决 |
+| 规划等待 Revoke 时取消会遗留未启动 leader | `ExternalGetPlanningLeadersGuard` 在 BatchGetStart handoff 前拥有新 leader;Drop 将仍为 `Starting` 的 op 发布为 safe miss、按 Arc identity 清 marker 并唤醒 joiner | 已解决 |
+| prepared slot 在 start/revoke 异常路径失去 owner | claim guard、同 operation identity Revoke retry 与 local slot release guard 已覆盖;响应不确定不猜测释放 | 已解决(仍需端到端故障注入) |
+| external handle 无界遗留 | handle sweeper 已接入,超时与显式 cancel 复用 plan Drop/interest release 路径 | 已解决(仍需进程死亡测试) |
+| payload 每 key 一次 transfer submission | 控制面仍批量,payload backend submission 仍需运行期计数与 benchmark 后决定是否扩展 descriptor batch API | 未解决/待测 |
+
+下文 §2~§4 保留为“2026-07-16 修复前快照”,用于解释为什么做上述改动;其中出现的
+“当前实现”均指该历史快照,不得覆盖本节和集成设计主文档的当前规约。
+
+## 原始结论(2026-07-16 修复前)
+
+**per-key singleflight 的目标应当保留,当前“两层共享操作”应当收缩。** 重叠但不完全相同的 batch 确实需要按 key 选出 local、joiner 和 leader,同时继续批量执行 master RPC。当前实现又在它外面保留了 exact-batch `ExternalGetStartSharedOp`,因此一次请求同时支付 exact-batch 去重和 per-key 去重的状态、锁、通知、结果缓存与生命周期成本。完全相同的 batch 本来就会自然 join 同一组 per-key flight;第二层去重没有形成独立的正确性边界。
+
+**当前首要风险是取消安全。** 常数级性能排在生命周期闭环之后。`decision_registered: bool`、`undecided`、`retained` 和 `waiter_count` 都依赖后续代码手工归还。future 在计数增加后的任一 `.await` 被取消时,没有 `Drop` guard 关闭这次 interest。静态控制流已经足以证明计数可能永久不归零,进而让 leader task、相同 batch、reclaim fence 或 exact-batch registry 一直等待。
+
+**结构性 overhead 已经存在,但实际延迟幅度仍需 benchmark。** 当前路径包含多份 key 向量和结果向量复制、每个新 leader key 的独立共享对象、两层 `Mutex + Notify`、通常两个后台 task、全局 owner key fence 内的线性扫描,以及每个远端 key 一次 `transfer_data_no_copy` 调用。它们在短 value、高并发、大 batch 场景更可能可见;没有 allocator、锁等待和端到端延迟数据前,不应写成“已经造成 X% 回退”。
+
+**PegaFlow 最值得学习的是所有权与生命周期的组织方式。** 它没有解决不同请求之间重叠 block 的 per-key singleflight,算法不能直接搬到 Fluxon。可借鉴的是:一次 prefix scan 直接取得 `Arc` pin、一个 ready prefix 对应一个 opaque lease、资源计数由 RAII / TTL 收口、request-level prefetch 只保留一个 task,以及数据面先汇总 descriptor 再提交。PegaFlow 当前 `QueryPrefetch` 在 probe 阶段就 pin、active prefetch 只按 `req_id` 标识、lease TTL 为 600 秒,这些都不应直接复制到 Fluxon。
+
+建议按以下顺序推进:
+
+1. **P0,先封闭生命周期**:把 decision、waiter、prepared slot 和 handle 改成 cancellation-safe guard;为 owner handle / operation 增加有界 TTL 或 generation cleanup;补齐取消与 revoke 不确定性测试。
+2. **P1,删除 exact-batch shared-op 层**:每个 external handle 直接持有一个 `BatchPlan`,其中的 item 引用 local holder 或 per-key flight。保留当前公开的 `get_start -> get_transfer | cancel` 契约。
+3. **P2,补全 master 收敛语义**:现有 `(key, requester)` RAII fence 保留,但把通用 `KeyBeingWritten`、`InvalidArgument`、`Unknown` 收敛成有限且可处理的 leader / join / already-committed / stale 结果。
+4. **P3,依据数据继续收缩**:若 per-key 对象和 wakeup 仍是热点,再把同一 leader cohort 折叠成一个 `BatchFlight`;若 suffix start / revoke 成本显著,再评估 side-effect-free probe 与 reserve 分离。
+
+## 1. 评审边界与证据等级
+
+本文沿三条轴评估当前路径,后文也按同样的轴给出验收条件:
+
+| 评估轴 | 覆盖内容 | 本文不声称的内容 |
+| --- | --- | --- |
+| 正确性与生命周期 | leader / joiner 归属、取消、revoke、prepared slot、master fence、reclaim 交互 | 不证明未检查模块的全系统回收行为 |
+| 控制面与数据面成本 | 对象、复制、锁、task、future、RPC 和 transfer 提交边界 | 不把静态计数换算成延迟或 QPS 百分比 |
+| 可验证性 | 并发测试、故障注入、metrics 和 benchmark 维度 | 不以单轮最终 QPS 代替路径级证据 |
+
+文中的判断分为三类:
+
+- **实现事实**:可由当前符号和控制流直接确认。
+- **风险推断**:由 Rust future 取消、所有权或锁作用域推导,仍需测试复现和量化影响。
+- **待测结论**:只能通过 benchmark、fault injection 或运行期 metrics 判断。
+
+PegaFlow 对照只用于提炼设计模式。它的 cache 层次、vLLM 接口和跨节点协议与 Fluxon 不同,因此本文不会从 PegaFlow 的局部实现推出 Fluxon 的全路径性能结论。
+
+## 2. 修复前 Fluxon 路径快照
+
+### 2.1 两层共享状态(已删除 exact-batch 层)
+
+修复前 owner 同时维护下列共享状态:
+
+| 层次 | 主要类型或容器 | 去重粒度 | 持有的状态 |
+| --- | --- | --- | --- |
+| external handle | `external_get_start_registry` / `ExternalGetStartEntry` | 一个返回给 external 的 handle | `req_node_id` 和 exact-batch shared op |
+| exact-batch | `external_get_start_by_key` / `ExternalGetStartSharedOp` | `keys + atomic_group_lens + prefix_best_effort` 完全相同 | `Starting/Running/Ready/Failed`、`waiter_count`、完整 dedup key、prefix、keys、items、`Mutex`、`Notify` |
+| per-key | `OwnerKeyControlState.external_get` / `ExternalGetKeySharedOp` | owner 内单个 key | `Starting/Started/Finishing/Revoking/Ready/Failed`、`undecided`、`retained`、key、`Mutex`、`Notify` |
+| master fence | `PreparedGetRequesterTable` / `PreparedGetRequesterLease` | `(key, requester)` | active `get_id` 和 RAII release |
+| prepared memory | `OwnerLocalReserveSlotState::Prepared` | 一个 local-reserve slot | 精确 grant、slot、地址和大小 |
+
+其中 master 的 `(key, requester)` fence 是这轮更新里已经落地的有效改进。它允许不同 GPU owner 同时 materialize 同一 key,同时阻止同一 requester 的两个 prepared Get 并行发布。当前不足是冲突结果仍不可 join,也不能直接取得已经提交的 canonical holder。
+
+### 2.2 修复前调用与数据流
+
+```mermaid
+flowchart TD
+ A[ExternalBatchGetStartReq] --> B{exact-batch DashMap}
+ B -->|已有| C[waiter_count + 1
等待同一 ExternalGetStartSharedOp]
+ B -->|新建| D[创建 exact-batch shared op]
+ D --> E[owner_key_control 下逐 key 扫描]
+ E --> F[Local: clone MemoryInfo]
+ E --> G[Join: per-key undecided + 1]
+ E --> H[Leader: 创建 per-key shared op]
+ H --> I[一次 BatchGetStart 处理 leader keys]
+ I --> J[task 1: external_get_key_singleflight]
+ J --> K{每个 key 的 prefix interest}
+ K -->|retained > 0| L[per-key transfer + BatchGetDone]
+ K -->|retained = 0| M[BatchGetRevoke + 释放 slot]
+ L --> N[per-key Ready]
+ M --> N
+ F --> O[task 2: external_get_start_transfer]
+ G --> O
+ N --> O
+ O --> P[复制为 exact-batch Ready keys/items]
+ C --> Q[返回独立 handle]
+ P --> Q
+ Q --> R[get_transfer 再复制 Ready keys/items
并安装 external holdings]
+```
+
+这里的 batch 化只覆盖部分边界:
+
+- owner 到 master 使用一次 `BatchGetStart`、一次 `BatchGetDone` 或 `BatchGetRevoke` RPC。
+- master 的 `handle_batch_get_start`、`handle_batch_get_done` 和 `handle_batch_get_revoke` 仍在进程内逐 item 调用单 key handler。
+- payload 阶段的 `batch_get_finish_started` 为每个需要传输的 key 创建一个 future;每个 future 调用一次 `transfer_data_no_copy`。该调用继续形成一次 closed-runtime transfer 请求。当前代码没有在这个边界先按 peer / transport 合并 descriptor。
+
+因此,“RPC 是 batch”不能直接推广成“payload 提交和底层 DMA 已经是一个 batch”。底层 backend 可能还有自己的优化,但不在本次已追踪到的调用链内。
+
+### 2.3 当前资源终止路径
+
+| 资源或计数 | 获得位置 | 正常终止 | 当前取消或不确定性缺口 |
+| --- | --- | --- | --- |
+| per-key `undecided` | `plan_external_get_key_items` 看到 `Starting/Started` | `decide_external_get_key_item` | 计数与 `decision_registered: bool` 分离;future Drop 不会自动 decide / abandon |
+| per-key `retained` | prefix 计算后手工增加 | leader finish / revoke 后发布 terminal | 依赖所有 `undecided` 先归零;一个丢失 decision 会阻塞整个 cohort |
+| exact-batch `waiter_count` | exact-batch 命中或创建 | transfer / cancel 末尾 `release_external_get_start_waiter` | start 或 transfer RPC future 中途取消时没有 guard |
+| external handle entry | `get_start` 返回前插入 `external_get_start_registry` | `get_transfer` 或 `cancel` remove | 容器没有 TTL;调用方进程死亡或消息丢失时没有本层兜底 |
+| prepared local slot | leader `BatchGetStart` 前 claim | Done commit,或 Revoke 成功后 release | `OwnerLocalReserveSlotLease` 没有 Drop cleanup;claim 后的 future 取消和 Revoke RPC 不确定性都可能失去释放路径 |
+| master requester fence | master 接受 prepared Get | `InflightGetInfo` Drop | 已有 RAII,且 inflight cache 有 60 秒 TTL;它不能替 owner 归还本地 `Prepared` slot |
+
+## 3. 正确性与生命周期发现
+
+### 3.1 手工 decision 不是 cancellation-safe 所有权
+
+`plan_external_get_key_items` 在 owner key fence 内先增加 `undecided`,随后调用链至少会经过 master RPC、逐 key start-code 等待和 prefix 计算。`ExternalGetStartOwnerItem::Shared` 只保存一个 `decision_registered: bool`,没有在 `Drop` 中减少计数。
+
+如果 future 在增加计数后、执行 `decide_external_get_key_item` 或 `abandon_external_get_key_decisions` 前被取消:
+
+1. `undecided` 永久保留本次 interest。
+2. `classify_external_get_key_leader` 一直等待 `undecided == 0`。
+3. `finish_external_get_key_leaders` 按 leader 顺序 await;一个 key 卡住会阻止同 cohort 后续 key 进入 finish / revoke。
+4. per-key marker 继续占用 owner key fence,reclaim 对该 key 返回 `Busy`。
+5. exact-batch creator 仍可能停在 `Starting`,相同 batch 后续全部等待同一未完成操作。
+
+这是 P0 正确性问题。修复应让“interest 存在”对应一个实际拥有 Drop 语义的 guard;bool 与计数器的约定不再承担所有权。
+
+同一问题也覆盖 prepared slot。`batch_get_start_with_local_reserve_targets` 先取得 `OwnerLocalReserveSlotLease`,再 `.await` master RPC;返回 `Err` 的显式分支会释放 lease,但 future 在 await 中被 Drop 时不会执行该分支。`OwnerLocalReserveSlotLease` 本身没有 `Drop` cleanup,所以本次 claim 的 slots 可能继续停在 `Prepared`。per-key leader task 又是在该 RPC 成功返回后才 spawn;若请求在 await 中被取消,master 可能已经接受操作,owner 却没有 executor 接管它。同步计数可以直接在 Drop 中归还;需要 RPC 的 cleanup 应由 Drop 把工作提交给一个有界、可观测的 owner cleanup actor,并由 TTL 处理 actor 或进程失效,不能依赖一个无所有者的临时 task。
+
+### 3.2 exact-batch waiter 和 handle 也存在同类缺口
+
+exact-batch 层的 `waiter_count` 同样依赖显式 release:
+
+- `external_batch_get_start` 注册 waiter 后可能在 prepare 或等待 prefix 时被取消。
+- `external_batch_get_transfer` 先从 handle registry remove,再等待 shared result;等待期间被取消后,调用方已经没有 handle 可以补发 cancel,函数尾部的 waiter release 也不会执行。
+- terminal exact-batch op 只有在 `waiter_count == 0` 时才从 `external_get_start_by_key` 删除。
+
+因此这一层不仅增加常数成本,也增加了一个独立的泄漏和永久等待面。删除该层会直接减少一种必须证明正确的生命周期。
+
+### 3.3 exact-batch 的收益有限,参数归属也不清晰
+
+exact-batch 层并非完全没有收益。它能让 100% 相同的并发请求只计算一次 prefix、只注册一次 per-key decision,并共享聚合后的 result vector。删除它以后,每个 handle 都需要构造自己的轻量 `BatchPlan`,因此必须把 100% identical workload 纳入 benchmark。
+
+这项收益没有形成独立的正确性边界:真正避免重复 slot、master Start 和 payload transfer 的仍是 per-key flight。exact-batch 层为节省重复 plan 工作,引入了第二套 phase、waiter、result cache 和 cleanup 协议。对当前重点覆盖的“前缀大量重叠但 batch 长度或 atomic groups 不完全相同”场景,它又无法命中。
+
+当前参数归属也暴露了这层抽象的歧义:`ExternalGetStartDedupKey` 不包含 `transfer_concurrency`,`ExternalGetStartSharedOp` 却保存第一个 creator 的值。相同 batch 使用不同 `transfer_concurrency` 时,后来的显式参数不会决定共享工作的并发度。per-key sharing 本身也意味着 shared keys 服从各自 leader cohort 的并发策略。这里需要保留一个规范契约:优先把 transfer concurrency 变成 owner / transport 的统一策略;如果仍保留请求参数,文档必须明确它只影响本请求新建的 leader cohort,不能维持未说明的 first-creator-wins 行为。
+
+### 3.4 Revoke 不确定性没有 owner 侧闭环
+
+当 suffix key 无人 retain 时,leader 进入 `Revoking`。`BatchGetRevoke` 成功后,代码才释放精确 prepared target。RPC 返回错误时,当前实现发布 per-key `Failed` 并清除 marker,但没有保存可重试的 `get_id` 和 target,也没有为 owner `Prepared` slot 建立 TTL。
+
+master 的 `InflightGetInfo` 过期后会 Drop `PreparedGetRequesterLease`,所以 master fence 最迟可以重新开放。owner 本地 slot 是否最终返回 `Free`,无法从当前路径证明:释放它所需的精确 target 已经随 terminal op 丢失。更安全的状态应保留 `Revoking { get_id, target, next_retry }`,直到满足下列有限终态之一:
+
+- master 明确确认 Revoke,owner 释放 slot;
+- master 返回 Done 已胜出,owner 按 canonical committed backing 收敛;
+- master operation TTL 明确过期,owner 核对 generation 后释放 slot;
+- owner generation 结束,整个 pool 随进程生命周期销毁。
+
+在终态前清除 per-key marker 会把“master 是否仍在执行”和“owner slot 是否仍被占用”重新变成两个失去关联的状态。
+
+### 3.5 master 防重已经部分完成,收敛语义仍缺失
+
+当前 `PreparedGetRequesterTable` 已正确使用 `(key, requester)` 作为 identity,并通过 `PreparedGetRequesterLease::Drop` 释放。这一部分应保留。
+
+仍需收敛的分支包括:
+
+| 场景 | 当前行为 | 需要的有限结果 |
+| --- | --- | --- |
+| 同 requester 已有 prepared Get | `KeyBeingWritten` | `Join { operation_id, immutable_start_item }`,或明确可重试的 transient 结果 |
+| requester 已有同版本 live replica | `InvalidArgument: cannot replace a live replica` | `AlreadyCommitted`,释放新 slot 后取得 canonical local holder |
+| GetDone 时同版本 route 已被另一个操作发布 | `Unknown: could not publish current route` | `AlreadyCommitted`,输家不覆盖 backing |
+| GetDone 的 `put_id` 已落后 | 依赖通用错误路径 | `Stale`,revoke / release 后重新 probe |
+
+owner singleflight 可以是主要的快速路径,但不能成为唯一能解释冲突的正确性边界。master 已经维护唯一性 fence,下一步应返回可枚举、可测试的结果,避免 owner 从错误字符串猜测状态。
+
+## 4. 修复前控制面与数据面成本
+
+下表只描述当前源码可以确认的成本。最后一列中的性能影响仍需测量。
+
+| 成本维度 | 当前实现事实 | 随什么增长 | 判断 |
+| --- | --- | --- | --- |
+| key 所有权复制 | exact-batch DashMap key 和 `ExternalGetStartSharedOp.dedup_key` 各自拥有完整 key 向量;transfer prefix 又被构造并在 `Running` / task 间复制;每个 per-key op 还拥有自己的 `String` | batch key 数和 key 长度 | 明确存在,可通过删除 exact-batch 层减少;延迟占比待测 |
+| 共享对象 | 每个新 leader key 有一个 `Arc`、`Mutex`、独立 `Arc` 和 terminal result;每个 exact batch 再有一套 shared op | leader key 数和并发 batch 数 | 对短 value / 高并发更敏感,实际 allocator 成本待测 |
+| 全局 fence | `owner_key_control` 在同一临界区内先扫描 revoking keys,再逐 key 做 local / join / leader 规划,并穿插 per-key state lock 和索引查询 | required key 数及并发请求数 | 可能形成 p99 lock contention;需要 lock-wait histogram |
+| per-key lock / wakeup | start publish、decision、classification、terminal publish 和每个 waiter 都访问 per-key state;exact-batch 又有第二层 lock / notify | joiner 数和状态变化次数 | 两层 wakeup 没有独立语义,优先删除外层 |
+| task | 非全 local 的 creator 通常生成 `external_get_key_singleflight` 和 `external_get_start_transfer` 两个 task | creator batch 数 | 第二个 task 只聚合 per-key terminal 到 exact-batch terminal,可删除 |
+| waiter future | `finish_external_get_start_transfer` 对 transferable items 建立 `join_all` future;revoke 等待也按 key 建 future | transferable / revoking key 数 | 调度与 wakeup 成本明确,幅度待测 |
+| result materialize | per-key `Ready` 保存结果;聚合 task 再构造 exact-batch `Ready`;每个 handle transfer 再 clone `keys/items` | transferable key 数和相同 batch waiter 数 | exact-batch result cache 造成额外向量与 `Arc` refcount 流量 |
+| payload 提交 | `batch_get_finish_started` 为每个远端 key 调用一次 `transfer_data_no_copy`;每次继续形成独立 closed-runtime transfer request | 远端 hit key 数 | 控制面 RPC 已 batch,transfer 提交尚未在该边界合并 |
+| master batch handler | `BatchGetStart/Done/Revoke` handler 在 master 内逐 item await 单 key handler | leader / terminal key 数 | 网络 RPC 数受控,master 本地调度仍为 O(keys) |
+| suffix speculation | 所有 leader 先 Start,prefix 计算后再按 `retained` 选择 Finish 或 Revoke | raw hit 之后的 leader suffix | 避免 probe RPC,但增加 slot claim、master work 和 revoke;需和 probe/reserve 实测比较 |
+
+当前优先级更高的风险依次是取消后不终止、全局 fence 临界区、双层状态 wakeup 和逐 key transfer submission。key clone 也明确存在,但不应先为它引入新的缓存或兼容层。
+
+## 5. PegaFlow 可以借鉴的设计模式
+
+PegaFlow 对照源码固定在提交 `939363f198a0ffded9e3f30d8af9cdd74439f16c`:
+
+| PegaFlow 模式 | 实现证据 | 对 Fluxon 的启发 |
+| --- | --- | --- |
+| 一次 prefix scan 取得稳定引用 | [`ReadCache::get_prefix_blocks`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/src/storage/read_cache.rs#L35-L50) 在一个 cache mutex 下按序扫描并 clone `Arc` | Fluxon 的 local branch 已在 owner fence 下 clone `MemoryInfo`;让 `BatchPlan` 直接拥有这些 pin 即可,无需再包装 exact-batch state |
+| 一个 ready prefix 对应一个 opaque lease | [`QueryLeaseManager`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/src/lease.rs#L42-L184) 让 lease 持有整个 `Vec>`,支持 consume、release、consumer count 和 sweep | Fluxon handle 应拥有整批 plan / lease,并在 transfer 或 cancel 时一次消费;进程死亡由 TTL 收口 |
+| request-level prefetch state | [`PrefetchState`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/src/storage/prefetch.rs#L79-L155) 用一个 `HashMap` 和一个 mutex 管理后台任务 | per-key singleflight 只保留跨请求去重真正需要的状态;请求级聚合不要再复制一套 phase machine |
+| probe 与 commit 分离 | [`vllm-request-state-machine.md`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/docs/vllm-request-state-machine.md#L318-L461) 明确提出 `QueryPrefetch -> ReserveLoadBlocks -> Load -> ReleaseReservation -> TTL` | Fluxon 可用 side-effect-free probe 先确定 prefix,再只 reserve transferable prefix;是否值得多一次 RPC 必须 benchmark |
+| 取消安全的资源 guard | [`TransferLockGuard`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/src/backing/transfer_lock_guard.rs#L13-L70) 和 [`SsdPrefetchReservation`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/src/storage/prefetch.rs#L141-L155) 都在 `Drop` 中归还资源 | `undecided`、waiter、slot 和 master operation retry ownership 都应由 guard 表达 |
+| descriptor-first 的批量数据面 | [`transfer`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/src/transfer/mod.rs#L1-L16) 与 [`gpu_worker`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/src/gpu_worker.rs#L350-L496) 先收集所有 layer / segment descriptor,再交给一次 backend batch 并同步一次 | Fluxon 应统计并减少实际 transfer submission,而不只统计 BatchGet RPC 数 |
+| 路径契约测试和 metrics | [`prefix_semantics`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/tests/prefix_semantics.rs)、[`prefetch_lease`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/pegaflow-core/tests/prefetch_lease.rs) 和 [`test_connector_fault_tolerance`](https://github.com/novitalabs/pegaflow/blob/939363f198a0ffded9e3f30d8af9cdd74439f16c/python/tests/test_connector_fault_tolerance.py) 分别覆盖 prefix、lease consumer、失败与 cleanup | Fluxon 需要把 overlap、取消和资源归零作为 merge gate,纯 prefix helper 不足以覆盖生命周期 |
+
+### 5.1 不应照搬的部分
+
+- **PegaFlow 没有跨请求 per-block singleflight**:active prefetch 按 `req_id` 组织;两个不同请求的重叠 block 仍不共享同一个 per-block future。
+- **当前 probe 仍有副作用**:`QueryPrefetch` 已经 pin ready blocks。PegaFlow 自己的设计文档也把 probe / reserve 分离列为未来协议。
+- **`req_id` 不是完整 identity**:同一请求在 preemption 或调度重试后可能对应不同 hash slice;PegaFlow 文档建议加入 `digest(block_hashes)`。
+- **600 秒 lease TTL 不适合直接复制**:Fluxon prepared local-reserve slot 是稀缺资源,TTL 应由 slot 容量、最大合法 transfer 时长和故障恢复目标决定。
+- **语义边界不同**:PegaFlow 的简单 prefix cache scan 不承担 Fluxon 的 atomic group、master route、owner reclaim fence 和跨 requester 唯一性。
+
+## 6. 建议的收缩设计
+
+### 6.1 第一阶段:handle 直接持有 `BatchPlan`
+
+第一阶段保留现有 per-key shared op,先删除 exact-batch shared op。内部结构可以收敛为:
+
+```rust
+struct ExternalGetBatchPlan {
+ req_node_id: String,
+ keys: Vec,
+ prefix: ExternalGetStartPrefixResult,
+ items: Vec,
+}
+
+enum ExternalGetPlanItem {
+ Local(Arc),
+ Shared(ExternalGetInterest),
+}
+```
+
+这只是内部设计草图,名称应在实现时与仓库现有命名统一。关键契约是:
+
+- `ExternalGetInterest` 由 RAII guard 表达。创建时注册一次 prefix decision;prefix 计算会把它消费成 abandon 或 plan 持有的 result interest。未决定就 Drop 时自动 abandon;进入 `Finishing` 后的 Drop 只释放 waiter,不回滚已经提交的数据面终态。
+- `external_get_start_registry` 直接持有 `BatchPlan`,不再间接持有 `ExternalGetStartSharedOp`。
+- `get_transfer` 消费 plan 并等待其中的 per-key terminal;`cancel` Drop plan。两个入口共享同一条释放路径。
+- 完全相同的两个 batch 仍会在 per-key registry 中 join 相同 flight,因此不会重复 Start / transfer。
+- leader cohort 的现有后台 task 已经负责 transfer 和 Done。删除 `external_get_start_transfer` 聚合 task 不会失去 start 与 GPU allocation 的重叠,只会删除第二次等待和结果 materialize。
+- handle registry 需要 TTL / generation cleanup。TTL 回调与显式 cancel 必须调用同一个幂等终止函数。
+
+可以删除的重复 surface 包括:
+
+- `ExternalGetStartDedupKey`
+- `ExternalGetStartSharedOp`、`ExternalGetStartSharedState`、`ExternalGetStartSharedPhase`
+- `external_get_start_by_key`
+- `register_external_get_start_waiter`、`release_external_get_start_waiter`
+- `wait_external_get_start_prefix`、`wait_external_get_start_transfer`
+- `publish_external_get_start_ready` 及第二份 batch terminal result
+- `external_get_start_transfer` 聚合 task
+
+`ExternalGetStartSharedItemResult` 此后只属于 per-key terminal,应改成唯一且明确的 `ExternalGetKeyResult`,避免名字继续暗示 exact-batch sharing。
+
+```mermaid
+flowchart LR
+ A[ExternalBatchGetStartReq] --> B[owner fence 下构造 BatchPlan]
+ B --> C[Local
Arc MemoryInfo]
+ B --> D[Join
KeyInterest guard]
+ B --> E[Leader cohort]
+ E --> F[一次 BatchGetStart]
+ F --> G[一个 cohort task
transfer + Done/Revoke]
+ G --> H[per-key terminal]
+ C --> I[handle registry
BatchPlan + TTL]
+ D --> I
+ H --> I
+ I -->|get_transfer| J[消费 plan,安装 holdings]
+ I -->|cancel / TTL| K[幂等 Drop plan]
+```
+
+### 6.2 第二阶段:有数据再折叠成 `BatchFlight`
+
+删除 exact-batch 层后,如果 metrics 证明 per-key `Arc + Mutex + Notify` 仍是热点,可以让同一次扫描产生的 leader keys 共用一个 `BatchFlight`:
+
+- key registry 保存 `(Weak, slot_index, generation)`。
+- `BatchFlight` 持有一个 slots 数组、一个 mutex、一个 notify 和一个 cohort task。
+- joiner 只保存 `Arc + slot_index + InterestGuard`。
+- 每个 slot 有独立 outcome 和 interest count,但同步原语属于整个 cohort。
+- registry 删除必须同时校验 key、flight identity、slot 和 generation,旧 waiter 的 Drop 不能删除后来创建的 flight。
+
+这一步能把同步对象从 O(leader keys) 降到 O(leader cohorts),代价是一个 notify 可能唤醒同 cohort 的无关 slot waiter。应先测 per-key 对象和 wakeup 是否真的占主导,再决定是否接受该 trade-off。不要在删除重复层之前再叠加第三套 batch state machine。
+
+### 6.3 master 返回可收敛的有限结果
+
+master 的 prepared Get start 应返回一个可穷举结果集合。下面只描述分支语义,不是当前已存在的 Rust 类型:
+
+```text
+Leader { start_item }
+Join { operation_id, immutable_start_item }
+AlreadyCommitted { put_id, backing_identity }
+Stale
+```
+
+具体 wire 类型需要单独设计,但分支必须保持有限:
+
+- `Leader` 获得本次 prepared target 的唯一提交权。
+- `Join` 返回足够重建原 operation 的 immutable start 信息。owner 仍只能选出一个 transfer executor,其他 waiter 观察同一终态。
+- `AlreadyCommitted` 返回 version 和 backing identity,让 owner 释放候选 slot,并在 reclaim fence 下取得 canonical local holder。
+- `Stale` 终止旧 operation,重新读取当前 route / version。
+
+master fence 的 RAII lease 和 60 秒兜底可以继续保留,但 owner slot 的终止不能只依赖 master cache Drop。
+
+### 6.4 probe / reserve 作为可选协议,不先假设它更快
+
+当前 speculative Start 的优点是少一次网络往返,并能尽早开始远端 materialization;成本是 raw prefix 之后的 key 也 claim slot、进入 master、再走 Revoke,还需要 `undecided/retained/Revoking` 协调多个 batch 的 prefix interest。
+
+可选的两阶段协议为:
+
+1. `BatchGetProbe(keys)`:只返回每个 key 的 local / route 可用性和 version,不占 prepared slot。
+2. owner 按原始顺序和 atomic groups 算出 `transferable_len`。
+3. `BatchGetReserve(keys[..transferable_len], versions)`:全量成功或返回 stale / retry,不留下部分 reservation。
+4. transfer 消费 reservation;cancel / failure / TTL 释放 reservation。
+
+该协议能删除大部分 suffix revoke 和 decision accounting,但多一次 RPC,并引入 probe 与 reserve 之间的 eviction / version race。正确处理方式是 reserve 时原子复核,把 race 降级为 cache miss / retry。是否采用,应比较:
+
+- 当前 speculative Start + suffix Revoke;
+- probe + reserve;
+- 不同 batch 长度、miss 位置、overlap ratio 和 RTT 下的端到端结果。
+
+### 6.5 保留 batch 语义,并把 batch 延伸到 transfer submission
+
+无论控制面采用 per-key op 还是 `BatchFlight`,都应维持:
+
+1. owner 以逐 key/shard 短 fence 完成 required batch 的 local / join / leader 分类;不得用
+ process-wide lock 取得整批快照,也不得让任何同步锁跨 `.await`;
+2. leader keys 压缩成一次 `BatchGetStart`;
+3. terminal 使用一次 `BatchGetDone` 或 `BatchGetRevoke` 收敛;
+4. 结果按原 index scatter,再计算 raw prefix 和 atomic-group prefix;
+5. payload descriptor 先按 peer、方向和 transport 能力分组,再调用 batch transfer surface。
+
+PegaFlow 的 descriptor-first 做法适合借鉴到第 5 步。Fluxon 至少应新增 `transfer_submissions`、`descriptors_per_submission` 和 `bytes_per_submission`,先确认当前每 key 调用是否真的成为瓶颈,再设计 batch transfer API。
+
+## 7. 迁移顺序
+
+| 阶段 | 改动 | 完成判据 |
+| --- | --- | --- |
+| P0 生命周期 | 引入 decision / waiter / prepared operation guard;统一显式 cancel、future Drop、TTL cleanup;保留 revoke retry identity | fault injection 取消任一 await 后,active handle、interest、marker 和 prepared slot 都回到基线 |
+| P1 删除重复层 | handle 改持 `BatchPlan`;删除 exact-batch map、phase、notify、waiter 和聚合 task | identical / overlapping batch 的 leader Start 数不增加;公开 API 不变 |
+| P2 master 收敛 | wire 层增加有限 outcome;owner 实现 join、already-committed 和 stale 分支 | 同 requester 冲突不再暴露通用 `KeyBeingWritten` / `InvalidArgument` / `Unknown` |
+| P3 数据驱动优化 | 测量并选择 `BatchFlight`、probe/reserve、batch transfer submission | 每个新增机制都有对应热点证据和独立回退对照 |
+
+截至 2026-07-17,P0 的 Interest/planning leader/prepared-slot guard、P1 exact-batch 删除、
+P2 的 `(key, requester)` fence 与 overlap/ABA 防线已进入当前工作树并通过定向单元测试;
+P2 的完整有限 outcome 仍需故障注入确认。P3 的
+payload descriptor batch 仍未实现,必须由部署后的 submission metrics/QPS 决定,不能仅凭
+静态结构继续加层。
+
+P0 与 P1 可以先在现有 per-key 实现上完成。不要为了等待理想的 `BatchFlight` 一次性重写而继续保留已知的取消缺口。
+
+## 8. 测试、观测与验收门槛
+
+### 8.1 必须补齐的测试
+
+最新更新已经增加三类有价值的 unit test:两个非完全相同 batch 复用同一 per-key marker、旧 marker cleanup 不删除新 generation,以及 master `(key, requester)` fence 的同 owner / 跨 owner 与 ABA 行为。它们验证了同步注册和 identity 规则,但没有启动真实 RPC / transfer / cancel 生命周期。下列测试仍需直接运行 async 流程,显式检查 exit code / timeout 和资源终态:
+
+| 场景 | 必须断言 |
+| --- | --- |
+| overlap:`[a,b,c]` 与 `[b,c,d]` | `b/c` 对同 owner 各只有一个 leader Start 和一份 committed backing;两个 batch 各自 prefix 正确 |
+| identical batch | 删除 exact-batch 层后仍只产生一组 per-key leader;两个 handle 可独立 transfer / cancel |
+| duplicate key:`[a,a,b]` | `a` 只有一个 flight;两个原 index 都得到一致结果;interest 不下溢或泄漏 |
+| atomic-group suffix | group 不被切开;suffix local pin、join interest 和 leader prepared slot 全部释放 |
+| task cancellation matrix | 在注册 decision、Start 前后、等待 prefix、等待 result、handle remove 后逐点 abort;后续同 key 请求可在 deadline 内完成 |
+| Revoke 响应丢失 | owner 保留 retry identity;master TTL / terminal query 后 slot 最终回到 `Free`,不会直接清 marker 丢状态 |
+| Done / Revoke race | 只有一个终态释放资源;Done 胜出时 Revoke 不释放 committed slot |
+| reclaim race | Local、Starting、Finishing、Revoking 和 terminal 各阶段与 reclaim 并发,不出现地址复用或永久 `Busy` |
+| master requester fence | 同 requester 第二个 Get 返回 join / already;不同 requester 可以同时成为 leader |
+| stale version | 旧 `put_id` 不覆盖新 route;候选 slot 被回收并重新 probe |
+| owner / external generation change | 旧 handle、旧 flight 和旧 cleanup 不能删除或消费新 generation 状态 |
+
+### 8.2 必须暴露的路径 metrics
+
+| 类别 | 指标 |
+| --- | --- |
+| 分类 | `required_keys`、`local_pinned`、`inflight_joined`、`leader_started`、`duplicate_indices` |
+| 去重收益 | `get_start_avoided`、`transfer_avoided`、`identical_batch_joined` |
+| 生命周期 | active handles / flights / interests / prepared slots、各自 age、cancel / TTL / Drop cleanup 次数 |
+| suffix | local pin drop、join interest drop、leader revoke、revoke retry、revoke unknown |
+| master 收敛 | leader、join、already-committed、stale、TTL expired,按结果分别计数 |
+| 等待与锁 | owner fence wait / hold、join wait、flight lifetime 的 histogram |
+| 数据面 | BatchGet RPC items、transfer submissions、descriptors / bytes per submission、Done / Revoke batch size |
+
+所有 gauge 都必须能在 workload 结束后的有界时间内回到基线。只增加累计 counter 无法发现永久挂住的 handle、interest 或 prepared slot。
+
+### 8.3 benchmark 矩阵
+
+至少比较三个实现:变更前的 batch path、当前双层 shared-op path、删除 exact-batch 层后的 `BatchPlan` path。输入矩阵应覆盖:
+
+- key 数:`1 / 16 / 64 / 256`;
+- overlap ratio:`0% / 50% / 90% / 100%`;
+- first miss:首部、中部、尾部和 all-hit;
+- 并发:单请求、同 owner 多请求、多个 requester owner;
+- value / page 大小:控制面占主导的小 value,以及数据面占主导的实际 KV page;
+- local / same-host / remote transport;
+- fault-free、cancel storm 和 Revoke 响应延迟 / 丢失。
+
+采集 `get_start` p50 / p99、完整 transfer p50 / p99、CPU、allocation bytes / count、task spawn、lock wait、RPC items、transfer submission 和最终 slot / handle gauge。验收时先检查语义计数和资源归零,再比较延迟;最终 QPS 只能作为补充结果。
+
+## 9. Fluxon 证据索引
+
+| 事实 | 当前代码位置 |
+| --- | --- |
+| per-key flight 与 request plan 类型 | `fluxon_rs/fluxon_kv/src/client_kv_api/mod.rs`:`ExternalGetKeySharedOp`、`ExternalGetKeyInterest`、`ExternalGetStartEntry` |
+| per-key 规划和 RAII decision | `fluxon_rs/fluxon_kv/src/client_kv_api/external_api.rs`:`plan_external_get_key_items`、`ExternalGetKeyInterest::decide/Drop` |
+| leader finish / revoke | 同文件:`classify_external_get_key_leader`、`finish_external_get_key_leaders` |
+| request plan handle(无 exact-batch shared-op) | 同文件:`external_batch_get_start`、`finish_external_get_start_transfer`、`external_batch_get_transfer` |
+| per-key payload transfer submission | `fluxon_rs/fluxon_kv/src/client_kv_api/get.rs`:`batch_get_finish_started` |
+| transfer closed-runtime 边界 | `fluxon_rs/fluxon_commu/src/facade/transfer_engine.rs`:`transfer_data_no_copy` |
+| external Get 与 reclaim fence | `fluxon_rs/fluxon_kv/src/client_kv_api/reclaim.rs`:`prepare_one` |
+| master requester fence 与 TTL | `fluxon_rs/fluxon_kv/src/master_kv_router/mod.rs`:`PreparedGetRequesterTable`、`PreparedGetRequesterLease`、`inflight_gets` |
+| master 冲突和 batch handler | `fluxon_rs/fluxon_kv/src/master_kv_router/get.rs`:`handle_get_start`、`handle_get_done`、`handle_batch_get_start` |
+| 已有 overlap / ABA unit tests | `external_get_start_batch_tests::overlapping_nonidentical_batches_share_each_key_but_keep_leaders_batched`、`old_singleflight_cleanup_cannot_remove_new_generation`、`prepared_get_singleflight_is_same_owner_only_and_aba_safe` |
+| 原设计要求 | `fluxon_doc_cn/design/sglang_fluxon_kv集成设计.md`:`Batch Get 的逐 key 交集、pin 与 singleflight` |
+
+## 10. 决策记录
+
+- **接受** owner 侧 per-key overlap 去重和 master 侧 `(key, requester)` 防御性唯一性。
+- **接受** BatchGetStart / transfer / Done 在数据流上的 cohort 概念。
+- **拒绝** exact-batch shared op 与 per-key shared op 长期并存。
+- **拒绝** 用 bool 和手工 counter 表达跨 `.await` 的资源所有权。
+- **暂缓** `BatchFlight` 和 probe/reserve 协议,直到 P0 / P1 完成且 metrics 指向对应热点。
+- **要求** 在把该路径视为完成前,补齐取消、overlap、Revoke 不确定性、reclaim race、master 收敛测试和资源归零观测。
+
+## 11. 2026-07-16 实现跟进
+
+本分析提出的 P0/P1 已进入工作区实现:
+
+- 删除 `ExternalGetStartDedupKey`、`ExternalGetStartSharedOp`、
+ `external_get_start_by_key`、request-level waiter/phase/result cache 和
+ `external_get_start_transfer` 聚合 task;handle 直接拥有自己的 keys/items plan。
+- `ExternalGetKeyInterest` 以 RAII 表达 prefix decision;未决定的 request future 被取消时,
+ Drop 自动减少 `undecided`。leader cohort 在第一个 master RPC await 前交给注册后台 task,
+ 因而请求取消不会丢失 Start/Finish/Revoke executor。
+- prepared slot claim 使用 Drop cleanup guard;明确接受的 slots 才逐项 disarm。external
+ handle 增加 360 秒 TTL 和统一 Drop 回收,覆盖当前对齐 workload 的 300 秒合法请求窗口。
+- `Revoking` 保存完整 start item;BatchGetRevoke 的 transport、响应长度和 identity 不确定性
+ 保留 marker/slot 并重试,只有明确 Revoke 成功才释放 target。transfer/install 失败的
+ cleanup 也在独立注册 task 中持有 get ids 和 targets,调用方取消不再丢失清理 ownership。
+- overlap/ABA 测试之外新增了 pending Interest Drop 和真实 task abort 测试;当前仍需用真实 RPC
+ fault injection 覆盖 Done response 丢失、owner generation 切换和完整 transfer cancellation matrix。
+- BatchGetDone 不再固定重试三次后遗留 pending-visible slot,而是使用同一组 get ids 幂等重试,
+ 严格校验响应长度和逐项 identity,直到明确终态或 owner shutdown。BatchGetStart 没有响应的
+ 未知提交点会将 prepared slots 隔离 65 秒,覆盖 master 60 秒 inflight TTL;已知 get ids 的
+ 异常响应则移交独立 Revoke cleanup。
+- owner 周期快照增加 active handle、per-key flight 阶段、undecided/retained interest 和
+ Free/Prepared/Pending/Committed local-reserve slot 计数,用于三机 workload 后资源归零验收。
+
+这次跟进没有宣称 payload 已成为单次底层 DMA batch。控制面 cohort 仍是一次
+BatchGetStart/Done/Revoke,但 `transfer_data_no_copy` 的 descriptor-first 合并仍属于后续 P3,
+必须由 submission metrics 和 benchmark 决定接口形态。
+
+2026-07-17 使用宝宝盘 `CARGO_TARGET_DIR` 和单并发构建做了最新工作树回归。
+Clippy 在仅放行仓库既有 `uninit_vec` 的前提下强制
+`-D clippy::await_holding_lock` 通过;master-router、owner-hot、local-reserve、
+member-left 和 `test_memholder_pin` 分别为 `28/28`、`9/9`、`8/8`、`5/5`、`1/1`。
+`fluxon_kv --lib` 全量为 `176 passed, 0 failed`,旧的 `test_memholder_pin` 失败已不再复现。
+上述结果不替代真实 RPC fault injection 和三机生命周期验证;尤其仍需主动丢弃
+Start/Done/Revoke 响应并检查快照临时态回到基线。
diff --git "a/fluxon_doc_cn/design/sglang_fluxon_kv\351\233\206\346\210\220\350\256\276\350\256\241.md" "b/fluxon_doc_cn/design/sglang_fluxon_kv\351\233\206\346\210\220\350\256\276\350\256\241.md"
new file mode 100644
index 0000000..8d6fa0f
--- /dev/null
+++ "b/fluxon_doc_cn/design/sglang_fluxon_kv\351\233\206\346\210\220\350\256\276\350\256\241.md"
@@ -0,0 +1,1686 @@
+# SGLang Fluxon KV 集成设计
+
+## 背景与目标
+
+本文说明开源仓库中 SGLang HiCache 接入 Fluxon KV 的 hostless 实现设计,聚焦接口契约、状态归属和生命周期边界。本文不是通用 `FlatDict` KV API 的完整说明。
+
+稳定结论:本集成的目标是把 SGLang HiCache + Mooncake 形态下分裂的 L2/L3 KV Cache 收敛到 Fluxon 统一管理的本机/远端 KV 层。Fluxon 需要同时具备两类能力:一类是 `key -> value` 的分布式缓存、路由、版本和生命周期能力;另一类是 HiCache L2 所依赖的 hostless 数据面能力,也就是让 SGLang native kernel 直接在 GPU KV cache 和 Fluxon 管理的 host value memory 之间搬运数据。
+
+关键 insights 先集中列在这里,后文围绕这些结论展开接口、状态和时序设计:
+
+- **L2/L3 分离是工程解耦选择,但会带来资源冗余和 L2 全局不可见**
+ - 因果链:HiCache L2 留在推理框架内,Mooncake L3 作为外部 backend,能降低接入复杂度;代价是同一类 KV page 被两套系统分别索引、驻留、驱逐和释放。同一批 page 可能同时驻留在本机 L2 和外部 L3,且其它 worker 或其它节点不能通过统一 route 判断本机 L2 page 是否可复用。
+ - 本设计落点:把逻辑 L2/L3 收敛到 Fluxon local side / remote side,用一套 owner、holder、route、commit/release 管理同一批 page。page key 在 Fluxon master/owner 中形成统一路由;只有 commit future 成功后,跨 worker 和跨节点复用才把该 page 视为可见。
+- **同机 worker 缺少共享内存快路径**
+ - 因果链:多个 SGLang worker 容易各自维护后端 segment、本机缓存副本和独立 pin/release 状态;同机交接也可能绕行后端协议。
+ - 本设计落点:同机 worker attach 到同一个 Fluxon owner shared segment,通过 holder 引用管理本机可见 value 的生命周期。
+- **统一 L2/L3 需要分布式 KV 能力**
+ - 因果链:Mooncake 这类分布式 KV backend 的目标仍然成立:KV Cache 需要利用远端 CPU 内存,尤其是无推理负载机器上的闲置内存,作为 shared backing。只用本进程数组或本机缓存,无法统一索引、放置和复用这些远端闲置内存。
+ - 本设计落点:Fluxon 保留 `key -> value` route、inflight 判重、commit future 和 local/remote 放置能力,把远端闲置 CPU 内存纳入 remote side,同时把本机 L2 纳入同一套 owner/holder 生命周期。
+- **保留 L2 低延迟需要 kernel 直连内存**
+ - 因果链:普通 `put/get(key, bytes)` KV 接口需要在 CPU 侧 materialize payload、查地址、组织拷贝或拆装 bytes;长上下文下如果再按 page 和 layer 展开,会形成 `page_count * layer_count` 级别的 CPU 控制面循环。
+ - 本设计落点:Fluxon 只构造 `plan_ptr(value_ptrs)`,SGLang native kernel 直接在 GPU KV cache 和 Fluxon host value memory 之间搬运 bytes。
+- **KV Cache 适合 write-back 最终一致性,最终目标是极致化这条路径**
+ - 因果链:page value 按不可变对象使用,缓存副本可丢失;写回失败只会降低命中率,不改变推理正确性。因此优化重点应放在缩短 write-back 和 restore 的热路径,而不是把每次缓存写回做成强同步持久写。
+ - 本设计落点:native write 完成后异步推进 route commit,`KvFuture` 成功前不作为跨 worker / 跨节点 shared backing;写入侧减少同步等待、CPU 循环和中间 bytes 包装,读取侧先规划 prefix,再只 materialize 可恢复前缀。
+- **需要适配 SGLang KVCache 特化接口**
+ - 因果链:SGLang KVCache restore 不是普通逐 key `get`,而是面向一批有序 page keys 的 prefix restore。它需要一次性回答“这一批最多恢复到哪里”,并且不能切开一个 radix node 对应的 atomic group。batch 化还能减少逐 key 调用、查表、锁和跨边界调度开销。
+ - 本设计落点:Fluxon hostless get 以 batch 为单位计算 `raw_prefix_hit_len`、`transferable_len` 和 `prefix_hit_groups`。`get_start` 合并存在性 / prefix 判断和可恢复前缀的数据拉取启动,返回 handle 和 prefix;`get_transfer` 再消费 handle,把可恢复前缀转换成 readable `plan_ptr(value_ptrs)`。
+
+### 关键设计倾向与强制规约
+
+本文使用 `MUST`、`MUST NOT`、`SHOULD` 和 `MAY` 区分正确性规约与性能倾向:
+
+- `MUST / MUST NOT` 是内存、版本、可见性或批量语义的硬不变量;即使某个实现能得到更高
+ QPS,违反它也不得验收。
+- `SHOULD` 是默认性能方向;偏离时必须给出可重复的对照实验和没有破坏硬不变量的证据。
+- `MAY` 表示在不改变外部契约时可替换的内部策略。
+
+设计取舍的优先级固定为:首先保证 backing/version/holder 正确性,其次保证本地快路径
+不被全局控制面阻塞,再次保证 batch/atomic/TP 语义,最后才是单轮命中率或 QPS。
+关键规约如下:
+
+| ID | 级别 | 规约 | 验收含义 |
+| --- | --- | --- | --- |
+| V1 | MUST | native write 和 stream 可见性完成后,Put slot 必须立即进入 `local-read-ready` / `precommit_local_visible_info`。同 owner local read 不等待 master PutDone。 | 在人为延迟 master RPC 时,同 owner Get 仍能命中并取得 holder。 |
+| V2 | MUST NOT | `local-read-ready` 不得被解释为 global committed。master route 成功前,其它 owner 不得通过全局 route 观察该 value。 | local 与 remote 可见性分别故障注入,不出现未完成 route 的跨 owner 读。 |
+| V3 | MUST | `Committed(route_live=true)` 和 owner-hot admission 必须等 master route/maintenance 成功;这个门禁不得反向阻塞 V1 的 local read。 | hot Moka 中每个条目都可与相同 `put_id + backing identity` 的 live route 对账。 |
+| G1 | MUST | required batch `R` 必须与可 pin 的 local-visible `L` 和可共享 inflight Get `F` 完整分流:`LocalJoin=R∩L`、`InflightJoin=R∩F`、`Leader=R-(L∪F)`。每个 key 独立线性化,不要求用一把锁取得整批快照。 | 重叠但不完全相同的 batch 不会对共享 key 重复申请 slot 或传输,同时大 batch 不会阻塞无关 key。 |
+| G2 | MUST | 任何 leader GetStart/payload transfer 之前,`LocalJoin` 必须已持有 `MemoryInfo` holder pin,`InflightJoin` 必须已持有 shared-op waiter/pin,`Leader` 必须已原子安装 marker。 | pin 与 hot demotion/reclaim 并发时,kernel 看到的地址不会提前回收或复用。 |
+| G3 | MUST NOT | 不得用“snapshot inflight set,稍后批量 insert”实现与非;必须逐 key 使用原子 entry/CAS 确定 leader 和 joiner。 | 并发两批均只有一个 leader,registry 清理不发生 ABA 删新操作。 |
+| G4 | MUST | leader 子集的压缩响应必须先按原 index 重组成与 `R` 等长结果,然后才能按原 `atomic_group_lens` 计算 `raw_prefix_hit_len/transferable_len`。 | 不会因压缩 leader 而改变 prefix 顺序,也不恢复半个 radix node。 |
+| G5 | MUST | `transferable_len` 之后的 local pin、join waiter 和 leader prepared Get 必须立即释放/revoke;不得保留到 TTL。 | suffix holder、master inflight 和 prepared slots 在每轮结束后可精确对账归零。 |
+| G6 | MUST | owner 侧按 key singleflight;master 侧的防御性唯一性粒度为 `(key, requester_owner)`,不是全局 key。 | 同 owner 不重复 materialize;不同 GPU owner 仍可同时拉取同一 key。 |
+| G7 | MUST | per-key shared Get 必须至少有 `Starting/Started/Ready/Failed-or-Revoked` 阶段,所有 waiter 观察同一终态并共享同一 canonical `MemoryInfo`。 | 重叠 batch 测试中 master GetStart、DMA 和 committed local route 均只出现一份。 |
+| G8 | MUST / SHOULD | per-key singleflight 只是 leader/joiner 的线性化粒度,不得把控制面降级为逐 key RPC。同一 required batch 的 leader 必须压缩成一次 BatchGetStart,GetDone/Revoke 也必须批量收敛(MUST)。payload 当前仍逐 key调用 `transfer_data_no_copy`;应按 peer/transport 汇总 descriptor 后批量提交,或至少在有界 time-window 内并行聚合(SHOULD,尚未实现)。 | Start/Done/Revoke 次数按 batch 而不是 leader key 数增长;另外单独统计 payload submission,未实现 descriptor batch 前不得宣称数据面已完全 batch 化。 |
+| G9 | SHOULD | 同时到达的多个 required batches 中,已经判定为 leader 且具有兼容 value geometry/transport 的 keys 应进入短 time-window micro-batch;窗口必须有界,不能为凑 batch 无界延迟 local hit 或 prefix 响应。 | 同时并发压力下 batch size 提升,但 p50/p90 GetStart 不因等待窗口显著恶化。 |
+| G10 | MUST NOT | owner Get 规划、hot pin 和 runtime metrics 不得持 process-wide key-control mutex 遍历 batch/cohort;key-control 只能是短 O(1) 的逐 key/shard 临界区,禁止跨 `.await`、RPC、RDMA。规划取消必须用 RAII 清理尚未 BatchGetStart 的 leader marker。 | 大 batch 与无关 key 并发前进;取消任一 await 后没有永久 `Starting` marker、undecided 或 prepared slot。 |
+| A1 | MUST | atomic group/TP cohort 可以逐项 local-ready,但只有完整 group 才能对 restore 发布命中;同一 cohort 先全部 promotion,再统一 owner-hot admission。 | 任一 rank/member 失败时不暴露半组 GPU KV 状态,首个成员不会提前 hot eviction。 |
+| A2 | MUST | local-first Put 的同一 batch 必须由一个 publish job 持有全部 reservation/pending fence;PutDone 不确定或部分 promotion 时只用同一 identity 整批 roll-forward。声明不完整的 atomic group 禁止退化为逐 key 发布。 | 注入丢响应、取消和局部 promotion 后,所有成员最终一致发布,且不存在半组 hot admission 或提前释放 slot。 |
+| S1 | MUST | KV 驱逐和复用单位是单个 slot/key/version;`512 MiB` grant 只是物理容器,不是驱逐、恢复或等待单位。 | 一个 grant 中部分冷 slot 可独立降级、回收并被后续 Put/Get 复用。 |
+| S2 | MUST | slot 只有在 `route_live == false && holder_ref_count == 0` 时才能回到 Free;route 释放和 holder 释放的任意先后顺序都不得提前复用。 | 故障注入覆盖 route-first/holder-first,不出现 use-after-free 或重复 free-list entry。 |
+| S3 | MUST | owner 容量驱逐与 remote replica 解耦。owner 提交完整、精确的 atomic/TP source cohort;master 只验证并删除该 owner source,不选择 victim,也不要求 CPU route 已存在。最后一份 cache route 允许删除,后续正常 miss/recompute。 | 有 CPU 时只删 GPU source;无 CPU 时 route 消失;remote write 失败只损失后续命中率,不否决 owner 回收。 |
+| S4 | MUST | Moka 已发出的 size eviction 必须进入有界、可重试的 exact source-eviction ownership。cohort 元数据暂不完整时保留同一 selected identity 重试,不能静默丢弃、降级成单 key 删除或重新进入 append。 | `size_evictions` 可与 selected/handoff/committed/restored/obsolete/retry 对账;只有物理 Free 或安全恢复 hot 才结清 selected debt。 |
+| S5 | MUST NOT | `evict_some`、hot ratio、slot 循环或 grant shrink 不得修改 owner 配置容量。GPU0/GPU1/CPU 实验容量仍为 `128/128/256 GiB`。 | 调优前后 `contribute_to_cluster_pool_size`、Moka max capacity 和 expected-grant 契约可对账。 |
+| T1 | MUST | Put/Get/replica/demotion 的结果不确定时必须使用同一 operation ID 重试幂等终态,不得猜测失败并释放可能已发布的 backing。 | 响应丢失/重放后 route、holder、slot 和 quota token 都只提交或归还一次。 |
+| T2 | MUST | 相同 `put_id` 的 live-local/GetDone 竞争是可收敛状态,不得作为 `InvalidArgument/Unknown` 永久失败;输家释放新 slot 并复用 canonical holder。不同 `put_id` 才是 stale。 | `cannot replace a live replica` / `could not publish current route` 从可见错误归零,且不会覆盖当前 backing。 |
+| P1 | SHOULD | local hit 和 local-ready Put 快路径不同步等待 master/RDMA;跨边界工作按 batch/time-window 合并,避免逐 key `start-transfer-end`。 | 进行 master/RDMA 延迟注入时 local-only 路径延迟和 QPS 不应同比例恶化。 |
+| P2 | MUST | 实验必须同时记录 QPS、L1/L2/L3/总命中、local/inflight/leader 分流、CPU source Get、replica/demotion、slot free/used 与业务错误。 | 不得只使用 QPS 或逻辑 admission 声称容量收益、去重或 CPU 已生效。 |
+| P3 | MUST | 与 Mooncake 的容量对齐按“进程内 HiCache L2 + 外部 storage”总和计算;GPU 侧 external segment 必须扣除 L2,CPU 无 L2 保留完整 256 GiB。 | 两方总容量均为精确 512 GiB,开跑前 allocated=0,不用重复计入 L2 的基线做对比。 |
+
+上述规约是后续代码、并发测试和三机压测的验收清单。历史兼容路径可以保留,但不得
+以 fallback 名义跳过 V1/G2/G4/S2/T1 这些正确性门禁。
+
+### Mooncake 对齐实验给出的性能约束
+
+2026-07-12 的三机对齐实验使用两台 TP=2 GPU 节点和一台纯 CPU 节点,标称容量均为
+GPU0/GPU1/CPU = 128/128/256 GiB,同一批 1152 个 agent 请求中,原版 SGLang +
+Mooncake 达到 6.9192 QPS,Fluxon E15 为 3.5134 QPS。Mooncake 没有本设计的 Put
+atomic-group admission;它在本轮也没有进入驱逐区。因此该结果证明 group 完整性是 CPU
+副本可消费性的必要条件,但不是这轮端到端差距的唯一来源。
+
+| 观测 | Fluxon E15 | Mooncake 对齐轮 | 设计约束 |
+| --- | ---: | ---: | --- |
+| 总命中率 | 44.57% | 93.05% | 先对齐实际 payload 容量和驱逐水位,再比较 group 策略。 |
+| cache hit tokens | 12,794,304 | 26,713,408 | Fluxon 多重算 13,919,104 tokens,是吞吐差距主体。 |
+| 单 GPU 有效 payload 驱逐点 | 约 57.6 GiB | 本轮约 106 GiB 且未驱逐 | slot 内部碎片和过早水位不能隐藏在相同标称容量后面。 |
+| load-back 平均耗时 | 57.1 ms | 5.99 ms | 容量问题消除后,仍需优化 `kernel + layer_first` 读取路径。 |
+
+E15 的 4,718,592-byte value 被旧 slot class 扩成 8 MiB,payload 利用率 56.25%;再叠加
+旧 Moka 0.8 水位,每个 128 GiB GPU owner 只承载约 57.6 GiB payload 就开始驱逐。
+失败候选立即回插又导致 571 万次请求区间 reclaim candidates 中只有约 4.59 万次成功。
+本设计因此把 exact-fit slot、可配置水位和失败候选退避视为容量控制面的基础正确性,不能
+用新增 atomic group 掩盖这些问题。完整实验设置、计数和日志位置记录在
+`sglang_fluxon_agent_experiments.md`。
+
+### 核心实现思路
+
+核心实现按上面的 insight 逐条落到接口和状态边界:
+
+- **对应 L2/L3 分离带来资源冗余和 L2 全局不可见**
+ - 实现思路:把 L2/L3 从两个后端系统收敛成 Fluxon 的 local side / remote side。local side 承接本机 shared segment、reserve slots 和 holder;remote side 承接跨 owner / 跨机器 KV 数据面。
+ - 主要落点:Fluxon master/owner route、`MemHolder`、commit/release 生命周期。
+- **对应同机 worker 缺少共享内存快路径**
+ - 实现思路:一台机器运行一个 Fluxon owner,同机 SGLang worker 以 external/client attach 到同一个 owner shared segment。worker 只注册本进程 CUDA context 可见的 mapping,底层内存归属和释放由 owner 管理。
+ - 主要落点:`wait_local_segments_ready()`、owner shared segment、CUDA host registration。
+- **对应统一 L2/L3 需要分布式 KV 能力**
+ - 实现思路:继续保留类似 Mooncake 的分布式 KV 目标,用 page key 做全局命名,用 route 和 version 定位本机 owner 或远端 owner,把无推理负载机器上的闲置 CPU 内存作为可放置、可复用的 remote side backing。inflight 判重和 commit future 约束写入发布,避免远端共享副本提前可见。
+ - 主要落点:Fluxon master route、local/remote owner 放置、`local_fast_put_start` reservation、`local_fast_put_commit` 发布。
+- **对应保留 L2 低延迟需要 kernel 直连内存**
+ - 实现思路:在保留分布式 `key -> value` KV 语义的前提下,把普通 `put/get` 的数据面拆成两条 hostless 两阶段路径。Fluxon 不接管 KV page 内部 layout,只提供 key 路由、holder 生命周期和 `plan_ptr(value_ptrs)`。
+ - `plan_ptr` 协议:Fluxon 和 SGLang kernel 之间只共享一段短生命周期 plan blob。blob 里保存本次 batch 的 `value_ptrs[]`;Fluxon 负责这些地址的分配、可见性和 holder 生命周期,SGLang kernel 负责按自己的 KV layout 解释并读写这些地址。
+ - Put:`put_start -> native write -> put_commit` 构成写入闭环。`put_start` 本质上为本次 batch 分配 Fluxon 管理的 value memory,并返回 `plan_ptr(value_ptrs)`;SGLang native write kernel 直接往这些地址写入 GPU KV bytes;`put_commit` 在 kernel 写完后通知 Fluxon,把这些 slots 发布为可路由的 KV value。
+ - Get / restore:`get_start -> get_transfer -> native restore -> release_views` 构成读取闭环。`get_start` 先计算连续安全前缀;`get_transfer` 把可恢复前缀 materialize 成 readable `plan_ptr(value_ptrs)` 并持有 holder;SGLang native restore kernel 直接从这些地址恢复 GPU KV cache,完成后 `release_views` 释放 plan 引用。
+ - 主要落点:plan blob ABI、`value_ptrs[]`、`local_fast_put_start/local_fast_put_commit`、`get_start/get_transfer`、SGLang `write_*_to_fluxon_values` / `restore_*_from_fluxon_values`。
+- **对应 KV Cache 适合 write-back 最终一致性**
+ - 实现思路:KV Cache 的不可变、可丢失语义天然对应 Fluxon 的 master / owner / external 三层架构。master 管全局 route 和最终可见性,owner 管本机共享内存和 holder 生命周期,external 是 SGLang worker 的热路径接入层。
+ - Put:native write 完成后先更新 external / owner 本地可见索引并返回 `KvFuture`,后台再推进 master route commit。`KvFuture` 成功前只表示本机 write-back 进入提交流程;响应不确定或可能已发布时必须保留 local-visible/fence 并以同一 identity 整批 roll-forward。只有明确证明 master 从未发布且操作已经终止的 abort 才能清理本地在途状态并退化为后续 cache miss。
+ - Get / restore:优先查询 external / owner 本地热路径;本地 miss 时再进入远端 prefetch 和 restore。读取只 materialize 连续安全前缀,失败时 rollback 或按 miss 继续。
+ - 主要落点:master route、owner shared segment、external local visible index、`KvFuture`、`get_start/get_transfer/release_views`。
+- **对应适配 SGLang KVCache 特化接口**
+ - 实现思路:把 restore 查询建模成 SGLang KVCache 专用的 batch prefix planning,而不是逐 key 独立 get。
+ - Reject inflight / exist:KV page 按不可变缓存对象写回,重复 page key 不应产生第二份并发写入。`local_fast_put_start` 固定使用 `reject_if_inflight_same_key` 和 `reject_if_exist_same_key`,在分配 writable memory 前拒绝在途或已存在的同 key value,避免重复写回和未提交数据被误发布。
+ - Prefetch 对应 `get_start`:`get_start(keys, prefix_best_effort, atomic_group_lens)` 按 key 顺序完成存在性 / prefix 判断。全 owner-local 批次直接返回 `InlineLocal` holder metadata;混合命中或需要远端 materialization 的批次启动 `keys[..transferable_len]` 的后台 prefetch transfer。
+ - Batch 和 group 对齐:`get_start` 按 atomic group 边界把 `raw_prefix_hit_len` 收敛成 `transferable_len`;`get_transfer` 只消费这个 handle 对应的 `InlineLocal` 或 `OwnerRpc` 有限分支,并构造 batch 级 readable `plan_ptr(value_ptrs)`,保证 kernel restore 看到的是连续、完整、可回滚的 readable batch plan。
+ - 主要落点:`reject_if_inflight_same_key`、`reject_if_exist_same_key`、`GetStartResult`、`atomic_group_lens`、`raw_prefix_hit_len`、`transferable_len`、`prefix_hit_groups`、`get_start/get_transfer`。
+
+因此,本设计要统一的是 L2/L3 的对象归属、可见性和生命周期,同时保留 L2 路径的低延迟数据面。单独替换成普通远端 KV 会丢掉 HiCache L2 的 kernel 直连能力;单独保留本地数组或进程内缓存,又无法让 L2 进入全局命中、驱逐和跨节点复用链路。
+
+当前调用链主要分为三条主线:
+
+- 写入侧:`local_fast_put_start -> SGLang native write -> local_fast_put_commit`。
+- 读取侧:`get_start -> get_transfer -> SGLang native restore -> release_views`。
+- 放弃读取侧 restore 时:`cancel_get_transfer` 释放 `get_start` 持有的资源。
+
+这个集成把 SGLang 逻辑上的 L2/L3 缓存落到 Fluxon 统一管理的本机/远端 KV 层中。这样可以用同一套 owner、holder、commit/release 语义管理 KV page,减少传统 L2 host cache 与 L3 backend 分属不同系统时产生的同机重复缓存和生命周期割裂。
+
+常见部署下,一台机器启动一个 Fluxon owner;同机多个 GPU 对应的多个 SGLang worker 进程通过 external/client attach 到同一个 owner shared segment。相比一个 SGLang 进程一个后端 segment、进程间不共享后端 segment 的形态,Fluxon 把同机 host/shared memory、owner local reserve slots 和 holder 生命周期放到同一个 owner 生命周期模型里。这样多个 SGLang worker 可以通过同一个 owner segment 获得本机快速可见性和受控的本地可写内存供给,减少各进程为了各自安全边界重复持有 KV、固定预留 segment 或独立维护 pin/release 状态带来的浪费。
+
+## 范围边界
+
+| 范围 | 当前结论 |
+| --- | --- |
+| SGLang hostless 写入 | 已接入。SGLang 通过 `local_fast_put_start` 取得一批可写 host value 地址,native kernel 写入后再调用 `local_fast_put_commit` 提交。 |
+| SGLang hostless 读取 | 使用 `get_start/get_transfer/release_views`。`get_start` 先计算连续可恢复前缀,`get_transfer` 再把可恢复前缀转换成 readable `plan_ptr`。 |
+| Fluxon value layout | Fluxon 不理解 KV page 内部布局;只按 `key + value_len` 管理连续字节。 |
+| SGLang node 状态 | SGLang 的 `storage_*` 字段是调度层状态,不等同于 Fluxon master route;跨节点复用以 Fluxon commit future 成功为准。 |
+
+## 总体架构
+
+```mermaid
+flowchart LR
+ A["SGLang HiCache radix node"] --> B["HiCacheFluxon backend"]
+ B --> C["FluxonKVCacheStore"]
+ C --> D["fluxon_pyo3::KvClient"]
+ D --> E["Fluxon external / owner"]
+ E --> F["Fluxon master"]
+
+ B --> G["sgl-kernel kvcacheio"]
+ D --> H["plan_ptr blob
magic/count/value_ptrs"]
+ H --> G
+ G --> I["GPU KV cache"]
+ E --> J["owner shared segment
local reserve slots / MemHolder"]
+ J --> H
+```
+
+这里有两个层次:
+
+- KV 语义层:SGLang 传入 page key,Fluxon 对外保存 `key -> value`。
+- hostless 数据层:Fluxon 返回 `plan_ptr`,SGLang native kernel 根据 blob 里的 `value_ptrs[]` 直接执行 GPU/host 数据传输。
+
+从缓存物理层级看,Fluxon 把传统 L2/L3 逻辑抽象落到 local side / remote side:local side 覆盖本机 GPU KV、owner shared segment、owner local reserve slots 和 `MemHolder`;remote side 覆盖跨 owner 或跨机器的数据面。多个 SGLang worker attach 到同一个 owner segment 时,同机 KV bytes 不需要按 worker 进程重复保存在多个后端 segment 中。
+
+`plan_ptr` 只是一轮 backup 或 restore 的短生命周期 carrier。它不能作为 key、缓存地址、跨进程句柄或长期状态保存。
+
+## 公共契约
+
+本节只列 SGLang HiCache hostless 接入 Fluxon KV 时直接依赖的接口。
+
+| 接口 | 层级 | 契约 |
+| --- | --- | --- |
+| `wait_local_segments_ready()` | Fluxon + SGLang 集成 | 返回当前进程可见的 local segment mapping,供 SGLang 做 CUDA host registration。 |
+| `local_fast_put_start(keys, value_len, opts)` | SGLang hostless 写入 | 为一批等长 values 准备可写地址,返回 put `plan_ptr`。 |
+| `local_fast_put_commit(plan_ptr)` | SGLang hostless 写入 | 在 SGLang native kernel 写完 `value_ptrs[]` 后消费 put plan,把对应 slots 提交为 Fluxon KV route,返回 `KvFuture`。 |
+| `put_abort(plan_ptr)` | SGLang hostless 写入 | 在 commit 前释放 put plan、key reservation 和 local reserve slot lease。 |
+| `GetStartResult` | SGLang hostless 读取 | 描述连续命中前缀、可传输长度、atomic group 命中数和第一个 miss 位置。 |
+| `GetStartHandle` | SGLang hostless 读取 | 持有一次 get-start 结果和 backend handle;必须被 `get_transfer` 消费或被 `cancel_get_transfer` 取消。 |
+| `get_start(keys, prefix_best_effort, atomic_group_lens)` | SGLang hostless 读取 | 按 key 顺序计算连续命中的 prefix,并返回 `GetStartHandle`。 |
+| `get_transfer(handle, *, consume_prefix_len=None)` | SGLang hostless 读取 | 消费 handle 的全部可传输前缀,或消费由 `consume_prefix_len` 选定的更短完整 atomic-group 前缀;释放 tail,执行必要 transfer,并返回 readable `plan_ptr`。 |
+| `cancel_get_transfer(handle)` | SGLang hostless 读取 | 放弃未 transfer 的 `GetStartHandle`,释放 get-start 期间持有的 owner/external 资源。 |
+| `release_views(plan_ptr)` | SGLang hostless 读取 | 释放 get-transfer 产生的 readable plan,丢弃其持有的 holder 引用。 |
+
+`PutOptionalArgs` 在 SGLang hostless 写入路径中的语义如下:
+
+| 字段 | SGLang 使用方式 |
+| --- | --- |
+| `reject_if_inflight_same_key` | 固定开启,避免同一 page key 并发写回造成重复 inflight put。 |
+| `reject_if_exist_same_key` | 固定开启,SGLang 把重复 key 当作已写回或冲突重试处理。 |
+| `write_through` | 当前配置决定提交策略;调用方显式传入时 Fluxon 必须按字段语义执行。 |
+| `make_replica_task` | owner-local write-back 成功后的 batch 级异步副本总开关。 |
+| `make_replica_task_mask` | `local_fast_put_start` 的可选逐 key 副本准入结果;长度必须与 `keys` 相同。 |
+| `atomic_group_lens` | `local_fast_put_start` 有序 key batch 的原子分组;每项必须大于 0,总和必须严格等于 `keys` 长度。组内副本准入必须一致。 |
+| `lease_id` | 当前 SGLang hostless 主线不依赖 lease。 |
+
+## Key 与组件命名
+
+SGLang 传给 Fluxon 的 key 必须先经过 backend namespace 处理:
+
+```text
+storage_key = key_prefix + ":" + logical_key
+logical_key = page_hash + optional_component_suffix + config_suffix + optional_extra_backend_tag
+```
+
+规则:
+
+- page hash 是 SGLang prefix 复用和 Fluxon KV 存取的共同语义 ID。
+- `PoolName.KV` 使用默认 component;Mamba 等额外 component 通过 suffix 区分。
+- `config_suffix` 编入模型名、TP/PP 等会影响 page layout 的维度,避免不同运行配置复用同一批 physical values。
+- `extra_backend_tag` 用于同一集群内隔离实验或实例,不改变 Fluxon KV 的值格式。
+
+Fluxon 只看最终 `storage_key`。page 内部如何拆成 K/V layer、MLA tensor 或 Mamba state,由 SGLang kernel 参数解释。
+
+## Segment Registration
+
+hostless 读写依赖 SGLang 进程可访问的 Fluxon owner segment 已经完成 CUDA host registration。
+
+常见部署中,同一台机器上的多个 SGLang worker 连接同一个 Fluxon owner,并映射同一个 owner shared segment。每个 SGLang 进程仍需要在自己的 CUDA context 中完成 host registration;底层内存归属、holder 引用和回收由 owner 统一管理。
+
+```mermaid
+sequenceDiagram
+ participant S as SGLang HiCache
+ participant B as HiCacheFluxon
+ participant P as Fluxon Python store
+ participant R as fluxon_pyo3
+ participant O as owner segment mapping
+ participant C as CUDA runtime
+
+ S->>B: register_mem_pool_host / register_mem_host_pool_v2
+ B->>P: wait_local_segments_ready()
+ P->>R: wait_local_segments_ready()
+ R->>O: wait mapped range
+ O-->>R: segment_label, write_ptr, read_ptr, len, generation
+ R-->>P: segment list
+ P-->>B: dict list
+ B->>C: cudaHostRegister(write_ptr/read_ptr, len)
+```
+
+`wait_local_segments_ready()` 返回的 item 至少包含:
+
+| 字段 | 含义 |
+| --- | --- |
+| `segment_label` | owner 本地一般为 `cpu:0`;external attach owner 时为 `external_owner:0`。 |
+| `write_ptr` | 当前进程可写映射地址。 |
+| `read_ptr` | 当前进程可读映射地址,存在时也可注册。 |
+| `len` | 映射长度。 |
+| `generation` | owner 启动代际,用于拒绝过期 holder 或 mapping。 |
+| `node_id` | segment 所属 Fluxon node。 |
+
+SGLang external-client 模式要求看到 `external_owner:*` segment。注册失败时必须同步报错,不能退回到未注册 host memory 的 direct H2D path。
+
+## Plan Blob ABI
+
+`plan_ptr` 是 Fluxon 返回给 SGLang 的临时句柄,本质上是一段 plan blob 的首地址。SGLang native kernel 通过 `plan_ptr` 找到 blob,再从 blob 里读取本次 batch 对应的 value 地址表。
+
+引入 plan blob 的目的不是单纯定义一个新句柄,而是把 KV 数据面的控制面展开从热路径中移走。实际测试中,如果 KV 接口用 Python dict/list 传递 page、layer 和 value 地址信息,或者用 C++ 结构体承载同等信息,调用链仍然会在 Python 层或 C++ 层按 `page_count * layer_count` 做大量遍历、校验和拷贝任务组织;长上下文场景下,这部分 CPU 控制面开销会被显著放大,直接抬高 backup / restore 延迟。
+
+因此这里需要一套足够扁平、稳定、可被 native kernel 直接消费的协议:Fluxon 只把本次 batch 已经分配或 materialize 好的 value 起始地址压成 `value_ptrs[]`,通过 `plan_ptr` 交给 SGLang kernel;kernel 再按自己的 KV layout 参数解释这些地址,并直接发起 GPU KV cache 和 Fluxon host value memory 之间的搬运。对 backup 来说,这条路径让 kernel 直接把 GPU KV bytes 写到 CPU host value memory;对 restore 来说,则从这些 host value 地址恢复回 GPU KV cache。这正是开头“hostless 数据面能力”的落点:Fluxon 管地址、路由和生命周期,SGLang kernel 管 KV layout 和真实数据搬运。
+
+plan blob 是 Fluxon 在 `local_fast_put_start(...)` 或 `get_transfer(...)` 时创建的一段连续 host memory,格式固定:
+
+```c
+uint64_t magic; // 固定校验值,确认 plan_ptr 指向 Fluxon plan blob
+uint64_t count; // value_ptrs 的数量,也就是本次 batch 的 page 数
+uint64_t value_ptrs[count]; // 每个 page 对应的 Fluxon value 起始地址
+```
+
+如果 SGLang 一次写入或恢复 10 个 page,Fluxon 会创建一个 blob,并返回一个 `plan_ptr`:
+
+```text
+plan_ptr -> blob 起始地址
+
+blob[0] = magic
+blob[1] = 10
+blob[2] = value_ptr_0
+blob[3] = value_ptr_1
+...
+blob[11] = value_ptr_9
+```
+
+`magic` 不是 KV value 地址,只是固定校验值;`value_ptr_0 ... value_ptr_9` 才是 Fluxon 为这些 page 准备的 value 起始地址。它们是当前进程可访问的绝对地址,不是偏移量。
+
+`value_len` 不写入 blob。Fluxon 只负责按 `value_len` 分配每个 value 的连续字节区间,并把起始地址放进 `value_ptrs[]`;每个 value 内部如何切成 K/V、layer、MLA 或 Mamba state,由 SGLang 调用 write/restore kernel 时显式传入 layout 参数。
+
+`plan_ptr` 只在当前进程、当前 batch 生命周期内有效。`local_fast_put_commit(plan_ptr)`、`put_abort(plan_ptr)` 或 `release_views(plan_ptr)` 后,Fluxon 会清理对应 plan,SGLang 不能继续使用这个 `plan_ptr`。
+
+## Backup 时序
+
+hostless backup 的核心约束是:Fluxon 负责 GPU KV cache 之下的本机/远端 KV 层的地址分配、route 提交和生命周期管理,但真正的 KV bytes 由 SGLang native kernel 从 GPU KV cache 写入。因此 Fluxon 不能在收到 key 后立刻发布 KV route;它必须先完成 key reservation、put id 分配和 owner-local reserve slot claim,把稳定可写的 `value_ptrs[]` 通过 `plan_ptr` 返回给 SGLang。SGLang native kernel 写完这些地址后,`local_fast_put_commit` 才能把这些 slots 提交为 resident values,并发布 Fluxon KV route。
+
+这条 backup 路径按 write-back 最终一致性设计:native write 完成后先进入本地可见和后台 commit 流程,跨 worker / 跨节点可见性以 `KvFuture` 成功为准。`KvFuture` 返回失败时,上层不得把 page 标成全局 `storage_backed`,后续可按 cache miss 处理;但 Fluxon 内部若处于响应不确定或可能已发布状态,仍必须保留 local-visible/fence 并以同一 identity roll-forward,不能把“上层不采用”误解为可以立即释放 backing。
+
+### 本地主副本与异步副本控制
+
+`write_through` 和 `make_replica_task` 是两个独立契约。`write_through` 选择同步远端放置还是 owner-local write-back;`make_replica_task` 只控制 owner-local commit 成功后是否创建异步副本任务。SGLang 的纯本地模式必须使用 `write_through=false, make_replica_task=false`,不能把关闭副本等同于开启 write-through。
+
+| 模式 | `write_through` | `make_replica_task` | 主数据 | 异步副本 |
+| --- | --- | --- | --- | --- |
+| local-only | `false` | `false` | 当前 GPU owner | 无 |
+| local + remote replica | `false` | `true` | 当前 GPU owner | 由 master 的 `replica_task_placement` 选择 |
+| write-through | `true` | 不参与异步副本决策 | master 直接选择远端 target | 无额外异步副本任务 |
+
+`PutOptionalArgs.make_replica_task` 必须贯穿 Python store、PyO3 和 Rust put request。兼容层如果只识别 `write_through` 而丢弃该字段,SGLang 即使记录 `replica_admitted=0`,Rust 仍可能按旧默认创建副本;因此启动检查必须同时确认 Python `PutOptionalArgs` 和 `fluxon_pyo3.KvClient.local_fast_put_start` 的签名都包含 `make_replica_task`。
+
+page admission 还要求 `make_replica_task_mask` 贯穿同一条链路。mask 缺省时,Fluxon 把 batch 级 `make_replica_task` 广播到所有 keys,保留 eager-all 和 local-only 的标量契约;mask 存在时,其长度必须严格等于 `keys` 长度,实际逐项决策为 `!write_through && make_replica_task && make_replica_task_mask[i]`。Python store 在调用 PyO3 前检查容器、长度和元素类型,PyO3 在任何 reserve 或 RPC 之前再次检查长度。external 模式把结果写入每个 `ExternalBatchPutStartItemReq.make_replica_task`;owner 模式把整份 mask 保存在 staged plan 中,commit 时按相同 key 顺序写入每个 `OwnerLocalPublishItem.make_replica_task`。
+
+Put 与 Get 使用同一种 `atomic_group_lens` 分区语义。SGLang hostless backup 把一个 radix node 的全部 page keys 标成一个 atomic group;副本策略只能整组 admitted 或整组 skipped。`min_replica_pages` 选择完整 group,允许为了满足最小值而超过它;`max_replica_pages_per_batch` 是硬上限,只保留能够完整装入预算的 group,不能截取 group 的前几页。ratio/score 策略先得到 group 级概率和优先级,再展开为组内全 true 或全 false 的 per-key mask。
+
+该契约做双层 fail-fast。Python store 要求 group lengths 是严格的 `list[int]`、每项大于 0、总和等于 key 数,并拒绝组内混合 mask;PyO3 在任何 owner slot reserve 或 external RPC 前重复同样校验。未传 `atomic_group_lens` 时,每个 key 视为长度 1 的独立 group,保留已有调用方的逐 key 行为。存在性过滤或冲突重试若只能留下半个 group,SGLang hostless 路径直接放弃该次 backup,不能把剩余 keys 重新声明成一个完整 group。
+
+标量降级不能用于 page admission。若一批中只有一页 admitted,把 mask 折叠为 `any(mask)` 会让整个 batch 都创建副本;逻辑 admission 日志与物理 CPU transfer 因而失配。启动自检必须同时确认 Python `PutOptionalArgs` 和 PyO3 签名包含 `make_replica_task_mask`,端到端验收则比较 SGLang admitted page 数、master CPU target 数和 GPU owner 成功 transfer 数,而不能只看 admission 日志。
+
+SGLang 的 admitted 只表示该 atomic group 通过客户端准入并为组内 pages 请求创建副本,不表示目标 owner 已经接收整组。当前 replica actor 仍逐 key 完成 transfer/append;相同 key 的并发副本可能在目标侧返回 `KeyBeingWritten`。因此 Put 原子准入消除了策略主动制造的稀疏组,但尚未把远端复制提交升级成组事务。数据面仍需检查“成功 `local_fast_put_start` 行中的 admitted 数 = 成功 transfer 数 + 可逐项对账的目标侧拒绝数”,并继续要求“成功 transfer 数 = `appended=true` 数 = master CPU target 数”。全局 admission 累计计数可能包含随后被 backend 拒绝并重试的尝试,不能直接作为已 staged page 数或物理复制分母。
+
+CPU-only 副本模式不新增第二个客户端开关。客户端仍设置 `make_replica_task=true`,master 再通过 `restrict_to_remote_only_node_roles=true` 和 `remote_only_node_roles=["remote_cache"]` 把 replica target 限定到 CPU owner。若 strict 候选为空,任务返回 NoSpace,不回退到其它 GPU owner。
+
+端到端验收以实际数据面计数为准:local-only 要求 GPU owner 的 `put_transfer success`、`replica task append done` 和 master `replica_task_target_counts` 全部为 0;CPU-only 副本要求两台 GPU owner 的 transfer/append 总数严格等于 master 的 CPU target 数,并且 GPU target 为 0。仅检查 SGLang 配置日志不足以证明副本策略已经生效。
+
+### 弹性本地侧预分配
+
+弹性本地侧预分配对应 owner 本地写入预留池。它是在 owner shared segment 中为 SGLang hostless put 预先划分的 writable slots。`local_fast_put_start` 从这些 slots 中为本次 put 分配地址,并把地址写入 `plan_ptr(value_ptrs)` 返回给 SGLang native kernel。此时 slot 只处于 reserved/prepared 状态,还没有绑定为 Fluxon KV 的正式 `key -> value` route。
+
+这个 pool 是共享 owner segment 上的弹性本地可写内存供给层。它让多个 SGLang worker 都能快速取得受 owner 生命周期管理的 `value_ptrs[]`,同时避免为每个 worker 固定切出长期独占的后端 segment。reserve slot 不足时可以按需求补充 grant,空闲后再按 cooldown 回收。
+
+对象含义:
+
+| 对象 | 含义 |
+| --- | --- |
+| grant | owner 侧一次申请的大块本地内存,当前固定为 `512 MiB`。 |
+| slot | grant 内按 `slot_size` 切分的小块;一个 slot 承载一个 Fluxon value。 |
+| slot lease | `local_fast_put_start` 为本次 batch 临时 claim 到的一组 slots;失败或 abort 时必须释放。 |
+| value pointer | slot 的起始地址,会写入 plan blob 的 `value_ptrs[]`,供 SGLang kernel 直接写入。 |
+| resident value | `local_fast_put_commit` 后由 slot 构造出的本地可读 value。 |
+| route | master/owner 确认后的 key 到 value 位置映射;route 成功后该 value 才是全局可见的 KV replica。 |
+
+slot 生命周期:
+
+```text
+Free
+ -> Prepared // local_fast_put_start claim slot
+ -> PendingLocalVisible // local_fast_put_commit 开始,本地 resident value 已记录为 pending visible
+ -> Committed // put_done 成功,route 引用该 slot
+ -> Free // route 和 holder 引用都释放后回收
+```
+
+如果 native write 失败,调用方必须执行 `put_abort(plan_ptr)`,Prepared slots 会回到 Free。`local_fast_put_commit` 成功返回后,slot 是否能释放由 route 引用和 holder 引用共同决定;只要 master/owner route 或 `MemHolder` 仍引用该 slot,底层 grant 就不能释放。
+
+当前容量策略:
+
+| 项 | 当前实现 |
+| --- | --- |
+| grant 物理粒度 | `OWNER_LOCAL_RESERVE_GRANT_QUANTUM_BYTES = 512 * 1024 * 1024` |
+| 最小 slot size | `4 KiB` |
+| slot size 计算 | `align_up(max(value_len, 4 KiB), 4 KiB)`;按页对齐 exact-fit,不再向上取整到 2 的幂。 |
+| slot 上限 | `slot_size <= 512 MiB` |
+| refill 触发 | 当前 slot class free slots 不足时登记 pending demand 并唤醒 rebalance actor。 |
+| 预期容量预热 | owner 可配置 `owner_local_reserve_expected_capacity: {value_len, payload_capacity_bytes}`。启动时按 grant 内真实可用 slots 计算并申请所需 grants;SGLang 启动脚本必须等预热完成。 |
+| expected capacity | 配置预期容量后,`expected_grant_count` 同时是预热目标和物理 grant 硬上限;业务压力只能触发 owner writeback/reclaim,不能把 owner 容量扩到该上限之外。只有未配置 expected capacity 的动态模式可继续按 demand 申请 grant。 |
+| 默认等待 | soft wait `10 ms`,hard timeout `30 s`。hard timeout 覆盖一次有界 replica/reclaim 背压事务,不用于掩盖 master 调度饥饿,也不替代 terminal/pending 对账。 |
+| shrink 单位 | 整个 grant;不做 live grant compaction。 |
+| Moka 计费权重 | committed slot 使用 4 KiB 对齐后的实际 `slot_size`,而不是原始 `value_len`;容量驱逐因此对应真实内存占用。 |
+| Moka 容量水位 | owner `replica_writeback_hot_capacity_ratio` 控制 local hot/writeback 窗口;master `replica_cache_capacity_ratio` 只约束 `Allocation && !owner_local_indexed && lease_id.is_none()` 域。两者都不改变 owner segment 或 expected grants。 |
+| 常规容量驱逐 | owner hot Moka 的 Size 事件只转移“写回任务 ownership”,不直接释放 backing;master 的环 B Moka Size 事件进入 unindexed Allocation reclaim。两个环分别闭合。 |
+| 物理 NoSpace 兜底 | owner pressure actor 根据 free-slot 缺口减去 selection debt,只在这一处调用无谓词 `evict_some`(单次 ≤256 MiB、≥200 ms 间隔)启动 writeback。旧 route 全表扫描已删除;remote write 失败不进入 master drop/reclaim。 |
+| 容量不变量 | `evict_some` 只驱逐一部分条目,不修改 Moka `max_capacity`,也不修改 owner 配置容量。 |
+| route 维护反压 | route 发布后的 prefix-index/Moka 更新使用既有有界 actor;队列满时 `put_done` 等待,避免 metadata/Moka 水位落后于物理分配。它不负责 owner slot-pressure victim 扫描。 |
+
+Qwen3-VL-8B、TP=2、page size 64 的单 rank K/V value 是 4,718,592 bytes,正好是
+4 KiB 的整数倍。exact-fit 后一个 512 MiB grant 可以提供 113 个 slots;旧的
+`next_power_of_two` 会把每页扩成 8 MiB,一个 grant 只能提供 64 个 slots。以 128 GiB
+owner 为例,旧 `0.8` 水位叠加 56.25% slot payload 利用率后只有约 57.6 GiB 的有效 KV
+payload,会在物理 owner 仍有大量空闲时提前驱逐。exact-fit 与 0.95 水位分别解决内部碎片
+和过早驱逐;两项都不修改 owner 的 128 GiB segment 容量。
+
+预期容量的 grant 数必须计入每个 grant 尾部不能容纳完整 slot 的碎片,而不能简单用总 payload
+除以 512 MiB。计算为:
+
+```text
+slot_size = align_up(value_len, 4 KiB)
+slots_per_grant = floor(512 MiB / slot_size)
+expected_grants = ceil(payload_capacity_bytes / (slots_per_grant * slot_size))
+```
+
+Qwen3-VL 本轮 `value_len=4,718,592`、0.8 payload 目标 109,951,162,777 bytes 对应
+207 grants;可用 23,391 slots / 110,372,585,472 bytes,实际物理预留
+111,132,278,784 bytes。owner 配置容量仍为 128 GiB。配置层会拒绝非正 value/capacity/timeout、
+`hard_timeout <= soft_timeout`、value 大于 512 MiB、预热物理容量超过 owner DRAM,以及在
+master、external client 或 side worker 上设置 owner-only expected capacity。claim 超时统一
+返回 local-reserve timeout 类错误,并用 `stage=claim_turn|refill` 和完整 pool 状态区分等待
+claim 顺序与 refill/reclaim 失败。
+
+route 发布与容量记账必须保持有界距离。local-first `BatchPutDone` 可以一次发布上百个 page;
+如果每个 page 都独立 spawn prefix/Moka 维护任务,route 已提交和 owner slot 已占用的速度会
+超过 Moka 记账速度。此时即使 segment 仍有大量空闲,master 运行时也可能被任务和逐 page
+日志占满,使新的 512 MiB reserve RPC 超过 hard timeout。当前实现用一个有界批处理 actor
+统一消费 post-route 事件:一次 prefix-index write lock 覆盖整批,随后逐项推进 Moka;消费
+前校验当前 route 仍匹配 `put_id + owner replica`。容量 512 的队列同时承担反压边界,不能
+改回无界任务生成,也不能用增加 owner 容量代替该控制面约束。
+
+底层物理释放收束在 grant 级别。单个 committed slot 只是 grant 内逻辑索引,不直接拥有释放整块 mmap/registered memory 的权力。
+
+### Fluxon put_start / put_commit 接口设计
+
+Fluxon 的 backup 解法是把普通 KV `put(key, bytes)` 拆成 `put_start -> native write -> put_commit`。这个拆分的核心是接口解耦:Fluxon 先给本次写入分配受 owner 生命周期管理的 value memory,并把地址交给 SGLang;SGLang native/CUDA kernel 负责真正把 GPU KV cache bytes 写入这些地址;kernel 写完后,SGLang 再通知 Fluxon 发布这批 value。这样普通 KV 接口里的 payload materialization、CPU 侧 bytes 拆装和逐 page/layer 拷贝组织都不会进入 backup 热路径。
+
+`put_start` 不是一次可见写入,它的核心结果是内存分配和指针返回。Fluxon 为本次 batch claim owner-local reserve slots,生成 `plan_ptr(value_ptrs)`;`value_ptrs[]` 指向 Fluxon owner segment 中已经准备好的 writable value memory,SGLang kernel 可以直接写这些地址。判重、reservation 和 put id 是保护这次内存分配不被重复写入或提前发布的控制面约束;这些 value 在 `put_commit` 前不会作为可读 route 暴露。
+
+`put_commit` 是 kernel 写完后的通知和发布点。SGLang 在 CUDA write 完成后调用它,Fluxon 消费 put plan,把对应 slots 转成 resident values,并异步推进 route commit。`KvFuture` 成功前,数据只表示本次 write-back 已进入 Fluxon commit 流程;`KvFuture` 成功后,page 才能成为跨 worker / 跨节点可复用的 shared backing。这样 `put_start -> native write -> put_commit` 就构成了 hostless put 的完整闭环。
+
+这套接口拆分直接使用上面的弹性本地侧预分配池:`put_start` 从预分配 reserve slots 中 claim 短生命周期 slot lease,热路径拿到的是已准备好的地址。
+
+| 解法层 | 做什么 | 解决的问题 |
+| --- | --- | --- |
+| `local_fast_put_start(keys, value_len)` | 从 owner-local reserve slots claim 本次 batch 的 slot lease,分配 writable value memory,并返回 `plan_ptr(value_ptrs)`;key reservation 和 put id 保护这次分配 | 在 native write 前只暴露可写地址,不发布可读 route,避免其它 get 读到尚未写完的 value |
+| SGLang native write | SGLang native kernel 根据 `plan_ptr(value_ptrs)` 把 GPU KV page 写入 Fluxon 管理的 host value memory | 避免普通 KV `put(bytes)` 路径在 CPU 侧拆装 payload 和逐 page/layer 组织拷贝 |
+| `local_fast_put_commit(plan_ptr)` | 消费 put plan,把已写完的 slots 转为 resident values,推进 transfer / route commit,并返回 `KvFuture` | 把本地写完和全局发布分开;只有 future 成功后,SGLang 才能把 node 标记为 `storage_backed` |
+| 弹性本地侧预分配 | owner shared segment 中按 slot size 管理 reserve slots;free slots 不足时按 pending demand 补充 grant,空闲后按 cooldown 回收 | 热路径直接 claim 已准备好的可写地址,避免每个 worker 固定独占 segment,也避免每次 backup 临时分配或注册 host memory |
+
+写入阶段的拆分主要是为了同时满足三个约束:
+
+- 数据面效率:SGLang 不需要先把 GPU KV page 包装成通用 KV payload 再交给后端,而是直接用 kernel 写入 Fluxon 返回的 value 地址。
+- 可见性安全:`put_start` 阶段只预留地址,不发布 route;避免其它 get 读到尚未写完或尚未 commit 的 value。
+- 容量弹性:本地侧预分配池提供短生命周期 slot lease,而不是为每个 SGLang worker 固定切出长期独占内存;容量不足时由 owner 侧 refill,空闲后按 grant 粒度回收。
+
+```mermaid
+sequenceDiagram
+ participant U as UnifiedRadixCache
+ participant B as HiCacheFluxon
+ participant F as Fluxon store
+ participant K as sgl-kernel
+ participant O as Fluxon owner
+ participant M as Fluxon master
+
+ U->>B: local_fast_put_start(page_keys, value_len, atomic_group_lens)
+ B->>F: local_fast_put_start(storage_keys, value_len, opts(mask, groups))
+ F->>O: claim local reserve slots / external owner offsets
+ O-->>F: plan_ptr(value_ptrs)
+ B-->>U: plan_ptr
+ U->>K: write_*_to_fluxon_values(plan_ptr, page_indices, layout ptrs)
+ K-->>U: writes queued on CUDA stream
+ U->>U: record local_ready_event
+ U->>B: local_fast_put_commit(plan_ptr) after event ready
+ B->>F: local_fast_put_commit(plan_ptr)
+ F->>O: record precommit visible / transfer_end or put_done
+ O->>M: commit route
+ F-->>B: KvFuture
+ U->>U: scheduler poll future
+```
+
+hostless backup 默认不依赖 put 前 exists 扫描。重复 key 或在途 key 由 `local_fast_put_start` 的 `reject_if_exist_same_key` 和 `reject_if_inflight_same_key` 准入语义处理;SGLang 上层按冲突错误做重试或跳过。
+
+`local_fast_put_start(keys, value_len)` 的要求:
+
+- `keys` 不能为空。
+- `value_len` 必须大于 0,且同一批 keys 共享同一个 value size。
+- `atomic_group_lens` 存在时必须由正整数构成,且总和严格等于 `keys` 长度;同组的 `make_replica_task_mask` 值必须完全一致。
+- SGLang 的 radix node backup 默认使用 `[len(node_page_keys)]`。过滤已有 key 或冲突重试不能切开该组;无法保留完整组时本次 backup 失败关闭。
+- SGLang 必须在 `local_fast_put_commit` 前完成 native write;写入失败时必须调用 `put_abort`。
+- `local_fast_put_commit` 只能调用一次;调用后 plan 从 registry 清理,后续只能等待返回的 `KvFuture`。
+
+commit 请求会带上 `len`、`src_offset` 和 target 信息。Fluxon 用这些字段判断本次 value 是否落在当前进程可访问的 owner segment 或 owner-local reserve slot 中;如果需要本地可见索引,`put_done` 会返回 owner 分配的 `local_cache_holder_id`。`MemoryInfo` 和 holder 生命周期在下文说明。
+
+## Prefetch 与 Restore 时序
+
+hostless restore 的核心约束是:SGLang 只能恢复有序 page keys 的连续前缀,并且不能切开一个 radix node 对应的 atomic group。Fluxon 需要先在本机/远端 KV 层里判断这批 keys 的可恢复边界,再把真正可恢复的部分提前 materialize 到当前进程可读的 holder / memory view 中,最后转换成 SGLang kernel 可读取的 `plan_ptr(value_ptrs)`。
+
+因此读取侧分成 prefetch 和 restore 两段。当前实现把 start-to-transfer 计划显式限定为两个分支:
+
+- Prefetch:`get_start(keys, prefix_best_effort, atomic_group_lens)` 按 key 顺序做 local visible check / owner get start,计算 page 级连续命中前缀 `raw_prefix_hit_len`,再按 `atomic_group_lens` 向下收敛成 `transferable_len`。全 owner-local 批次返回 `InlineLocal { items }`:owner 为每个导出 page 安装独立 external holding,并把 offset、len、holding ID 和 owner generation 随 start response 返回;该分支不创建 transfer registry,也不启动后台 transfer。混合命中、master fallback 或需要远端 materialization 的批次返回 `OwnerRpc`,继续由 shared op 保存后台 prefetch 的 holder / transfer result。
+- Restore:SGLang 拿到 `transferable_len` 后再决定是否分配 GPU KV pages 并继续恢复。`get_transfer(handle, consume_prefix_len=...)` 可以消费不超过 `transferable_len` 的更短完整 atomic-group 前缀;省略该参数时消费全部可传输前缀。`InlineLocal` 分支在 external 进程校验 owner generation 和 mmap 范围后,只为消费前缀构造 holders,tail holding IDs 进入现有 holder-ACK 合批队列;`OwnerRpc` 分支在同一 transfer handler 内 drop tail items,只等待或取得消费前缀的 prefetch 结果。两个分支最终都生成持有 holder 引用的 readable `plan_ptr`,随后 SGLang native kernel 使用 `restore_*_from_fluxon_values(...)` 把 Fluxon value memory 拷回 GPU KV cache。
+
+读取阶段拆成 prefetch 和 restore 两步,主要是为了保证:
+
+- prefix 安全:中间 page miss 时,只恢复连续命中的完整前缀,不构造带洞的 GPU KV 状态。
+- atomic group 安全:`transferable_len` 不会切开 radix node group,避免恢复半个 node。
+- 资源效率:SGLang 在知道可恢复边界后再分配 GPU KV pages。`OwnerRpc` 可以重叠远端 materialization 和 GPU page 分配;`InlineLocal` 省去第二次 owner transfer RPC 和无用后台任务。
+- 生命周期安全:`get_transfer` 返回的 plan 持有 holder 引用,直到 `release_views(plan_ptr)` 后才释放,保证 kernel restore 期间 value 地址稳定。
+
+```mermaid
+sequenceDiagram
+ participant U as UnifiedRadixCache
+ participant B as HiCacheFluxon
+ participant F as Fluxon store
+ participant E as external client
+ participant O as Fluxon owner / external
+ participant K as sgl-kernel
+
+ U->>B: get_start(page_keys, atomic_group_lens)
+ B->>F: get_start(storage_keys, prefix_best_effort, atomic_group_lens)
+ F->>E: batch_get_start(keys)
+ E->>O: ExternalBatchGetStartReq
+ O->>O: local visible snapshot / owner get start
+ O->>O: compute raw_prefix_hit_len and transferable_len
+ alt fully owner-local
+ O->>O: allocate external holding IDs and install holdings
+ O-->>E: handle + InlineLocal(items, generation)
+ E->>E: cache inline plan by handle
+ else mixed hit or remote materialization
+ O->>O: create shared op and prefetch keys[..transferable_len]
+ O-->>E: handle + OwnerRpc
+ end
+ E-->>F: backend handle + raw_prefix_hit_len
+ F->>F: build GetStartResult(raw_prefix_hit_len, transferable_len, ...)
+ F-->>B: GetStartHandle + GetStartResult
+ B-->>U: transferable_len / prefix_hit_groups / first_miss_index
+ U->>U: allocate GPU KV pages for transferable prefix
+ alt transferable_len > 0 and caller chooses restore
+ U->>B: get_transfer(handle, consume_prefix_len)
+ B->>F: get_transfer(handle, consume_prefix_len)
+ F->>E: batch_get_transfer(handle, consumed prefix)
+ alt InlineLocal
+ E->>E: validate generation and mmap range
+ E->>E: enqueue tail holding IDs to batched ACK
+ E->>E: construct consumed-prefix holders
+ else OwnerRpc
+ E->>O: ExternalBatchGetTransferReq(consume_prefix_len)
+ O->>O: validate atomic boundary and drop tail items
+ O-->>E: consumed-prefix holders / transfer results
+ end
+ E-->>F: plan_ptr(value_ptrs, holders kept alive)
+ F-->>B: plan_ptr
+ B-->>U: plan_ptr
+ U->>K: restore_*_from_fluxon_values(plan_ptr, prefix page indices, layout ptrs)
+ K-->>U: H2D queued on CUDA stream
+ U->>B: release_views(plan_ptr) after restore finalizer
+ else caller gives up restore
+ U->>B: cancel_get_transfer(handle)
+ B->>F: cancel_get_transfer(handle)
+ F->>E: cancel_batch_get_start(handle)
+ alt InlineLocal
+ E->>O: ExternalBatchGetCancelReq(holding IDs)
+ O->>O: release exact external holdings
+ else OwnerRpc
+ E->>O: ExternalBatchGetCancelReq
+ O->>O: release shared op / prefetched holders
+ end
+ end
+```
+
+`GetStartResult` 的关键字段如下:
+
+| 字段 | 含义 |
+| --- | --- |
+| `raw_prefix_hit_len` | 按 key 顺序连续命中的 page 数,未按 atomic group 收敛。 |
+| `transferable_len` | `get_transfer` 最多可消费的 page 数;它不会切开 atomic group。 |
+| `prefix_hit_groups` | 完整命中的 atomic group 数。 |
+| `first_miss_index` | 第一个 miss page 的 index;全部命中时为 `None`。 |
+| `first_miss_group_index` | 第一个 miss 所在 atomic group;全部命中时为 `None`。 |
+| `all_hit` | `transferable_len == len(keys)`。 |
+
+生命周期规则:
+
+- `get_start` 成功后,调用方必须二选一:`get_transfer(handle)` 或 `cancel_get_transfer(handle)`;放弃 restore 时必须取消 handle,释放可能已经启动的 prefetch 资源。
+- `get_transfer(handle, consume_prefix_len=...)` 的值必须大于 0、不超过 `transferable_len`,并精确落在 atomic-group 边界;参数校验失败时 handle 仍可 cancel 或用合法长度重试。
+- `get_transfer` 成功后,handle 已被消费;tail 资源同时移交给 owner 直接 drop 或 external holder-ACK 合批队列,后续只由 returned `plan_ptr` 和 `release_views(plan_ptr)` 管理消费前缀。
+- `release_views(plan_ptr)` 必须在 native restore 完成后执行,即使 native restore 失败也要释放。
+- `InlineLocal` holding 从 owner 返回 start response 前已经安装;全量 `get_transfer` 把它们移交给 returned plan,部分前缀 `get_transfer` 只移交前缀并将 tail holding IDs 无等待送入 holder-ACK 合批队列,`cancel_get_transfer` 则携带全部 holding IDs 精确释放。owner 重启或 generation 不匹配时不能继续使用旧 inline plan。
+- `get_start` 只命中部分前缀时,SGLang 只能恢复 `transferable_len` 覆盖的完整 atomic groups,不能构造半个 atomic group 的 GPU KV 状态。
+- `get_transfer` 返回 miss / KeyNotFound 时,SGLang 必须放弃本次 restore 并执行 rollback。
+- owner 必须为未被 `get_transfer/cancel_get_transfer` 消费的 handle 设置有界 TTL;TTL 回收与
+ 显式 cancel 走同一条 plan Drop 路径,不能永久 pin local holder 或 per-key result。
+
+### Batch Get 的逐 key 交集、pin 与 singleflight
+
+hostless restore 的 batch 通常不会完全相同。多个会话或同一会话的不同轮次会共享
+大量前缀 page keys,但各自的 batch 长度、后缀和 `atomic_group_lens` 不同。因此以
+`keys + atomic_group_lens + prefix_best_effort` 整个结构作为去重 key,只能合并完全相同的
+batch;它不能阻止两个重叠 batch 对同一 page 重复申请 prepared slot、重复发起 Get
+和重复 DMA。
+
+读路径必须在 owner 侧按单 key 分流,同时保留原 batch 的顺序和 group 边界。对一个有序
+required batch `R`,定义:
+
+```text
+L = 通过 owner reclaim fence 后可以立即取得 holder 的 local-visible keys
+F = 当前 owner 上已有可共享 Get 操作的 inflight keys
+
+LocalJoin = R ∩ L
+InflightJoin = R ∩ F
+Leader = R - (L ∪ F)
+```
+
+这里的“逐 key”只指 singleflight registry 的判定和线性化粒度,不指 RPC 粒度。
+owner 逐 key 完成 `R` 的完整分流后,必须把全部 `Leader` 按原 index 压缩成
+`leader_keys[] + prepared_targets[] + original_indices[]`,通过一次 BatchGetStart 发布;全部终态
+使用 BatchGetDone/Revoke 收敛,最后才按 `original_indices[]` scatter 回 `R`。当前 payload
+backend 仍对每个 key 调用一次 `transfer_data_no_copy`,因此只能表述为“控制面已 batch、数据面
+descriptor batch 待实现”,不能表述成已按 peer/transport 成批提交。后续应直接按 peer/transport
+汇总 descriptor,或使用有界 time-window 聚合兼容传输;不得为了 per-key future 在循环中逐个
+执行完整的 `GetStart -> transfer -> GetDone`。
+
+`L` 只包含能在当前 owner generation 内取得稳定 `MemoryInfo` 的
+`precommit_local_visible_info` 和 `get_cached_info`。只有 master route、只有
+`local_snapshot_info`、已进入 reclaim fence,或只存在 hidden pending 但还没有可共享
+future 的 key,都不得当作 local hit。`pending_local_get_info` 属于 `F` 的生命周期,
+不应通过把未发布 bytes 提前放入 `L` 来规避竞争。
+
+一次 batch 的必须时序如下:
+
+1. owner 使用 256-shard key-control table 逐 key选择 local / join / leader;每次只持一个
+ key 所属 shard 的短临界区,禁止持锁遍历 `R`,也禁止任何锁跨 `.await`。
+2. `LocalJoin` 在离开该 key fence 前 clone 对应的 `Arc`,以 resident holder 引用 pin
+ 住 backing。即使 route 随后被 hot demotion,该 slot 也要等本次 holder 释放后才能
+ 变成 Free。
+3. `InflightJoin` 在离开 registry 前取得逐 key `Interest` guard;该 guard 必须能观察
+ 同一个终态,不得另外申请 slot 或发起 DMA。prefix decision 由 guard 的所有权表达,
+ request future 在任一 `.await` 被取消时,`Drop` 必须自动归还 `undecided`,不得依赖
+ `decision_registered: bool` 和尾部手工减计数。
+4. `Leader` 使用逐 key 原子 entry 操作先安装 shared op,再加入本轮压缩后的
+ master BatchGetStart。不能先对 inflight set 做一次普通 snapshot,稍后再批量
+ insert;否则两个 batch 仍可以同时观察到空集并都成为 leader。
+ 若规划在 BatchGetStart handoff 前取消,`ExternalGetPlanningLeadersGuard` 必须把本轮新建且
+ 仍为 `Starting` 的 op 发布为安全 miss、按 Arc identity 清 marker 并唤醒 joiner;此时尚无
+ prepared target/master operation,所以不得发 Revoke,也不得留下孤儿 marker。
+5. 只有 `LocalJoin` 和 `InflightJoin` 已经取得 pin,且所有 `Leader` 都已发布
+ inflight marker 后,才能对 leader 子集发起 master GetStart 和后续 payload transfer。
+6. leader 的压缩响应必须按原 index 放回与 `R` 等长的 result slots,local、joiner
+ 和 leader 的状态全部拼回后,才可计算 `raw_prefix_hit_len`。不得在压缩后的
+ leader 列表上计算 prefix。
+7. `transferable_len` 仍使用原始 `atomic_group_lens` 向下收敛。一个 group 可以同时
+ 包含 local、joiner 和 leader,但只有所有成员均可读时才能对上层发布完整命中。
+8. 超出 `transferable_len` 的后缀必须立即释放 local pin、减少 inflight waiter,并对
+ 已 start 的 leader 执行 revoke。未发布的 prepared slot 必须返回 Free,不能因为该
+ key 不在连续前缀内就保留到 TTL。
+9. 保留前缀的 local holder、joined future 和 leader future 一直存活到 `get_transfer`
+ 成功移交给 readable plan,或 `cancel_get_transfer` 明确取消。
+
+请求级不得再叠加 exact-batch shared-op 状态机。每个 external handle 直接持有自己的
+`BatchPlan(keys + Local/Interest items)`;完全相同和部分重叠的 batch 都自然在 per-key
+registry 合流。leader 子集在进入第一个网络 `.await` 前移交给一个注册到 owner task
+registry 的 cohort task,该 task 独立完成 `BatchGetStart -> transfer -> BatchGetDone/Revoke`。
+因此 external Start RPC 被取消只会 Drop 本请求的 Interest,不会让已经安装的 leader 失去
+executor。该收缩同时删除 request 级的第二套 phase/Mutex/Notify、waiter_count、完整 key
+向量结果缓存和纯聚合后台 task,但不改变公开的 start/transfer/cancel API。
+
+prepared local-reserve claim 也必须由 guard 持有。BatchGetStart 明确接受的 target 逐项从
+guard disarm 并转交 per-key flight。已经收到响应、但响应长度或 target identity 异常时,
+所有已返回 `get_id` 的成功项必须先移交注册 Revoke cleanup,只有明确未接受的 slots 才能
+立即返回 Free。若 BatchGetStart transport 失败且完全没有响应,owner 不知道 master 是否已经
+接受,也没有 `get_id` 可以 revoke;这类 slots 不得立即复用,当前实现隔离 65 秒,覆盖 master
+60 秒 inflight Get TTL 后再尝试释放。该 TTL 隔离是未知提交点的安全兜底,不替代后续带
+request identity 的 terminal query 协议。
+
+`Revoking` 状态必须保留完整 `get_id + prepared_target`;RPC 超时、响应丢失或长度/identity
+不匹配时保留 marker 和 slot 并重试,只有 master 明确确认 Revoke 后才释放 slot。若 Done
+已胜出,则 Revoke 失败方不得释放已经 committed 的 slot。BatchGetDone 同样必须携带原始
+`get_id` 集合幂等重试,并逐项校验响应 identity;不能在固定三次重试后把
+pending-visible slot 遗留为无 owner 状态。owner shutdown 是唯一允许停止该重试的进程级终态。
+
+per-key shared op 至少要区分下列阶段:
+
+| 阶段 | 对 prefix / 资源的含义 |
+| --- | --- |
+| `Starting` | leader marker 已经对后续 batch 可见,但 master 尚未接受;joiner 只能等待,不能把它计为命中。 |
+| `Started` | master 已返回成功的 `put_id/get_id` 和精确 target;可用于计算连续 prefix,但 bytes 还不得暴露给 kernel。 |
+| `Finishing` | 至少一个 Interest 保留该 key;唯一 cohort executor 正在 transfer/GetDone,后续请求只能 join。 |
+| `Revoking { item }` | 无 Interest 保留该 key;`item` 持有 get_id 和精确 target,直到 Revoke 明确终止并释放 slot。 |
+| `Ready` | transfer、幂等 GetDone 和 local promotion 已完成;所有 waiter 共享同一个 `Arc`。 |
+| `Failed/Revoked` | 终态错误对所有 waiter 一致可见;leader 只释放一次 prepared slot 和 master inflight。 |
+
+shared op 的 registry 清理必须同时比对 key 和 op identity/generation,只删除当前这一个
+terminal op。旧 waiter 的延迟 drop 不能删除同 key 随后创建的新 op,这与现有
+`key + Arc::ptr_eq` 的 generation 清理原则相同。同一 required batch 内即使出现
+重复 key,也只能有一个 leader/shared op;原始的多个 index 分别持有结果引用。
+
+owner 侧 singleflight 是主去重边界,master 仍需要防御性地保证一个 requester 不发布两个
+local committed targets。master 防重粒度必须是 `(key, requester_owner)`,不是全局
+`key`;GPU0 和 GPU1 同时把同一 key materialize 到各自 owner 是合法的,不应被串行化。
+prepared Get 从 master GetStart 接受到 GetDone/Revoke/TTL 终态期间占有该
+`(key, requester_owner)`。
+
+master 已经看到 requester 上有相同版本 live replica,或 GetDone 时另一个操作已经
+发布同版本 route,都属于可收敛竞争,不得使用 `InvalidArgument/Unknown` 当作永久
+失败。master 应返回可区分的 busy/already-exists 终态:owner 释放本次多申请的
+prepared slot,在 reclaim fence 下重新获取 canonical local holder;正在 demotion 的 key 则等待
+route 移除后从 remote replica 重试。相同 `put_id` 的 GetDone 竞争必须幂等收敛到唯一
+local route,输家不覆盖 canonical backing;不同 `put_id` 才是 stale Get,必须 revoke。
+
+该路径至少要暴露 `required/local_pinned/inflight_joined/leader_started`、被省略的
+GetStart/DMA 数、suffix local-pin/waiter/revoke 数、join 等待时间以及 conflict retry 数。
+当前 owner 周期快照已暴露 active handles、per-key flights 的 Starting/Finishing/Revoking、
+`undecided/retained` interest,以及 local-reserve 的 Free/Prepared/Pending/Committed slot 数;
+workload 结束后这些临时态必须在有界时间内回到基线。
+`prepared local-reserve Get target cannot replace a live replica` 和
+`prepared local-reserve Get target could not publish current route` 应该从可见错误中归零;
+仅看最终 QPS 不能证明重叠 batch 已经正确去重。
+
+## Fluxon 本地可见索引与生命周期
+
+Fluxon client/external 侧会维护当前进程可直接访问的 value 索引,以及 get/put plan 持有的 holder 引用。SGLang hostless 路径主要涉及下面几类状态:
+
+| 状态 | 创建入口 | 生命周期 |
+| --- | --- | --- |
+| precommit local visible | `local_fast_put_commit` 开始后,由 owner-local reserve slot 对应的 `MemoryInfo` 记录到 `precommit_local_visible_info` | 表示 value 已在当前进程可读但 global publish 尚未收敛;成功后转为 committed entry。响应不确定/局部失败时保留并 roll-forward,只有证明从未发布的显式 abort 才移除。 |
+| committed local visible info | put commit 成功后,由 SGLang 进程内的 Fluxon external/client 将 `MemoryInfo` 记录到 `get_cached_info` | 保存 key、put version、`holder_id`、`offset`、`len` 和 owner node;后续 `get_start/get_transfer` 可以复用这份 `MemoryInfo` 构造 readable plan。 |
+| inline external holding | owner 判定整个 transferable batch 都在本地可见后,为每个导出 page 分配 external holding ID 并安装 holding | start response 到达 external 后由 handle 缓存;随后必须移交给 `get_transfer` plan 或由 cancel 携带 holding IDs 释放。holding 绑定 owner generation。 |
+| get-transfer holder | `get_transfer` 成功后绑定到 readable plan,并由 plan 持有引用 | `release_views(plan_ptr)` 后释放引用;plan 生命周期内 holder 保证对应 value 不被释放。 |
+
+Put 的本地可见性和全局发布是两个独立的线性化点,不得把两者合并成
+“等 master 后才能本地读”:
+
+| 线性化点 | 条件 | 立即允许的操作 |
+| --- | --- | --- |
+| `local-read-ready` | native kernel 已把 bytes 写入 local-reserve slot,且对应 CUDA stream 可见性已满足;client 将唯一 `MemoryInfo` 安装到 `precommit_local_visible_info` | 同 owner 的 `local_visible_mem_holder(s)` 立即可以命中、pin 并读取该 slot,不等待 master PutDone。 |
+| `global-route-ready` | master PutDone 已按 key/version 发布 route,并完成必要的 route maintenance | 该 slot 才进入 `Committed(route_live=true)`,可被其它 owner 通过全局 route 发现,并可进入 owner-hot Moka 参与后续分级。 |
+
+因此延后的只是 global committed 和 hot admission,不是 local read。之所以不在
+`local-read-ready` 时立即加入 owner-hot Moka,是因为 Size eviction 可能马上启动
+replica/demotion/reclaim;此时 master route 还未发布,会使回收协议无法用同一
+`put_id + backing identity` 证明对象归属。precommit entry 本身持有 resident holder,已经
+足以保证本地读期间地址稳定,不需要依赖 hot Moka。
+
+同一 atomic/TP cohort 可以逐项进入 `local-read-ready`,但 batch Get 仍必须在原
+`atomic_group_lens` 边界上判定可恢复前缀;这保留了“本地已写页立即可复用”,
+又不会向 SGLang 恢复半个 radix node。master 发布不确定或部分成功时保留 local-visible
+entry/fence 并整批 roll-forward;只有明确从未发布的 abort 才能移除 precommit。已经取得的
+local-read holder 仍可安全完成当前读,最后一个 holder 释放后才能回收 slot。
+
+收到 `local_cache_holder_id` 后,SGLang 进程内的 Fluxon external/client 使用 `holder_id`、`offset` 和 `len` 构造 `MemoryInfo`,并记录到自身 `get_cached_info`。`MemoryInfo` 记录当前进程访问该 value 所需的地址信息和释放动作;后续 `get_start` 命中 `get_cached_info` 时,可以直接把这份 `MemoryInfo` 纳入本次 get 结果,`get_transfer` 再把这些 value 地址写入 readable plan。底层内存的回收由 owner route、holder 引用和 owner-local reserve grant 生命周期共同约束。
+
+内部 `MemoryInfo.holder_id` 与 external holding ID 属于两个生命周期域。resident local-reserve page 的内部 holder ID 可以为 0;每次 external export 仍由 owner 分配非零、递增且独立的 holding ID,同一内部 backing 的并发导出也不能复用 ID。这样 cancel/delete ACK 可以按本次 export 精确对账,多个 resident page 不会因内部 ID 相同而覆盖 holding。external 在构造 inline holder 前还必须验证 response generation 与当前 mmap owner generation 一致,并验证 `offset + len` 位于 mapping 范围内。
+
+`precommit_local_visible_info` 覆盖 local-ready 到 global route/promotion 完成之间的窗口。
+put commit 成功后会原子转入 committed index;PutDone 响应不确定或局部失败时 publish job
+必须继续持有 precommit/fence 并以同一 identity roll-forward,不能清理可能已被 master 发布的
+backing。只有明确证明 master 从未发布且操作已终止的 abort 路径才可移除 precommit。
+
+## Local-side 主动驱逐与安全回收
+
+local-side 主动驱逐分成“owner 选择冷 source”和“master/owner 安全删除该 source”两个
+阶段。owner hot Moka 的 Size removal 或 pressure actor 的 `evict_some` 只完成候选选择;
+dispatcher 随后提交完整、精确的 atomic/TP source cohort。该容量事务不查询 CPU replica,
+也不等待 remote append:有其它 replica 时只删当前 owner source,没有其它 replica 时允许
+删除最后一份 cache route,后续 Get 正常 miss/recompute。proactive remote replica 是独立的
+命中率优化,失败只代表少一份可命中的 CPU copy,不拥有 owner 容量释放的否决权。
+
+### 触发与职责边界
+
+- owner hot Moka 以 committed slot 的真实 `slot_size` 计费,value 只保存
+ `Weak`,不因热度索引额外 pin backing。
+- synchronous eviction callback 只发送 `(key, put_id, Weak, weight)` 到 lossless metadata
+ channel;callback 不取 key-control、不查 index、不展开 cohort、不 RPC/RDMA。
+- async dispatcher 从当前 local index point-pin `Arc`,在锁外展开完整 atomic/TP cohort,
+ 构造 `OwnerSourceEvictionMember { key, put_id, backing }`;不使用 cohort-wide 同步锁。
+- `evict_some(requested_weight)` 是 owner slot-pressure actor 的唯一生产调用点;参数是本轮
+ 希望选择的字节数,不是新容量。单次 ≤256 MiB、最短间隔 200 ms,调用前后
+ `max_capacity`、owner 配置容量和 expected grants 均不变。
+- `selection_debt_bytes` 覆盖已离开 hot window、仍由 source-delete/retry 状态机拥有的字节。
+ pressure actor 只选择 `free-slot deficit - selection debt`,避免重复超选。
+- `BatchEvictOwnerSourceReq/Resp` 使用 Msg ID `3069/3070`。RPC 不可达、activity Busy、
+ partial overlap 或 actor 暂不可用都按同一 exact identity 有界重试,不重新 append;
+ physical Free、stale/obsolete 或安全恢复 hot 是 selected debt 的唯一终态。
+- active size class 为 `0` 时是尚未分配的正常 idle,pressure actor 静默等待;当前只支持
+ `1` 个 active class;`>1` 时停止选择并限频报错,不能用全局 Moka 在多 class 中猜 victim。
+
+### 安全回收时序
+
+```mermaid
+sequenceDiagram
+ participant H as owner hot Moka
+ participant O as GPU owner dispatcher
+ participant M as Fluxon master
+ participant R as existing local reader
+
+ H->>O: Size event(key, put_id, Weak, weight)
+ O->>O: point-pin current source / expand exact cohort
+ O->>M: BatchEvictOwnerSource(exact cohort)
+ M->>M: validate caller generation, closure and backing
+ M->>M: install all master activity fences
+ M->>O: cohort Prepare
+ alt reader already holds source
+ O-->>M: Busy; restore visible index
+ R-->>O: holder released
+ M->>M: bounded retry of the same identity
+ end
+ O-->>M: all Prepared
+ M->>O: cohort Commit
+ O->>O: Prepared -> Releasing under key shard
+ O->>O: unlock key; release route+holder in one pool update
+ O->>O: Releasing -> Committed
+ M->>M: remove exact owner routes; last route may disappear
+ M->>O: cohort Finalize
+```
+
+Master 与 owner 两侧分别承担下面的正确性约束:
+
+| 约束 | 当前实现 |
+| --- | --- |
+| 并发线性化 | Master 为每个 key 统计 put/get/replica activity。只有三个计数都为零且没有其它 reclaim 时才能原子安装 reclaim fence;安装后新的 put/get/replica 不能取得 activity lease。已经取得 source holder 的读或 replica transfer 必须先排空,但 transfer 是否成功不改变容量事务义务。 |
+| 版本隔离 | 每个候选携带 `put_id` 和 reclaim `epoch`。Master 在安装 fence 前、Prepare 后以及删除 route 前重复检查当前 route 仍是同一版本。 |
+| backing/slot 防 ABA | committed slot 必须同时匹配 owner、`grant_id`、`slot_index` 和 `slot_size`;旧驱逐请求不能释放新版本或已经复用的 slot。 |
+| 现有读者排空 | Owner Prepare 只持该 key 所属的短 sharded key-control fence,先从 local index 隐藏 backing,再要求唯一 holder。若已有 get/plan/transfer pin,则恢复索引并返回 Busy。 |
+| 写入冲突隔离 | Owner Prepare 遇到 `local_puts`、`precommit_local_visible_info` 或 `external_pending_puts` 时返回 Busy,不会回收正在写入或尚未发布完成的 value。 |
+| 精确物理释放 | Owner Commit 在 key-shard 内只切换 `Prepared -> Releasing`,随即释放 key 锁;之后在一次 slot-pool 临界区同时释放 committed route ref 与 resident holder,再回到 key shard 标记 `Committed`。这避免 key-shard mutex 嵌套 slot-pool mutex,也不暴露两次 pool update 间的中间状态。 |
+| route 更新顺序 | Owner Commit 释放物理 backing 后,Master 只删除相同 `put_id` 且 backing identity 相同的 replica;这个短窗口仍受 master reclaim fence 保护,对新请求不可访问。Master 更新 route 后才 Finalize owner fence。 |
+| cohort 事务 | owner exact source deletion 和 master allocation capacity reclaim 都以一个 cohort request 进入 lossless queue。全组 master fence 与 Prepare 要么全部取得、要么全部撤销;一旦任一 Commit 已成功,只允许 roll-forward 完成其余成员。 |
+| 幂等与失败关闭 | Prepare/Commit/Abort/Finalize 都按 `item + epoch` 判定已应用状态。RPC 不确定时重放同一 identity;已经 Commit 的 backing 不可回滚。未收敛时保留 fence,优先阻塞该 key 而不是暴露已释放内存。 |
+| partial/replay 判定 | 全部 source 已不存在是 `Completed`;部分不存在只有在完整 exact identity 已在 reclaim registry 中时才是 `AlreadyInProgress`。无对应在途事务返回 `Stale`,部分 overlap 返回 `RetryableBusy`,不能把残缺 cohort 当作新事务接受。 |
+| 失败候选收敛 | 暂时 Busy 的当前版本保持 pending 并退避重试,不立即插回 Moka;stale route 或版本变化直接结束 pending。只有队列关闭等无法继续推进的路径,才在 route 仍匹配 `put_id` 且 owner replica live 时重新插入,避免旧条目覆盖新版本。 |
+
+从可观察状态看,回收事务允许 Owner Commit 与 Master route 删除之间短暂存在“owner slot 已回收到 allocator、可以复用,但旧 route 对象尚在”的内部状态,但对应 key 的 master reclaim fence 在整个窗口内拒绝新操作;安装 fence 之前已经取得 lease 的 get/put/replica 又必须先结束。因此调用方不会通过该 route 访问已经释放或复用的 backing。
+
+### 副本保留语义
+
+内存/并发正确性与“驱逐后是否仍有另一份可恢复数据”是两个不同契约:
+
+- **环 A(owner-indexed source)**:hot removal/`evict_some` 只是 owner 的 victim selection。
+ owner 把完整 exact cohort 交给 master 做 source route fence/delete;不要求 CPU 副本。
+ 有其它 replica 时只删除该 source,没有其它 replica 时允许最后 route 消失。RPC/Busy 暂时
+ 失败时 source 保持可读并按同一 identity 重试;remote write 失败不进入容量失败补偿。
+- **环 B(master-only/unindexed Allocation)**:master 有界 Moka 的原生 Size eviction 可以删除最后
+ 一份 cache route,后续正常 miss/recompute;这是 cache 容量语义,与环 A 的结果一致,
+ 但 victim authority 与 backing 释放位置不同。该分类只取决于 backing/index/lease
+ 语义,不取决于 CPU/GPU 或 active/remote-only 节点角色;当前默认置换策略为
+ Moka TinyLFU,正确性不依赖具体 victim 顺序。
+- 旧 `OwnerReclaimReason::Reserve` route 全表扫描、`reclaim_before_grow` 和 master 侧手动
+ `evict_some` 均已删除。NoSpace 只返回明确可重试结果;owner pressure actor 等待真实 Free
+ slot,不通过增大 grant 数绕过 configured capacity。
+- `atomic_group_lens`/Put group 是 source cohort 描述;master 在 source deletion 当下验证
+ 请求集合与当前 atomic/TP closure 完全一致。它不从单 key补全 cohort,也不要求共同 CPU
+ owner;任一 member stale 时整组不 Commit。
+
+### 当前验证证据与覆盖边界
+
+2026-07-17 对当前 Fluxon 工作树完成 direct source-delete 本机回归。使用
+`CARGO_TARGET_DIR=/tmp/fluxon_target_direct`、`-j 4` 和 closed-sdk runtime,最新代码
+`cargo test -p fluxon_kv --lib --no-run` 通过;exact source cohort 定向测试 `7/7`,owner
+slot/reclaim 定向测试 `19/19`,`fluxon_kv --lib` 全量为
+`171 passed, 0 failed`(193.19s)。覆盖有/无 CPU replica、最后 route 删除、stale backing、
+partial replay-only、cohort fence all-or-nothing、retry exactly-once、`Releasing` 和
+route+holder 单次 pool update。全量测试还修复了 `0 active class` 被误判后每 10ms 报错的
+idle actor 空转。本机回归本身不替代 release 三机部署、真实 RDMA 与 E44 自然冷跑,后者
+按下一段独立验收。
+
+上述独立验收已在 2026-07-17 的 E44 r9 完成。三机容量保持
+GPU0/GPU1/CPU=`128/128/256 GiB`,两 GPU 各固定 `232` grants、`26,216` slots;
+S96×T24、concurrency 24、session-stream、无预热自然冷跑为 `2304/2304` 成功,QPS
+`5.609336`,L1/L2/L3/总命中率 `5.4060%/0%/54.6878%/60.0939%`。CPU owner 被选为
+唯一 proactive replica target(`77,384` 个),并实际为两个 GPU requester 提供
+`183,923` 个 key、`808.26 GiB` Get source 数据。
+
+容量闭环终态为:GPU0/GPU1 direct source commit `130,540/145,888`,dispatch failure
+均为 0;Free slots `466/488`,Prepared/Pending 均为 0;active flights、retry entries、
+selected 和 selection debt 全为 0;grant 数始终等于 expected `232`。业务错误、refill
+timeout、P2P、panic、OOM 和 scheduler exception 均为 0。日志中的 recoverable
+`not_ready` 只对应最后一份 route 已按容量策略删除后的正常 miss/recompute;所有真正提交的
+prefetch 均以非零 prefix 成功,没有 TP prepare reject 或 transport error。该结果证明环 A
+的 direct source-delete 正确性和容量收敛,不证明 selective CPU replica 已达到 Mooncake 的
+命中率;后者仍是独立性能策略,不能反向成为 Free 的前置条件。
+
+三机 SGLang 正式压力还覆盖了 expected-capacity 与常规驱逐的组合。E16a4 使用两台 TP=2
+GPU owner 和一台 CPU-only owner,标称容量保持 128/128/256 GiB;两 GPU 各预热 207 grants,
+正式请求期间各按 pending demand 增到 208 grants。1152/1152 请求成功,QPS 4.845,两侧
+Moka 稳定在 23,296 entries / 109,924,319,232 weighted bytes,owner reserve 在继续写入时
+稳定维持约 128--170 free slots;refill timeout、NoSpace、OOM、scheduler fatal 均为 0。
+master 的 replica target 只有 CPU owner,共 19,395 个,GPU 间副本为 0。这证明预热和
+shrink 下限不改变 owner 容量,也不会阻止达到水位后的常规 `evict some` 持续回收。
+
+同拓扑 E16b 把水位和 expected payload 从 0.8/102.4 GiB 提到 0.95/121.6 GiB 后,QPS
+从 4.845 提高到 5.967,总命中从 73.67% 提高到 90.28%;Mooncake 为 6.919 QPS、
+93.05% 命中。容量对齐后只剩 2.77pp 命中差,但仍有 13.76% QPS 差距。两 GPU 的
+`init_load_back` 平均总时长分别为 80.07/52.49 ms,其中 `restore_sync` 平均
+68.46/42.33 ms、radix eviction 平均 9.66/8.31 ms,而 kernel launch 记账约 0.05 ms。
+因此 expected capacity、exact-fit 和 group 完整性都不能替代数据面优化:需要继续减少
+同步点,分析 GPU0/GPU1 restore sync 不对称,并评估 `page_first_direct` 组织方式。E16b
+仍有 26/24 次 TP transferable mismatch;common-prefix retry 是独立的命中修复,不应被当成
+剩余全部性能差距的解释。
+
+E16c 的单变量结果进一步确认了这个边界。启用 TP-invariant Put atomic-group admission 和
+TP common-prefix retry 后,总命中从 90.28% 提高到 92.76%,与 Mooncake 的 93.05% 只差
+0.29pp;TP mismatch skip 从 26/24 降到 0。但 QPS 只从 5.967 提高到 6.136,仍比
+Mooncake 低 11.32%。E16c 的 GPU0/GPU1 load-back 平均总时长仍为 80.55/53.84 ms,
+`restore_sync` 仍占 69.29/43.51 ms。结论是:atomic group 是 CPU selective replica 可消费性
+与 TP 正确收敛所必需的协议信息,但在命中率已经对齐后,主要性能工作必须转向 restore
+kernel 的同步边界、GPU radix eviction 和 page/layer 搬运顺序。
+
+后续 E16f 的逐层 restore kernel 把 Fluxon QPS 提到 6.635;E16s 的 deferred
+`release_views` 和 E16t 的整批 local-visible snapshot 继续把 QPS 提到 6.808。E16u 再为
+全 owner-local get 增加 `InlineLocal` 分支,两个独立冷栈正式轮分别为 6.754/6.760 QPS,
+1152/1152 成功且各自实际备份 3,869,056 tokens。`get_transfer` mean 已从 E16t 的
+4.192/1.957 ms 降到 0.558/0.585 ms,但冷 QPS 没有继续提高;一次复用旧 owner keys 的热轮
+达到 6.894 QPS,却只实际备份 246,976 tokens,不能作为冷 A/B。
+
+E16u 冷复测把剩余 radix eviction 进一步定位到同步 write-back。GPU0/GPU1 的
+`init_load_back` mean 为 10.621/8.399 ms,其中 eviction 为 8.859/6.688 ms;阻塞
+`_wait_for_fluxon_hostless_backup` 与 `writing_check(write_back=True)` 的累计 duration 约占
+这些 eviction 累计时间的 95.7%/90.5%。parent-chain backup mean 为 6.508/4.435 ms,主要
+由 stream sync 3.735/2.322 ms 和 `local_fast_put_start` 1.988/1.366 ms 构成。因此当前性能
+边界已经从 get transfer RPC 转到 write-back 时机与批次组织。后续优化需要在不改变 owner
+128/128/256 GiB 容量、不降低连续完整前缀命中的前提下,评估有界 proactive write-back 或
+多 radix node 共用 Put plan/一次 stream sync;单纯继续减少 get 控制面 RPC 的收益不足以
+代表冷端到端收益。
+
+### Write-through parent 依赖与驱逐时补备份
+
+hostless write-through 的 `storage_backed` 也必须满足连续前缀不变量:一个 child 只有在
+parent 已经 remote-backed 后才能成为独立可恢复节点。proactive `write_backup` 遇到 parent
+尚在途时会先提交 parent,但不会自动把 child 排队到 parent ACK 之后重试。因此 child 可能
+既没有 `storage_backed`,也没有 `storage_pending`;后续 device eviction 若直接删除该 radix
+node,会丢失仍可通过一次补写保留的前缀。
+
+驱逐时的收敛顺序应为:
+
+1. 已经 `storage_backed`:直接走 `_evict_to_fluxon_storage`。
+2. 当前 node 有 `ongoing_fluxon_hostless_backup` 或 pending ACK:只等待已有操作;成功后走
+ Fluxon tombstone。
+3. 没有可恢复副本也没有 pending:调用 `write_backup(node, write_back=True)`;该调用递归
+ 补齐缺失 parent,随后 `writing_check(write_back=True)` 阻塞到 ACK。
+4. ACK 后再次检查 `_fluxon_hostless_node_storage_ready`。只有确认完整 KV(及需要时 Mamba)
+ 均 remote-backed 才释放 device backing;提交失败、ACK 失败或仍不可恢复时保留原来的
+ fail-closed 删除语义。
+
+E16w 只实现第 2 步,但两台 GPU 的新分支触发均为 0,排除了“主要是 ACK 尚在途”的假设。
+E16x 补齐第 3--4 步,正式轮中 GPU0/GPU1 分别产生 118/114 条 TP-rank 同步补备份提交,
+失败为 0;其中直接 leaf completion 各 88 条,parent-chain completion 为 30/26 条。
+总命中由 E16w 的 92.38% 提高到 92.73%,增加 99,520 tokens;QPS 仅从 6.8081 降到
+6.7915。每次 load-back 的 radix eviction mean 只增加 0.169/0.127 ms。这说明按需补齐
+parent chain 可以作为正确性和命中保底,不需要把所有 write-through 操作重新变成同步写。
+
+该结果也进一步限定 atomic group 的作用边界。Mooncake 没有 Fluxon Put atomic-group
+admission,仍达到 93.05% 命中和 6.9192 QPS;E16x 在命中只低 0.323pp 时仍低 1.85% QPS。
+因此 Put atomic group 是 TP 一致准入、CPU selective replica 完整组和“有完整远端副本才
+优先驱逐”的正确性信息,但不是剩余端到端差距的充分解释。Mooncake 每次 restore 平均跨
+3.20/3.26 个 radix node,E16x 为 10.15/10.08 个;Mooncake 的命中主要经过 32 GiB 本地
+HiCache L2,Fluxon hostless 的命中主要表现为 external L3。后续数据面比较必须记录每个
+request 的实际 restore bytes、来源 locality、CUDA event 等待和合并后的 batch 数,不能仅
+比较命中率或 atomic-group 数量。
+
+E16x 的分节点指标显示剩余差距集中在 GPU0:Fluxon TTFT/E2E mean 为 1.036/1.629s,
+Mooncake 为 0.865/1.522s;GPU1 的 Fluxon 1.719/2.499s 反而略快于 Mooncake 的
+1.728/2.524s。GPU0 同时承载 Fluxon master、owner、router 和 workload,因此还需把
+restore 数据面耗时与 master/owner CPU 竞争分开测量;在证据出来前,不应通过扩大 owner
+容量或改变副本语义掩盖该不对称。
+
+E16y 对该不对称做了 NUMA 定向实验。两台 GPU 都属于 NUMA node1,但 E16x 的 GPU0/GPU1
+owner backing 分别有 128.14/97.40 GiB 落在远端 NUMA0。把 owner 与 SGLang 绑定到
+NUMA1、把 master/router/workload 绑定到 NUMA0 后,两台 owner 都有约 128.25 GiB 落在
+NUMA1;备份量、命中率和 fallback 数保持相同,QPS 从 6.7915 提高到 6.8520,与 Mooncake
+只差 0.97%。因此大块 registered host backing 的 first-touch NUMA 必须成为部署契约,而
+不能依赖进程启动瞬间的随机 CPU。
+
+但 E16y 不是最终绑核方案:GPU1 layerwise completion 降低 5.20%,GPU0 却没有改善,且
+两端 `get_start` p50/p90 因 owner 与 SGLang 共用受限 CPU 集而上升。正确的后续拆分是只让
+owner 在 GPU-local NUMA 完成大块 backing first-touch,SGLang/master/router/workload 保持
+正常调度;若需要长期绑核,再为 owner data plane 与 SGLang scheduler 分配不重叠的 core
+集合。不能把“NUMA-local memory”与“所有线程绑在同一个 NUMA CPU 集”视为同一要求。
+
+E16z 完成该拆分后,在备份量和 fallback 数不变的条件下达到 6.9007 QPS,比 E16x 提高
+1.61%,与 Mooncake 只差 0.27%;完整 1152 请求 wall 仅差 0.45s。由此 owner backing 的
+GPU-local first-touch 应固化为部署步骤,而 SGLang 不应与 owner 共用同一受限 affinity。
+steady-state owner 也不一定要永久绑核:E16z 的 `get_start` p50 仍比 unrestricted E16x 高
+约 0.5--0.6 ms。更精确的生命周期是 owner 在 segment/backing 与 expected-capacity reserve
+建立期间绑定 GPU-local NUMA,完成后把全部已有线程 affinity 放宽;first-touch 页面会保持
+原 NUMA placement,控制/RPC 线程则可以重新使用其它 CPU 和 NIC-local core。
+
+E16aa 对这一两阶段生命周期做了正式 A/B。放宽后 `get_start` p50 确实从 E16z 的
+2.55/2.60 ms 降到 2.15/2.29 ms,页面仍驻留 NUMA1;但 QPS 为 6.8902,比 E16z 低
+0.15%,没有形成端到端正收益。考虑实现和运维复杂度,当前建议采用 E16z 的简单规则:
+GPU owner 整个生命周期绑定 GPU-local NUMA CPU 集,SGLang/master/router/workload 不绑核。
+E16z 6.9007 QPS 与 Mooncake 6.9192 只差 0.27%;若后续追求稳定的亚百分比收益,应先
+增加独立冷轮重复次数和 per-request CUDA/CPU tracing,而不是继续叠加 affinity 状态切换。
+
+E16ab 随后按 E16z 配置做了独立全冷栈复测。两个 GPU owner 启动后分别有
+128.151/128.143 GiB 页面位于 NUMA1,容量、副本开关、请求序列和 active source 均未变化。
+本轮 1152/1152 成功、严格 576/576,得到 6.8812 QPS、92.72% 总命中;它比 E16z 低
+0.28%,比 Mooncake 单轮低 0.55%。两次 E16z 形态冷轮均值为 6.8909 QPS,比 Mooncake
+低 0.41%。因此原先 0.27% 差值确实落在单轮波动量级,但不能据此宣称 Fluxon 已超过
+Mooncake。两轮的 `get_start`、`init_load_back`、radix eviction、restored-node 数和
+layerwise completion 高度复现,未出现新的系统瓶颈或错误。
+
+“Fluxon 有额外优化所以理应直接超过 Mooncake”并不是有效的性能推导。Put atomic group 和
+缺失 parent 补写首先是正确性/可恢复性契约;InlineLocal、batch local-visible、deferred
+`release_views` 和 layerwise kernel 则用于抵消 external owner/hostless 路径相对进程内 L2
+的 RPC、holder 和恢复组织成本。Mooncake 对齐轮有 70.55% tokens 命中进程内本地 L2,并把
+512 GiB 五个 segment 全部作为 primary capacity,285.3 GB 数据没有发生容量驱逐;E16z/E16ab
+约 62% tokens 经过 external owner,只使用两个 128 GiB GPU owner 保存 primary,256 GiB
+CPU owner 为空。因此现有优化是在不同路径上把端到端拉平,并不是在 Mooncake 相同基线上的
+额外加速项。
+
+能够形成 Fluxon 独有容量收益的下一闭环仍是“完整 CPU 副本 -> GPU 优先驱逐该完整 group ->
+CPU 成为可恢复剩余副本”。Put `atomic_group_lens` 目前只保证准入不主动切组;replica actor
+仍逐 key append,owner/Moka 也没有完整 group 的 remote-complete 状态可用于候选排序。只有
+补齐远端整组完成记账、版本失效和 recoverable-first eviction 后,CPU 256 GiB 才会从异步
+复制开销转化为 GPU eviction 后的额外命中。这比继续压缩已经只有约 2--3 ms 的 `get_start`
+或 0.7--0.8 ms 的 radix eviction 更可能带来稳定超过 Mooncake 的收益。
+
+E16ad 进一步纠正了“配置容量”和“实际参与容量”的表述。E16z/E16ab 虽然启动了
+128/128/256 GiB 三台 owner,但 `replica_task.enabled=false`;SGLang
+`external_batch_put_start` 又直接使用 requester-local reserve,因此只有两个 GPU owner 保存
+primary,CPU owner entries 为 0。CPU 的 256 GiB 在这两轮只是注册容量,不能计作实际数据
+容量。Mooncake 对齐轮则确实把五个 segment 都作为 primary 使用,两者的容量利用语义并不
+等价。
+
+E16ad 在相同物理容量和 NUMA 配置上启用 CPU-only `kv_score_only` replica,master 严格限制
+remote target role 为 `remote_cache`,GPU 间仍无副本。由于当前 PyO3 契约令
+`write_through || !make_replica_task` 都跳过 replica,本轮 HiCache 使用 write-back。最终
+GPU0/GPU1/CPU 分别保存 121.585/121.584/142.938 GiB,CPU 有 18,296 entries,master
+记录 18,336 个 CPU target 和 0 个 GPU target,证明三台 owner 均实际进入数据面。
+
+但该轮只有 6.8124 QPS、92.69% 总命中;相对 no-replica 冷复测 E16ab,QPS 低 1.00%,
+命中低 0.03pp,同时产生约 0.517 Gbps 聚合 RDMA TX。CPU 没占满 256 GiB 是 kv-score 跳过
+低分 group 的预期结果,更关键的是这 142.938 GiB 仍为重复副本。当前 GPU capacity eviction
+没有利用“完整 CPU group 已完成”的状态选择候选,所以 CPU 写入没有转化为更多可消费命中。
+
+这里必须区分两个后续实现方向:
+
+- 若维持“CPU 只存副本”,需要补齐完整组 remote append 完成、版本失效和
+ recoverable-first GPU eviction;GPU 本地 copy 被驱逐后,CPU copy 才转化为有效扩容。
+- 若要求像 Mooncake 一样把 128+128+256 GiB 全部作为独立 primary pool,需要实现
+ requester-local first、local 压力后 spill 到 CPU 的 remote-primary batch put。当前
+ external batch commit 显式拒绝 remote primary,修改 `prefer_local_placement` 不能得到
+ 该语义。
+
+因此“三 owner 都有占用”已经由 E16ad 验证;“512 GiB 都是独立有效容量”尚未成立,不能把
+副本字节与 primary 字节直接相加。
+
+### 低分 KV 提前 CPU write-back 策略
+
+SGLang Fluxon HiCache 新增显式准入策略 `kv_score_low_only`。它保留 GPU requester-local
+owner 上的 primary,同时为低分 KV atomic group 提前创建 replica task;三机 CPU-only
+拓扑由 master 的 `remote_cache` target role 约束副本只落到 CPU owner。策略本身只决定
+replica task 准入,不改变 target role、owner 容量或 GPU 间副本规则。
+
+配置示例:
+
+```json
+{
+ "replica_task": {
+ "enabled": true,
+ "admission": {
+ "policy": "kv_score_low_only",
+ "score_threshold": 0.55,
+ "min_replica_pages": 0,
+ "max_replica_pages_per_batch": 512
+ }
+ }
+}
+```
+
+该策略使用包含边界的 `score <= score_threshold`。Put 仍按 `atomic_group_lens` 作整体决策,
+同一 group 的 mask 全 true 或全 false。group priority 使用 `1 - mean(group scores)`;因此触发
+`min_replica_pages` 补选或 `max_replica_pages_per_batch` 裁剪时,优先选择最低分 group,且不会
+为了填满页数预算切开 group。原 `kv_score_only` 保持 `score >= score_threshold` 和高分优先
+语义不变。
+
+物理 storage key 继续区分 TP rank,准入 identity 则保留 TP size 并去掉 TP rank;同一逻辑
+group 在所有 TP ranks 上得到一致的低分准入结果。该实现属于 proactive CPU write-back
+admission:CPU append 成功前 GPU primary 不会提前释放,成功后当前 eviction 也尚未按
+remote-complete group 排序。完整的“低分提前写回 -> GPU `evict some` 优先回收已完整备份组
+-> CPU 副本承担后续恢复”闭环仍需 remote-complete 记账、版本失效和 recoverable-first
+eviction。
+
+E16ae 已用与 E16ad 相同的 128/128/256 GiB、双 TP=2、owner-only NUMA1 和 1152 请求配置
+完成低分策略正式压力。1152/1152 成功,QPS 为 6.8073,总命中 92.72%;E16ad 高分策略为
+6.8124 QPS、92.69% 命中,差异分别为 -0.075% 和 +0.03pp。低分策略把 CPU retained usage
+从 142.938 GiB 提高到 243.188 GiB,历史 CPU targets 从 18,336 提高到 42,214,聚合 RDMA
+TX 从 0.517 提高到 1.189 Gbps,但没有得到可测的吞吐或命中收益。两 GPU 仍各保存约
+121.570 GiB primary,GPU replica target 为 0;CPU cache 已达到 95% effective capacity。
+这进一步证明 admission 选择本身不能替代 recoverable-first eviction:额外 CPU 副本在当前
+消费路径上仍主要体现为重复容量和写流量。
+
+Mooncake 对齐轮也必须按相同抽象层解释。SGLang 启动参数是
+`--hicache-write-policy write_back`,安装版 Mooncake `ReplicateConfig()` 默认
+`replica_num=1`。Mooncake master 管理 120,928 keys,每个对象 2,359,296 bytes;乘积
+285,304,946,688 bytes 与 master allocated bytes、五个 segment allocated bytes 之和完全
+相等。这证明 global store 中每个对象只有一份,五个 segment 共同承担单份对象的 placement。
+它不是“GPU segment 保留 primary,再 write-through 一份 CPU replica”。
+
+因此更严格的路径映射是:Mooncake 的 SGLang device/L2/L3 采用 write-back demotion,global
+store 的 512 GiB 提供单份对象容量;Fluxon E16ad 则在 GPU owner primary 仍存在时异步写
+CPU replica。二者都可能在层间迁移瞬间短暂同时持有数据,但稳态容量语义不同。若目标是复刻
+Mooncake 同时发挥 Fluxon 的统一管理优势,Fluxon 更合适的实现是 local owner 到 CPU owner
+的 single-copy demotion/spill:CPU commit 成功后删除 local copy,而不是长期保留两份。
+recoverable-first replica eviction 可以作为现有协议的过渡实现,但验收最终应统计单份逻辑
+字节、物理总字节和迁移后 source 删除,不能只统计 CPU entries 非零。
+
+### Master 两级 Moka 提前写回
+
+Master 新增可选的包含式两级 Moka,用 master Moka 的实际容量淘汰序列代替 Put 时的 score
+预测。这里的 T1/T2 是
+Fluxon master 的 owner route 元数据层级,不是 SGLang HiCache 的 GPU L1 / host L2:
+
+| 层级 | 容量 | 淘汰动作 | 是否释放 owner 数据 |
+| --- | --- | --- | --- |
+| T1 hot tier | `replica_writeback_tier1_capacity_ratio × owner segment` | 批量通知源 owner 发起 remote-only replica task | 否 |
+| T2 resident tier | `replica_cache_capacity_ratio × owner segment` | 进入现有 fenced owner reclaim | 是 |
+
+T1 是 T2 的热数据子集。新 route 同时进入 T2 和 T1,因此启用 T1 不会从 T2 切走容量,也不会
+改变 owner 的 `max_capacity`。经 master get 路径观察到的 T2 命中会把对应 key 重新提升到
+T1。T1 因容量发生 `Size` 淘汰时,master 按源 owner 合并请求,再通过内部
+`BatchEnqueueReplicaTaskReq` 通知 owner。
+Owner 继续复用已有的 `put_append_start -> RDMA transfer -> put_append_done` replica actor;目标仍
+由 `replica_task_placement` 选择。启用两级写回时,配置校验要求
+`restrict_to_remote_only_node_roles=true`,防止写回落到另一个 GPU owner。
+
+```mermaid
+sequenceDiagram
+ participant M as Fluxon master T1/T2
+ participant G as GPU owner
+ participant C as remote CPU owner
+
+ G->>M: publish local route
+ M->>M: insert T2 resident + T1 hot
+ M->>M: T1 Size eviction
+ M->>G: BatchEnqueueReplicaTaskReq(key, put_id)
+ G->>M: put_append_start
+ M-->>G: reserve CPU target
+ G->>C: RDMA transfer
+ G->>M: put_append_done
+ M->>M: append CPU replica route
+ Note over M,G: GPU owner source remains in T2
+ M->>G: later T2 fenced reclaim
+```
+
+Master 配置示例:
+
+```yaml
+replica_task_placement:
+ remote_only_node_roles: ["remote_cache"]
+ restrict_to_remote_only_node_roles: true
+replica_cache_capacity_ratio: 0.95
+replica_writeback_tier1_capacity_ratio: 0.75
+```
+
+`replica_writeback_tier1_capacity_ratio` 必须大于 0,且严格小于
+`replica_cache_capacity_ratio`。不配置该字段时保留原单层 resident Moka 行为。以
+128 GiB owner、T2 `0.95`、T1 `0.75` 为例,owner resident 上限仍约为 121.6 GiB;约 25.6
+GiB 的 T1/T2 容量差提供 replica 完成窗口,而不是把 owner 容量降到 96 GiB。
+Master 的周期日志同时报告 `writeback_tier1_triggered`、
+`writeback_tier1_owner_accepted` 和 `writeback_tier1_failed`。其中 accepted 只表示源 owner
+已把任务放入 replica actor,CPU route 是否完成仍以 `replica_task_target_counts` 和
+`put_append_done appended=true` 为准。
+
+当前实现仍是过渡策略,边界如下:
+
+- T1 淘汰只启动逐 key remote replica,尚未记录 SGLang radix atomic group,也没有整组完成
+ 状态。
+- Owner `InlineLocal` fast path 当前不经过 master get,因此这类本地命中不会刷新 T1;当前顺序
+ 是 master 可观察的 insert/get 淘汰序列,不是所有 SGLang 访问的全局 LRU。
+- CPU replica 完成后不会立即删除 GPU owner source;source 只有在后续 T2 reclaim 时释放。
+- 该策略能让写回时机跟随 master 实际热度淘汰,并为异步复制提供有界提前量;它尚未实现
+ Mooncake 式 single-copy demotion。
+- 运行该策略时,SGLang Put 侧应关闭 `kv_score_only` / `kv_score_low_only` eager admission,
+ 避免同一 key 同时由 Put admission 和 T1 淘汰重复请求副本。
+
+#### E16ag 三机验收结果
+
+首轮 E16af 虽然成功解析 T1/T2 比例,但把 source eligibility 错误绑定到
+`prefill/decode` active-client role。实际 GPU storage owner 的 role 是 `sglang_owner`,因此
+两台 GPU 都没有构造 T1。E16ag 把资格修正为“已注册且非 `remote_cache` 的 owner”,从而
+允许 GPU storage owner 作为写回源,同时继续排除纯 CPU owner。
+
+E16ag 在固定 GPU0/GPU1/CPU = 128/128/256 GiB、双 TP=2、96 sessions × 12 turns、
+concurrency 16 的正式三机压力中得到 1152/1152 成功、6.8151 QPS、92.74% 总命中。两台
+GPU 的 resident weighted bytes 均为 121.570 GiB,T2 上限仍为 121.600 GiB;T1 均为
+95.977 GiB,上限为 96 GiB。因此包含式 T1 没有改变 owner 配置容量,也没有把 T2 降到
+T1 容量。
+
+CPU 最终持有 13,061 entries / 102.039 GiB,历史完成 13,090 个 replica targets,目标全部是
+`remote_cache` CPU,GPU target 为 0。流水线计数满足:
+
+```text
+T1 triggered 16525
+ = pre-dispatch stale 226
+ + owner source-missing/version-mismatch 1209
+ + owner accepted 15090
+
+owner accepted 15090
+ = target KeyBeingWritten 2000
+ + CPU replica completed 13090
+```
+
+这说明策略已经生效,版本检查也正确阻止了过期 source 被复制;同时暴露了两个明确窗口。
+第一,T1 淘汰事件入队后,source 可能先被 T2 回收,现有 holder 获取发生得太晚。第二,owner
+接单只表示任务进入 replica actor,目标侧相同 key 仍可能处于写入中。下一版应在 T1 eviction
+时为相同 `put_id` 安装短期 source pin,复制完成/失败后释放,并对 `KeyBeingWritten` 做有界、
+版本安全的重试。观测上应把 pre-dispatch stale、source missing 和 busy key 分开计数,不能只
+用 `writeback_tier1_failed` 聚合。
+
+性能上,E16ag 相对无 T1 的 E16af 提高 0.75%,但相对 no-replica E16ab 低 0.96%,相对
+Mooncake 低 1.50%。CPU 副本物理存在不等于形成额外可消费命中:当前 source 在 T2 前仍保留,
+CPU 字节主要是暂时重复容量;逐 key 成功也不能证明完整 radix group 可恢复。因此 source pin /
+busy-key retry 解决复制完成率之后,仍需记录 atomic group remote-complete,并让 T2
+recoverable-first eviction 优先选择已有完整 CPU 副本的 group,才能把 CPU 容量稳定转化为
+命中收益。
+
+### Owner-local hot Moka 提前写回
+
+当前实现把提前写回的热度观察点下沉到 storage owner。每个 GPU owner 维护一个逐 key 的
+owner-local hot Moka;本地 committed value 进入该 tier,`InlineLocal` 等 owner 本地命中会刷新
+热度。hot tier 发生 `RemovalCause::Size` 时,callback 只把 key/put_id/Weak/weight 交给
+lossless metadata channel;离开 Moka housekeeper 后,异步 dispatcher 才逐 key point-check、
+pin 当前 backing、展开 cohort,并交给 replica actor 写入 remote CPU owner。Master 仍负责
+remote route 成功后的 cohort demotion/reclaim 仲裁。
+
+```mermaid
+sequenceDiagram
+ participant S as SGLang / external client
+ participant G as GPU storage owner
+ participant H as owner-local hot Moka
+ participant M as Fluxon master T2
+ participant C as remote CPU owner
+
+ S->>G: local commit(key, put_id)
+ G->>H: insert weak entry(key, put_id, slot_size)
+ S->>G: InlineLocal hit
+ G->>H: touch current entry
+ H->>G: Size eviction(key, put_id)
+ G->>G: async dispatcher: DashMap point-check + Arc pin
+ G->>M: put_append_start(key, put_id)
+ M-->>G: reserve remote-only target
+ G->>C: RDMA transfer
+ G->>M: put_append_done
+ M->>M: append CPU replica route
+ alt owner-hot demotion intent 且完整 TP cohort 可从同一 CPU owner 恢复
+ M->>M: Size-evict exact source cohort
+ M->>G: fenced Prepare / Commit / Finalize
+ G-->>M: release current local backing
+ else append/版本/整组检查失败或 CPU NoSpace
+ M-->>G: retain GPU source
+ end
+ G->>G: release pin and inflight dedup guard
+```
+
+Owner 配置只增加一个 canonical 参数:
+
+```yaml
+replica_writeback_hot_capacity_ratio: 0.75
+```
+
+该比例必须有限且位于 `(0, 1)`,并且只能出现在非零容量 owner 配置中。hot Moka 的
+`max_capacity` 等于该比例乘 owner DRAM;128 GiB owner 配置 `0.75` 时,逻辑 hot 水位为
+96 GiB。这个计算不修改 `contribute_to_cluster_pool_size.dram`、local-reserve grant 数量、owner
+segment 或 master T2 的 `replica_cache_capacity_ratio`。Moka value 只保存
+`Weak`;所有 hot entries 因而不会整体 pin 住 owner backing。计费优先使用
+local-reserve 的实际 `slot_size`,普通 allocation 使用 payload length。
+
+CPU-only 目标仍由 master 的 placement 契约提供:
+
+```yaml
+replica_task_placement:
+ remote_only_node_roles: ["remote_cache"]
+ restrict_to_remote_only_node_roles: true
+replica_cache_capacity_ratio: 0.95
+# replica_writeback_tier1_capacity_ratio 不配置
+```
+
+第一版实验必须关闭 master T1 和 SGLang Put 侧的 eager replica admission,避免同一个 key 有
+多个独立提前写回触发器。owner hot tier 只决定写回时机;如果 master 没有限定
+`remote_only_node_roles`,目标仍可能由普通 placement 选到其它 owner。
+
+并发与失败处理遵守下面的不变量:
+
+| 场景 | 当前处理 |
+| --- | --- |
+| hot eviction 与 T2 reclaim 竞争 | owner-hot pin 不取全局 `owner_key_control`。dispatcher 在 DashMap shard read guard 内校验 current identity 并 clone `Arc`;pin 先发生时 reclaim remove 后的唯一强引用检查返回 Busy,Prepare 先发生时 dispatcher 看到 index 缺失且 live Weak,按 `RetryableReclaimFence` 退避,绝不从 Prepared 升级 Weak。 |
+| 版本替换、delete、显式失效 | eviction listener 只消费 `RemovalCause::Size`。`Replaced` 和 `Explicit` 不创建 replica;reclaim/delete 按 `put_id` 条件失效 hot entry,不能删除随后安装的新版本。 |
+| 旧事件与重复事件 | listener 携带 eviction entry 的 `put_id + Weak identity`;dispatcher 以当前 owner index 再校验。`(key, put_id)` guard 覆盖排队、transfer 和 append-done 全周期。 |
+| 控制面阻塞 | Moka callback 只向 lossless 轻量 metadata channel 发送事件,不点查 index、不展开 cohort、不 pin、不等待 RPC/RDMA。dispatcher 完成 point-pin 后再进入容量 128 的 replica task queue;RetryableCold 按 owner committed identity 去重并退避。 |
+| append start busy key | owner-hot 任务遇到 `KeyBeingWritten` 时最多尝试 3 次,重试间隔为 25/50 ms。其它错误不做这一层重试;最终失败释放 pin,保留 GPU primary,不会把失败任务解释为 CPU replica 已完成。 |
+| 可观察性 | owner 每 30 秒记录 hot capacity/weighted bytes、Size eviction、enqueued/completed/already-satisfied/failed/obsolete、dispatch-failed、inflight 以及 stale/reclaim/duplicate skip 计数。CPU 数据面完成仍以 `put_append_done appended=true` 和 master target 计数为准。 |
+
+E16ah 的第一版实现修复了 master T1 的两个直接缺口:source 在 eviction callback 内完成 pin,
+且 owner `InlineLocal` 命中能参与热度更新;当时仍是逐 key proactive replica write-back,CPU
+append 成功后不会直接删除 GPU source。后续版本把 Put atomic group 存入 route,并让 owner
+在任一 group member 被 hot Moka 淘汰时 pin、排队完整 group。Master 进一步按 canonical
+`__` 后缀验证所有 TP rank 的逻辑 group 边界及同一 CPU owner 上的完整副本。
+
+E16ap 在该完整性信息之上增加 single-copy demotion。只有 owner-hot 任务的内部 append 请求
+携带 demotion intent;普通 eager replica 不携带。append 成功或同版本 CPU route 已存在时,
+Master 再次验证 source 版本、atomic group、TP cohort 和共同 `remote_cache` owner,然后以
+`RemovalCause::Size` 从 resident Moka 选择该 exact cohort,复用既有两阶段 fenced reclaim。
+检查不通过、CPU NoSpace、append 失败或版本变化时保留 GPU source。该路径不新增用户侧副本
+开关,也不修改 owner segment、local-reserve grant 或 `contribute_to_cluster_pool_size.dram`。
+
+#### E16ah 三机验收结果
+
+E16ah 在固定 GPU0/GPU1/CPU = 128/128/256 GiB、96 sessions × 12 turns、concurrency 16
+的三机正式压力中得到 1152/1152 成功、6.8174 QPS 和 92.96% 总命中。两个 GPU owner 的
+T2 resident 均为 121.570 GiB,上限仍为 121.600 GiB;两个 owner-local hot Moka 上限均为
+96 GiB。CPU 终态持有 19,538 entries / 152.641 GiB。Master T1 容量为 0,SGLang eager
+replica 关闭,19,658 个历史 replica target 全部指向 CPU `remote_cache` owner。
+
+两个 GPU 的 `pending_eviction_reclaim_bytes` 终态分别为 4.500/4.406 GiB,且 workload 结束
+超过 15 分钟后仍未下降;E16ab 为 0/0,E16ag 为 0.125/0 GiB。它不改变 128 GiB owner
+配置或 121.6 GiB resident T2 上限,但代表已从 Moka 淘汰、尚未完成 owner reclaim 的权重,
+会消耗 segment headroom。高峰期 owner 拒绝原因为 active holder,终态 hot inflight 已归零,
+后续 retry 却没有再到达 owner phase。当前观测还不能区分 master key-activity fence 泄漏和
+reclaim retry 停滞,因此 local-side 物理回收尚未完全闭环。
+
+终态流水线满足:
+
+```text
+44626 Size eviction = 43762 enqueued + 864 duplicate
+43762 enqueued = 19658 completed + 24103 already-satisfied + 1 failed
+19658 completed = 19658 master CPU replica targets
+```
+
+stale、reclaim、obsolete、dispatch-failed 和 inflight 均为 0。相较 E16ag,dispatch 前 stale
+与 source-gone/version-mismatch 从 226/1,209 降为 0,最终 `KeyBeingWritten` 丢失从 2,000
+降为 1;这验证了 owner callback 内立即 pin 和有界 busy retry。唯一失败是一个 key 在三次
+append-start 尝试后仍处于 `KeyBeingWritten`;transfer 和 append-done failure 均为 0。
+
+性能上,E16ah 相对 E16ag QPS 只提高 0.034%,相对 no-replica E16ab 低 0.93%,相对
+Mooncake 低 1.47%。总命中相对 E16ag 提高 0.218 pp,与 Mooncake 只差 0.089 pp,但单轮变化
+不能全部归因于 CPU;当前 get 指标还没有按 source owner 拆分。owner-local hot Moka 已修复
+副本触发和完成窗口,仍需 atomic-group remote-complete 与 recoverable-first T2 eviction 才能
+把包含式 CPU 副本稳定转成独立有效容量和吞吐收益。
+
+在启用 group-aware 淘汰前,应先给 pending reclaim 增加 master-activity、owner-holder、
+route-changed、retry-queued 和 retry-completed 分原因计数,并保证 source pin 释放后请求最终
+reclaim 或安全回插。否则 logical T2 命中和 CPU replica 完成率提高时,物理 segment headroom
+仍可能被未终结的 reclaim 占用。
+
+#### E16ai Local-side reclaim 闭环验收
+
+E16ai 在不修改 owner 容量的前提下完成了上述 pending 闭环。修复包含三个关键生命周期约束:
+
+- completed `get_holding` 只是 holder backing 的观测状态,不再作为 master 侧 reclaim 的
+ blanket veto;真实读者由 owner Prepare 对 `MemoryInfo` 强引用进行权威检查;
+- put/get/replica activity lease 在 done、revoke、TTL 和响应发送失败等终态显式幂等释放,
+ `Drop` 只作兜底,Moka retired clone 不再延迟 activity 计数;
+- `ReuseReplica` get done 只有在相同版本、相同 Allocation identity 下才把 master route 标成
+ `owner_local_indexed`,确保后续进入 owner 两阶段 reclaim,而不是错误的 master-only 删除。
+
+Capacity eviction 对 Busy 请求最多执行 8 次有界指数退避;达到上限后,只在 route 仍是同一
+`put_id` 时把当前条目安全回插 Moka。queue 关闭路径也先结束 pending weight,再按同样的
+current-version 条件回插。pending weight 使用 checked subtraction,运行时分别暴露
+master-activity、master-holder-observed、owner-holder、owner-other、route-changed、retry
+queued/completed/restored 和 reclaim-completed 计数。
+
+固定 GPU0/GPU1/CPU = 128/128/256 GiB、GPU T2 ratio 0.95、hot ratio 0.75、96 sessions ×
+12 turns、concurrency 16 的三机正式轮中,两个 GPU pending 的最大 30 秒快照为
+1.031/1.477 GiB;workload 结束后的首个快照均为 0,并在超过 12 分钟的静默观察中保持 0。
+GPU T2 resident 仍为 121.570/121.570 GiB,上限仍为 121.600/121.600 GiB。E16ah 长期残留的
+4.500/4.406 GiB 以及两个 GPU 各 12 次 local get-target NoSpace 均消失。
+
+```text
+reclaim counter GPU0 GPU1 total
+master activity deferred 10 7 17
+completed holder observed 5939 5707 11646
+owner real-holder deferred 5092 4978 10070
+route changed / terminal removal 3149 2996 6145
+retry queued 5007 4583 9590
+retry completed 965 868 1833
+bounded-retry restored 95 402 497
+reclaim completed 3149 2996 6145
+terminal pending bytes 0 0 0
+```
+
+这些计数是 attempt 计数,不构成逐列相加的 unique-key 等式;唯一终态要求是 pending 请求
+最终 route removal/change 或安全回插,且 pending bytes 归零。本轮没有 pending underflow、
+safe reclaim queue full/closed 或 retry queue closed。静默期 master 仍有 71 个 completed
+`get_holding` / 335,020,032 bytes;它们是 client 仍持有的真实 backing,不是 activity lease,
+也不再阻塞其它无真实 reader 的 owner reclaim。
+
+正式结果为 1152/1152 成功、6.7585 QPS、92.9691% 总命中。CPU 保留 20,717 entries /
+161.852 GiB,20,773 个历史副本目标全部指向 CPU;owner-hot 对账严格满足:
+
+```text
+46576 Size eviction = 45204 enqueued + 35 reclaim-race skip + 1337 duplicate
+45204 enqueued = 20773 completed + 24431 already-satisfied
+20773 completed = 20773 master CPU replica targets
+```
+
+E16ai 相对 E16ah QPS 低 0.865%,总命中只高 0.008 pp;因此 reclaim 闭环解决了正确性和
+物理 headroom,但没有把包含式 CPU 副本转成性能收益。atomic-group remote-complete 与
+recoverable-first T2 eviction(或 single-copy demotion)仍是下一层容量策略,不能回退上述
+activity、holder、版本和 slot identity 安全约束。
+
+#### E16ao 手动聚合回收与 E16ap 完整组降级
+
+E16ao 修复了 resident Moka 八个 segment 各自执行硬容量淘汰的问题。resident Moka 只作为
+无界 metadata/LRU index,Master 按 owner 的 0.95 聚合有效容量显式调用 `evict_some_if`;存在
+ready CPU tier 时,GPU ordinary fallback 为 0,只允许完整 TP cohort 且已有 CPU 副本的条目
+进入两阶段 reclaim。固定三机压力得到 1152/1152 成功、6.6634 QPS、92.58% 总命中和 1,240
+次 CPU-source Get。两个 GPU 的 pending reclaim 终态为 0,recoverable selected 合计约
+34.6 GB,CPU 却保留约 200.9 GB;测量窗口只从 CPU 读取 5.85 GB。该结果证明 group、TP
+cohort、CPU Get 和物理 reclaim 均已生效,同时表明大部分 CPU 字节仍是包含式重复副本。
+
+E16ap 把 owner-hot write-back 的成功终态收敛为 exact-cohort demotion,内部状态流如下:
+
+| 阶段 | 必须满足的条件 | 失败行为 |
+| --- | --- | --- |
+| hot 触发 | Moka `RemovalCause::Size`、当前 `put_id` 和 `MemoryInfo` identity 匹配,完整 owner atomic group 可 pin | 不创建任务,GPU source 保持当前状态 |
+| remote append | target 被 `remote_only_node_roles=["remote_cache"]` 限定,append 发布同版本 CPU route | NoSpace/transfer/append 失败释放 pin,不请求 source demotion |
+| cohort 验证 | 每个 atomic-group member 当前、每个 TP rank 边界一致,所有成员在同一个 CPU owner 上 live | 本次只计 attempt,不选择任何 GPU resident entry |
+| exact selection | resident descriptor 仍是预期版本且当前 route 仍可恢复 | 跳过变化条目,不能删除随后写入的新版本 |
+| 物理回收 | Moka Size listener 安装 master activity fence,再执行 owner Prepare/Commit/Finalize | Busy 有界重试;耗尽后仅把仍为当前版本的条目安全回插 |
+
+E16ap 压测把 owner hot ratio 设为 0.95,与 master resident ratio 对齐。128 GiB GPU owner 的
+逻辑触发点为 121.6 GiB;这只改变冷数据开始异步迁移的时机,不改变 128 GiB 物理 segment。
+CPU 未满时,完整冷 cohort 写入 CPU 后释放 GPU copy;CPU 无空间时,任务不降级,GPU 仍可
+使用剩余物理 headroom。Master 每 30 秒额外报告 `owner_hot_demotion_attempts`、
+`owner_hot_demotion_cohorts` 和 `owner_hot_demotion_selected_bytes`,用于区分“复制完成”与
+“已经转化为独立二级容量”。正式三机结果需要同时满足非零 demotion、CPU-source Get、两侧
+pending 归零和 QPS 高于双 GPU no-replica 基线,不能只用 CPU retained bytes 判定策略有效。
+
+两 Fluxon 模拟压测的完整记录保存在本工作区的 `remote_cpu.md`。当前一次完整压力运行得到:
+
+```text
+Moka eviction callbacks: 11463
+safe reclaim reclaimed: 11452
+safe reclaim restored: 11
+processed eviction weight: 96158613504 bytes
+```
+
+`reclaimed + restored` 与 callback 数严格相等;11 个 Busy/未回收条目按当前版本重新插回 Moka。8 个均匀抽样 key 的 payload 长度和首尾字节均校验通过,其中 3 个已确认在 A local backing 被驱逐后通过 B -> A P2P 读取恢复。该次日志中 safe reclaim queue full/closed、NoSpace 返回调用方、panic、OOM 和 batch failure 均为 0。
+
+这次稳态压力运行由按真实 slot 权重的常规 Moka capacity eviction 提前释放内存,因此没有实际触发“申请不到 `512 MiB` grant 后调用 `evict_some`”的 NoSpace 兜底。`evict_some` 另有定向 Moka 契约测试,覆盖请求驱逐量大于/小于现有占用、Busy 条目回插后的 LRU 顺序,并断言调用前后 `max_capacity` 不变。Master activity fence 和 Moka 路径已有单元测试;Owner Prepare/Commit/Abort/Finalize 的主要证据目前来自端到端压测,后续仍应补充直接的并发与故障注入单元测试。
+
+#### E16au--E16ax 包含式写回与 local IPC 结论
+
+E16au 将 owner-hot 的成功终态改为包含式 CPU write-through:CPU route 发布成功后不立即
+demote GPU source,GPU backing 只由独立的 95% resident 压力回收。固定
+GPU0/GPU1/CPU = 128/128/256 GiB 的正式压力得到 1152/1152、6.7446 QPS、92.90% 总命中;
+CPU 保留 8,793 entries / 73.761 GB,CPU-source Get 为 4,349 / 20.521 GB,owner-hot
+demotion 与终态 pending reclaim 均为 0。包含式语义消除了主动降级争用,但仍未超过双 GPU
+baseline 或 Mooncake;提前 CPU copy 大多只是已有 GPU source 的替代恢复源。
+
+这轮有 255,972 条 iceoryx2 `FailedToDeliverSignal`。开放层调低日志等级无法控制闭源
+`libfluxon_commu_core.so` 内静态链接的独立 log-level 符号;完全关闭 local IPC 又在同机
+direct P2P 路径触发 608、`KeyBeingWritten` 终态失败和 Prefill OOM。E16ax 只把
+owner/client receiver 改为 WaitSet 后虽完成 1152/1152,QPS 降至 6.6932;warning 仍有
+232,723 条,且 96.85% 移入 SGLang 关键进程。故 local IPC 必须保留,WaitSet 和完全禁用
+两条方案均不能作为当前修复。
+
+#### E16ay Get-target 压力与 local reserve 物理容量闭环
+
+> 本节记录 E16ay 当时的历史实现,不是当前容量规约。后续修复已经把 configured
+> `expected_grant_count` 改为硬上限,并删除 route 全表扫描;当前行为以本章开头规约表和
+> “Local-side 主动驱逐与安全回收”章节为准。
+
+E16ay 当时 local reserve 的 expected capacity 是下限而非硬上限。Put 并发需求会使它从配置的
+grant 数继续扩张;后续 slot 回收只让 grant 内部变空,并不会自动把这段 512 MiB 物理
+allocation 归还 master segment allocator。原 shrink actor 还只检查队尾 grant,并要求连续全空
+5 秒。
+另一方面,CPU -> GPU remote Get 的 target 仍是 master 直接从请求 owner 的普通 allocator
+分配。因此系统可能同时出现:
+
+- master Get target 报 `NoSpace/free_capacity=0`;
+- owner reserve 内存在大量 free slots;
+- `pending_eviction_reclaim_bytes=0`,说明这不是 safe reclaim backlog。
+
+E16ax 的实测即为 GPU0/GPU1 18/9 次 Get-target `NoSpace`,而 reserve 最终仍有 1,085/989 个
+空闲 4,718,592-byte slot。E16au 精确重扫也有 10/26 次。该错误会在 atomic-group prefix
+计算前截断可传输前缀,使已经存在的 CPU route 不能转成额外命中。
+
+修复契约如下:
+
+| 阶段 | 规则 |
+| --- | --- |
+| 首次 batch get-start | 保留每个 item 的独立结果,只收集 `API_NO_SPACE` 索引;成功项、KeyNotFound 和其它错误不重发。 |
+| 物理让位 | owner 可同步摘除任意完全空闲的多余 grant,不受队尾和 idle cooldown 限制;必须先收到 `release_local_grant` 完成,再重试 Get。 |
+| 容量下限 | 归还后 grant 数不得低于 `max(expected_grants, ceil((used_slots + pending_slot_demand) / slots_per_grant))`;Prepared、PendingLocalVisible、Committed 或仍有 holder 的 slot 都不能释放。 |
+| 有界重试 | 每轮只替换仍为 NoSpace 的 item,最多 8 轮;不能安全归还 grant 时保留原错误和 prefix fallback。 |
+| 容量语义 | 不修改 `contribute_to_cluster_pool_size.dram`、resident/hot Moka capacity 或 expected reserve;这是把已经空闲的物理 grant 及时归还 allocator,不是缩 owner 容量。 |
+
+该路径与 `evict_some` 的职责不同:本步骤优先回收“已经完全空闲但仍被 reserve 持有”的
+grant,不产生新的 KV 淘汰;若没有这种 grant,现有 safe reclaim/NoSpace 语义继续生效,不能
+为了 Get 强行释放 live backing。新增定向测试覆盖只选 NoSpace item、非队尾 grant 与容量下限。
+
+E16ay 的固定 128/128/256 GiB 三机正式验收得到 1152/1152、6.8269 QPS 和 93.02% 总命中。
+GPU0/GPU1 分别通过 2/3 次 pressure retry 恢复 30/54 个 NoSpace item,全部首轮达到
+`no_space_after=0`,没有错误传到 SGLang。CPU-source Get 为 4,942 /
+23,319,281,664 bytes;CPU 保留 8,855 entries / 74,281,123,840 bytes;两个 GPU terminal
+pending 与 owner-hot demotion 都为 0。配置的 owner 容量、resident/hot 容量和 232-grant
+expected 下限没有改变。这证明同步归还空 grant 可以闭合 Get-target 物理 headroom,但 QPS
+仍比双 GPU baseline 低 0.79%、比 Mooncake 低 1.33%。
+
+#### 单 KV slot 分级循环(取代整 grant Get-target 让位)
+
+E16ay 的整 grant 让位只是在旧 Get target 仍从 master 普通 allocator 分配时的兼容方案,
+不是最终的缓存分级语义。`512 MiB` grant 只是 local-reserve 的物理容器,不应成为 KV
+驱逐或恢复的最小单位。多个 grant 中的所有 committed KV slot 已由同一个 owner-hot Moka
+按 key、版本和实际 slot weight 统一管理;因此正确闭环是逐 KV slot 循环:
+
+```text
+任意 grant 的 Committed slot
+ -> 跑出 owner hot window
+ -> owner 展开完整 exact atomic/TP source cohort
+ -> BatchEvictOwnerSource
+ -> master activity fence + owner holder drain
+ -> owner route 引用与 resident holder 一次 pool update 归还
+ -> master 删除 exact source route
+ -> 该 slot 立即 Free
+ -> 后续 remote Get 直接 claim 这个 Free slot
+```
+
+CPU proactive replica 可以在这条路径之前、期间或完全不发生;它只改变删除 source 后还能否
+remote hit,不是释放 slot 的前置条件。
+
+这条路径不等待同一 grant 的其它 slot 释放,也不排空或归还整个 grant。普通 idle shrink
+仍可在 grant 已经自然全空且高于保留下限时回收物理 allocation,但它不参与 Get 的正确性
+或前进性。
+
+Moka size eviction 是冷候选 ownership transfer,不能被当作可丢失通知。如果触发 key 的
+atomic/TP cohort 元数据暂不完整,owner 保留同一个 selected identity,在有界 retry actor 中
+等待当前 route/metadata 可构造完整 cohort;不能退化成逐 key source delete,也不能重新进入
+replica append。RPC/Busy/partial overlap 时 GPU route/holder 保持 live,并且事件必须以
+handoff、committed、restored、obsolete 或 retry 状态可对账,不能让 slot 永久留在 hot cache
+之外。
+
+remote Get 的 local-side target 使用与 hostless Put 相同的全 pool slot allocator;allocator
+可以从任意 grant 取一个匹配 `slot_size` 的 Free slot。没有 Free slot 时,复用现有 Moka
+候选与 owner 两阶段 fence,逐 KV reclaim 所需数量的 slot,而不是选择某个 grant drain。
+slot 状态机和失败契约如下:
+
+| 阶段 | 约束 |
+| --- | --- |
+| GetStart | client 先 claim `Free -> Prepared`,把精确的 `grant_id/slot_index/slot_size/address` 交给 master;master 校验 owner 和 grant 几何,不能改用另一个 target。 |
+| transfer 完成 | `Prepared -> Pending`,唯一 resident `MemoryInfo` 放入 hidden pending-get 表;此时 bytes 不得通过 local-visible index 暴露。 |
+| GetDone 成功 | master 以 key/version/owner CAS 发布 committed-slot route,并确保 resident Moka entry 已可见;client 再把同一 `MemoryInfo` 原子移入 committed index、`Pending -> Committed`,并加入 owner-hot Moka。 |
+| transfer/GetDone 明确失败 | revoke master inflight,移除 hidden pending;最后一个 `MemoryInfo` holder 归还后单 slot 回到 Free。 |
+| GetDone 响应丢失 | 保留 hidden pending,用同一 `get_id` 重试幂等终态;不能猜测失败并释放可能已经被 master 发布的 slot。 |
+| 并发或旧版本 | 同 key+owner 只能有一个 live committed route;CAS 输家不覆盖现有 backing。delete/reclaim 按 put id 清理 hidden pending,避免 master 已发布而 client 尚未 promote 的窗口泄漏。 |
+| owner source eviction | 完整 exact cohort 可直接请求删除当前 owner source,不依赖 CPU route;最后一份 cache route 也可删除。master fence 与 holder drain 完成前 slot 不得复用。 |
+
+hostless Put 也必须经过同一发布门禁。`local_fast_put_commit` 先把写完的 slot 置为
+`PendingLocalVisible`,只允许当前 owner 本地读取;master PutDone 已发布 route 并同步完成
+resident Moka admission 后,client 才执行 `PendingLocalVisible -> Committed(route_live=true)`。
+同一 batch 的成功成员要先全部完成 promotion,再统一加入 owner-hot Moka,避免第一个成员刚
+admit 就被容量淘汰,而其 route 或 atomic/TP cohort 仍未完整发布。PutDone 明确失败或响应
+结果不确定时都保留 pending/fence,并使用同一操作身份重试;不能释放可能已经被 master
+发布的 backing,也不能把部分 item 的错误当作整批可回滚。
+
+发布生命周期必须以 batch/job 为单位持有,而不是每个 key 各自 fire-and-forget。一个
+`OwnerLocalPublishJob` 持有整批 master key reservations;external local-first 路径还持有
+整批 `ExternalPendingPutCtx` 和 owner reclaim fences。PutDone 响应不确定、响应长度/identity
+不一致或任一 item 未达终态时,job 保留全部状态并以 25ms~1s 退避重试。全部 master route
+成功后才开始 local promotion;若取消或局部错误发生在 promotion 中间,下一轮接受已经
+promoted 的成员并继续其余成员,只向前收敛。所有 promotion 完成后才统一 owner-hot
+admission。只要任一 item 声明 atomic group,而 job 未包含连续、同序的完整成员,就禁止
+退化到逐 key PutDone;不得用“部分成功”换取半组可见性。
+
+`Committed` slot 的可复用条件仍是 `route_live == false && holder_ref_count == 0`;释放 route
+与释放 holder 各执行一次,任意先后顺序都不能提前复用。Get 使用 local-reserve slot 不占旧的
+master 普通-allocation durable quota;旧 quota token 则必须绑定普通 route 生命周期,在
+revoke、失败、替换或删除时恰好归还一次。
+
+容量语义保持不变:GPU owner 仍是配置的 `128 GiB`,CPU owner 仍是 `256 GiB`;该改造
+不调整 `contribute_to_cluster_pool_size.dram`、resident/hot Moka capacity 或 expected grant
+数量,只让完成 exact source deletion 的单个 KV slot 立即回到同一 size class 的 Free list。
+
+#### E16az external local-IPC busy-poll 验收
+
+E16ay 的两个 GPU owner 仍分别产生 147,124/150,701 条 iceoryx2
+`FailedToDeliverSignal`。失败的是 owner -> SGLang external 的逐消息 event notification,
+subscriber 数据本身仍存在。E16az 保持 E16ay release 与所有容量/策略/工作负载不变,仅令 GPU
+owner 和 SGLang external receiver 使用 `iceoryx_external_busy_poll=true`。此模式直接 drain
+subscriber,发送端不会创建 notifier;owner/client 内部 receiver 原有的 busy-poll 设置不变。
+
+正式轮仍为 1152/1152、576/576,四份相关日志的 `FailedToDeliverSignal` 全部为 0,GPU owner
+日志缩到约 132/137 KB。local IPC 和 RDMA 数据路径仍存活,CPU-source Get 为 4,965 /
+23,427,809,280 bytes,CPU 保留 8,610 entries / 72,225,914,880 bytes;74 个 Get-target
+NoSpace item 全部被 E16ay 路径恢复。terminal pending、surfaced NoSpace、业务 5xx、P2P 608、
+OOM 和 scheduler exception 均为 0。
+
+性能为 6.7888 QPS、93.02% 总命中,比 E16ay 低 0.56%、比双 GPU baseline 低 1.34%。因此
+事件告警格式化不是剩余吞吐主瓶颈;持续 external polling 的 CPU contention 可能抵消省下的
+日志工作,且这一小幅差值可能包含单轮波动。设计上可把 external busy-poll 作为避免 notifier
+风暴的运行模式,但不能把它视为 CPU replica 转化为吞吐收益的容量策略。下一步仍应直接减少
+包含式重复副本的无效工作,或降低 CPU Get/replica 进入 SGLang 关键路径的等待开销。
+
+#### E16ba Hot/resident lead-window 契约
+
+包含式 owner-hot write-through 不能通过简单地把 hot capacity 提到 resident capacity 来实现
+“驱逐即 write-back”。hot eviction 只产生异步 CPU append 信号;resident eviction 则要求
+CPU 上已经存在完整、同版本、同 TP cohort 的 durable backing。两者必须保留足够的 lead
+window,否则 resident pressure 会先于 remote completion 到达。
+
+固定 resident=0.95、reserve=0.90 和 128/128/256 GiB 容量,对 hot=0.92/0.94/0.95 的三次
+正式冷启动扫描结果为:
+
+| hot | QPS | CPU placement | CPU Gets | recoverable selected | shortfall |
+| ---: | ---: | ---: | ---: | ---: | ---: |
+| 0.90 E16ay | 6.826884 | 8,860 | 4,942 | 65,729,986,560 B | 0 |
+| 0.92 | 6.824876 | 7,413 | 3,214 | 51,091,865,600 B | 0 |
+| 0.94 | 6.792892 | 5,893 | 2,108 | 41,800,433,664 B | 0 |
+| 0.95 | 6.623042 | 5,341 | 1,496 | 36,394,500,096 B | 32,925,181,564 B |
+
+四轮 overall hit 都为 93.02%,因此下降不是 cache miss 数量变化。0.92 以几乎相同 QPS
+减少 16.3% placement 和 35.0% CPU Get,证明较早触发包含冗余工作;0.94 开始变慢;0.95
+请求 69,316,116,502 bytes recoverable victim 时只有 36,394,500,096 bytes 已 CPU-complete,
+QPS 相对 0.90 下降 2.99%。三轮均无 surfaced NoSpace、terminal pending、业务错误或容量变化。
+
+因此静态 hot threshold 在该 workload 下必须比 resident 至少提前约 3--5pp。若设计目标是真正
+的 eviction-time write-back,应由 resident 的精确 victim 触发 CPU append,并为该 victim
+建立有界 completion fence;或者根据 replica queue depth、传输延迟和 resident growth rate
+动态计算 lead。不能让两个独立 Moka 同阈值后依赖调度碰巧完成。
+
+#### E16bc--E16bj SGLang 调度容量、replica pipeline 与发布门禁
+
+固定 Fluxon GPU0/GPU1/CPU owner 为 128/128/256 GiB 后,端到端吞吐的最大增益并非继续改
+Moka 水位,而是解除 SGLang scheduler 的串行与 token-pool 约束。E16bc 只把每个 TP=2 服务的
+`max_total_tokens` 从 100,000 提到 200,000,QPS 从 E16bb 的 6.773531 提到 8.645063;E16cc
+再启用默认 overlap scheduler,正式冷启动达到 10.056034 QPS。该轮 1,152/1,152 成功、两端
+各 576 请求、总命中 92.96%,GPU 初始化后仍各有约 53.51 GiB 可用显存。这里的
+`max_total_tokens` 是 SGLang GPU scheduler/KV pool 参数,不修改 Fluxon owner segment、resident
+Moka、hot Moka 或 local-reserve 的容量;配置与结果记录不能把两种“容量”混为一谈。
+
+overlap scheduler 允许 HiCache restore/prefill 的调度推进与 GPU decode 工作交叠。关闭 overlap
+时,同样的外部命中率并不表示同样的吞吐,因为 storage future、prefill admission 和 decode
+会在 scheduler step 边界上串行等待。因此正式性能对比必须同时固定
+`max_total_tokens`、`disable_overlap_schedule`、CUDA graph、并发和 workload round barrier。
+
+owner replica actor 原先全局只能有一个 append pipeline 在途,每个不同 key 都依次等待
+append-start、RDMA completion 与 append-done。`test_spec_config.replica_task_max_inflight` 将其改为
+1--64 的有界跨 key 并发,默认 1 保持兼容;全局 semaphore 限制总在途数,每个 key 另有单许可
+semaphore,所以不同 key 可以并行,同 key 的版本/任务仍严格串行。E16bh 在两个 GPU owner 设为
+16,CPU owner 保持 1;该参数改变复制流水线深度,不改变 owner 容量、placement 或原子组完整性。
+
+组合发布还必须经过目标机 ABI 预检。E16bf 的 wheel 文件名虽然声明
+`manylinux_2_28_x86_64`,其中新 closed core 实际引用了 `GLIBC_2.39`,三台正式机器因此在 master
+import 阶段 fail-fast,未发送任何 workload。发布门禁应对 wheel 内每个 bundled `.so` 检查最高
+GLIBC symbol version,并在三种目标 Python/宿主环境做 import smoke;仅检查 wheel tag 或构建成功
+不足以证明可部署。兼容性回退 E16bd 保留开放层 replica pipeline,并沿用已验证 closed core。
+
+E16bj 在真实 manylinux 2.28 builder 中重建 closed core 后,通过了 wheel 内全部 `.so` 的
+symbol-version 扫描和 Python 3.10/3.11/3.12 import smoke。正式三机冷启动保持
+GPU0/GPU1/CPU = 128/128/256 GiB、GPU pipeline=16、CPU-only placement、200k token pool 和
+overlap scheduler,得到 1,152/1,152、576/576、10.045299 QPS 和 92.99% 总命中。CPU 承担
+8,770 个 replica target 与 4,990 次 remote Get;GPU replica target、终态 replica inflight、
+replica failure 和 pending reclaim 均为 0。E16cc/E16bh/E16bj 的最低和平均 QPS 为
+10.020985/10.040772,说明 10-QPS 结果可由可部署组合版本复现。
+
+E16be closed sharding 修复要求所有正长度 transfer 的 child 都非空:按 sharding quantum 做 ceil
+division并省略空 child;只有显式零长度请求保留一个零长度 child,以维持 completion contract。
+这项约束属于通信正确性和尾延迟卫生,不能用 wheel tag 代替测试,也不能从 E16bj 相对 E16bh
+仅 +0.24% 的单轮差值推导出稳定吞吐增益。
+
+## SGLang Node Storage 状态
+
+SGLang 侧的 node metadata 不等价于 Fluxon master route 状态。当前四个字段建议按下面语义解释:
+
+| 字段 | true 的含义 | 清理时机 |
+| --- | --- | --- |
+| `storage_staged` | 该 node 有一批 Fluxon hostless backup 正在 staged 路径中。 | `KvFuture` 完成或失败后清空。 |
+| `storage_local_ready` | CUDA write 已完成,SGLang 已调用 `local_fast_put_commit`,但返回的 `KvFuture` 还未 ack;该状态只表示本次 hostless backup 已进入 Fluxon commit 流程,不表示 KV route 已经全局确认。 | async ack 结束后清空。 |
+| `storage_pending` | Fluxon `KvFuture` 还没结束。 | future 成功或失败后清空。 |
+| `storage_backed` | Fluxon 后台提交成功,KV route 已确认可作为 shared backing。 | 该 node 被删除或失效时清空。 |
+
+因此,SGLang 可以用 `storage_staged/storage_local_ready` 判断本次 hostless backup 已经推进到本地写入或 commit 阶段;但跨节点复用和长期共享必须等 `KvFuture` 成功,并以 `storage_backed` 为准。
+
+TP 场景下,每个 rank 仍有各自的 radix tree 和恢复决策。`get_start` 的结果只描述当前 rank 这批 keys 的可恢复前缀;如果一个 rank miss、另一个 rank hit,上层必须按 SGLang 的 TP restore 约束处理一致性,不能把单 rank 的部分成功当作完整 request 已恢复。
+
+Put admission 的随机 identity 必须跨 TP rank 稳定。物理 storage key 保留 `_0_2`、`_1_2`
+等 rank 后缀,用于区分不同 rank 的 KV bytes;ratio/score jitter 和 atomic-group hash 则使用
+保留 TP size、去掉 TP rank 的 admission key。同一个逻辑 radix group 因而在所有 TP ranks
+上得到同一 admitted/skipped 决策,同时仍生成不同物理 keys。只约束单 rank 内 mask 不切组,
+无法满足这个跨 rank 不变量。
+
+各 rank 首次 `get_start` 的 `transferable_len` 不一致时,SGLang 先用 collective 取最小值。
+该最小值必须落在共同的 atomic-group 边界;每个 rank 取消原 handle,再以该最小完整-group
+前缀重新执行一次 `get_start`,随后再次 collective 校验长度一致。第二次仍不一致才放弃本轮
+restore。不能直接使用较长 rank 的 handle,也不应因为尾部差异把两个 rank 已共同命中的
+前缀全部作废。
+
+`get_start` 长度一致只完成了 intent 协商,不能作为 restore 的最终提交点。每个 rank 在
+`get_transfer` 返回可用 plan 后必须再进入一次 TP commit gate;只有所有 rank 都成功,plan
+才可进入 ready-prefetch、radix node 和 layerwise restore。任一 rank 超时、P2P 失败或 plan
+缺失时,所有 rank 统一取消 handle、释放已成功 rank 的 views,并把本轮 restored-token 数置为
+0,按共同 cache miss 重算。这个门禁必须位于任何 rank 发布 restored prefix 之前,禁止一侧
+restore、另一侧 recompute。异步 CUDA descriptor submission 的错误也必须在消费 prefix 前对
+所有 rank 可见;已经修改临时 node/slot 的实现必须全 rank 回滚后才能继续调度。
+
+## 失败处理
+
+| 场景 | 必须动作 |
+| --- | --- |
+| `local_fast_put_start` 后 native write 失败 | 调用 `put_abort(plan_ptr)`,释放 key reservation 和 local reserve slot lease。 |
+| `local_fast_put_commit` 返回 future 后后台失败 | SGLang 清理 `storage_staged/storage_pending/storage_local_ready`,必要时删除已 evicted 的 dead leaf。 |
+| `get_start` 后放弃 restore | 调用 `cancel_get_transfer(handle)`,释放 get-start 持有的 owner/external 资源。 |
+| `get_start` 只命中部分前缀 | 只允许恢复 `transferable_len` 覆盖的完整 atomic groups;后续 page 按 miss 处理。 |
+| 任一 TP rank 的 `get_transfer` 失败或 plan 缺失 | TP commit gate 在所有 rank 上拒绝发布;释放成功 rank 的 views,统一按 0-token miss 重算。 |
+| `get_transfer` 成功后 native restore 失败 | 先 `release_views(plan_ptr)`,再执行 SGLang rollback;此时 handle 已被消费。 |
+| CUDA host registration 失败 | direct path 同步失败,不能降级为未注册 host memory。 |
+| `plan_ptr` 类型用错 | `local_fast_put_commit`、`put_abort`、`release_views` 都按 registry entry 类型校验并 fail fast。 |
diff --git "a/fluxon_doc_cn/design/teststack_2_Benchmark\347\233\221\346\216\247\345\267\245\345\205\267\351\223\276\344\270\216AIPerf\346\216\245\345\205\245\350\247\204\345\210\222.md" "b/fluxon_doc_cn/design/teststack_2_Benchmark\347\233\221\346\216\247\345\267\245\345\205\267\351\223\276\344\270\216AIPerf\346\216\245\345\205\245\350\247\204\345\210\222.md"
new file mode 100644
index 0000000..37cb6c6
--- /dev/null
+++ "b/fluxon_doc_cn/design/teststack_2_Benchmark\347\233\221\346\216\247\345\267\245\345\205\267\351\223\276\344\270\216AIPerf\346\216\245\345\205\245\350\247\204\345\210\222.md"
@@ -0,0 +1,565 @@
+# Benchmark 监控工具链与 AIPerf 接入规划
+
+> 状态:设计阶段
+> 调研基线:2026-07-10
+> AIPerf 基线:[`ActivePeter/aiperf:teleai`](https://github.com/ActivePeter/aiperf/tree/teleai),提交 [`d72160e20957013d6608afcc88ed24100cb27dc5`](https://github.com/ActivePeter/aiperf/commit/d72160e20957013d6608afcc88ed24100cb27dc5)
+
+相关文档:[TestStack 架构与 CI 测试流程](./teststack_1_当前架构与CI测试流程.md)、[本地文件日志与 Greptime OTLP 导出链路](./log_1_本地文件日志与Greptime_OTLP导出链路.md)。AIPerf 能力判断依据固定提交下的 [architecture](https://github.com/ActivePeter/aiperf/blob/d72160e20957013d6608afcc88ed24100cb27dc5/docs/architecture.md)、[API endpoints](https://github.com/ActivePeter/aiperf/blob/d72160e20957013d6608afcc88ed24100cb27dc5/docs/reference/api-endpoints.md)、[server metrics](https://github.com/ActivePeter/aiperf/blob/d72160e20957013d6608afcc88ed24100cb27dc5/docs/server-metrics/server-metrics.md) 和 [profile exports](https://github.com/ActivePeter/aiperf/blob/d72160e20957013d6608afcc88ed24100cb27dc5/docs/tutorials/working-with-profile-exports.md)。
+
+## 1. 结论
+
+Fluxon 当前已经具备 benchmark 编排、终态结果、服务指标、日志和常驻 UI,但这些能力仍分布在不同链路中。现有链路适合 KV、MQ、RPC 和 FS benchmark,缺少生成式 AI 请求的 TTFT、ITL、token throughput、goodput、trace replay 等负载和指标语义。
+
+规划采用以下边界:
+
+- **AIPerf 只负责生成式 AI 负载和请求侧统计**:新增显式的 `AIPERF` test stack mode,首期支持单个 load generator 对 OpenAI-compatible SGLang endpoint 发压。
+- **`test_runner` 继续负责 case 生命周期**:suite 编译、资源准备、启动、超时、取消、终态、历史记录、UI 和对外 API 都由 `test_runner` 管理。
+- **Greptime 继续负责连续时序指标与日志**:Fluxon 服务指标、日志和已有监控链路不迁移到 AIPerf。AIPerf 的请求结果以 run artifact 为事实来源。
+- **AIPerf UI 和 API 不成为新的公共入口**:AIPerf 以 `runtime.ui: none` 运行;其 loopback API 只供同机适配器读取,`test_runner` UI 通过现有 ops 接口展示标准化后的进度和结果。
+- **现有 distributed benchmark coordinator 保持原路径**:KV、MQ、RPC、FS 的 `benchmark_result.json` 语义不由这次接入改写。`AIPERF` 是现有有限模式集合中的新分支。
+- **依赖必须固定到提交和构建产物哈希**:`teleai` 是未保护的可变分支,运行时不能直接按分支头安装。
+
+首期不启用 AIPerf sweep、AIPerf multi-run、AIPerf OTel 导出和 GPU telemetry。case 矩阵仍由 TestStack 的 `scene × scale × profile` 决定,避免形成嵌套调度和重复监控通道。
+
+## 2. 目标与非目标
+
+### 2.1 目标
+
+1. 把生成式 AI benchmark 收敛进 `start testbed -> testrunner` 两步模型。
+2. 复用 AIPerf 已有的请求生成、warmup、并发或请求速率控制、请求级记录和聚合统计。
+3. run artifact 和请求链路统一使用 `case_id + run_index`;连续服务指标通过 `cluster/instance + run time window` 与 run 关联。
+4. 让常驻 `test_runner` UI 同时展示实时进度、终态摘要、artifact 和 Greptime 时间窗口。
+5. 保证离线安装、固定依赖、固定随机种子和明确的失败状态。
+
+### 2.2 非目标
+
+- 不用 AIPerf 替换 `distributed_benchmark_coordinator.py`。
+- 不把 AIPerf FastAPI、TUI 或 plot dashboard 暴露为 TestStack 公共服务。
+- 不在 Fluxon 内重写 TTFT、ITL、token throughput 等 AIPerf 已提供的算法。
+- 不允许 suite 同时传入自由格式 AIPerf YAML、任意 CLI 参数和环境变量覆盖。
+- 首期不支持多 load generator、AIPerf Kubernetes service mode、真实用户 prompt、鉴权 endpoint 和自动性能回归判定。
+
+## 3. 当前工具链分析
+
+### 3.1 当前数据流
+
+```mermaid
+flowchart LR
+ A[ci_test_list.yaml] --> B[test_runner.py]
+ B --> C[resolved_case.yaml]
+ B --> D[distributed benchmark coordinator]
+ D --> E[benchmark nodes]
+ E --> F[benchmark_result.json]
+ F --> B
+ B --> G[summary.yaml + case_runs.yaml]
+
+ H[Fluxon processes] --> I[Prometheus remote write]
+ I --> J[Greptime]
+ H --> K[local logs / OTLP logs]
+ K --> J
+
+ G --> L[test_runner UI]
+ J --> L
+ M[ops interfaces] --> L
+```
+
+当前各层职责如下。
+
+| 层 | 当前实现 | 已有能力 | 当前边界 |
+| --- | --- | --- | --- |
+| suite 与 case 编译 | `test_runner.py` | `scene × scale × profile`、`resolved_case`、run 目录 | 没有生成式 AI workload branch |
+| testbed 生命周期 | `start_test_bed.py`、controller、ops | 启动共享服务、apply、进程状态和日志 | 不执行单个 benchmark case |
+| benchmark 执行 | coordinator + nodes | KV、MQ、RPC、FS 的分布式同步和聚合 | 指标模型面向 operation、bytes 和内部 phase |
+| 终态 | `benchmark_result.json`、`summary.yaml`、`case_runs.yaml` | 强制等待结果、校验节点完成、记录 run outcome | UI 主要展示原始摘要,缺少通用指标视图 |
+| 连续监控 | Prometheus-compatible remote write、Greptime | 服务指标、部分 transport 指标、日志 | 与 run artifact 的关联字段尚未统一 |
+| 常驻 UI | `test_runner_ui.py` | suite/run 状态、日志、GitOps、ops log | 当前没有 benchmark 曲线和统一结果 API |
+
+### 3.2 当前链路已经稳定的部分
+
+- `case_runs.yaml` 是 suite workdir 内执行状态的单一事实来源。
+- `summary.yaml` 是单次 run 的终态摘要,runner 会在执行前写入可诊断的占位结果。
+- TestStack benchmark 通过 `benchmark_result.json` 完成终态握手。runner 会校验每轮 `completion.status`、节点数量和 operation 数量。
+- UI 是常驻服务,run 结束后仍能读取历史、日志和 ops 状态。
+- Fluxon 指标使用 Prometheus-compatible 协议进入 Greptime,服务日志同时保留本地文件和 OTLP 路径。
+
+### 3.3 主要缺口
+
+| 缺口 | 影响 | 本设计的处理 |
+| --- | --- | --- |
+| 缺少生成式 AI 请求语义 | 现有 operation latency 无法表达 TTFT、ITL、token throughput 和多轮会话 | 使用 AIPerf 计算并保留原始 metric tag 与单位 |
+| 进度、终态和时序指标分散 | UI 需要分别读状态文件、原始 JSON、日志和 Greptime | 在现有 `/api/run_state` 下增加有界的 `benchmark` 分支 |
+| run 关联字段未贯穿 | 很难把请求突刺与服务指标、日志对齐 | 固定 artifact 关联键,并保存 cluster/instance 与 benchmark 时间窗口 |
+| 当前 UI 缺少 benchmark 图表 | 只能查看 JSON 和日志 | UI 读取标准化摘要、timeslice 和 server metrics artifact |
+| 多节点 percentile 聚合口径有限 | 当前 distributed benchmark 的 p50/p95/p99 是按成功 operation 数加权的节点 percentile,不能作为全量样本 percentile 使用 | AIPerf 路径保留请求级 JSONL,并由 AIPerf 计算请求总体 percentile |
+| 工具能力容易重复 | AIPerf 也有 UI、API、server metrics、OTel 和 GPU telemetry | 用明确的所有权表限制每条链路 |
+
+上表中的 percentile 结论只描述当前 coordinator 的节点聚合实现,不扩展为其他 benchmark 或整个监控系统的结论。
+
+## 4. AIPerf `teleai` 分支评估
+
+### 4.1 可直接复用的能力
+
+| 能力 | `teleai` 快照行为 | Fluxon 用法 |
+| --- | --- | --- |
+| Python 版本 | `requires-python = ">=3.10,<3.14"` | 满足 Fluxon 的 Python `>=3.10` 要求 |
+| 许可证 | Apache-2.0 | 构建产物保留许可证与 attribution |
+| endpoint | OpenAI chat/completions 等 | 首期只开放 OpenAI-compatible chat streaming |
+| 负载控制 | concurrency、request rate、warmup、ramp、trace replay | 首期开放 fixed concurrency 和 fixed request rate 两个有限分支 |
+| 请求指标 | TTFT、ITL、request latency、request/token throughput、error 等 | 直接采用 AIPerf metric tag 和单位,不在适配层重算 |
+| artifact | summary JSON/CSV、请求级 JSONL、`inputs.json`、timeslice | summary JSON 和请求级 JSONL 为首期必需产物 |
+| server metrics | 自动发现或显式抓取 Prometheus `/metrics`,当前默认每 333 ms 采样 | 首期只抓取 runner 解析出的单个 SGLang metrics endpoint |
+| 实时 API | `/api/run`、`/api/progress`、`/api/metrics`、`/api/results`、`/metrics` | 只绑定 loopback,由同机适配器读取 |
+| UI | dashboard、simple、none | 固定 `none`,避免出现第二套 UI 归属和入口 |
+| OTel | 可流式导出 metrics 和 timing | 首期关闭,待 Greptime OTLP metrics 契约单独评审 |
+
+### 4.2 依赖快照结论
+
+截至 2026-07-10:
+
+- `teleai` 指向提交 `d72160e20957013d6608afcc88ed24100cb27dc5`。
+- GitHub compare 显示该提交下 [`main...teleai`](https://github.com/ActivePeter/aiperf/compare/main...teleai) 为 `identical`。
+- 仓库中没有可识别的 TeleAI 专属配置、插件或 patch。
+- `teleai` 分支未启用 branch protection。
+
+因此,“使用 `teleai` 分支”在工程上必须落实为下面四项记录:
+
+1. source repo:`https://github.com/ActivePeter/aiperf`
+2. requested branch:`teleai`
+3. resolved commit:`d72160e20957013d6608afcc88ed24100cb27dc5`
+4. wheel 与 wheelhouse manifest 的 SHA-256
+
+实现前还需要业务方确认:当前意图是否就是采用这份与 fork `main` 相同的快照;如果预期存在 TeleAI 定制能力,应先明确对应提交或差异清单。
+
+### 4.3 不能直接沿用的默认行为
+
+- **不能按 branch head 在线安装**:testbed 运行期间不访问 PyPI 或 GitHub。
+- **不能使用环境变量替换普通参数**:suite 编译器生成完整 AIPerf YAML,启动命令只传 `--config`。
+- **不能使用 AIPerf sweep 或 multi-run 展开 case**:TestStack 已拥有 case 空间和 run history。
+- **不能依赖 AIPerf 默认 UI**:非 TTY 与 TTY 下必须都固定为 `none`。
+- **不能依赖 server metrics 自动发现**:显式写入 SGLang `/metrics` URL,并关闭 Kubernetes discovery。
+- **不能直接公开 AIPerf API port**:API 只监听 `127.0.0.1`,端口由 testbed port allocator 分配。
+
+## 5. 目标架构
+
+### 5.1 核心角色
+
+| 角色 | 责任 | 不负责什么 |
+| --- | --- | --- |
+| `start_test_bed.py` | 准备 SGLang、Greptime、controller、ops 和常驻 runner UI | 不启动单次 AIPerf run |
+| `test_runner.py` | 编译 `AIPERF` case、分配资源、驱动 prepare/execute/finalize、写终态 | 不计算 TTFT 或 ITL |
+| AIPerf adapter | 在 load generator 上启动 AIPerf、轮询 loopback API、原子写进度、校验 artifact、生成终态 | 不拥有 suite、历史或公共 API |
+| AIPerf | 发请求并计算请求侧指标 | 不拥有 testbed 和长期监控 |
+| SGLang endpoint | 被测服务,暴露 OpenAI-compatible API 和 `/metrics` | 不决定 benchmark 成败 |
+| Greptime | 保存 Fluxon 连续指标与日志 | 首期不保存 AIPerf 请求级记录 |
+| `test_runner` UI | 组合 run 状态、AIPerf 摘要、artifact、ops log 和 Greptime 时间窗口 | 不直接连接公开的 AIPerf 服务 |
+
+实现代码按现有 runner 分层落位:
+
+| 模块 | 计划改动 |
+| --- | --- |
+| `test_runner.py` | 增加 `AIPERF` suite schema、case 编译和有界 dispatch,不承载子进程细节 |
+| `test_runner_runtime_backend.py` | 增加 `AIPERF` prepare、execute、finalize 和终态读取 |
+| `aiperf_adapter.py` | 唯一的直接进程入口,负责 AIPerf 子进程、loopback API、timeout 和结果发布 |
+| `aiperf_contract.py` | generated config、progress 和 result 的强类型模型与 validator,不提供第二个执行入口 |
+| 现有 runner UI 代码 | 在 `/api/run_state` 与 run 页面增加 `AIPERF` 显式分支 |
+
+### 5.2 数据流
+
+```mermaid
+flowchart LR
+ A[suite] --> B[test_runner compile]
+ B --> C[resolved_case.yaml]
+ B --> D[aiperf.generated.yaml]
+ B --> E[ops apply on load generator]
+ E --> F[AIPerf adapter]
+ F --> G[aiperf profile]
+ G --> H[SGLang OpenAI endpoint]
+ H --> G
+
+ G --> I[AIPerf artifacts]
+ G --> J[loopback API]
+ J --> F
+ F --> K[aiperf_progress.json]
+ F --> L[benchmark_result.json]
+ L --> B
+ B --> M[summary.yaml + case_runs.yaml]
+
+ H --> N[SGLang /metrics]
+ N --> G
+ O[Fluxon services] --> P[Greptime metrics + logs]
+
+ Q[test_runner UI/API] --> M
+ Q --> K
+ Q --> I
+ Q --> P
+ Q --> R[ops status + logs]
+```
+
+AIPerf adapter 是单次 case 的直接进程入口,生命周期与该 case 一致。它不作为 testbed 常驻服务运行。
+
+### 5.3 所有权与事实来源
+
+| 数据 | 事实来源 | 保留周期 | UI 读取方式 |
+| --- | --- | --- | --- |
+| suite/case 状态 | `case_runs.yaml` | suite workdir 生命周期 | runner 本地读取 |
+| run 终态 | `summary.yaml` | run artifact 生命周期 | runner 本地读取 |
+| AIPerf 请求摘要 | `aiperf/profile_export_aiperf.json` | run artifact 生命周期 | runner 结果适配器 |
+| AIPerf 请求级记录 | `aiperf/profile_export.jsonl` | run artifact 生命周期 | 按需下载或离线分析 |
+| AIPerf 实时进度 | `aiperf_progress.json` | case 运行期间,终态后保留最后快照 | 通过 ops 文件读取接口 |
+| SGLang run-window 指标 | `aiperf/server_metrics_export.jsonl` | run artifact 生命周期 | runner 图表适配器 |
+| Fluxon 连续指标 | Greptime Prometheus-compatible API | Greptime retention | runner monitor 查询接口 |
+| 服务与进程日志 | 本地 daily shard、Greptime `fluxon_logs`、run log | 各自既有 retention | 现有 log/ops log API |
+
+首期不把 AIPerf summary 再复制到 Greptime。这样可以避免 artifact、OTel 和 Prometheus 三条通道同时保存同一组请求指标。若后续需要跨 run 长期聚合,应单独设计一个由 runner 控制的离线导入协议。
+
+## 6. 配置与编译模型
+
+### 6.1 suite 只增加一个有限模式
+
+目标 suite 结构采用 `scene.test_stack.mode: AIPERF`。各层仍保持现有分工:
+
+| 层 | `AIPERF` 分支内容 |
+| --- | --- |
+| scene | model、endpoint type、streaming、dataset、warmup、profiling phase、随机种子 |
+| scale | load generator target、SGLang endpoint instance、资源规模 |
+| profile | AIPerf artifact set、adapter deploy 模板、SGLang runtime 组合 |
+| resolved case | 具体 URL、metrics URL、run_dir、loopback API port、完整 source manifest |
+
+下面是提议中的 suite 片段,只用于说明字段归属,当前代码还不能执行:
+
+```yaml
+scenes:
+ sglang_chat_concurrency:
+ test_stack:
+ mode: AIPERF
+ aiperf:
+ model: example-model
+ endpoint_type: chat
+ streaming: true
+ random_seed: 42
+ dataset:
+ type: synthetic
+ entries: 512
+ input_tokens: 1024
+ output_tokens: 256
+ warmup:
+ concurrency: 8
+ requests: 32
+ profiling:
+ type: concurrency
+ concurrency: 32
+ requests: 512
+```
+
+Fluxon 只接受上述有界字段,不接受 `extra_args`、任意 AIPerf config fragment 或环境变量模板。需要新增 workload 能力时,先把它加入明确的 schema 分支和 contract test。
+
+### 6.2 生成单一 AIPerf 配置
+
+runner 根据 `resolved_case` 生成 `aiperf/aiperf.generated.yaml`。该文件是编译产物,用户不直接维护。Fluxon suite 统一使用 `snake_case`;生成器在 AIPerf 边界按该提交自带 JSON Schema 输出 `camelCase` alias,不在同一份配置中混用两种拼写。
+
+```yaml
+schemaVersion: "2.0"
+randomSeed: 42
+
+benchmark:
+ model: example-model
+ endpoint:
+ url: http://sglang-host:30000/v1/chat/completions
+ type: chat
+ streaming: true
+ headers:
+ X-Fluxon-Case-ID: sglang_chat_concurrency__n1__teleai
+ X-Fluxon-Run-Index: "1"
+ dataset:
+ type: synthetic
+ entries: 512
+ prompts: {isl: 1024, osl: 256}
+ warmup:
+ type: concurrency
+ concurrency: 8
+ requests: 32
+ profiling:
+ type: concurrency
+ concurrency: 32
+ requests: 512
+ artifacts:
+ dir: /testbed/run/results/example/run_1/aiperf
+ summary: [json]
+ records: [jsonl]
+ raw: false
+ trace: false
+ sliceDuration: 5
+ serverMetrics:
+ enabled: true
+ urls:
+ - http://sglang-host:30000/metrics
+ formats: [json, jsonl]
+ discovery:
+ mode: disabled
+ gpuTelemetry:
+ enabled: false
+ runtime:
+ ui: none
+ apiHost: 127.0.0.1
+ apiPort: 19081
+```
+
+启动命令固定为:
+
+```bash
+aiperf profile --config /testbed/run/results/example/run_1/aiperf/aiperf.generated.yaml
+```
+
+`testbed_aiperf_api_port` 由现有 testbed port allocator 派生并写入 resolved case,不增加用户侧端口配置项。
+
+### 6.3 依赖交付
+
+构建阶段完成以下动作:
+
+1. 从固定 commit 构建 AIPerf wheel。
+2. 解析并下载目标平台的完整 dependency wheelhouse。
+3. 生成包含文件名、版本、许可证和 SHA-256 的 manifest。
+4. 把 wheelhouse 作为 profile 所选 artifact set 的一部分发布。
+5. load generator 在隔离 venv 中离线安装,禁止运行时访问 package index。
+
+`resolved_case.yaml` 和 `benchmark_result.json` 都要记录 source repo、requested branch、resolved commit、AIPerf package version 与 wheelhouse manifest SHA-256。
+
+## 7. 执行与终态契约
+
+### 7.1 执行时序
+
+```mermaid
+sequenceDiagram
+ participant R as test_runner
+ participant O as ops/deployer
+ participant A as AIPerf adapter
+ participant P as aiperf profile
+ participant S as SGLang
+
+ R->>O: apply adapter workload
+ O->>A: start generated config
+ A->>P: spawn direct process
+ P->>S: warmup requests
+ P->>S: profiling requests
+ loop every 1 second while running
+ A->>P: GET loopback /api/progress and /api/metrics
+ A->>A: atomic replace aiperf_progress.json
+ end
+ P-->>A: exit code + artifacts
+ A->>A: validate and write benchmark_result.json
+ R->>O: read terminal result
+ R->>R: validate, write summary, finalize apply
+```
+
+### 7.2 `benchmark_result.json`
+
+`AIPERF` 使用独立的强类型 payload,runner 根据已编译的 mode 选择专用 validator。建议最小结构如下:
+
+```json
+{
+ "schema_version": 1,
+ "result_kind": "AIPERF",
+ "case_id": "sglang_chat_concurrency__n1__teleai",
+ "run_index": 1,
+ "completion": {
+ "status": "SUCCESS",
+ "exit_code": 0,
+ "error": null
+ },
+ "source": {
+ "repo": "https://github.com/ActivePeter/aiperf",
+ "requested_branch": "teleai",
+ "resolved_commit": "d72160e20957013d6608afcc88ed24100cb27dc5",
+ "package_version": "",
+ "wheelhouse_manifest_sha256": ""
+ },
+ "timing": {
+ "started_at_unix_ns": 0,
+ "finished_at_unix_ns": 0
+ },
+ "summary": {
+ "request_count": 0,
+ "error_request_count": 0,
+ "metrics": {}
+ },
+ "artifacts": []
+}
+```
+
+`metrics` 中保留 AIPerf 的 canonical tag、统计字段和单位。适配器不把 `time_to_first_token` 改名为另一套 Fluxon 私有名称。
+
+### 7.3 完成状态
+
+完成状态使用有限枚举:
+
+| 状态 | 含义 | runner outcome |
+| --- | --- | --- |
+| `SUCCESS` | 进程退出码为 0,必需 artifact 可解析,至少有一个成功请求 | `SUCCESS` |
+| `START_FAILED` | venv、配置校验或子进程启动失败 | `FAILED` |
+| `PROCESS_FAILED` | AIPerf 非零退出 | `FAILED` |
+| `RESULT_INVALID` | summary 缺失、schema 不符或计数不满足不变量 | `FAILED` |
+| `TIMEOUT` | 超过 case deadline,adapter 已终止子进程 | `FAILED` |
+| `CANCELLED` | runner 或 operator 发出取消 | `FAILED` |
+
+首期不把普通请求错误率直接转换为 case 失败。请求错误会进入 summary;后续性能门禁必须使用单独、显式、可版本化的 acceptance policy。
+
+### 7.4 不变量
+
+- `case_id`、`run_index` 必须与 `resolved_case.yaml` 完全一致。
+- `resolved_commit` 和 wheelhouse manifest hash 必须与 staged artifact manifest 一致。
+- `finished_at_unix_ns >= started_at_unix_ns`。
+- `SUCCESS` 要求 AIPerf exit code 为 0、summary JSON 可解析、`request_count > 0`,并且 `request_count - error_request_count > 0`。
+- 所有 artifact path 必须位于当前 run_dir 内,并记录 size 与 SHA-256。
+- adapter 只能用临时文件加原子 rename 发布进度和终态,runner 不读取半写文件。
+- timeout 或 cancel 后必须等待 AIPerf 进程组退出,再写终态。
+
+## 8. 监控与 UI 设计
+
+### 8.1 指标分域
+
+| 域 | 代表指标 | 来源 | 首期展示 |
+| --- | --- | --- | --- |
+| 生命周期 | status、phase、completed/total requests、elapsed | adapter progress | run 状态卡 |
+| 请求体验 | TTFT、ITL、request latency、error rate | AIPerf summary/timeslice | percentile 表与时间曲线 |
+| 吞吐 | request throughput、input/output token throughput | AIPerf | 摘要与时间曲线;goodput 随阶段 3 的显式 SLO 配置加入 |
+| SUT | queue depth、KV cache usage、running/waiting requests | AIPerf server metrics artifact | 与请求曲线共享时间轴 |
+| Fluxon 基础设施 | transport、KV、MQ、FS 和服务指标 | Greptime | 按 run 时间窗口查询 |
+| 日志 | adapter、AIPerf、SGLang、Fluxon 服务日志 | ops、本地日志、Greptime | 复用现有日志查看器 |
+
+请求级 `x_request_id` 和 `x_correlation_id` 只进入 record/log,不作为 Greptime 时序标签,避免高基数。
+
+### 8.2 关联字段
+
+| 字段 | artifact | 请求 header | Greptime 查询标签 | 说明 |
+| --- | --- | --- | --- | --- |
+| `case_id` | 必需 | `X-Fluxon-Case-ID` | 不要求 | 逻辑 case,由 runner 关联到查询窗口 |
+| `run_index` | 必需 | `X-Fluxon-Run-Index` | 不要求 | 同一 case 的第 N 次运行 |
+| `cluster_name` | 必需 | 可选 | 必需 | testbed 集群 |
+| `instance_key` | 必需 | 无 | 必需 | SUT 与 Fluxon 服务实例 |
+| `model` | 必需 | 请求 body 已包含 | 可选 | 模型身份 |
+| `endpoint_type` | 必需 | 无 | 可选 | 首期固定为 `chat` |
+| `case_key` | 必需 | 无 | 不使用 | 配置快照 hash,不进入时序标签 |
+| source commit/hash | 必需 | 无 | 不使用 | 保存在 provenance 中 |
+
+长生命周期服务不能随每个 case 动态改写 Prometheus label。runner 应在 resolved case 和终态中保存 `cluster_name`、相关 `instance_key`、`started_at_unix_ns` 与 `finished_at_unix_ns`,再用这组条件查询 Greptime。`case_id` 和 `run_index` 只需要出现在 run artifact、AIPerf 请求 header 及可选的 run marker 日志中。
+
+跨 AIPerf 请求时间和 Greptime 时序数据做叠图前,testbed preflight 必须检查 load generator 与 SUT 时钟。首期允许的最大时钟偏差应固定为 1 秒,超过阈值时 case 在启动前失败。
+
+### 8.3 runner API 与页面
+
+扩展现有 `/api/run_state`,增加一个按 mode 判别的 `benchmark` 对象:
+
+```json
+{
+ "benchmark": {
+ "kind": "AIPERF",
+ "status": "RUNNING",
+ "progress": {},
+ "summary": null,
+ "artifacts": [],
+ "monitor_window": {
+ "start_unix_ns": 0,
+ "end_unix_ns": null
+ }
+ }
+}
+```
+
+不新增平行的 AIPerf UI service。run 页面增加以下区域:
+
+1. workload 与 provenance
+2. warmup/profiling 进度
+3. TTFT、ITL、latency、throughput 和 error 摘要
+4. timeslice 与 SGLang server metrics 曲线
+5. Greptime 同时间窗口入口
+6. artifact 与 ops log 列表
+
+UI 服务重启后,历史页只依赖 run artifact 和 Greptime,不能依赖已经退出的 AIPerf API。
+
+## 9. 可复现性、性能边界与安全
+
+### 9.1 可复现性
+
+- 每个 scene 必须显式给出 `random_seed`、warmup、stop condition、ISL、OSL 和 streaming。
+- endpoint URL 从已解析的 SGLang instance 派生,不允许在 scene 中另写自由 URL。
+- load generator 与 SUT 首期必须位于不同 target,避免 generator CPU、网络和 SUT 资源相互竞争。
+- 记录 AIPerf commit、wheelhouse hash、Python 版本、SGLang image/commit、模型、tokenizer 和完整 generated config。
+- server metrics 始终使用相同的显式采样配置。AIPerf 当前 333 ms 采样行为本身也属于对比条件。
+- raw response 默认不保存;请求级 metrics JSONL 必须保存,以便复核 percentile 和异常请求。
+
+### 9.2 性能结论边界
+
+AIPerf 给出的请求指标覆盖 load generator 到 SGLang HTTP endpoint 的请求路径。它包含客户端排队、网络和服务端处理的组合影响,不能单独证明 Fluxon 某个内部模块的耗时。
+
+SGLang `/metrics` 与 Greptime 指标用于解释同一运行窗口内的服务状态。两者采样频率、时钟和标签不同,叠图只提供时间相关性,不自动证明因果关系。
+
+### 9.3 安全与数据保留
+
+- AIPerf API 只监听 loopback。
+- generated config、result 和 artifact path 都要经过 run_dir containment 校验。
+- 首期只使用 synthetic dataset,不接入真实用户 prompt。
+- `raw: false`、`trace: false` 为固定默认值。
+- endpoint headers 在 result 和日志中必须经过 AIPerf 现有 redaction,再由 adapter 二次校验。
+- 后续鉴权方案必须使用专门的 secret 注入路径,不能把 token 写入 suite、generated config 或普通环境变量。
+
+## 10. 实施阶段
+
+### 阶段 0:依赖与样例固化
+
+- 确认 `teleai` 快照就是目标版本,或取得明确的 TeleAI patch commit。
+- 构建离线 wheelhouse、license 清单和 SHA-256 manifest。
+- 用固定 SGLang mock/fixture 验证 AIPerf summary、records、server metrics 和 loopback API schema。
+- 保存一份脱敏 golden artifact,作为 adapter contract test 输入。
+
+### 阶段 1:终态 MVP
+
+- 在 suite schema 中加入 `AIPERF` mode 和有限 workload 字段。
+- 增加 case 编译、testbed port 分配、generated YAML 和 isolated venv prepare。
+- 增加 AIPerf adapter 的 direct-process 启动、timeout、cancel、artifact 校验和终态写入。
+- 增加专用 result validator,并把小型摘要写入 `summary.yaml`。
+- UI 先展示终态摘要、artifact 和日志。
+
+### 阶段 2:实时监控
+
+- adapter 每秒读取 loopback `/api/progress` 和 `/api/metrics`,原子更新进度快照。
+- 扩展现有 `/api/run_state` 和 run 页面,不新增服务。
+- 增加 timeslice 与 SGLang server metrics 图表。
+- 用 run 时间窗口和关联字段查询 Greptime。
+
+### 阶段 3:对比与门禁
+
+- 由 `test_runner` 管理重复 run 和基线选择,禁止打开 AIPerf multi-run 形成第二层历史。
+- 定义版本化 acceptance policy,明确 metric、stat、方向、阈值和缺失值行为。
+- 对 percentile 门禁优先读取请求级记录的 pooled 统计,不使用节点 percentile 的平均值。
+- 增加跨 run 对比页和机器可读的 evaluation 结果。
+
+## 11. 测试计划
+
+| 测试 | 执行模型 | 成功条件 |
+| --- | --- | --- |
+| config compile | contract test | suite 字段唯一映射到 generated YAML,未知字段快速失败 |
+| dependency provenance | contract test | commit、package version 和 wheelhouse hash 一致 |
+| happy path | 直接启动 adapter 进程 + mock OpenAI endpoint | exit 0、终态 `SUCCESS`、必需 artifact 存在 |
+| request failure | 直接启动 adapter 进程 + 失败响应 endpoint | 错误计数保留,至少一个成功请求时完成 |
+| invalid result | 直接启动 adapter 进程并注入损坏 artifact | `RESULT_INVALID`,runner case 失败 |
+| timeout | 直接启动 adapter 进程 + 挂起 endpoint | 整个进程组退出,终态 `TIMEOUT` |
+| cancel | 运行中发送 runner cancel | 终态 `CANCELLED`,没有残留 AIPerf 进程 |
+| API isolation | process test | API 只监听 loopback,testbed 外无法访问 |
+| UI restart | 启动常驻 UI,完成 run 后重启 UI | 历史摘要和图表仍能从 artifact 恢复 |
+| clock preflight | process test | 偏差超过 1 秒时在发压前失败 |
+| offline install | clean testbed process | 无外网条件下从 wheelhouse 完成安装和运行 |
+
+进程生命周期测试按独立脚本或进程直接运行,并显式检查 exit code 与残留进程。不要为了统一外观再套一层 pytest 入口。
+
+## 12. 验收条件
+
+首期完成必须同时满足:
+
+1. 用户仍只执行 `start testbed` 和 `testrunner`。
+2. suite 只有一个 `AIPERF` 配置入口,运行时只消费生成的单一 YAML。
+3. testbed 无外网时可以安装并运行固定 AIPerf 快照。
+4. runner 能区分启动失败、进程失败、结果损坏、超时和取消。
+5. UI 不公开 AIPerf API,run 结束和 UI 重启后仍能展示结果。
+6. TTFT、ITL、latency、throughput 和 error 指标可追溯到 AIPerf artifact,不经二次重算。
+7. AIPerf 请求窗口可以和 SGLang server metrics、Fluxon Greptime 指标及日志按 `case_id + run_index + time range` 对齐。
+8. 现有 KV、MQ、RPC、FS benchmark 路径和结果校验保持不变。
diff --git a/fluxon_py/__init__.py b/fluxon_py/__init__.py
index 753281c..7c57441 100644
--- a/fluxon_py/__init__.py
+++ b/fluxon_py/__init__.py
@@ -66,6 +66,9 @@
"KvFuture",
"MemHolder",
"FluxonMemHolder",
+ "GpuBufferRegistration",
+ "GpuDestination",
+ "GpuGetStartHandle",
# Backend management
"KvClientType",
"new_store",
@@ -148,6 +151,9 @@
_LAZY_PYO3 = {
"FluxonMemHolder": ("kvclient.fluxon", "FluxonMemHolder"),
+ "GpuBufferRegistration": ("kvclient.fluxon", "GpuBufferRegistration"),
+ "GpuDestination": ("kvclient.fluxon", "GpuDestination"),
+ "GpuGetStartHandle": ("kvclient.fluxon", "GpuGetStartHandle"),
}
diff --git a/fluxon_py/config.py b/fluxon_py/config.py
index 5861f64..7340d5f 100644
--- a/fluxon_py/config.py
+++ b/fluxon_py/config.py
@@ -66,6 +66,7 @@ def _yaml_template():
protocol_type: # Protocol type (('tcp'|'rdma'))
rdma_device_names: # Explicit RDMA devices for protocol config (['{str}'](optional))
pprof_duration_seconds: # Dump pprof flamegraph after N seconds (int(optional))
+replica_writeback_hot_capacity_ratio: 0.75 # Owner-local hot working-set ratio in the open interval zero to one (float(optional))
contribute_to_cluster_pool_size: # Capacity contributed to cluster pool (dict(optional))
dram: 1677721600 # - DRAM contribution (int(multiple of 16777216))
vram: # - VRAM contribution per GPU (dict(dynamic_key))
@@ -82,11 +83,18 @@ def _yaml_template():
prefer_local_placement: false # Prefer placing new KV writes on the requester-local owner when possible (bool(optional))
short_circuit_put_payload_path: false # Keep large put_start allocation but skip payload memcpy + transfer (bool(optional))
skip_put_end_commit: false # Return success after payload transfer without put_done commit; inflight_put TTL cleanup only (bool(optional))
+ ssd_read_source_policy: legacy_remote_first # legacy_remote_first|local_ssd_only_first (str(optional))
+ owner_local_reserve_soft_wait_timeout_ms: # Local-reserve polling interval, >0 (int(optional))
+ owner_local_reserve_hard_timeout_ms: # Local-reserve end-to-end claim timeout, > soft wait (int(optional))
+ owner_local_reserve_expected_capacity: # Owner-only local-reserve prewarm target (dict(optional))
+ value_len: # Canonical value payload bytes, >0 and <=512 MiB (int)
+ payload_capacity_bytes: # Expected payload capacity to keep resident, >0 (int)
transport_mode: # transfer_only|transfer_with_rpc (str(optional))
tcp_thread_reactor_shard_count: # tcp_thread reactor shard count, 1..16 (int(optional))
tcp_thread_bulk_lane_count: # tcp_thread bulk lane count, 1..8 (int(optional))
tcp_thread_control_lane_count: # tcp_thread control lane count, 1..8 (int(optional))
user_rpc_sync_handler_thread_count: # Owner-dedicated sync user-RPC worker thread count, >0 (int(optional))
+ replica_task_max_inflight: # Deprecated compatibility field, still validated as 1..64; direct remote-Put singleflight does not use a global actor queue (int(optional))
require_transfer_rpc_fast_path_ready_timeout_seconds: # Require owner-owner transfer-rpc fast path before owner ready/shared.json publication (int(optional))
rdma_device_names: # Explicit RDMA devices for benchmark/test fast-path fanout (['{str}'](optional))
enable_side_transfer: false # Enable TCP side-transfer fast-path (bool(optional))
@@ -110,6 +118,9 @@ def _yaml_template():
cluster_name: # Cluster name (str)
share_mem_path: # Shared bundle path for mmap.file/shared.json/peer metadata (str)
large_file_paths: # Owner-mode ordered large-file roots (['{str}'](optional))
+ large_limit_size: # Optional per-root SSD capacity in bytes (list(optional))
+ ssd_write_rate_limit_bytes_per_sec: # Optional non-queueing SSD write rate (int(optional))
+ ssd_write_burst_bytes: # Paired immediate SSD write burst (int(optional))
p2p_listen_port: # P2P QUIC listen port override (int(optional))
redis_compat: # Enable Redis protocol shim (dict(optional))
listen_addr: # TCP listen addr, e.g. "127.0.0.1:16379" (str)
@@ -135,11 +146,16 @@ def _normalize_test_spec_config(raw: Any, ctx: str) -> Dict[str, Any]:
"prefer_local_placement",
"short_circuit_put_payload_path",
"skip_put_end_commit",
+ "ssd_read_source_policy",
+ "owner_local_reserve_soft_wait_timeout_ms",
+ "owner_local_reserve_hard_timeout_ms",
+ "owner_local_reserve_expected_capacity",
"transport_mode",
"tcp_thread_reactor_shard_count",
"tcp_thread_bulk_lane_count",
"tcp_thread_control_lane_count",
"user_rpc_sync_handler_thread_count",
+ "replica_task_max_inflight",
"require_transfer_rpc_fast_path_ready_timeout_seconds",
"rdma_device_names",
"enable_side_transfer",
@@ -172,6 +188,21 @@ def _normalize_test_spec_config(raw: Any, ctx: str) -> Dict[str, Any]:
raise ValueError(f"{ctx}.{key} must be a bool")
out[key] = value
+ ssd_read_source_policy = raw.get("ssd_read_source_policy")
+ if ssd_read_source_policy is not None:
+ if not isinstance(ssd_read_source_policy, str):
+ raise ValueError(f"{ctx}.ssd_read_source_policy must be a string")
+ allowed_ssd_read_source_policies = {
+ "legacy_remote_first",
+ "local_ssd_only_first",
+ }
+ if ssd_read_source_policy not in allowed_ssd_read_source_policies:
+ raise ValueError(
+ f"{ctx}.ssd_read_source_policy must be one of "
+ f"{sorted(allowed_ssd_read_source_policies)}, got {ssd_read_source_policy!r}"
+ )
+ out["ssd_read_source_policy"] = ssd_read_source_policy
+
transport_mode = raw.get("transport_mode")
transport_mode_was_explicit = transport_mode is not None
side_transfer_role_raw = raw.get("side_transfer_role")
@@ -234,6 +265,7 @@ def _normalize_test_spec_config(raw: Any, ctx: str) -> Dict[str, Any]:
("tcp_thread_reactor_shard_count", 1, 16),
("tcp_thread_bulk_lane_count", 1, 8),
("tcp_thread_control_lane_count", 1, 8),
+ ("replica_task_max_inflight", 1, 64),
):
value = raw.get(key)
if value is None:
@@ -254,6 +286,50 @@ def _normalize_test_spec_config(raw: Any, ctx: str) -> Dict[str, Any]:
raise ValueError(f"{ctx}.user_rpc_sync_handler_thread_count must be > 0")
out["user_rpc_sync_handler_thread_count"] = user_rpc_sync_handler_thread_count
+ reserve_timeouts: Dict[str, int] = {}
+ for key in (
+ "owner_local_reserve_soft_wait_timeout_ms",
+ "owner_local_reserve_hard_timeout_ms",
+ ):
+ value = raw.get(key)
+ if value is None:
+ continue
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise ValueError(f"{ctx}.{key} must be an int")
+ if value <= 0:
+ raise ValueError(f"{ctx}.{key} must be > 0")
+ reserve_timeouts[key] = value
+ out[key] = value
+ soft_timeout_ms = reserve_timeouts.get("owner_local_reserve_soft_wait_timeout_ms", 10)
+ hard_timeout_ms = reserve_timeouts.get("owner_local_reserve_hard_timeout_ms", 10_000)
+ if hard_timeout_ms <= soft_timeout_ms:
+ raise ValueError(
+ f"{ctx}.owner_local_reserve_hard_timeout_ms must be greater than "
+ f"{ctx}.owner_local_reserve_soft_wait_timeout_ms"
+ )
+
+ expected_capacity = raw.get("owner_local_reserve_expected_capacity")
+ if expected_capacity is not None:
+ expected_ctx = f"{ctx}.owner_local_reserve_expected_capacity"
+ if not isinstance(expected_capacity, dict):
+ raise ValueError(f"{expected_ctx} must be a mapping")
+ unknown_expected = sorted(
+ set(expected_capacity.keys()) - {"value_len", "payload_capacity_bytes"}
+ )
+ if unknown_expected:
+ raise ValueError(f"{expected_ctx} contains unknown keys: {unknown_expected}")
+ normalized_expected: Dict[str, int] = {}
+ for key in ("value_len", "payload_capacity_bytes"):
+ value = expected_capacity.get(key)
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise ValueError(f"{expected_ctx}.{key} must be an int")
+ if value <= 0:
+ raise ValueError(f"{expected_ctx}.{key} must be > 0")
+ normalized_expected[key] = value
+ if normalized_expected["value_len"] > 512 * 1024 * 1024:
+ raise ValueError(f"{expected_ctx}.value_len must be <= 536870912")
+ out["owner_local_reserve_expected_capacity"] = normalized_expected
+
side_transfer_worker_count = raw.get("side_transfer_worker_count")
if side_transfer_worker_count is not None:
if isinstance(side_transfer_worker_count, bool) or not isinstance(side_transfer_worker_count, int):
@@ -348,6 +424,23 @@ def _validate_fluxonkv_contract(cfg: Dict[str, Any]) -> None:
raise ValueError("fluxonkv_spec must be a mapping")
is_zero_contribution = _is_zero_contribution_fluxonkv_config(cfg)
+ test_spec_config = cfg.get("test_spec_config") or {}
+ expected_capacity = test_spec_config.get("owner_local_reserve_expected_capacity")
+ hot_capacity_ratio = cfg.get("replica_writeback_hot_capacity_ratio")
+
+ if hot_capacity_ratio is not None:
+ if isinstance(hot_capacity_ratio, bool) or not isinstance(
+ hot_capacity_ratio, (int, float)
+ ):
+ raise ValueError(
+ "replica_writeback_hot_capacity_ratio must be a number in (0, 1)"
+ )
+ hot_capacity_ratio = float(hot_capacity_ratio)
+ if not 0.0 < hot_capacity_ratio < 1.0:
+ raise ValueError(
+ "replica_writeback_hot_capacity_ratio must be finite and in (0, 1)"
+ )
+ cfg["replica_writeback_hot_capacity_ratio"] = hot_capacity_ratio
share_mem_path = spec.get("share_mem_path")
if not isinstance(share_mem_path, str) or not share_mem_path.strip():
@@ -360,11 +453,22 @@ def _validate_fluxonkv_contract(cfg: Dict[str, Any]) -> None:
raise ValueError("fluxonkv_spec.transfer_engine has been removed from Fluxon KV config")
if is_zero_contribution:
+ if hot_capacity_ratio is not None:
+ raise ValueError(
+ "replica_writeback_hot_capacity_ratio is only valid on owner configs"
+ )
+ if expected_capacity is not None:
+ raise ValueError(
+ "test_spec_config.owner_local_reserve_expected_capacity is only valid on owner configs"
+ )
forbidden_spec_keys = [
"etcd_addresses",
"redis_compat",
"sub_cluster",
"large_file_paths",
+ "large_limit_size",
+ "ssd_write_rate_limit_bytes_per_sec",
+ "ssd_write_burst_bytes",
]
for key in forbidden_spec_keys:
if key in spec:
@@ -379,6 +483,23 @@ def _validate_fluxonkv_contract(cfg: Dict[str, Any]) -> None:
if int(contrib["dram"]) == 0:
raise ValueError("owner mode requires non-zero contribute_to_cluster_pool_size.dram")
+ if expected_capacity is not None:
+ value_len = int(expected_capacity["value_len"])
+ payload_capacity_bytes = int(expected_capacity["payload_capacity_bytes"])
+ slot_size = max(value_len, 4096)
+ slot_size = (slot_size + 4095) & ~4095
+ slots_per_grant = (512 * 1024 * 1024) // slot_size
+ value_count = (payload_capacity_bytes + value_len - 1) // value_len
+ expected_grants = (value_count + slots_per_grant - 1) // slots_per_grant
+ physical_reserve_bytes = expected_grants * 512 * 1024 * 1024
+ owner_dram_bytes = int(contrib["dram"])
+ if physical_reserve_bytes > owner_dram_bytes:
+ raise ValueError(
+ "test_spec_config.owner_local_reserve_expected_capacity requires "
+ f"{physical_reserve_bytes} physical bytes across {expected_grants} grants, "
+ f"exceeding owner dram contribution {owner_dram_bytes}"
+ )
+
if "etcd_addresses" not in spec:
raise ValueError("fluxonkv_spec.etcd_addresses is required for owner mode")
etcd_addresses = spec.get("etcd_addresses")
@@ -404,6 +525,26 @@ def _validate_fluxonkv_contract(cfg: Dict[str, Any]) -> None:
f"fluxonkv_spec.large_file_paths[{idx}] must be a non-empty string in owner mode"
)
+ write_rate = spec.get("ssd_write_rate_limit_bytes_per_sec")
+ write_burst = spec.get("ssd_write_burst_bytes")
+ if (write_rate is None) != (write_burst is None):
+ raise ValueError(
+ "fluxonkv_spec.ssd_write_rate_limit_bytes_per_sec and "
+ "ssd_write_burst_bytes must be configured together"
+ )
+ if write_rate is not None:
+ if (
+ isinstance(write_rate, bool)
+ or not isinstance(write_rate, int)
+ or write_rate <= 0
+ or isinstance(write_burst, bool)
+ or not isinstance(write_burst, int)
+ or write_burst <= 0
+ ):
+ raise ValueError("SSD write rate and burst must both be positive integers")
+ if "large_limit_size" not in spec:
+ raise ValueError("SSD write admission requires fluxonkv_spec.large_limit_size")
+
class FluxonKvClientConfig():
"""Configuration class for KV Cache stores that reads from YAML config files."""
@@ -434,6 +575,10 @@ def __init__(self, config_dict: Dict[str, Any]):
raise ValueError(
"exactly one of [mooncake_spec, fluxonkv_spec] is required (and the chosen spec must not be null)"
)
+ if "replica_writeback_hot_capacity_ratio" in plain and not has_fluxon:
+ raise ValueError(
+ "replica_writeback_hot_capacity_ratio requires fluxonkv_spec"
+ )
pprof_duration_seconds = plain.get("pprof_duration_seconds")
if pprof_duration_seconds is None:
@@ -750,8 +895,8 @@ def parse_type_recursive(type_str: str) -> Optional[Tuple[str, Dict[str, Any]]]:
return parsed_type_name, merged_params
return type_name, {"constraint": constraint}
- # 6) Primitive types: str, int, bool, None
- if type_str in ["str", "int", "bool", "None"]:
+ # 6) Primitive types: str, int, float, bool, list, None
+ if type_str in ["str", "int", "float", "bool", "list", "None"]:
return type_str, {}
debug_print("type_str ", type_str, "not matched to any type")
@@ -871,13 +1016,29 @@ def raise_validation_error(msg: str):
raise_validation_error(f"Value must be multiple of {multiple}, got {value}")
else:
return None
-
+
+ elif type_name == "float":
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ if raise_err:
+ raise_validation_error(
+ f"Expected float-compatible number, got {type(value).__name__}"
+ )
+ else:
+ return None
+
elif type_name == "bool":
if not isinstance(value, bool):
if raise_err:
raise_validation_error(f"Expected bool, got {type(value).__name__}")
else:
return None
+
+ elif type_name == "list":
+ if not isinstance(value, list):
+ if raise_err:
+ raise_validation_error(f"Expected list, got {type(value).__name__}")
+ else:
+ return None
elif type_name == "dict":
if not isinstance(value, dict):
diff --git a/fluxon_py/kvclient/fluxon.py b/fluxon_py/kvclient/fluxon.py
index 1325e3d..25f3776 100644
--- a/fluxon_py/kvclient/fluxon.py
+++ b/fluxon_py/kvclient/fluxon.py
@@ -3,6 +3,7 @@
This module provides a concrete implementation using the PyO3 Rust bindings.
"""
+from dataclasses import dataclass
from typing import Union, Optional, Callable, Any, Dict, List, Tuple
import ctypes
import os
@@ -25,7 +26,7 @@
from .kvclient_interface import KvClient
from .kvclient_interface import KvLeaseApi, KvRpcApi, PutOptionalArgs, FlatDict
from .backend_fallback_close import unregister_store_from_cleanup
-from .kvclient_interface import KvFuture, MemHolder
+from .kvclient_interface import GetStartHandle, GetStartResult, KvFuture, MemHolder
from .nonzerocopy_encode import (
DLPacked,
INTERNAL_DLPACK_META_KEY,
@@ -54,6 +55,63 @@
_SIDE_TRANSFER_WORKER_PYTHON_ENV = "FLUXON_KV_SIDE_WORKER_PYTHON"
_BLOCKING_PUT_OUTER_TOTAL_LOG_INTERVAL_NS = 10 * 1_000_000_000
+_MIN_EXPLICIT_RPC_TIMEOUT_MS = int(fluxon_pyo3.MIN_EXPLICIT_RPC_TIMEOUT_MS)
+
+
+def _validate_explicit_rpc_timeout_ms(timeout_ms: int) -> None:
+ if not isinstance(timeout_ms, int):
+ raise InvalidArgumentError(message=f"timeout_ms must be int; got {type(timeout_ms)}")
+ if timeout_ms < _MIN_EXPLICIT_RPC_TIMEOUT_MS:
+ raise InvalidArgumentError(
+ message=(
+ f"timeout_ms must be >= {_MIN_EXPLICIT_RPC_TIMEOUT_MS}; "
+ f"got {timeout_ms}"
+ )
+ )
+
+
+def _validate_put_atomic_groups(
+ keys: List[str],
+ atomic_group_lens: Optional[List[int]],
+ make_replica_task_mask: Optional[List[bool]],
+) -> Optional[List[int]]:
+ if atomic_group_lens is None:
+ return None
+ if not isinstance(atomic_group_lens, list):
+ raise ValueError(
+ "atomic_group_lens must be list[int] when provided; "
+ f"got {type(atomic_group_lens)}"
+ )
+ normalized_group_lens: List[int] = []
+ for index, length in enumerate(atomic_group_lens):
+ if type(length) is not int:
+ raise ValueError(
+ "atomic_group_lens items must be int; "
+ f"index={index} got={type(length)}"
+ )
+ if length <= 0:
+ raise ValueError(
+ "atomic_group_lens entries must be > 0; "
+ f"index={index} got={length}"
+ )
+ normalized_group_lens.append(length)
+ group_sum = sum(normalized_group_lens)
+ if group_sum != len(keys):
+ raise ValueError(
+ "atomic_group_lens must sum to keys length; "
+ f"sum={group_sum} keys={len(keys)}"
+ )
+ if make_replica_task_mask is not None:
+ offset = 0
+ for group_index, group_len in enumerate(normalized_group_lens):
+ group_mask = make_replica_task_mask[offset : offset + group_len]
+ if any(item != group_mask[0] for item in group_mask[1:]):
+ raise ValueError(
+ "make_replica_task_mask must be uniform within each atomic group; "
+ f"group_index={group_index} offset={offset} len={group_len}"
+ )
+ offset += group_len
+ return normalized_group_lens
def _percentile_nearest_rank_ns(sorted_values: List[int], percentile: int) -> int:
@@ -138,11 +196,233 @@ def _resolve_side_transfer_worker_python() -> str:
return sys.executable
+@dataclass(frozen=True)
+class _RegisteredBufferDescriptor:
+ ptr: int
+ size: int
+ device_kind: str = "host"
+ device_id: str = ""
+ layout: str = "raw"
+ metadata: Optional[Dict[str, Any]] = None
+
+ @property
+ def end(self) -> int:
+ return self.ptr + self.size
+
+ def contains(self, ptr: int, size: int) -> bool:
+ req_end = ptr + size
+ return ptr >= self.ptr and req_end <= self.end
+
+ def as_dict(self) -> Dict[str, Any]:
+ return {
+ "ptr": self.ptr,
+ "size": self.size,
+ "device_kind": self.device_kind,
+ "device_id": self.device_id,
+ "layout": self.layout,
+ "metadata": dict(self.metadata or {}),
+ }
+
+
+@dataclass(frozen=True)
+class GpuBufferRegistration:
+ registration_id: int
+ ptr: int
+ size: int
+ device_id: int
+
+ def __post_init__(self) -> None:
+ if type(self.registration_id) is not int or self.registration_id <= 0:
+ raise ValueError("registration_id must be a positive int")
+ if type(self.ptr) is not int or self.ptr <= 0:
+ raise ValueError("GPU registration ptr must be a positive int")
+ if type(self.size) is not int or self.size <= 0:
+ raise ValueError("GPU registration size must be a positive int")
+ if type(self.device_id) is not int or self.device_id < 0:
+ raise ValueError("GPU device_id must be a non-negative int")
+ if self.end > (1 << 64):
+ raise ValueError("GPU registration range overflows u64")
+
+ @property
+ def end(self) -> int:
+ return self.ptr + self.size
+
+ def destination(self, ptr: int, capacity: int) -> "GpuDestination":
+ destination = GpuDestination(
+ registration_id=self.registration_id,
+ ptr=ptr,
+ capacity=capacity,
+ )
+ if destination.ptr < self.ptr or destination.end > self.end:
+ raise ValueError(
+ "GPU destination is outside its registration: "
+ f"registration=[{self.ptr:#x},{self.end:#x}) "
+ f"destination=[{destination.ptr:#x},{destination.end:#x})"
+ )
+ return destination
+
+
+@dataclass(frozen=True)
+class GpuDestination:
+ registration_id: int
+ ptr: int
+ capacity: int
+
+ def __post_init__(self) -> None:
+ if type(self.registration_id) is not int or self.registration_id <= 0:
+ raise ValueError("registration_id must be a positive int")
+ if type(self.ptr) is not int or self.ptr <= 0:
+ raise ValueError("GPU destination ptr must be a positive int")
+ if type(self.capacity) is not int or self.capacity <= 0:
+ raise ValueError("GPU destination capacity must be a positive int")
+ if self.end > (1 << 64):
+ raise ValueError("GPU destination range overflows u64")
+
+ @property
+ def end(self) -> int:
+ return self.ptr + self.capacity
+
+
+@dataclass
+class GpuGetStartHandle:
+ """One-shot handle for a background RDMA pull into GPU staging."""
+
+ keys: Tuple[str, ...]
+ destinations: Tuple[GpuDestination, ...]
+ result: GetStartResult
+ created_at_ns: int
+ backend_token: int
+ backend_handle: int
+ remote_indices: Tuple[int, ...]
+ closed: bool = False
+ transfer_wall_us: Optional[int] = None
+ finish_wait_us: Optional[int] = None
+ terminal_before_consume: Optional[bool] = None
+ terminal_to_consume_us: Optional[int] = None
+
+
+@dataclass
+class GetPlanHandle:
+ """Target-free Get plan that must be executed once or cancelled."""
+
+ keys: Tuple[str, ...]
+ result: GetStartResult
+ gpu_result: GetStartResult
+ created_at_ns: int
+ backend_token: int
+ backend_handle: int
+ gpu_remote_indices: Tuple[int, ...]
+ closed: bool = False
+
+
def _map_nospace_to_storagefull(err: ApiError) -> ApiError:
"""Normalize storage-capacity errors without depending on backend internals."""
return err
+def _get_start_prefix_hit_groups(
+ raw_prefix_hit_len: int,
+ group_lens: Tuple[int, ...],
+) -> int:
+ prefix_hit_groups = 0
+ transferable_len = 0
+ for group_len in group_lens:
+ next_transferable_len = transferable_len + group_len
+ if next_transferable_len > raw_prefix_hit_len:
+ break
+ transferable_len = next_transferable_len
+ prefix_hit_groups += 1
+ return prefix_hit_groups
+
+
+def _get_start_group_index_for_key_index(
+ group_lens: Tuple[int, ...],
+ key_index: int,
+) -> Optional[int]:
+ cursor = 0
+ for group_index, group_len in enumerate(group_lens):
+ cursor += group_len
+ if key_index < cursor:
+ return group_index
+ return None
+
+
+def _build_get_start_result_from_backend_payload(
+ payload: Dict[str, Any],
+ keys: List[str],
+ prefix_best_effort: bool,
+ normalized_group_lens: Optional[List[int]],
+) -> GetStartResult:
+ result_keys = tuple(keys)
+ group_lens = (
+ (len(result_keys),)
+ if normalized_group_lens is None
+ else tuple(normalized_group_lens)
+ )
+ raw_prefix_hit_len = int(payload["raw_prefix_hit_len"])
+ if raw_prefix_hit_len < 0 or raw_prefix_hit_len > len(result_keys):
+ raise RuntimeError(
+ "get_start returned invalid raw_prefix_hit_len: "
+ f"raw_prefix_hit_len={raw_prefix_hit_len} keys={len(result_keys)}"
+ )
+ prefix_hit_groups = _get_start_prefix_hit_groups(
+ raw_prefix_hit_len,
+ group_lens,
+ )
+ if not prefix_best_effort and prefix_hit_groups != len(group_lens):
+ transferable_len = 0
+ prefix_hit_groups = 0
+ else:
+ transferable_len = sum(group_lens[:prefix_hit_groups])
+ first_miss_index = (
+ None if raw_prefix_hit_len == len(result_keys) else raw_prefix_hit_len
+ )
+ first_miss_group_index = (
+ None
+ if first_miss_index is None
+ else _get_start_group_index_for_key_index(group_lens, first_miss_index)
+ )
+ return GetStartResult(
+ keys=result_keys,
+ raw_prefix_hit_len=raw_prefix_hit_len,
+ transferable_len=transferable_len,
+ prefix_hit_groups=prefix_hit_groups,
+ atomic_group_lens=tuple(normalized_group_lens) if normalized_group_lens is not None else None,
+ prefix_best_effort=prefix_best_effort,
+ first_miss_index=first_miss_index,
+ first_miss_group_index=first_miss_group_index,
+ all_hit=transferable_len == len(result_keys),
+ )
+
+
+def _narrow_get_start_result(result: GetStartResult, consume_prefix_len: int) -> GetStartResult:
+ group_lens = result.atomic_group_lens or (len(result.keys),)
+ selected_groups: List[int] = []
+ cursor = 0
+ for group_len in group_lens:
+ if cursor + group_len > consume_prefix_len:
+ break
+ selected_groups.append(int(group_len))
+ cursor += int(group_len)
+ if cursor != consume_prefix_len:
+ raise ValueError(
+ "consume_prefix_len must end at an atomic-group boundary: "
+ f"consume={consume_prefix_len} groups={group_lens}"
+ )
+ keys = result.keys[:consume_prefix_len]
+ return GetStartResult(
+ keys=keys,
+ raw_prefix_hit_len=consume_prefix_len,
+ transferable_len=consume_prefix_len,
+ prefix_hit_groups=len(selected_groups),
+ atomic_group_lens=tuple(selected_groups),
+ prefix_best_effort=result.prefix_best_effort,
+ first_miss_index=None,
+ first_miss_group_index=None,
+ all_hit=True,
+ )
+
+
def _error_to_ret_code(err: ApiError) -> int:
if hasattr(err, "code") and callable(err.code):
try:
@@ -299,7 +579,12 @@ def __init__(self, config: FluxonKvClientConfig):
self._client: Optional[fluxon_pyo3.KvClient] = None
self._config = config
self._init_error: Optional[ApiError] = None
+ self._registered_buffer_descriptors: List[_RegisteredBufferDescriptor] = []
+ self._gpu_buffer_registration: Optional[GpuBufferRegistration] = None
cluster_name = config.fluxonkv_spec_cluster_name
+ self._batch_concurrency = 128
+ if self._batch_concurrency <= 0:
+ raise ValueError("batch_concurrency must be > 0")
self._blocking_put_outer_total_log_window = _BlockingPutOuterTotalLogWindow(
f"FluxonKVCacheStore[{cluster_name}]"
)
@@ -368,11 +653,21 @@ def put(
reject_if_inflight_same_key = (
bool(opts.reject_if_inflight_same_key) if opts is not None else False
)
+ reject_if_exist_same_key = (
+ bool(opts.reject_if_exist_same_key) if opts is not None else False
+ )
+ write_through = bool(opts.write_through) if opts is not None else True
+ make_replica_task = (
+ bool(opts.make_replica_task) if opts is not None else True
+ )
inner_res = self._client.put(
key,
ptrs,
lease_id=lease_id,
reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exist_same_key=reject_if_exist_same_key,
+ write_through=write_through,
+ make_replica_task=make_replica_task,
)
if not inner_res.is_ok():
err = inner_res.unwrap_error()
@@ -416,11 +711,21 @@ def put_blocking(
reject_if_inflight_same_key = (
bool(opts.reject_if_inflight_same_key) if opts is not None else False
)
+ reject_if_exist_same_key = (
+ bool(opts.reject_if_exist_same_key) if opts is not None else False
+ )
+ write_through = bool(opts.write_through) if opts is not None else True
+ make_replica_task = (
+ bool(opts.make_replica_task) if opts is not None else True
+ )
inner_res = self._client.put_blocking(
key,
ptrs,
lease_id=lease_id,
reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exist_same_key=reject_if_exist_same_key,
+ write_through=write_through,
+ make_replica_task=make_replica_task,
)
if not inner_res.is_ok():
return Result.new_error(inner_res.unwrap_error())
@@ -448,6 +753,205 @@ def get_blocking(self, key: str) -> Result[MemHolder, ApiError]:
except ApiError as e:
return Result.new_error(e)
+ @staticmethod
+ def _normalize_batch_result_list(batch_result: Any, expected_len: int, op_name: str) -> List[Any]:
+ if isinstance(batch_result, Result):
+ if not batch_result.is_ok():
+ raise RuntimeError(f"{op_name} backend error: {batch_result.unwrap_error()}")
+ batch_result = batch_result.unwrap()
+ if not isinstance(batch_result, list):
+ raise RuntimeError(f"{op_name} returned non-list: {type(batch_result)}")
+ if len(batch_result) != expected_len:
+ raise RuntimeError(
+ f"{op_name} returned unexpected length: expected={expected_len} got={len(batch_result)}"
+ )
+ return list(batch_result)
+
+ def batch_put_blocking(
+ self,
+ keys: List[str],
+ values: List[FlatDict],
+ opts: Optional[PutOptionalArgs] = None,
+ concurrency: Optional[int] = None,
+ ) -> List[Result[OkNone, ApiError]]:
+ if len(keys) != len(values):
+ raise ValueError("batch_put_blocking requires keys and values to have the same length")
+ if len(keys) == 0:
+ return []
+ if self._client is None:
+ err = GeneralError(message="Store not initialized when batch_put_blocking(). Call setup() first.")
+ return [Result.new_error(err) for _ in keys]
+
+ lease_id: Optional[int] = opts.lease_id if opts is not None else None
+ reject_if_inflight_same_key = (
+ bool(opts.reject_if_inflight_same_key) if opts is not None else False
+ )
+ reject_if_exist_same_key = (
+ bool(opts.reject_if_exist_same_key) if opts is not None else False
+ )
+ write_through = bool(opts.write_through) if opts is not None else True
+ make_replica_task = bool(opts.make_replica_task) if opts is not None else True
+
+ keepalive_groups: List[List[bytes]] = []
+ dlpack_groups: List[List[object]] = []
+ ptr_groups: List[List[tuple[int, int, int, int, int, Optional[int]]]] = []
+ try:
+ for value in values:
+ keepalive: List[bytes] = []
+ dlpack_capsules: List[object] = []
+ ptr_groups.append(build_flat_dict_ptrs(value, keepalive, dlpack_capsules))
+ keepalive_groups.append(keepalive)
+ dlpack_groups.append(dlpack_capsules)
+
+ inner_res = self._client.batch_put_blocking(
+ keys,
+ ptr_groups,
+ lease_id=lease_id,
+ reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exist_same_key=reject_if_exist_same_key,
+ write_through=write_through,
+ make_replica_task=make_replica_task,
+ concurrency=concurrency if concurrency is not None else self._batch_concurrency,
+ )
+ if not inner_res.is_ok():
+ err = inner_res.unwrap_error()
+ return [Result.new_error(err) for _ in keys]
+
+ batch_results = self._normalize_batch_result_list(
+ inner_res.unwrap(), len(keys), "batch_put_blocking"
+ )
+ submit_out: List[Result[OkNone, ApiError]] = []
+ for idx, item in enumerate(batch_results):
+ if isinstance(item, Result):
+ submit_out.append(item)
+ continue
+ if item is None:
+ submit_out.append(Result.new_ok(OkNone()))
+ continue
+ if isinstance(item, int) and item == 0:
+ submit_out.append(Result.new_ok(OkNone()))
+ continue
+ if isinstance(item, int):
+ submit_out.append(
+ Result.new_error(
+ GeneralError(
+ message=(
+ "batch_put_blocking returned backend code "
+ f"{item} for key {keys[idx]!r}"
+ )
+ )
+ )
+ )
+ continue
+ submit_out.append(
+ Result.new_error(
+ GeneralError(
+ message=f"unexpected batch_put result type: {type(item)}"
+ )
+ )
+ )
+ return submit_out
+ except ApiError as e:
+ return [Result.new_error(e) for _ in keys]
+ except Exception as e:
+ return [Result.new_error(GeneralError(f"batch_put_blocking failed: {e}")) for _ in keys]
+ finally:
+ for keepalive in keepalive_groups:
+ keepalive.clear()
+ for dlpack_capsules in dlpack_groups:
+ dlpack_capsules.clear()
+
+ def batch_get_blocking(
+ self,
+ keys: List[str],
+ concurrency: Optional[int] = None,
+ ) -> List[Result[Union[Any, MemHolder], ApiError]]:
+ if len(keys) == 0:
+ return []
+ if self._client is None:
+ err = GeneralError(message="Store not initialized when batch_get_blocking(). Call setup() first.")
+ return [Result.new_error(err) for _ in keys]
+
+ try:
+ inner_res = self._client.batch_get_blocking(
+ keys,
+ concurrency=concurrency if concurrency is not None else self._batch_concurrency,
+ )
+ if not inner_res.is_ok():
+ err = inner_res.unwrap_error()
+ return [Result.new_error(err) for _ in keys]
+
+ batch_results = self._normalize_batch_result_list(
+ inner_res.unwrap(), len(keys), "batch_get_blocking"
+ )
+ out: List[Result[Union[Any, MemHolder], ApiError]] = []
+ for idx, item in enumerate(batch_results):
+ if isinstance(item, Result):
+ out.append(item)
+ continue
+ if item is None:
+ out.append(
+ Result.new_error(
+ GeneralError(message=f"batch_get_blocking returned None for key {keys[idx]!r}")
+ )
+ )
+ continue
+ out.append(Result.new_ok(item))
+ return out
+ except ApiError as e:
+ return [Result.new_error(e) for _ in keys]
+ except Exception as e:
+ return [Result.new_error(GeneralError(f"batch_get_blocking failed: {e}")) for _ in keys]
+
+ def put_payload_from_ptr(
+ self,
+ key: str,
+ payload_ptr: int,
+ payload_size: int,
+ opts: Optional[PutOptionalArgs] = None,
+ ) -> Result[KvFuture, ApiError]:
+ if self._client is None:
+ return Result.new_error(
+ GeneralError(
+ message="Store not initialized when put_payload_from_ptr(). Call setup() first."
+ )
+ )
+
+ keepalive: List[bytes] = []
+ try:
+ ptrs = _build_payload_field_ptrs(payload_ptr, payload_size, keepalive)
+ lease_id: Optional[int] = opts.lease_id if opts is not None else None
+ reject_if_inflight_same_key = (
+ bool(opts.reject_if_inflight_same_key) if opts is not None else False
+ )
+ reject_if_exist_same_key = (
+ bool(opts.reject_if_exist_same_key) if opts is not None else False
+ )
+ write_through = bool(opts.write_through) if opts is not None else True
+ make_replica_task = (
+ bool(opts.make_replica_task) if opts is not None else True
+ )
+ inner_res = self._client.put(
+ key,
+ ptrs,
+ lease_id=lease_id,
+ reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exist_same_key=reject_if_exist_same_key,
+ write_through=write_through,
+ make_replica_task=make_replica_task,
+ )
+ if not inner_res.is_ok():
+ return Result.new_error(_map_nospace_to_storagefull(inner_res.unwrap_error()))
+ inner_future = inner_res.unwrap()
+ assert inner_future is not None
+ outer_future = _FluxonPutFuture(inner_future, keepalive, [])
+ keepalive = []
+ return Result.new_ok(outer_future)
+ except ApiError as e:
+ return Result.new_error(e)
+ finally:
+ keepalive.clear()
+
def put_payload_from_ptr_blocking(
self,
key: str,
@@ -469,11 +973,21 @@ def put_payload_from_ptr_blocking(
reject_if_inflight_same_key = (
bool(opts.reject_if_inflight_same_key) if opts is not None else False
)
+ reject_if_exist_same_key = (
+ bool(opts.reject_if_exist_same_key) if opts is not None else False
+ )
+ write_through = bool(opts.write_through) if opts is not None else True
+ make_replica_task = (
+ bool(opts.make_replica_task) if opts is not None else True
+ )
inner_res = self._client.put_blocking(
key,
ptrs,
lease_id=lease_id,
reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exist_same_key=reject_if_exist_same_key,
+ write_through=write_through,
+ make_replica_task=make_replica_task,
)
if not inner_res.is_ok():
return Result.new_error(inner_res.unwrap_error())
@@ -566,17 +1080,466 @@ def get_payload_into_ptr_blocking(
del holder
return Result.new_ok(payload_size)
+ def register_buffer(
+ self,
+ ptr: int,
+ size: int,
+ device_kind: str = "host",
+ device_id: str = "",
+ layout: str = "raw",
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> Result[OkNone, ApiError]:
+ if self._client is None:
+ return Result.new_error(
+ GeneralError(
+ message="Store not initialized when register_buffer(). Call setup() first."
+ )
+ )
+ if not isinstance(ptr, int):
+ return Result.new_error(
+ InvalidArgumentError(message=f"ptr must be int; got {type(ptr)}")
+ )
+ if not isinstance(size, int):
+ return Result.new_error(
+ InvalidArgumentError(message=f"size must be int; got {type(size)}")
+ )
+ if ptr < 0:
+ return Result.new_error(
+ InvalidArgumentError(message=f"ptr must be >= 0; got {ptr}")
+ )
+ if size < 0:
+ return Result.new_error(
+ InvalidArgumentError(message=f"size must be >= 0; got {size}")
+ )
+ if not isinstance(device_kind, str):
+ return Result.new_error(
+ InvalidArgumentError(
+ message=f"device_kind must be str; got {type(device_kind)}"
+ )
+ )
+ if device_kind.strip().lower() != "host":
+ return Result.new_error(
+ InvalidArgumentError(
+ message=(
+ "register_buffer supports host memory only; "
+ "use register_gpu_buffer for a CUDA staging range"
+ )
+ )
+ )
+ if not isinstance(device_id, str):
+ return Result.new_error(
+ InvalidArgumentError(
+ message=f"device_id must be str; got {type(device_id)}"
+ )
+ )
+ if not isinstance(layout, str):
+ return Result.new_error(
+ InvalidArgumentError(
+ message=f"layout must be str; got {type(layout)}"
+ )
+ )
+ if metadata is not None and not isinstance(metadata, dict):
+ return Result.new_error(
+ InvalidArgumentError(
+ message=f"metadata must be dict or None; got {type(metadata)}"
+ )
+ )
+ try:
+ inner_res = self._client.register_buffer(ptr, size)
+ if not inner_res.is_ok():
+ return Result.new_error(inner_res.unwrap_error())
+ _ = inner_res.unwrap()
+ self._registered_buffer_descriptors.append(
+ _RegisteredBufferDescriptor(
+ ptr=int(ptr),
+ size=int(size),
+ device_kind=device_kind,
+ device_id=device_id,
+ layout=layout,
+ metadata=dict(metadata or {}),
+ )
+ )
+ return Result.new_ok(OkNone())
+ except ApiError as e:
+ return Result.new_error(e)
+
+ def register_gpu_buffer(
+ self,
+ ptr: int,
+ size: int,
+ device_id: int,
+ ) -> Result[GpuBufferRegistration, ApiError]:
+ if self._client is None:
+ return Result.new_error(
+ GeneralError(
+ message="Store not initialized when register_gpu_buffer(). Call setup() first."
+ )
+ )
+ if isinstance(ptr, bool) or not isinstance(ptr, int) or ptr <= 0:
+ return Result.new_error(
+ InvalidArgumentError(message=f"GPU ptr must be a positive int; got {ptr!r}")
+ )
+ if isinstance(size, bool) or not isinstance(size, int) or size <= 0:
+ return Result.new_error(
+ InvalidArgumentError(message=f"GPU size must be a positive int; got {size!r}")
+ )
+ if (
+ isinstance(device_id, bool)
+ or not isinstance(device_id, int)
+ or device_id < 0
+ ):
+ return Result.new_error(
+ InvalidArgumentError(
+ message=f"GPU device_id must be a non-negative int; got {device_id!r}"
+ )
+ )
+ if ptr + size > (1 << 64):
+ return Result.new_error(
+ InvalidArgumentError(
+ message=f"GPU registration range overflows u64: ptr={ptr:#x} size={size}"
+ )
+ )
+ if self._gpu_buffer_registration is not None:
+ return Result.new_error(
+ InvalidArgumentError(
+ message=(
+ "one GPU buffer is already registered: "
+ f"registration_id={self._gpu_buffer_registration.registration_id}"
+ )
+ )
+ )
+ try:
+ inner_res = self._client.register_gpu_buffer(ptr, size, device_id)
+ if not inner_res.is_ok():
+ return Result.new_error(inner_res.unwrap_error())
+ payload = inner_res.unwrap()
+ if not isinstance(payload, dict):
+ return Result.new_error(
+ GeneralError(
+ message=f"register_gpu_buffer returned non-dict payload: {type(payload)}"
+ )
+ )
+ registration = GpuBufferRegistration(
+ registration_id=int(payload["registration_id"]),
+ ptr=int(payload["ptr"]),
+ size=int(payload["len"]),
+ device_id=int(payload["device_id"]),
+ )
+ if (
+ registration.registration_id <= 0
+ or registration.ptr != ptr
+ or registration.size != size
+ or registration.device_id != device_id
+ ):
+ return Result.new_error(
+ GeneralError(
+ message=(
+ "register_gpu_buffer returned a mismatched registration: "
+ f"{registration!r}"
+ )
+ )
+ )
+ self._gpu_buffer_registration = registration
+ return Result.new_ok(registration)
+ except ApiError as e:
+ return Result.new_error(e)
+
+ def validate_gpu_destination(
+ self,
+ destination: GpuDestination,
+ ) -> Result[OkNone, ApiError]:
+ if self._client is None:
+ return Result.new_error(
+ GeneralError(
+ message="Store not initialized when validate_gpu_destination()."
+ )
+ )
+ if not isinstance(destination, GpuDestination):
+ return Result.new_error(
+ InvalidArgumentError(
+ message=(
+ "validate_gpu_destination requires GpuDestination; "
+ f"got {type(destination)}"
+ )
+ )
+ )
+ if destination.capacity <= 0 or destination.ptr <= 0:
+ return Result.new_error(
+ InvalidArgumentError(
+ message=f"invalid GPU destination: {destination!r}"
+ )
+ )
+ try:
+ inner_res = self._client.validate_gpu_destination(
+ destination.registration_id,
+ destination.ptr,
+ destination.capacity,
+ )
+ if not inner_res.is_ok():
+ return Result.new_error(inner_res.unwrap_error())
+ _ = inner_res.unwrap()
+ return Result.new_ok(OkNone())
+ except ApiError as e:
+ return Result.new_error(e)
+
+ def unregister_gpu_buffer(
+ self,
+ registration: GpuBufferRegistration,
+ ) -> Result[OkNone, ApiError]:
+ if self._client is None:
+ return Result.new_error(
+ GeneralError(
+ message="Store not initialized when unregister_gpu_buffer()."
+ )
+ )
+ if not isinstance(registration, GpuBufferRegistration):
+ return Result.new_error(
+ InvalidArgumentError(
+ message=(
+ "unregister_gpu_buffer requires GpuBufferRegistration; "
+ f"got {type(registration)}"
+ )
+ )
+ )
+ active = self._gpu_buffer_registration
+ if active != registration:
+ return Result.new_error(
+ InvalidArgumentError(
+ message=(
+ "GPU registration is not active on this store: "
+ f"requested={registration!r} active={active!r}"
+ )
+ )
+ )
+ try:
+ inner_res = self._client.unregister_gpu_buffer(registration.registration_id)
+ if not inner_res.is_ok():
+ return Result.new_error(inner_res.unwrap_error())
+ _ = inner_res.unwrap()
+ self._gpu_buffer_registration = None
+ return Result.new_ok(OkNone())
+ except ApiError as e:
+ return Result.new_error(e)
+
+ def batch_put_from(
+ self,
+ keys: List[str],
+ payload_ptrs: List[int],
+ payload_sizes: List[int],
+ opts: Optional[PutOptionalArgs] = None,
+ ) -> List[int]:
+ if len(keys) != len(payload_ptrs) or len(keys) != len(payload_sizes):
+ raise ValueError(
+ "batch_put_from requires keys, payload_ptrs, and payload_sizes to have the same length"
+ )
+ if len(keys) == 0:
+ return []
+
+ if self._client is None:
+ code = _error_to_ret_code(
+ GeneralError(
+ message="Store not initialized when batch_put_from(). Call setup() first."
+ )
+ )
+ return [code] * len(keys)
+
+ lease_id: Optional[int] = opts.lease_id if opts is not None else None
+ reject_if_inflight_same_key = (
+ bool(opts.reject_if_inflight_same_key) if opts is not None else False
+ )
+ reject_if_exist_same_key = (
+ bool(opts.reject_if_exist_same_key) if opts is not None else False
+ )
+ write_through = bool(opts.write_through) if opts is not None else True
+ make_replica_task = bool(opts.make_replica_task) if opts is not None else True
+
+ try:
+ inner_res = self._client.batch_put_from(
+ keys,
+ payload_ptrs,
+ payload_sizes,
+ lease_id=lease_id,
+ reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exist_same_key=reject_if_exist_same_key,
+ write_through=write_through,
+ make_replica_task=make_replica_task,
+ )
+ if inner_res.is_ok():
+ submit_results = list(inner_res.unwrap())
+ if len(submit_results) != len(keys):
+ raise RuntimeError(
+ "batch_put_from returned unexpected result length: "
+ f"expected={len(keys)} got={len(submit_results)}"
+ )
+ return [int(item) for item in submit_results]
+ err = inner_res.unwrap_error()
+ code = _error_to_ret_code(err)
+ return [code] * len(keys)
+ except ApiError as e:
+ code = _error_to_ret_code(e)
+ return [code] * len(keys)
+ except Exception as e:
+ code = _error_to_ret_code(GeneralError(f"batch_put_from failed: {e}"))
+ return [code] * len(keys)
+
def get_size(self, key: str) -> Result[int, ApiError]:
"""Get the size of a stored value (non-blocking)."""
return self._client.get_size(key)
- def is_exist(self, key: str) -> Result[bool, ApiError]:
+ def is_exist(self, key: str, allow_local_snapshot: bool = False) -> Result[bool, ApiError]:
"""Check if a key exists in the store (non-blocking)."""
try:
- return self._client.is_exist(key)
+ if self._client is None:
+ return Result.new_error(
+ GeneralError(
+ message="Store not initialized when is_exist(). Call setup() first."
+ )
+ )
+ return self._client.is_exist(key, allow_local_snapshot=allow_local_snapshot)
except Exception as e:
return Result.new_error(GeneralError(f"Existence check failed: {str(e)}"))
+ def batch_get_into(
+ self,
+ keys: List[str],
+ payload_ptrs: List[int],
+ payload_capacities: List[int],
+ ) -> List[int]:
+ if len(keys) != len(payload_ptrs) or len(keys) != len(payload_capacities):
+ raise ValueError(
+ "batch_get_into requires keys, payload_ptrs, and payload_capacities to have the same length"
+ )
+ if len(keys) == 0:
+ return []
+
+ if self._client is not None:
+ inner_res = self._client.batch_get_into(keys, payload_ptrs, payload_capacities)
+ if inner_res.is_ok():
+ return list(inner_res.unwrap())
+ err = inner_res.unwrap_error()
+ return [_error_to_ret_code(err)] * len(keys)
+
+ results: List[int] = []
+ for key, ptr, size in zip(keys, payload_ptrs, payload_capacities):
+ get_result = self.get_payload_into_ptr_blocking(key, ptr, size)
+ if get_result.is_ok():
+ results.append(int(get_result.unwrap()))
+ else:
+ results.append(_error_to_ret_code(get_result.unwrap_error()))
+ return results
+
+ def batch_is_exist(
+ self,
+ keys: List[str],
+ allow_local_snapshot: bool = False,
+ ) -> List[int]:
+ if len(keys) == 0:
+ return []
+
+ if self._client is None:
+ code = _error_to_ret_code(
+ GeneralError(
+ message="Store not initialized when batch_is_exist(). Call setup() first."
+ )
+ )
+ return [code] * len(keys)
+
+ try:
+ inner_res = self._client.batch_is_exist(
+ keys,
+ allow_local_snapshot=allow_local_snapshot,
+ )
+ if not inner_res.is_ok():
+ code = _error_to_ret_code(inner_res.unwrap_error())
+ return [code] * len(keys)
+ batch_results = self._normalize_batch_result_list(
+ inner_res.unwrap(), len(keys), "batch_is_exist"
+ )
+ out: List[int] = []
+ for idx, item in enumerate(batch_results):
+ if not isinstance(item, int):
+ raise GeneralError(
+ message=(
+ f"batch_is_exist returned non-int item for key {keys[idx]!r}: "
+ f"{type(item)}"
+ )
+ )
+ out.append(int(item))
+ return out
+ except ApiError as e:
+ code = _error_to_ret_code(e)
+ return [code] * len(keys)
+ except Exception as e:
+ code = _error_to_ret_code(GeneralError(f"batch_is_exist failed: {e}"))
+ return [code] * len(keys)
+
+ def get_meta(self, key: str) -> Result[Dict[str, Any], ApiError]:
+ """Query key metadata and one live placement without fetching payload bytes."""
+ try:
+ inner = self._client.get_meta(key)
+ if not inner.is_ok():
+ return Result.new_error(inner.unwrap_error())
+ meta = inner.unwrap()
+ assert isinstance(meta, dict), f"get_meta returned non-dict: {type(meta)}"
+ return Result.new_ok(meta)
+ except Exception as e:
+ return Result.new_error(GeneralError(f"GetMeta failed for key '{key}': {str(e)}"))
+
+ def batch_get_meta(self, keys: List[str]) -> List[Dict[str, Any]]:
+ if len(keys) == 0:
+ return []
+
+ if self._client is not None and hasattr(self._client, "batch_get_meta"):
+ inner_res = self._client.batch_get_meta(keys)
+ if inner_res.is_ok():
+ rows = inner_res.unwrap()
+ assert isinstance(rows, list), (
+ f"batch_get_meta returned non-list: {type(rows)}"
+ )
+ return list(rows)
+ err = inner_res.unwrap_error()
+ code = _error_to_ret_code(err)
+ return [
+ {
+ "exists": False,
+ "len": 0,
+ "node_id": "",
+ "src_addr": 0,
+ "src_base_addr": 0,
+ "segment_device_id": "",
+ "segment_device_desc": "",
+ "replica_count": 0,
+ "transport_error": True,
+ "error_code": -code,
+ "error_json": str(err),
+ }
+ for _ in keys
+ ]
+
+ rows: List[Dict[str, Any]] = []
+ for key in keys:
+ meta_res = self.get_meta(key)
+ if meta_res.is_ok():
+ rows.append(meta_res.unwrap())
+ else:
+ err = meta_res.unwrap_error()
+ rows.append(
+ {
+ "exists": False,
+ "len": 0,
+ "node_id": "",
+ "src_addr": 0,
+ "src_base_addr": 0,
+ "segment_device_id": "",
+ "segment_device_desc": "",
+ "replica_count": 0,
+ "transport_error": True,
+ "error_code": -_error_to_ret_code(err),
+ "error_json": str(err),
+ }
+ )
+ return rows
+
def count_prefix(self, prefix: str) -> Result[int, ApiError]:
"""Count number of keys with the given prefix.
@@ -604,10 +1567,7 @@ def rpc_call(
try:
if self._client is None:
raise GeneralError(message="Store not initialized when rpc_call(). Call setup() first.")
- if not isinstance(timeout_ms, int):
- raise InvalidArgumentError(message=f"timeout_ms must be int; got {type(timeout_ms)}")
- if timeout_ms < 10_000:
- raise InvalidArgumentError(message=f"timeout_ms must be >= 10000; got {timeout_ms}")
+ _validate_explicit_rpc_timeout_ms(timeout_ms)
encoded = encode_flat_kv_dict(payload)
if not encoded.is_ok():
@@ -634,10 +1594,7 @@ def rpc_call_bytes(
raise GeneralError(message="Store not initialized when rpc_call_bytes(). Call setup() first.")
if not isinstance(payload, (bytes, bytearray)):
raise InvalidArgumentError(message=f"payload must be bytes; got {type(payload)}")
- if not isinstance(timeout_ms, int):
- raise InvalidArgumentError(message=f"timeout_ms must be int; got {type(timeout_ms)}")
- if timeout_ms < 10_000:
- raise InvalidArgumentError(message=f"timeout_ms must be >= 10000; got {timeout_ms}")
+ _validate_explicit_rpc_timeout_ms(timeout_ms)
inner = self._client.rpc_call(node_id, path, bytes(payload), timeout_ms)
if not inner.is_ok():
@@ -729,10 +1686,7 @@ def sync_kv_to_file(
raise InvalidArgumentError(message=f"file_offset must be int; got {type(file_offset)}")
if file_offset < 0:
raise InvalidArgumentError(message=f"file_offset must be >= 0; got {file_offset}")
- if not isinstance(timeout_ms, int):
- raise InvalidArgumentError(message=f"timeout_ms must be int; got {type(timeout_ms)}")
- if timeout_ms < 10_000:
- raise InvalidArgumentError(message=f"timeout_ms must be >= 10000; got {timeout_ms}")
+ _validate_explicit_rpc_timeout_ms(timeout_ms)
return self._client.sync_kv_to_file(
target_instance_key,
@@ -776,6 +1730,13 @@ def instance_key(self) -> Result[str, ApiError]:
def close(self) -> Result[OkNone, ApiError]:
"""Close and tear down the store."""
try:
+ if self._gpu_buffer_registration is not None:
+ unregister_result = self.unregister_gpu_buffer(
+ self._gpu_buffer_registration
+ )
+ if not unregister_result.is_ok():
+ return Result.new_error(unregister_result.unwrap_error())
+ _ = unregister_result.unwrap()
# Backend returns a Result; MUST be explicitly consumed to avoid
# leaking an unconsumed Result that triggers __del__ assertion.
res = self._client.close()
@@ -806,6 +1767,757 @@ def get_cluster_name(self) -> str:
raise RuntimeError("Store not initialized")
return str(self._client.cluster_name())
+ def wait_local_segments_ready(self) -> List[dict[str, Any]]:
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when wait_local_segments_ready(). Call setup() first."
+ )
+ inner_res = self._client.wait_local_segments_ready()
+ if not inner_res.is_ok():
+ raise RuntimeError(
+ f"wait_local_segments_ready backend error: {inner_res.unwrap_error()}"
+ )
+ inner_res = inner_res.unwrap()
+ if not isinstance(inner_res, list):
+ raise RuntimeError(
+ "wait_local_segments_ready must return a list of segment mappings"
+ )
+ out: List[dict[str, Any]] = []
+ for item in inner_res:
+ if not isinstance(item, dict):
+ raise RuntimeError(
+ "wait_local_segments_ready segment item must be a dict"
+ )
+ out.append(dict(item))
+ return out
+
+ def local_fast_put_start(
+ self,
+ keys: List[str],
+ value_len: int,
+ opts: Optional[PutOptionalArgs] = None,
+ ) -> int:
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when local_fast_put_start(). Call setup() first."
+ )
+ if len(keys) == 0:
+ raise ValueError("local_fast_put_start requires at least one key")
+ if not isinstance(value_len, int):
+ raise ValueError(f"value_len must be int; got {type(value_len)}")
+ if value_len <= 0:
+ raise ValueError(f"value_len must be > 0; got {value_len}")
+ reject_if_inflight_same_key = (
+ bool(opts.reject_if_inflight_same_key) if opts is not None else False
+ )
+ reject_if_exist_same_key = (
+ bool(opts.reject_if_exist_same_key) if opts is not None else False
+ )
+ write_through = bool(opts.write_through) if opts is not None else True
+ make_replica_task = bool(opts.make_replica_task) if opts is not None else True
+ make_replica_task_mask: Optional[List[bool]] = None
+ if opts is not None and opts.make_replica_task_mask is not None:
+ requested_mask = opts.make_replica_task_mask
+ if not isinstance(requested_mask, list):
+ raise ValueError(
+ "make_replica_task_mask must be list[bool] when provided; "
+ f"got {type(requested_mask)}"
+ )
+ if len(requested_mask) != len(keys):
+ raise ValueError(
+ "make_replica_task_mask length must match keys length; "
+ f"keys={len(keys)} mask={len(requested_mask)}"
+ )
+ for index, item in enumerate(requested_mask):
+ if type(item) is not bool:
+ raise ValueError(
+ "make_replica_task_mask items must be bool; "
+ f"index={index} got={type(item)}"
+ )
+ make_replica_task_mask = list(requested_mask)
+ atomic_group_lens = _validate_put_atomic_groups(
+ keys,
+ opts.atomic_group_lens if opts is not None else None,
+ make_replica_task_mask,
+ )
+ inner_res = self._client.local_fast_put_start(
+ keys,
+ value_len,
+ reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exist_same_key=reject_if_exist_same_key,
+ write_through=write_through,
+ make_replica_task=make_replica_task,
+ make_replica_task_mask=make_replica_task_mask,
+ atomic_group_lens=atomic_group_lens,
+ )
+ if not inner_res.is_ok():
+ err = inner_res.unwrap_error()
+ if isinstance(err, Exception):
+ raise err
+ raise RuntimeError(f"local_fast_put_start backend error: {err}")
+ plan_ptr = inner_res.unwrap()
+ if not isinstance(plan_ptr, int) or plan_ptr <= 0:
+ raise RuntimeError(f"local_fast_put_start returned invalid plan_ptr: {plan_ptr!r}")
+ return int(plan_ptr)
+
+ def local_fast_put_commit(self, plan_ptr: int) -> KvFuture:
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when local_fast_put_commit(). Call setup() first."
+ )
+ inner_res = self._client.local_fast_put_commit(plan_ptr)
+ if not inner_res.is_ok():
+ raise RuntimeError(
+ f"local_fast_put_commit backend error: {inner_res.unwrap_error()}"
+ )
+ inner_future = inner_res.unwrap()
+ if inner_future is None:
+ raise RuntimeError("local_fast_put_commit returned empty future")
+ return _FluxonBatchRetCodeFuture(inner_future, [plan_ptr])
+
+ def put_abort(self, plan_ptr: int) -> None:
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when put_abort(). Call setup() first."
+ )
+ inner_res = self._client.put_abort(plan_ptr)
+ if not inner_res.is_ok():
+ raise RuntimeError(f"put_abort backend error: {inner_res.unwrap_error()}")
+ _ = inner_res.unwrap()
+
+ def get_views(
+ self,
+ keys: List[str],
+ concurrency: Optional[int] = None,
+ ) -> int:
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when get_views(). Call setup() first."
+ )
+ if len(keys) == 0:
+ raise ValueError("get_views requires at least one key")
+ inner_res = self._client.get_views(
+ keys,
+ concurrency=concurrency if concurrency is not None else self._batch_concurrency,
+ )
+ if not inner_res.is_ok():
+ raise RuntimeError(f"get_views backend error: {inner_res.unwrap_error()}")
+ plan_ptr = inner_res.unwrap()
+ if not isinstance(plan_ptr, int) or plan_ptr <= 0:
+ raise RuntimeError(f"get_views returned invalid plan_ptr: {plan_ptr!r}")
+ return int(plan_ptr)
+
+ def release_views(self, plan_ptr: int) -> None:
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when release_views(). Call setup() first."
+ )
+ inner_res = self._client.release_views(plan_ptr)
+ if not inner_res.is_ok():
+ raise RuntimeError(f"release_views backend error: {inner_res.unwrap_error()}")
+ _ = inner_res.unwrap()
+
+ def get_start(
+ self,
+ keys: List[str],
+ prefix_best_effort: bool = True,
+ atomic_group_lens: Optional[List[int]] = None,
+ ) -> GetStartHandle:
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when get_start(). Call setup() first."
+ )
+ if len(keys) == 0:
+ raise ValueError("get_start requires at least one key")
+ normalized_group_lens: Optional[List[int]] = None
+ if atomic_group_lens is not None:
+ normalized_group_lens = [int(length) for length in atomic_group_lens]
+ if any(length <= 0 for length in normalized_group_lens):
+ raise ValueError("get_start atomic_group_lens entries must be > 0")
+ if sum(normalized_group_lens) != len(keys):
+ raise ValueError(
+ "get_start atomic_group_lens must sum to keys length: "
+ f"sum={sum(normalized_group_lens)} keys={len(keys)}"
+ )
+
+ started_at_ns = time.monotonic_ns()
+ inner_res = self._client.get_start(
+ list(keys),
+ bool(prefix_best_effort),
+ normalized_group_lens,
+ self._batch_concurrency,
+ )
+ if not inner_res.is_ok():
+ raise RuntimeError(f"get_start backend error: {inner_res.unwrap_error()}")
+ payload = inner_res.unwrap()
+ if not isinstance(payload, dict):
+ raise RuntimeError(f"get_start returned non-dict payload: {type(payload)}")
+ backend_handle = int(payload["handle"])
+ result = _build_get_start_result_from_backend_payload(
+ payload,
+ list(keys),
+ bool(prefix_best_effort),
+ normalized_group_lens,
+ )
+ logging.info(
+ "FluxonKVCacheStore get_start result: keys=%d raw_prefix_hit_len=%d "
+ "transferable_len=%d prefix_hit_groups=%d all_hit=%s "
+ "first_miss_index=%s first_miss_group_index=%s "
+ "prefix_best_effort=%s duration_ms=%.3f",
+ len(keys),
+ result.raw_prefix_hit_len,
+ result.transferable_len,
+ result.prefix_hit_groups,
+ result.all_hit,
+ result.first_miss_index,
+ result.first_miss_group_index,
+ result.prefix_best_effort,
+ (time.monotonic_ns() - started_at_ns) / 1_000_000.0,
+ )
+ return GetStartHandle(
+ keys=tuple(keys),
+ result=result,
+ created_at_ns=started_at_ns,
+ backend_token=id(self),
+ backend_handle=backend_handle,
+ )
+
+ def get_plan(
+ self,
+ keys: List[str],
+ prefix_best_effort: bool = True,
+ atomic_group_lens: Optional[List[int]] = None,
+ ) -> GetPlanHandle:
+ if self._client is None:
+ raise RuntimeError("Store not initialized when get_plan(). Call setup() first.")
+ if not keys:
+ raise ValueError("get_plan requires at least one key")
+ normalized_group_lens: Optional[List[int]] = None
+ if atomic_group_lens is not None:
+ normalized_group_lens = [int(length) for length in atomic_group_lens]
+ if any(length <= 0 for length in normalized_group_lens):
+ raise ValueError("get_plan atomic_group_lens entries must be > 0")
+ if sum(normalized_group_lens) != len(keys):
+ raise ValueError(
+ "get_plan atomic_group_lens must sum to keys length: "
+ f"sum={sum(normalized_group_lens)} keys={len(keys)}"
+ )
+ started_at_ns = time.monotonic_ns()
+ inner_res = self._client.get_plan(
+ list(keys),
+ bool(prefix_best_effort),
+ normalized_group_lens,
+ )
+ if not inner_res.is_ok():
+ raise RuntimeError(f"get_plan backend error: {inner_res.unwrap_error()}")
+ payload = inner_res.unwrap()
+ if not isinstance(payload, dict):
+ raise RuntimeError(f"get_plan returned non-dict payload: {type(payload)}")
+ backend_handle = int(payload["handle"])
+ try:
+ result = _build_get_start_result_from_backend_payload(
+ payload,
+ list(keys),
+ bool(prefix_best_effort),
+ normalized_group_lens,
+ )
+ gpu_payload = dict(payload)
+ gpu_payload["raw_prefix_hit_len"] = payload["gpu_raw_prefix_hit_len"]
+ gpu_result = _build_get_start_result_from_backend_payload(
+ gpu_payload,
+ list(keys),
+ bool(prefix_best_effort),
+ normalized_group_lens,
+ )
+ gpu_remote_indices = tuple(int(index) for index in payload["gpu_remote_indices"])
+ if (
+ tuple(sorted(set(gpu_remote_indices))) != gpu_remote_indices
+ or any(
+ index < 0 or index >= gpu_result.transferable_len
+ for index in gpu_remote_indices
+ )
+ ):
+ raise RuntimeError(
+ "get_plan returned invalid gpu_remote_indices: "
+ f"indices={gpu_remote_indices} "
+ f"transferable={gpu_result.transferable_len}"
+ )
+ except Exception:
+ cancel_res = self._client.cancel_get_plan(backend_handle)
+ if not cancel_res.is_ok():
+ logging.exception(
+ "get_plan payload validation failed and cleanup also failed: %s",
+ cancel_res.unwrap_error(),
+ )
+ else:
+ _ = cancel_res.unwrap()
+ raise
+ return GetPlanHandle(
+ keys=tuple(keys),
+ result=result,
+ gpu_result=gpu_result,
+ created_at_ns=started_at_ns,
+ backend_token=id(self),
+ backend_handle=backend_handle,
+ gpu_remote_indices=gpu_remote_indices,
+ )
+
+ def cancel_get_plan(self, handle: GetPlanHandle) -> None:
+ if not isinstance(handle, GetPlanHandle):
+ raise TypeError(f"cancel_get_plan requires GetPlanHandle, got {type(handle)}")
+ if handle.backend_token != id(self):
+ raise RuntimeError("cancel_get_plan handle belongs to a different store")
+ if handle.closed:
+ return
+ if self._client is None:
+ raise RuntimeError("Store not initialized when cancel_get_plan().")
+ inner_res = self._client.cancel_get_plan(handle.backend_handle)
+ if not inner_res.is_ok():
+ raise RuntimeError(f"cancel_get_plan backend error: {inner_res.unwrap_error()}")
+ _ = inner_res.unwrap()
+ handle.closed = True
+
+ def execute_get_plan_cpu(
+ self,
+ handle: GetPlanHandle,
+ *,
+ consume_prefix_len: int,
+ concurrency: Optional[int] = None,
+ ) -> GetStartHandle:
+ if not isinstance(handle, GetPlanHandle):
+ raise TypeError(
+ f"execute_get_plan_cpu requires GetPlanHandle, got {type(handle)}"
+ )
+ if handle.backend_token != id(self) or handle.closed:
+ raise RuntimeError("execute_get_plan_cpu requires a live plan from this store")
+ narrowed = _narrow_get_start_result(handle.result, int(consume_prefix_len))
+ if consume_prefix_len <= 0 or consume_prefix_len > handle.result.transferable_len:
+ raise ValueError(
+ "execute_get_plan_cpu consume prefix is outside the CPU plan: "
+ f"consume={consume_prefix_len} transferable={handle.result.transferable_len}"
+ )
+ if self._client is None:
+ raise RuntimeError("Store not initialized when execute_get_plan_cpu().")
+ inner_res = self._client.execute_get_plan_cpu(
+ handle.backend_handle,
+ int(consume_prefix_len),
+ self._batch_concurrency if concurrency is None else int(concurrency),
+ )
+ if not inner_res.is_ok():
+ raise RuntimeError(
+ f"execute_get_plan_cpu backend error: {inner_res.unwrap_error()}"
+ )
+ _ = inner_res.unwrap()
+ handle.closed = True
+ return GetStartHandle(
+ keys=narrowed.keys,
+ result=narrowed,
+ created_at_ns=handle.created_at_ns,
+ backend_token=id(self),
+ backend_handle=handle.backend_handle,
+ )
+
+ def execute_get_plan_gpu(
+ self,
+ handle: GetPlanHandle,
+ destinations: List[GpuDestination],
+ *,
+ consume_prefix_len: int,
+ concurrency: Optional[int] = None,
+ ) -> GpuGetStartHandle:
+ if not isinstance(handle, GetPlanHandle):
+ raise TypeError(
+ f"execute_get_plan_gpu requires GetPlanHandle, got {type(handle)}"
+ )
+ if handle.backend_token != id(self) or handle.closed:
+ raise RuntimeError("execute_get_plan_gpu requires a live plan from this store")
+ remote_indices = tuple(
+ index for index in handle.gpu_remote_indices if index < consume_prefix_len
+ )
+ if not remote_indices:
+ raise ValueError("execute_get_plan_gpu requires at least one remote source")
+ if len(destinations) != len(remote_indices):
+ raise ValueError(
+ "execute_get_plan_gpu requires one exact destination per remote source: "
+ f"destinations={len(destinations)} remote={len(remote_indices)} "
+ f"consume={consume_prefix_len}"
+ )
+ narrowed = _narrow_get_start_result(handle.gpu_result, int(consume_prefix_len))
+ if consume_prefix_len <= 0 or consume_prefix_len > handle.gpu_result.transferable_len:
+ raise ValueError(
+ "execute_get_plan_gpu consume prefix is outside the GPU plan: "
+ f"consume={consume_prefix_len} transferable={handle.gpu_result.transferable_len}"
+ )
+ active_registration = self._gpu_buffer_registration
+ if active_registration is None:
+ raise RuntimeError("execute_get_plan_gpu requires an active GPU registration")
+ for index, destination in enumerate(destinations):
+ if not isinstance(destination, GpuDestination):
+ raise TypeError(
+ "execute_get_plan_gpu destinations must be GpuDestination: "
+ f"index={index} got={type(destination)}"
+ )
+ if destination.registration_id != active_registration.registration_id:
+ raise ValueError(
+ "execute_get_plan_gpu destination uses a stale registration: "
+ f"index={index}"
+ )
+ if destination.ptr < active_registration.ptr or destination.end > active_registration.end:
+ raise ValueError(
+ "execute_get_plan_gpu destination is outside the active registration: "
+ f"index={index}"
+ )
+ if self._client is None:
+ raise RuntimeError("Store not initialized when execute_get_plan_gpu().")
+ inner_res = self._client.execute_get_plan_gpu(
+ handle.backend_handle,
+ [
+ (destination.registration_id, destination.ptr, destination.capacity)
+ for destination in destinations
+ ],
+ int(consume_prefix_len),
+ self._batch_concurrency if concurrency is None else int(concurrency),
+ )
+ if not inner_res.is_ok():
+ raise RuntimeError(
+ f"execute_get_plan_gpu backend error: {inner_res.unwrap_error()}"
+ )
+ _ = inner_res.unwrap()
+ handle.closed = True
+ return GpuGetStartHandle(
+ keys=narrowed.keys,
+ destinations=tuple(destinations),
+ result=narrowed,
+ created_at_ns=handle.created_at_ns,
+ backend_token=id(self),
+ backend_handle=handle.backend_handle,
+ remote_indices=remote_indices,
+ )
+
+ def cancel_get_transfer(self, handle: GetStartHandle) -> None:
+ if not isinstance(handle, GetStartHandle):
+ raise TypeError(f"cancel_get_transfer requires GetStartHandle, got {type(handle)}")
+ if handle.backend_token is not None and handle.backend_token != id(self):
+ raise RuntimeError(
+ "cancel_get_transfer handle belongs to a different FluxonKVCacheStore"
+ )
+ if handle.closed:
+ return
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when cancel_get_transfer(). Call setup() first."
+ )
+ inner_res = self._client.cancel_get_transfer(int(handle.backend_handle))
+ if not inner_res.is_ok():
+ raise RuntimeError(
+ f"cancel_get_transfer backend error: {inner_res.unwrap_error()}"
+ )
+ _ = inner_res.unwrap()
+ handle.closed = True
+
+ def get_transfer(
+ self,
+ handle: GetStartHandle,
+ concurrency: Optional[int] = None,
+ *,
+ consume_prefix_len: Optional[int] = None,
+ ) -> int:
+ if not isinstance(handle, GetStartHandle):
+ raise TypeError(f"get_transfer requires GetStartHandle, got {type(handle)}")
+ if handle.backend_token is not None and handle.backend_token != id(self):
+ raise RuntimeError("get_transfer handle belongs to a different FluxonKVCacheStore")
+ if handle.closed:
+ raise RuntimeError("get_transfer handle has been closed")
+ result = handle.result
+ if result.transferable_len == 0:
+ raise RuntimeError(
+ "get_transfer requires a non-empty transferable prefix: "
+ f"transferable_len={result.transferable_len} total={len(result.keys)} "
+ f"raw_prefix_hit_len={result.raw_prefix_hit_len} "
+ f"first_miss_index={result.first_miss_index} "
+ f"first_miss_group_index={result.first_miss_group_index}"
+ )
+ if consume_prefix_len is None:
+ normalized_consume_prefix_len = result.transferable_len
+ else:
+ if isinstance(consume_prefix_len, bool) or not isinstance(
+ consume_prefix_len, int
+ ):
+ raise TypeError(
+ "get_transfer consume_prefix_len must be an int or None, got "
+ f"{type(consume_prefix_len)}"
+ )
+ normalized_consume_prefix_len = int(consume_prefix_len)
+ if (
+ normalized_consume_prefix_len <= 0
+ or normalized_consume_prefix_len > result.transferable_len
+ ):
+ raise ValueError(
+ "get_transfer consume_prefix_len must be within the live "
+ "transferable prefix: "
+ f"consume={normalized_consume_prefix_len} "
+ f"transferable={result.transferable_len}"
+ )
+ group_lens = result.atomic_group_lens or (len(result.keys),)
+ group_end = 0
+ for group_len in group_lens:
+ group_end += int(group_len)
+ if group_end >= normalized_consume_prefix_len:
+ break
+ if group_end != normalized_consume_prefix_len:
+ raise ValueError(
+ "get_transfer consume_prefix_len must end at an atomic-group "
+ "boundary: "
+ f"consume={normalized_consume_prefix_len} "
+ f"atomic_group_lens={group_lens}"
+ )
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when get_transfer(). Call setup() first."
+ )
+ _ = concurrency
+ inner_res = self._client.get_transfer(
+ handle.backend_handle, normalized_consume_prefix_len
+ )
+ if not inner_res.is_ok():
+ handle.closed = True
+ raise RuntimeError(f"get_transfer backend error: {inner_res.unwrap_error()}")
+ plan_ptr = inner_res.unwrap()
+ handle.closed = True
+ if not isinstance(plan_ptr, int) or plan_ptr <= 0:
+ raise RuntimeError(f"get_transfer returned invalid plan_ptr: {plan_ptr!r}")
+ return int(plan_ptr)
+
+ def get_start_gpu(
+ self,
+ keys: List[str],
+ destinations: List[GpuDestination],
+ prefix_best_effort: bool = True,
+ atomic_group_lens: Optional[List[int]] = None,
+ ) -> GpuGetStartHandle:
+ """Start background remote reads directly into caller-owned GPU staging."""
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when get_start_gpu(). Call setup() first."
+ )
+ if len(keys) == 0:
+ raise ValueError("get_start_gpu requires at least one key")
+ if len(keys) != len(destinations):
+ raise ValueError(
+ "get_start_gpu requires one destination per key: "
+ f"keys={len(keys)} destinations={len(destinations)}"
+ )
+ active_registration = self._gpu_buffer_registration
+ if active_registration is None:
+ raise RuntimeError("get_start_gpu requires an active GPU registration")
+ for index, destination in enumerate(destinations):
+ if not isinstance(destination, GpuDestination):
+ raise TypeError(
+ "get_start_gpu destinations must be GpuDestination: "
+ f"index={index} got={type(destination)}"
+ )
+ if destination.registration_id != active_registration.registration_id:
+ raise ValueError(
+ "get_start_gpu destination uses a stale registration: "
+ f"index={index} destination_id={destination.registration_id} "
+ f"active_id={active_registration.registration_id}"
+ )
+ if (
+ destination.ptr < active_registration.ptr
+ or destination.end > active_registration.end
+ ):
+ raise ValueError(
+ "get_start_gpu destination is outside the active registration: "
+ f"index={index} destination={destination!r} "
+ f"registration={active_registration!r}"
+ )
+
+ normalized_group_lens: Optional[List[int]] = None
+ if atomic_group_lens is not None:
+ normalized_group_lens = [int(length) for length in atomic_group_lens]
+ if any(length <= 0 for length in normalized_group_lens):
+ raise ValueError("get_start_gpu atomic_group_lens entries must be > 0")
+ if sum(normalized_group_lens) != len(keys):
+ raise ValueError(
+ "get_start_gpu atomic_group_lens must sum to keys length: "
+ f"sum={sum(normalized_group_lens)} keys={len(keys)}"
+ )
+
+ started_at_ns = time.monotonic_ns()
+ inner_res = self._client.get_start_gpu(
+ list(keys),
+ [
+ (
+ destination.registration_id,
+ destination.ptr,
+ destination.capacity,
+ )
+ for destination in destinations
+ ],
+ bool(prefix_best_effort),
+ normalized_group_lens,
+ self._batch_concurrency,
+ )
+ if not inner_res.is_ok():
+ raise RuntimeError(f"get_start_gpu backend error: {inner_res.unwrap_error()}")
+ payload = inner_res.unwrap()
+ if not isinstance(payload, dict):
+ raise RuntimeError(
+ f"get_start_gpu returned non-dict payload: {type(payload)}"
+ )
+ backend_handle = int(payload["handle"])
+ try:
+ result = _build_get_start_result_from_backend_payload(
+ payload,
+ list(keys),
+ bool(prefix_best_effort),
+ normalized_group_lens,
+ )
+ except Exception:
+ cancel_res = self._client.cancel_get_transfer_gpu(backend_handle)
+ if not cancel_res.is_ok():
+ logging.exception(
+ "get_start_gpu payload validation failed and cleanup also failed: %s",
+ cancel_res.unwrap_error(),
+ )
+ else:
+ _ = cancel_res.unwrap()
+ raise
+ return GpuGetStartHandle(
+ keys=tuple(keys),
+ destinations=tuple(destinations),
+ result=result,
+ created_at_ns=started_at_ns,
+ backend_token=id(self),
+ backend_handle=backend_handle,
+ remote_indices=tuple(range(result.transferable_len)),
+ )
+
+ def cancel_get_transfer_gpu(self, handle: GpuGetStartHandle) -> None:
+ if not isinstance(handle, GpuGetStartHandle):
+ raise TypeError(
+ f"cancel_get_transfer_gpu requires GpuGetStartHandle, got {type(handle)}"
+ )
+ if handle.backend_token != id(self):
+ raise RuntimeError(
+ "cancel_get_transfer_gpu handle belongs to a different FluxonKVCacheStore"
+ )
+ if handle.closed:
+ return
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when cancel_get_transfer_gpu(). Call setup() first."
+ )
+ inner_res = self._client.cancel_get_transfer_gpu(handle.backend_handle)
+ handle.closed = True
+ if not inner_res.is_ok():
+ raise RuntimeError(
+ f"cancel_get_transfer_gpu backend error: {inner_res.unwrap_error()}"
+ )
+ _ = inner_res.unwrap()
+
+ def get_transfer_gpu(
+ self,
+ handle: GpuGetStartHandle,
+ *,
+ consume_prefix_len: Optional[int] = None,
+ ) -> int:
+ """Wait for GPU transfer and return one ordered CPU/GPU source plan."""
+ if not isinstance(handle, GpuGetStartHandle):
+ raise TypeError(
+ f"get_transfer_gpu requires GpuGetStartHandle, got {type(handle)}"
+ )
+ if handle.backend_token != id(self):
+ raise RuntimeError(
+ "get_transfer_gpu handle belongs to a different FluxonKVCacheStore"
+ )
+ if handle.closed:
+ raise RuntimeError("get_transfer_gpu handle has been closed")
+ result = handle.result
+ normalized_consume_prefix_len = (
+ result.transferable_len
+ if consume_prefix_len is None
+ else consume_prefix_len
+ )
+ if (
+ isinstance(normalized_consume_prefix_len, bool)
+ or not isinstance(normalized_consume_prefix_len, int)
+ or normalized_consume_prefix_len <= 0
+ or normalized_consume_prefix_len > result.transferable_len
+ ):
+ raise ValueError(
+ "get_transfer_gpu consume_prefix_len must be within the live prefix: "
+ f"consume={normalized_consume_prefix_len!r} "
+ f"transferable={result.transferable_len}"
+ )
+ group_lens = result.atomic_group_lens or (len(result.keys),)
+ group_end = 0
+ for group_len in group_lens:
+ group_end += int(group_len)
+ if group_end >= normalized_consume_prefix_len:
+ break
+ if group_end != normalized_consume_prefix_len:
+ raise ValueError(
+ "get_transfer_gpu consume_prefix_len must end at an atomic-group boundary: "
+ f"consume={normalized_consume_prefix_len} groups={group_lens}"
+ )
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when get_transfer_gpu(). Call setup() first."
+ )
+ inner_res = self._client.get_transfer_gpu(
+ handle.backend_handle,
+ normalized_consume_prefix_len,
+ )
+ handle.closed = True
+ if not inner_res.is_ok():
+ raise RuntimeError(
+ f"get_transfer_gpu backend error: {inner_res.unwrap_error()}"
+ )
+ payload = inner_res.unwrap()
+ if not isinstance(payload, dict):
+ raise RuntimeError(
+ f"get_transfer_gpu returned non-dict payload: {type(payload)}"
+ )
+ transferred_prefix_len = int(payload["transferred_prefix_len"])
+ consumed_prefix_len = int(payload["consumed_prefix_len"])
+ if (
+ transferred_prefix_len != result.transferable_len
+ or consumed_prefix_len != normalized_consume_prefix_len
+ ):
+ raise RuntimeError(
+ "get_transfer_gpu returned inconsistent terminal lengths: "
+ f"transferred={transferred_prefix_len}/{result.transferable_len} "
+ f"consumed={consumed_prefix_len}/{normalized_consume_prefix_len}"
+ )
+ transfer_wall_us = payload["transfer_wall_us"]
+ finish_wait_us = payload["finish_wait_us"]
+ terminal_before_consume = payload["terminal_before_consume"]
+ terminal_to_consume_us = payload["terminal_to_consume_us"]
+ for field_name, value in (
+ ("transfer_wall_us", transfer_wall_us),
+ ("finish_wait_us", finish_wait_us),
+ ("terminal_to_consume_us", terminal_to_consume_us),
+ ):
+ if type(value) is not int or value < 0:
+ raise RuntimeError(
+ "get_transfer_gpu returned invalid timing: "
+ f"{field_name}={value!r}"
+ )
+ if type(terminal_before_consume) is not bool:
+ raise RuntimeError(
+ "get_transfer_gpu returned invalid terminal_before_consume: "
+ f"{terminal_before_consume!r}"
+ )
+ handle.transfer_wall_us = transfer_wall_us
+ handle.finish_wait_us = finish_wait_us
+ handle.terminal_before_consume = terminal_before_consume
+ handle.terminal_to_consume_us = terminal_to_consume_us
+ plan_ptr = int(payload["plan_ptr"])
+ if plan_ptr <= 0:
+ raise RuntimeError(f"get_transfer_gpu returned invalid plan_ptr: {plan_ptr}")
+ return plan_ptr
+
def get_etcd_config(self) -> List[str]:
if self._client is None:
raise RuntimeError("Store not initialized")
@@ -889,6 +2601,19 @@ def metrics_snapshot(self) -> MetricSnapshot:
return MetricSnapshot(per_segment=normalized)
+ def observability_snapshot_async(self) -> KvFuture:
+ """Return a future for Fluxon locality and IO counters."""
+ if self._client is None:
+ raise RuntimeError(
+ "Store not initialized when observability_snapshot_async(). Call setup() first."
+ )
+ res = self._client.observability_snapshot_async()
+ if not res.is_ok():
+ raise RuntimeError(
+ f"observability_snapshot_async backend error: {res.unwrap_error()}"
+ )
+ return res.unwrap()
+
# --- Fluxon-kv lease helpers (synchronous) ---
def allocate_lease(self, ttl_seconds: int) -> Result[int, ApiError]:
try:
@@ -948,7 +2673,7 @@ def __init__(self, inner_future: Any) -> None:
self._inner = inner_future
def is_waiting(self) -> bool:
- return bool(getattr(self._inner, "is_waiting")())
+ return bool(self._inner.is_waiting())
def _decode_wait_success(
self,
@@ -985,7 +2710,7 @@ def __init__(self, inner_future: Any) -> None:
self._inner = inner_future
def is_waiting(self) -> bool:
- return bool(getattr(self._inner, "is_waiting")())
+ return bool(self._inner.is_waiting())
def wait(self) -> Result[bytes, ApiError]:
res = self._inner.wait()
@@ -1022,7 +2747,7 @@ def __del__(self) -> None:
self._dlpack_capsules = []
def is_waiting(self) -> bool:
- return bool(getattr(self._inner, "is_waiting")())
+ return bool(self._inner.is_waiting())
def wait(self) -> Result[Union[Any, MemHolder], ApiError]:
from ..api_error import OkNone, Result as PyResult # type: ignore
@@ -1035,3 +2760,36 @@ def wait(self) -> Result[Union[Any, MemHolder], ApiError]:
_ = res.unwrap()
return PyResult.new_ok(OkNone()) # type: ignore
+
+
+class _FluxonBatchRetCodeFuture(KvFuture):
+ """Future wrapper for batch APIs that resolve to one integer ret-code per key."""
+
+ def __init__(self, inner_future: Any, keepalive: List[object]) -> None:
+ self._inner = inner_future
+ self._keepalive = keepalive
+
+ def __del__(self) -> None:
+ self._keepalive = []
+
+ def is_waiting(self) -> bool:
+ return bool(self._inner.is_waiting())
+
+ def wait(self) -> Result[List[int], ApiError]:
+ res = self._inner.wait()
+ self._keepalive = []
+ if not res.is_ok():
+ return Result.new_error(res.unwrap_error())
+ raw = res.unwrap()
+ if not isinstance(raw, list):
+ return Result.new_error(
+ GeneralError(message=f"batch future returned non-list payload: {type(raw)}")
+ )
+ out: List[int] = []
+ for item in raw:
+ if not isinstance(item, int):
+ return Result.new_error(
+ GeneralError(message=f"batch future returned non-int item: {type(item)}")
+ )
+ out.append(int(item))
+ return Result.new_ok(out)
diff --git a/fluxon_py/kvclient/kvclient_interface.py b/fluxon_py/kvclient/kvclient_interface.py
index f50db0f..ead8e4e 100644
--- a/fluxon_py/kvclient/kvclient_interface.py
+++ b/fluxon_py/kvclient/kvclient_interface.py
@@ -21,6 +21,48 @@
FlatDict = Dict[str, Union[int, float, bool, str, bytes, DLPacked]]
+@dataclass(frozen=True)
+class GetStartResult:
+ """
+ Result of a group-prefix best-effort get_start().
+
+ Semantics:
+ - ``keys`` is the caller-provided ordered page-key sequence.
+ - ``raw_prefix_hit_len`` is the page-level continuous hit prefix.
+ - ``transferable_len`` is rounded down to complete atomic groups and is the
+ maximum prefix that can be consumed by get_transfer(). A caller may
+ consume a shorter prefix only at an atomic-group boundary.
+ """
+
+ keys: Tuple[str, ...]
+ raw_prefix_hit_len: int
+ transferable_len: int
+ prefix_hit_groups: int
+ atomic_group_lens: Optional[Tuple[int, ...]]
+ prefix_best_effort: bool
+ first_miss_index: Optional[int]
+ first_miss_group_index: Optional[int]
+ all_hit: bool
+
+
+@dataclass
+class GetStartHandle:
+ """
+ Opaque-ish handle returned by get_start() and consumed by get_transfer().
+
+ Callers must pass this handle to cancel_get_transfer() when abandoning it
+ without calling get_transfer(). Fluxon backends may keep strong holder
+ references alive while this handle is live.
+ """
+
+ keys: Tuple[str, ...]
+ result: GetStartResult
+ created_at_ns: int
+ backend_token: Optional[int] = None
+ backend_handle: int = 0
+ closed: bool = False
+
+
@dataclass
class PutOptionalArgs:
"""
@@ -29,9 +71,27 @@ class PutOptionalArgs:
- lease_id: attach the written key to a lease on commit.
- reject_if_inflight_same_key: ask Fluxon to fail-fast when the same key is already
being written by another inflight put.
+ - reject_if_exist_same_key: ask Fluxon to fail-fast when the key already has a
+ committed live replica.
+ - write_through: keep synchronous remote-placement semantics when the backend
+ supports an async write-back path. Defaults to True to match SGLang
+ HiCache's default write policy.
+ - make_replica_task: enqueue an asynchronous replica after a local write-back
+ commit. Set False for a local-only write-back.
+ - make_replica_task_mask: optional per-key replica admission decisions for
+ local_fast_put_start(). Its length must match the key batch. The scalar
+ make_replica_task remains the batch-wide gate.
+ - atomic_group_lens: optional positive lengths that partition the ordered
+ local_fast_put_start() key batch into atomic groups. Replica admission
+ must be uniform within every group.
"""
lease_id: Optional[int] = None
reject_if_inflight_same_key: bool = False
+ reject_if_exist_same_key: bool = False
+ write_through: bool = True
+ make_replica_task: bool = True
+ make_replica_task_mask: Optional[List[bool]] = None
+ atomic_group_lens: Optional[List[int]] = None
def support_mooncake(self) -> Tuple[bool, List[str]]:
"""
@@ -48,6 +108,16 @@ def support_mooncake(self) -> Tuple[bool, List[str]]:
unsupported.append("lease_id")
if self.reject_if_inflight_same_key:
unsupported.append("reject_if_inflight_same_key")
+ if self.reject_if_exist_same_key:
+ unsupported.append("reject_if_exist_same_key")
+ if self.write_through:
+ unsupported.append("write_through")
+ if not self.make_replica_task:
+ unsupported.append("make_replica_task")
+ if self.make_replica_task_mask is not None:
+ unsupported.append("make_replica_task_mask")
+ if self.atomic_group_lens is not None:
+ unsupported.append("atomic_group_lens")
return (len(unsupported) == 0, unsupported)
@@ -139,7 +209,7 @@ def put_blocking(
_ = wait_result.unwrap()
return Result.new_ok(OkNone())
- def get_blocking(self, key: str) -> Result["MemHolder", ApiError]:
+ def get_blocking(self, key: str) -> Result[Union[Any, "MemHolder"], ApiError]:
"""Synchronously retrieve a value by key.
Default implementation delegates to ``get()`` followed by
@@ -151,6 +221,103 @@ def get_blocking(self, key: str) -> Result["MemHolder", ApiError]:
return Result.new_error(get_result.unwrap_error())
return get_result.unwrap().wait()
+ def batch_put_blocking(
+ self,
+ keys: List[str],
+ values: List[FlatDict],
+ opts: Optional[PutOptionalArgs] = None,
+ concurrency: Optional[int] = None,
+ ) -> List[Result[OkNone, ApiError]]:
+ """Synchronously store a batch of key-value pairs."""
+ if len(keys) != len(values):
+ raise ValueError("batch_put_blocking requires keys and values to have the same length")
+ _ = concurrency
+ return [self.put_blocking(key, value, opts=opts) for key, value in zip(keys, values)]
+
+ def batch_get_blocking(
+ self,
+ keys: List[str],
+ concurrency: Optional[int] = None,
+ ) -> List[Result[Union[Any, "MemHolder"], ApiError]]:
+ """Synchronously retrieve a batch of keys."""
+ _ = concurrency
+ return [self.get_blocking(key) for key in keys]
+
+ def local_fast_put_start(
+ self,
+ keys: List[str],
+ value_len: int,
+ opts: Optional[PutOptionalArgs] = None,
+ ) -> int:
+ _ = keys
+ _ = value_len
+ _ = opts
+ raise NotImplementedError(
+ "local_fast_put_start is only implemented by backends with native plan_ptr support"
+ )
+
+ def local_fast_put_commit(self, plan_ptr: int) -> "KvFuture":
+ _ = plan_ptr
+ raise NotImplementedError(
+ "local_fast_put_commit is only implemented by backends with native plan_ptr support"
+ )
+
+ def put_abort(self, plan_ptr: int) -> None:
+ _ = plan_ptr
+ raise NotImplementedError(
+ "put_abort is only implemented by backends with native plan_ptr support"
+ )
+
+ def get_views(
+ self,
+ keys: List[str],
+ concurrency: Optional[int] = None,
+ ) -> int:
+ _ = keys
+ _ = concurrency
+ raise NotImplementedError(
+ "get_views is only implemented by backends with native plan_ptr support"
+ )
+
+ def release_views(self, plan_ptr: int) -> None:
+ _ = plan_ptr
+ raise NotImplementedError(
+ "release_views is only implemented by backends with native plan_ptr support"
+ )
+
+ def get_start(
+ self,
+ keys: List[str],
+ prefix_best_effort: bool = True,
+ atomic_group_lens: Optional[List[int]] = None,
+ ) -> GetStartHandle:
+ _ = keys
+ _ = prefix_best_effort
+ _ = atomic_group_lens
+ raise NotImplementedError(
+ "get_start is only implemented by backends with native prefix get support"
+ )
+
+ def get_transfer(
+ self,
+ handle: GetStartHandle,
+ concurrency: Optional[int] = None,
+ *,
+ consume_prefix_len: Optional[int] = None,
+ ) -> int:
+ _ = handle
+ _ = concurrency
+ _ = consume_prefix_len
+ raise NotImplementedError(
+ "get_transfer is only implemented by backends with native prefix get support"
+ )
+
+ def cancel_get_transfer(self, handle: GetStartHandle) -> None:
+ _ = handle
+ raise NotImplementedError(
+ "cancel_get_transfer is only implemented by backends with native prefix get support"
+ )
+
@abstractmethod
def get_size(self, key: str) -> Result[int, ApiError]:
"""Get the size of a stored value (non-blocking)."""
diff --git a/fluxon_py/tests/test_config.py b/fluxon_py/tests/test_config.py
index 6de5180..0b7cdc6 100644
--- a/fluxon_py/tests/test_config.py
+++ b/fluxon_py/tests/test_config.py
@@ -49,11 +49,13 @@ def _build_checks(selected_test_id: Optional[str]) -> List[Tuple[str, Callable[[
("fluxonkv_owner_requires_sub_cluster", test_fluxonkv_owner_requires_sub_cluster),
("fluxonkv_owner_requires_large_file_paths", test_fluxonkv_owner_requires_large_file_paths),
("fluxonkv_external_forbids_large_file_paths", test_fluxonkv_external_forbids_large_file_paths),
+ ("fluxonkv_owner_ssd_capacity", test_fluxonkv_owner_ssd_capacity),
("fluxonkv_p2p_relay_removed", test_fluxonkv_p2p_relay_removed),
("fluxon_client_config_yaml_shape", test_fluxon_client_config_yaml_shape),
("fluxonkv_protocol_field", test_fluxonkv_protocol_field),
("fluxonkv_runtime_defaults_are_internal", test_fluxonkv_runtime_defaults_are_internal),
("fluxonkv_removed_rdma_config_keys", test_fluxonkv_removed_rdma_config_keys),
+ ("fluxonkv_owner_hot_writeback", test_fluxonkv_owner_hot_writeback),
("fluxonkv_test_spec_config", test_fluxonkv_test_spec_config),
("fluxon_pyo3_import_authority", test_fluxon_pyo3_import_authority),
]
@@ -333,6 +335,66 @@ def test_fluxonkv_external_forbids_large_file_paths():
print(f"❌ FAIL: test_fluxonkv_external_forbids_large_file_paths - {e}")
+def test_fluxonkv_owner_ssd_capacity():
+ """Ensure the Python schema forwards owner SSD capacity to Rust unchanged."""
+ try:
+ owner = _owner_fluxonkv_base_config(tag="owner_ssd_capacity")
+ owner["fluxonkv_spec"]["large_limit_size"] = [67108864]
+ owner["fluxonkv_spec"]["ssd_write_rate_limit_bytes_per_sec"] = 268435456
+ owner["fluxonkv_spec"]["ssd_write_burst_bytes"] = 67108864
+ config = FluxonKvClientConfig(owner)
+ assert config.to_dict()["fluxonkv_spec"]["large_limit_size"] == [67108864]
+ assert (
+ config.to_dict()["fluxonkv_spec"]["ssd_write_rate_limit_bytes_per_sec"]
+ == 268435456
+ )
+ assert config.to_dict()["fluxonkv_spec"]["ssd_write_burst_bytes"] == 67108864
+ rendered = config.to_fluxon_kv_client_config_yaml_str()
+ assert "large_limit_size:" in rendered
+ assert "- 67108864" in rendered
+ assert "ssd_write_rate_limit_bytes_per_sec: 268435456" in rendered
+ assert "ssd_write_burst_bytes: 67108864" in rendered
+
+ unpaired = _owner_fluxonkv_base_config(tag="owner_ssd_unpaired_limit")
+ unpaired["fluxonkv_spec"]["large_limit_size"] = [67108864]
+ unpaired["fluxonkv_spec"]["ssd_write_rate_limit_bytes_per_sec"] = 1
+ try:
+ FluxonKvClientConfig(unpaired)
+ print("❌ FAIL: test_fluxonkv_owner_ssd_capacity - unpaired rate should be rejected")
+ return
+ except ValueError:
+ pass
+
+ wrong_shape = _owner_fluxonkv_base_config(tag="owner_ssd_capacity_wrong_shape")
+ wrong_shape["fluxonkv_spec"]["large_limit_size"] = 67108864
+ try:
+ FluxonKvClientConfig(wrong_shape)
+ print("❌ FAIL: test_fluxonkv_owner_ssd_capacity - non-list capacity should be rejected")
+ return
+ except ValueError:
+ pass
+
+ external = {
+ "instance_key": "test_external_ssd_capacity",
+ "contribute_to_cluster_pool_size": {"dram": 0, "vram": {}},
+ "fluxonkv_spec": {
+ "cluster_name": "test_cluster",
+ "share_mem_path": "/tmp/kvcache_shared_memory/test",
+ "large_limit_size": [67108864],
+ },
+ }
+ try:
+ FluxonKvClientConfig(external)
+ print("❌ FAIL: test_fluxonkv_owner_ssd_capacity - external capacity should be rejected")
+ return
+ except ValueError:
+ pass
+
+ print("✅ PASS: test_fluxonkv_owner_ssd_capacity")
+ except Exception as e:
+ print(f"❌ FAIL: test_fluxonkv_owner_ssd_capacity - {e}")
+
+
def test_fluxonkv_p2p_relay_removed():
"""Ensure removed fluxonkv_spec.p2p_relay is rejected as an unknown key."""
try:
@@ -444,6 +506,61 @@ def test_fluxonkv_removed_rdma_config_keys():
print(f"❌ FAIL: test_fluxonkv_removed_rdma_config_keys - {e}")
+def test_fluxonkv_owner_hot_writeback():
+ """Validate the owner-only hot-tier ratio and preserve it in Rust YAML."""
+ try:
+ owner = _owner_fluxonkv_base_config(tag="owner_hot_writeback")
+ owner["replica_writeback_hot_capacity_ratio"] = 0.75
+ loaded = yaml.safe_load(
+ FluxonKvClientConfig(owner).to_fluxon_kv_client_config_yaml_str()
+ )
+ assert loaded["replica_writeback_hot_capacity_ratio"] == 0.75
+
+ for invalid in (0, 1, -0.1, float("nan"), True, "0.75"):
+ invalid_owner = _owner_fluxonkv_base_config(tag="owner_hot_invalid")
+ invalid_owner["replica_writeback_hot_capacity_ratio"] = invalid
+ try:
+ FluxonKvClientConfig(invalid_owner)
+ print(
+ "❌ FAIL: test_fluxonkv_owner_hot_writeback - invalid ratio should be rejected"
+ )
+ return
+ except ValueError:
+ pass
+
+ external = {
+ "instance_key": "external_hot_invalid",
+ "replica_writeback_hot_capacity_ratio": 0.75,
+ "fluxonkv_spec": {
+ "cluster_name": "test_cluster",
+ "share_mem_path": "/tmp/kvcache_shared_memory/external_hot_invalid",
+ },
+ }
+ try:
+ FluxonKvClientConfig(external)
+ print(
+ "❌ FAIL: test_fluxonkv_owner_hot_writeback - external ratio should be rejected"
+ )
+ return
+ except ValueError:
+ pass
+
+ mooncake = config_dict()
+ mooncake["replica_writeback_hot_capacity_ratio"] = 0.75
+ try:
+ FluxonKvClientConfig(mooncake)
+ print(
+ "❌ FAIL: test_fluxonkv_owner_hot_writeback - Mooncake ratio should be rejected"
+ )
+ return
+ except ValueError:
+ pass
+
+ print("✅ PASS: test_fluxonkv_owner_hot_writeback")
+ except Exception as e:
+ print(f"❌ FAIL: test_fluxonkv_owner_hot_writeback - {e}")
+
+
def test_fluxonkv_test_spec_config():
"""Ensure test_spec_config is accepted, normalized, and serialized."""
try:
@@ -473,6 +590,10 @@ def test_fluxonkv_test_spec_config():
rdma_devices["test_spec_config"]["tcp_thread_reactor_shard_count"] = 2
rdma_devices["test_spec_config"]["tcp_thread_bulk_lane_count"] = 4
rdma_devices["test_spec_config"]["tcp_thread_control_lane_count"] = 3
+ rdma_devices["test_spec_config"]["replica_task_max_inflight"] = 16
+ rdma_devices["test_spec_config"]["ssd_read_source_policy"] = (
+ "local_ssd_only_first"
+ )
rdma_devices["test_spec_config"][
"require_transfer_rpc_fast_path_ready_timeout_seconds"
] = 45
@@ -487,12 +608,76 @@ def test_fluxonkv_test_spec_config():
assert loaded["test_spec_config"]["tcp_thread_reactor_shard_count"] == 2
assert loaded["test_spec_config"]["tcp_thread_bulk_lane_count"] == 4
assert loaded["test_spec_config"]["tcp_thread_control_lane_count"] == 3
+ assert loaded["test_spec_config"]["replica_task_max_inflight"] == 16
+ assert (
+ loaded["test_spec_config"]["ssd_read_source_policy"]
+ == "local_ssd_only_first"
+ )
assert (
loaded["test_spec_config"]["require_transfer_rpc_fast_path_ready_timeout_seconds"]
== 45
)
assert config.protocol_rdma_device_names == "mlx5_0,mlx5_4"
+ for removed_policy in (
+ "local_ssd_after_memory",
+ "local_ssd_before_remote_memory",
+ ):
+ invalid_policy = copy.deepcopy(rdma_devices)
+ invalid_policy["test_spec_config"]["ssd_read_source_policy"] = removed_policy
+ try:
+ FluxonKvClientConfig(invalid_policy)
+ raise AssertionError(
+ f"removed SSD read policy must be rejected: {removed_policy}"
+ )
+ except ValueError:
+ pass
+
+ expected_capacity = _owner_fluxonkv_base_config(tag="expected_capacity")
+ expected_capacity["contribute_to_cluster_pool_size"]["dram"] = 137438953472
+ expected_capacity["test_spec_config"] = {
+ "owner_local_reserve_soft_wait_timeout_ms": 10,
+ "owner_local_reserve_hard_timeout_ms": 10_000,
+ "owner_local_reserve_expected_capacity": {
+ "value_len": 4_718_592,
+ "payload_capacity_bytes": 109_951_162_777,
+ },
+ }
+ config = FluxonKvClientConfig(expected_capacity)
+ loaded = yaml.safe_load(config.to_fluxon_kv_client_config_yaml_str())
+ assert loaded["test_spec_config"]["owner_local_reserve_soft_wait_timeout_ms"] == 10
+ assert loaded["test_spec_config"]["owner_local_reserve_hard_timeout_ms"] == 10_000
+ assert loaded["test_spec_config"]["owner_local_reserve_expected_capacity"] == {
+ "value_len": 4_718_592,
+ "payload_capacity_bytes": 109_951_162_777,
+ }
+
+ invalid_reserve_timeout = copy.deepcopy(expected_capacity)
+ invalid_reserve_timeout["test_spec_config"][
+ "owner_local_reserve_hard_timeout_ms"
+ ] = 10
+ try:
+ FluxonKvClientConfig(invalid_reserve_timeout)
+ print(
+ "❌ FAIL: test_fluxonkv_test_spec_config - local-reserve hard timeout should exceed soft timeout"
+ )
+ return
+ except ValueError:
+ pass
+
+ invalid_expected_capacity = copy.deepcopy(expected_capacity)
+ invalid_expected_capacity["test_spec_config"][
+ "owner_local_reserve_expected_capacity"
+ ]["value_len"] = 0
+ try:
+ FluxonKvClientConfig(invalid_expected_capacity)
+ print(
+ "❌ FAIL: test_fluxonkv_test_spec_config - expected-capacity value_len=0 should be rejected"
+ )
+ return
+ except ValueError:
+ pass
+
implicit_transport = copy.deepcopy(base)
implicit_transport["test_spec_config"] = {
"disable_observability": True,
diff --git a/fluxon_release/closed_sdk/lib/libfluxon_commu_core.so b/fluxon_release/closed_sdk/lib/libfluxon_commu_core.so
index 0d1a1d3..ce9458d 100755
Binary files a/fluxon_release/closed_sdk/lib/libfluxon_commu_core.so and b/fluxon_release/closed_sdk/lib/libfluxon_commu_core.so differ
diff --git a/fluxon_rs/Cargo.lock b/fluxon_rs/Cargo.lock
index a4b0ecd..59b0cd0 100644
--- a/fluxon_rs/Cargo.lock
+++ b/fluxon_rs/Cargo.lock
@@ -485,6 +485,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
+ "jobserver",
+ "libc",
"shlex",
]
@@ -575,6 +577,12 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+[[package]]
+name = "cmsketch"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7ee2cfacbd29706479902b06d75ad8f1362900836aa32799eabc7e004bfd854"
+
[[package]]
name = "cobs"
version = "0.3.0"
@@ -615,6 +623,17 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "core_affinity"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342"
+dependencies = [
+ "libc",
+ "num_cpus",
+ "winapi",
+]
+
[[package]]
name = "cpp_demangle"
version = "0.4.5"
@@ -960,6 +979,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+[[package]]
+name = "fastant"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e825441bfb2d831c47c97d05821552db8832479f44c571b97fededbf0099c07"
+dependencies = [
+ "small_ctor",
+ "web-time",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1232,6 +1261,7 @@ dependencies = [
"fluxon_framework_compiled",
"fluxon_observability",
"fluxon_util",
+ "foyer",
"futures",
"hex",
"hyper 0.14.32",
@@ -1447,6 +1477,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -1471,6 +1507,130 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "foyer"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0abc0b87814989efa711f9becd9f26969820e2d3905db27d10969c4bd45890"
+dependencies = [
+ "anyhow",
+ "equivalent",
+ "foyer-common",
+ "foyer-memory",
+ "foyer-storage",
+ "foyer-tokio",
+ "futures-util",
+ "mea",
+ "mixtrics",
+ "pin-project",
+ "serde",
+ "tracing",
+]
+
+[[package]]
+name = "foyer-common"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3db80d5dece93adb7ad709c84578794724a9cba342a7e566c3551c7ec626789"
+dependencies = [
+ "anyhow",
+ "bincode",
+ "bytes",
+ "cfg-if",
+ "foyer-tokio",
+ "mixtrics",
+ "parking_lot",
+ "pin-project",
+ "serde",
+ "twox-hash",
+]
+
+[[package]]
+name = "foyer-intrusive-collections"
+version = "0.10.0-dev"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b"
+dependencies = [
+ "memoffset",
+]
+
+[[package]]
+name = "foyer-memory"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db907f40a527ca2aa2f40a5f68b32ea58aa70f050cd233518e9ffd402cfba6ce"
+dependencies = [
+ "anyhow",
+ "bitflags 2.9.1",
+ "cmsketch",
+ "equivalent",
+ "foyer-common",
+ "foyer-intrusive-collections",
+ "foyer-tokio",
+ "futures-util",
+ "hashbrown 0.16.1",
+ "itertools 0.14.0",
+ "mea",
+ "mixtrics",
+ "parking_lot",
+ "paste",
+ "pin-project",
+ "serde",
+ "tracing",
+]
+
+[[package]]
+name = "foyer-storage"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1983f1db3d0710e9c9d5fc116d9202dccd41a2d1e032572224f1aff5520aa958"
+dependencies = [
+ "allocator-api2",
+ "anyhow",
+ "bytes",
+ "core_affinity",
+ "equivalent",
+ "fastant",
+ "foyer-common",
+ "foyer-memory",
+ "foyer-tokio",
+ "fs4",
+ "futures-core",
+ "futures-util",
+ "hashbrown 0.16.1",
+ "io-uring",
+ "itertools 0.14.0",
+ "libc",
+ "lz4",
+ "mea",
+ "parking_lot",
+ "pin-project",
+ "rand 0.9.2",
+ "serde",
+ "tracing",
+ "twox-hash",
+ "zstd",
+]
+
+[[package]]
+name = "foyer-tokio"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6577b05a7ffad0db555aedf00bfe52af818220fc4c1c3a7a12520896fc38627"
+dependencies = [
+ "tokio",
+]
+
+[[package]]
+name = "fs4"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
+dependencies = [
+ "rustix 1.0.7",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "futures"
version = "0.3.31"
@@ -1618,11 +1778,22 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
]
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
[[package]]
name = "gimli"
version = "0.31.1"
@@ -1711,7 +1882,18 @@ checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
]
[[package]]
@@ -2395,6 +2577,17 @@ dependencies = [
"str_stack",
]
+[[package]]
+name = "io-uring"
+version = "0.7.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9080b15e63775b9a2ac7dca720f7050a8b955e092ea0f6020a4a80f69998cdc0"
+dependencies = [
+ "bitflags 2.9.1",
+ "cfg-if",
+ "libc",
+]
+
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2446,12 +2639,40 @@ dependencies = [
"either",
]
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
[[package]]
name = "js-sys"
version = "0.3.77"
@@ -2586,6 +2807,25 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+[[package]]
+name = "lz4"
+version = "1.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4"
+dependencies = [
+ "lz4-sys",
+]
+
+[[package]]
+name = "lz4-sys"
+version = "1.11.1+lz4-1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6"
+dependencies = [
+ "cc",
+ "libc",
+]
+
[[package]]
name = "matchers"
version = "0.1.0"
@@ -2601,6 +2841,15 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+[[package]]
+name = "mea"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2640d335e7273dacdcf51044026139b2e269c3bb0dfc3f8cb3496b85e3f6a42c"
+dependencies = [
+ "slab",
+]
+
[[package]]
name = "memchr"
version = "2.7.5"
@@ -2668,6 +2917,16 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "mixtrics"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c46b5adfb7a3ae4996d327a5bdc90e78fec025806dd312bdbe6f07a755e0ec9"
+dependencies = [
+ "itertools 0.15.0",
+ "parking_lot",
+]
+
[[package]]
name = "moka"
version = "0.12.11"
@@ -2831,6 +3090,16 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
[[package]]
name = "object"
version = "0.36.7"
@@ -3498,6 +3767,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "rand"
version = "0.7.3"
@@ -4206,9 +4481,15 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "slab"
-version = "0.4.10"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "small_ctor"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88414a5ca1f85d82cc34471e975f0f74f6aa54c40f062efa42c0080e7f763f81"
[[package]]
name = "smallvec"
@@ -5080,6 +5361,15 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "twox-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9"
+dependencies = [
+ "rand 0.9.2",
+]
+
[[package]]
name = "typenum"
version = "1.18.0"
@@ -5902,3 +6192,31 @@ dependencies = [
"quote",
"syn 2.0.104",
]
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/fluxon_rs/fluxon_commu/src/facade/p2p.rs b/fluxon_rs/fluxon_commu/src/facade/p2p.rs
index 8bcc169..1d4306c 100644
--- a/fluxon_rs/fluxon_commu/src/facade/p2p.rs
+++ b/fluxon_rs/fluxon_commu/src/facade/p2p.rs
@@ -1297,6 +1297,8 @@ pub mod rpc {
timeout: Option,
transport_policy: RpcTransportPolicy,
) -> P2PResult> {
+ validate_explicit_rpc_timeout(timeout)?;
+
let mut failed_count = 0;
let shutdown_poller = p2p.module_view().register_shutdown_poller();
@@ -1371,6 +1373,8 @@ pub mod rpc {
timeout: Option,
transport_policy: RpcTransportPolicy,
) -> P2PResult> {
+ validate_explicit_rpc_timeout(timeout)?;
+
regist_rpc_send::(p2p);
{
@@ -1381,31 +1385,12 @@ pub mod rpc {
.await?;
}
- let timeout_duration = match timeout {
- Some(duration) => {
- let min_duration = Duration::from_secs(MIN_EXPLICIT_RPC_TIMEOUT_SECS);
- if duration < min_duration {
- return Err(P2pError::InvalidRpcTimeout {
- timeout_ms: duration.as_secs() * 1_000
- + u64::from(duration.subsec_millis()),
- min_timeout_ms: MIN_EXPLICIT_RPC_TIMEOUT_SECS * 1_000,
- reason: format!(
- "Explicit RPC timeout below {}s is forbidden.",
- MIN_EXPLICIT_RPC_TIMEOUT_SECS
- ),
- });
- }
- Some(duration)
- }
- None => None,
- };
-
let wire_encode_started_at = Instant::now();
let msg_id = req.msg_id();
let wire_body = req.into_wire_body()?;
let wire_encode_us = duration_to_i64_us(wire_encode_started_at.elapsed());
let raw_output = p2p
- .call_raw_observed(node, msg_id, wire_body, timeout_duration, transport_policy)
+ .call_raw_observed(node, msg_id, wire_body, timeout, transport_policy)
.await?;
let response_local_observe = raw_output.message.local_observe;
let resp_decode_started_at = Instant::now();
diff --git a/fluxon_rs/fluxon_commu/src/facade/transfer_engine.rs b/fluxon_rs/fluxon_commu/src/facade/transfer_engine.rs
index 878e5c6..f701659 100644
--- a/fluxon_rs/fluxon_commu/src/facade/transfer_engine.rs
+++ b/fluxon_rs/fluxon_commu/src/facade/transfer_engine.rs
@@ -7,6 +7,9 @@ use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use crate::NodeIDString;
+pub use fluxon_commu_contract::{
+ CLOSED_RUNTIME_DIRECT_FAST_PATH_NOT_READY_MARKER, ClosedRuntimeLocalMemoryKind,
+};
use fluxon_commu_contract::{
ClosedRuntimeHandle, ClosedRuntimePeerGen, ClosedRuntimeTransferEngineOpenRuntimeRequest,
ClosedRuntimeTransferEngineOpenRuntimeResponse,
@@ -74,12 +77,10 @@ impl ClosedLocalSegmentLeaseRegistry {
where
G: Send + Sync + 'static,
{
- let boxed = self
- .guards
- .lock()
- .await
- .remove(&handle)
- .ok_or_else(|| format!("closed sdk local segment lease handle {handle} not found"))?;
+ let boxed =
+ self.guards.lock().await.remove(&handle).ok_or_else(|| {
+ format!("closed sdk local segment lease handle {handle} not found")
+ })?;
boxed.downcast::().map(|guard| *guard).map_err(|_| {
format!(
"closed sdk local segment lease handle {handle} has unexpected runtime guard type"
@@ -379,6 +380,25 @@ impl ClientTransferEngineCore {
runtime: R,
cpu_mem: &CpuAllocatedMem,
) -> TransferEngineResult<()>
+ where
+ R: ClientTransferEngineRuntime,
+ {
+ self.register_local_memory(
+ runtime,
+ cpu_mem.allocated_addr,
+ cpu_mem.allocated_size,
+ ClosedRuntimeLocalMemoryKind::Host,
+ )
+ .await
+ }
+
+ pub async fn register_local_memory(
+ &self,
+ runtime: R,
+ allocated_addr: u64,
+ allocated_size: u64,
+ memory_kind: ClosedRuntimeLocalMemoryKind,
+ ) -> TransferEngineResult<()>
where
R: ClientTransferEngineRuntime,
{
@@ -386,8 +406,9 @@ impl ClientTransferEngineCore {
let handle = self.ensure_closed_runtime_handle(&runtime).await?;
transfer_engine_register_local_segment(
handle,
- cpu_mem.allocated_addr,
- cpu_mem.allocated_size,
+ allocated_addr,
+ allocated_size,
+ memory_kind,
)
.await
.map_err(transfer_engine_closed_sdk_error)
@@ -397,13 +418,28 @@ impl ClientTransferEngineCore {
pub async fn unregister_local_segment(
&self,
cpu_mem: &CpuAllocatedMem,
+ ) -> TransferEngineResult<()> {
+ self.unregister_local_memory(
+ cpu_mem.allocated_addr,
+ cpu_mem.allocated_size,
+ ClosedRuntimeLocalMemoryKind::Host,
+ )
+ .await
+ }
+
+ pub async fn unregister_local_memory(
+ &self,
+ allocated_addr: u64,
+ allocated_size: u64,
+ memory_kind: ClosedRuntimeLocalMemoryKind,
) -> TransferEngineResult<()> {
{
if let Some(handle) = self.closed.handle.get().copied() {
transfer_engine_unregister_local_segment(
handle,
- cpu_mem.allocated_addr,
- cpu_mem.allocated_size,
+ allocated_addr,
+ allocated_size,
+ memory_kind,
)
.await
.map_err(transfer_engine_closed_sdk_error)
@@ -460,8 +496,9 @@ impl ClientTransferEngineCore {
target_addr,
len,
seg_guard,
+ false,
)
- .await
+ .await
}
}
@@ -474,6 +511,7 @@ impl ClientTransferEngineCore {
target_addr: u64,
len: u64,
seg_guard: Option,
+ require_fast_path: bool,
) -> TransferEngineResult
where
R: ClientTransferEngineRuntime,
@@ -482,7 +520,11 @@ impl ClientTransferEngineCore {
let initial_local_segment_guard = match seg_guard {
Some(guard) => Some(guard),
None if runtime.supports_local_segment_transfer() => {
- let local_addr = if peer_src_or_target { target_addr } else { src_addr };
+ let local_addr = if peer_src_or_target {
+ target_addr
+ } else {
+ src_addr
+ };
match runtime.ensure_local_segment_guard(local_addr, None).await {
Ok(guard) => Some(guard),
Err(_) => None,
@@ -504,6 +546,7 @@ impl ClientTransferEngineCore {
target_addr,
len,
initial_local_segment_guard_handle,
+ require_fast_path,
)
.await
.map_err(transfer_engine_closed_sdk_error);
diff --git a/fluxon_rs/fluxon_commu_closed_sdk_consumer/src/lib.rs b/fluxon_rs/fluxon_commu_closed_sdk_consumer/src/lib.rs
index 6fab54e..7395347 100644
--- a/fluxon_rs/fluxon_commu_closed_sdk_consumer/src/lib.rs
+++ b/fluxon_rs/fluxon_commu_closed_sdk_consumer/src/lib.rs
@@ -11,9 +11,9 @@ use fluxon_commu_contract::{
ClosedRuntimeCallRawObservedOutputView, ClosedRuntimeClusterEventStreamItem,
ClosedRuntimeClusterManagerCall, ClosedRuntimeClusterManagerResponse,
ClosedRuntimeClusterRdmaResolvedConfigStreamItem, ClosedRuntimeDesiredTransferPeer,
- ClosedRuntimeDispatchRequestView,
- ClosedRuntimeDispatchResponse, ClosedRuntimeDispatchTransportPolicy, ClosedRuntimeError,
- ClosedRuntimeHandle, ClosedRuntimeHostCallbackHandle, ClosedRuntimeP2pCall,
+ ClosedRuntimeDispatchRequestView, ClosedRuntimeDispatchResponse,
+ ClosedRuntimeDispatchTransportPolicy, ClosedRuntimeError, ClosedRuntimeHandle,
+ ClosedRuntimeHostCallbackHandle, ClosedRuntimeLocalMemoryKind, ClosedRuntimeP2pCall,
ClosedRuntimeP2pCallRawObservedRequestView, ClosedRuntimeP2pResponse,
ClosedRuntimeP2pSendResponseRawRequestView, ClosedRuntimePeerGen, ClosedRuntimeRawSlice,
ClosedRuntimeRequest, ClosedRuntimeResponse, ClosedRuntimeTransferEngineCall,
@@ -26,8 +26,8 @@ use fluxon_commu_contract::{
pub mod rdma_probe;
-pub const FLUXON_COMMU_CLOSED_SDK_SCHEMA_VERSION: u32 = 5;
-pub const FLUXON_COMMU_CLOSED_ABI_VERSION: u32 = 8;
+pub const FLUXON_COMMU_CLOSED_SDK_SCHEMA_VERSION: u32 = 6;
+pub const FLUXON_COMMU_CLOSED_ABI_VERSION: u32 = 9;
pub const FLUXON_COMMU_CLOSED_HOST_CALLBACKS_ABI_VERSION: u32 = 8;
pub const FLUXON_COMMU_CLOSED_RUNTIME_RESULT_OK: i32 = 0;
pub const FLUXON_COMMU_CLOSED_RUNTIME_RESULT_ERR: i32 = 1;
@@ -491,11 +491,15 @@ impl WireBodyPartsOwner {
let (raw_lengths, raw_payload) = match raw_bytes.len() {
0 => (WireBodyRawLengths::Empty, WireBodyRawPayload::Empty),
1 => {
- let part = raw_bytes.into_iter().next().expect("single raw part missing");
- let len =
- u32::try_from(part.len()).map_err(|_| ClosedSdkConsumerError::RuntimeDecode {
+ let part = raw_bytes
+ .into_iter()
+ .next()
+ .expect("single raw part missing");
+ let len = u32::try_from(part.len()).map_err(|_| {
+ ClosedSdkConsumerError::RuntimeDecode {
detail: format!("wire raw part too large for u32 length: {}", part.len()),
- })?;
+ }
+ })?;
(
WireBodyRawLengths::Single([len]),
WireBodyRawPayload::Single(part),
@@ -849,8 +853,7 @@ fn decode_call_raw_observed_output_view(
return Err(ClosedSdkConsumerError::RuntimeDecode {
detail: format!(
"closed SDK call_raw_observed serialize_part overflow: serialize_len={} full_len={}",
- message_view.body.serialize_part.len,
- message_view.body.full_body.len,
+ message_view.body.serialize_part.len, message_view.body.full_body.len,
),
});
}
@@ -860,21 +863,19 @@ fn decode_call_raw_observed_output_view(
.ok_or_else(|| ClosedSdkConsumerError::RuntimeDecode {
detail: "closed SDK call_raw_observed raw_bytes length overflow".to_string(),
})?;
- let expected_full_len =
- message_view
- .body
- .serialize_part
- .len
- .checked_add(raw_total)
- .ok_or_else(|| ClosedSdkConsumerError::RuntimeDecode {
- detail: "closed SDK call_raw_observed body length overflow".to_string(),
- })?;
+ let expected_full_len = message_view
+ .body
+ .serialize_part
+ .len
+ .checked_add(raw_total)
+ .ok_or_else(|| ClosedSdkConsumerError::RuntimeDecode {
+ detail: "closed SDK call_raw_observed body length overflow".to_string(),
+ })?;
if expected_full_len != message_view.body.full_body.len {
return Err(ClosedSdkConsumerError::RuntimeDecode {
detail: format!(
"closed SDK call_raw_observed body length mismatch: expected={} full_len={}",
- expected_full_len,
- message_view.body.full_body.len,
+ expected_full_len, message_view.body.full_body.len,
),
});
}
@@ -923,9 +924,7 @@ fn decode_call_raw_observed_output_view(
frame_recv_done_ts_us: message_view.local_observe.frame_recv_done_ts_us,
dispatch_enqueued_ts_us: message_view.local_observe.dispatch_enqueued_ts_us,
dispatch_started_ts_us: message_view.local_observe.dispatch_started_ts_us,
- complete_pending_call_ts_us: message_view
- .local_observe
- .complete_pending_call_ts_us,
+ complete_pending_call_ts_us: message_view.local_observe.complete_pending_call_ts_us,
},
},
observe: fluxon_commu_contract::ClosedRuntimeRpcCallTransportObserveTrace {
@@ -1550,8 +1549,8 @@ async fn invoke_completion_async_with_keepalive(
) -> i32,
) -> Result<(i32, Bytes), ClosedSdkConsumerError> {
let (sender, receiver) = tokio::sync::oneshot::channel::<(i32, Bytes)>();
- let user_data = Box::into_raw(Box::new(RuntimeCompletionState { sender, keepalive }))
- .cast::();
+ let user_data =
+ Box::into_raw(Box::new(RuntimeCompletionState { sender, keepalive })).cast::();
let submit_status = submit(user_data, Some(runtime_completion_callback));
if submit_status != 0 {
unsafe {
@@ -1867,12 +1866,14 @@ pub async fn transfer_engine_register_local_segment(
handle: ClosedRuntimeHandle,
allocated_addr: u64,
allocated_size: u64,
+ memory_kind: ClosedRuntimeLocalMemoryKind,
) -> Result<(), ClosedSdkConsumerError> {
match transfer_engine_call(
handle,
ClosedRuntimeTransferEngineCall::RegisterLocalSegment {
allocated_addr,
allocated_size,
+ memory_kind,
},
)
.await?
@@ -1888,12 +1889,14 @@ pub async fn transfer_engine_unregister_local_segment(
handle: ClosedRuntimeHandle,
allocated_addr: u64,
allocated_size: u64,
+ memory_kind: ClosedRuntimeLocalMemoryKind,
) -> Result<(), ClosedSdkConsumerError> {
match transfer_engine_call(
handle,
ClosedRuntimeTransferEngineCall::UnregisterLocalSegment {
allocated_addr,
allocated_size,
+ memory_kind,
},
)
.await?
@@ -1913,6 +1916,7 @@ pub async fn transfer_engine_transfer_data_no_copy(
target_addr: u64,
len: u64,
initial_local_segment_guard_handle: Option,
+ require_fast_path: bool,
) -> Result {
match transfer_engine_call(
handle,
@@ -1923,6 +1927,7 @@ pub async fn transfer_engine_transfer_data_no_copy(
target_addr,
len,
initial_local_segment_guard_handle,
+ require_fast_path,
},
)
.await?
@@ -2082,7 +2087,9 @@ pub async fn p2p_call_raw_observed(
)
.await?;
match status_code {
- FLUXON_COMMU_CLOSED_RUNTIME_RESULT_OK => decode_call_raw_observed_output_view(payload.as_ref()),
+ FLUXON_COMMU_CLOSED_RUNTIME_RESULT_OK => {
+ decode_call_raw_observed_output_view(payload.as_ref())
+ }
FLUXON_COMMU_CLOSED_RUNTIME_RESULT_ERR => {
let error = bitcode::decode::(payload.as_ref()).map_err(
|decode_error| ClosedSdkConsumerError::RuntimeDecode {
diff --git a/fluxon_rs/fluxon_commu_contract/src/closed_runtime.rs b/fluxon_rs/fluxon_commu_contract/src/closed_runtime.rs
index 80872e2..17241cf 100644
--- a/fluxon_rs/fluxon_commu_contract/src/closed_runtime.rs
+++ b/fluxon_rs/fluxon_commu_contract/src/closed_runtime.rs
@@ -744,6 +744,15 @@ pub struct ClosedRuntimeDesiredTransferPeer {
pub enable_transfer_segment: bool,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
+pub enum ClosedRuntimeLocalMemoryKind {
+ Host,
+ Gpu { device_id: u32 },
+}
+
+pub const CLOSED_RUNTIME_DIRECT_FAST_PATH_NOT_READY_MARKER: &str =
+ "fluxon_direct_fast_path_not_ready";
+
#[derive(Debug, Clone, Encode, Decode)]
pub enum ClosedRuntimeTransferEngineCall {
Init2ForInitDag {
@@ -764,10 +773,12 @@ pub enum ClosedRuntimeTransferEngineCall {
RegisterLocalSegment {
allocated_addr: u64,
allocated_size: u64,
+ memory_kind: ClosedRuntimeLocalMemoryKind,
},
UnregisterLocalSegment {
allocated_addr: u64,
allocated_size: u64,
+ memory_kind: ClosedRuntimeLocalMemoryKind,
},
TransferDataNoCopy {
peer_node: Option,
@@ -776,6 +787,7 @@ pub enum ClosedRuntimeTransferEngineCall {
target_addr: u64,
len: u64,
initial_local_segment_guard_handle: Option,
+ require_fast_path: bool,
},
TrySendWireDirect {
peer_gen: ClosedRuntimePeerGen,
diff --git a/fluxon_rs/fluxon_commu_contract/src/p2p.rs b/fluxon_rs/fluxon_commu_contract/src/p2p.rs
index 673692b..c548ee4 100644
--- a/fluxon_rs/fluxon_commu_contract/src/p2p.rs
+++ b/fluxon_rs/fluxon_commu_contract/src/p2p.rs
@@ -9,15 +9,16 @@ pub mod surface;
pub mod wire;
pub use rpc::{
- MIN_EXPLICIT_RPC_TIMEOUT_SECS, MsgPack, MsgPackSerializePart, RPCReq, RpcCallObserveTrace,
- RpcCallObservedOutput, USER_RPC_OBSERVE_TRACE_RAW_BYTES_INDEX,
+ MIN_EXPLICIT_RPC_TIMEOUT_MS, MIN_EXPLICIT_RPC_TIMEOUT_SECS, MsgPack, MsgPackSerializePart,
+ RPCReq, RpcCallObserveTrace, RpcCallObservedOutput, USER_RPC_OBSERVE_TRACE_RAW_BYTES_INDEX,
USER_RPC_OWNER1_OBSERVE_TRACE_RAW_BYTES_INDEX, USER_RPC_REQ_MSG_ID,
USER_RPC_REQUEST_OWNER1_OBSERVE_TRACE_RAW_BYTES_INDEX, USER_RPC_RESP_MSG_ID,
UserRpcBytesAsyncHandler, UserRpcBytesError, UserRpcBytesFuture, UserRpcBytesHandler,
UserRpcObserveTrace, UserRpcOwner1ObserveTrace, UserRpcTransportPathKind,
current_cross_process_monotonic_us, decode_user_rpc_observe_trace,
decode_user_rpc_owner1_observe_trace, encode_user_rpc_observe_trace,
- encode_user_rpc_owner1_observe_trace,
+ encode_user_rpc_owner1_observe_trace, validate_explicit_rpc_timeout,
+ validate_explicit_rpc_timeout_ms,
};
pub use surface::*;
pub use wire::*;
diff --git a/fluxon_rs/fluxon_commu_contract/src/p2p/rpc.rs b/fluxon_rs/fluxon_commu_contract/src/p2p/rpc.rs
index b65ccd9..144034d 100644
--- a/fluxon_rs/fluxon_commu_contract/src/p2p/rpc.rs
+++ b/fluxon_rs/fluxon_commu_contract/src/p2p/rpc.rs
@@ -17,14 +17,66 @@ use prost::bytes::Bytes as ProstBytes;
use std::fmt::Debug;
use std::future::Future;
use std::pin::Pin;
+use std::time::Duration;
pub const MIN_EXPLICIT_RPC_TIMEOUT_SECS: u64 = 10;
+pub const MIN_EXPLICIT_RPC_TIMEOUT_MS: u64 = MIN_EXPLICIT_RPC_TIMEOUT_SECS * 1_000;
pub const USER_RPC_REQ_MSG_ID: u32 = 7001;
pub const USER_RPC_RESP_MSG_ID: u32 = 7002;
pub const USER_RPC_REQUEST_OWNER1_OBSERVE_TRACE_RAW_BYTES_INDEX: usize = 1;
pub const USER_RPC_OBSERVE_TRACE_RAW_BYTES_INDEX: usize = 1;
pub const USER_RPC_OWNER1_OBSERVE_TRACE_RAW_BYTES_INDEX: usize = 2;
+pub fn validate_explicit_rpc_timeout(timeout: Option) -> P2PResult<()> {
+ let Some(timeout) = timeout else {
+ return Ok(());
+ };
+ if timeout < Duration::from_millis(MIN_EXPLICIT_RPC_TIMEOUT_MS) {
+ return Err(P2pError::InvalidRpcTimeout {
+ timeout_ms: timeout.as_millis().min(u128::from(u64::MAX)) as u64,
+ min_timeout_ms: MIN_EXPLICIT_RPC_TIMEOUT_MS,
+ reason: format!(
+ "Explicit RPC timeout below {}s is forbidden.",
+ MIN_EXPLICIT_RPC_TIMEOUT_SECS
+ ),
+ });
+ }
+ Ok(())
+}
+
+pub fn validate_explicit_rpc_timeout_ms(timeout_ms: u64) -> P2PResult<()> {
+ validate_explicit_rpc_timeout(Some(Duration::from_millis(timeout_ms)))
+}
+
+#[cfg(test)]
+mod explicit_rpc_timeout_tests {
+ use super::*;
+
+ #[test]
+ fn accepts_default_and_minimum_explicit_timeout() {
+ assert!(validate_explicit_rpc_timeout(None).is_ok());
+ assert!(validate_explicit_rpc_timeout_ms(MIN_EXPLICIT_RPC_TIMEOUT_MS).is_ok());
+ }
+
+ #[test]
+ fn rejects_subminimum_timeout_with_typed_error() {
+ let timeout_ms = MIN_EXPLICIT_RPC_TIMEOUT_MS - 1;
+ let err = validate_explicit_rpc_timeout_ms(timeout_ms).unwrap_err();
+ assert_eq!(err.code(), 617);
+ match err {
+ P2pError::InvalidRpcTimeout {
+ timeout_ms: actual_timeout_ms,
+ min_timeout_ms,
+ ..
+ } => {
+ assert_eq!(actual_timeout_ms, timeout_ms);
+ assert_eq!(min_timeout_ms, MIN_EXPLICIT_RPC_TIMEOUT_MS);
+ }
+ other => panic!("expected InvalidRpcTimeout, got {other:?}"),
+ }
+ }
+}
+
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
pub enum UserRpcTransportPathKind {
#[default]
diff --git a/fluxon_rs/fluxon_fs/src/agent.rs b/fluxon_rs/fluxon_fs/src/agent.rs
index a482616..45e15e7 100644
--- a/fluxon_rs/fluxon_fs/src/agent.rs
+++ b/fluxon_rs/fluxon_fs/src/agent.rs
@@ -1408,13 +1408,24 @@ impl FluxonFsAgent {
.id
.to_string();
let cache_root_base = if self.kv_framework.is_external_mode() {
- self.kv_framework
+ let shared_file_path = self
+ .kv_framework
.external_client_api_view()
.external_client_api()
.inner()
- .large_file_paths()
- .fs_disk_cache_base_dir()
- .map_err(|err| format!("invalid external large_file_paths: {}", err))?
+ .shared_file_path();
+ if shared_file_path.is_empty() {
+ return Err("external shared_file_path is empty".to_string());
+ }
+ let cache_root = Path::new(&shared_file_path).join("fluxon_fs_disk_cache");
+ fs::create_dir_all(&cache_root).map_err(|err| {
+ format!(
+ "invalid external shared_file_path for fluxon fs disk cache: {} ({})",
+ cache_root.display(),
+ err
+ )
+ })?;
+ cache_root
} else {
self.kv_framework
.client_seg_pool_view()
diff --git a/fluxon_rs/fluxon_fs/src/agent_service/transfer_agent.rs b/fluxon_rs/fluxon_fs/src/agent_service/transfer_agent.rs
index 1738ade..ca54a71 100644
--- a/fluxon_rs/fluxon_fs/src/agent_service/transfer_agent.rs
+++ b/fluxon_rs/fluxon_fs/src/agent_service/transfer_agent.rs
@@ -9,28 +9,23 @@ use std::time::{Duration, Instant};
use fluxon_fs_core::config::{
FS_AGENT_TRANSFER_STREAM_CLOSE_RPC_PATH, FS_AGENT_TRANSFER_STREAM_NEXT_RPC_PATH,
- FS_AGENT_TRANSFER_STREAM_OPEN_RPC_PATH,
- FS_MASTER_TRANSFER_SCHEDULER_HEARTBEAT_RPC_PATH, FS_MASTER_TRANSFER_SCHEDULER_RESULT_RPC_PATH,
- FluxonFsTransferBatchCollectInfoWire, FluxonFsTransferBatchKind,
- FluxonFsTransferCollectInfoKind, FluxonFsTransferDispositionWire,
- FluxonFsTransferFailedFileReasonKindWire,
- FluxonFsTransferReadStreamCloseWire, FluxonFsTransferReadStreamNextResultWire,
- FluxonFsTransferReadStreamNextWire, FluxonFsTransferReadStreamOpenResultWire,
- FluxonFsTransferReadStreamOpenWire,
- FluxonFsTransferSkipEntryKind, FluxonFsTransferSkipEntryWire,
- FluxonFsTransferManifestEntryWire, FluxonFsTransferManifestWire,
- FluxonFsTransferScanMode,
- FluxonFsTransferScanEventAckWire, FluxonFsTransferScanEventKindWire,
- FluxonFsTransferScanEventWire, FluxonFsTransferScanLaunchResultWire,
+ FS_AGENT_TRANSFER_STREAM_OPEN_RPC_PATH, FS_MASTER_TRANSFER_SCHEDULER_HEARTBEAT_RPC_PATH,
+ FS_MASTER_TRANSFER_SCHEDULER_RESULT_RPC_PATH, FluxonFsTransferBatchCollectInfoWire,
+ FluxonFsTransferBatchKind, FluxonFsTransferCollectInfoKind, FluxonFsTransferDispositionWire,
+ FluxonFsTransferFailedFileReasonKindWire, FluxonFsTransferManifestEntryWire,
+ FluxonFsTransferManifestWire, FluxonFsTransferReadStreamCloseWire,
+ FluxonFsTransferReadStreamNextResultWire, FluxonFsTransferReadStreamNextWire,
+ FluxonFsTransferReadStreamOpenResultWire, FluxonFsTransferReadStreamOpenWire,
FluxonFsTransferScanAssignmentWire, FluxonFsTransferScanBatchWire,
- FluxonFsTransferScanChildUnitWire, FluxonFsTransferScanFrontier,
+ FluxonFsTransferScanChildUnitWire, FluxonFsTransferScanEventAckWire,
+ FluxonFsTransferScanEventKindWire, FluxonFsTransferScanEventWire, FluxonFsTransferScanFrontier,
FluxonFsTransferScanFrontierDirEntry, FluxonFsTransferScanFrontierEntry,
- FluxonFsTransferScanResultWire,
- FluxonFsTransferSymlinkNoticeEntryWire, FluxonFsTransferWorkerCollectInfoResultWire,
- FluxonFsTransferWorkerAssignmentWire, FluxonFsTransferWorkerFileResultWire,
- FluxonFsTransferWorkerFailedFileResultWire,
- FluxonFsTransferWorkerHeartbeatResultWire, FluxonFsTransferWorkerHeartbeatTelemetryWire,
- FluxonFsTransferWorkerHeartbeatWire,
+ FluxonFsTransferScanLaunchResultWire, FluxonFsTransferScanMode, FluxonFsTransferScanResultWire,
+ FluxonFsTransferSkipEntryKind, FluxonFsTransferSkipEntryWire,
+ FluxonFsTransferSymlinkNoticeEntryWire, FluxonFsTransferWorkerAssignmentWire,
+ FluxonFsTransferWorkerCollectInfoResultWire, FluxonFsTransferWorkerFailedFileResultWire,
+ FluxonFsTransferWorkerFileResultWire, FluxonFsTransferWorkerHeartbeatResultWire,
+ FluxonFsTransferWorkerHeartbeatTelemetryWire, FluxonFsTransferWorkerHeartbeatWire,
FluxonFsTransferWorkerLaunchResultWire, FluxonFsTransferWorkerResultAckWire,
FluxonFsTransferWorkerResultWire, FluxonFsTransferWorkerStopReasonWire,
transfer_collect_info_output_relpath,
@@ -39,8 +34,8 @@ use fluxon_fs_core::retry::{
BackoffConfig, DEFAULT_WARN_INTERVAL_SECS, WarnConfig, next_backoff, should_warn,
};
use fluxon_kv::rpcresp_kvresult_convert::msg_and_error::{ApiError, KvError};
-use fluxon_kv::user_api::flat_dict::{FlatDict, FlatValue};
use fluxon_kv::user_api::FluxonUserApi;
+use fluxon_kv::user_api::flat_dict::{FlatDict, FlatValue};
use parking_lot::{Condvar, Mutex};
use super::{
@@ -202,16 +197,13 @@ fn transfer_scan_session_state() -> &'static Mutex {
TRANSFER_SCAN_SESSION_STATE.get_or_init(|| Mutex::new(TransferScanSessionState::default()))
}
-fn cleanup_expired_transfer_scan_sessions(
- state: &mut TransferScanSessionState,
- now_unix_ms: i64,
-) {
- state
- .root_dir_listing_sessions
- .retain(|_, session| session.lease_expire_unix_ms <= 0 || session.lease_expire_unix_ms > now_unix_ms);
- state
- .subtree_streaming_sessions
- .retain(|_, session| session.lease_expire_unix_ms <= 0 || session.lease_expire_unix_ms > now_unix_ms);
+fn cleanup_expired_transfer_scan_sessions(state: &mut TransferScanSessionState, now_unix_ms: i64) {
+ state.root_dir_listing_sessions.retain(|_, session| {
+ session.lease_expire_unix_ms <= 0 || session.lease_expire_unix_ms > now_unix_ms
+ });
+ state.subtree_streaming_sessions.retain(|_, session| {
+ session.lease_expire_unix_ms <= 0 || session.lease_expire_unix_ms > now_unix_ms
+ });
}
fn same_root_continuation_scan_unit(
@@ -301,10 +293,7 @@ fn flush_pending_root_direct_files_batch(
return Ok(None);
}
let batch = build_direct_files_only_batch_from_entries_with_batch_id(
- direct_files_only_batch_id_for_partition(
- assignment,
- session.next_direct_files_batch_index,
- ),
+ direct_files_only_batch_id_for_partition(assignment, session.next_direct_files_batch_index),
assignment,
assignment.root_relpath.clone(),
std::mem::take(&mut session.pending_direct_files),
@@ -313,7 +302,8 @@ fn flush_pending_root_direct_files_batch(
)?;
session.pending_direct_bytes = 0;
session.next_direct_files_batch_index = session.next_direct_files_batch_index.saturating_add(1);
- session.emitted_direct_files_batch_count = session.emitted_direct_files_batch_count.saturating_add(1);
+ session.emitted_direct_files_batch_count =
+ session.emitted_direct_files_batch_count.saturating_add(1);
Ok(Some(batch))
}
@@ -414,7 +404,8 @@ fn open_transfer_root_dir_listing_session(
root_dir_abs: &str,
assignment: &FluxonFsTransferScanAssignmentWire,
) -> Result