diff --git a/.github/workflows/all_test.yml b/.github/workflows/all_test.yml
index 3f36d2d..f082c74 100644
--- a/.github/workflows/all_test.yml
+++ b/.github/workflows/all_test.yml
@@ -15,6 +15,31 @@ jobs:
ci-2-virt-node:
runs-on: ubuntu-latest
steps:
+ - name: Reclaim runner disk before CI
+ run: |
+ set -euo pipefail
+ df -h / /mnt
+
+ # ubuntu-latest includes large SDKs that this Rust/Python workflow never uses.
+ sudo rm -rf \
+ /opt/ghc \
+ /opt/hostedtoolcache/CodeQL \
+ /usr/local/.ghcup \
+ /usr/local/lib/android \
+ /usr/share/dotnet \
+ /usr/share/swift
+
+ sudo apt-get clean
+ sudo rm -rf /var/lib/apt/lists/*
+
+ if command -v docker >/dev/null 2>&1; then
+ sudo systemctl start docker
+ docker system prune --all --force --volumes
+ docker system df
+ fi
+
+ df -h / /mnt
+
- name: Check out repository
uses: actions/checkout@v6
with:
@@ -34,6 +59,8 @@ jobs:
protobuf-compiler \
python3-venv \
rsync
+ sudo apt-get clean
+ sudo rm -rf /var/lib/apt/lists/*
- name: Verify Docker availability
run: |
@@ -97,9 +124,16 @@ jobs:
"scale": "n1_kvowner_dram_20gib",
"case_config": True,
"scene_config": {
- "kv_test_rounds": "p2p_only",
+ "kv_test_rounds": "p2p_only,p2p_only_ssd",
},
},
+ "ci_top_attention_cargo_kv_unit": {
+ "subject": "rust",
+ "runtime_contract": "rust_self_managed",
+ "scale": "n1_kvowner_dram_20gib",
+ "case_config": True,
+ "scene_config": {},
+ },
"ci_top_attention_log_mgmt": {
"subject": "rust",
"runtime_contract": "rust_self_managed",
@@ -181,7 +215,8 @@ jobs:
# Scene selection:
# - ci_top_attention_doc_page_build validates doc build through the prebuilt Docker image.
- # - ci_top_attention_bin_kvtest keeps the Rust kv_test entry under the testbed scene contract.
+ # - ci_top_attention_bin_kvtest covers the memory and native SSD P2P rounds.
+ # - ci_top_attention_cargo_kv_unit runs the Rust KV crate unit tests.
# - ci_top_attention_log_mgmt keeps log rolling/sharding coverage under the same CI testbed contract.
# - ci_top_attention_ctrl_c_kv keeps runtime child-retirement Ctrl-C coverage in this workflow.
# - ci_top_attention_ctrl_c_mq keeps MQ Ctrl-C exit coverage in this workflow.
@@ -214,6 +249,7 @@ jobs:
# - Keep the original per-scene scales from ci_test_list.yaml.
# - ci_top_attention_doc_page_build stays on n1_kvowner_dram_3gib.
# - ci_top_attention_bin_kvtest stays on n1_kvowner_dram_20gib.
+ # - ci_top_attention_cargo_kv_unit stays on n1_kvowner_dram_20gib.
# - ci_top_attention_log_mgmt stays on n1_kvowner_dram_20gib.
# - ci_top_attention_ctrl_c_kv stays on n1_kvowner_dram_3gib.
# - ci_top_attention_ctrl_c_mq stays on n1_kvowner_dram_20gib.
@@ -234,7 +270,9 @@ jobs:
python3 fluxon_test_stack/ci_2_virt_node.py \
--suite-path "$RUNNER_TEMP/ci_test_list.ci.yaml" \
--print-generated \
- --testbed-hostworkdir "$RUNNER_TEMP/fluxon_deploy"
+ --testbed-hostworkdir "$RUNNER_TEMP/fluxon_deploy" \
+ --cleanup-pack-runtime-after-success \
+ --cleanup-successful-case-artifacts
- name: Print ci_2_virt_node failure summary
if: ${{ failure() }}
@@ -282,7 +320,7 @@ jobs:
PY
- name: Normalize ci_2_virt_node debug artifact permissions
- if: ${{ always() }}
+ if: ${{ failure() }}
run: |
chmod -R a+rX .dever || true
chmod -R a+rX fluxon_release || true
@@ -300,7 +338,7 @@ jobs:
-exec chmod -R a+rX {} + || true
- name: Upload ci_2_virt_node debug artifacts
- if: ${{ always() }}
+ if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: ci-2-virt-node-debug-${{ github.sha }}
@@ -313,3 +351,9 @@ jobs:
.dever/**/pack_release_runtime/project-data/**/instances/**/logs/**
.dever/**/pack_release_runtime/project-data/**/instances/**/release/**
.dever/**/pack_release_runtime/project-data/**/assemblies/**/profile/**
+
+ - name: Report final runner disk usage
+ if: ${{ always() }}
+ run: |
+ df -h / /mnt
+ docker system df || true
diff --git a/AGENTS.md b/AGENTS.md
index 1b1367b..634c5f6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,8 @@
Keep this document concise.
- Core user, developer, and design docs are in-repo under fluxon_doc_cn/ and fluxon_doc_en/
- Detailed bilingual doc writing rules are indexed at `fluxon_doc_en/dev_doc/Developer - 3 - Documentation Writing Rules.md` and `fluxon_doc_cn/dev_doc/开发者 - 3 - 文档写作规约.md`
+- The bilingual technical copy-editing workflow and one-shot example are at `fluxon_doc_en/dev_doc/Developer - 5 - Technical Documentation Copy Editing.md` and `fluxon_doc_cn/dev_doc/开发者 - 5 - 技术文档审校.md`
+- Bilingual code review rules, finding levels, and review templates are at `fluxon_doc_en/dev_doc/Developer - 6 - Code Review Guidelines.md` and `fluxon_doc_cn/dev_doc/开发者 - 6 - Code Review 规约.md`
- teststack has two steps: start testbed and testrunner
- teststack has UI support; testrunner should own the UI authority and API surface, and the UI should run as a long-lived service that reuses the ops interfaces underneath
- All Python code in this project must be compatible with Python >=3.10
@@ -9,6 +11,9 @@ Keep this document concise.
- Git operations are limited to basic `stage`, `unstage`, `commit`, and `push`. Do not use other Git operations.
- Prefer contraction over compatibility by default. Do not add compatibility layers, deprecated paths, or aliases unless the task explicitly requires them.
- Prefer one canonical name for one concept. Avoid synonym parameters, duplicated entrypoints, and parallel config surfaces.
+- When a change crosses module boundaries or moves resource cleanup, identify one final-release owner. Keep public layers on contracts, composition layers on ordering, and internal modules on their own state and cleanup; fix the invariant at its owner without adding outer field mutation or a duplicate close path.
+- When a change adds lifecycle dependencies or background work, write the dependency order and implement shutdown as admission stop, scoped wake or cancel, quiescence, dependent release, then dependency release. Keep independent branches parallel and avoid a global lock or coordinator unless the dependency graph requires it.
+- When shutdown intent and cleanup completion can diverge, assign each state a scope and sole writer, keep transitions monotonic, make close repeatable, and treat successful close as a completion barrier. Do not add multiple sources of truth for the same lifecycle fact.
- Do not use environment variables for ordinary parameter passing. Prefer configuration files first, then explicit command-line arguments.
- Prefer convention over configuration. When one canonical path or default wiring is sufficient, do not add extra config knobs.
- Minimize multi-path config delivery. Do not pass the same config through parallel channels such as env vars, CLI flags, and files at the same time.
diff --git a/AGENTS_CN.md b/AGENTS_CN.md
index 38f0a50..383a2e1 100644
--- a/AGENTS_CN.md
+++ b/AGENTS_CN.md
@@ -1,6 +1,8 @@
保持本文档简洁。
- 核心用户文档、开发文档和设计文档都在仓库内的 `fluxon_doc_cn/` 和 `fluxon_doc_en/` 下
- 详细的中英文文档写作规约索引见 `fluxon_doc_cn/dev_doc/开发者 - 3 - 文档写作规约.md` 和 `fluxon_doc_en/dev_doc/Developer - 3 - Documentation Writing Rules.md`
+- 中英文技术文档审校流程和 one-shot 示例见 `fluxon_doc_cn/dev_doc/开发者 - 5 - 技术文档审校.md` 和 `fluxon_doc_en/dev_doc/Developer - 5 - Technical Documentation Copy Editing.md`
+- 中英文 Code Review 规约、finding 等级和 Review 模板见 `fluxon_doc_cn/dev_doc/开发者 - 6 - Code Review 规约.md` 和 `fluxon_doc_en/dev_doc/Developer - 6 - Code Review Guidelines.md`
- `teststack` 有两个步骤:`start testbed` 和 `testrunner`
- `teststack` 支持 UI;`testrunner` 应负责 UI 的 authority 和 API surface,但 UI 应作为常驻服务运行,并复用下层的 ops 接口
- 本项目所有 Python 代码都必须兼容 Python `>= 3.10`
@@ -9,6 +11,9 @@
- Git 操作仅限基础的 `stage`、`unstage`、`commit` 和 `push`。不要使用其他 Git 操作
- 默认优先收束而不是兼容。除非任务明确要求,否则不要添加兼容层、废弃路径或别名
- 一个概念优先只保留一个正式名字。避免同义参数、重复入口和并行配置面
+- 当改动跨越模块边界或移动资源清理职责时,必须指定唯一 final-release owner。公共层只依赖契约,组合层只安排顺序,内部模块维护自己的状态和 cleanup;在不变量 owner 处修复,不增加外层字段操作或第二条 close 路径
+- 当改动新增生命周期依赖或后台任务时,必须写明依赖顺序,并按“关闭入口、定向 wake/cancel、建立静默点、释放依赖方、最后释放被依赖方”执行关闭。互不依赖的分支保持并行,依赖图不需要时不引入全局锁或全局 coordinator
+- 当停止意图和清理完成可能分离时,必须为每个状态指定作用域和唯一 writer,保持状态单调、close 可重复,并把 close 成功作为完成屏障。不要为同一个生命周期事实增加多个 source of truth
- 不要用环境变量传递普通参数。优先使用配置文件,其次使用显式命令行参数
- 优先约定而不是配置。当一个标准路径或默认连接方式已经足够时,不要增加额外配置项
- 最小化多路径配置传递。不要同时通过环境变量、CLI 参数和文件等并行通道传递同一份配置
diff --git a/README.md b/README.md
index de50828..1e85399 100644
--- a/README.md
+++ b/README.md
@@ -125,6 +125,12 @@ The `TCP Benchmark` shows that Fluxon outperforms `MooncakeStore` and `Redis` on

+Fluxon KV can also use an owner's local SSD as a runtime backing layer for DRAM replicas. The chart below shows a single-node H100 SSD-pressure experiment measured through CUDA event completion. At c16, Fluxon's hit payload throughput for 4/8/16 MiB payloads was 3.83×/5.61×/6.73× that of the faster of the two Mooncake topologies.
+
+
+
+See the Chinese deep dive, [Fluxon KV SSD Storage: Using Local SSD as a Backing Layer for In-Memory Replicas](https://tele-ai.github.io/Fluxon/cn/blog/blog_2_Fluxon_KV_SSD%E5%AD%98%E5%82%A8%EF%BC%9A%E6%8A%8A%E6%9C%AC%E5%9C%B0SSD%E6%8E%A5%E6%88%90%E5%86%85%E5%AD%98%E5%89%AF%E6%9C%AC%E7%9A%84%E5%9B%9E%E5%A1%AB%E5%B1%82), for the full setup, hit rates, and scope.
+
### Fluxon FS Benchmark
The benchmark results show that small-file reads and large-file writes already outperform `Alluxio`, large-file read performance is broadly on par, and small-file write performance still has further room to improve.
@@ -285,6 +291,9 @@ Contributions are welcome. Before you start, please read the developer docs on G
- [Developer - 2 - Package middleware and images](https://tele-ai.github.io/Fluxon/dev_doc/Developer---2---Package-Middleware-and-Images/)
- [Developer - 3 - Documentation Writing Rules](https://tele-ai.github.io/Fluxon/dev_doc/Developer---3---Documentation-Writing-Rules/)
- [Developer - 4 - Publish a release](https://tele-ai.github.io/Fluxon/dev_doc/Developer---4---Publish-a-Release/)
+- [Developer - 5 - Technical Documentation Copy Editing](https://tele-ai.github.io/Fluxon/dev_doc/Developer---5---Technical-Documentation-Copy-Editing/)
+- [Developer - 6 - Code Review Guidelines](https://tele-ai.github.io/Fluxon/dev_doc/Developer---6---Code-Review-Guidelines/)
+- [Developer - 7 - Event Subscription and Full Snapshot Guidelines](https://tele-ai.github.io/Fluxon/dev_doc/Developer---7---Event-Subscription-and-Full-Snapshot-Guidelines/)
diff --git a/README_CN.md b/README_CN.md
index 62d3d9e..4f96bd9 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -127,6 +127,12 @@ Fluxon FS 是一款面向 AI 数据与模型文件、兼容 `S3` 的高性能文

+Fluxon KV 还可将 owner 本地 SSD 作为 DRAM 副本的运行期回填层。下图为单机 H100 的 SSD-pressure 实验,以 CUDA event 完成为计数边界;c16 下,Fluxon 在 4/8/16 MiB 组中的 hit payload 吞吐相对两种 Mooncake 拓扑中更快的一种分别达到 3.83×/5.61×/6.73×。
+
+
+
+完整实验设置、命中率和适用边界见博客:[Fluxon KV SSD 存储:把本地 SSD 接成内存副本的回填层](https://tele-ai.github.io/Fluxon/cn/blog/blog_2_Fluxon_KV_SSD%E5%AD%98%E5%82%A8%EF%BC%9A%E6%8A%8A%E6%9C%AC%E5%9C%B0SSD%E6%8E%A5%E6%88%90%E5%86%85%E5%AD%98%E5%89%AF%E6%9C%AC%E7%9A%84%E5%9B%9E%E5%A1%AB%E5%B1%82)。
+
### Fluxon FS 基准测试
测试结果显示,小文件读取和大文件写入性能已显著优于 `Alluxio`,大文件读取性能基本持平,小文件写入性能仍有进一步优化的空间。
@@ -287,6 +293,9 @@ ui
- [开发者 - 2 - 打包中间件和镜像](https://tele-ai.github.io/Fluxon/cn/dev_doc/%E5%BC%80%E5%8F%91%E8%80%85---2---%E6%89%93%E5%8C%85%E4%B8%AD%E9%97%B4%E4%BB%B6%E5%92%8C%E9%95%9C%E5%83%8F/)
- [开发者 - 3 - 文档写作规约](https://tele-ai.github.io/Fluxon/cn/dev_doc/%E5%BC%80%E5%8F%91%E8%80%85---3---%E6%96%87%E6%A1%A3%E5%86%99%E4%BD%9C%E8%A7%84%E7%BA%A6/)
- [开发者 - 4 - 发布 Release](https://tele-ai.github.io/Fluxon/cn/dev_doc/%E5%BC%80%E5%8F%91%E8%80%85---4---%E5%8F%91%E5%B8%83-Release/)
+- [开发者 - 5 - 技术文档审校](https://tele-ai.github.io/Fluxon/cn/dev_doc/%E5%BC%80%E5%8F%91%E8%80%85---5---%E6%8A%80%E6%9C%AF%E6%96%87%E6%A1%A3%E5%AE%A1%E6%A0%A1/)
+- [开发者 - 6 - Code Review 规约](https://tele-ai.github.io/Fluxon/cn/dev_doc/%E5%BC%80%E5%8F%91%E8%80%85---6---Code-Review-%E8%A7%84%E7%BA%A6/)
+- [开发者 - 7 - 事件订阅与全量快照规约](https://tele-ai.github.io/Fluxon/cn/dev_doc/%E5%BC%80%E5%8F%91%E8%80%85---7---%E4%BA%8B%E4%BB%B6%E8%AE%A2%E9%98%85%E4%B8%8E%E5%85%A8%E9%87%8F%E5%BF%AB%E7%85%A7%E8%A7%84%E7%BA%A6/)
diff --git a/deployment/gen_k8s_daemonset.py b/deployment/gen_k8s_daemonset.py
index 1499a85..cc0ad5d 100644
--- a/deployment/gen_k8s_daemonset.py
+++ b/deployment/gen_k8s_daemonset.py
@@ -349,7 +349,7 @@ def _mirror_daemonset_yaml_outdir(*, src_outdir: str, dst_outdir: str) -> None:
#
# Causal chain:
# - Operators often run the generator from the repo workdir, while fluxon-deployer reads
- # manifests from a hostworkdir-mounted directory (e.g. /opt/store_team_dev/fluxon_deployer).
+ # manifests from a hostworkdir-mounted directory (e.g. /tmp/fluxon-example/deploy).
# - If the two directories drift, deployer applies stale manifests and can crash-loop.
# - Therefore we provide an explicit opt-in "mirror_outdir" that:
# - cleans the destination directory (same semantics as the primary outdir)
diff --git a/deployment/tests/test_build_doc_site_contract.py b/deployment/tests/test_build_doc_site_contract.py
index 7236d99..9418ee5 100644
--- a/deployment/tests/test_build_doc_site_contract.py
+++ b/deployment/tests/test_build_doc_site_contract.py
@@ -99,6 +99,16 @@ def test_rewrite_homepage_target_path_for_chinese_home(self) -> None:
"../fluxon_rs/rust-toolchain.toml",
)
+ def test_rewrite_variant_repo_image_for_quartz_route(self) -> None:
+ for language in ("en", "cn"):
+ with self.subTest(language=language):
+ self.assertEqual(
+ _DOC_SITE.rewrite_variant_target_path(
+ "../../pics/kv_ssd_cpu_sweep.png", language=language
+ ),
+ "../pics/kv_ssd_cpu_sweep.png",
+ )
+
def test_stage_cn_only_design_into_en_root_when_english_design_missing(self) -> None:
tmpdir = Path(tempfile.mkdtemp(prefix="fluxon_doc_site_contract_"))
old_stage_root = _DOC_SITE.STAGE_DOCS_ROOT
diff --git a/deployment/tests/test_start_test_bed_bootstrap_log.py b/deployment/tests/test_start_test_bed_bootstrap_log.py
index 2bd6b00..36eaf28 100644
--- a/deployment/tests/test_start_test_bed_bootstrap_log.py
+++ b/deployment/tests/test_start_test_bed_bootstrap_log.py
@@ -1021,11 +1021,11 @@ def test_bare_then_apply_success_path_does_not_run_post_apply_stop() -> None:
gitops_config_path: ./gitops.yaml
bootstrap_phases:
- mode: fixed_bare
- node: infra44-ThinkStation-PX
+ node: example-node-a
services:
- etcd
- mode: fixed_bare
- node: infra44-ThinkStation-PX
+ node: example-node-a
services:
- fluxon_core_controller
deploy_workloads:
@@ -1047,7 +1047,7 @@ def test_bare_then_apply_success_path_does_not_run_post_apply_stop() -> None:
FLUXON_RELEASE_PYLIB_SRC_TAR: pylib_src.tar.gz
FLUXON_RELEASE_WHEEL: fluxon-0.2.1-cp38-abi3-manylinux_2_28_x86_64.whl
cluster_nodes:
- - hostname: infra44-ThinkStation-PX
+ - hostname: example-node-a
ip: 127.0.0.1
hostworkdir: /tmp/fluxon_testbed
ssh_user: tester
@@ -1056,28 +1056,28 @@ def test_bare_then_apply_success_path_does_not_run_post_apply_stop() -> None:
atomic_groups:
fluxon_core_controller:
phase: 1
- nodes: ["infra44-ThinkStation-PX"]
+ nodes: ["example-node-a"]
services: ["master", "owner", "ops_controller", "ops_agent"]
bootstrap_bare_services: ["etcd"]
service:
etcd:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
master:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
owner:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
ops_controller:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
ops_agent:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
fluxon_fs_agent:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
""".strip()
+ "\n",
encoding="utf-8",
@@ -1252,11 +1252,11 @@ def test_bare_only_stops_after_controller_ready() -> None:
gitops_config_path: ./gitops.yaml
bootstrap_phases:
- mode: fixed_bare
- node: infra44-ThinkStation-PX
+ node: example-node-a
services:
- etcd
- mode: fixed_bare
- node: infra44-ThinkStation-PX
+ node: example-node-a
services:
- fluxon_core_controller
- mode: coverage_bare
@@ -1282,7 +1282,7 @@ def test_bare_only_stops_after_controller_ready() -> None:
FLUXON_RELEASE_PYLIB_SRC_TAR: pylib_src.tar.gz
FLUXON_RELEASE_WHEEL: fluxon-0.2.1-cp38-abi3-manylinux_2_28_x86_64.whl
cluster_nodes:
- - hostname: infra44-ThinkStation-PX
+ - hostname: example-node-a
ip: 127.0.0.1
hostworkdir: /tmp/fluxon_testbed
ssh_user: tester
@@ -1291,31 +1291,31 @@ def test_bare_only_stops_after_controller_ready() -> None:
atomic_groups:
fluxon_core_controller:
phase: 1
- nodes: ["infra44-ThinkStation-PX"]
+ nodes: ["example-node-a"]
services: ["master", "owner", "ops_controller", "ops_agent"]
bootstrap_bare_services: ["etcd"]
service:
etcd:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
greptime:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
master:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
owner:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
ops_controller:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
ops_agent:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
fluxon_fs_agent:
node_bind:
- node: ["infra44-ThinkStation-PX"]
+ node: ["example-node-a"]
""".strip()
+ "\n",
encoding="utf-8",
@@ -1414,7 +1414,7 @@ def _fail_deploy_or_wait(**_: object) -> dict[str, str]:
{
"launches": [
{
- "node": "infra44-ThinkStation-PX",
+ "node": "example-node-a",
"selection_name": "etcd",
}
]
@@ -1422,7 +1422,7 @@ def _fail_deploy_or_wait(**_: object) -> dict[str, str]:
{
"launches": [
{
- "node": "infra44-ThinkStation-PX",
+ "node": "example-node-a",
"selection_name": "fluxon_core_controller",
}
]
@@ -1440,7 +1440,7 @@ def test_initial_controller_handover_uses_local_bare_stop() -> None:
with tempfile.TemporaryDirectory(prefix="test_start_test_bed_controller_handover_stop_") as td:
workdir = Path(td)
local_node_cfg = {
- "hostname": "infra44-ThinkStation-PX-a",
+ "hostname": "example-node-a-a",
"ip": "127.0.0.1",
"hostworkdir": str(workdir / "hostworkdir"),
"execution_mode": "local",
@@ -1452,15 +1452,15 @@ def test_initial_controller_handover_uses_local_bare_stop() -> None:
"atomic_groups": {
"fluxon_core_controller": {
"phase": 1,
- "nodes": ["infra44-ThinkStation-PX-a"],
+ "nodes": ["example-node-a-a"],
"services": ["master", "owner", "ops_controller", "ops_agent"],
}
},
"service": {
- "master": {"node_bind": {"node": ["infra44-ThinkStation-PX-a"]}},
- "owner": {"node_bind": {"node": ["infra44-ThinkStation-PX-a"]}},
- "ops_controller": {"node_bind": {"node": ["infra44-ThinkStation-PX-a"]}},
- "ops_agent": {"node_bind": {"node": ["infra44-ThinkStation-PX-a"]}},
+ "master": {"node_bind": {"node": ["example-node-a-a"]}},
+ "owner": {"node_bind": {"node": ["example-node-a-a"]}},
+ "ops_controller": {"node_bind": {"node": ["example-node-a-a"]}},
+ "ops_agent": {"node_bind": {"node": ["example-node-a-a"]}},
},
}
stop_calls: list[str] = []
@@ -1480,10 +1480,10 @@ def test_initial_controller_handover_uses_local_bare_stop() -> None:
module._run_local_stop = original_run_local_stop
assert stopped == ["master", "owner", "ops_controller", "ops_agent"], stopped
assert stop_calls == [
- "infra44-ThinkStation-PX-a:master",
- "infra44-ThinkStation-PX-a:owner",
- "infra44-ThinkStation-PX-a:ops_controller",
- "infra44-ThinkStation-PX-a:ops_agent",
+ "example-node-a-a:master",
+ "example-node-a-a:owner",
+ "example-node-a-a:ops_controller",
+ "example-node-a-a:ops_agent",
], stop_calls
print("PASS: test_initial_controller_handover_uses_local_bare_stop")
diff --git a/examples/dataloader_video_benchmark/README.md b/examples/dataloader_video_benchmark/README.md
new file mode 100644
index 0000000..19598e2
--- /dev/null
+++ b/examples/dataloader_video_benchmark/README.md
@@ -0,0 +1,109 @@
+# Dataloader Video Benchmark
+
+`dataloader_video_benchmark` compares the original `decord.VideoReader`
+path with the FluxonFS video reader path at the dataloader boundary.
+
+It is an example benchmark, not a training dataset implementation. It replays
+decode cases from CSV, timeout logs, or parquet metadata and reports decode
+latency, QPS, output bandwidth, FluxonFS range-read counters, and reader-pool
+stats.
+
+## Entrypoints
+
+- `benchmark.py`
+ - runs original and Fluxon video decode backends
+- `analyze_results.py`
+ - summarizes benchmark CSV output
+- `test_benchmark_contract.py`
+ - local contract test for the example script
+
+## Decode Case CSV
+
+The simplest input is a CSV with these columns:
+
+```csv
+idx,video_path,height,width,start_idx,end_idx,num_frames
+1,/abs/path/sample.mp4,480,832,0,63,16
+```
+
+`video_path` must be an absolute local path for the original backend. For the
+Fluxon backend, the same path must be under `--fluxon-remote-root`; the example
+converts it to `export_name + relpath` before calling FluxonFS.
+
+## Original Baseline
+
+```bash
+python3 examples/dataloader_video_benchmark/benchmark.py \
+ --backend original \
+ --case-csv /tmp/cases.csv \
+ --rounds 100 \
+ --warmup-rounds 5 \
+ --workers 4 \
+ --prefetch-factor 2 \
+ --num-threads 2 \
+ --output-csv /tmp/original.csv \
+ --output-json /tmp/original.summary.json
+```
+
+The original backend runs:
+
+```text
+decord.VideoReader(video_path).get_batch(indices).asnumpy()
+```
+
+## Fluxon Backend
+
+Start a FluxonFS master and agent first, then run:
+
+```bash
+python3 examples/dataloader_video_benchmark/benchmark.py \
+ --backend fluxon \
+ --case-csv /tmp/cases.csv \
+ --rounds 100 \
+ --warmup-rounds 5 \
+ --workers 4 \
+ --prefetch-factor 2 \
+ --decode-batch-size 3 \
+ --num-threads 2 \
+ --fluxon-kv-config /tmp/external.yaml \
+ --fluxon-remote-root /tmp/video_root \
+ --fluxon-agent-instance-key fs-agent-1 \
+ --fluxon-reader-cache-size 32 \
+ --fluxon-request-username bench \
+ --fluxon-request-password bench \
+ --output-csv /tmp/fluxon.csv \
+ --output-json /tmp/fluxon.summary.json
+```
+
+`--decode-batch-size` controls the benchmark's sample-batch window. The Fluxon
+backend passes that window to `FluxonFsVideoReaderPool.read_many_numpy_with_stats`.
+Requests with the same video reader key are merged into one native decode call
+and split back into per-sample rows.
+
+For the current synthetic benchmark shape, `--decode-batch-size 3` is the
+recommended first value: it coalesces the three clips per video while keeping
+enough scheduling granularity for multi-worker runs.
+
+## Analyze Results
+
+```bash
+python3 examples/dataloader_video_benchmark/analyze_results.py \
+ --input-csv /tmp/fluxon.csv \
+ --output-json /tmp/fluxon.analysis.json \
+ --output-md /tmp/fluxon.analysis.md \
+ --title fluxon_batch3_w4
+```
+
+The analyzer reports:
+
+- backend QPS, frames/s, and output MiB/s
+- p50/p95/p99 decode latency
+- pairwise original-vs-Fluxon speedup when both backends are present
+- FluxonFS remote read bytes, range-read calls, page cache hit rate, and reader
+ pool hit rate
+
+## Local Contract Test
+
+```bash
+python3 examples/dataloader_video_benchmark/test_benchmark_contract.py
+```
diff --git a/examples/dataloader_video_benchmark/analyze_results.py b/examples/dataloader_video_benchmark/analyze_results.py
new file mode 100644
index 0000000..486243b
--- /dev/null
+++ b/examples/dataloader_video_benchmark/analyze_results.py
@@ -0,0 +1,356 @@
+#!/usr/bin/env python3
+"""Analyze original-vs-Fluxon dataloader benchmark CSV results."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import statistics
+from collections import Counter, defaultdict
+from pathlib import Path
+from typing import Any, Iterable, Mapping, Optional
+
+
+LATENCY_FIELDS = ("elapsed_s", "open_s", "decode_s", "materialize_s")
+FLUXON_VIDEO_COUNTER_FIELDS = (
+ "fluxon_video_read_at_calls",
+ "fluxon_video_read_at_requested_bytes",
+ "fluxon_video_read_at_returned_bytes",
+ "fluxon_video_page_cache_hits",
+ "fluxon_video_page_cache_misses",
+ "fluxon_video_remote_read_calls",
+ "fluxon_video_remote_read_bytes",
+ "fluxon_video_decode_calls",
+ "fluxon_video_decode_frames_requested",
+ "fluxon_video_decode_errors",
+)
+
+
+def load_rows(path: Path) -> list[dict[str, str]]:
+ with path.open(newline="") as f:
+ return list(csv.DictReader(f))
+
+
+def fval(row: Mapping[str, str], key: str, default: float = 0.0) -> float:
+ raw = row.get(key, "")
+ if raw == "":
+ return default
+ try:
+ return float(raw)
+ except ValueError:
+ return default
+
+
+def ival(row: Mapping[str, str], key: str, default: int = 0) -> int:
+ raw = row.get(key, "")
+ if raw == "":
+ return default
+ try:
+ return int(float(raw))
+ except ValueError:
+ return default
+
+
+def percentile(values: list[float], q: float) -> float:
+ if not values:
+ return 0.0
+ ordered = sorted(values)
+ pos = (len(ordered) - 1) * q
+ lo = int(pos)
+ hi = min(lo + 1, len(ordered) - 1)
+ frac = pos - lo
+ return ordered[lo] * (1.0 - frac) + ordered[hi] * frac
+
+
+def numeric_summary(values: Iterable[float]) -> dict[str, float]:
+ data = [float(v) for v in values]
+ if not data:
+ return {
+ "count": 0,
+ "min": 0.0,
+ "mean": 0.0,
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "max": 0.0,
+ }
+ return {
+ "count": len(data),
+ "min": min(data),
+ "mean": statistics.fmean(data),
+ "p50": percentile(data, 0.50),
+ "p95": percentile(data, 0.95),
+ "p99": percentile(data, 0.99),
+ "max": max(data),
+ }
+
+
+def analyze(rows: list[dict[str, str]]) -> dict[str, Any]:
+ out: dict[str, Any] = {
+ "total_rows": len(rows),
+ "backends": {},
+ "pairwise": {},
+ "fluxon_video": {},
+ }
+
+ backends = sorted({row.get("backend", "") for row in rows if row.get("backend", "")})
+ for backend in backends:
+ backend_rows = [row for row in rows if row.get("backend") == backend]
+ ok_rows = [row for row in backend_rows if row.get("status") == "ok"]
+ status_counts = Counter(row.get("status", "unknown") for row in backend_rows)
+ frames = sum(ival(row, "num_frames") for row in ok_rows)
+ output_bytes = sum(ival(row, "nbytes") for row in ok_rows)
+ elapsed_sum = sum(fval(row, "elapsed_s") for row in ok_rows)
+ backend_summary: dict[str, Any] = {
+ "rows": len(backend_rows),
+ "ok": len(ok_rows),
+ "error": len(backend_rows) - len(ok_rows),
+ "status_counts": dict(sorted(status_counts.items())),
+ "frames": frames,
+ "output_bytes": output_bytes,
+ "elapsed_sum_s": elapsed_sum,
+ "qps": len(ok_rows) / elapsed_sum if elapsed_sum > 0 else 0.0,
+ "frames_per_s": frames / elapsed_sum if elapsed_sum > 0 else 0.0,
+ "output_mib_per_s": (output_bytes / (1024 * 1024)) / elapsed_sum if elapsed_sum > 0 else 0.0,
+ }
+ for field in LATENCY_FIELDS:
+ backend_summary[field] = numeric_summary(fval(row, field) for row in ok_rows)
+ backend_summary["batch_wall_s"] = numeric_summary(fval(row, "batch_wall_s") for row in ok_rows)
+ backend_summary["decode_batch_input_size"] = numeric_summary(
+ ival(row, "decode_batch_input_size") for row in ok_rows
+ )
+ out["backends"][backend] = backend_summary
+
+ out["pairwise"] = pairwise_analysis(rows)
+ out["fluxon_video"] = fluxon_video_analysis(rows)
+ return out
+
+
+def pairwise_analysis(rows: list[dict[str, str]]) -> dict[str, Any]:
+ by_sample: dict[str, dict[str, dict[str, str]]] = defaultdict(dict)
+ for row in rows:
+ sample_id = row.get("sample_id", "")
+ backend = row.get("backend", "")
+ if sample_id and backend:
+ by_sample[sample_id][backend] = row
+
+ comparable = []
+ mismatches = []
+ for sample_id, sample_rows in sorted(by_sample.items()):
+ original = sample_rows.get("original")
+ fluxon = sample_rows.get("fluxon")
+ if original is None or fluxon is None:
+ continue
+ if original.get("status") != "ok" or fluxon.get("status") != "ok":
+ continue
+ comparable.append((sample_id, original, fluxon))
+ for field in ("shape", "dtype", "fingerprint"):
+ lhs = original.get(field, "")
+ rhs = fluxon.get(field, "")
+ if lhs and rhs and lhs != rhs:
+ mismatches.append(
+ {
+ "sample_id": sample_id,
+ "field": field,
+ "original": lhs,
+ "fluxon": rhs,
+ }
+ )
+
+ speedups = []
+ deltas = []
+ for _sample_id, original, fluxon in comparable:
+ original_elapsed = fval(original, "elapsed_s")
+ fluxon_elapsed = fval(fluxon, "elapsed_s")
+ if original_elapsed > 0 and fluxon_elapsed > 0:
+ speedups.append(original_elapsed / fluxon_elapsed)
+ deltas.append(original_elapsed - fluxon_elapsed)
+
+ return {
+ "comparable_samples": len(comparable),
+ "speedup": numeric_summary(speedups),
+ "elapsed_delta_s": numeric_summary(deltas),
+ "mismatch_count": len(mismatches),
+ "mismatches": mismatches[:50],
+ }
+
+
+def fluxon_video_analysis(rows: list[dict[str, str]]) -> dict[str, Any]:
+ fluxon_ok = [row for row in rows if row.get("backend") == "fluxon" and row.get("status") == "ok"]
+ counters = {
+ field: sum(ival(row, field) for row in fluxon_ok)
+ for field in FLUXON_VIDEO_COUNTER_FIELDS
+ }
+ page_hits = counters["fluxon_video_page_cache_hits"]
+ page_misses = counters["fluxon_video_page_cache_misses"]
+ page_lookups = page_hits + page_misses
+ remote_read_bytes = counters["fluxon_video_remote_read_bytes"]
+ output_bytes = sum(ival(row, "nbytes") for row in fluxon_ok)
+ elapsed_sum = sum(fval(row, "elapsed_s") for row in fluxon_ok)
+
+ out: dict[str, Any] = {
+ "rows": len(fluxon_ok),
+ "counters": counters,
+ "reader_pool": fluxon_reader_pool_analysis(fluxon_ok),
+ "page_cache_hit_rate": page_hits / page_lookups if page_lookups > 0 else 0.0,
+ "remote_read_mib": remote_read_bytes / (1024 * 1024),
+ "remote_read_mib_per_s": (remote_read_bytes / (1024 * 1024)) / elapsed_sum if elapsed_sum > 0 else 0.0,
+ "compressed_to_output_byte_ratio": remote_read_bytes / output_bytes if output_bytes > 0 else 0.0,
+ "remote_read_bytes_per_row": numeric_summary(
+ ival(row, "fluxon_video_remote_read_bytes") for row in fluxon_ok
+ ),
+ "read_at_calls_per_row": numeric_summary(
+ ival(row, "fluxon_video_read_at_calls") for row in fluxon_ok
+ ),
+ }
+ return out
+
+
+def fluxon_reader_pool_analysis(rows: list[dict[str, str]]) -> dict[str, Any]:
+ if not rows:
+ return {
+ "reader_cache_hit_rate": 0.0,
+ "reader_cache_hits": 0,
+ "reader_cache_misses": 0,
+ "open_count": 0,
+ "close_count": 0,
+ "evict_count": 0,
+ "max_current_readers": 0,
+ "max_active_readers": 0,
+ }
+ row_hits = sum(ival(row, "fluxon_video_pool_reader_cache_hit") for row in rows)
+ snapshot_hit_max = max(ival(row, "fluxon_video_pool_reader_cache_hits") for row in rows)
+ snapshot_miss_max = max(ival(row, "fluxon_video_pool_reader_cache_misses") for row in rows)
+ return {
+ "reader_cache_hit_rate": row_hits / len(rows),
+ "reader_cache_hits": snapshot_hit_max,
+ "reader_cache_misses": snapshot_miss_max,
+ "open_count": max(ival(row, "fluxon_video_pool_open_count") for row in rows),
+ "close_count": max(ival(row, "fluxon_video_pool_close_count") for row in rows),
+ "evict_count": max(ival(row, "fluxon_video_pool_evict_count") for row in rows),
+ "max_current_readers": max(ival(row, "fluxon_video_pool_current_readers") for row in rows),
+ "max_active_readers": max(ival(row, "fluxon_video_pool_active_readers") for row in rows),
+ }
+
+
+def render_markdown(analysis: Mapping[str, Any], *, title: str) -> str:
+ lines = [f"# {title}", ""]
+ lines.append(f"- total_rows: {analysis.get('total_rows', 0)}")
+
+ lines.extend(["", "## Backend Summary", ""])
+ lines.append("| backend | rows | ok | error | p50 elapsed s | p95 elapsed s | qps | frames/s | output MiB/s |")
+ lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |")
+ backends = analysis.get("backends", {})
+ if isinstance(backends, Mapping):
+ for backend, data in backends.items():
+ if not isinstance(data, Mapping):
+ continue
+ elapsed = data.get("elapsed_s", {})
+ if not isinstance(elapsed, Mapping):
+ elapsed = {}
+ lines.append(
+ "| "
+ f"{backend} | {data.get('rows', 0)} | {data.get('ok', 0)} | {data.get('error', 0)} | "
+ f"{float(elapsed.get('p50', 0.0)):.6f} | {float(elapsed.get('p95', 0.0)):.6f} | "
+ f"{float(data.get('qps', 0.0)):.2f} | {float(data.get('frames_per_s', 0.0)):.2f} | "
+ f"{float(data.get('output_mib_per_s', 0.0)):.2f} |"
+ )
+
+ pairwise = analysis.get("pairwise", {})
+ if isinstance(pairwise, Mapping):
+ speed = pairwise.get("speedup", {})
+ delta = pairwise.get("elapsed_delta_s", {})
+ lines.extend(["", "## Pairwise", ""])
+ lines.append(f"- comparable_samples: {pairwise.get('comparable_samples', 0)}")
+ if isinstance(speed, Mapping):
+ lines.append(
+ "- speedup original/fluxon: "
+ f"p50={float(speed.get('p50', 0.0)):.3f}x, "
+ f"p95={float(speed.get('p95', 0.0)):.3f}x, "
+ f"mean={float(speed.get('mean', 0.0)):.3f}x"
+ )
+ if isinstance(delta, Mapping):
+ lines.append(
+ "- elapsed_delta original-fluxon: "
+ f"p50={float(delta.get('p50', 0.0)):.6f}s, "
+ f"mean={float(delta.get('mean', 0.0)):.6f}s"
+ )
+ lines.append(f"- mismatch_count: {pairwise.get('mismatch_count', 0)}")
+
+ fluxon_video = analysis.get("fluxon_video", {})
+ if isinstance(fluxon_video, Mapping):
+ counters = fluxon_video.get("counters", {})
+ if not isinstance(counters, Mapping):
+ counters = {}
+ lines.extend(["", "## Fluxon VideoReader I/O", ""])
+ lines.append(f"- rows: {fluxon_video.get('rows', 0)}")
+ lines.append(f"- page_cache_hit_rate: {float(fluxon_video.get('page_cache_hit_rate', 0.0)):.4f}")
+ lines.append(f"- remote_read_mib: {float(fluxon_video.get('remote_read_mib', 0.0)):.3f}")
+ lines.append(f"- remote_read_mib_per_s: {float(fluxon_video.get('remote_read_mib_per_s', 0.0)):.3f}")
+ lines.append(
+ "- compressed_to_output_byte_ratio: "
+ f"{float(fluxon_video.get('compressed_to_output_byte_ratio', 0.0)):.4f}"
+ )
+ reader_pool = fluxon_video.get("reader_pool", {})
+ if isinstance(reader_pool, Mapping):
+ lines.extend(["", "## Fluxon VideoReader Pool", ""])
+ lines.append(
+ f"- reader_cache_hit_rate: {float(reader_pool.get('reader_cache_hit_rate', 0.0)):.4f}"
+ )
+ lines.append(f"- reader_cache_hits: {reader_pool.get('reader_cache_hits', 0)}")
+ lines.append(f"- reader_cache_misses: {reader_pool.get('reader_cache_misses', 0)}")
+ lines.append(f"- open_count: {reader_pool.get('open_count', 0)}")
+ lines.append(f"- close_count: {reader_pool.get('close_count', 0)}")
+ lines.append(f"- evict_count: {reader_pool.get('evict_count', 0)}")
+ lines.append(f"- max_current_readers: {reader_pool.get('max_current_readers', 0)}")
+ lines.append(f"- max_active_readers: {reader_pool.get('max_active_readers', 0)}")
+ for field in FLUXON_VIDEO_COUNTER_FIELDS:
+ lines.append(f"- {field}: {counters.get(field, 0)}")
+
+ lines.append("")
+ return "\n".join(lines)
+
+
+def write_outputs(
+ analysis: Mapping[str, Any],
+ *,
+ output_json: Optional[Path],
+ output_md: Optional[Path],
+ title: str,
+) -> None:
+ if output_json is not None:
+ output_json.parent.mkdir(parents=True, exist_ok=True)
+ output_json.write_text(json.dumps(analysis, indent=2, sort_keys=True), encoding="utf-8")
+ if output_md is not None:
+ output_md.parent.mkdir(parents=True, exist_ok=True)
+ output_md.write_text(render_markdown(analysis, title=title), encoding="utf-8")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--input-csv", required=True)
+ parser.add_argument("--output-json", default="")
+ parser.add_argument("--output-md", default="")
+ parser.add_argument("--title", default="Dataloader VideoReader Benchmark Analysis")
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ rows = load_rows(Path(args.input_csv))
+ analysis = analyze(rows)
+ output_json = Path(args.output_json) if args.output_json else None
+ output_md = Path(args.output_md) if args.output_md else None
+ write_outputs(
+ analysis,
+ output_json=output_json,
+ output_md=output_md,
+ title=args.title,
+ )
+ print(render_markdown(analysis, title=args.title), end="")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/dataloader_video_benchmark/benchmark.py b/examples/dataloader_video_benchmark/benchmark.py
new file mode 100644
index 0000000..7d95103
--- /dev/null
+++ b/examples/dataloader_video_benchmark/benchmark.py
@@ -0,0 +1,1125 @@
+#!/usr/bin/env python3
+"""Compare original decord video loading with FluxonFS VideoReader.
+
+This is an integration benchmark entrypoint for the dataloader boundary. It
+replays decode cases from CSV/log/parquet metadata and runs the same frame
+selection through:
+
+* original: decord.VideoReader(video_path).get_batch(...).asnumpy()
+* fluxon: FluxonFsPatcher.open_video_reader_pool(...).read_frames_numpy_with_stats(...)
+
+The Fluxon path uses export_name + relpath and does not materialize a local
+file path for FFmpeg. It requires fluxon_pyo3 built with the
+fluxon_fs_video_ffmpeg feature.
+"""
+
+from __future__ import annotations
+
+import argparse
+import copy
+import csv
+import hashlib
+import json
+import os
+import random
+import re
+import statistics
+import sys
+import threading
+import time
+import traceback
+from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Any, Iterable, Mapping, Optional
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+
+DEFAULT_MANIFEST = "/mayukuo/flow-lite/runs/dualpointer/cached_metadata/parquets_list.txt"
+
+TIMEOUT_RE = re.compile(
+ r"Timeout loading data at index (?P\d+):.*?"
+ r"'(?P/[^']+)'\s*,\s*"
+ r"(?P\d+)\s*,\s*"
+ r"(?P\d+)\s*,\s*"
+ r"(?P\d+)\s*,\s*"
+ r"(?P\d+)\s*,\s*"
+ r"(?P\d+)",
+)
+
+
+class RawDefaultsHelpFormatter(
+ argparse.ArgumentDefaultsHelpFormatter,
+ argparse.RawDescriptionHelpFormatter,
+):
+ pass
+
+
+@dataclass(frozen=True)
+class DecodeCase:
+ source: str
+ idx: int
+ video_path: str
+ height: int
+ width: int
+ start_idx: int
+ end_idx: int
+ num_frames: int
+
+ @property
+ def key(self) -> tuple[Any, ...]:
+ return (
+ self.video_path,
+ self.height,
+ self.width,
+ self.start_idx,
+ self.end_idx,
+ self.num_frames,
+ )
+
+
+@dataclass(frozen=True)
+class BenchmarkJob:
+ sample_id: str
+ round_id: int
+ case_position: int
+ backend: str
+ case: DecodeCase
+
+
+class OriginalDecordBackend:
+ name = "original"
+
+ def __init__(self, *, num_threads: int, fingerprint_bytes: int) -> None:
+ from decord import VideoReader, cpu # type: ignore
+
+ self._video_reader_cls = VideoReader
+ self._cpu = cpu
+ self._num_threads = num_threads
+ self._fingerprint_bytes = fingerprint_bytes
+
+ def decode(self, case: DecodeCase) -> dict[str, Any]:
+ indices = frame_indices(case)
+
+ t0 = time.perf_counter()
+ reader = self._video_reader_cls(
+ case.video_path,
+ width=case.width,
+ height=case.height,
+ num_threads=self._num_threads,
+ ctx=self._cpu(0),
+ )
+ t1 = time.perf_counter()
+ batch = reader.get_batch(indices)
+ t2 = time.perf_counter()
+ array = batch.asnumpy()
+ t3 = time.perf_counter()
+
+ row = {
+ "status": "ok",
+ "open_s": t1 - t0,
+ "decode_s": t2 - t1,
+ "materialize_s": t3 - t2,
+ "elapsed_s": t3 - t0,
+ }
+ row.update(array_result_fields(array, self._fingerprint_bytes))
+ return row
+
+ def decode_batch(self, cases: list[DecodeCase]) -> list[dict[str, Any]]:
+ return [self.decode(case) for case in cases]
+
+
+class FluxonVideoBackend:
+ name = "fluxon"
+
+ def __init__(
+ self,
+ *,
+ runtime: "FluxonBenchmarkRuntime",
+ num_threads: int,
+ fingerprint_bytes: int,
+ ) -> None:
+ self._runtime = runtime
+ self._num_threads = num_threads
+ self._fingerprint_bytes = fingerprint_bytes
+
+ def decode(self, case: DecodeCase) -> dict[str, Any]:
+ return self.decode_batch([case])[0]
+
+ def decode_batch(self, cases: list[DecodeCase]) -> list[dict[str, Any]]:
+ from fluxon_py.fluxon_fs.video import FluxonFsVideoReadRequest
+
+ requests: list[FluxonFsVideoReadRequest] = []
+ relpaths: list[str] = []
+ frame_counts: list[int] = []
+ for case in cases:
+ indices = tuple(frame_indices(case))
+ relpath = self._runtime.relpath_for_case(case)
+ requests.append(
+ FluxonFsVideoReadRequest(
+ export_name=self._runtime.export_name,
+ relpath=relpath,
+ height=case.height,
+ width=case.width,
+ num_threads=self._num_threads,
+ indices=indices,
+ )
+ )
+ relpaths.append(relpath)
+ frame_counts.append(len(indices))
+
+ t0 = time.perf_counter()
+ results = self._runtime.video_reader_pool.read_many_numpy_with_stats(requests)
+ t1 = time.perf_counter()
+ batch_elapsed_s = t1 - t0
+ weights = [max(1, int(getattr(result.array, "nbytes", 0))) for result in results]
+ elapsed_shares = split_float_by_weights(batch_elapsed_s, weights)
+
+ rows: list[dict[str, Any]] = []
+ for case, relpath, frame_count, result, elapsed_s in zip(
+ cases,
+ relpaths,
+ frame_counts,
+ results,
+ elapsed_shares,
+ ):
+ row = {
+ "status": "ok",
+ "fluxon_relpath": relpath,
+ "open_s": 0.0,
+ "decode_s": elapsed_s,
+ "materialize_s": 0.0,
+ "elapsed_s": elapsed_s,
+ "batch_wall_s": batch_elapsed_s,
+ "decode_batch_input_size": len(cases),
+ "decode_batch_input_frames": sum(frame_counts),
+ "decode_batch_sample_frames": frame_count,
+ "fluxon_video_pool_reader_cache_hit": int(result.reader_cache_hit),
+ }
+ row.update(array_result_fields(result.array, self._fingerprint_bytes))
+ row.update({f"fluxon_video_{key}": value for key, value in result.reader_stats.items()})
+ row.update({f"fluxon_video_pool_{key}": value for key, value in result.pool_stats.items()})
+ row.update({f"fluxon_video_batch_{key}": value for key, value in result.batch_stats.items()})
+ rows.append(row)
+ return rows
+
+
+class FluxonBenchmarkRuntime:
+ """Owns the FluxonFS client used by the benchmark."""
+
+ def __init__(
+ self,
+ *,
+ patcher: Any,
+ stores: list[Any],
+ video_reader_pool: Any,
+ export_name: str,
+ remote_root_abs: Path,
+ close_timeout_s: float,
+ ) -> None:
+ self.patcher = patcher
+ self._stores = stores
+ self.video_reader_pool = video_reader_pool
+ self.export_name = export_name
+ self.remote_root_abs = remote_root_abs
+ self.close_timeout_s = close_timeout_s
+
+ @classmethod
+ def open(cls, args: argparse.Namespace) -> "FluxonBenchmarkRuntime":
+ import yaml
+ from fluxon_py.config import FluxonKvClientConfig
+ from fluxon_py.fluxon_fs.patcher import FluxonFsPatcher
+ from fluxon_py.kvclient import new_store
+
+ kv_cfg = load_yaml_mapping(args.fluxon_kv_config, "fluxon kv config")
+ suffix = f"{os.getpid()}_{int(time.time() * 1000)}"
+ client_key = args.fluxon_client_instance_key or f"dataloader_perf_client_{suffix}"
+ agent_node_id = resolve_fluxon_agent_node_id(args)
+
+ client_store = new_fluxon_store(
+ new_store_fn=new_store,
+ config_cls=FluxonKvClientConfig,
+ base_config=kv_cfg,
+ instance_key=client_key,
+ )
+
+ remote_root_abs = require_abs_existing_dir(
+ args.fluxon_remote_root,
+ "fluxon remote root",
+ )
+ cache_yaml = yaml.safe_dump(
+ {
+ "stale_window_ms": int(args.fluxon_stale_window_ms),
+ "rules": [],
+ "exports": {
+ args.fluxon_export_name: {
+ "remote_root_dir_abs": str(remote_root_abs),
+ "nodes": [str(agent_node_id)],
+ "cache_max_bytes": int(args.fluxon_cache_max_bytes),
+ "metadata_cache_ttl_ms": int(args.fluxon_metadata_cache_ttl_ms),
+ }
+ },
+ },
+ sort_keys=False,
+ )
+
+ patcher = FluxonFsPatcher(client_store)
+ patcher.set_cache_config_yaml(cache_yaml)
+ patcher.wait_cache_config_loaded()
+ if args.fluxon_request_username:
+ patcher.set_request_identity(args.fluxon_request_username, args.fluxon_request_password)
+ video_reader_pool = patcher.open_video_reader_pool(
+ max_readers=int(args.fluxon_reader_cache_size),
+ )
+
+ return cls(
+ patcher=patcher,
+ stores=[client_store],
+ video_reader_pool=video_reader_pool,
+ export_name=str(args.fluxon_export_name),
+ remote_root_abs=remote_root_abs,
+ close_timeout_s=float(args.fluxon_close_timeout_s),
+ )
+
+ def relpath_for_case(self, case: DecodeCase) -> str:
+ video_path = Path(case.video_path)
+ if not video_path.is_absolute():
+ raise ValueError(f"Fluxon backend requires absolute video_path, got {case.video_path!r}")
+ video_abs = video_path.resolve(strict=False)
+ try:
+ rel = video_abs.relative_to(self.remote_root_abs)
+ except ValueError as exc:
+ raise ValueError(
+ f"video_path is outside fluxon remote root: path={video_abs} root={self.remote_root_abs}"
+ ) from exc
+ rel_s = rel.as_posix()
+ if not rel_s or rel_s == ".":
+ raise ValueError(f"invalid empty Fluxon relpath for {video_abs}")
+ return rel_s
+
+ def close(self) -> None:
+ self.video_reader_pool.close()
+ for store in self._stores:
+ close_store_with_timeout(store, timeout_s=self.close_timeout_s)
+
+ def __enter__(self) -> "FluxonBenchmarkRuntime":
+ return self
+
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
+ self.close()
+
+
+def unwrap_result(result: Any, ctx: str) -> Any:
+ if result.is_ok():
+ return result.unwrap()
+ raise RuntimeError(f"{ctx} failed: {result.unwrap_error()}")
+
+
+def resolve_fluxon_agent_node_id(args: argparse.Namespace) -> str:
+ agent_instance_key = str(args.fluxon_agent_instance_key).strip()
+ agent_node_id = str(args.fluxon_agent_node_id).strip()
+ if agent_instance_key and agent_node_id and agent_instance_key != agent_node_id:
+ raise ValueError(
+ "--fluxon-agent-instance-key and --fluxon-agent-node-id must match when both are set"
+ )
+ node_id = agent_instance_key or agent_node_id
+ if not node_id:
+ raise ValueError(
+ "--fluxon-agent-instance-key is required for the Fluxon backend; "
+ "start fluxon_py.runtime.start_fs_agent with the same instance_key first"
+ )
+ return node_id
+
+
+def close_store_with_timeout(store: Any, *, timeout_s: float) -> None:
+ result_holder: dict[str, Any] = {}
+
+ def close_worker() -> None:
+ try:
+ close_res = store.close()
+ if close_res.is_ok():
+ result_holder["ok"] = close_res.unwrap()
+ else:
+ result_holder["error"] = close_res.unwrap_error()
+ except BaseException as exc:
+ result_holder["exception"] = exc
+ result_holder["traceback"] = traceback.format_exc(limit=6)
+
+ worker = threading.Thread(target=close_worker, daemon=True)
+ worker.start()
+ worker.join(max(0.0, float(timeout_s)))
+ if worker.is_alive():
+ print(
+ f"[dataloader_perf][WARNING] store.close exceeded {timeout_s:.3f}s; continuing",
+ flush=True,
+ )
+ return
+ if "error" in result_holder:
+ print(f"[dataloader_perf][WARNING] store.close failed: {result_holder['error']}", flush=True)
+ if "exception" in result_holder:
+ print(
+ "[dataloader_perf][WARNING] store.close raised: "
+ f"{type(result_holder['exception']).__name__}: {result_holder['exception']}",
+ flush=True,
+ )
+
+
+def new_fluxon_store(
+ *,
+ new_store_fn: Any,
+ config_cls: Any,
+ base_config: Mapping[str, Any],
+ instance_key: str,
+) -> Any:
+ cfg = copy.deepcopy(dict(base_config))
+ cfg["instance_key"] = instance_key
+ result = new_store_fn(config_cls(cfg))
+ return unwrap_result(result, f"new_store({instance_key})")
+
+
+def frame_indices(case: DecodeCase) -> list[int]:
+ if case.num_frames <= 0:
+ raise ValueError(f"num_frames must be positive, got {case.num_frames}")
+ if case.num_frames == 1:
+ return [int(case.start_idx)]
+ span = case.end_idx - case.start_idx
+ return [
+ int(case.start_idx + (span * i) / (case.num_frames - 1))
+ for i in range(case.num_frames)
+ ]
+
+
+def split_float_by_weights(value: float, weights: list[int]) -> list[float]:
+ if not weights:
+ return []
+ positive_weights = [max(0, int(weight)) for weight in weights]
+ total_weight = sum(positive_weights)
+ if total_weight <= 0:
+ return [value / len(weights) for _ in weights]
+ shares = [(value * weight) / total_weight for weight in positive_weights]
+ if shares:
+ shares[-1] += value - sum(shares)
+ return shares
+
+
+def array_result_fields(array: Any, fingerprint_bytes: int) -> dict[str, Any]:
+ shape = tuple(int(x) for x in getattr(array, "shape", ()))
+ dtype = str(getattr(array, "dtype", "unknown"))
+ nbytes = int(getattr(array, "nbytes", 0))
+ out = {
+ "shape": "x".join(str(x) for x in shape),
+ "dtype": dtype,
+ "nbytes": nbytes,
+ }
+ if fingerprint_bytes > 0:
+ out["fingerprint"] = array_fingerprint(array, fingerprint_bytes)
+ return out
+
+
+def array_fingerprint(array: Any, fingerprint_bytes: int) -> str:
+ if fingerprint_bytes <= 0:
+ return ""
+ data = array.tobytes(order="C")
+ sample_n = min(len(data), fingerprint_bytes)
+ h = hashlib.blake2b(digest_size=16)
+ h.update(str(getattr(array, "shape", "")).encode("utf-8"))
+ h.update(str(getattr(array, "dtype", "")).encode("utf-8"))
+ h.update(data[:sample_n])
+ if len(data) > sample_n:
+ h.update(data[-sample_n:])
+ return h.hexdigest()
+
+
+def parse_case_csv(path: str) -> list[DecodeCase]:
+ cases: list[DecodeCase] = []
+ csv_path = Path(path)
+ if not csv_path.exists():
+ raise FileNotFoundError(path)
+ with csv_path.open(newline="") as f:
+ for row in csv.DictReader(f):
+ video_path = row.get("video_path") or row.get("path")
+ if not video_path:
+ continue
+ cases.append(
+ DecodeCase(
+ source=row.get("source") or f"csv:{path}",
+ idx=int(row.get("idx") or len(cases)),
+ video_path=video_path,
+ height=int(row["height"]),
+ width=int(row["width"]),
+ start_idx=int(row["start_idx"]),
+ end_idx=int(row["end_idx"]),
+ num_frames=int(row["num_frames"]),
+ )
+ )
+ return cases
+
+
+def parse_timeout_log(path: str) -> list[DecodeCase]:
+ cases: list[DecodeCase] = []
+ log_path = Path(path)
+ if not log_path.exists():
+ raise FileNotFoundError(path)
+ for line in log_path.read_text(errors="replace").splitlines():
+ match = TIMEOUT_RE.search(line)
+ if match is None:
+ continue
+ cases.append(
+ DecodeCase(
+ source=f"log:{path}",
+ idx=int(match.group("idx")),
+ video_path=match.group("path"),
+ height=int(match.group("height")),
+ width=int(match.group("width")),
+ start_idx=int(match.group("start")),
+ end_idx=int(match.group("end")),
+ num_frames=int(match.group("num")),
+ )
+ )
+ return cases
+
+
+def read_manifest(path: str) -> list[str]:
+ manifest = Path(path)
+ if not manifest.exists():
+ return []
+ return [
+ line.strip()
+ for line in manifest.read_text().splitlines()
+ if line.strip() and not line.lstrip().startswith("#")
+ ]
+
+
+def case_from_parquet_row(dataset: Any, idx: int, source: str) -> DecodeCase:
+ row = dataset.take([idx]).to_pydict()
+ video_path = row["video_path"][0]
+ height = int(row["height"][0])
+ width = int(row["width"][0])
+ caption_start = int(row["caption_start"][0])
+ caption_end = int(row["caption_end"][0])
+ real_num_frames = int(row["real_num_frames"][0])
+ dst_num_frames = int(row["dst_num_frames"][0])
+
+ span = min(real_num_frames, caption_end - caption_start)
+ max_start = caption_end - span
+ if max_start <= caption_start:
+ window_start = caption_start
+ else:
+ window_start = random.randint(caption_start, max_start)
+ window_end = window_start + span - 1
+
+ return DecodeCase(
+ source=source,
+ idx=idx,
+ video_path=video_path,
+ height=height,
+ width=width,
+ start_idx=window_start,
+ end_idx=window_end,
+ num_frames=dst_num_frames,
+ )
+
+
+def sample_parquet_cases(
+ parquet_paths: list[str],
+ *,
+ count: int,
+ seed: int,
+) -> list[DecodeCase]:
+ if count <= 0:
+ return []
+ if not parquet_paths:
+ raise FileNotFoundError("No parquet paths provided for random sampling.")
+
+ import pyarrow.dataset as ds # type: ignore
+
+ random.seed(seed)
+ dataset = ds.dataset(parquet_paths, format="parquet")
+ total_rows = dataset.count_rows()
+ indices = [random.randrange(total_rows) for _ in range(count)]
+ return [case_from_parquet_row(dataset, idx, "parquet-random") for idx in indices]
+
+
+def build_cases(args: argparse.Namespace) -> list[DecodeCase]:
+ cases: list[DecodeCase] = []
+ for csv_path in args.case_csv:
+ parsed = parse_case_csv(csv_path)
+ print(f"Loaded {len(parsed)} decode cases from {csv_path}", flush=True)
+ cases.extend(parsed)
+ for log_path in args.case_log:
+ parsed = parse_timeout_log(log_path)
+ print(f"Loaded {len(parsed)} decode cases from {log_path}", flush=True)
+ cases.extend(parsed)
+
+ parquet_paths: list[str] = list(args.parquet)
+ if not parquet_paths and args.manifest:
+ parquet_paths = read_manifest(args.manifest)
+ if args.random_cases > 0:
+ parsed = sample_parquet_cases(
+ parquet_paths,
+ count=args.random_cases,
+ seed=args.seed,
+ )
+ print(f"Sampled {len(parsed)} decode cases from parquet metadata", flush=True)
+ cases.extend(parsed)
+
+ if args.dedupe:
+ unique: dict[tuple[Any, ...], DecodeCase] = {}
+ for case in cases:
+ unique.setdefault(case.key, case)
+ cases = list(unique.values())
+
+ if args.shuffle:
+ random.Random(args.seed).shuffle(cases)
+ if args.limit > 0:
+ cases = cases[: args.limit]
+
+ validate_cases(cases)
+ return cases
+
+
+def validate_cases(cases: Iterable[DecodeCase]) -> None:
+ for case in cases:
+ if case.height <= 0 or case.width <= 0:
+ raise ValueError(f"case idx={case.idx} has invalid size {case.height}x{case.width}")
+ if case.num_frames <= 0:
+ raise ValueError(f"case idx={case.idx} has invalid num_frames={case.num_frames}")
+ if not case.video_path:
+ raise ValueError(f"case idx={case.idx} has empty video_path")
+
+
+def selected_backends(args: argparse.Namespace) -> list[str]:
+ if args.backend == "both":
+ return ["original", "fluxon"]
+ return [args.backend]
+
+
+def build_jobs(
+ cases: list[DecodeCase],
+ *,
+ backends: list[str],
+ rounds: int,
+ alternate_backend_order: bool,
+) -> list[BenchmarkJob]:
+ jobs: list[BenchmarkJob] = []
+ for round_id in range(1, rounds + 1):
+ for case_pos, case in enumerate(cases, start=1):
+ ordered = list(backends)
+ if alternate_backend_order and len(ordered) == 2 and (round_id + case_pos) % 2 == 1:
+ ordered.reverse()
+ sample_id = f"r{round_id:04d}_c{case_pos:06d}"
+ for backend in ordered:
+ jobs.append(
+ BenchmarkJob(
+ sample_id=sample_id,
+ round_id=round_id,
+ case_position=case_pos,
+ backend=backend,
+ case=case,
+ )
+ )
+ return jobs
+
+
+def build_job_row(job: BenchmarkJob, started: str) -> dict[str, Any]:
+ return {
+ "sample_id": job.sample_id,
+ "round_id": job.round_id,
+ "case_position": job.case_position,
+ "backend": job.backend,
+ "started": started,
+ "source": job.case.source,
+ "idx": job.case.idx,
+ "video_path": job.case.video_path,
+ "height": job.case.height,
+ "width": job.case.width,
+ "start_idx": job.case.start_idx,
+ "end_idx": job.case.end_idx,
+ "num_frames": job.case.num_frames,
+ }
+
+
+def run_job(job: BenchmarkJob, backend: Any) -> dict[str, Any]:
+ started = time.strftime("%Y-%m-%d %H:%M:%S")
+ t0 = time.perf_counter()
+ row = build_job_row(job, started)
+ try:
+ row.update(backend.decode(job.case))
+ except BaseException as exc:
+ row.update(
+ {
+ "status": "error",
+ "elapsed_s": time.perf_counter() - t0,
+ "error_type": type(exc).__name__,
+ "error": str(exc),
+ "traceback": traceback.format_exc(limit=8),
+ }
+ )
+ return row
+
+
+def run_backend_job_batch(jobs: list[BenchmarkJob], backend: Any) -> list[dict[str, Any]]:
+ started = time.strftime("%Y-%m-%d %H:%M:%S")
+ rows = [build_job_row(job, started) for job in jobs]
+ t0 = time.perf_counter()
+ try:
+ result_rows = backend.decode_batch([job.case for job in jobs])
+ if len(result_rows) != len(rows):
+ raise RuntimeError(
+ "backend returned unexpected batch result count: "
+ f"expected={len(rows)} actual={len(result_rows)}"
+ )
+ for row, result in zip(rows, result_rows):
+ row.update(result)
+ except BaseException as exc:
+ elapsed_s = time.perf_counter() - t0
+ elapsed_shares = split_float_by_weights(elapsed_s, [1 for _ in rows])
+ for row, elapsed_share in zip(rows, elapsed_shares):
+ row.update(
+ {
+ "status": "error",
+ "elapsed_s": elapsed_share,
+ "batch_wall_s": elapsed_s,
+ "error_type": type(exc).__name__,
+ "error": str(exc),
+ "traceback": traceback.format_exc(limit=8),
+ }
+ )
+ return rows
+
+
+def run_job_window(jobs: list[BenchmarkJob], backends: Mapping[str, Any]) -> list[dict[str, Any]]:
+ grouped: dict[str, list[BenchmarkJob]] = {}
+ for job in jobs:
+ grouped.setdefault(job.backend, []).append(job)
+
+ rows: list[dict[str, Any]] = []
+ for backend_name, backend_jobs in grouped.items():
+ rows.extend(run_backend_job_batch(backend_jobs, backends[backend_name]))
+ return rows
+
+
+def build_job_windows(jobs: list[BenchmarkJob], decode_batch_size: int) -> list[list[BenchmarkJob]]:
+ if decode_batch_size <= 1:
+ return [[job] for job in jobs]
+ return [
+ jobs[start : start + decode_batch_size]
+ for start in range(0, len(jobs), decode_batch_size)
+ ]
+
+
+def run_jobs(
+ jobs: list[BenchmarkJob],
+ *,
+ backends: Mapping[str, Any],
+ workers: int,
+ prefetch_factor: int,
+ decode_batch_size: int,
+ label: str,
+) -> tuple[list[dict[str, Any]], float]:
+ rows: list[dict[str, Any]] = []
+ started = time.perf_counter()
+ if not jobs:
+ return rows, 0.0
+
+ job_windows = build_job_windows(jobs, decode_batch_size)
+ print(
+ f"{label}: jobs={len(jobs)} windows={len(job_windows)} workers={workers} "
+ f"prefetch_factor={prefetch_factor} decode_batch_size={decode_batch_size}",
+ flush=True,
+ )
+ if workers <= 1:
+ for window in job_windows:
+ for row in run_job_window(window, backends):
+ rows.append(row)
+ print_progress(label, len(rows), len(jobs), row)
+ return rows, time.perf_counter() - started
+
+ max_inflight = max(workers, workers * prefetch_factor)
+ window_iter = iter(job_windows)
+ with ThreadPoolExecutor(max_workers=workers) as executor:
+ pending: dict[Any, list[BenchmarkJob]] = {}
+
+ def submit_next() -> bool:
+ try:
+ window = next(window_iter)
+ except StopIteration:
+ return False
+ pending[executor.submit(run_job_window, window, backends)] = window
+ return True
+
+ for _ in range(min(len(job_windows), max_inflight)):
+ submit_next()
+
+ while pending:
+ completed, _ = wait(pending.keys(), return_when=FIRST_COMPLETED)
+ for future in completed:
+ pending.pop(future)
+ for row in future.result():
+ rows.append(row)
+ print_progress(label, len(rows), len(jobs), row)
+ submit_next()
+
+ rows.sort(key=lambda r: (int(r["round_id"]), int(r["case_position"]), str(r["backend"])))
+ return rows, time.perf_counter() - started
+
+
+def print_progress(label: str, done: int, total: int, row: Mapping[str, Any]) -> None:
+ print(
+ f"[{label} {done}/{total}] "
+ f"backend={row.get('backend')} status={row.get('status')} "
+ f"elapsed={float(row.get('elapsed_s', 0.0)):.3f}s "
+ f"idx={row.get('idx')} path={row.get('video_path')}",
+ flush=True,
+ )
+
+
+def summarize(rows: list[dict[str, Any]], *, wall_s: float) -> dict[str, Any]:
+ summary: dict[str, Any] = {
+ "wall_s": wall_s,
+ "total_rows": len(rows),
+ "backends": {},
+ }
+
+ for backend in sorted({str(r.get("backend")) for r in rows}):
+ backend_rows = [r for r in rows if r.get("backend") == backend]
+ ok_rows = [r for r in backend_rows if r.get("status") == "ok"]
+ elapsed = [float(r.get("elapsed_s", 0.0)) for r in ok_rows]
+ frames = sum(int(r.get("num_frames", 0)) for r in ok_rows)
+ backend_summary = {
+ "rows": len(backend_rows),
+ "ok": len(ok_rows),
+ "error": len(backend_rows) - len(ok_rows),
+ "frames": frames,
+ }
+ if elapsed:
+ elapsed_sum = sum(elapsed)
+ backend_summary.update(
+ {
+ "elapsed_sum_s": elapsed_sum,
+ "decode_rows_per_s": len(ok_rows) / elapsed_sum if elapsed_sum > 0 else 0.0,
+ "elapsed_min_s": min(elapsed),
+ "elapsed_mean_s": statistics.fmean(elapsed),
+ "elapsed_p50_s": percentile(elapsed, 0.50),
+ "elapsed_p95_s": percentile(elapsed, 0.95),
+ "elapsed_p99_s": percentile(elapsed, 0.99),
+ "elapsed_max_s": max(elapsed),
+ "decode_frames_per_s": frames / elapsed_sum if elapsed_sum > 0 else 0.0,
+ }
+ )
+ summary["backends"][backend] = backend_summary
+
+ if {"original", "fluxon"}.issubset(summary["backends"].keys()):
+ original_elapsed = [
+ float(r["elapsed_s"])
+ for r in rows
+ if r.get("backend") == "original" and r.get("status") == "ok"
+ ]
+ fluxon_elapsed = [
+ float(r["elapsed_s"])
+ for r in rows
+ if r.get("backend") == "fluxon" and r.get("status") == "ok"
+ ]
+ if original_elapsed and fluxon_elapsed:
+ original_p50 = percentile(original_elapsed, 0.50)
+ fluxon_p50 = percentile(fluxon_elapsed, 0.50)
+ summary["fluxon_vs_original"] = {
+ "p50_speedup": original_p50 / fluxon_p50 if fluxon_p50 > 0 else 0.0,
+ "original_p50_s": original_p50,
+ "fluxon_p50_s": fluxon_p50,
+ }
+ return summary
+
+
+def percentile(values: list[float], q: float) -> float:
+ if not values:
+ return 0.0
+ ordered = sorted(values)
+ pos = (len(ordered) - 1) * q
+ lo = int(pos)
+ hi = min(lo + 1, len(ordered) - 1)
+ frac = pos - lo
+ return ordered[lo] * (1.0 - frac) + ordered[hi] * frac
+
+
+def print_summary(summary: Mapping[str, Any]) -> None:
+ print("Summary:", flush=True)
+ print(f" wall_s: {float(summary.get('wall_s', 0.0)):.3f}", flush=True)
+ backends = summary.get("backends", {})
+ if isinstance(backends, Mapping):
+ for backend, data in backends.items():
+ if not isinstance(data, Mapping):
+ continue
+ print(
+ f" {backend}: rows={data.get('rows')} ok={data.get('ok')} error={data.get('error')}",
+ flush=True,
+ )
+ if "elapsed_p50_s" in data:
+ print(
+ " elapsed_s: "
+ f"mean={float(data['elapsed_mean_s']):.3f}, "
+ f"p50={float(data['elapsed_p50_s']):.3f}, "
+ f"p95={float(data['elapsed_p95_s']):.3f}, "
+ f"max={float(data['elapsed_max_s']):.3f}, "
+ f"qps={float(data.get('decode_rows_per_s', 0.0)):.2f}, "
+ f"frames/s={float(data['decode_frames_per_s']):.2f}",
+ flush=True,
+ )
+ speed = summary.get("fluxon_vs_original")
+ if isinstance(speed, Mapping):
+ print(
+ " fluxon_vs_original: "
+ f"p50_speedup={float(speed.get('p50_speedup', 0.0)):.3f}x "
+ f"(original={float(speed.get('original_p50_s', 0.0)):.3f}s, "
+ f"fluxon={float(speed.get('fluxon_p50_s', 0.0)):.3f}s)",
+ flush=True,
+ )
+
+
+def write_rows_csv(path: str, rows: list[dict[str, Any]]) -> None:
+ output = Path(path)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ fieldnames = sorted({key for row in rows for key in row.keys()})
+ with output.open("w", newline="") as f:
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
+ writer.writeheader()
+ writer.writerows(rows)
+
+
+def write_json(path: str, payload: Mapping[str, Any]) -> None:
+ output = Path(path)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
+
+
+def load_yaml_mapping(path: str, name: str) -> dict[str, Any]:
+ import yaml
+
+ p = Path(path)
+ if not p.exists():
+ raise FileNotFoundError(path)
+ obj = yaml.safe_load(p.read_text(encoding="utf-8"))
+ if not isinstance(obj, dict):
+ raise ValueError(f"{name} must be a YAML mapping")
+ return obj
+
+
+def require_abs_existing_dir(path: str, name: str) -> Path:
+ p = Path(path)
+ if not p.is_absolute():
+ raise ValueError(f"{name} must be an absolute path")
+ resolved = p.resolve()
+ if not resolved.is_dir():
+ raise ValueError(f"{name} must exist and be a directory: {resolved}")
+ return resolved
+
+
+def validate_args(args: argparse.Namespace) -> None:
+ if args.rounds <= 0:
+ raise ValueError("--rounds must be positive")
+ if args.warmup_rounds < 0:
+ raise ValueError("--warmup-rounds must be non-negative")
+ if args.workers <= 0:
+ raise ValueError("--workers must be positive")
+ if args.prefetch_factor <= 0:
+ raise ValueError("--prefetch-factor must be positive")
+ if args.decode_batch_size <= 0:
+ raise ValueError("--decode-batch-size must be positive")
+ if args.num_threads <= 0:
+ raise ValueError("--num-threads must be positive")
+ if args.fingerprint_bytes < 0:
+ raise ValueError("--fingerprint-bytes must be non-negative")
+ if args.backend in ("fluxon", "both"):
+ if args.fluxon_reader_cache_size <= 0:
+ raise ValueError("--fluxon-reader-cache-size must be positive")
+ if not args.fluxon_kv_config:
+ raise ValueError("--fluxon-kv-config is required for the Fluxon backend")
+ if not args.fluxon_remote_root:
+ raise ValueError("--fluxon-remote-root is required for the Fluxon backend")
+ if bool(args.fluxon_request_username) != bool(args.fluxon_request_password):
+ raise ValueError(
+ "--fluxon-request-username and --fluxon-request-password must be provided together"
+ )
+
+
+def build_arg_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description=__doc__,
+ formatter_class=RawDefaultsHelpFormatter,
+ )
+
+ input_group = parser.add_argument_group("decode cases")
+ input_group.add_argument("--case-csv", action="append", default=[])
+ input_group.add_argument("--case-log", action="append", default=[])
+ input_group.add_argument("--manifest", default=DEFAULT_MANIFEST)
+ input_group.add_argument("--parquet", action="append", default=[])
+ input_group.add_argument("--random-cases", type=int, default=0)
+ input_group.add_argument("--limit", type=int, default=0)
+ input_group.add_argument("--shuffle", action="store_true")
+ input_group.add_argument("--no-dedupe", dest="dedupe", action="store_false")
+ input_group.set_defaults(dedupe=True)
+
+ bench_group = parser.add_argument_group("benchmark")
+ bench_group.add_argument("--backend", choices=["original", "fluxon", "both"], default="both")
+ bench_group.add_argument("--rounds", type=int, default=1)
+ bench_group.add_argument("--warmup-rounds", type=int, default=0)
+ bench_group.add_argument("--workers", type=int, default=1)
+ bench_group.add_argument("--prefetch-factor", type=int, default=2)
+ bench_group.add_argument("--decode-batch-size", type=int, default=1)
+ bench_group.add_argument("--num-threads", type=int, default=8)
+ bench_group.add_argument("--seed", type=int, default=1234)
+ bench_group.add_argument("--fingerprint-bytes", type=int, default=0)
+ bench_group.add_argument("--no-alternate-backend-order", dest="alternate_backend_order", action="store_false")
+ bench_group.add_argument("--allow-errors", action="store_true")
+ bench_group.set_defaults(alternate_backend_order=True)
+
+ fluxon_group = parser.add_argument_group("fluxon backend")
+ fluxon_group.add_argument("--fluxon-kv-config", default="")
+ fluxon_group.add_argument("--fluxon-remote-root", default="")
+ fluxon_group.add_argument("--fluxon-export-name", default="dataloader-videos")
+ fluxon_group.add_argument(
+ "--fluxon-agent-instance-key",
+ default="",
+ help="External fluxon_py.runtime.start_fs_agent instance_key used as the static export node.",
+ )
+ fluxon_group.add_argument("--fluxon-client-instance-key", default="")
+ fluxon_group.add_argument(
+ "--fluxon-agent-node-id",
+ default="",
+ help="Optional explicit node id; must match --fluxon-agent-instance-key when both are set.",
+ )
+ fluxon_group.add_argument("--fluxon-cache-max-bytes", type=int, default=1 << 40)
+ fluxon_group.add_argument("--fluxon-reader-cache-size", type=int, default=32)
+ fluxon_group.add_argument("--fluxon-metadata-cache-ttl-ms", type=int, default=1000)
+ fluxon_group.add_argument("--fluxon-stale-window-ms", type=int, default=1000)
+ fluxon_group.add_argument("--fluxon-close-timeout-s", type=float, default=10.0)
+ fluxon_group.add_argument(
+ "--fluxon-request-username",
+ default="",
+ help="FluxonFS username used to sign file RPC tokens.",
+ )
+ fluxon_group.add_argument(
+ "--fluxon-request-password",
+ default="",
+ help="FluxonFS password used to sign file RPC tokens.",
+ )
+
+ output_group = parser.add_argument_group("output")
+ output_group.add_argument(
+ "--output-csv",
+ default="runs/dualpointer/dataloader_video_benchmark/results.csv",
+ )
+ output_group.add_argument(
+ "--output-json",
+ default="runs/dualpointer/dataloader_video_benchmark/summary.json",
+ )
+ return parser
+
+
+def main(argv: Optional[list[str]] = None) -> int:
+ parser = build_arg_parser()
+ args = parser.parse_args(argv)
+ validate_args(args)
+
+ cases = build_cases(args)
+ if not cases:
+ raise SystemExit(
+ "No decode cases found. Pass --case-csv, --case-log, or --random-cases with parquet metadata."
+ )
+
+ backends_to_run = selected_backends(args)
+ print(
+ "Dataloader decode benchmark: "
+ f"cases={len(cases)} backend={args.backend} rounds={args.rounds} "
+ f"warmup_rounds={args.warmup_rounds} workers={args.workers} "
+ f"prefetch_factor={args.prefetch_factor} decode_batch_size={args.decode_batch_size} "
+ f"num_threads={args.num_threads}",
+ flush=True,
+ )
+
+ fluxon_runtime: Optional[FluxonBenchmarkRuntime] = None
+ try:
+ if "fluxon" in backends_to_run:
+ fluxon_runtime = FluxonBenchmarkRuntime.open(args)
+
+ backend_objs: dict[str, Any] = {}
+ if "original" in backends_to_run:
+ backend_objs["original"] = OriginalDecordBackend(
+ num_threads=args.num_threads,
+ fingerprint_bytes=args.fingerprint_bytes,
+ )
+ if "fluxon" in backends_to_run:
+ if fluxon_runtime is None:
+ raise RuntimeError("Fluxon runtime was not initialized")
+ backend_objs["fluxon"] = FluxonVideoBackend(
+ runtime=fluxon_runtime,
+ num_threads=args.num_threads,
+ fingerprint_bytes=args.fingerprint_bytes,
+ )
+
+ warmup_jobs = build_jobs(
+ cases,
+ backends=backends_to_run,
+ rounds=args.warmup_rounds,
+ alternate_backend_order=args.alternate_backend_order,
+ )
+ if warmup_jobs:
+ run_jobs(
+ warmup_jobs,
+ backends=backend_objs,
+ workers=args.workers,
+ prefetch_factor=args.prefetch_factor,
+ decode_batch_size=args.decode_batch_size,
+ label="warmup",
+ )
+
+ jobs = build_jobs(
+ cases,
+ backends=backends_to_run,
+ rounds=args.rounds,
+ alternate_backend_order=args.alternate_backend_order,
+ )
+ rows, wall_s = run_jobs(
+ jobs,
+ backends=backend_objs,
+ workers=args.workers,
+ prefetch_factor=args.prefetch_factor,
+ decode_batch_size=args.decode_batch_size,
+ label="measure",
+ )
+ finally:
+ if fluxon_runtime is not None:
+ fluxon_runtime.close()
+
+ summary = summarize(rows, wall_s=wall_s)
+ write_rows_csv(args.output_csv, rows)
+ write_json(
+ args.output_json,
+ {
+ "args": sanitized_args(args),
+ "summary": summary,
+ "rows": rows,
+ },
+ )
+ print_summary(summary)
+ print(f"Wrote CSV: {args.output_csv}", flush=True)
+ print(f"Wrote JSON: {args.output_json}", flush=True)
+
+ if not args.allow_errors and any(row.get("status") != "ok" for row in rows):
+ return 1
+ return 0
+
+
+def sanitized_args(args: argparse.Namespace) -> dict[str, Any]:
+ out = dict(vars(args))
+ if out.get("fluxon_request_password"):
+ out["fluxon_request_password"] = ""
+ return out
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/dataloader_video_benchmark/test_benchmark_contract.py b/examples/dataloader_video_benchmark/test_benchmark_contract.py
new file mode 100644
index 0000000..28db0a6
--- /dev/null
+++ b/examples/dataloader_video_benchmark/test_benchmark_contract.py
@@ -0,0 +1,220 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import csv
+import importlib.util
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+EXAMPLE_DIR = Path(__file__).resolve().parent
+MODULE_PATH = EXAMPLE_DIR / "benchmark.py"
+
+
+def _load_module():
+ spec = importlib.util.spec_from_file_location("dataloader_video_benchmark", MODULE_PATH)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"failed to load module spec: {MODULE_PATH}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+DL = _load_module()
+
+
+class TestDataloaderPerfContract(unittest.TestCase):
+ def test_parse_case_csv_and_frame_indices(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_s:
+ csv_path = Path(tmp_s) / "cases.csv"
+ with csv_path.open("w", newline="") as f:
+ writer = csv.DictWriter(
+ f,
+ fieldnames=[
+ "idx",
+ "video_path",
+ "height",
+ "width",
+ "start_idx",
+ "end_idx",
+ "num_frames",
+ ],
+ )
+ writer.writeheader()
+ writer.writerow(
+ {
+ "idx": "7",
+ "video_path": "/data/a.mp4",
+ "height": "480",
+ "width": "832",
+ "start_idx": "10",
+ "end_idx": "18",
+ "num_frames": "5",
+ }
+ )
+
+ cases = DL.parse_case_csv(str(csv_path))
+
+ self.assertEqual(len(cases), 1)
+ self.assertEqual(cases[0].idx, 7)
+ self.assertEqual(DL.frame_indices(cases[0]), [10, 12, 14, 16, 18])
+
+ def test_build_jobs_alternates_backend_order(self) -> None:
+ cases = [
+ DL.DecodeCase(
+ source="test",
+ idx=1,
+ video_path="/data/a.mp4",
+ height=4,
+ width=4,
+ start_idx=0,
+ end_idx=3,
+ num_frames=2,
+ ),
+ DL.DecodeCase(
+ source="test",
+ idx=2,
+ video_path="/data/b.mp4",
+ height=4,
+ width=4,
+ start_idx=0,
+ end_idx=3,
+ num_frames=2,
+ ),
+ ]
+
+ jobs = DL.build_jobs(
+ cases,
+ backends=["original", "fluxon"],
+ rounds=1,
+ alternate_backend_order=True,
+ )
+
+ self.assertEqual([job.backend for job in jobs], ["original", "fluxon", "fluxon", "original"])
+ self.assertEqual(jobs[0].sample_id, jobs[1].sample_id)
+ self.assertEqual(jobs[2].sample_id, jobs[3].sample_id)
+
+ def test_summarize_reports_fluxon_speedup(self) -> None:
+ rows = [
+ {
+ "backend": "original",
+ "status": "ok",
+ "elapsed_s": 2.0,
+ "num_frames": 4,
+ },
+ {
+ "backend": "fluxon",
+ "status": "ok",
+ "elapsed_s": 1.0,
+ "num_frames": 4,
+ },
+ ]
+
+ summary = DL.summarize(rows, wall_s=3.0)
+
+ self.assertEqual(summary["backends"]["original"]["ok"], 1)
+ self.assertEqual(summary["backends"]["fluxon"]["ok"], 1)
+ self.assertEqual(summary["fluxon_vs_original"]["p50_speedup"], 2.0)
+
+ def test_resolve_fluxon_agent_node_id_requires_external_agent(self) -> None:
+ args = DL.argparse.Namespace(
+ fluxon_agent_instance_key="fs-agent-1",
+ fluxon_agent_node_id="",
+ )
+ self.assertEqual(DL.resolve_fluxon_agent_node_id(args), "fs-agent-1")
+
+ args = DL.argparse.Namespace(
+ fluxon_agent_instance_key="fs-agent-1",
+ fluxon_agent_node_id="fs-agent-2",
+ )
+ with self.assertRaisesRegex(ValueError, "must match"):
+ DL.resolve_fluxon_agent_node_id(args)
+
+ args = DL.argparse.Namespace(
+ fluxon_agent_instance_key="",
+ fluxon_agent_node_id="",
+ )
+ with self.assertRaisesRegex(ValueError, "start fluxon_py.runtime.start_fs_agent"):
+ DL.resolve_fluxon_agent_node_id(args)
+
+ def test_sanitized_args_redacts_fluxon_password(self) -> None:
+ args = DL.argparse.Namespace(
+ fluxon_request_username="bench",
+ fluxon_request_password="secret",
+ other="value",
+ )
+
+ self.assertEqual(
+ DL.sanitized_args(args),
+ {
+ "fluxon_request_username": "bench",
+ "fluxon_request_password": "",
+ "other": "value",
+ },
+ )
+
+ def test_run_jobs_uses_decode_batch_windows(self) -> None:
+ class BatchBackend:
+ def __init__(self) -> None:
+ self.calls = []
+
+ def decode_batch(self, cases):
+ self.calls.append([case.idx for case in cases])
+ return [
+ {
+ "status": "ok",
+ "open_s": 0.0,
+ "decode_s": 0.1,
+ "materialize_s": 0.0,
+ "elapsed_s": 0.1,
+ "shape": "1",
+ "dtype": "uint8",
+ "nbytes": 1,
+ }
+ for _case in cases
+ ]
+
+ cases = [
+ DL.DecodeCase(
+ source="test",
+ idx=i,
+ video_path=f"/data/{i}.mp4",
+ height=4,
+ width=4,
+ start_idx=0,
+ end_idx=3,
+ num_frames=2,
+ )
+ for i in range(1, 4)
+ ]
+ jobs = DL.build_jobs(
+ cases,
+ backends=["fluxon"],
+ rounds=1,
+ alternate_backend_order=True,
+ )
+ backend = BatchBackend()
+
+ rows, _wall_s = DL.run_jobs(
+ jobs,
+ backends={"fluxon": backend},
+ workers=1,
+ prefetch_factor=2,
+ decode_batch_size=2,
+ label="test",
+ )
+
+ self.assertEqual(backend.calls, [[1, 2], [3]])
+ self.assertEqual(len(rows), 3)
+ self.assertTrue(all(row["status"] == "ok" for row in rows))
+
+
+def main() -> None:
+ unittest.main()
+
+
+if __name__ == "__main__":
+ main()
diff --git "a/fluxon_doc_cn/blog/blog_2_Fluxon_KV_SSD\345\255\230\345\202\250\357\274\232\346\212\212\346\234\254\345\234\260SSD\346\216\245\346\210\220\345\206\205\345\255\230\345\211\257\346\234\254\347\232\204\345\233\236\345\241\253\345\261\202.md" "b/fluxon_doc_cn/blog/blog_2_Fluxon_KV_SSD\345\255\230\345\202\250\357\274\232\346\212\212\346\234\254\345\234\260SSD\346\216\245\346\210\220\345\206\205\345\255\230\345\211\257\346\234\254\347\232\204\345\233\236\345\241\253\345\261\202.md"
new file mode 100644
index 0000000..497bdd6
--- /dev/null
+++ "b/fluxon_doc_cn/blog/blog_2_Fluxon_KV_SSD\345\255\230\345\202\250\357\274\232\346\212\212\346\234\254\345\234\260SSD\346\216\245\346\210\220\345\206\205\345\255\230\345\211\257\346\234\254\347\232\204\345\233\236\345\241\253\345\261\202.md"
@@ -0,0 +1,1307 @@
+# Mooncake 6x 吞吐:让 Fluxon KV 支持 DRAM + SSD 多级缓存
+
+Fluxon KV 是 Fluxon 统一 AI 数据面中的分布式键值缓存服务,面向世界模型推理中的潜变量缓存(latent cache)、大模型推理中的键值缓存(KV cache),以及跨进程、跨节点的大对象复用。应用通过 `put`、`get` 和 `delete` 存取数据,Fluxon KV 统一管理 key-version 路由、副本生命周期和数据搬运,并以 `MemHolder` 交付读取结果。
+
+纯 DRAM 缓存能提供低延迟访问,但可命中工作集受内存容量约束;当工作集大于 DRAM 时,被驱逐的数据会转为 miss,owner 的本地 NVMe 容量和带宽也尚未进入读取路径。将本地 SSD 纳入缓存层次后,系统可以保留更多运行期数据,并在内存副本缺失时从 SSD 回填。对于存在数据复用且并发度足够的 AI 负载,这种分层具有提高整体命中率、减少上游重复计算或远程重取,并更充分利用本地存储与传输带宽的潜力。实际收益取决于访问分布、并发度、介质性能和回填开销。
+
+本文将存储节点(owner)的本地 SSD 作为 DRAM 内存层的运行期回填层(backing store)。`put` 操作在内存副本可读后返回,系统随后在后台异步写入 SSD;`get` 操作优先查询内存,仅在没有可读内存副本时从 SSD 回填。SSD 不引入独立的公共 API,也不承担冷启动恢复。
+
+下文将控制节点(master)维护的 key-version 级副本索引称为路由表(`route`),将贡献内存或 SSD 容量的节点称为存储节点(owner)。全文围绕四个核心要点展开:
+
+| 设计方面 | 核心要点说明 |
+| --- | --- |
+| 用户契约 | 继续使用 `put`、`get` 和 `delete` 操作;`get` 仍返回 `MemHolder`。 |
+| 控制面与资源 | master 维护 `route`,并统一调度用于最终 value 副本和临时传输的内存分配(allocation)。owner 提供 memory segment,承载真实 bytes,并管理本地 SSD。 |
+| 数据路径 | 写入的同步完成点是内存副本发布;读取优先使用内存,SSD 只在内存无可读副本时回填。 |
+| 恢复边界 | SSD 仅作为运行期缓存。owner 重启后重建空 shard 和 ring,不扫描旧 shard,也不从 master 的 `route` 中恢复旧 SSD bytes。 |
+
+正文先说明 master 与 owner 的分工、设计取舍和当前边界,再介绍配置方式,并沿写入、读取与 SSD 回填的数据流展开实现。随后讨论 owner 本地 IO 和一致性,最后给出性能测试结果及其适用范围。
+
+## 1. 架构概览:master 管理路由和内存分配,owner 管理本地 SSD
+
+内存 allocation 分为两类:一类承载最终 value 副本,另一类供跨 owner 传输临时使用,例如远端 SSD 回填的 source staging。master 统一调度这两类区间,因为它们的分配、保活和释放都与 `route` 及 in-flight 请求状态联动。allocation 对应的真实 bytes 仍位于 owner 注册的 memory segment。
+
+SSD allocation 只负责最终 value 副本,因此只涉及 owner 本地 shard/ring 的文件区间。文件 offset、覆盖保护和 IO 调度都是本地状态,因此 owner 负责 SSD 分配和 ring 驱逐。
+
+| 对象 | 资源或状态归属 | 分配与驱逐职责 |
+| --- | --- | --- |
+| `route` | master 持有 key-version 级副本索引。 | master 选择内存副本的放置位置并维护内存 allocation 生命周期。在内存或 SSD 副本失效后,master 收敛逻辑路由。 |
+| 内存 | bytes 位于 owner 注册的 memory segment。 | master 从这些 segment 分配两类区间:内存副本区间和通信临时区间。master 同时管理对应 allocation 的保活与驱逐。 |
+| SSD | bytes 位于 owner 本地的 shard 和 ring。 | owner 分配 SSD 文件区间并执行 ring 驱逐,再把 commit 或驱逐结果通知 master 更新 `route`。 |
+
+在 `route` 实现中,master 的 `OneKvNodesRoutes` 按 key-version 组织路由。`node_replicas` 是一张以 owner 为 key 的 map,每个 entry 用 `memory` 和 `ssd` 记录两层副本。二者共享当前 route 的 `put_id`,不能跨版本复用。SSD 文件位置、读写队列、文件 IO 对齐和覆盖保护都由 owner 管理。
+
+### 1.1 角色与图示约定
+
+全文时序图用角色前缀标明逻辑边界;前缀不表示独立进程。
+
+| 前缀 | 表示的职责 |
+| --- | --- |
+| `external-*` | 集群外的公共 API 调用方或 benchmark 消费方,不贡献 Fluxon 存储容量。 |
+| `master-*` | master 内的控制面,管理 `route`、in-flight 状态、master 侧 `Allocation` 保活句柄(guard)及 holding 生命周期,不中转 payload。 |
+| `owner-*` | owner 侧的请求入口、内存或 SSD 存储组件,以及本地数据传输端点。多个 `owner-*` 角色可以位于同一 owner 进程,也可以分布在不同 owner。 |
+
+后续时序图同时涉及请求协调、bytes 位置、allocation 保活和数据搬运,四者不能混用:
+
+| 关系 | 文中的规范说法 | 当前实现落点 |
+| --- | --- | --- |
+| 请求由谁协调 | `owner-requester` 接收并推进本次公共请求。 | owner 侧 `ClientKvApi`;external 包装层在图中通常折叠。 |
+| payload bytes 位于哪里 | bytes 位于 source/target owner 已注册的 memory segment,或 SSD owner 的本地 shard。 | RPC plan 中的 node、base address 和 absolute address。 |
+| allocation 由谁保活 | master 持有表示 owner segment 区间的 RAII `Allocation` guard(保活句柄)。 | `InflightPutInfo`、`OneKvNodesRoutes.memory`、`InflightGetInfo` 和 `get_holding`。 |
+| 数据由谁搬运 | memory get 由 requester pull;远端 SSD 回填由 SSD source push。 | owner 侧 `ClientTransferEngine`。 |
+
+`owner-requester` 只表示请求协调者,不代表固定 buffer,也不持有 master 侧的 `Allocation` 对象。它在不同操作中的数据面职责如下:
+
+| 操作 | owner 侧 range | master 侧 guard | 完成结果 |
+| --- | --- | --- | --- |
+| `put` | 常规路径的 source/staging bytes 位于 requester owner;side-transfer 路径位于单独的 `owner-put-source`。最终 target 由 master 选择,可能与 source 同 owner,也可能位于 `owner-put-target`。 | 本地 placement 用一个 `InflightPutAllocation::Local` guard 同时表示 source 和 target;远端 placement 用 `Remote { src, target }` 两个 guard。 | `PutDone` 把 target guard 转入 memory route;`put` 不产生 holder。 |
+| `get` | route 路径的最终 target bytes 位于 requester owner。已有本地 replica 时直接复用;否则 memory source 或 SSD source 把 payload 写入该 target。 | `InflightGetInfo.allocation` 保活 target;`GetDone` 后转入 master `get_holding`。memory source 或远端 SSD source staging 的额外 guard 位于 `source_allocation`。 | owner 返回 holder metadata,external 在自己的进程中构造 `MemHolder`。 |
+| `delete` | 没有 payload source、staging 或 target range。 | 不创建 payload `Allocation`。 | 只更新控制面 route,并执行既有 holder 失效流程。 |
+
+后续 `put` / `get` 时序从 owner 进入 master route 路径开始,折叠 external 包装 RPC。owner 返回共享内存 offset、长度和 `holder_id` 等 metadata,external 再据此构造 `MemHolder`,形成完整的公共结果。公共 `get` 操作还可能先命中 owner 本地的 `get_cached_info`,直接复用已有 `MemoryInfo`。第 4–7 节描述本地缓存未命中后进入 `GetStart` 的路径。
+
+时序图默认采用常规 owner 请求入口。专用 side-transfer worker 会把请求处理者与 put source owner 分开:`PutStartReq.source_node_id` 指向与 worker 共享 mmap 的 owner,source/staging bytes 位于该 owner 的 segment。这个 fast path 不改变 master 选择 put target、`PutDone` 发布 target 的主契约。
+
+### 1.2 四条主链路
+
+第 3–7 节将沿这四条链路展开 allocation、传输和失败清理。
+
+| 链路 | 核心流程 | 完成或收敛点 |
+| --- | --- | --- |
+| 写入与 SSD commit | master 分配 source/target;`PutDone` 发布内存副本;owner 随后异步写入 SSD。 | 公共 `put` 操作在内存副本发布后完成。SSD commit 只有在版本、memory 和 tomb 条件仍成立时才进入 `route`。 |
+| 内存命中读取 | requester 复用本地副本,或从远端 memory owner pull 到最终 target。 | `GetDone` 把 target guard 转入 `get_holding`,external 根据 metadata 构造 `MemHolder`。 |
+| SSD 回填读取 | 当前版本没有可读内存副本时,本地路径直接读入 requester target;远端路径读入 source staging 后按 chunk push。 | 两条路径复用同一个 `GetDone` 和 holder 交付链。 |
+| SSD 驱逐与陈旧 route 兜底 | owner 覆盖 ring entry 后批量通知 master;读取端仍会复查本地 entry。 | 批量通知收敛正常驱逐;陈旧 source 读取失败时执行 revoke 并有界重新选路。 |
+
+payload 不经过 master。master 只维护逻辑路由和 allocation 生命周期;SSD shard、offset、IO 对齐与 ring 状态只在 owner 本地解析。
+
+### 1.3 设计取舍与当前边界
+
+引入 SSD 后,需要先明确三项职责边界:
+
+1. **同步语义**:SSD 不改变 `put` / `get` 操作的同步语义。
+2. **路由信息**:master 的 `route` 不记录 SSD 物理布局。
+3. **恢复能力**:SSD 不承担重启后的数据恢复。
+
+在这些边界内,本设计保持现有公共 API 和内存副本语义,利用 owner 本地 SSD 扩大运行期缓存容量,并在没有可读内存副本时提供回填来源。文件布局和 IO 复杂度均封装在 owner 内部。
+
+因此,当前 SSD 被定位为运行期回填层。它不向用户暴露独立的 SSD API,也不支持单 value 跨 device 条带化。SSD 文件布局、IO 调度和 ring 生命周期均由 owner 管理,不进入公共 API 或 master route。
+
+| 设计选择 | 作用 | 代价 | 当前边界 |
+| --- | --- | --- | --- |
+| master 不保存 SSD offset | 跨节点控制面只处理 key-version、owner、payload 长度和 tomb tag 等逻辑信息。 | master 无法据此检查本地物理布局;陈旧 route 要靠节点 tomb、批量清理与读取兜底收敛。 | **route 是逻辑索引,不是恢复元数据**。 |
+| `PutDone` 不等待 SSD | 同步写入只等待内存副本,SSD 背压留在 owner 本地 persist 路径。 | SSD 副本是 late commit,需要用 `put_id` 拒绝旧版本 commit,并在 master 处理 commit RPC 的窗口 pin 住本地 entry。 | **内存副本 ready 是同步提交点**。 |
+| owner 本地驱逐 | ring 决定物理覆盖顺序,再批量通知 master 清理逻辑 route;发送失败的当前 batch 会重试。 | 有界队列满、进程退出或 master 持续不可用时,master 可能保留陈旧 route。 | **读取失败会 revoke 并有限重试**。 |
+| `O_DIRECT + io_uring`,而非 `mmap` / PageCache | KV SSD 路径绕过 PageCache,直接提交对齐 IO。 | 实现必须协调 staging 地址、文件 offset、IO 长度、aligned buffer、ring 覆盖和 read pin。 | **文件 IO 实现封装在 owner**。 |
+| 本地 SSD 回填 | SSD owner 与 requester owner 相同时复用最终 target,不申请独立 source staging。 | target capacity 必须覆盖对齐 IO;地址不满足 direct read 条件时仍会经过 scratch copy。 | **direct 条件成立时省掉本地二次 copy**。 |
+| SSD owner 主动推送 | chunk ready 后由 SSD owner 直接推到 requester target,读盘和网络传输可重叠。 | requester 必须先通过 `SsdStageReadReq` 提供 target 地址和 `get_id`。 | **省去 stage-ready 后的一次 RTT**。 |
+| value 级 device 选择 | 单 value 使用一个 device 上的连续文件区间,不需要跨 device 组合读结果。 | 单个大 value 不跨 device 条带化。 | **多 device 并行发生在 value 粒度**。 |
+| 启动时重建空 shard 和 ring | 明确 SSD 只作为运行期缓存:不扫描 shard,也不通过 WAL 或 checkpoint 恢复 entry。 | 重启后需要由新写入重新形成 SSD 副本。 | **不支持冷启动恢复**。 |
+| scratch 读路径 | 不满足 direct read 对齐时仍能正确读取真实 payload。 | 该 chunk 多一次本地 copy。 | **padding 不进入用户 holder**。 |
+
+## 2. 配置与观测:容量按介质分层可见
+
+KV SSD 复用 owner 已有的 `large_file_paths`,不增加第二套路径参数,以保持配置项尽可能简洁。`large_limit_size` 与这些路径一一对应;运行时按实际 device 去重,并分别上报内存与 SSD 的容量和占用。
+
+### 2.1 配置入口
+
+配置仍位于 owner 的 `fluxonkv_spec`;`large_limit_size` 与 `large_file_paths` 等长:
+
+```yaml
+fluxonkv_spec:
+ large_file_paths: [/data/fluxon_large0, /data/fluxon_large1] # Derive one KV SSD cache root from each path.
+ large_limit_size: ["4GB", "8GB"] # Limit the matching cache roots in the same order.
+```
+
+- **启用条件**
+ - `large_limit_size` 未配置或为 `null`:不启用 KV SSD。
+ - `large_limit_size` 已配置:必须与 `large_file_paths` 长度一致,数组下标一一对应。
+- **容量值格式**
+ - 可以使用整数 bytes。
+ - 也可以使用 `"512.9B"`、`"1.5GB"` 这类 size 字符串。
+ - `MB/GB` 按二进制单位解析,等价于 `MiB/GiB`;小数结果按 bytes 向下截断。
+ - 解析结果必须大于或等于 512 bytes,满足当前 `O_DIRECT` 对齐约束。
+- **external 约束**
+ - 不贡献存储容量的 external 不能声明 `large_limit_size`。
+ - external 只继承 owner 暴露的共享 bundle 和运行时路径信息,不贡献 SSD 容量。
+
+### 2.2 路径派生与设备去重
+
+owner 不读取单独的 SSD 路径参数。每个 SSD cache root 都从对应的 `large_file_paths[i]` 派生:
+
+```text
+/_cluster_kv_ssd_storage//
+```
+
+- **创建 root**:owner 创建派生目录,再通过 `metadata.dev()` 获取实际 device 身份。
+- **同设备去重**
+ - 多个 path 指向同一实际 device:只保留第一个 root。
+ - 如果不去重,同一块盘上的两个目录会被误认为两条独立 IO 路径,导致调度层高估并行度。
+
+### 2.3 容量、ring 与 per-device 对象
+
+- **容量作用域**
+ - `large_limit_size[i]` 只约束 `large_file_paths[i]` 派生的 KV SSD cache root。
+ - 它限制 key-value payload 在 KV SSD ring 中的用量,不代表整块 SSD 的物理容量。
+ - 日志、profile、FS disk cache 等其他大文件资产不受该值约束。
+- **ring 空间不足**
+
+ **ring 是构建在定长 shard 文件之上的循环空间分配器和本地 key-version 索引。** 每个 shard 用 `head` 为新 value 分配对齐后的连续区间;写到文件末尾时,物理 offset 回到文件开头继续复用空间。`tail` 随覆盖边界向前推进,回收最旧且已经可以覆盖的 entry。ring 同时记录 entry 的 `Writing/Committed` 状态、read pin 和 route commit pin,遇到仍在写入、读取或提交 route 的区间时会等待,不会直接覆盖。
+
+ ring 的作用是在固定容量内持续复用 SSD cache 空间,并把被覆盖的 key-version 交给 owner 后台通知 master 收敛逻辑 `route`。文中的 ring 与 `io_uring` 职责不同:前者管理文件区间、entry 状态和驱逐顺序,后者负责提交实际的文件读写。第 6.2 节再展开状态转换和覆盖保护。
+
+ 选择 ring 的直接工程动机,是让每个 shard 内的写入尽量保持追加式、对齐且连续。`head` 单向推进,空间用完后再回绕覆盖,省去了随机空洞分配、碎片合并和后台 compaction。现代 NVMe 也能处理随机 IO;这里更看重 `O_DIRECT` 连续写入、固定容量复用和回收路径的可预测性。
+
+ Moka 与 ring 解决的是不同层次的问题:
+
+ | 机制 | 当前职责 | 无法替代的另一层能力 |
+ | --- | --- | --- |
+ | Moka | master 侧按 weight 管理内存副本的准入和驱逐。 | 不分配 SSD 文件 offset,也不管理 `Writing/Committed`、read pin 或 route commit pin。 |
+ | ring | owner 侧分配连续 SSD 区间,并按物理写入顺序回收和覆盖。 | 不提供 Moka 的访问热度与内存准入策略。 |
+
+ 如果直接用 Moka 选择任意 SSD key 驱逐,被删除 value 的文件区间会形成不连续空洞,仍需额外实现 extent allocator、空洞合并或 compaction。Moka 可以叠加为逻辑淘汰策略,但不能替代 ring 对 SSD 物理空间的管理。
+
+ - 目标区域可以覆盖:ring 推进 tail 并驱逐旧 entry。
+ - 目标区域仍有未完成写入、active read pin 或 route commit pin:当前写入等待空间释放。
+- **`KvSsdStorage`**
+ - 持有一组去重后的 device root。
+ - 每个 device root 都独立持有:
+ - **shard 文件集合**:存放 ring 分配的连续 SSD offset。
+ - **writer queue**:接收本 device 的写任务。
+ - **reader queue**:接收本 device 的读任务。
+ - **`UringIoEngine`**:提交本 device shard 文件上的 `read/write`;`Iovec` 只作为测试和对照路径保留。
+- **shard 到 device 的映射**
+ - 每个 device root 的 `large_limit_size` 会划分给该 device 的多个 shard。
+ - `shard_to_device` 记录 shard 所属 device。
+ - 读路径根据 committed entry 的 `shard_id` 找回对应 reader queue。
+
+### 2.4 能力标记与容量观测
+
+启用 KV SSD 的 owner 会在 `ClusterMember.metadata` 中发布 `kv_ssd_storage=true`。master 在 `PutStart` 时读取当前 live client members,并按下表确定 target 候选集和后续 SSD persist 决策:
+
+| live member 状态 | put target 候选集 | `PutDone` 后的处理 |
+| --- | --- | --- |
+| 没有 owner 声明 `kv_ssd_storage=true` | 所有具有已注册内存 allocator 的 live owner。 | `persist_to_ssd=false`,跳过 `SsdReplicaPersistReq`,按纯内存模式完成。 |
+| 至少一个 owner 声明 `kv_ssd_storage=true` | 同时具有内存 allocator 和该标记的 live owner。 | `persist_to_ssd=true`,向选中的 target owner 异步发送 `SsdReplicaPersistReq`。 |
+
+master 把这次决策保存在 `InflightPutInfo` 中,`PutDone` 不重新推断。该标记只表达 owner 是否具备 KV SSD 能力,不暴露本地 shard、offset 或容量;容量和占用仍通过指标单独上报。target owner 收到 persist 请求后还会检查本地 `KvSsdStorage`,用于兜住 member capability 快照与 owner 实际状态之间的竞态。
+
+owner 按资源类型分别上报 capacity/used,UI 也分别展示内存和 SSD:
+
+- **`memory_segment`**
+ - 指标:`kvcache_segment_capacity_bytes` / `kvcache_segment_used_bytes`。
+ - 含义:owner 可直接承载 `MemHolder` 的内存 segment 容量和占用。
+- **`kv_ssd`**
+ - 指标:`kv_ssd_capacity_bytes` / `kv_ssd_used_bytes`。
+ - 含义:owner 本地 SSD 介质层的容量和 ring 已用空间。
+
+## 3. 写入:先内存可见,再异步进入 SSD
+
+**`put` 操作的同步完成点是内存副本发布。** `PutDoneResp` 返回后,用户已经可以读到该副本。SSD 对齐、写盘、ring 空间等待和 route 提交都在后台执行。
+
+SSD persist 复用内存 target 所在 owner 的本地地址,因此 master 选择内存 target 时也同时决定是否生成 SSD 副本以及由哪个 owner 写盘。这里分为三层:
+
+| 层次 | 行为 | 分支定位 |
+| --- | --- | --- |
+| 能力上报 | owner 已通过 member metadata 上报 `kv_ssd_storage=true`。内存 segment 注册继续只描述 master 可分配的地址区间;SSD 文件区间仍由 owner 本地管理。 | master 已经能判断 owner 是否具备 KV SSD 能力,无需通过 persist RPC 探测。 |
+| master 决策 | live SSD-capable owner 集合为空时使用纯内存 placement;集合非空时,target 必须同时具有内存 allocator 和 SSD capability。决策结果以 `persist_to_ssd` 写入 `InflightPutInfo`。 | 无 SSD owner 时不发送 persist RPC;启用 SSD placement 时不会主动选择无 SSD owner。 |
+| owner 侧防御 | target owner 在处理 persist 请求时再次检查本地 `KvSsdStorage`。 | `persisted=false` 只覆盖 capability 快照过期、owner 重启或重新注册等竞态,不参与正常能力探测。 |
+
+一次写入分为三个阶段:
+
+| 阶段 | 动作 | 完成后的状态 |
+| --- | --- | --- |
+| 1. 填充 target | master 根据 live member capability 和已注册内存 allocator 生成 placement plan,并在 `InflightPutInfo` 中记录 `persist_to_ssd`。系统先将 payload 写入 `owner-put-source` 的 range。对于本地 placement,系统直接复用该 range;对于远端 placement,系统再将 payload 传输到 `owner-put-target`。 | payload 已到 target 地址,SSD persist 决策已经固定,尚未对其他读取发布。 |
+| 2. 发布内存副本 | `owner-requester` 发送 `PutDoneReq`。master 将 target guard 转入 route 的 `memory`,并释放不再需要的 source staging。 | master 返回 `PutDoneResp`;公共 `put` 操作完成,内存副本可读。 |
+| 3. 按 placement 决策处理 SSD | `persist_to_ssd=false` 时跳过 SSD RPC;为 `true` 时,master 保活 target 并发送 `SsdReplicaPersistReq`。target owner 再确认本地 `KvSsdStorage` 可用,然后对齐 payload、写入 shard,并通过 `SsdReplicaCommitReq` 提交 SSD route。 | 纯内存模式直接进入内存 cache;SSD commit 校验通过后 `ssd` 字段可用。竞态兜底、persist 失败或过期 commit 都不回滚第 2 阶段。 |
+
+下图按公共 `put` 操作的语义展开这三个阶段。图中省略 external 使用的 `ExternalPutStartReq`、共享内存写入和 `ExternalPutTransferEndReq` 包装层。常规路径将 `owner-put-source` 合并到 `owner-requester`;side-transfer 的分离边界见第 1.1 节。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant external_user as external-user
+ participant owner_requester as owner-requester
+ participant owner_put_target as owner-put-target
+ participant master_route as master-route
+ participant owner_ssd as owner-ssd-storage
+ participant owner_ring as owner-ssd-ring
+
+ external_user->>owner_requester: put(key, value)
+ owner_requester->>master_route: PutStartReq
+ master_route->>master_route: 读取 live member 的 kv_ssd_storage capability
+ alt 存在 SSD-capable owner
+ master_route->>master_route: target = memory allocator ∩ SSD owner;persist_to_ssd = true
+ else 不存在 SSD-capable owner
+ master_route->>master_route: target = memory allocator;persist_to_ssd = false
+ end
+ master_route->>master_route: 分配并保活 InflightPutAllocation
+ master_route-->>owner_requester: source / target node-address plan
+ alt target 与 requester/source 同 owner
+ owner_requester->>owner_requester: payload 写入本地 target range
+ else target 在其他 owner
+ owner_requester->>owner_put_target: source/staging → target range
+ end
+ owner_requester->>master_route: PutDoneReq
+ master_route->>master_route: target guard 转入 memory route;远端 source guard 释放
+
+ par 返回公共 put
+ master_route-->>owner_requester: PutDoneResp
+ owner_requester-->>external_user: put 完成,内存副本可读
+ and PutDone 后的介质处理
+ alt persist_to_ssd = false
+ master_route->>master_route: 跳过 SSD RPC;内存副本进入驱逐 cache
+ else persist_to_ssd = true
+ master_route->>master_route: 保活 target allocation 并预留 cache capacity
+ master_route->>owner_ssd: SsdReplicaPersistReq(key, put_id, target_addr, len)
+ alt owner 本地无 KvSsdStorage(capability 状态竞态兜底)
+ owner_ssd-->>master_route: SsdReplicaPersistResp(persisted = false)
+ else owner 本地 KvSsdStorage 可用
+ owner_ssd->>owner_ring: 对齐、分配 offset 并写 shard
+ alt 本地 persist 失败
+ owner_ring-->>owner_ssd: write / ring error
+ owner_ssd-->>master_route: persist error
+ else 本地 persist 成功
+ owner_ring-->>owner_ssd: Committed entry + KvSsdPersistGuard
+ owner_ssd->>master_route: SsdReplicaCommitReq
+ master_route->>master_route: 校验 key、put_id、memory 和 tomb tag
+ alt route 仍然有效
+ master_route->>master_route: 更新对应 owner entry 的 ssd 字段
+ master_route-->>owner_ssd: SsdReplicaCommitResp(OK)
+ else route 已删除或版本已变化
+ master_route->>master_route: 忽略 late commit,不更新 route
+ master_route-->>owner_ssd: SsdReplicaCommitResp(OK)
+ end
+ owner_ssd->>owner_ring: 释放 route commit pin
+ owner_ssd-->>master_route: SsdReplicaPersistResp(persisted = true)
+ end
+ end
+ master_route->>master_route: 释放临时预留,内存副本加入驱逐 cache
+ end
+ end
+```
+
+除 SSD capability 兜底外,后台路径还需要处理三类并发风险:
+
+| 风险 | 机制 | 保证 |
+| --- | --- | --- |
+| persist 期间 target 被释放或容量超卖 | `Arc` 保活 target,capacity reservation 按 allocation capacity 扣减 cache 预算。RAII guard 覆盖成功、失败、取消和 master 关闭。 | persist 始终引用有效 allocation,容量账本与生命周期一致。 |
+| 旧版本完成得更晚 | master 用 `put_id` 校验 late commit。 | 旧 SSD 副本不会进入新版本 route。 |
+| 本地已写完,master 尚未处理完 commit RPC | `KvSsdPersistGuard` 在该窗口期 pin 住 ring entry。 | commit RPC 返回前,对应 SSD bytes 不会被 ring 覆盖;返回 `OK` 不代表 route 一定已发布。 |
+
+### 3.1 route 数据结构和 SSD commit 条件
+
+`OneKvNodesRoutes` 中与写入可见性直接相关的结构如下,其他字段已省略:
+
+```rust
+pub struct OneKvNodesRoutes {
+ pub put_id: PutIDForAKey, // Bind all replicas to one key version.
+ // ...
+ pub node_replicas: RwLock>, // Index both cache layers by owner.
+ // ...
+}
+
+pub struct KvNodeReplicas {
+ pub tomb_tag: NodeTombTag, // Track this owner incarnation once.
+ pub memory: Option>, // Hold the readable memory replica when present.
+ pub ssd: Option, // Record the readable SSD replica when present.
+}
+
+pub struct KvSsdReplicaInfo {
+ pub len: u64, // Preserve the real payload length.
+}
+```
+
+`node_replicas` 中的每个 owner 只对应一个 entry:
+
+- **owner 身份**:来自 `HashMap` 的 `NodeID` key,不在 entry 内重复保存。
+- **节点实例**:`tomb_tag` 绑定本次 owner incarnation;节点离开或重启后,旧副本不能被新实例复用。
+- **介质状态**:`memory` 和 `ssd` 独立更新。内存驱逐只清空 `memory`,SSD ring 驱逐或读取失败只清空 `ssd`;两者都为空时才删除 entry。
+
+owner 本地写盘成功后仍需提交 `SsdReplicaCommitReq`。master 只在以下条件全部满足时写入 `KvSsdReplicaInfo { len }`:
+
+- 当前 `kv_routes` 仍有该 key,且 route 的 `put_id` 与请求一致。
+- 对应 owner 的内存副本仍然存在。
+- owner 的 tomb tag 仍然 live。
+
+`SsdReplicaCommitResp` 当前不区分“已发布”和“已忽略”。route 缺失、版本变化、memory 缺失或节点 tomb 时,master 会忽略 commit,但仍返回 `OK`。因此,owner 侧的 `persisted = true` 只表示本地 entry 已写入且 commit RPC 无协议错误,不能确认 master route 一定包含该 SSD 副本。
+
+SSD persist 可能晚于同 key 的后续覆盖写。`put_id` 校验会丢弃旧版本 late commit,避免它进入新版本 route。`KvSsdPersistGuard` 从本地 commit 开始保活 entry,直到 master 处理完这次 commit RPC;无论 route 最终发布还是请求被忽略,guard 都会释放。
+
+## 4. 读取:内存优先,SSD 只做回填
+
+**只有当前 key-version 没有可读内存副本时,master 才选 SSD。** 以下流程从 owner 本地 `get_cached_info` 未命中后开始。进入 route 路径后,`GetStartReq` 按下表为 `get` 操作选择 source:
+
+| 分支 | 触发条件 | payload 路径 | 完成方式 |
+| --- | --- | --- | --- |
+| 内存 | route 中存在可读 `memory` 副本。 | source 在 requester owner 时,master 复用对应的 replica guard,requester 无需搬运 payload;否则由 requester 从 `owner-memory` pull 到最终 target。 | requester 发送 `GetDoneReq`。owner 返回 holder metadata,external 再构造公共 `MemHolder`。 |
+| 本地 SSD | 没有可读内存副本,且 SSD source 就在 requester owner。 | shard 读到最终 target。满足 direct read 条件时直接写入;否则经过 owner 内部 scratch buffer 复制一次。 | requester 发送 `GetDoneReq`,后续 holder 交付与内存分支相同。 |
+| 远端 SSD | 没有可读内存副本,且 SSD source 在其他 owner。 | `owner-ssd-source` 按 chunk 读入本地 staging,每个 chunk ready 后主动 push 到 requester target。 | SSD source owner 发送 `GetDoneReq`;requester 用 `done_*` 字段构造同一套 holder metadata,再由 external 构造公共 `MemHolder`。 |
+| 未命中 | 当前 key-version 没有可读的 memory 或 SSD 副本。 | 不传输 payload。 | 返回 `KeyNotFound`。 |
+
+本文把“回填”限定为“把 SSD payload 写入 requester 的最终 target”。staging 是远端 SSD source owner 上的临时、对齐内存;它不会成为用户的 `MemHolder`。
+
+下图展开四条 route 分支、数据面 range 的位置和 master 侧 allocation 控制关系。图中已折叠 owner 本地 `get_cached_info` 未命中之前的包装层。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant external_user as external-user
+ participant owner_requester as owner-requester
+ participant master_route as master-route
+ participant owner_memory as owner-memory
+ participant owner_ssd as owner-ssd-source
+ participant owner_media as owner-local-ssd
+
+ external_user->>owner_requester: get(key)
+ owner_requester->>master_route: GetStartReq
+ master_route->>master_route: 读当前 key-version 的 node_replicas 快照
+
+ alt 存在可用 memory 副本
+ master_route-->>owner_requester: memory source + requester target plan
+ alt source 就在 requester owner
+ owner_requester->>owner_requester: 复用本地 replica range
+ else source 在其他 owner
+ owner_requester->>owner_memory: transfer_data_no_copy (pull)
+ owner_memory-->>owner_requester: payload 写入最终 target
+ end
+ owner_requester->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,必要时 promotion
+ master_route-->>owner_requester: GetDoneResp(holder_id)
+ owner_requester-->>external_user: ExternalMemHolderInfo;external 本地构造 MemHolder
+ else 无 memory 副本,但存在可用 SSD 副本
+ master_route->>master_route: 在 requester segment 分配并保活最终 target
+ alt SSD source 在其他 owner
+ master_route->>master_route: 在 SSD source segment 分配并保活对齐 staging
+ end
+ master_route-->>owner_requester: SSD source + target + stage plan
+ alt SSD source 就在 requester owner
+ owner_requester->>owner_ssd: 本地 load_and_push_kv_from_ssd 调用
+ owner_ssd->>owner_media: pin entry 并读取 shard (direct or scratch)
+ owner_media-->>owner_ssd: IO 完成,最终 target 已写入
+ owner_ssd-->>owner_requester: 本地读取完成
+ owner_requester->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,必要时 promotion
+ master_route-->>owner_requester: GetDoneResp(holder_id)
+ else SSD source 在其他 owner
+ owner_requester->>owner_ssd: SsdStageReadReq(get_id, stage, target, len)
+ loop 每个 payload chunk
+ owner_ssd->>owner_media: pin 并读入 source staging
+ owner_media-->>owner_ssd: IO 完成,staging chunk ready
+ owner_ssd->>owner_requester: push chunk 到 target offset
+ end
+ owner_ssd->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,必要时 promotion
+ master_route-->>owner_ssd: GetDoneResp(holder_id)
+ owner_ssd-->>owner_requester: SsdStageReadResp(done_*)
+ end
+ owner_requester-->>external_user: ExternalMemHolderInfo;external 本地构造 MemHolder
+ else 当前 key-version 没有可读副本
+ master_route-->>owner_requester: KeyNotFound
+ owner_requester-->>external_user: miss
+ end
+```
+
+三处实现边界需要单独说明:
+
+- **本地 SSD 回填**:`src_addr == target_addr`,控制面不申请 source staging。地址、offset 和容量满足 direct read 条件时,`KvSsdStorage` 直接写最终 target;否则内部 scratch fallback 会增加一次本地 copy。
+- **远端 SSD staging**:master 先计算 `ssd_stage_len = align_ssd_io_len(len)`,再从 SSD source owner 已注册的 segment 中额外分配 `SSD_ALIGNMENT - 1` bytes,从 allocation range 内取得 512-byte 对齐的 `src_addr`。requester 只发送一次 `SsdStageReadReq`,后续 chunk 均由 SSD owner 主动 push。
+- **陈旧 SSD route**:读取失败后先 revoke 当前 source,再重新选路;该失败不会向用户返回半成品 holder。
+
+### 4.1 target 分配与有界背压
+
+`get` target 遇到内存压力时按以下顺序处理:
+
+1. **遍历 allocator**:先尝试所有可用 allocator,不在单个 allocator 上连续空转。
+2. **推进回收**:运行该 owner 的 replica Moka cache、`inflight_puts` 和 `inflight_gets` maintenance tasks,然后立即重试。`Cache::remove()` 会让 entry 立即不可查询,但 RAII value 可能仍在维护队列中保活 allocation。maintenance 完成后,空间才真正释放。
+3. **等待外部释放**:仍无空间时,让出执行权并等待 route 删除、owner 缓存失效 RPC 或 holder ACK。重试间隔为 2 ms,总等待上限为 5 s。
+4. **有界失败**:5 s 后仍无空间才返回带 total/free capacity 的 `NoSpace`;等待不会扩大 owner 的配置容量。
+
+新 target 在传输期间尚未进入驱逐 cache,但已经占用 requester owner 的 segment。master 为可提升为内存副本的 durable target 立即预留 allocation capacity,并通过 `InflightGetInfo` 或 allocation 的 on-drop callback 保活 reservation。
+
+这份预留与异步 SSD persist、lease 共用 `cache_reserved_bytes`,避免把在途 `get` 占用误算为空闲预算。`GetRevoke`、60 秒 in-flight TTL 或 `GetStartResp` 发送失败都会释放它。
+
+temporary target 不进入 Moka。`GetDone` 后,master 的 `get_holding` 继续持有 target guard,直到 owner 侧最后一个 `MemoryInfo` 释放并向 master ACK,或 member-left 清理该 requester。requester owner 的非 cache 空间因此要容纳并发 target,以及尚未走完释放链的 temporary holder。
+
+### 4.2 `GetDone` 与 target 生命周期
+
+- **非 lease 的 durable promotion 成功**:master 先更新 route,再释放独立的临时预留,并在同一次请求处理中按相同 weight 插入 Moka,避免 route 与容量索引不同步。
+- **带 lease 的 durable promotion 成功**:target 不进入 Moka;reservation 已绑定到 allocation 的 on-drop callback,随该 leased replica 的 allocation 一起存活。
+- **无法 promotion**:route 版本变化、节点 tomb 或 route 已删除时,target 降级为 temporary holder 并释放 durable slot。非 lease 路径的独立 reservation 随 `InflightGetInfo` 释放;lease 路径的 reservation 已绑定 allocation,会保留到 temporary holder 释放。
+- **所有成功路径**:`GetDone` 都把 requester target guard 写入 master 的 `get_holding`;远端 SSD source staging 和 memory source 的额外保活引用随 `InflightGetInfo` 释放。
+
+与 allocation 生命周期直接相关的字段如下:
+
+```rust
+pub struct InflightGetInfo {
+ pub put_id: PutIDForAKey, // Guard the selected key version.
+ pub src_node_id: NodeID, // Identify the selected source owner.
+ pub allocation: Arc, // Hold the requester target until GetDone.
+ pub source_allocation: Option>, // Keep a memory source or remote SSD staging alive.
+ pub route: Arc, // Keep the route and its durable slot alive.
+ pub allocation_mode: GetAllocationMode, // Decide whether promotion is allowed.
+ pub source_kind: GetSourceKind, // Select memory or SSD cleanup semantics.
+ pub cache_capacity_reservation: Option>, // Reserve durable target capacity.
+ // ... Other request identity and length fields.
+}
+```
+
+`InflightGetInfo` 的核心作用是在一次 `get` 内同时保活 target、source 和 route:
+
+- **`allocation`** 始终表示 requester segment 中的 target;`GetDone` 后由 `OwnerHoldingGetInfo` 继续保活。用户侧 `MemHolder` 引用这段 bytes,但不持有这个 master 侧 Rust 对象。
+- **`source_allocation`** 是 master 侧的保活引用:memory 路径用它保活内存副本,远端 SSD 路径用它保活 staging,本地 SSD 回填时为 `None`。`GetDone`、`GetRevoke` 或 in-flight TTL 清理都会释放该引用。
+- **`route` 与容量预留** 共同保证 durable promotion 期间的 route 生命周期和容量预算。非 lease 成功路径在同一次请求中更新 route、释放独立 guard 并插入 Moka;lease 路径则由 allocation callback 延续 reservation。
+- **`put_id`、`src_node_id` 和 `source_kind`** 限定失败清理范围,避免撤销新版本 route 或错误的介质副本。
+
+external 公共 `get` 的 holder 交付跨越三层对象。只有 master 的 `get_holding` 持有 allocation guard,不能把三层对象统称为“holder 持有 allocation”:
+
+| 层级 | 实际持有 | 正常释放链 |
+| --- | --- | --- |
+| master | `get_holding[(requester_owner_id, holder_id)]` 中的 `OwnerHoldingGetInfo`,内部持有 requester target 的 `Arc`。 | owner 的 delete-ack batch 通过 `BatchDeleteAckReq` 到达后移除;requester member-left 时也会批量清理。 |
+| requester owner | `external_get_holding[(external_client_id, holder_id)]` 中的 `Arc`。它引用 target metadata,未持有 master 进程里的 Rust guard。 | external holder 释放后收到 `ExternalDeleteAckReq` 并移除该引用;owner 侧最后一个 `MemoryInfo` 释放时把记录放入 delete-ack batch,再由 `BatchDeleteAckReq` 通知 master。`get_cached_info` 若仍有引用,master holding 会继续保留。 |
+| external process | 根据共享内存 base、`ExternalMemHolderInfo.offset/len` 和 `holder_id` 构造的公共 `MemHolder`。 | 对象释放时向 requester owner 发送 `ExternalDeleteAckReq`;它不直接访问或释放 master 的 `Allocation` 对象。 |
+
+holder 释放只移除 `get_holding` 这条引用。`ReuseReplica` 或 durable promotion 成功后,route 的 `memory` 仍持有独立的 `Arc`;该副本要经过缓存驱逐或 route 清理才会释放。temporary target 没有这条 route 引用。
+
+## 5. 读取子步骤——回填传输:本地复用 target,远端 source 主动 push
+
+上一节已经确定了读取 source、requester target 及其生命周期;进入实际搬运阶段后,memory 与 SSD 链路在 source 形态上分开。memory route 指向 owner 已注册 memory segment 中的现成 bytes,requester 取得 source 和 target 地址后即可发起 pull。SSD route 指向 shard 文件中的 entry,磁盘内容还不是 transfer engine 可直接使用的内存 source,必须先由 SSD owner 完成本地读盘。
+
+最终 target 始终位于 requester owner 的 memory segment,并由 master 侧 guard 保活。当 SSD owner 与 requester owner 为同一节点时,本地 IO 可直接写入 target。当两者不同时,本地 IO 无法跨 owner 写入 segment。此时,SSD owner 先用本地 source staging 接收 chunk,待 chunk ready 后主动 push 到远端 target。这个方向既由 SSD IO 与跨节点传输的边界决定,也让远端路径可以重叠读盘和网络传输。
+
+### 5.1 本地回填复用最终 target
+
+SSD owner 与 requester owner 在同一节点时,master 令 `src_addr == target_addr`,并把 target allocation 的实际 capacity 放进 `ssd_stage_len`。owner 随后调用 `load_into_addr`:满足 direct read 条件时,读盘直接写 target;否则由 `KvSsdStorage` 通过 scratch buffer 完成。两条分支都不需要控制面申请独立 source allocation。
+
+```rust
+if peer_id.is_none() && stage_addr == target_addr {
+ return store
+ .load_into_addr(
+ key, // Select the logical KV entry.
+ put_id, // Select the exact committed key version.
+ target_addr, // Read into the final range in the requester segment.
+ len, // Expose only the real payload length.
+ stage_len, // Provide the target allocation capacity for aligned IO.
+ )
+ .await;
+}
+```
+
+### 5.2 远端传输方向
+
+两条远端路径的数据都从 source 流向 requester target,差别在 transfer 发起方:
+
+- **内存:requester pull**:requester 已从 `GetStartResp` 取得远端 `src_addr` 和本地 `target_addr`,由它发起从 peer source 到本地 target 的 transfer。
+- **SSD:owner push**:requester 只发一次 `SsdStageReadReq`。SSD owner 每读好一个 chunk 就 push 到 requester target,省去 stage-ready 后的一次 RTT,并让读盘与网络传输重叠。
+
+`transfer_data_no_copy` 的方向位决定 peer 端地址在本次传输中是 source 还是 target。该参数只描述数据方向,不表示 peer 持有 master 侧的 `Allocation` guard。下面是删减后的接口:
+
+```rust
+pub async fn transfer_data_no_copy(
+ &self, // Use this node's transfer engine.
+ peer_node: Option, // Select the remote peer; None means local transfer.
+ peer_src_or_target: bool, // True: peer address is source; false: it is target.
+ src_addr: u64, // Provide the absolute source address.
+ target_addr: u64, // Provide the absolute target address.
+ len: u64, // Transfer this many real payload bytes.
+ seg_guard: Option, // Optionally keep the local segment alive.
+) -> KvResult;
+```
+
+方向位同时决定 peer 地址的语义和本地 segment guard:
+
+- **`true`**:peer 地址是 source,当前节点 pull 到本地 target;owner 侧 segment guard 守住 `target_addr`。
+- **`false`**:peer 地址是 target,当前节点从本地 source push;owner 侧 segment guard 守住 `src_addr`。
+
+远端 memory get 和远端 SSD 回填分别使用这两个方向:
+
+```rust
+// Memory get: requester owner initiates transfer with peer as source.
+client_transfer_engine
+ .transfer_data_no_copy(
+ Some(memory_owner_id), // Contact the owner that holds the memory replica.
+ true, // Interpret the peer address as the source.
+ remote_src_addr, // Read from the replica range in the peer's segment.
+ requester_target_addr, // Write into the requester's local target.
+ len, // Move only the real payload bytes.
+ None, // Let the engine acquire the local target guard.
+ )
+ .await?;
+
+// SSD refill: SSD owner pushes a ready chunk to requester target.
+let chunk_target_addr = requester_target_addr
+ .checked_add(chunk.offset) // Place this chunk at its payload offset.
+ .ok_or_else(|| KvError::Api(ApiError::InvalidArgument { // Convert overflow into a KV error.
+ detail: "chunk target addr overflow".to_string(), // Explain which address calculation failed.
+ }))?;
+
+client_transfer_engine
+ .transfer_data_no_copy(
+ Some(requester_owner_id), // Contact the requester whose segment contains the target.
+ false, // Interpret the peer address as the target.
+ chunk.stage_addr, // Read from this SSD owner's ready staging chunk.
+ chunk_target_addr, // Write to the matching offset in requester target.
+ chunk.len, // Move this chunk's real payload length.
+ None, // Let the engine acquire the local source guard.
+ )
+ .await?;
+```
+
+只有远端 SSD 回填会发送 `SsdStageReadReq`。请求把 key-version、`get_id`、staging 地址与容量、requester target 位置和真实 payload 长度交给 SSD owner:
+
+```rust
+pub struct SsdStageReadReq {
+ pub key: String, // Identify the logical KV entry.
+ pub put_id: PutIDForAKey, // Reject data from a different key version.
+ pub get_id: u64, // Bind the stage operation to its in-flight get.
+ pub stage_addr: u64, // Point to aligned staging on the SSD owner.
+ pub stage_len: u64, // State the available aligned staging capacity.
+ pub target_node_id: NodeIDString, // Identify the requester whose segment contains the target.
+ pub target_addr: u64, // Point to the final target range in that segment.
+ pub len: u64, // Preserve the real payload length.
+}
+```
+
+### 5.3 chunk pipeline 与背压
+
+远端 SSD owner 把读盘和传输拆成两个并发 future:
+
+- **producer**:按 chunk 从 SSD 读到 source staging。
+- **consumer**:收到 ready chunk 后立刻 push 到 requester target。
+
+两者通过有界 `mpsc` ready queue 连接。默认 chunk 大小为 4 MiB;producer 最多并发 4 个 SSD read,consumer 最多保留 4 个 transfer inflight,ready queue 容纳 8 个已就绪 chunk 描述。第一个 chunk 读好后即可开始网络传输。
+
+#### producer / consumer 实现片段
+
+```rust
+let ready_queue_capacity = DEFAULT_READ_TRANSFER_PIPELINE_INFLIGHT
+ .saturating_mul(2) // Buffer two transfer windows between reader and sender.
+ .max(1); // Keep the bounded channel valid for any configured window.
+let (chunk_tx, chunk_rx) =
+ ::tokio::sync::mpsc::channel(ready_queue_capacity); // Connect producer and consumer.
+
+let producer = store.load_into_addr_chunks(
+ key, // Select the logical KV entry.
+ put_id, // Select the exact committed version.
+ stage_addr, // Read chunks into aligned source staging.
+ len, // Stop after the real payload length.
+ stage_len, // Enforce the staging allocation capacity.
+ DEFAULT_READ_TRANSFER_PIPELINE_CHUNK_BYTES, // Bound each SSD read to 4 MiB.
+ DEFAULT_READ_TRANSFER_PIPELINE_INFLIGHT, // Allow up to four concurrent SSD reads.
+ chunk_tx, // Publish each ready chunk to the sender.
+);
+
+let consumer = self.transfer_loaded_ssd_chunks(
+ peer_id, // Select the remote requester; None means the target is local.
+ target_addr, // Use the final target range in the requester segment.
+ chunk_rx, // Consume chunks as soon as their SSD reads complete.
+);
+let (producer_res, consumer_res) = ::tokio::join!(
+ producer, // Run SSD reads concurrently with network transfers.
+ consumer, // Push ready chunks without waiting for the full value.
+);
+match (producer_res, consumer_res) {
+ (Ok(()), Ok(())) => Ok(()),
+ (_, Err(err)) => Err(err),
+ (Err(err), _) => Err(err),
+}
+```
+
+consumer 只在 transfer inflight 少于 4 时从 ready queue 取新 chunk。达到上限后,有界 queue 会把背压传给 producer;已提交的 transfer 完成后,consumer 再继续取 chunk。
+
+这条 pipeline 中有三种不同的保活机制:
+
+| 机制 | 保护对象 | 释放时点 |
+| --- | --- | --- |
+| ring read pin | SSD entry 与文件 offset,防止尚未完成的 SSD read 被 tail 覆盖。 | producer 完成全部 SSD read,并把最后一个 ready chunk 送入有界 queue 后。 |
+| `InflightGetInfo.source_allocation` | master 侧 guard,保活 SSD owner segment 中的整块 source staging range。 | `GetDone`、`GetRevoke` 或 in-flight TTL。 |
+| transfer segment guard | 某个在途 push 使用的本地 staging 地址范围。 | 对应 chunk transfer 完成。 |
+
+ring read pin 只覆盖 SSD 读取阶段,不覆盖整个网络 push 窗口。producer 释放 pin 后,尚未完成的 push 由 master 侧 staging guard 和每次 transfer 的 segment guard 保护。
+
+下图聚焦远端 SSD 回填内部的 pipeline;`GetStart/GetDone` 的完整流程见第 4 节。`owner-requester-target` 只表示数据传输端点,其 allocation 生命周期仍由 master 的 in-flight 状态和 `get_holding` 管理。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant owner_reader as owner-ssd-reader
+ participant owner_media as owner-local-ssd
+ participant owner_queue as owner-ready-queue
+ participant owner_sender as owner-ssd-sender
+ participant owner_target as owner-requester-target
+
+ owner_reader->>owner_media: pin committed ring entry
+ par read producer
+ loop 按 4 MiB chunk 读取
+ owner_reader->>owner_media: read chunk N into source staging
+ owner_media-->>owner_reader: IO complete, chunk N ready
+ owner_reader->>owner_queue: enqueue chunk N
+ end
+ owner_reader->>owner_media: 最后一个 ready chunk 入队后 release ring pin
+ and transfer consumer
+ loop 按 payload offset 推送
+ owner_queue-->>owner_sender: deliver chunk N
+ owner_sender->>owner_target: push chunk N
+ owner_target-->>owner_sender: transfer complete
+ opt transfer inflight 已达 4
+ owner_sender->>owner_queue: pause dequeue
+ owner_queue-->>owner_reader: bounded-channel backpressure
+ end
+ end
+ end
+ Note over owner_sender,owner_target: 未完成的 push 继续由 master staging guard 和 transfer guard 保活
+```
+
+## 6. owner 本地机制:对齐、ring 和 IO 调度
+
+**SSD 介质的控制引擎留在 owner。** SSD 文件 offset、`O_DIRECT` 文件 IO 对齐、ring 覆盖保护和 `io_uring` 调度都由 owner 处理。master 仍负责分配 requester target 和跨节点 staging,并传递相应地址、容量与真实 payload 长度。用户只看到真实 payload 语义。
+
+master 使用已注册的 owner segment allocator 为每次回填分配 requester target,并创建对应 guard;跨节点回填还会分配 SSD source staging。bytes 位于相应 owner 的 segment,RAII guard 位于 master。master 不记录 SSD 文件布局,用户侧仍通过 `MemHolder` 访问真实 payload。
+
+### 6.1 写入:区分真实长度和对齐长度
+
+**`len` 是 payload 长度,`aligned_len` 是 owner 本地 IO 长度。** target owner 从请求携带的 target 地址复制 payload,再构造 512-byte 对齐的写入 buffer。route 中的 `Arc` 在此期间保活 target 地址范围。
+
+| 字段 | 含义 | 使用范围 |
+| --- | --- | --- |
+| `len` | 真实 payload 长度。 | master route、`SsdReplicaCommitReq`、transfer 长度和 `MemHolder.len`。 |
+| `aligned_len` | `align_up(len, 512)` 得到的对齐长度。 | owner 本地 ring 分配、shard offset 推进和 `O_DIRECT` read/write。 |
+
+例如,1000-byte payload 的 `aligned_len` 是 1024 bytes:
+
+1. owner 创建 1024-byte 的对齐 `AlignedBuffer`,先置零,再把前 1000 bytes 填入 payload。
+2. ring 分配、shard offset 推进和 SSD 写入都使用 1024 bytes。
+3. `SsdIndexEntry` 同时记录 `len = 1000` 和 `aligned_len = 1024`;owner 向 master 提交的仍是 `len = 1000`。
+4. 后续读取会校验 `entry.len`,transfer 和 `MemHolder` 只暴露前 1000 bytes。末尾 24 bytes padding 留在 owner 内部。
+
+### 6.2 ring:从 `Writing` 到 `Committed`
+
+**只有完整写入的 `Committed` entry 才能参与读取;写入、route 提交或读取期间,ring 不会覆盖对应区间。**
+
+本地 SSD 引擎按 value 粒度选路:
+
+- 用 `next_write_device` round-robin 选择一个有效 device。
+- 把整个 value 发送到选中 device 的 writer queue。
+- 当前实现不把同一个 payload 拆到多块 device 做条带化。
+
+因此,多 device 并行发生在 value 粒度:不同 value 可以落到不同 device;一个 value 内部仍是某个 shard 的连续 offset。
+
+owner 本地 `SsdRingBuffer` 决定 SSD 文件位置。writer task 只在本 device 的 `shard_ids` 里选择 shard,并为对齐后的 payload 分配连续文件区间。ring 按以下规则分配:
+
+- `aligned_len = align_up(len, 512)`,ring 按对齐后长度分配空间。
+- 每个 shard 是环形空间,`head` 前进分配新写入,`tail` 表示可以覆盖到哪里。
+- 如果当前 shard 尾部剩余空间不足,会跳到文件开头继续找连续空间。
+- 如果推进 `tail` 会覆盖未完成写入、active read pin 或 route commit pin,返回 `BlockedByBusyIo`。
+
+分配完成后,状态转换、覆盖保护和 route 清理遵循以下规则:
+
+- **`Writing`**
+ - offset 已分配,SSD 写入尚未成功。
+ - 默认单 buffer 路径使用 `O_DIRECT` 和 `io_uring` write。
+- **`Committed`**
+ - 只有实际写入字节数等于 aligned buffer 长度,entry 才从 `Writing` 转为 `Committed`。
+ - `pin_read` 只接受 committed 且 offset 仍有效的 entry,未完成写入不会被 `get` 选中。
+- **ring 覆盖保护**
+ - route commit pin:保护本地 commit 到 master 处理完 commit RPC 的窗口。
+ - read pin:保护 SSD read 使用 entry 与 file offset 的窗口;远端 push 后半段由 staging allocation 和 transfer guard 保活。
+- **tail 驱逐通知**
+ - tail 推进时,ring 返回本次被覆盖的全部 committed `KvSsdKey`。
+ - writer 把它们送入有界失效队列,不在 SSD 写入热路径逐条调用 master。
+ - owner batch task 按 256 条或 10 ms 的阈值聚合 RPC;第 7 节展开 master 的条件清理和读取兜底。
+
+下图展示 owner 本地 `Writing → Committed` 与 master 发布 SSD route 之间的时序。`owner-memory-target` 只有前 `len` bytes 是真实 payload,其地址范围由 master route 中的 `Arc` 保活。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant master_route as master-route
+ participant owner_target as owner-memory-target
+ participant owner_writer as owner-ssd-writer
+ participant owner_ring as owner-ssd-ring
+ participant owner_engine as owner-io-engine
+ participant owner_media as owner-local-ssd
+
+ master_route->>owner_writer: SsdReplicaPersistReq(key, put_id, target_addr, len)
+ owner_writer->>owner_target: copy first len bytes
+ owner_target-->>owner_writer: real payload
+ owner_writer->>owner_writer: zero padding and build aligned_len buffer
+ owner_writer->>owner_ring: reserve contiguous aligned_len bytes
+ owner_ring-->>owner_writer: shard_id + file_offset + Writing entry
+ owner_writer->>owner_engine: O_DIRECT write(aligned buffer)
+ owner_engine->>owner_media: submit aligned_len bytes
+ owner_media-->>owner_engine: write completion(actual bytes)
+ owner_engine-->>owner_writer: completion
+
+ alt actual bytes == aligned_len
+ owner_writer->>owner_ring: Writing → Committed and acquire route commit pin
+ owner_ring-->>owner_writer: KvSsdPersistGuard
+ owner_writer->>master_route: SsdReplicaCommitReq(key, put_id, len)
+ master_route->>master_route: validate, then publish or ignore commit
+ master_route-->>owner_writer: SsdReplicaCommitResp
+ owner_writer->>owner_ring: release route commit pin
+ owner_writer-->>master_route: SsdReplicaPersistResp(persisted = true)
+ else partial write or IO failure
+ owner_writer->>owner_ring: abort Writing entry
+ owner_writer-->>master_route: SsdReplicaPersistResp(error)
+ end
+```
+
+### 6.3 读取:direct read 和 scratch fallback
+
+**direct read 是有条件的 owner 本地 fast path;任一对齐或容量条件不满足时,scratch read 负责兜底。**
+
+每个 chunk 的文件位置由 `entry.file_offset + offset` 得到。本地回填读入 requester target;远端回填读入 SSD source staging。
+
+| 路径 | 选择条件 | owner 本地动作 | 复制边界 |
+| --- | --- | --- | --- |
+| direct read | 读入地址和文件 offset 都按 512 bytes 对齐,target capacity 覆盖对齐后的 IO 范围。按 chunk 读取时,当前 read 长度也必须对齐。 | `io_uring` 直接把 shard 内容读入本地目标地址。 | 这段 SSD-to-target 读取没有额外的 owner 本地 copy;这不代表整条 `get` 链路都是零拷贝。 |
+| scratch read | 任一 direct read 条件不满足。 | 先读入 512-byte 对齐的 `AlignedBuffer`,再把当前 chunk 的真实 payload 复制到目标地址。 | 多一次 owner 本地 CPU copy。 |
+
+两条路径都只向下游暴露真实 payload 长度。`O_DIRECT` padding 不进入 transfer 长度,也不进入用户 `MemHolder.len`。
+
+下图只画 SSD source owner 内部的读盘。`owner-read-target` 始终位于 SSD source owner 本地:本地回填时,它同时是 requester target;远端回填时,它是 source staging。远端 requester 的最终 target 不在图中,后续 push 见第 5.3 节。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant owner_reader as owner-ssd-reader
+ participant owner_ring as owner-ssd-ring
+ participant owner_engine as owner-io-engine
+ participant owner_media as owner-local-ssd
+ participant owner_scratch as owner-scratch-buffer
+ participant owner_target as owner-read-target
+
+ owner_reader->>owner_ring: pin committed entry and request chunk plan
+ owner_ring-->>owner_reader: file_offset + len + aligned read len
+ owner_reader->>owner_reader: check address, offset, length, and capacity alignment
+
+ alt direct read 条件全部成立
+ owner_reader->>owner_engine: read shard directly into owner-read-target
+ owner_engine->>owner_media: submit O_DIRECT read
+ owner_media-->>owner_engine: IO complete, target memory filled
+ owner_engine-->>owner_reader: completion
+ owner_reader-->>owner_target: publish current chunk's real len
+ else 任一对齐条件不成立
+ owner_reader->>owner_scratch: acquire aligned buffer
+ owner_reader->>owner_engine: read shard into owner-scratch-buffer
+ owner_engine->>owner_media: submit O_DIRECT read
+ owner_media-->>owner_engine: IO complete, scratch memory filled
+ owner_engine-->>owner_reader: completion
+ owner_reader->>owner_target: copy only current chunk's real payload bytes
+ end
+ owner_reader->>owner_ring: release read pin
+ Note over owner_reader,owner_target: transfer 和 MemHolder 只看到真实 len,padding 留在 owner 内部
+```
+
+### 6.4 IO 调度:让回填读优先,同时保留后台写进展
+
+**同一个 device 上,SSD 回填读位于 `get` 的同步等待路径,SSD persist 写则在内存副本发布后异步进行。** 因此当前调度给读更多的 SQE 提交机会,但仍持续推进写。写长期不进展会延迟 SSD 副本 commit,并让 `Writing` entry 和对齐 buffer 继续占用资源;持续读下的绝对读优先还可能拖住 ring 后续复用空间。`read_weight = 2` 表达的是这种有界的读偏置。
+
+调度分成两层,各自处理不同问题:
+
+| 层次 | 当前机制 | 设计原因 |
+| --- | --- | --- |
+| device 任务层 | 每个去重后的 device 独立持有 writer queue、reader queue 和 `UringIoEngine`。writer task 管理 ring 分配以及 `Writing -> Committed/abort`;reader task 管理 chunk read 和 completion 复查。 | 在当前 owner 本地调度范围内,实际 device 是 IO 竞争边界。独立队列避免忙 device 在软件调度层阻住空闲 device,也让读写使用不同的上层并发上限和状态机。 |
+| `io_uring` 提交层 | 每个 `io_uring` 线程各有 read/write channel,在 `io_depth` 内选择下一个 SQE。 | 分开 channel 后,引擎可以在真正占用 SQE 槽位时仲裁读写,避免后到的回填读只能排在单一 FIFO 中的后台写之后。 |
+
+`io_depth` 在设备并行度与在途资源占用之间设置上界:多个已提交 SQE 可以让 SSD 持续有工作可做,上限则避免单个 `io_uring` 线程无界占用 buffer 和 completion context。真正能到达这一层的并发量,还会受 writer/reader task 各自 inflight 上限的约束。
+
+`read_weight = 2` 只在第二层生效。每个 `io_uring` 线程独立维护 `read_inflight` 和 `write_inflight`:前者小于等于后者的两倍时,先尝试 read channel;超过后,先尝试 write channel。首选 channel 为空时会立即取另一个 channel 的任务,不会为了维持比例而让 SQE 槽位空闲。
+
+这个 `2:1` 是固定的软调度偏置,不是硬上限或队列积压阈值。等号分支会再提交一个 read,completion 速率也会持续改变 inflight 组成;因此它不保证单个 device 上的精确 `2:1` 比例。它也不考察队列长度、请求大小、等待时间或 deadline。权重只决定下一个尚未提交的 SQE;已提交的 IO 不会被抢占,`io_depth` 已满时,新的回填读仍需等待 CQE 释放槽位。所以这是一个低成本的提交偏置,不构成读延迟 SLA。
+
+**`SingleBuffer` 是对当前数据形态的特化。** 写路径已经把 payload 放入连续的对齐 `AlignedBuffer`;direct read 和 scratch read 的目标也都是一段连续内存。默认 `KvSsdUringMode::SingleBuffer` 因而直接提交 `IORING_OP_READ` / `IORING_OP_WRITE`,避免构造并保活只含一个元素的 iovec。`Iovec` 保留为测试和对照路径。这项特化没有消除 `O_DIRECT` 对齐、padding、scratch copy 或网络传输,其收益范围仅限于 owner 本地 IO 提交层。
+
+read completion 复查与 read pin 也承担不同职责:
+
+| 机制 | 作用时段 | 职责 |
+| --- | --- | --- |
+| read pin | 从获取 committed entry 到本次读取结束。 | 阻止 `tail` 越过正在使用的文件区间,预防 offset 在 IO 期间被复用。 |
+| completion 复查 | CQE 返回后。 | 检查读取开始时捕获的 `entry.begin` 是否仍不早于当前 `tail`。已失效时删除 entry 并返回 `KeyNotFound`。 |
+
+read pin 负责预防覆盖,completion 复查负责拦住异常状态下的结果发布。复查无法撤销已经写入 target 的 IO,因而不能取代 pin;它是跨过异步提交边界后的最后一道不变量检查。在完整 `get` 路径中,返回错误后的 SSD stage 会进入第 7.2 节的 revoke 与有界重新选路,不会向用户交付这次 target。
+
+下图聚焦单个 device 内部的调度,不涉及 `master-*` 或 `external-*` 角色。`owner-kernel-io` 表示该 owner 文件上的 `io_uring` SQE/CQE 交互,不表示 owner 独占内核。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant owner_write as owner-write-path
+ participant owner_read as owner-read-path
+ participant owner_engine as owner-io-engine
+ participant owner_kernel as owner-kernel-io
+ participant owner_media as owner-local-ssd
+ participant owner_ring as owner-ssd-ring
+
+ par write task arrives
+ owner_write->>owner_ring: reserve offset and create Writing entry
+ owner_ring-->>owner_write: shard_id + file_offset
+ owner_write->>owner_engine: enqueue write request
+ and read task arrives
+ owner_read->>owner_ring: pin committed entry and resolve offset
+ owner_ring-->>owner_read: read plan + pin
+ owner_read->>owner_engine: enqueue read request
+ end
+
+ loop while this io_uring thread has a free SQE slot
+ owner_engine->>owner_engine: choose next unsent IO by the per-thread inflight mix, then fall back to the other channel
+ Note over owner_engine,owner_kernel: read_weight does not preempt submitted SQEs
+ alt write request selected
+ owner_engine->>owner_kernel: submit WRITE SQE
+ owner_kernel->>owner_media: O_DIRECT write
+ owner_media-->>owner_kernel: device completion
+ owner_kernel-->>owner_engine: write CQE
+ owner_engine-->>owner_write: actual bytes or error
+ owner_write->>owner_ring: commit or abort Writing entry
+ else read request selected
+ owner_engine->>owner_kernel: submit READ SQE
+ owner_kernel->>owner_media: O_DIRECT read
+ owner_media-->>owner_kernel: device completion
+ owner_kernel-->>owner_engine: read CQE
+ owner_engine-->>owner_read: actual bytes or error
+ owner_read->>owner_ring: check captured entry.begin is not before current tail
+ alt captured range 仍有效
+ owner_ring-->>owner_read: range valid, return chunk bytes
+ else captured range 已失效
+ owner_ring-->>owner_read: stale
+ owner_read->>owner_ring: remove stale entry
+ owner_read->>owner_read: return KeyNotFound
+ end
+ end
+ end
+```
+
+## 7. 一致性:沿用 key-version 和 holder 生命周期
+
+**SSD 副本只有在 master route 和 owner ring 都有效时才可读。** `get_holding` 保存的是 requester target 的 master 侧 `Allocation` guard;远端 source staging guard 只保留在本次 `InflightGetInfo` 中。
+
+一次 SSD 回填的完成语义包含以下四个方面:
+
+- **`GetDoneReq` 调用方**
+ - 本地 SSD source:requester 读盘并完成 target 写入后调用。
+ - 远端 SSD source:SSD owner 读盘并 push 到 requester target 后调用。
+- **最终 holder 与 allocation 生命周期**
+ - `inflight_gets.req_node_id` 始终指向 requester owner,不受 `GetDoneReq` 调用方影响。
+ - master 把 requester target guard 写入 `get_holding` 并生成 `holder_id`;target bytes 仍位于 requester owner 的 segment。
+ - 远端 source staging guard 不进入 `get_holding`,只随 in-flight get 生命周期释放。
+- **远端响应转发**
+ - SSD owner 把 `GetDoneResp` 中的 `holder_id`、`allocation_mode`、错误码和 server time 写入 `SsdStageReadResp.done_*`。
+ - requester 使用这些字段构造 owner 侧 `MemoryInfo` 和 `ExternalMemHolderInfo` 响应。external 再构造公共 `MemHolder`,用户看不到 SSD stage RPC。
+- **durable promotion**
+ - `GetStart` 尝试预留 durable slot。成功时 `allocation_mode = DurableReplica`,否则为 `Temporary`。
+ - `GetDone` 只在 allocation mode 为 durable、route `put_id` 仍匹配、且 requester tomb tag 仍 live 时,才把 target guard 写入 entry 的 `memory`。
+ - promotion 成功后,SSD 回填结果成为新的内存副本。
+
+### 7.1 owner 驱逐后批量清理 route
+
+为了让 master 中因物理驱逐产生的陈旧 route 尽快收敛,同时尽量减少逐条通知产生的控制面通信,owner 先完成物理驱逐,再聚合对应的 key-version,批量通知 master 清理逻辑 route:
+
+- **驱逐输入**:ring 推进 `tail` 时,一次返回本轮覆盖掉的全部 committed key-version。
+- **batch 触发**
+ - 第一条记录到达后,最多等待 10 ms。
+ - 累计到 256 条时立即发送。
+- **RPC 失败**:当前 batch 留在 task 中重试。
+- **队列或控制面不可用**
+ - 有界队列满、进程退出或 master 持续不可用,都不阻塞 SSD 写入。
+ - 可能残留的陈旧 route 由第 7.2 节的读取兜底处理。
+
+批量 RPC 只携带待清理的 key-version:
+
+```rust
+pub struct SsdReplicaEviction {
+ pub key: String, // Identify the route entry to inspect.
+ pub put_id: PutIDForAKey, // Restrict cleanup to the evicted key version.
+}
+
+pub struct BatchSsdReplicaEvictReq {
+ pub evictions: Vec, // Carry one owner-side eviction batch.
+}
+```
+
+master 收到 batch 后按以下规则清理 route:
+
+- **owner 身份**:请求不重复传 `node_id`,master 直接使用 RPC 发送方身份。
+- **版本门禁**:只有当前 route `put_id` 与驱逐项匹配,master 才清除该 owner entry 的 `ssd`。
+- **清理粒度**
+ - entry 仍有内存副本:只清除 `ssd`。
+ - `memory` 和 `ssd` 都为空:删除 owner entry。
+ - 整个 key-version 没有 live 副本:删除 `kv_routes`。
+- **prefix index**
+ - 在一次写锁内批量清理。
+ - 同名 key 已写入新 route:保留新版本 prefix entry。
+- **master 职责边界**:master 不参与 SSD offset 分配,也不决定本地驱逐顺序;只接收 owner 的驱逐结果并收敛逻辑 route。
+
+### 7.2 陈旧 SSD route 的读取兜底
+
+**SSD stage 失败后,requester 先撤销当前 source,再有界重新选路。** 重新选路可以继续使用其他 live source;没有副本时返回 miss。连续 3 次 SSD stage 失败后返回 error。任何失败分支都不会向用户暴露半成品 holder。
+
+SSD stage 失败时,请求方会调用:
+
+```rust
+GetRevokeReq {
+ get_id, // Revoke this in-flight get and release its allocations.
+ drop_ssd_source: true, // Clear the failed SSD option from the current route.
+}
+```
+
+master 按下列顺序撤销失败 source:
+
+- **释放本次 in-flight**:先删除 `inflight_gets`,让 target 和可选 staging 进入释放路径。
+- **限定 route 删除条件**
+ - `drop_ssd_source == true`。
+ - `source_kind == GetSourceKind::Ssd`;memory source 不会因该标志被删除。
+ - route `put_id` 与本次 in-flight get 仍一致;不撤销新版本 route。
+- **清理失败 source**
+ - 把 `src_node_id` 对应 entry 的 `ssd` 设为 `None`。
+ - entry 也没有内存副本:删除该 entry。
+ - 整个 key-version 已无 live 副本:删除 `kv_routes`;启用 prefix index 时,再异步清理对应索引。
+ - 失败 source 清理与主动 batch 驱逐共用同一个条件删除函数。
+- **重新选路**
+ - `GetRevokeResp` 返回后,请求方重新发送 `GetStartReq`。
+ - 下一次可能命中另一份内存副本、SSD 副本、同 key 新版本,或返回 miss。
+ - 陈旧 SSD source 最多触发 3 次重新选路,避免无限循环。
+ - SSD stage RPC 失败和不合法的 `ssd_stage_len` 都进入同一套 revoke 与重选流程。
+- **allocation 与崩溃清理**
+ - 远端 staging 保存在 `source_allocation`;本地回填时该字段为 `None`。
+ - `GetDone`、`GetRevoke` 或 in-flight TTL 驱逐都会释放远端 staging。
+ - requester 在 in-flight 阶段崩溃:节点 tomb 阻止副本 promotion 和旧节点复用。`inflight_gets` 最迟由 60 秒 TTL 释放 target/staging guard。
+ - requester 在 `GetDone` 后离开:master 的 member-left 清理移除该节点的 `get_holding`;正常路径则由 owner 的 holder delete-ack batch 释放。
+ - 远端 SSD owner 崩溃:其进程内 staging bytes 消失。master 中对应的 staging guard 随 `GetRevoke` 或 in-flight TTL 释放,节点 tomb 使旧 SSD route 不再可选。
+
+在 master 接受 `GetDoneReq` 之前发生的读取或传输失败会走 `GetRevoke`,target guard 不进入 `get_holding`,用户也不会拿到半成品 payload。
+
+另一个边界发生在 master 已提交 `GetDone`、但响应随后丢失时。此时 target guard 已写入 `get_holding`,requester 却可能没有收到 `holder_id`。
+
+当前通用 holder 协议没有按 `get_id` 幂等查询已提交结果的接口。这类未交付 holder 无法由随后的 `GetRevoke` 立即回收,只能等待能够识别该 holding 的后续清理,例如 requester member-left。这不影响 SSD 数据完整性,但属于当前完成协议的资源回收边界。
+
+下图展示成功完成、失败 revoke 和超时清理三条路径。远端路径由 `owner-ssd-source` 代替 requester 调用 `GetDoneReq`,但最终 target 始终位于 requester segment。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant external_user as external-user
+ participant owner_requester as owner-requester
+ participant master_route as master-route
+ participant owner_ssd as owner-ssd-source
+
+ owner_requester->>owner_ssd: SsdStageReadReq 或本地 stage 调用
+ alt SSD stage 成功,target 已完整
+ owner_ssd-->>owner_requester: 本地回填完成或最后一个 chunk 已 push
+ alt SSD source 就在 requester owner
+ owner_requester->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,释放其他 in-flight 引用
+ master_route-->>owner_requester: GetDoneResp(holder_id)
+ else SSD source 在其他 owner
+ owner_ssd->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,释放 staging 和其他 in-flight 引用
+ master_route-->>owner_ssd: GetDoneResp(holder_id, allocation_mode)
+ owner_ssd-->>owner_requester: SsdStageReadResp(done_*)
+ end
+ owner_requester-->>external_user: ExternalMemHolderInfo;external 本地构造 MemHolder
+ else SSD stage 失败
+ owner_ssd-->>owner_requester: stage error
+ owner_requester->>master_route: GetRevokeReq(drop_ssd_source = true)
+ master_route->>master_route: remove inflight_gets and release target / staging references
+ alt source_kind = Ssd 且 route put_id 仍匹配
+ master_route->>master_route: 清空当前 owner entry.ssd,必要时删除 entry / route
+ else source 类型或版本已变化
+ master_route->>master_route: 保留当前 route
+ end
+ master_route-->>owner_requester: GetRevokeResp
+ loop 陈旧 SSD source 最多重新选路 3 次
+ owner_requester->>master_route: GetStartReq
+ master_route-->>owner_requester: 其他 live source / 新版本 / miss
+ end
+ owner_requester-->>external_user: 重试结果、miss 或达到上限后的 error
+ end
+
+ opt 请求未进入 GetDone 或 GetRevoke
+ master_route->>master_route: 60 s in-flight TTL 释放 target / staging guard
+ end
+ opt requester 在 GetDone 后离开
+ master_route->>master_route: member-left 清理该节点的 get_holding
+ end
+```
+
+### 7.3 删除、驱逐与重启
+
+- **覆盖写与 delete**:覆盖写生成新 `put_id`,旧 SSD late commit 因版本不匹配被忽略。显式 delete 移除整个 `kv_routes` 条目;节点 tomb 清理则删除该节点在 `node_replicas` 中的整个 entry。
+- **两层独立驱逐**:内存驱逐只清空 `memory`;SSD ring 驱逐或读取失败只清空 `ssd`。两个字段都为空时才删除 owner entry,整个 route 没有副本时才删除 `kv_routes`。
+- **物理 bytes 不等于可读副本**:route 被删除或版本不匹配后,旧 bytes 即使还在 shard 中也不会被公共 `get` 命中。陈旧 route 被选中时,`pin_read` 会复查 committed 状态和 offset,失败后 revoke source 并重新 `GetStart`。
+- **owner 重启**:shard 以 `create + truncate` 打开,并重建空的本地 SSD cache 和 ring。当前没有 WAL、checkpoint 或 shard 扫描。控制面尚未完成 tomb 清理时,读取兜底会撤销失效 SSD source,再重新选路或返回 miss。
+
+## 8. 性能测试
+
+本节先给出核心结果,再说明计数边界与实验设置,最后分别展开 CPU、GPU 和 SSD 路径证据。性能数字只在对应小节定义的硬件、数据集、输出模式和统计窗口内成立。
+
+### 8.1 核心结果
+
+- **CPU / GPU 使用同一 pressure 口径**:CPU 主图包含 192 条记录,覆盖原生引用 / handle 与 `bytes`、4 个 payload、4 个并发度、Fluxon、两种 Mooncake 拓扑和 SSD 关/开;GPU 主图包含 72 条记录,覆盖 `cuda`、3 个 payload、4 个并发度、3 种拓扑和 SSD 关/开。264 条记录全部为 `SUCCESS`、0 error、来源完整且 unknown 为 0。
+- **miss 是结果,不是过滤条件**:两侧都使用约 2.5 GiB 数据集和 2 GiB DRAM。柱高只统计 hit payload,红点显示 `hit / (hit + miss)`。SSD-off 的 CPU 命中率为 Fluxon **89.112%–94.390%**、Mooncake 独立 owner **23.759%–66.879%**、Mooncake 每进程 **22.488%–68.655%**;GPU 对应范围为 **89.326%–93.267%**、**34.557%–62.223%** 和 **35.430%–64.782%**。
+- **SSD-on 也保留实际 miss**:CPU 的 Fluxon 和 Mooncake 独立 owner 在 SSD-on 下均为 100% 命中,Mooncake 每进程为 **72.830%–100%**;GPU 对应为 100%、100% 和 **59.855%–100%**。因此 SSD-on/off 柱差同时包含命中集合、介质来源和系统调度差异,不能解释成单一 SSD 开销。
+- **SSD-on 的 hit 同时来自两层**:CPU 中 Fluxon 的 SSD 来源占 hit 的 **17.550%–48.650%**,Mooncake 独立 owner 为 **62.065%–82.385%**,Mooncake 每进程为 **59.341%–83.011%**;GPU 对应为 **20.242%–46.508%**、**64.837%–77.135%** 和 **56.073%–84.198%**。
+- **GPU 多并发已进入同一张主图**:Fluxon 在 SSD-off 下从 c2 到 c16 提高到 5.83、5.83 和 4.68 倍,SSD-on 下提高到 6.50、7.22 和 6.80 倍,分别对应 4、8、16 MiB。Mooncake 的两种拓扑也按相同的六柱顺序展示,不再另画一张来源图。
+- **同路径 Foyer 消融下原生 SSD 仍然更快**:64 条测量记录对应 32 个原生/Foyer 配对,全部为 `SUCCESS`、0 error、0 unknown、0 persist failure,两侧命中率均为 100%。当前 Foyer adapter 的 hit payload 带宽是原生实现的 **14.8%–65.9%**,配对中位数为 **37.2%**,32 个配对中没有胜出;P95 是原生实现的 **2.30–8.46 倍**。这是当前接入路径的结果,不外推为 Foyer 库的通用性能结论。
+
+这些数字只覆盖下文定义的单机、固定容量、Zipf 分布、30 秒窗口和各自 fast path。实验不包括跨机器 RDMA、多物理盘条带化、重复运行的统计置信区间或真实业务 trace。
+
+### 8.2 计数边界:原生引用 / handle、`bytes` 与 `cuda`
+
+Mooncake 没有 Fluxon 意义上的 holder。`holder` 只是 benchmark 配置 `kv_get_output=holder` 的归一化模式名,Fluxon 和 Mooncake 实际返回 `MemHolder` 与 `BufferHandle`。
+
+Mooncake 常规 `Store.get()` 直接返回 Python `bytes`,因此结果包含完整 payload copy。`Store.get_buffer()` 返回 native `BufferHandle`,无需创建 Python `bytes` 即可取得 `ptr()`、`size()` 或 `memoryview`。原生引用 / handle 组把计数边界放在 host 内存引用返回之后。
+
+#### 两侧原生引用的调用与生命周期差异
+
+benchmark 中的实际分支等价于:
+
+```python
+if backend == "MOONCAKE":
+ # Internal adapter calls Mooncake Store.get_buffer().
+ result = mooncake_store.get_buffer_blocking(key) # Fetch a native BufferHandle.
+else:
+ result = fluxon_store.get_blocking(key) # Fetch a native MemHolder.
+
+native_ref = result.unwrap() # Keep the native host reference alive while counting success.
+```
+
+两者只在本次 benchmark 的**计数边界**上对齐,类型语义并不相同:
+
+| 对比维度 | Fluxon | Mooncake | 对齐方式 |
+| --- | --- | --- | --- |
+| 实际返回类型 | `MemHolder` | `mooncake.store.BufferHandle` | 本文直接写真实类型,不写“Mooncake holder”。 |
+| 底层调用 | `KvClient.get_blocking()` | `Store.get_buffer()`,由 benchmark adapter 包成 `get_buffer_blocking()` | 都在 native host buffer ready 后返回。 |
+| 数据访问 | `MemHolder.access()` | `ptr()`、`size()`、`memoryview(BufferHandle)` | 原生引用模式不 materialize;bytes / CUDA 模式再继续消费。 |
+| 生命周期语义 | 参与 Fluxon 的 `holder_id`、`get_holding` 和释放流程。 | 由 Mooncake Python binding 的 `BufferHandle` 管理返回 buffer 的生命周期。 | 只要求对象存活到本次操作计数完成。 |
+| 不对齐的部分 | Fluxon holder 协议。 | 没有 Fluxon 的 `holder_id/get_holding/revoke` 协议。 | 不声称 API 或生命周期协议等价。 |
+
+原生引用模式仍然包含后端选择 SSD route 时从 SSD 到 host 的搬运。它只省去随后将完整 payload 物化为 Python `bytes` 的额外 copy。`cuda` 从同一个 native holder 取得 pageable host pointer,再交给 Fluxon 与 Mooncake 共用的双槽 H2D 流水线。只有对应 CUDA event 完成后,这次操作才计为成功。
+
+下图覆盖一次 benchmark `get` 从发起到达到对应计数边界的过程。`external-backend-adapter` 是 benchmark 侧的统一适配层,封装完整的 Fluxon 或 Mooncake get;它不表示两套系统共享 Fluxon 的 owner/master 架构。
+
+- **`external-benchmark-worker`**:发起 KV get,按 `holder/bytes/cuda` 模式消费结果,并在该模式的完成点计数。
+- **`external-backend-adapter`**:在同一调用面上包住 Fluxon 或 Mooncake 的完整 get,并返回已驻留 host 的 `MemHolder` 或 `BufferHandle`。
+- **`external-host-output`**:表示 pageable host 上的 native reference 生命周期,以及 `bytes` 模式的完整 payload 物化。
+- **`external-pinned-slot`**:每个 benchmark worker 复用的两个 pinned host slot 之一。
+- **`external-cuda-runtime`**:对应 slot 的 CUDA stream 和 event,负责 `cudaMemcpyAsync` 提交与完成通知。
+- **`external-gpu`**:接收 payload 的 GPU device buffer;这次实验不计 D2H 或 kernel 执行。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant external_worker as external-benchmark-worker
+ participant external_backend as external-backend-adapter
+ participant external_host as external-host-output
+ participant external_pinned as external-pinned-slot
+ participant external_cuda as external-cuda-runtime
+ participant external_gpu as external-gpu
+
+ external_worker->>external_backend: get(key)
+ external_backend-->>external_worker: MemHolder or BufferHandle with host-resident payload
+ external_worker->>external_host: retain native reference until this operation is counted
+
+ alt holder output
+ external_worker->>external_worker: validate native reference and count success
+ external_worker->>external_host: release native reference
+ else bytes output
+ external_worker->>external_host: materialize the full payload as Python bytes
+ external_host-->>external_worker: bytes ready
+ external_worker->>external_worker: validate bytes and count success
+ external_worker->>external_host: release native reference
+ else cuda output
+ external_worker->>external_pinned: host memcpy from pageable payload into the next free slot
+ external_worker->>external_cuda: cudaMemcpyAsync(pinned slot, device buffer)
+ external_cuda->>external_gpu: start H2D DMA
+ par current H2D
+ external_gpu-->>external_cuda: matching CUDA event completes
+ external_cuda-->>external_worker: H2D complete
+ and next KV request
+ external_worker->>external_backend: get(next key)
+ external_backend-->>external_worker: next native host reference
+ end
+ external_worker->>external_worker: count current success after its event
+ external_worker->>external_host: release current native reference
+ end
+```
+
+| benchmark 输出模式 | Fluxon 映射 | Mooncake 映射 | 共同计数边界 |
+| --- | --- | --- | --- |
+| `holder`(归一化模式名) | `get_blocking()` 返回 `MemHolder`。 | `get_buffer_blocking()` 调用 Mooncake `Store.get_buffer()`,返回 `BufferHandle`;该对象暴露 `ptr()`、`size()` 和 buffer protocol。 | 原生引用已返回;不创建完整 Python `bytes`。 |
+| `bytes` | `MemHolder.access()` 解码出 payload `bytes`,校验长度并触碰首尾字节。 | 从 `memoryview(BufferHandle)` 定位 payload,再调用 `bytes(...)` 完整复制,校验长度并触碰首尾字节。 | 完整 Python `bytes` 已生成;不含 GPU copy。 |
+| `cuda` | 从 `MemHolder` 中取得 CPU DLPack payload 指针。 | 使用 `BufferHandle.ptr() + payload_offset` 取得 host 指针。 | 对应 CUDA event 已完成;不含 D2H 和 kernel 执行。 |
+
+来源计数与输出计数共用同一个 warmup 后统计窗口,但两套系统的观测方式不同:
+
+| 后端 | DRAM / SSD 归因方式 | 完整性判定 | 边界 |
+| --- | --- | --- | --- |
+| Fluxon | Rust get 路径保留 `GetSourceKind::Memory` / `GetSourceKind::Ssd`,经 external holder 传到 benchmark;每个成功操作单独计数。 | 每个 hit 都必须有且只有一个 source kind。 | 表示本次 get 选中并完成的 route source;它不是 NVMe 设备层 IO trace。 |
+| Mooncake | 在统计窗口起止点采样 `get_offload_rpc_read_count()`;`0.3.11.post1` 的单 key `get_buffer()` 在选中 `LOCAL_DISK` 后会进入唯一的 offload-RPC 计数入口。counter 增量记为 SSD hit,成功 hit 减该增量记为 DRAM hit。 | counter 必须单调,增量不能超过该节点成功 hit。 | 这是窗口 counter 推断。本次 CPU 和 GPU 有效记录的采样时刻与窗口边界最大偏差分别为 13.047 ms 和 1.912 ms。 |
+
+Fluxon / Mooncake coordinator 只在每个节点的 `memory + ssd + unknown == hit`、观测完整且 `unknown == 0` 时发布完整来源结果。如果任一节点的计数不自洽,该节点的全部 hit 都归入 unknown,不根据剩余量猜测来源。miss 不分配 DRAM / SSD source,单独进入命中率分母。CPU 的 192 条记录和 GPU 的 72 条记录均为完整来源、unknown 为 0。
+
+分层逻辑读带宽是整个统计窗口的平均值:`tier logical read GiB/s = tier hit 数 × 原始 payload bytes ÷ 窗口秒数 ÷ 2^30`。CPU 和 GPU 的统计窗口均为 30 秒。本次实验使用固定 payload,因此可以从来源操作数精确换算逻辑读取量。柱高不包含 miss;红点使用 `hit ÷ (hit + miss)`,两者共同描述本次 pressure workload。
+
+bootstrap 在统计窗口前完成,窗口内的 workload 只包含 get。因此,已纳入记录的 DRAM 和 SSD 逻辑写带宽均为 `0.000 GiB/s`。
+
+SSD 回填写入 host memory 的流量不重复计入 DRAM 逻辑写带宽。物理 DRAM 流量、SSD 读放大和回填写放大需要通过硬件计数器或设备 trace 观测。
+
+#### CUDA 双槽 pipeline 与阶段指标口径
+
+`cuda` 模式为每个 benchmark 线程固定复用 2 组 pinned host buffer、stream、event 和 GPU buffer,不增加新的 YAML 参数。线程先把 pageable source 复制到空闲 pinned slot,再调用 `cudaMemcpyAsync`。提交后不等待 event,下一次 KV get 可以和 H2D 重叠;两个 slot 都在途时,才等待最早提交的 event。
+
+统计窗口结束时会 drain 所有在途 slot,但完成时间超出窗口的操作仍会被过滤。Mooncake 的 benchmark get 路径直接调用 `get_buffer_blocking()`,不再额外调用一次 `get_size()`。
+
+两边只在 host pointer 的提取方式上不同,之后都进入同一个 H2D 流水线。`cuda_host_stage_us` 记录 pageable source 到复用 pinned slot 的 copy,`cuda_submit_us` 记录 `cudaMemcpyAsync` 调用,`cuda_h2d_event_us` 记录覆盖 H2D 的 CUDA event 区间。
+
+`cuda_pipeline_residence_us` 从开始占用 slot 到前台确认 event 完成,包含 host staging、H2D 和完成轮询。它与下一次 KV get 存在重叠,不能和其他阶段机械相加。benchmark 会把 `MemHolder` 或 `BufferHandle` 连同相关 view 保留到 event 完成,使两边释放 native reference 的时点与成功计数边界一致。
+
+### 8.3 实验设置
+
+主图 case 在同一台测试机上串行执行,硬件为 Intel Xeon Platinum 8468、Samsung NVMe(XFS)和 NVIDIA H100 80 GB,GPU 链路为 PCIe 5.0 x16。bootstrap 后只读,workload 使用固定 value size 和 Zipf 分布。
+
+CPU 和 GPU 主图统一使用 SSD-pressure 数据集:数据集约 2.5 GiB,DRAM 贡献为 2 GiB;SSD-on 再贡献 16 GiB SSD,SSD-off 不贡献 SSD。数据集、请求分布、worker 布局和统计窗口保持一致。由于数据集大于 DRAM,miss 和成功 hit 集合的变化就是实验要观察的结果;图同时报告命中率与 hit payload 带宽,不把开关差解释成纯介质开销。
+
+| 组别 | DRAM 存储贡献 / 原始数据集 | payload / keyspace | worker 布局 | 总时长 / warmup / 统计窗口 |
+| --- | --- | --- | --- | --- |
+| CPU Fluxon / Mooncake pressure | 2 GiB / 约 2.5 GiB | `1/4/8/16 MiB - 128 B` / `2560/640/320/160`;原生引用 / `bytes` | c2=`1×2`、c4=`1×4`、c8=`2×4`、c16=`4×4` | 35 / 5 / 30 s |
+| CPU Fluxon SSD backend 消融 | 2 GiB / 约 2.5 GiB;两侧再配置 16 GiB SSD | 与 CPU pressure 相同;`MemHolder` / `bytes` | 与 CPU Fluxon / Mooncake pressure 相同 | 35 / 5 / 30 s |
+| GPU pressure 开关矩阵 | 2 GiB / 约 2.5 GiB | `4/8/16 MiB - 128 B` / `640/320/160`;`cuda` | c2=`1×2`、c4=`1×4`、c8=`2×4`、c16=`4×4` | 35 / 5 / 30 s |
+
+主图使用的 Fluxon wheel SHA-256 前缀为 `11118d3`,Mooncake 为 `0.3.11.post1`。同路径 SSD backend 消融使用 Fluxon wheel SHA-256 前缀 `4896d0e`,其中集成 Foyer 0.22.3 作为 test-only owner backend。后文的早期 GPU SSD 写盘证据使用 Fluxon wheel SHA-256 前缀 `54de857`,不与主图或 backend 消融记录合并。
+
+表中的 DRAM 存储贡献是 KV 可分配容量,不等同于进程 RSS。主图对齐的是 2 GiB 逻辑 value 容量和 16 GiB 逻辑 SSD 容量;Fluxon 与 Mooncake 的 envelope、allocator、索引和元数据不同,物理 DRAM 占用并不相等。逻辑 payload 带宽也不包含后端编码和元数据。
+
+同路径 SSD backend 消融中的 2 GiB 仍是 Fluxon owner 上层 DRAM。集成 Foyer 另外保留 1 B 内部 memory capacity,并关闭该层的 memory admission;这 1 B 不替代或扣减 Fluxon 的 2 GiB DRAM。
+
+CPU 和 GPU 的 cN 都表示所有 benchmark 进程合计 N 个 worker 线程。Fluxon / Mooncake 的 c8 和 c16 分别使用 2 个和 4 个 benchmark 进程,因此结果同时包含多进程绕开 Python GIL 后带来的并行度;GPU 每个线程固定复用两个 pinned host、stream、event 和 device buffer slot。
+
+CPU 和 GPU 主图中的 Mooncake 都覆盖两种拓扑:
+
+- **`DEDICATED_OWNER`**:一个常驻 owner 贡献 2 GiB DRAM 和 16 GiB SSD,所有 benchmark 进程都是 zero-contribution。
+- **`PER_BENCHMARK_PROCESS`**:不启动独立 owner,每个 benchmark 进程同时作为请求方和存储 endpoint。2 GiB DRAM 和 16 GiB SSD 按进程数等分:c2/c4 每个实例为 2 GiB + 16 GiB,c8 为 1 GiB + 8 GiB,c16 为 512 MiB + 4 GiB。
+
+在 SSD-on 侧,每个 storage instance 使用独立 SSD 目录,但都位于同一块 Samsung NVMe 上。两种拓扑从 c2 到 c16 都保持 2 GiB DRAM + 16 GiB SSD 的集群逻辑总容量;SSD-off 侧保持同样的 2 GiB DRAM,去掉 16 GiB SSD 贡献。
+
+Fluxon / Mooncake 每个拓扑的两侧使用相同的 DRAM 贡献、pressure 数据集、requester buffer、worker 布局和 transport。排除用于隔离 case 的 cluster ID、目录和端口后,Fluxon 只增删 owner `owner_kv_ssd`;Mooncake 只增删 `--enable_offload=true` 和 16 GiB SSD 贡献。
+
+Mooncake 的 CPU requester buffer 在 SSD 关/开两侧均为 256 MiB,GPU 每个 benchmark 进程两侧均为 2 GiB。两侧也都使用 `tcp_pool_nodelay_backport`。
+
+#### bootstrap、transport 与容量排除项
+
+每个 case 都按单 PUT 串行 bootstrap。1、4、8、16 MiB payload 在每次 PUT 后分别等待 6、25、50、100 ms,把 payload 提交速率上限控制在约 160 MiB/s。该速率不足以在 Mooncake 的一个 10 秒 offload heartbeat 周期内填满 2 GiB DRAM 层,从而避免 bootstrap 成功与否取决于首次 heartbeat 和 DRAM 写满的先后顺序。800 MiB/s 的启动探针只用于发现这个竞态,没有进入最终结果。
+
+主图构建和早期 GPU SSD 写盘证据所用构建都对 Mooncake 设置 `MC_TCP_ENABLE_CONNECTION_POOL=1`,并在 client connect 与 server accept 两端补 `TCP_NODELAY`。结果中的 transport profile 记为 `tcp_pool_nodelay_backport`。
+
+在只开启 connection pool、未补 `TCP_NODELAY` 的预跑中,`batch_get_into_offload_object_internal` 均值从 `2.22 ms` 增至 `7.37 ms`,P95 出现约 `46 ms` 台阶。最终结果不混入默认短连接或只开 connection pool 的运行,也不把这个 backport 口径写成原版 `0.3.11.post1` 的默认表现。
+
+Mooncake SSD offload 的单 slice 上限是 `0xFFFFF0` bytes。CPU 和 GPU 的 16 MiB 组都将 benchmark payload 减去 128 bytes,加入 91-byte FlatDict envelope 后仍低于该上限。
+
+Fluxon / Mooncake 主图和 Fluxon SSD backend 消融均关闭 capacity guard,Mooncake 的 eviction high watermark 设为 0.80。SSD 容量不是这些实验的限制因素。主图只纳入状态为 `SUCCESS`、0 error、来源计数完整且 unknown 为 0 的运行;pressure 中的 miss 在 SSD 关/开两侧都保留并进入命中率。
+
+### 8.4 CPU 性能对比:`MemHolder` / `BufferHandle` 与 `bytes`
+
+下图的两行分别是原生引用 / handle 和 `bytes`,四列分别是 1/4/8/16 MiB。每个 payload 分面覆盖 c2/c4/c8/c16;每个并发度固定按 Fluxon、Mooncake 独立 owner、Mooncake 每进程 × SSD 关/开排列六根柱。蓝、橙、青分别区分三种实现组,浅色表示 DRAM、深色表示 SSD,红点使用右轴表示总命中率。各 payload 分面使用独立左轴,范围写在右上角。
+
+
+
+CPU 的 192 条记录覆盖 `2 输出模式 × 4 payload × 4 并发度 × 3 实现组 × 2 SSD 状态`。`fluxon_test_stack/single_node_ssd_bench/plot_kv_ssd_cpu_sweep.py` 与 GPU 脚本共同调用同目录下的 `kv_ssd_pressure_chart.py`。输入文件按命令行顺序覆盖同 case key;最终矩阵缺一条、存在 error、来源不完整或 unknown 非零都会直接失败。
+
+| 实现组 | SSD-off 命中率 | SSD-on 命中率 | SSD-on 中 SSD 来源占 hit |
+| --- | --- | --- | --- |
+| Fluxon | 89.112%–94.390% | 100% | 17.550%–48.650% |
+| Mooncake 独立 owner | 23.759%–66.879% | 100% | 62.065%–82.385% |
+| Mooncake 每进程 | 22.488%–68.655% | 72.830%–100% | 59.341%–83.011% |
+
+结果支持三个直接判断:
+
+- **命中率和 hit payload 带宽必须一起看**:SSD-off 会产生快速 miss;Mooncake 每进程的部分 SSD-on case 也有 miss。柱高不包含这些 miss,因此单看柱高会遗漏成功集合的变化。
+- **开关差不是单项 SSD 开销**:Fluxon 原生引用的 SSD-on hit 带宽相对 SSD-off 变化为 -88.8%–-21.1%,`bytes` 为 -45.8%–+2.2%;Mooncake 两种拓扑同时包含正负变化。差值混合了命中率、DRAM/SSD 来源、回填、输出物化和调度,不能拆成一个内部阶段。
+- **输出模式需要分开比较**:原生引用 / handle 在 host reference ready 时计数,`bytes` 还包含完整 payload 物化。两行都按每次 hit 的完整逻辑 payload 计带宽;handle 行并没有读取或复制同等字节,因此其数值可以超过物理 DRAM 带宽,不能跨行用柱高计算复制效率或模式倍率。
+
+统计窗口内只有 get,因此已完成记录的 DRAM 和 SSD 逻辑写带宽均为 `0.000 GiB/s`。图中报告逻辑 payload 流量,不代表物理 DRAM 或 NVMe 带宽。
+
+#### 8.4.1 同一 Fluxon 路径下的 SSD backend 消融
+
+[Foyer](https://github.com/foyer-rs/foyer/tree/v0.22.3) 是一个面向高性能和高并发场景的 Rust 内存/磁盘混合缓存库。它的 `HybridCache` 在一套接口下组合内存 cache 与磁盘 storage,并提供块式磁盘引擎、可替换的缓存算法和 IO engine。
+
+对 Fluxon 而言,Foyer 是一个现成的通用实现参照。这组消融为一个直接的工程取舍提供依据:Fluxon 原生 SSD backend 在目标 workload 和既有数据路径中的功能与性能表现,是否足以支持继续自研和维护。若通用库已经满足需求,直接复用 Foyer 可以减少存储引擎本身的开发与维护。
+
+要比较 SSD 存储层,不能直接把 Fluxon 的整套 cache 替换掉。Fluxon 的内存路径同时包含 master `route`、owner DRAM 副本、allocation 与 holder 生命周期、跨 owner 传输,以及 SSD 回填后的 promotion。如果连上层内存 cache 也替换为 Foyer,公共语义和数据路径会一起改变,结果无法归因到 SSD backend。因此,实验保留 Fluxon 的内存层及其上层路径,将替换边界统一收敛到 owner 内部的 `KvSsdStorage`:一侧使用 Fluxon 原生的 SSD 持久化与读取实现,另一侧接入 Foyer `HybridCache` 的 storage tier,并关闭 Foyer 自身的 memory admission。
+
+Foyer 分支通过 test-only 配置启用:
+
+```yaml
+test_spec_config:
+ kv_ssd_storage_backend: foyer
+```
+
+默认值仍为 `native`。Foyer 分支当前只接受一个 SSD root,并在配置阶段拒绝 `kv_ssd_uring_mode: iovec`。两组都调用相同的公共 KV API,经过相同的 FlatDict、route、transport、PyO3/GIL 边界和 benchmark 进程布局;写入 SSD backend 的都是 Fluxon target 中包含 FlatDict envelope 的完整编码 value。owner 上层 DRAM 都是 2 GiB,SSD 都是 16 GiB,且两种 SSD backend 都使用 direct I/O。图中的逻辑带宽仍只按原始 payload bytes 计数。
+
+Foyer 内部另设 1 B memory capacity、1 个 memory shard,并用 filter 关闭 memory admission。这里关闭的是 SSD backend 内部可能形成的额外内存层,不影响 Fluxon owner 的 2 GiB DRAM。专项测试在 persist 和重复 get 后观测到 Foyer memory usage 为 0,所有读取来源均为 Foyer storage tier。
+
+| 对比项 | Fluxon 原生 SSD | Fluxon Foyer SSD |
+| --- | --- | --- |
+| SSD 组织 | Fluxon ring、定长 shard 和 `io_uring`。 | Foyer 0.22.3 `HybridCache`,`WriteOnInsertion`、`PsyncIoEngine` 和 64 MiB block。 |
+| 读取落点 | 满足对齐条件时直接读入 Fluxon target;远端路径可让 SSD chunk read 与 push 重叠。 | Foyer 先返回包含完整 value 的 `Vec`,再复制到 Fluxon target;远端路径取得完整 value 后才逐 chunk 交给 push。 |
+| persist 并发 | 使用原生 writer queue 和 device worker。 | forced storage admission;`enqueue → wait → may_contains` 串行化,避免 Foyer byte queue 满时静默丢弃写入。 |
+| 驱逐收敛 | ring 覆盖后批量通知 master 删除对应 SSD route。 | Foyer 0.22.3 没有向当前集成暴露等价的 SSD eviction callback;陈旧 route 只能在读取失败时撤销。 |
+
+这个 workload 在 bootstrap 后只读,因此 Foyer persist 串行化不进入 30 秒统计窗口。约 2.5 GiB 数据集也小于 16 GiB SSD 容量;本次结果不评估 Foyer 容量驱逐后的 route 收敛。图中的浅色仍表示 Fluxon 上层 DRAM route,深色表示 Fluxon SSD route;它不把 Foyer 内部 memory cache 作为第三层来源。
+
+图按 `MemHolder` / `bytes` 分成两行,每行覆盖 1 / 4 / 8 / 16 MiB;每个并发度放置原生与 Foyer 两根柱。蓝/紫区分 SSD 实现,浅/深区分 DRAM 与 SSD 来源,红点表示命中率。
+
+
+
+PNG 由 `fluxon_test_stack/single_node_ssd_bench/plot_kv_ssd_foyer_ablation.py` 读取原始汇总生成。脚本要求 64 条记录的 case key 完整,校验容量、并发布局、统计窗口、来源计数和配对控制项后才绘图。这里的 64 是测量记录数,即 32 个原生/Foyer 配对,不表示 64 种部署。
+
+所有配对的命中率都是 100%,因此这组差距没有 miss 数量优势可以解释。P95 的同向变化表明差距同时出现在延迟侧。`bytes` 会在两侧共同追加完整 payload 物化,其中位比值高于 `MemHolder` 与公共物化成本稀释 backend 相对差异相容,但单次矩阵不足以分解这部分因果贡献。
+
+当前两条读取路径的明确差异是:Foyer adapter 先取得完整 `Vec` 再复制到 Fluxon target;原生实现可以直接读入 target,远端时还保留 chunk read 与 push 重叠。同时还存在 `PsyncIoEngine` / `io_uring`、完整 value 交付和调度方式等差异。这些差异共同属于当前 adapter 对比,本次数据无法把柱高差逐项分配给某个内部阶段。
+
+配对 case 对相同的 thread id 和 operation index 使用同一套确定性 Zipf key 选择。统计边界仍是固定 30 秒窗口,不是固定操作数;较慢的一侧会停在同一确定性序列的更短前缀,实测 DRAM/SSD 来源比例也可能随之变化。因此,这组数据回答指定 pressure workload 下的整体 backend 表现,不把柱高比值解释成单次纯 SSD get 的加速比。
+
+### 8.5 GPU 性能对比:多并发 SSD 开关与 Mooncake 存储拓扑
+
+上一节的 CPU 测试以 KV 数据到达 CPU 内存为计数终点,分别观察原生引用 / handle 和完整 `bytes` 物化。AI 推理或训练实际消费 KV 数据时,数据通常还需要继续搬到 GPU。因此,本节把计数终点延伸到对应 CUDA event 完成,比较多并发下 SSD 开关和 Mooncake 两种存储拓扑对 GPU 可用数据吞吐的影响。该边界包含 H2D 传输,不包含 GPU kernel 执行或 D2H。
+
+GPU 与 CPU 使用同一种画法,只少了原生引用 / `bytes` 两行。4、8、16 MiB 各占一个分面;每个分面都覆盖 c2/c4/c8/c16,每个并发度再按 Fluxon、Mooncake 独立 owner、Mooncake 每进程 × SSD 关/开排列六根柱。蓝、橙、青区分三种实现,浅色表示 DRAM、深色表示 SSD,红点显示命中率;六组 case 都从 native host buffer 进入同一条 `cuda` H2D 路径。
+
+
+
+GPU PNG 由 `fluxon_test_stack/single_node_ssd_bench/plot_kv_ssd_gpu_sweep.py` 读取一个或多个 pressure 汇总,并与 CPU 脚本共同调用同目录下的 `kv_ssd_pressure_chart.py`。脚本校验最终 72 条记录完整后直接使用 Pillow 绘制;各 payload 分面使用独立左轴,命中率共用 0%–100% 右轴。
+
+| 拓扑 | SSD-off 命中率 | SSD-on 命中率 | SSD-on 中 SSD 来源占 hit |
+| --- | --- | --- | --- |
+| Fluxon | 89.326%–93.267% | 100% | 20.242%–46.508% |
+| Mooncake 独立 owner | 34.557%–62.223% | 100% | 64.837%–77.135% |
+| Mooncake 每进程 | 35.430%–64.782% | 59.855%–100% | 56.073%–84.198% |
+
+下表把图中的并发扩展收敛为 c16/c2 hit payload 带宽倍率,三个 payload 的顺序都是 4 / 8 / 16 MiB:
+
+| 拓扑 | SSD-off c16/c2 | SSD-on c16/c2 |
+| --- | --- | --- |
+| Fluxon | 5.83 / 5.83 / 4.68 倍 | 6.50 / 7.22 / 6.80 倍 |
+| Mooncake 独立 owner | 1.33 / 1.50 / 1.10 倍 | 1.73 / 1.37 / 1.35 倍 |
+| Mooncake 每进程 | 2.67 / 2.01 / 1.23 倍 | 3.02 / 2.07 / 1.77 倍 |
+
+Fluxon 的 SSD-on hit 带宽相对 SSD-off 变化为 -54.6%–-27.9%,Mooncake 独立 owner 为 -32.0%–+37.3%,Mooncake 每进程为 -62.6%–-17.5%。这些变化必须与上表的命中率一起解释。c16 下,每进程相对独立 owner 的 hit 带宽在 SSD-off 下分别高 125.7%、107.3% 和 97.9%,SSD-on 下高 82.4%、64.5% 和 57.9%;这些是包含 endpoint 本地性、进程数和 transport 调度的整体拓扑结果,不能归因到单个内部环节。
+
+主矩阵体现了三个结果:
+
+- **Fluxon 能随并发扩展**:4、8、16 MiB 从 c2 到 c16 分别提高到 7.18、7.68 和 5.33 倍。
+- **16 MiB 在 c8 后开始收益递减**:c8 到 c16 只再提高 14.0%,backend get 均值从 `3.082 ms` 增至 `7.367 ms`,P95 从 `24.007 ms` 增至 `51.150 ms`。H2D 之前的路径已开始制约吞吐。
+- **Mooncake 拓扑会影响结果**:每进程贡献模式在 c16 下相对独立 owner 分别提高 91.3%、103.7% 和 66.8%。两种模式的逻辑容量相同,差异主要出现在 GPU copy 之前。来源诊断显示 8/16 MiB 的每进程贡献模式具有更高 SSD 命中率,因此更高吞吐无法由更高 DRAM 命中率解释;独立 owner 串行点、endpoint 本地性和 transport 调度成本仍未拆分。
+
+c16 下,Fluxon 相对两种 Mooncake 拓扑中更快的一种,在 4、8、16 MiB 组中分别为 3.83、5.61 和 6.73 倍。这些比值只覆盖第 8.2–8.3 节定义的计数边界、单机 fast path 和固定容量。
+
+## 9. 总结
+
+Fluxon KV 将本地 SSD 定位为运行期回填层,不改变用户侧 `put`、`get` 和 `delete` 契约。master 维护 key-version 级 `route`,并统一调度用于最终 value 副本和临时传输的内存 allocation。owner 承载真实 bytes,管理 SSD 文件位置、IO 和 ring 生命周期。
+
+`put` 操作在内存副本发布后返回,SSD persist 在后台完成。`get` 操作优先读取内存。没有可读内存副本时,本地 SSD 回填复用 requester target,远端回填则按 chunk 从 source staging push 到 requester target。两条路径最后都进入同一个 `GetDone` 和 holder 交付链。
+
+同路径消融进一步表明,在本文固定的 pressure workload 下,当前 Foyer adapter 的 hit payload 带宽中位数为原生 SSD 的 37.2%,P95 为 2.30–8.46 倍,32 个配对中没有胜出。两侧均为 100% 命中,但 Foyer adapter 多了完整 `Vec` 到 Fluxon target 的复制,也没有原生路径的 direct-target 与 chunk read/push 重叠。这一结果限定于当前 adapter,不代表 Foyer 库的一般性能。
+
+当前 SSD 层不提供冷启动恢复,也不向用户暴露独立 API。专项测试验证了强制回填和陈旧 route 兜底,并报告限定条件下的端到端逻辑 payload 表现。这些结果不代表裸 SSD 带宽,也不外推到跨机器、多 owner 或多盘场景。
+
+将本地 SSD 纳入同一套 KV 路由、生命周期和观测体系,是 Fluxon 构建统一 AI 数据面的一次关键扩展:内存继续承担低延迟热路径,SSD 在不改变 `put`、`get` 和 `delete` 契约的前提下承接更大的运行期工作集,命中数据还可以沿既有路径继续送往 GPU。我们希望这项能力减少 AI 推理和训练系统在分层缓存、回填传输与数据搬运上的重复建设,让开发者把更多精力放在模型和业务创新上。Fluxon 已基于 Apache License 2.0 开源,GitHub 仓库地址:https://github.com/Tele-AI/Fluxon。欢迎社区一起推进面向 AI 场景的数据流动基础设施。
diff --git "a/fluxon_doc_cn/design/fluxon_0_\351\205\215\347\275\256\346\200\273\350\247\210.md" "b/fluxon_doc_cn/design/fluxon_0_\351\205\215\347\275\256\346\200\273\350\247\210.md"
index a4c5865..8c5e779 100644
--- "a/fluxon_doc_cn/design/fluxon_0_\351\205\215\347\275\256\346\200\273\350\247\210.md"
+++ "b/fluxon_doc_cn/design/fluxon_0_\351\205\215\347\275\256\346\200\273\350\247\210.md"
@@ -193,7 +193,7 @@ test_runner_ui:
bootstrap_phases:
- mode: fixed_bare
- node: infra44-ThinkStation-PX
+ node: example-node-a
services: [etcd, greptime, tikv_pd, tikv]
```
@@ -208,7 +208,7 @@ retention:
repos:
- addr: git@github.com:Tele-AI/fluxon.git
follow:
- - branch: big_step2
+ - branch: example-feature-branch
run:
name_prefix: fluxon_ci
commands:
@@ -260,8 +260,8 @@ instance_key: my-owner-1
# 只要 dram > 0,就进入 owner 分支
contribute_to_cluster_pool_size:
- # 容量按 16 MiB 对齐
- dram: 1677721600
+ # 容量解析后向下对齐到 16 MiB
+ dram: "1.5GB"
vram: {}
fluxonkv_spec:
@@ -282,6 +282,11 @@ fluxonkv_spec:
large_file_paths:
- /var/lib/fluxon/large
+ # 可选;和 large_file_paths 长度一致,限制每个 KV SSD backing tier root 的 payload 用量
+ # 缺省或 null 时不启用 KV SSD
+ large_limit_size:
+ - "4GB"
+
# 可选
p2p_listen_port: 31001
@@ -315,8 +320,10 @@ fluxonkv_spec:
- `monitoring` 在 master 上必填。
- `master_ui` 依赖 `monitoring`,并作为嵌入式 monitor HTTP 服务启动。
-- `contribute_to_cluster_pool_size` 里的容量都按 16 MiB 对齐;`dram = 0` 但 `vram` 非 0 会被拒绝,避免半 owner 半 external 的模糊状态。
+- size 字段可以写整数 bytes,也可以写 `"512.9B"`、`"1.5GB"` 这类字符串;`MB/GB` 按二进制单位解析,等价于 `MiB/GiB`,小数结果按 bytes 向下截断。
+- `contribute_to_cluster_pool_size` 里的容量解析后都向下对齐到 16 MiB;`dram = 0` 但 `vram` 非 0 会被拒绝,避免半 owner 半 external 的模糊状态。
- owner 模式要求 `contribute_to_cluster_pool_size.dram > 0`,并且必须显式提供 `etcd_addresses`、`sub_cluster`、`large_file_paths`。
+- `fluxonkv_spec.large_limit_size` 只允许 owner 配置;出现时必须和 `large_file_paths` 长度一致,每项解析后必须大于或等于 512 bytes。
- zero-contribution `external` 模式禁止再写 owner 专属字段;运行时会从 owner `shared.json` 补齐这部分信息。
- `share_mem_path` 会拼成 `cluster_name` 作用域路径;`mmap.file`、`shared.json` 和 peer metadata 都位于这个 cluster-scoped 目录下。
- `test_spec_config.side_transfer_role = worker` 不是第三套 YAML,而是 zero-contribution client 的子分支;它强制 `TransferEngineType::P2p`,并关闭 transfer RPC fast path。
@@ -444,11 +451,11 @@ http_listen_addr: 0.0.0.0:18080
network:
subnet_whitelist:
- 127.0.0.0/8
- - 10.0.0.0/24
+ - 192.0.2.0/24
primary_ip_to_extended_ips:
- 10.0.0.10:
- - 10.0.0.11
- - 10.0.0.12
+ 192.0.2.10:
+ - 192.0.2.11
+ - 192.0.2.12
```
以及协议/传输分支这两个输入:
diff --git "a/fluxon_doc_cn/design/fluxonfs_videoreader\346\216\245\345\217\243\350\256\276\350\256\241.md" "b/fluxon_doc_cn/design/fluxonfs_videoreader\346\216\245\345\217\243\350\256\276\350\256\241.md"
new file mode 100644
index 0000000..9594c97
--- /dev/null
+++ "b/fluxon_doc_cn/design/fluxonfs_videoreader\346\216\245\345\217\243\350\256\276\350\256\241.md"
@@ -0,0 +1,397 @@
+# FluxonFS VideoReader 接口设计
+
+## 执行摘要
+
+- `VideoReader(path)` 这类第三方解码入口在 C/C++ 层直接访问文件路径,绕不过 Python 侧 `open()` patcher。FluxonFS 需要提供一个原生 `VideoReader` 接口,把 FFmpeg 的随机读请求接到 FluxonFS 的 `stat / read_chunk / cache` 链路上。
+- 单 reader 公共入口为 `FluxonFsPatcher.open_video_reader(...)`。调用方传 `export_name + relpath + 输出尺寸`,返回 `FluxonFsVideoReader`,再调用 `read_frames_numpy(indices)` 得到 `uint8 NHWC` 数组。
+- 训练 dataloader 的推荐入口为 `FluxonFsPatcher.open_video_reader_pool(...)`。pool 在当前 Python 进程内按 `(export_name, relpath, height, width, num_threads, request_identity)` 管理 reader LRU,复用 reader 级 page cache,调用方不直接维护 reader 缓存。
+- 底层只保留 `RangeSource` 读取路径:FFmpeg 通过 `AVIOContext` 回调向 FluxonFS 请求字节范围。export 路由、身份校验、metadata cache、KV piece cache、异步 backfill、磁盘缓存等能力都收束在 FluxonFS 内部,VideoReader 不新增缓存协议或本地文件分支。
+- 解码在训练进程本地完成。FS agent 只提供字节范围读取和缓存填充,不承担视频解码和帧数组传输。
+- v1 先支持 CPU FFmpeg 解码。NVDEC / GPU decode 可以作为后续显式 backend 分支加入,不进入首版公共契约。
+
+## 1. 背景与目标
+
+训练数据集当前用 `decord.VideoReader(path).get_batch(...).asnumpy()` 解码视频。这个路径的问题在于:
+
+- FFmpeg 在 native 层按路径访问文件系统,Python 的 `open()` / `read()` patcher 无法接管它。
+- `VideoReader(...)` 初始化阶段可能扫描 packet / keyframe,远端文件会承受额外顺序读和 seek。
+- 同一视频多个窗口重复读取时,现有探针和训练路径会重复打开、重复索引、重复 seek/decode。
+
+本文设计的目标是在 FluxonFS 内提供一个面向视频解码的读接口:
+
+- 让 FFmpeg 的 `read / seek` 请求走 FluxonFS 的远端读和缓存能力。
+- 把视频读取的公共接口固定在 FluxonFS 语义上,而不是暴露某个第三方解码库的文件路径约定。
+- 保持解码本地化,避免把未压缩帧通过 FS agent RPC 传输。
+- 对缓存、身份、文件版本和失败语义给出稳定边界。
+
+## 2. 非目标
+
+- 不在 FS agent 服务端解码视频。
+- 不复刻 `decord.VideoReader` 的全部 API。
+- 不新增独立于 FluxonFS export 配置的 per-video 配置文件。
+- 不为 VideoReader 增加环境变量参数传递。
+- 不为视频路径新增第二套 KV cache key 布局。
+- 不在首版支持写入视频、转码、音频流处理或 GPU decode。
+
+## 3. 当前实现基础
+
+FluxonFS 已有的读链路可以直接作为 VideoReader 的字节源:
+
+| 现有模块 | 当前职责 | VideoReader 复用方式 |
+| --- | --- | --- |
+| `FluxonFsPatcher` | 当前 Python 进程内的 FS 挂载、cache config 和请求身份入口 | 提供 `open_video_reader(...)` 和 `open_video_reader_pool(...)` 公共入口 |
+| `FluxonFsExport` | 定义 export 根目录、路由、cache key prefix、metadata TTL 和 backfill 策略 | VideoReader 只接受 `export_name + relpath`,不绕过 export |
+| `read_chunk` RPC | agent 端按 `offset + length` 读取远端文件 | FFmpeg `AVIOContext` 的 read 回调最终落到这里 |
+| `remote_read_chunk_by_handle...` | client agent 侧带 metadata、KV cache 和 miss policy 的范围读 | 作为 `FluxonFsVideoByteSource` 的主读接口 |
+| KV piece cache | 以 `(export, relpath, size, mtime_ns, piece_index)` 组织固定大小 piece | 随机读、重复读和跨 reader 复用 |
+| FluxonFS 内部缓存层 | KV piece cache、异步 backfill、磁盘缓存等内部优化 | VideoReader 不直接选择或配置,只通过范围读接口受益 |
+
+当前 Python patcher 对 `open()` 的支持仍然保留,但它不是视频接口的主路径。FFmpeg 从 C/C++ 层读取文件时,不会自动进入 Python `open()` patcher。
+
+## 4. 公共接口
+
+单 reader 公共入口放在 `FluxonFsPatcher` 上:
+
+```python
+with patcher.open_video_reader(
+ export_name="train-videos",
+ relpath="bucket/a/b/sample.mp4",
+ height=480,
+ width=832,
+ num_threads=8,
+) as reader:
+ frames = reader.read_frames_numpy([0, 8, 16, 24])
+```
+
+稳定契约:
+
+| 项目 | 契约 |
+| --- | --- |
+| 入口 | `FluxonFsPatcher.open_video_reader(...)` |
+| 定位文件 | `export_name: str` + `relpath: str` |
+| 输出尺寸 | `height: int` + `width: int`,必须为正数 |
+| 解码线程 | `num_threads: int`,必须为正数 |
+| 读取方法 | `read_frames_numpy(indices: Sequence[int]) -> numpy.ndarray` |
+| 返回数组 | `dtype=uint8`,shape 为 `(len(indices), height, width, 3)` |
+| 生命周期 | reader 支持 context manager;`close()` 后所有读取失败 |
+| 身份 | 打开 reader 时捕获 `patcher` 当前 request identity;reader 生命周期内不跟随后续身份变更 |
+
+单 reader 只提供 `read_frames_numpy` 一个读取方法。不要同时暴露 `get_batch()`、`get_batch_numpy()`、`read_frames()` 等同义入口。
+
+训练 dataloader 应优先使用进程内 pool:
+
+```python
+pool = patcher.open_video_reader_pool(max_readers=32)
+frames = pool.read_frames_numpy(
+ export_name="train-videos",
+ relpath="bucket/a/b/sample.mp4",
+ height=480,
+ width=832,
+ num_threads=8,
+ indices=[0, 8, 16, 24],
+)
+```
+
+业务已经把多个样本整理成 batch 时,应把每个 clip 的 frame plan 作为 `FluxonFsVideoReadRequest` 传给 pool:
+
+```python
+requests = [
+ FluxonFsVideoReadRequest(
+ export_name="train-videos",
+ relpath="bucket/a/b/sample.mp4",
+ height=480,
+ width=832,
+ num_threads=8,
+ indices=(0, 8, 16, 24),
+ ),
+ FluxonFsVideoReadRequest(
+ export_name="train-videos",
+ relpath="bucket/a/b/sample.mp4",
+ height=480,
+ width=832,
+ num_threads=8,
+ indices=(12, 20, 28, 36),
+ ),
+]
+results = pool.read_many_numpy_with_stats(requests)
+```
+
+pool 的稳定契约:
+
+| 项目 | 契约 |
+| --- | --- |
+| 入口 | `FluxonFsPatcher.open_video_reader_pool(max_readers=32)` |
+| LRU key | `(export_name, relpath, height, width, num_threads, request_identity)` |
+| 容量 | `max_readers: int`,必须为正数;活跃 reader 不会被强制关闭 |
+| 读取方法 | `read_frames_numpy(...) -> numpy.ndarray` |
+| 指标方法 | `read_frames_numpy_with_stats(...) -> FluxonFsVideoReadResult` |
+| 批量方法 | `read_many_numpy_with_stats(requests: Sequence[FluxonFsVideoReadRequest]) -> list[FluxonFsVideoReadResult]` |
+| 生命周期 | pool 支持 context manager;`close()` 后所有新读取失败 |
+| 线程语义 | 一个 cached reader 同一时刻只租给一个调用;并发请求可打开多个 reader |
+
+`read_many_numpy_with_stats(...)` 在 pool 内部按 LRU key 分组。同一视频、同一输出尺寸和同一 request identity 的多个 clip 会合并为一次 native `read_frames_numpy(combined_indices)` 调用,然后按请求顺序切片返回。这个 batch 只改变调度粒度;每个请求的输出 shape、dtype 和错误语义仍按单请求契约处理。
+
+pool 只管理 reader 对象生命周期、reader 级 page cache 复用和同 reader key 的批量调度,不新增文件缓存协议。FluxonFS 的 KV piece cache、异步 backfill、磁盘缓存和权限检查仍然只在 RangeSource 读链路内生效。
+
+构建要求:
+
+- native 解码实现需要用 `fluxon_pyo3` 的 `fluxon_fs_video_ffmpeg` feature 构建,并且构建环境提供 FFmpeg 开发库。
+- 未启用该 feature 时,Python 公共入口仍存在,但打开 reader 会返回明确错误。这是构建期能力开关,不是第二条运行时读取路径。
+
+## 5. 架构
+
+```mermaid
+flowchart LR
+ A[Python 训练代码] --> P[FluxonFsVideoReaderPool 可选]
+ P --> B[FluxonFsPatcher.open_video_reader]
+ A --> B
+ B --> C[PyO3 FluxonFsVideoReader]
+ C --> D[FFmpeg Decoder]
+ D --> E[AVIOContext read / seek callback]
+ E --> F[FluxonFsVideoByteSource]
+ F --> G[reader page cache]
+ G --> H[remote_read_chunk_by_handle]
+ H --> I[FluxonFS 内部缓存层]
+ I --> J[read_chunk RPC]
+ J --> K[fs_agent export 根目录]
+ D --> L[numpy.ndarray uint8 NHWC]
+```
+
+这张图里的关键边界:
+
+- Python 只负责创建 reader、传入 frame index 和接收 numpy 数组。
+- FFmpeg 回调不调用 Python,避免 GIL 和 Python callback 成为高频 I/O 路径。
+- `FluxonFsVideoByteSource` 运行在 Rust/native 层,直接复用 FluxonFS agent 的读接口。
+- VideoReader 只看到范围读接口;KV、磁盘或其他缓存策略都属于 FluxonFS 内部实现。
+
+## 6. 详细设计
+
+### 6.1 打开 reader
+
+`open_video_reader(...)` 的打开流程:
+
+1. 校验 `export_name`、`relpath`、`height`、`width`、`num_threads`。
+2. 从 `FluxonFsPatcher` 对应的 Rust agent 捕获当前 request identity。
+3. 调用现有远端 `stat` 获取 `size`、`mtime_ns`、`is_file`。
+4. 构造文件签名 `sig = (size, mtime_ns)`。
+5. 创建 reader 级 `FluxonFsVideoByteSource` 和 page cache,绑定 `export_name`、`relpath`、`size`、`mtime_ns`、request identity。
+6. 首版在 `read_frames_numpy(...)` 内创建 FFmpeg `AVIOContext` / decoder,并把 `read_packet`、`seek` 回调绑定到 byte source。
+
+打开阶段只产生一个 reader handle。后续每次 `read_frames_numpy(...)` 复用同一个 byte source 和 reader 级 page cache;当前实现暂不复用 FFmpeg decoder context。pool 的批量入口会把同一 reader key 下的多个 clip 合并为一次 `read_frames_numpy(...)`,从而减少重复从视频头顺序解码到目标帧的成本。
+
+### 6.2 FFmpeg 读回调
+
+FFmpeg 的读回调以当前位置为基础请求一段 bytes:
+
+```text
+read_packet(buf, want)
+ -> source.read_at(current_offset, want)
+ -> current_offset += bytes_read
+```
+
+seek 回调只更新当前位置:
+
+```text
+seek(offset, whence)
+ -> source.seek(offset, whence)
+```
+
+`AVSEEK_SIZE` 返回打开时 stat 得到的 `size`。如果 FFmpeg 请求超过 EOF,返回实际可读长度;如果请求已经在 EOF 之后,返回 EOF。
+
+### 6.3 `FluxonFsVideoByteSource`
+
+`FluxonFsVideoByteSource` 是内部 Rust 对象,不作为 Python 公共类型暴露。它负责把 FFmpeg 的小范围读合并成 FluxonFS 适合的 page 读取。
+
+| 字段 | 作用 |
+| --- | --- |
+| `export_name` | export 名称 |
+| `relpath` | export 内相对路径 |
+| `size` | 打开时文件大小 |
+| `mtime_ns` | 打开时文件 mtime |
+| `request_identity` | 打开 reader 时捕获的身份 |
+| `page_bytes` | 内部页大小,默认跟随现有 piece / read chunk 约束 |
+| `page_cache` | reader 级 LRU,减少 FFmpeg 小读和重复 seek 的 RPC 次数 |
+
+VideoReader 只保留 `RangeSource` 路径。字节源的读取顺序如下:
+
+1. 命中 reader 级 `page_cache`:直接切片返回。
+2. 命中 KV piece cache:读取 piece 后填入 `page_cache`。
+3. 未命中 KV:调用 `remote_read_chunk_by_handle...`,按当前 export 的 cache 策略决定是否 backfill。
+
+### 6.4 缓存复用策略
+
+VideoReader 不新增独立缓存配置。它复用已有 export 字段:
+
+| 配置 / 状态 | 复用方式 |
+| --- | --- |
+| `cache_kv_key_prefix` | KV piece cache key 前缀 |
+| `cache_bytes_field_key` | KV 中保存 piece bytes 的字段 |
+| `cache_max_bytes` | 判断对象是否允许进入 KV cache |
+| `metadata_cache_ttl_ms` | 复用现有 stat / metadata cache 行为 |
+| `async_backfill_enabled` | KV miss 后是否异步补齐 piece |
+| FluxonFS 内部磁盘缓存 | 作为 `remote_read_chunk_by_handle...` 之后的内部优化,不向 VideoReader 暴露 FD / path |
+
+缓存 key 继续使用现有签名:
+
+```text
+export_name + relpath + size + mtime_ns
+```
+
+这个签名的含义是“打开 reader 时看到的文件版本”。如果文件内容变化但 `size` 和 `mtime_ns` 没变,FluxonFS 无法稳定识别这是新版本;上游数据集需要保证训练视频文件在读取期间不可变。
+
+### 6.5 单一读取路径
+
+内部只保留 `RangeSource`:
+
+```text
+FFmpeg read / seek
+ -> AVIOContext callback
+ -> FluxonFsVideoByteSource.read_at(offset, length)
+ -> remote_read_chunk_by_handle(...)
+ -> FluxonFS 内部缓存层 / read_chunk RPC
+```
+
+VideoReader 不接收本地 FD / path,不判断是否整文件 materialize,也不直接操作磁盘缓存。后续如果 FluxonFS 需要把某些 range read 映射到本地磁盘缓存,那也是 `remote_read_chunk_by_handle(...)` 内部的实现变化,不改变 VideoReader 契约。
+
+### 6.6 帧读取
+
+`read_frames_numpy(indices)` 的执行流程:
+
+1. 校验 `indices` 非负,且能转换为 frame index 列表。
+2. 首版按容器读取顺序从视频开头解码到最大目标帧,保证 frame index 语义稳定。
+3. 后续可以在明确 PTS / frame index 映射后加入 keyframe seek 优化。
+4. 对输出帧执行 resize / pixel format 转换。
+5. 写入一个 numpy-owned `uint8` 输出数组。
+
+首版输出固定为 NHWC RGB:
+
+```text
+(frame_count, height, width, 3)
+```
+
+这一路径仍然会 materialize 帧数组。设计目标是减少压缩视频字节读取和重复远端 I/O,不宣称帧输出零拷贝。
+
+## 7. 生命周期与所有权
+
+```mermaid
+sequenceDiagram
+ participant Py as Python caller
+ participant Patcher as FluxonFsPatcher
+ participant Reader as FluxonFsVideoReader
+ participant Source as FluxonFsVideoByteSource
+ participant Agent as FluxonFS Agent
+
+ Py->>Patcher: open_video_reader(export, relpath, size args)
+ Patcher->>Agent: stat(export, relpath, identity)
+ Agent-->>Patcher: size, mtime_ns
+ Patcher->>Reader: create native reader
+ Reader->>Source: bind export, relpath, size, mtime_ns, identity
+ Py->>Reader: read_frames_numpy(indices)
+ Reader->>Source: read_at(offset, length)
+ Source->>Agent: remote_read_chunk_by_handle(...)
+ Agent-->>Source: bytes
+ Source-->>Reader: bytes
+ Reader-->>Py: numpy.ndarray
+ Py->>Reader: close()
+```
+
+| 对象 | 所有者 | 生命周期 |
+| --- | --- | --- |
+| `FluxonFsPatcher` | Python 调用方 | 必须长于 reader |
+| `FluxonFsVideoReaderPool` | Python 调用方 / DataLoader worker | 进程内 LRU;通常随 worker 生命周期创建和销毁 |
+| `FluxonFsVideoReader` | Python 调用方 | context manager 或显式 `close()` |
+| `FluxonFsVideoByteSource` | native reader | 随 reader 创建和销毁 |
+| FFmpeg decoder context | native reader | 首版随每次 `read_frames_numpy(...)` 创建和销毁 |
+| numpy 输出数组 | Python 调用方 | 每次 `read_frames_numpy` 独立返回 |
+
+reader 关闭后,byte source 和 reader 级 page cache 释放;FFmpeg context 由每次 `read_frames_numpy(...)` 创建并在该次调用结束时释放。FluxonFS 的共享 KV cache 和内部磁盘缓存按现有 agent 生命周期管理。
+
+## 8. 失败语义与不变量
+
+| 条件 | 行为 |
+| --- | --- |
+| `export_name` 不存在 | 打开 reader 失败 |
+| `relpath` 不存在或不是文件 | 打开 reader 失败 |
+| 权限不足 | 打开或读取失败,错误保留 FluxonFS 权限语义 |
+| `height / width / num_threads <= 0` | 打开 reader 失败 |
+| `indices` 包含负数 | `read_frames_numpy` 失败 |
+| FFmpeg 无法识别容器或视频流 | `read_frames_numpy` 失败 |
+| 读取中远端短读 | 当前读取失败 |
+| reader 已关闭 | 后续读取失败 |
+
+关键不变量:
+
+- 所有远端数据访问都必须经过 FluxonFS export 和 access check。
+- FFmpeg 高频读回调不能调用 Python。
+- 缓存 key 和失效语义沿用 FluxonFS 当前 `(export, relpath, size, mtime_ns)` 签名。
+- 单 reader 公共接口只暴露一种读取方法:`read_frames_numpy`。
+- pool 公共接口提供单请求读取和批量读取;批量读取的输入必须是显式的 `FluxonFsVideoReadRequest`。
+- v1 返回 numpy-owned 数组,不把 native decoder 内部 buffer 暴露给 Python 长期持有。
+
+## 9. 当前实现与专用 fast path 的边界
+
+### 当前实现可直接复用
+
+- `read_chunk` RPC 的 `offset + length` 文件读取。
+- agent 侧 `remote_read_chunk_by_handle...` 的 KV cache / remote read 分支。
+- `FluxonFsS3KvMissPolicy::{RemoteRead, StageToKvThenRead}` 的有限 miss 策略。
+- `async_backfill_enabled` 对 KV piece 的异步补齐。
+- FluxonFS 内部缓存能力,包括 KV piece cache、异步 backfill 和磁盘缓存。
+
+### 需要新增
+
+- `fluxon_py/fluxon_fs/video.py`:Python 公共 facade 和类型导出。
+- `FluxonFsPatcher.open_video_reader(...)`:单 reader 公共入口。
+- `FluxonFsPatcher.open_video_reader_pool(...)`:训练 dataloader 推荐入口,负责进程内 reader LRU。
+- `FluxonFsVideoReadRequest` 和 `read_many_numpy_with_stats(...)`:pool 级批量调度入口,负责同 reader key 多 clip 合并。
+- native `FluxonFsVideoReader`:持有 byte source、reader 级 page cache 和输出数组构造逻辑,并驱动 FFmpeg decoder。
+- native `FluxonFsVideoByteSource`:FFmpeg `AVIOContext` 回调背后的 FluxonFS 范围读适配层。
+- 针对视频 decode 的指标:open 耗时、range read 次数、KV hit / miss、FluxonFS 内部缓存 hit / miss、decode 耗时。
+
+首版实现边界:
+
+- `open_video_reader(...)` 校验参数、捕获 request identity,并通过 FluxonFS agent 做 `stat`。
+- reader 生命周期内复用 `RangeSource` 和 reader 级 page cache。
+- `read_frames_numpy(...)` 每次创建 FFmpeg context,通过 `AVIOContext` 回调读取 FluxonFS range,并返回 numpy-owned `uint8 NHWC` 数组。
+- `FluxonFsVideoReaderPool` 在 Python 进程内复用 idle reader;活跃 reader 不参与 LRU 关闭,释放后再参与淘汰。
+- `read_many_numpy_with_stats(...)` 只在 Python pool 层合并同 reader key 的请求;native 层仍只有一个 frame-index batch decode 入口。
+- 帧定位先采用顺序解码。keyframe seek、持久 decoder、GPU decode 和指标上报是后续显式优化项。
+
+### 专用 fast path
+
+首版不为 VideoReader 增加专用 fast path。唯一热路径是 `RangeSource`。缓存优化只能在 FluxonFS 内部发生,例如 KV piece 命中、异步 backfill 或内部磁盘缓存命中;这些优化不得改变 `open_video_reader(...)` 和 `read_frames_numpy(...)` 的公共契约。
+
+未来如果加入 GPU decode,应作为新的 decode backend 分支,例如:
+
+```text
+DecodeBackend = CpuFfmpeg | CudaNvdec
+```
+
+这个分支只改变解码后端,不改变 FluxonFS 字节读取和缓存契约。
+
+## 10. 验证计划
+
+首版验证按三层做:
+
+| 层级 | 验证内容 |
+| --- | --- |
+| 字节源单测 | `read_at`、seek、EOF、跨 page 读取、重复读命中 page cache |
+| FluxonFS 集成测试 | export 权限、metadata cache、KV piece cache、内部磁盘缓存、文件变更签名 |
+| 视频过程测试 | 同一视频多窗口读取、和 `decord.VideoReader(path)` 的帧 shape / dtype / 基本像素一致性、timeout 行为 |
+
+性能指标必须绑定测试条件。至少记录:
+
+- 视频文件大小、编码格式、分辨率、帧数。
+- export 位置和底层存储类型。
+- cache 冷启动 / 热启动。
+- reader open 时间。
+- FFmpeg range read 次数和总读取 bytes。
+- KV cache hit / miss。
+- FluxonFS 内部缓存 hit / miss。
+- `read_frames_numpy` 端到端耗时。
+
+## 11. 关键结论
+
+FluxonFS VideoReader 的核心价值是把 FFmpeg 的随机字节读取纳入 FluxonFS 的 export、权限和缓存链路。单 reader 接口提供 `FluxonFsPatcher.open_video_reader(...) -> FluxonFsVideoReader`;训练 dataloader 使用 `FluxonFsPatcher.open_video_reader_pool(...)` 复用 reader。底层统一走 `RangeSource`,缓存能力收束在 FluxonFS 内部。
+
+这条设计保留训练进程本地解码,避免远端传输未压缩帧;同时让同一视频的重复窗口读取可以命中 FluxonFS cache,减少 FFmpeg 对远端文件系统的重复直接访问。
diff --git "a/fluxon_doc_cn/design/kv_1_\346\246\202\350\247\210\344\270\216\345\210\206\345\261\202.md" "b/fluxon_doc_cn/design/kv_1_\346\246\202\350\247\210\344\270\216\345\210\206\345\261\202.md"
index 7975c9c..2faa190 100644
--- "a/fluxon_doc_cn/design/kv_1_\346\246\202\350\247\210\344\270\216\345\210\206\345\261\202.md"
+++ "b/fluxon_doc_cn/design/kv_1_\346\246\202\350\247\210\344\270\216\345\210\206\345\261\202.md"
@@ -63,12 +63,26 @@ pub struct OneKvNodesRoutes {
pub put_id: PutIDForAKey,
// 这个 key-version 绑定的 lease;None 表示非 lease key。
pub lease_id: Option,
- // 这个已提交版本当前所有 live replica。
- pub nodes_replicas: RwLock>,
+ // 按 owner 索引这个版本的内存与 SSD 副本。
+ pub node_replicas: RwLock>,
// 限制 get 驱动的 durable replica 提升并发数。
pub get_durable_slots_used: AtomicU32,
}
+pub struct KvNodeReplicas {
+ // 标识当前 NodeID 对应的节点实例。
+ pub tomb_tag: NodeTombTag,
+ // 当前内存副本;驱逐后独立设为 None。
+ pub memory: Option>,
+ // 当前 SSD 副本;失败或驱逐后独立设为 None。
+ pub ssd: Option,
+}
+
+pub struct KvSsdReplicaInfo {
+ // SSD 中真实 payload 的长度。
+ pub len: u64,
+}
+
pub struct InflightPutInfo {
// 放置策略最终选中的目标节点。
pub node_id: NodeID,
@@ -106,7 +120,7 @@ pub struct InflightGetInfo {
- `put_id`:当前实现里用于区分同一 key 不同版本的版本标识,形状是 `(put_time_ms, put_version)`,不是数学意义上的全局唯一 ID。
- `lease_id`:这个版本是否绑定 lease。`None` 表示非 lease key,`Some(id)` 表示受 lease 管理。
-- `nodes_replicas`:该版本当前有哪些副本,每个副本对应哪个 node、哪块 allocation、当前 tomb 状态如何。
+- `node_replicas`:按 node 保存一份公共 tomb tag,并用 `memory` / `ssd` 两个 `Option` 独立记录两层副本。内存或 SSD 单独驱逐时只清理自己的字段;两个字段都为空时删除该 node entry。
这意味着:
diff --git "a/fluxon_doc_cn/design/kv_2_\350\260\203\347\224\250\346\227\266\345\272\217.md" "b/fluxon_doc_cn/design/kv_2_\350\260\203\347\224\250\346\227\266\345\272\217.md"
index 45c0b4d..bded54c 100644
--- "a/fluxon_doc_cn/design/kv_2_\350\260\203\347\224\250\346\227\266\345\272\217.md"
+++ "b/fluxon_doc_cn/design/kv_2_\350\260\203\347\224\250\346\227\266\345\272\217.md"
@@ -35,8 +35,8 @@ pub struct OneKvNodesRoutes {
pub put_id: PutIDForAKey,
// 该版本是否绑定 lease。
pub lease_id: Option,
- // 该版本当前所有 live replica。
- pub nodes_replicas: RwLock>,
+ // 按 owner 索引该版本的内存与 SSD 副本。
+ pub node_replicas: RwLock>,
...
}
```
@@ -126,8 +126,8 @@ pub struct OwnerHoldingGetInfo {
pub struct OneKvNodesRoutes {
pub put_id: PutIDForAKey,
pub lease_id: Option,
- // get_start 从这里挑选源副本。
- pub nodes_replicas: RwLock>,
+ // get_start 先选 memory,再从同一快照选择 SSD。
+ pub node_replicas: RwLock>,
// 限制 DurableReplica 提升并发数。
pub get_durable_slots_used: AtomicU32,
...
@@ -218,8 +218,8 @@ pub enum DeleteKeyInfo {
pub struct OneKvNodesRoutes {
pub put_id: PutIDForAKey,
- // delete 后需要按旧路由枚举哪些节点仍有 replica / cache。
- pub nodes_replicas: RwLock>,
+ // delete 后按旧路由枚举仍有内存 replica / cache 的节点。
+ pub node_replicas: RwLock>,
...
}
```
diff --git "a/fluxon_doc_cn/design/kv_3_\345\217\202\346\225\260\344\270\216\345\271\266\345\217\221.md" "b/fluxon_doc_cn/design/kv_3_\345\217\202\346\225\260\344\270\216\345\271\266\345\217\221.md"
index 7cacd5b..b7ca5c9 100644
--- "a/fluxon_doc_cn/design/kv_3_\345\217\202\346\225\260\344\270\216\345\271\266\345\217\221.md"
+++ "b/fluxon_doc_cn/design/kv_3_\345\217\202\346\225\260\344\270\216\345\271\266\345\217\221.md"
@@ -108,13 +108,14 @@ client 侧 `get` 的热路径是:
### master 路由访问:短读锁 + 复制快照
-当前 `kv_routes` 是 `DashMap>`,而 `nodes_replicas` 是 `RwLock>`。
+当前 `kv_routes` 是 `DashMap>`,而 `node_replicas` 是 `RwLock>`。每个 node entry 用独立的 `memory` / `ssd` 选项表示两层副本。
典型做法是:
- 先从 `kv_routes` 取出 `Arc`。
-- 用很短的读锁把 `nodes_replicas` clone 成局部 `HashMap` 快照。
-- 后续选源副本、处理 tomb、决定分配模式时都基于快照继续。
+- 用很短的读锁把 `node_replicas` clone 成局部 `HashMap` 快照。
+- 后续先选择 `memory` 副本;没有可用内存副本时,再从同一快照选择 `ssd` 副本。
+- tomb 清理删除整个 node entry;内存和 SSD 驱逐只更新各自的 `Option`,两个字段都为空时才删除 entry。
这样做的目的不是绝对无锁,而是:
diff --git "a/fluxon_doc_cn/design/kv_5_SSD\345\255\230\345\202\250\350\256\276\350\256\241.md" "b/fluxon_doc_cn/design/kv_5_SSD\345\255\230\345\202\250\350\256\276\350\256\241.md"
new file mode 100644
index 0000000..552edb2
--- /dev/null
+++ "b/fluxon_doc_cn/design/kv_5_SSD\345\255\230\345\202\250\350\256\276\350\256\241.md"
@@ -0,0 +1,1305 @@
+# KV 设计 5 - DRAM / SSD 多级缓存
+
+## 稳定结论
+
+Fluxon KV 将存储节点(owner)的本地 SSD 接入既有 key-version 副本体系,作为 DRAM 内存层的运行期回填层(backing store)。`put` 操作在内存副本可读后返回,系统随后在后台异步写入 SSD;`get` 操作优先查询内存,仅在没有可读内存副本时从 SSD 回填。
+
+该设计不改变公共契约:用户继续调用 `put`、`get` 和 `delete`,`get` 仍返回 `MemHolder`。master 管理 key-version 级逻辑路由和内存 allocation 生命周期;owner 管理本地 SSD 文件位置、IO 和 ring 驱逐。SSD 不向用户暴露独立 API,也不承担冷启动恢复。
+
+当工作集大于 DRAM 时,SSD 层可以保留已被驱逐的内存副本,扩大运行期可命中工作集。实际命中率和吞吐收益取决于访问分布、并发度、介质性能和回填开销,不由接入 SSD 这一事实单独保证。
+
+本文档将控制节点(master)维护的 key-version 级副本索引称为路由表(`route`),将贡献内存或 SSD 容量的节点称为存储节点(owner)。设计要点如下:
+
+| 设计方面 | 核心要点说明 |
+| --- | --- |
+| 用户契约 | 继续使用 `put`、`get` 和 `delete` 操作;`get` 仍返回 `MemHolder`。 |
+| 控制面与资源 | master 维护 `route`,并统一调度用于最终 value 副本和临时传输的内存分配(allocation)。owner 提供 memory segment,承载真实 bytes,并管理本地 SSD。 |
+| 数据路径 | 写入的同步完成点是内存副本发布;读取优先使用内存,SSD 只在内存无可读副本时回填。 |
+| 恢复边界 | SSD 仅作为运行期缓存。owner 重启后重建空 shard 和 ring,不扫描旧 shard,也不从 master 的 `route` 中恢复旧 SSD bytes。 |
+
+## 1. 架构概览:master 管理路由和内存分配,owner 管理本地 SSD
+
+内存 allocation 分为两类:一类承载最终 value 副本,另一类供跨 owner 传输临时使用,例如远端 SSD 回填的 source staging。master 统一调度这两类区间,因为它们的分配、保活和释放都与 `route` 及 in-flight 请求状态联动。allocation 对应的真实 bytes 仍位于 owner 注册的 memory segment。
+
+SSD allocation 只负责最终 value 副本,因此只涉及 owner 本地 shard/ring 的文件区间。文件 offset、覆盖保护和 IO 调度都是本地状态,因此 owner 负责 SSD 分配和 ring 驱逐。
+
+| 对象 | 资源或状态归属 | 分配与驱逐职责 |
+| --- | --- | --- |
+| `route` | master 持有 key-version 级副本索引。 | master 选择内存副本的放置位置并维护内存 allocation 生命周期。在内存或 SSD 副本失效后,master 收敛逻辑路由。 |
+| 内存 | bytes 位于 owner 注册的 memory segment。 | master 从这些 segment 分配两类区间:内存副本区间和通信临时区间。master 同时管理对应 allocation 的保活与驱逐。 |
+| SSD | bytes 位于 owner 本地的 shard 和 ring。 | owner 分配 SSD 文件区间并执行 ring 驱逐,再把 commit 或驱逐结果通知 master 更新 `route`。 |
+
+在 `route` 实现中,master 的 `OneKvNodesRoutes` 按 key-version 组织路由。`node_replicas` 是一张以 owner 为 key 的 map,每个 entry 用 `memory` 和 `ssd` 记录两层副本。二者共享当前 route 的 `put_id`,不能跨版本复用。SSD 文件位置、读写队列、文件 IO 对齐和覆盖保护都由 owner 管理。
+
+### 1.1 角色与图示约定
+
+全文时序图用角色前缀标明逻辑边界;前缀不表示独立进程。
+
+| 前缀 | 表示的职责 |
+| --- | --- |
+| `external-*` | 集群外的公共 API 调用方或 benchmark 消费方,不贡献 Fluxon 存储容量。 |
+| `master-*` | master 内的控制面,管理 `route`、in-flight 状态、master 侧 `Allocation` 保活句柄(guard)及 holding 生命周期,不中转 payload。 |
+| `owner-*` | owner 侧的请求入口、内存或 SSD 存储组件,以及本地数据传输端点。多个 `owner-*` 角色可以位于同一 owner 进程,也可以分布在不同 owner。 |
+
+后续时序图同时涉及请求协调、bytes 位置、allocation 保活和数据搬运,四者不能混用:
+
+| 关系 | 文中的规范说法 | 当前实现落点 |
+| --- | --- | --- |
+| 请求由谁协调 | `owner-requester` 接收并推进本次公共请求。 | owner 侧 `ClientKvApi`;external 包装层在图中通常折叠。 |
+| payload bytes 位于哪里 | bytes 位于 source/target owner 已注册的 memory segment,或 SSD owner 的本地 shard。 | RPC plan 中的 node、base address 和 absolute address。 |
+| allocation 由谁保活 | master 持有表示 owner segment 区间的 RAII `Allocation` guard(保活句柄)。 | `InflightPutInfo`、`OneKvNodesRoutes.memory`、`InflightGetInfo` 和 `get_holding`。 |
+| 数据由谁搬运 | memory get 由 requester pull;远端 SSD 回填由 SSD source push。 | owner 侧 `ClientTransferEngine`。 |
+
+`owner-requester` 只表示请求协调者,不代表固定 buffer,也不持有 master 侧的 `Allocation` 对象。它在不同操作中的数据面职责如下:
+
+| 操作 | owner 侧 range | master 侧 guard | 完成结果 |
+| --- | --- | --- | --- |
+| `put` | 常规路径的 source/staging bytes 位于 requester owner;side-transfer 路径位于单独的 `owner-put-source`。最终 target 由 master 选择,可能与 source 同 owner,也可能位于 `owner-put-target`。 | 本地 placement 用一个 `InflightPutAllocation::Local` guard 同时表示 source 和 target;远端 placement 用 `Remote { src, target }` 两个 guard。 | `PutDone` 把 target guard 转入 memory route;`put` 不产生 holder。 |
+| `get` | route 路径的最终 target bytes 位于 requester owner。已有本地 replica 时直接复用;否则 memory source 或 SSD source 把 payload 写入该 target。 | `InflightGetInfo.allocation` 保活 target;`GetDone` 后转入 master `get_holding`。memory source 或远端 SSD source staging 的额外 guard 位于 `source_allocation`。 | owner 返回 holder metadata,external 在自己的进程中构造 `MemHolder`。 |
+| `delete` | 没有 payload source、staging 或 target range。 | 不创建 payload `Allocation`。 | 只更新控制面 route,并执行既有 holder 失效流程。 |
+
+后续 `put` / `get` 时序从 owner 进入 master route 路径开始,折叠 external 包装 RPC。owner 返回共享内存 offset、长度和 `holder_id` 等 metadata,external 再据此构造 `MemHolder`,形成完整的公共结果。公共 `get` 操作还可能先命中 owner 本地的 `get_cached_info`,直接复用已有 `MemoryInfo`。第 4–7 节描述本地缓存未命中后进入 `GetStart` 的路径。
+
+时序图默认采用常规 owner 请求入口。专用 side-transfer worker 会把请求处理者与 put source owner 分开:`PutStartReq.source_node_id` 指向与 worker 共享 mmap 的 owner,source/staging bytes 位于该 owner 的 segment。这个 fast path 不改变 master 选择 put target、`PutDone` 发布 target 的主契约。
+
+### 1.2 四条主链路
+
+第 3–7 节将沿这四条链路展开 allocation、传输和失败清理。
+
+| 链路 | 核心流程 | 完成或收敛点 |
+| --- | --- | --- |
+| 写入与 SSD commit | master 分配 source/target;`PutDone` 发布内存副本;owner 随后异步写入 SSD。 | 公共 `put` 操作在内存副本发布后完成。SSD commit 只有在版本、memory 和 tomb 条件仍成立时才进入 `route`。 |
+| 内存命中读取 | requester 复用本地副本,或从远端 memory owner pull 到最终 target。 | `GetDone` 把 target guard 转入 `get_holding`,external 根据 metadata 构造 `MemHolder`。 |
+| SSD 回填读取 | 当前版本没有可读内存副本时,本地路径直接读入 requester target;远端路径读入 source staging 后按 chunk push。 | 两条路径复用同一个 `GetDone` 和 holder 交付链。 |
+| SSD 驱逐与陈旧 route 兜底 | owner 覆盖 ring entry 后批量通知 master;读取端仍会复查本地 entry。 | 批量通知收敛正常驱逐;陈旧 source 读取失败时执行 revoke 并有界重新选路。 |
+
+payload 不经过 master。master 只维护逻辑路由和 allocation 生命周期;SSD shard、offset、IO 对齐与 ring 状态只在 owner 本地解析。
+
+### 1.3 设计取舍与当前边界
+
+引入 SSD 后,需要先明确三项职责边界:
+
+1. **同步语义**:SSD 不改变 `put` / `get` 操作的同步语义。
+2. **路由信息**:master 的 `route` 不记录 SSD 物理布局。
+3. **恢复能力**:SSD 不承担重启后的数据恢复。
+
+在这些边界内,本设计保持现有公共 API 和内存副本语义,利用 owner 本地 SSD 扩大运行期缓存容量,并在没有可读内存副本时提供回填来源。文件布局和 IO 复杂度均封装在 owner 内部。
+
+因此,当前 SSD 被定位为运行期回填层。它不向用户暴露独立的 SSD API,也不支持单 value 跨 device 条带化。SSD 文件布局、IO 调度和 ring 生命周期均由 owner 管理,不进入公共 API 或 master route。
+
+| 设计选择 | 作用 | 代价 | 当前边界 |
+| --- | --- | --- | --- |
+| master 不保存 SSD offset | 跨节点控制面只处理 key-version、owner、payload 长度和 tomb tag 等逻辑信息。 | master 无法据此检查本地物理布局;陈旧 route 要靠节点 tomb、批量清理与读取兜底收敛。 | **route 是逻辑索引,不是恢复元数据**。 |
+| `PutDone` 不等待 SSD | 同步写入只等待内存副本,SSD 背压留在 owner 本地 persist 路径。 | SSD 副本是 late commit,需要用 `put_id` 拒绝旧版本 commit,并在 master 处理 commit RPC 的窗口 pin 住本地 entry。 | **内存副本 ready 是同步提交点**。 |
+| owner 本地驱逐 | ring 决定物理覆盖顺序,再批量通知 master 清理逻辑 route;发送失败的当前 batch 会重试。 | 有界队列满、进程退出或 master 持续不可用时,master 可能保留陈旧 route。 | **读取失败会 revoke 并有限重试**。 |
+| `O_DIRECT + io_uring`,而非 `mmap` / PageCache | KV SSD 路径绕过 PageCache,直接提交对齐 IO。 | 实现必须协调 staging 地址、文件 offset、IO 长度、aligned buffer、ring 覆盖和 read pin。 | **文件 IO 实现封装在 owner**。 |
+| 本地 SSD 回填 | SSD owner 与 requester owner 相同时复用最终 target,不申请独立 source staging。 | target capacity 必须覆盖对齐 IO;地址不满足 direct read 条件时仍会经过 scratch copy。 | **direct 条件成立时省掉本地二次 copy**。 |
+| SSD owner 主动推送 | chunk ready 后由 SSD owner 直接推到 requester target,读盘和网络传输可重叠。 | requester 必须先通过 `SsdStageReadReq` 提供 target 地址和 `get_id`。 | **省去 stage-ready 后的一次 RTT**。 |
+| value 级 device 选择 | 单 value 使用一个 device 上的连续文件区间,不需要跨 device 组合读结果。 | 单个大 value 不跨 device 条带化。 | **多 device 并行发生在 value 粒度**。 |
+| 启动时重建空 shard 和 ring | 明确 SSD 只作为运行期缓存:不扫描 shard,也不通过 WAL 或 checkpoint 恢复 entry。 | 重启后需要由新写入重新形成 SSD 副本。 | **不支持冷启动恢复**。 |
+| scratch 读路径 | 不满足 direct read 对齐时仍能正确读取真实 payload。 | 该 chunk 多一次本地 copy。 | **padding 不进入用户 holder**。 |
+
+## 2. 配置与观测:容量按介质分层可见
+
+KV SSD 复用 owner 已有的 `large_file_paths`,不增加第二套路径参数,以保持配置项尽可能简洁。`large_limit_size` 与这些路径一一对应;运行时按实际 device 去重,并分别上报内存与 SSD 的容量和占用。
+
+### 2.1 配置入口
+
+配置仍位于 owner 的 `fluxonkv_spec`;`large_limit_size` 与 `large_file_paths` 等长:
+
+```yaml
+fluxonkv_spec:
+ large_file_paths: [/data/fluxon_large0, /data/fluxon_large1] # Derive one KV SSD cache root from each path.
+ large_limit_size: ["4GB", "8GB"] # Limit the matching cache roots in the same order.
+```
+
+- **启用条件**
+ - `large_limit_size` 未配置或为 `null`:不启用 KV SSD。
+ - `large_limit_size` 已配置:必须与 `large_file_paths` 长度一致,数组下标一一对应。
+- **容量值格式**
+ - 可以使用整数 bytes。
+ - 也可以使用 `"512.9B"`、`"1.5GB"` 这类 size 字符串。
+ - `MB/GB` 按二进制单位解析,等价于 `MiB/GiB`;小数结果按 bytes 向下截断。
+ - 解析结果必须大于或等于 512 bytes,满足当前 `O_DIRECT` 对齐约束。
+- **external 约束**
+ - 不贡献存储容量的 external 不能声明 `large_limit_size`。
+ - external 只继承 owner 暴露的共享 bundle 和运行时路径信息,不贡献 SSD 容量。
+
+### 2.2 路径派生与设备去重
+
+owner 不读取单独的 SSD 路径参数。每个 SSD cache root 都从对应的 `large_file_paths[i]` 派生:
+
+```text
+/_cluster_kv_ssd_storage//
+```
+
+- **创建 root**:owner 创建派生目录,再通过 `metadata.dev()` 获取实际 device 身份。
+- **同设备去重**
+ - 多个 path 指向同一实际 device:只保留第一个 root。
+ - 如果不去重,同一块盘上的两个目录会被误认为两条独立 IO 路径,导致调度层高估并行度。
+
+### 2.3 容量、ring 与 per-device 对象
+
+- **容量作用域**
+ - `large_limit_size[i]` 只约束 `large_file_paths[i]` 派生的 KV SSD cache root。
+ - 它限制 key-value payload 在 KV SSD ring 中的用量,不代表整块 SSD 的物理容量。
+ - 日志、profile、FS disk cache 等其他大文件资产不受该值约束。
+- **ring 空间不足**
+
+ **ring 是构建在定长 shard 文件之上的循环空间分配器和本地 key-version 索引。** 每个 shard 用 `head` 为新 value 分配对齐后的连续区间;写到文件末尾时,物理 offset 回到文件开头继续复用空间。`tail` 随覆盖边界向前推进,回收最旧且已经可以覆盖的 entry。ring 同时记录 entry 的 `Writing/Committed` 状态、read pin 和 route commit pin,遇到仍在写入、读取或提交 route 的区间时会等待,不会直接覆盖。
+
+ ring 的作用是在固定容量内持续复用 SSD cache 空间,并把被覆盖的 key-version 交给 owner 后台通知 master 收敛逻辑 `route`。文中的 ring 与 `io_uring` 职责不同:前者管理文件区间、entry 状态和驱逐顺序,后者负责提交实际的文件读写。第 6.2 节再展开状态转换和覆盖保护。
+
+ 选择 ring 的直接工程动机,是让每个 shard 内的写入尽量保持追加式、对齐且连续。`head` 单向推进,空间用完后再回绕覆盖,省去了随机空洞分配、碎片合并和后台 compaction。现代 NVMe 也能处理随机 IO;这里更看重 `O_DIRECT` 连续写入、固定容量复用和回收路径的可预测性。
+
+ Moka 与 ring 解决的是不同层次的问题:
+
+ | 机制 | 当前职责 | 无法替代的另一层能力 |
+ | --- | --- | --- |
+ | Moka | master 侧按 weight 管理内存副本的准入和驱逐。 | 不分配 SSD 文件 offset,也不管理 `Writing/Committed`、read pin 或 route commit pin。 |
+ | ring | owner 侧分配连续 SSD 区间,并按物理写入顺序回收和覆盖。 | 不提供 Moka 的访问热度与内存准入策略。 |
+
+ 如果直接用 Moka 选择任意 SSD key 驱逐,被删除 value 的文件区间会形成不连续空洞,仍需额外实现 extent allocator、空洞合并或 compaction。Moka 可以叠加为逻辑淘汰策略,但不能替代 ring 对 SSD 物理空间的管理。
+
+ - 目标区域可以覆盖:ring 推进 tail 并驱逐旧 entry。
+ - 目标区域仍有未完成写入、active read pin 或 route commit pin:当前写入等待空间释放。
+- **`KvSsdStorage`**
+ - 持有一组去重后的 device root。
+ - 每个 device root 都独立持有:
+ - **shard 文件集合**:存放 ring 分配的连续 SSD offset。
+ - **writer queue**:接收本 device 的写任务。
+ - **reader queue**:接收本 device 的读任务。
+ - **`UringIoEngine`**:提交本 device shard 文件上的 `read/write`;`Iovec` 只作为测试和对照路径保留。
+- **shard 到 device 的映射**
+ - 每个 device root 的 `large_limit_size` 会划分给该 device 的多个 shard。
+ - `shard_to_device` 记录 shard 所属 device。
+ - 读路径根据 committed entry 的 `shard_id` 找回对应 reader queue。
+
+### 2.4 能力标记与容量观测
+
+启用 KV SSD 的 owner 会在 `ClusterMember.metadata` 中发布 `kv_ssd_storage=true`。master 在 `PutStart` 时读取当前 live client members,并按下表确定 target 候选集和后续 SSD persist 决策:
+
+| live member 状态 | put target 候选集 | `PutDone` 后的处理 |
+| --- | --- | --- |
+| 没有 owner 声明 `kv_ssd_storage=true` | 所有具有已注册内存 allocator 的 live owner。 | `persist_to_ssd=false`,跳过 `SsdReplicaPersistReq`,按纯内存模式完成。 |
+| 至少一个 owner 声明 `kv_ssd_storage=true` | 同时具有内存 allocator 和该标记的 live owner。 | `persist_to_ssd=true`,向选中的 target owner 异步发送 `SsdReplicaPersistReq`。 |
+
+master 把这次决策保存在 `InflightPutInfo` 中,`PutDone` 不重新推断。该标记只表达 owner 是否具备 KV SSD 能力,不暴露本地 shard、offset 或容量;容量和占用仍通过指标单独上报。target owner 收到 persist 请求后还会检查本地 `KvSsdStorage`,用于兜住 member capability 快照与 owner 实际状态之间的竞态。
+
+owner 按资源类型分别上报 capacity/used,UI 也分别展示内存和 SSD:
+
+- **`memory_segment`**
+ - 指标:`kvcache_segment_capacity_bytes` / `kvcache_segment_used_bytes`。
+ - 含义:owner 可直接承载 `MemHolder` 的内存 segment 容量和占用。
+- **`kv_ssd`**
+ - 指标:`kv_ssd_capacity_bytes` / `kv_ssd_used_bytes`。
+ - 含义:owner 本地 SSD 介质层的容量和 ring 已用空间。
+
+## 3. 写入:先内存可见,再异步进入 SSD
+
+**`put` 操作的同步完成点是内存副本发布。** `PutDoneResp` 返回后,用户已经可以读到该副本。SSD 对齐、写盘、ring 空间等待和 route 提交都在后台执行。
+
+SSD persist 复用内存 target 所在 owner 的本地地址,因此 master 选择内存 target 时也同时决定是否生成 SSD 副本以及由哪个 owner 写盘。这里分为三层:
+
+| 层次 | 行为 | 分支定位 |
+| --- | --- | --- |
+| 能力上报 | owner 已通过 member metadata 上报 `kv_ssd_storage=true`。内存 segment 注册继续只描述 master 可分配的地址区间;SSD 文件区间仍由 owner 本地管理。 | master 已经能判断 owner 是否具备 KV SSD 能力,无需通过 persist RPC 探测。 |
+| master 决策 | live SSD-capable owner 集合为空时使用纯内存 placement;集合非空时,target 必须同时具有内存 allocator 和 SSD capability。决策结果以 `persist_to_ssd` 写入 `InflightPutInfo`。 | 无 SSD owner 时不发送 persist RPC;启用 SSD placement 时不会主动选择无 SSD owner。 |
+| owner 侧防御 | target owner 在处理 persist 请求时再次检查本地 `KvSsdStorage`。 | `persisted=false` 只覆盖 capability 快照过期、owner 重启或重新注册等竞态,不参与正常能力探测。 |
+
+一次写入分为三个阶段:
+
+| 阶段 | 动作 | 完成后的状态 |
+| --- | --- | --- |
+| 1. 填充 target | master 根据 live member capability 和已注册内存 allocator 生成 placement plan,并在 `InflightPutInfo` 中记录 `persist_to_ssd`。系统先将 payload 写入 `owner-put-source` 的 range。对于本地 placement,系统直接复用该 range;对于远端 placement,系统再将 payload 传输到 `owner-put-target`。 | payload 已到 target 地址,SSD persist 决策已经固定,尚未对其他读取发布。 |
+| 2. 发布内存副本 | `owner-requester` 发送 `PutDoneReq`。master 将 target guard 转入 route 的 `memory`,并释放不再需要的 source staging。 | master 返回 `PutDoneResp`;公共 `put` 操作完成,内存副本可读。 |
+| 3. 按 placement 决策处理 SSD | `persist_to_ssd=false` 时跳过 SSD RPC;为 `true` 时,master 保活 target 并发送 `SsdReplicaPersistReq`。target owner 再确认本地 `KvSsdStorage` 可用,然后对齐 payload、写入 shard,并通过 `SsdReplicaCommitReq` 提交 SSD route。 | 纯内存模式直接进入内存 cache;SSD commit 校验通过后 `ssd` 字段可用。竞态兜底、persist 失败或过期 commit 都不回滚第 2 阶段。 |
+
+下图按公共 `put` 操作的语义展开这三个阶段。图中省略 external 使用的 `ExternalPutStartReq`、共享内存写入和 `ExternalPutTransferEndReq` 包装层。常规路径将 `owner-put-source` 合并到 `owner-requester`;side-transfer 的分离边界见第 1.1 节。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant external_user as external-user
+ participant owner_requester as owner-requester
+ participant owner_put_target as owner-put-target
+ participant master_route as master-route
+ participant owner_ssd as owner-ssd-storage
+ participant owner_ring as owner-ssd-ring
+
+ external_user->>owner_requester: put(key, value)
+ owner_requester->>master_route: PutStartReq
+ master_route->>master_route: 读取 live member 的 kv_ssd_storage capability
+ alt 存在 SSD-capable owner
+ master_route->>master_route: target = memory allocator ∩ SSD owner;persist_to_ssd = true
+ else 不存在 SSD-capable owner
+ master_route->>master_route: target = memory allocator;persist_to_ssd = false
+ end
+ master_route->>master_route: 分配并保活 InflightPutAllocation
+ master_route-->>owner_requester: source / target node-address plan
+ alt target 与 requester/source 同 owner
+ owner_requester->>owner_requester: payload 写入本地 target range
+ else target 在其他 owner
+ owner_requester->>owner_put_target: source/staging → target range
+ end
+ owner_requester->>master_route: PutDoneReq
+ master_route->>master_route: target guard 转入 memory route;远端 source guard 释放
+
+ par 返回公共 put
+ master_route-->>owner_requester: PutDoneResp
+ owner_requester-->>external_user: put 完成,内存副本可读
+ and PutDone 后的介质处理
+ alt persist_to_ssd = false
+ master_route->>master_route: 跳过 SSD RPC;内存副本进入驱逐 cache
+ else persist_to_ssd = true
+ master_route->>master_route: 保活 target allocation 并预留 cache capacity
+ master_route->>owner_ssd: SsdReplicaPersistReq(key, put_id, target_addr, len)
+ alt owner 本地无 KvSsdStorage(capability 状态竞态兜底)
+ owner_ssd-->>master_route: SsdReplicaPersistResp(persisted = false)
+ else owner 本地 KvSsdStorage 可用
+ owner_ssd->>owner_ring: 对齐、分配 offset 并写 shard
+ alt 本地 persist 失败
+ owner_ring-->>owner_ssd: write / ring error
+ owner_ssd-->>master_route: persist error
+ else 本地 persist 成功
+ owner_ring-->>owner_ssd: Committed entry + KvSsdPersistGuard
+ owner_ssd->>master_route: SsdReplicaCommitReq
+ master_route->>master_route: 校验 key、put_id、memory 和 tomb tag
+ alt route 仍然有效
+ master_route->>master_route: 更新对应 owner entry 的 ssd 字段
+ master_route-->>owner_ssd: SsdReplicaCommitResp(OK)
+ else route 已删除或版本已变化
+ master_route->>master_route: 忽略 late commit,不更新 route
+ master_route-->>owner_ssd: SsdReplicaCommitResp(OK)
+ end
+ owner_ssd->>owner_ring: 释放 route commit pin
+ owner_ssd-->>master_route: SsdReplicaPersistResp(persisted = true)
+ end
+ end
+ master_route->>master_route: 释放临时预留,内存副本加入驱逐 cache
+ end
+ end
+```
+
+除 SSD capability 兜底外,后台路径还需要处理三类并发风险:
+
+| 风险 | 机制 | 保证 |
+| --- | --- | --- |
+| persist 期间 target 被释放或容量超卖 | `Arc` 保活 target,capacity reservation 按 allocation capacity 扣减 cache 预算。RAII guard 覆盖成功、失败、取消和 master 关闭。 | persist 始终引用有效 allocation,容量账本与生命周期一致。 |
+| 旧版本完成得更晚 | master 用 `put_id` 校验 late commit。 | 旧 SSD 副本不会进入新版本 route。 |
+| 本地已写完,master 尚未处理完 commit RPC | `KvSsdPersistGuard` 在该窗口期 pin 住 ring entry。 | commit RPC 返回前,对应 SSD bytes 不会被 ring 覆盖;返回 `OK` 不代表 route 一定已发布。 |
+
+### 3.1 route 数据结构和 SSD commit 条件
+
+`OneKvNodesRoutes` 中与写入可见性直接相关的结构如下,其他字段已省略:
+
+```rust
+pub struct OneKvNodesRoutes {
+ pub put_id: PutIDForAKey, // Bind all replicas to one key version.
+ // ...
+ pub node_replicas: RwLock>, // Index both cache layers by owner.
+ // ...
+}
+
+pub struct KvNodeReplicas {
+ pub tomb_tag: NodeTombTag, // Track this owner incarnation once.
+ pub memory: Option>, // Hold the readable memory replica when present.
+ pub ssd: Option, // Record the readable SSD replica when present.
+}
+
+pub struct KvSsdReplicaInfo {
+ pub len: u64, // Preserve the real payload length.
+}
+```
+
+`node_replicas` 中的每个 owner 只对应一个 entry:
+
+- **owner 身份**:来自 `HashMap` 的 `NodeID` key,不在 entry 内重复保存。
+- **节点实例**:`tomb_tag` 绑定本次 owner incarnation;节点离开或重启后,旧副本不能被新实例复用。
+- **介质状态**:`memory` 和 `ssd` 独立更新。内存驱逐只清空 `memory`,SSD ring 驱逐或读取失败只清空 `ssd`;两者都为空时才删除 entry。
+
+owner 本地写盘成功后仍需提交 `SsdReplicaCommitReq`。master 只在以下条件全部满足时写入 `KvSsdReplicaInfo { len }`:
+
+- 当前 `kv_routes` 仍有该 key,且 route 的 `put_id` 与请求一致。
+- 对应 owner 的内存副本仍然存在。
+- owner 的 tomb tag 仍然 live。
+
+`SsdReplicaCommitResp` 当前不区分“已发布”和“已忽略”。route 缺失、版本变化、memory 缺失或节点 tomb 时,master 会忽略 commit,但仍返回 `OK`。因此,owner 侧的 `persisted = true` 只表示本地 entry 已写入且 commit RPC 无协议错误,不能确认 master route 一定包含该 SSD 副本。
+
+SSD persist 可能晚于同 key 的后续覆盖写。`put_id` 校验会丢弃旧版本 late commit,避免它进入新版本 route。`KvSsdPersistGuard` 从本地 commit 开始保活 entry,直到 master 处理完这次 commit RPC;无论 route 最终发布还是请求被忽略,guard 都会释放。
+
+## 4. 读取:内存优先,SSD 只做回填
+
+**只有当前 key-version 没有可读内存副本时,master 才选 SSD。** 以下流程从 owner 本地 `get_cached_info` 未命中后开始。进入 route 路径后,`GetStartReq` 按下表为 `get` 操作选择 source:
+
+| 分支 | 触发条件 | payload 路径 | 完成方式 |
+| --- | --- | --- | --- |
+| 内存 | route 中存在可读 `memory` 副本。 | source 在 requester owner 时,master 复用对应的 replica guard,requester 无需搬运 payload;否则由 requester 从 `owner-memory` pull 到最终 target。 | requester 发送 `GetDoneReq`。owner 返回 holder metadata,external 再构造公共 `MemHolder`。 |
+| 本地 SSD | 没有可读内存副本,且 SSD source 就在 requester owner。 | shard 读到最终 target。满足 direct read 条件时直接写入;否则经过 owner 内部 scratch buffer 复制一次。 | requester 发送 `GetDoneReq`,后续 holder 交付与内存分支相同。 |
+| 远端 SSD | 没有可读内存副本,且 SSD source 在其他 owner。 | `owner-ssd-source` 按 chunk 读入本地 staging,每个 chunk ready 后主动 push 到 requester target。 | SSD source owner 发送 `GetDoneReq`;requester 用 `done_*` 字段构造同一套 holder metadata,再由 external 构造公共 `MemHolder`。 |
+| 未命中 | 当前 key-version 没有可读的 memory 或 SSD 副本。 | 不传输 payload。 | 返回 `KeyNotFound`。 |
+
+本文把“回填”限定为“把 SSD payload 写入 requester 的最终 target”。staging 是远端 SSD source owner 上的临时、对齐内存;它不会成为用户的 `MemHolder`。
+
+下图展开四条 route 分支、数据面 range 的位置和 master 侧 allocation 控制关系。图中已折叠 owner 本地 `get_cached_info` 未命中之前的包装层。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant external_user as external-user
+ participant owner_requester as owner-requester
+ participant master_route as master-route
+ participant owner_memory as owner-memory
+ participant owner_ssd as owner-ssd-source
+ participant owner_media as owner-local-ssd
+
+ external_user->>owner_requester: get(key)
+ owner_requester->>master_route: GetStartReq
+ master_route->>master_route: 读当前 key-version 的 node_replicas 快照
+
+ alt 存在可用 memory 副本
+ master_route-->>owner_requester: memory source + requester target plan
+ alt source 就在 requester owner
+ owner_requester->>owner_requester: 复用本地 replica range
+ else source 在其他 owner
+ owner_requester->>owner_memory: transfer_data_no_copy (pull)
+ owner_memory-->>owner_requester: payload 写入最终 target
+ end
+ owner_requester->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,必要时 promotion
+ master_route-->>owner_requester: GetDoneResp(holder_id)
+ owner_requester-->>external_user: ExternalMemHolderInfo;external 本地构造 MemHolder
+ else 无 memory 副本,但存在可用 SSD 副本
+ master_route->>master_route: 在 requester segment 分配并保活最终 target
+ alt SSD source 在其他 owner
+ master_route->>master_route: 在 SSD source segment 分配并保活对齐 staging
+ end
+ master_route-->>owner_requester: SSD source + target + stage plan
+ alt SSD source 就在 requester owner
+ owner_requester->>owner_ssd: 本地 load_and_push_kv_from_ssd 调用
+ owner_ssd->>owner_media: pin entry 并读取 shard (direct or scratch)
+ owner_media-->>owner_ssd: IO 完成,最终 target 已写入
+ owner_ssd-->>owner_requester: 本地读取完成
+ owner_requester->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,必要时 promotion
+ master_route-->>owner_requester: GetDoneResp(holder_id)
+ else SSD source 在其他 owner
+ owner_requester->>owner_ssd: SsdStageReadReq(get_id, stage, target, len)
+ loop 每个 payload chunk
+ owner_ssd->>owner_media: pin 并读入 source staging
+ owner_media-->>owner_ssd: IO 完成,staging chunk ready
+ owner_ssd->>owner_requester: push chunk 到 target offset
+ end
+ owner_ssd->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,必要时 promotion
+ master_route-->>owner_ssd: GetDoneResp(holder_id)
+ owner_ssd-->>owner_requester: SsdStageReadResp(done_*)
+ end
+ owner_requester-->>external_user: ExternalMemHolderInfo;external 本地构造 MemHolder
+ else 当前 key-version 没有可读副本
+ master_route-->>owner_requester: KeyNotFound
+ owner_requester-->>external_user: miss
+ end
+```
+
+三处实现边界需要单独说明:
+
+- **本地 SSD 回填**:`src_addr == target_addr`,控制面不申请 source staging。地址、offset 和容量满足 direct read 条件时,`KvSsdStorage` 直接写最终 target;否则内部 scratch fallback 会增加一次本地 copy。
+- **远端 SSD staging**:master 先计算 `ssd_stage_len = align_ssd_io_len(len)`,再从 SSD source owner 已注册的 segment 中额外分配 `SSD_ALIGNMENT - 1` bytes,从 allocation range 内取得 512-byte 对齐的 `src_addr`。requester 只发送一次 `SsdStageReadReq`,后续 chunk 均由 SSD owner 主动 push。
+- **陈旧 SSD route**:读取失败后先 revoke 当前 source,再重新选路;该失败不会向用户返回半成品 holder。
+
+### 4.1 target 分配与有界背压
+
+`get` target 遇到内存压力时按以下顺序处理:
+
+1. **遍历 allocator**:先尝试所有可用 allocator,不在单个 allocator 上连续空转。
+2. **推进回收**:运行该 owner 的 replica Moka cache、`inflight_puts` 和 `inflight_gets` maintenance tasks,然后立即重试。`Cache::remove()` 会让 entry 立即不可查询,但 RAII value 可能仍在维护队列中保活 allocation。maintenance 完成后,空间才真正释放。
+3. **等待外部释放**:仍无空间时,让出执行权并等待 route 删除、owner 缓存失效 RPC 或 holder ACK。重试间隔为 2 ms,总等待上限为 5 s。
+4. **有界失败**:5 s 后仍无空间才返回带 total/free capacity 的 `NoSpace`;等待不会扩大 owner 的配置容量。
+
+新 target 在传输期间尚未进入驱逐 cache,但已经占用 requester owner 的 segment。master 为可提升为内存副本的 durable target 立即预留 allocation capacity,并通过 `InflightGetInfo` 或 allocation 的 on-drop callback 保活 reservation。
+
+这份预留与异步 SSD persist、lease 共用 `cache_reserved_bytes`,避免把在途 `get` 占用误算为空闲预算。`GetRevoke`、60 秒 in-flight TTL 或 `GetStartResp` 发送失败都会释放它。
+
+temporary target 不进入 Moka。`GetDone` 后,master 的 `get_holding` 继续持有 target guard,直到 owner 侧最后一个 `MemoryInfo` 释放并向 master ACK,或 member-left 清理该 requester。requester owner 的非 cache 空间因此要容纳并发 target,以及尚未走完释放链的 temporary holder。
+
+### 4.2 `GetDone` 与 target 生命周期
+
+- **非 lease 的 durable promotion 成功**:master 先更新 route,再释放独立的临时预留,并在同一次请求处理中按相同 weight 插入 Moka,避免 route 与容量索引不同步。
+- **带 lease 的 durable promotion 成功**:target 不进入 Moka;reservation 已绑定到 allocation 的 on-drop callback,随该 leased replica 的 allocation 一起存活。
+- **无法 promotion**:route 版本变化、节点 tomb 或 route 已删除时,target 降级为 temporary holder 并释放 durable slot。非 lease 路径的独立 reservation 随 `InflightGetInfo` 释放;lease 路径的 reservation 已绑定 allocation,会保留到 temporary holder 释放。
+- **所有成功路径**:`GetDone` 都把 requester target guard 写入 master 的 `get_holding`;远端 SSD source staging 和 memory source 的额外保活引用随 `InflightGetInfo` 释放。
+
+与 allocation 生命周期直接相关的字段如下:
+
+```rust
+pub struct InflightGetInfo {
+ pub put_id: PutIDForAKey, // Guard the selected key version.
+ pub src_node_id: NodeID, // Identify the selected source owner.
+ pub allocation: Arc, // Hold the requester target until GetDone.
+ pub source_allocation: Option>, // Keep a memory source or remote SSD staging alive.
+ pub route: Arc, // Keep the route and its durable slot alive.
+ pub allocation_mode: GetAllocationMode, // Decide whether promotion is allowed.
+ pub source_kind: GetSourceKind, // Select memory or SSD cleanup semantics.
+ pub cache_capacity_reservation: Option>, // Reserve durable target capacity.
+ // ... Other request identity and length fields.
+}
+```
+
+`InflightGetInfo` 的核心作用是在一次 `get` 内同时保活 target、source 和 route:
+
+- **`allocation`** 始终表示 requester segment 中的 target;`GetDone` 后由 `OwnerHoldingGetInfo` 继续保活。用户侧 `MemHolder` 引用这段 bytes,但不持有这个 master 侧 Rust 对象。
+- **`source_allocation`** 是 master 侧的保活引用:memory 路径用它保活内存副本,远端 SSD 路径用它保活 staging,本地 SSD 回填时为 `None`。`GetDone`、`GetRevoke` 或 in-flight TTL 清理都会释放该引用。
+- **`route` 与容量预留** 共同保证 durable promotion 期间的 route 生命周期和容量预算。非 lease 成功路径在同一次请求中更新 route、释放独立 guard 并插入 Moka;lease 路径则由 allocation callback 延续 reservation。
+- **`put_id`、`src_node_id` 和 `source_kind`** 限定失败清理范围,避免撤销新版本 route 或错误的介质副本。
+
+external 公共 `get` 的 holder 交付跨越三层对象。只有 master 的 `get_holding` 持有 allocation guard,不能把三层对象统称为“holder 持有 allocation”:
+
+| 层级 | 实际持有 | 正常释放链 |
+| --- | --- | --- |
+| master | `get_holding[(requester_owner_id, holder_id)]` 中的 `OwnerHoldingGetInfo`,内部持有 requester target 的 `Arc`。 | owner 的 delete-ack batch 通过 `BatchDeleteAckReq` 到达后移除;requester member-left 时也会批量清理。 |
+| requester owner | `external_get_holding[(external_client_id, holder_id)]` 中的 `Arc`。它引用 target metadata,未持有 master 进程里的 Rust guard。 | external holder 释放后收到 `ExternalDeleteAckReq` 并移除该引用;owner 侧最后一个 `MemoryInfo` 释放时把记录放入 delete-ack batch,再由 `BatchDeleteAckReq` 通知 master。`get_cached_info` 若仍有引用,master holding 会继续保留。 |
+| external process | 根据共享内存 base、`ExternalMemHolderInfo.offset/len` 和 `holder_id` 构造的公共 `MemHolder`。 | 对象释放时向 requester owner 发送 `ExternalDeleteAckReq`;它不直接访问或释放 master 的 `Allocation` 对象。 |
+
+holder 释放只移除 `get_holding` 这条引用。`ReuseReplica` 或 durable promotion 成功后,route 的 `memory` 仍持有独立的 `Arc`;该副本要经过缓存驱逐或 route 清理才会释放。temporary target 没有这条 route 引用。
+
+## 5. 读取子步骤——回填传输:本地复用 target,远端 source 主动 push
+
+上一节已经确定了读取 source、requester target 及其生命周期;进入实际搬运阶段后,memory 与 SSD 链路在 source 形态上分开。memory route 指向 owner 已注册 memory segment 中的现成 bytes,requester 取得 source 和 target 地址后即可发起 pull。SSD route 指向 shard 文件中的 entry,磁盘内容还不是 transfer engine 可直接使用的内存 source,必须先由 SSD owner 完成本地读盘。
+
+最终 target 始终位于 requester owner 的 memory segment,并由 master 侧 guard 保活。当 SSD owner 与 requester owner 为同一节点时,本地 IO 可直接写入 target。当两者不同时,本地 IO 无法跨 owner 写入 segment。此时,SSD owner 先用本地 source staging 接收 chunk,待 chunk ready 后主动 push 到远端 target。这个方向既由 SSD IO 与跨节点传输的边界决定,也让远端路径可以重叠读盘和网络传输。
+
+### 5.1 本地回填复用最终 target
+
+SSD owner 与 requester owner 在同一节点时,master 令 `src_addr == target_addr`,并把 target allocation 的实际 capacity 放进 `ssd_stage_len`。owner 随后调用 `load_into_addr`:满足 direct read 条件时,读盘直接写 target;否则由 `KvSsdStorage` 通过 scratch buffer 完成。两条分支都不需要控制面申请独立 source allocation。
+
+```rust
+if peer_id.is_none() && stage_addr == target_addr {
+ return store
+ .load_into_addr(
+ key, // Select the logical KV entry.
+ put_id, // Select the exact committed key version.
+ target_addr, // Read into the final range in the requester segment.
+ len, // Expose only the real payload length.
+ stage_len, // Provide the target allocation capacity for aligned IO.
+ )
+ .await;
+}
+```
+
+### 5.2 远端传输方向
+
+两条远端路径的数据都从 source 流向 requester target,差别在 transfer 发起方:
+
+- **内存:requester pull**:requester 已从 `GetStartResp` 取得远端 `src_addr` 和本地 `target_addr`,由它发起从 peer source 到本地 target 的 transfer。
+- **SSD:owner push**:requester 只发一次 `SsdStageReadReq`。SSD owner 每读好一个 chunk 就 push 到 requester target,省去 stage-ready 后的一次 RTT,并让读盘与网络传输重叠。
+
+`transfer_data_no_copy` 的方向位决定 peer 端地址在本次传输中是 source 还是 target。该参数只描述数据方向,不表示 peer 持有 master 侧的 `Allocation` guard。下面是删减后的接口:
+
+```rust
+pub async fn transfer_data_no_copy(
+ &self, // Use this node's transfer engine.
+ peer_node: Option, // Select the remote peer; None means local transfer.
+ peer_src_or_target: bool, // True: peer address is source; false: it is target.
+ src_addr: u64, // Provide the absolute source address.
+ target_addr: u64, // Provide the absolute target address.
+ len: u64, // Transfer this many real payload bytes.
+ seg_guard: Option, // Optionally keep the local segment alive.
+) -> KvResult;
+```
+
+方向位同时决定 peer 地址的语义和本地 segment guard:
+
+- **`true`**:peer 地址是 source,当前节点 pull 到本地 target;owner 侧 segment guard 守住 `target_addr`。
+- **`false`**:peer 地址是 target,当前节点从本地 source push;owner 侧 segment guard 守住 `src_addr`。
+
+远端 memory get 和远端 SSD 回填分别使用这两个方向:
+
+```rust
+// Memory get: requester owner initiates transfer with peer as source.
+client_transfer_engine
+ .transfer_data_no_copy(
+ Some(memory_owner_id), // Contact the owner that holds the memory replica.
+ true, // Interpret the peer address as the source.
+ remote_src_addr, // Read from the replica range in the peer's segment.
+ requester_target_addr, // Write into the requester's local target.
+ len, // Move only the real payload bytes.
+ None, // Let the engine acquire the local target guard.
+ )
+ .await?;
+
+// SSD refill: SSD owner pushes a ready chunk to requester target.
+let chunk_target_addr = requester_target_addr
+ .checked_add(chunk.offset) // Place this chunk at its payload offset.
+ .ok_or_else(|| KvError::Api(ApiError::InvalidArgument { // Convert overflow into a KV error.
+ detail: "chunk target addr overflow".to_string(), // Explain which address calculation failed.
+ }))?;
+
+client_transfer_engine
+ .transfer_data_no_copy(
+ Some(requester_owner_id), // Contact the requester whose segment contains the target.
+ false, // Interpret the peer address as the target.
+ chunk.stage_addr, // Read from this SSD owner's ready staging chunk.
+ chunk_target_addr, // Write to the matching offset in requester target.
+ chunk.len, // Move this chunk's real payload length.
+ None, // Let the engine acquire the local source guard.
+ )
+ .await?;
+```
+
+只有远端 SSD 回填会发送 `SsdStageReadReq`。请求把 key-version、`get_id`、staging 地址与容量、requester target 位置和真实 payload 长度交给 SSD owner:
+
+```rust
+pub struct SsdStageReadReq {
+ pub key: String, // Identify the logical KV entry.
+ pub put_id: PutIDForAKey, // Reject data from a different key version.
+ pub get_id: u64, // Bind the stage operation to its in-flight get.
+ pub stage_addr: u64, // Point to aligned staging on the SSD owner.
+ pub stage_len: u64, // State the available aligned staging capacity.
+ pub target_node_id: NodeIDString, // Identify the requester whose segment contains the target.
+ pub target_addr: u64, // Point to the final target range in that segment.
+ pub len: u64, // Preserve the real payload length.
+}
+```
+
+### 5.3 chunk pipeline 与背压
+
+远端 SSD owner 把读盘和传输拆成两个并发 future:
+
+- **producer**:按 chunk 从 SSD 读到 source staging。
+- **consumer**:收到 ready chunk 后立刻 push 到 requester target。
+
+两者通过有界 `mpsc` ready queue 连接。默认 chunk 大小为 4 MiB;producer 最多并发 4 个 SSD read,consumer 最多保留 4 个 transfer inflight,ready queue 容纳 8 个已就绪 chunk 描述。第一个 chunk 读好后即可开始网络传输。
+
+#### producer / consumer 实现片段
+
+```rust
+let ready_queue_capacity = DEFAULT_READ_TRANSFER_PIPELINE_INFLIGHT
+ .saturating_mul(2) // Buffer two transfer windows between reader and sender.
+ .max(1); // Keep the bounded channel valid for any configured window.
+let (chunk_tx, chunk_rx) =
+ ::tokio::sync::mpsc::channel(ready_queue_capacity); // Connect producer and consumer.
+
+let producer = store.load_into_addr_chunks(
+ key, // Select the logical KV entry.
+ put_id, // Select the exact committed version.
+ stage_addr, // Read chunks into aligned source staging.
+ len, // Stop after the real payload length.
+ stage_len, // Enforce the staging allocation capacity.
+ DEFAULT_READ_TRANSFER_PIPELINE_CHUNK_BYTES, // Bound each SSD read to 4 MiB.
+ DEFAULT_READ_TRANSFER_PIPELINE_INFLIGHT, // Allow up to four concurrent SSD reads.
+ chunk_tx, // Publish each ready chunk to the sender.
+);
+
+let consumer = self.transfer_loaded_ssd_chunks(
+ peer_id, // Select the remote requester; None means the target is local.
+ target_addr, // Use the final target range in the requester segment.
+ chunk_rx, // Consume chunks as soon as their SSD reads complete.
+);
+let (producer_res, consumer_res) = ::tokio::join!(
+ producer, // Run SSD reads concurrently with network transfers.
+ consumer, // Push ready chunks without waiting for the full value.
+);
+match (producer_res, consumer_res) {
+ (Ok(()), Ok(())) => Ok(()),
+ (_, Err(err)) => Err(err),
+ (Err(err), _) => Err(err),
+}
+```
+
+consumer 只在 transfer inflight 少于 4 时从 ready queue 取新 chunk。达到上限后,有界 queue 会把背压传给 producer;已提交的 transfer 完成后,consumer 再继续取 chunk。
+
+这条 pipeline 中有三种不同的保活机制:
+
+| 机制 | 保护对象 | 释放时点 |
+| --- | --- | --- |
+| ring read pin | SSD entry 与文件 offset,防止尚未完成的 SSD read 被 tail 覆盖。 | producer 完成全部 SSD read,并把最后一个 ready chunk 送入有界 queue 后。 |
+| `InflightGetInfo.source_allocation` | master 侧 guard,保活 SSD owner segment 中的整块 source staging range。 | `GetDone`、`GetRevoke` 或 in-flight TTL。 |
+| transfer segment guard | 某个在途 push 使用的本地 staging 地址范围。 | 对应 chunk transfer 完成。 |
+
+ring read pin 只覆盖 SSD 读取阶段,不覆盖整个网络 push 窗口。producer 释放 pin 后,尚未完成的 push 由 master 侧 staging guard 和每次 transfer 的 segment guard 保护。
+
+下图聚焦远端 SSD 回填内部的 pipeline;`GetStart/GetDone` 的完整流程见第 4 节。`owner-requester-target` 只表示数据传输端点,其 allocation 生命周期仍由 master 的 in-flight 状态和 `get_holding` 管理。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant owner_reader as owner-ssd-reader
+ participant owner_media as owner-local-ssd
+ participant owner_queue as owner-ready-queue
+ participant owner_sender as owner-ssd-sender
+ participant owner_target as owner-requester-target
+
+ owner_reader->>owner_media: pin committed ring entry
+ par read producer
+ loop 按 4 MiB chunk 读取
+ owner_reader->>owner_media: read chunk N into source staging
+ owner_media-->>owner_reader: IO complete, chunk N ready
+ owner_reader->>owner_queue: enqueue chunk N
+ end
+ owner_reader->>owner_media: 最后一个 ready chunk 入队后 release ring pin
+ and transfer consumer
+ loop 按 payload offset 推送
+ owner_queue-->>owner_sender: deliver chunk N
+ owner_sender->>owner_target: push chunk N
+ owner_target-->>owner_sender: transfer complete
+ opt transfer inflight 已达 4
+ owner_sender->>owner_queue: pause dequeue
+ owner_queue-->>owner_reader: bounded-channel backpressure
+ end
+ end
+ end
+ Note over owner_sender,owner_target: 未完成的 push 继续由 master staging guard 和 transfer guard 保活
+```
+
+## 6. owner 本地机制:对齐、ring 和 IO 调度
+
+**SSD 介质的控制引擎留在 owner。** SSD 文件 offset、`O_DIRECT` 文件 IO 对齐、ring 覆盖保护和 `io_uring` 调度都由 owner 处理。master 仍负责分配 requester target 和跨节点 staging,并传递相应地址、容量与真实 payload 长度。用户只看到真实 payload 语义。
+
+master 使用已注册的 owner segment allocator 为每次回填分配 requester target,并创建对应 guard;跨节点回填还会分配 SSD source staging。bytes 位于相应 owner 的 segment,RAII guard 位于 master。master 不记录 SSD 文件布局,用户侧仍通过 `MemHolder` 访问真实 payload。
+
+### 6.1 写入:区分真实长度和对齐长度
+
+**`len` 是 payload 长度,`aligned_len` 是 owner 本地 IO 长度。** target owner 从请求携带的 target 地址复制 payload,再构造 512-byte 对齐的写入 buffer。route 中的 `Arc` 在此期间保活 target 地址范围。
+
+| 字段 | 含义 | 使用范围 |
+| --- | --- | --- |
+| `len` | 真实 payload 长度。 | master route、`SsdReplicaCommitReq`、transfer 长度和 `MemHolder.len`。 |
+| `aligned_len` | `align_up(len, 512)` 得到的对齐长度。 | owner 本地 ring 分配、shard offset 推进和 `O_DIRECT` read/write。 |
+
+例如,1000-byte payload 的 `aligned_len` 是 1024 bytes:
+
+1. owner 创建 1024-byte 的对齐 `AlignedBuffer`,先置零,再把前 1000 bytes 填入 payload。
+2. ring 分配、shard offset 推进和 SSD 写入都使用 1024 bytes。
+3. `SsdIndexEntry` 同时记录 `len = 1000` 和 `aligned_len = 1024`;owner 向 master 提交的仍是 `len = 1000`。
+4. 后续读取会校验 `entry.len`,transfer 和 `MemHolder` 只暴露前 1000 bytes。末尾 24 bytes padding 留在 owner 内部。
+
+### 6.2 ring:从 `Writing` 到 `Committed`
+
+**只有完整写入的 `Committed` entry 才能参与读取;写入、route 提交或读取期间,ring 不会覆盖对应区间。**
+
+本地 SSD 引擎按 value 粒度选路:
+
+- 用 `next_write_device` round-robin 选择一个有效 device。
+- 把整个 value 发送到选中 device 的 writer queue。
+- 当前实现不把同一个 payload 拆到多块 device 做条带化。
+
+因此,多 device 并行发生在 value 粒度:不同 value 可以落到不同 device;一个 value 内部仍是某个 shard 的连续 offset。
+
+owner 本地 `SsdRingBuffer` 决定 SSD 文件位置。writer task 只在本 device 的 `shard_ids` 里选择 shard,并为对齐后的 payload 分配连续文件区间。ring 按以下规则分配:
+
+- `aligned_len = align_up(len, 512)`,ring 按对齐后长度分配空间。
+- 每个 shard 是环形空间,`head` 前进分配新写入,`tail` 表示可以覆盖到哪里。
+- 如果当前 shard 尾部剩余空间不足,会跳到文件开头继续找连续空间。
+- 如果推进 `tail` 会覆盖未完成写入、active read pin 或 route commit pin,返回 `BlockedByBusyIo`。
+
+分配完成后,状态转换、覆盖保护和 route 清理遵循以下规则:
+
+- **`Writing`**
+ - offset 已分配,SSD 写入尚未成功。
+ - 默认单 buffer 路径使用 `O_DIRECT` 和 `io_uring` write。
+- **`Committed`**
+ - 只有实际写入字节数等于 aligned buffer 长度,entry 才从 `Writing` 转为 `Committed`。
+ - `pin_read` 只接受 committed 且 offset 仍有效的 entry,未完成写入不会被 `get` 选中。
+- **ring 覆盖保护**
+ - route commit pin:保护本地 commit 到 master 处理完 commit RPC 的窗口。
+ - read pin:保护 SSD read 使用 entry 与 file offset 的窗口;远端 push 后半段由 staging allocation 和 transfer guard 保活。
+- **tail 驱逐通知**
+ - tail 推进时,ring 返回本次被覆盖的全部 committed `KvSsdKey`。
+ - writer 把它们送入有界失效队列,不在 SSD 写入热路径逐条调用 master。
+ - owner batch task 按 256 条或 10 ms 的阈值聚合 RPC;第 7 节展开 master 的条件清理和读取兜底。
+
+下图展示 owner 本地 `Writing → Committed` 与 master 发布 SSD route 之间的时序。`owner-memory-target` 只有前 `len` bytes 是真实 payload,其地址范围由 master route 中的 `Arc` 保活。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant master_route as master-route
+ participant owner_target as owner-memory-target
+ participant owner_writer as owner-ssd-writer
+ participant owner_ring as owner-ssd-ring
+ participant owner_engine as owner-io-engine
+ participant owner_media as owner-local-ssd
+
+ master_route->>owner_writer: SsdReplicaPersistReq(key, put_id, target_addr, len)
+ owner_writer->>owner_target: copy first len bytes
+ owner_target-->>owner_writer: real payload
+ owner_writer->>owner_writer: zero padding and build aligned_len buffer
+ owner_writer->>owner_ring: reserve contiguous aligned_len bytes
+ owner_ring-->>owner_writer: shard_id + file_offset + Writing entry
+ owner_writer->>owner_engine: O_DIRECT write(aligned buffer)
+ owner_engine->>owner_media: submit aligned_len bytes
+ owner_media-->>owner_engine: write completion(actual bytes)
+ owner_engine-->>owner_writer: completion
+
+ alt actual bytes == aligned_len
+ owner_writer->>owner_ring: Writing → Committed and acquire route commit pin
+ owner_ring-->>owner_writer: KvSsdPersistGuard
+ owner_writer->>master_route: SsdReplicaCommitReq(key, put_id, len)
+ master_route->>master_route: validate, then publish or ignore commit
+ master_route-->>owner_writer: SsdReplicaCommitResp
+ owner_writer->>owner_ring: release route commit pin
+ owner_writer-->>master_route: SsdReplicaPersistResp(persisted = true)
+ else partial write or IO failure
+ owner_writer->>owner_ring: abort Writing entry
+ owner_writer-->>master_route: SsdReplicaPersistResp(error)
+ end
+```
+
+### 6.3 读取:direct read 和 scratch fallback
+
+**direct read 是有条件的 owner 本地 fast path;任一对齐或容量条件不满足时,scratch read 负责兜底。**
+
+每个 chunk 的文件位置由 `entry.file_offset + offset` 得到。本地回填读入 requester target;远端回填读入 SSD source staging。
+
+| 路径 | 选择条件 | owner 本地动作 | 复制边界 |
+| --- | --- | --- | --- |
+| direct read | 读入地址和文件 offset 都按 512 bytes 对齐,target capacity 覆盖对齐后的 IO 范围。按 chunk 读取时,当前 read 长度也必须对齐。 | `io_uring` 直接把 shard 内容读入本地目标地址。 | 这段 SSD-to-target 读取没有额外的 owner 本地 copy;这不代表整条 `get` 链路都是零拷贝。 |
+| scratch read | 任一 direct read 条件不满足。 | 先读入 512-byte 对齐的 `AlignedBuffer`,再把当前 chunk 的真实 payload 复制到目标地址。 | 多一次 owner 本地 CPU copy。 |
+
+两条路径都只向下游暴露真实 payload 长度。`O_DIRECT` padding 不进入 transfer 长度,也不进入用户 `MemHolder.len`。
+
+下图只画 SSD source owner 内部的读盘。`owner-read-target` 始终位于 SSD source owner 本地:本地回填时,它同时是 requester target;远端回填时,它是 source staging。远端 requester 的最终 target 不在图中,后续 push 见第 5.3 节。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant owner_reader as owner-ssd-reader
+ participant owner_ring as owner-ssd-ring
+ participant owner_engine as owner-io-engine
+ participant owner_media as owner-local-ssd
+ participant owner_scratch as owner-scratch-buffer
+ participant owner_target as owner-read-target
+
+ owner_reader->>owner_ring: pin committed entry and request chunk plan
+ owner_ring-->>owner_reader: file_offset + len + aligned read len
+ owner_reader->>owner_reader: check address, offset, length, and capacity alignment
+
+ alt direct read 条件全部成立
+ owner_reader->>owner_engine: read shard directly into owner-read-target
+ owner_engine->>owner_media: submit O_DIRECT read
+ owner_media-->>owner_engine: IO complete, target memory filled
+ owner_engine-->>owner_reader: completion
+ owner_reader-->>owner_target: publish current chunk's real len
+ else 任一对齐条件不成立
+ owner_reader->>owner_scratch: acquire aligned buffer
+ owner_reader->>owner_engine: read shard into owner-scratch-buffer
+ owner_engine->>owner_media: submit O_DIRECT read
+ owner_media-->>owner_engine: IO complete, scratch memory filled
+ owner_engine-->>owner_reader: completion
+ owner_reader->>owner_target: copy only current chunk's real payload bytes
+ end
+ owner_reader->>owner_ring: release read pin
+ Note over owner_reader,owner_target: transfer 和 MemHolder 只看到真实 len,padding 留在 owner 内部
+```
+
+### 6.4 IO 调度:让回填读优先,同时保留后台写进展
+
+**同一个 device 上,SSD 回填读位于 `get` 的同步等待路径,SSD persist 写则在内存副本发布后异步进行。** 因此当前调度给读更多的 SQE 提交机会,但仍持续推进写。写长期不进展会延迟 SSD 副本 commit,并让 `Writing` entry 和对齐 buffer 继续占用资源;持续读下的绝对读优先还可能拖住 ring 后续复用空间。`read_weight = 2` 表达的是这种有界的读偏置。
+
+调度分成两层,各自处理不同问题:
+
+| 层次 | 当前机制 | 设计原因 |
+| --- | --- | --- |
+| device 任务层 | 每个去重后的 device 独立持有 writer queue、reader queue 和 `UringIoEngine`。writer task 管理 ring 分配以及 `Writing -> Committed/abort`;reader task 管理 chunk read 和 completion 复查。 | 在当前 owner 本地调度范围内,实际 device 是 IO 竞争边界。独立队列避免忙 device 在软件调度层阻住空闲 device,也让读写使用不同的上层并发上限和状态机。 |
+| `io_uring` 提交层 | 每个 `io_uring` 线程各有 read/write channel,在 `io_depth` 内选择下一个 SQE。 | 分开 channel 后,引擎可以在真正占用 SQE 槽位时仲裁读写,避免后到的回填读只能排在单一 FIFO 中的后台写之后。 |
+
+`io_depth` 在设备并行度与在途资源占用之间设置上界:多个已提交 SQE 可以让 SSD 持续有工作可做,上限则避免单个 `io_uring` 线程无界占用 buffer 和 completion context。真正能到达这一层的并发量,还会受 writer/reader task 各自 inflight 上限的约束。
+
+`read_weight = 2` 只在第二层生效。每个 `io_uring` 线程独立维护 `read_inflight` 和 `write_inflight`:前者小于等于后者的两倍时,先尝试 read channel;超过后,先尝试 write channel。首选 channel 为空时会立即取另一个 channel 的任务,不会为了维持比例而让 SQE 槽位空闲。
+
+这个 `2:1` 是固定的软调度偏置,不是硬上限或队列积压阈值。等号分支会再提交一个 read,completion 速率也会持续改变 inflight 组成;因此它不保证单个 device 上的精确 `2:1` 比例。它也不考察队列长度、请求大小、等待时间或 deadline。权重只决定下一个尚未提交的 SQE;已提交的 IO 不会被抢占,`io_depth` 已满时,新的回填读仍需等待 CQE 释放槽位。所以这是一个低成本的提交偏置,不构成读延迟 SLA。
+
+**`SingleBuffer` 是对当前数据形态的特化。** 写路径已经把 payload 放入连续的对齐 `AlignedBuffer`;direct read 和 scratch read 的目标也都是一段连续内存。默认 `KvSsdUringMode::SingleBuffer` 因而直接提交 `IORING_OP_READ` / `IORING_OP_WRITE`,避免构造并保活只含一个元素的 iovec。`Iovec` 保留为测试和对照路径。这项特化没有消除 `O_DIRECT` 对齐、padding、scratch copy 或网络传输,其收益范围仅限于 owner 本地 IO 提交层。
+
+read completion 复查与 read pin 也承担不同职责:
+
+| 机制 | 作用时段 | 职责 |
+| --- | --- | --- |
+| read pin | 从获取 committed entry 到本次读取结束。 | 阻止 `tail` 越过正在使用的文件区间,预防 offset 在 IO 期间被复用。 |
+| completion 复查 | CQE 返回后。 | 检查读取开始时捕获的 `entry.begin` 是否仍不早于当前 `tail`。已失效时删除 entry 并返回 `KeyNotFound`。 |
+
+read pin 负责预防覆盖,completion 复查负责拦住异常状态下的结果发布。复查无法撤销已经写入 target 的 IO,因而不能取代 pin;它是跨过异步提交边界后的最后一道不变量检查。在完整 `get` 路径中,返回错误后的 SSD stage 会进入第 7.2 节的 revoke 与有界重新选路,不会向用户交付这次 target。
+
+下图聚焦单个 device 内部的调度,不涉及 `master-*` 或 `external-*` 角色。`owner-kernel-io` 表示该 owner 文件上的 `io_uring` SQE/CQE 交互,不表示 owner 独占内核。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant owner_write as owner-write-path
+ participant owner_read as owner-read-path
+ participant owner_engine as owner-io-engine
+ participant owner_kernel as owner-kernel-io
+ participant owner_media as owner-local-ssd
+ participant owner_ring as owner-ssd-ring
+
+ par write task arrives
+ owner_write->>owner_ring: reserve offset and create Writing entry
+ owner_ring-->>owner_write: shard_id + file_offset
+ owner_write->>owner_engine: enqueue write request
+ and read task arrives
+ owner_read->>owner_ring: pin committed entry and resolve offset
+ owner_ring-->>owner_read: read plan + pin
+ owner_read->>owner_engine: enqueue read request
+ end
+
+ loop while this io_uring thread has a free SQE slot
+ owner_engine->>owner_engine: choose next unsent IO by the per-thread inflight mix, then fall back to the other channel
+ Note over owner_engine,owner_kernel: read_weight does not preempt submitted SQEs
+ alt write request selected
+ owner_engine->>owner_kernel: submit WRITE SQE
+ owner_kernel->>owner_media: O_DIRECT write
+ owner_media-->>owner_kernel: device completion
+ owner_kernel-->>owner_engine: write CQE
+ owner_engine-->>owner_write: actual bytes or error
+ owner_write->>owner_ring: commit or abort Writing entry
+ else read request selected
+ owner_engine->>owner_kernel: submit READ SQE
+ owner_kernel->>owner_media: O_DIRECT read
+ owner_media-->>owner_kernel: device completion
+ owner_kernel-->>owner_engine: read CQE
+ owner_engine-->>owner_read: actual bytes or error
+ owner_read->>owner_ring: check captured entry.begin is not before current tail
+ alt captured range 仍有效
+ owner_ring-->>owner_read: range valid, return chunk bytes
+ else captured range 已失效
+ owner_ring-->>owner_read: stale
+ owner_read->>owner_ring: remove stale entry
+ owner_read->>owner_read: return KeyNotFound
+ end
+ end
+ end
+```
+
+## 7. 一致性:沿用 key-version 和 holder 生命周期
+
+**SSD 副本只有在 master route 和 owner ring 都有效时才可读。** `get_holding` 保存的是 requester target 的 master 侧 `Allocation` guard;远端 source staging guard 只保留在本次 `InflightGetInfo` 中。
+
+一次 SSD 回填的完成语义包含以下四个方面:
+
+- **`GetDoneReq` 调用方**
+ - 本地 SSD source:requester 读盘并完成 target 写入后调用。
+ - 远端 SSD source:SSD owner 读盘并 push 到 requester target 后调用。
+- **最终 holder 与 allocation 生命周期**
+ - `inflight_gets.req_node_id` 始终指向 requester owner,不受 `GetDoneReq` 调用方影响。
+ - master 把 requester target guard 写入 `get_holding` 并生成 `holder_id`;target bytes 仍位于 requester owner 的 segment。
+ - 远端 source staging guard 不进入 `get_holding`,只随 in-flight get 生命周期释放。
+- **远端响应转发**
+ - SSD owner 把 `GetDoneResp` 中的 `holder_id`、`allocation_mode`、错误码和 server time 写入 `SsdStageReadResp.done_*`。
+ - requester 使用这些字段构造 owner 侧 `MemoryInfo` 和 `ExternalMemHolderInfo` 响应。external 再构造公共 `MemHolder`,用户看不到 SSD stage RPC。
+- **durable promotion**
+ - `GetStart` 尝试预留 durable slot。成功时 `allocation_mode = DurableReplica`,否则为 `Temporary`。
+ - `GetDone` 只在 allocation mode 为 durable、route `put_id` 仍匹配、且 requester tomb tag 仍 live 时,才把 target guard 写入 entry 的 `memory`。
+ - promotion 成功后,SSD 回填结果成为新的内存副本。
+
+### 7.1 owner 驱逐后批量清理 route
+
+为了让 master 中因物理驱逐产生的陈旧 route 尽快收敛,同时尽量减少逐条通知产生的控制面通信,owner 先完成物理驱逐,再聚合对应的 key-version,批量通知 master 清理逻辑 route:
+
+- **驱逐输入**:ring 推进 `tail` 时,一次返回本轮覆盖掉的全部 committed key-version。
+- **batch 触发**
+ - 第一条记录到达后,最多等待 10 ms。
+ - 累计到 256 条时立即发送。
+- **RPC 失败**:当前 batch 留在 task 中重试。
+- **队列或控制面不可用**
+ - 有界队列满、进程退出或 master 持续不可用,都不阻塞 SSD 写入。
+ - 可能残留的陈旧 route 由第 7.2 节的读取兜底处理。
+
+批量 RPC 只携带待清理的 key-version:
+
+```rust
+pub struct SsdReplicaEviction {
+ pub key: String, // Identify the route entry to inspect.
+ pub put_id: PutIDForAKey, // Restrict cleanup to the evicted key version.
+}
+
+pub struct BatchSsdReplicaEvictReq {
+ pub evictions: Vec, // Carry one owner-side eviction batch.
+}
+```
+
+master 收到 batch 后按以下规则清理 route:
+
+- **owner 身份**:请求不重复传 `node_id`,master 直接使用 RPC 发送方身份。
+- **版本门禁**:只有当前 route `put_id` 与驱逐项匹配,master 才清除该 owner entry 的 `ssd`。
+- **清理粒度**
+ - entry 仍有内存副本:只清除 `ssd`。
+ - `memory` 和 `ssd` 都为空:删除 owner entry。
+ - 整个 key-version 没有 live 副本:删除 `kv_routes`。
+- **prefix index**
+ - 在一次写锁内批量清理。
+ - 同名 key 已写入新 route:保留新版本 prefix entry。
+- **master 职责边界**:master 不参与 SSD offset 分配,也不决定本地驱逐顺序;只接收 owner 的驱逐结果并收敛逻辑 route。
+
+### 7.2 陈旧 SSD route 的读取兜底
+
+**SSD stage 失败后,requester 先撤销当前 source,再有界重新选路。** 重新选路可以继续使用其他 live source;没有副本时返回 miss。连续 3 次 SSD stage 失败后返回 error。任何失败分支都不会向用户暴露半成品 holder。
+
+SSD stage 失败时,请求方会调用:
+
+```rust
+GetRevokeReq {
+ get_id, // Revoke this in-flight get and release its allocations.
+ drop_ssd_source: true, // Clear the failed SSD option from the current route.
+}
+```
+
+master 按下列顺序撤销失败 source:
+
+- **释放本次 in-flight**:先删除 `inflight_gets`,让 target 和可选 staging 进入释放路径。
+- **限定 route 删除条件**
+ - `drop_ssd_source == true`。
+ - `source_kind == GetSourceKind::Ssd`;memory source 不会因该标志被删除。
+ - route `put_id` 与本次 in-flight get 仍一致;不撤销新版本 route。
+- **清理失败 source**
+ - 把 `src_node_id` 对应 entry 的 `ssd` 设为 `None`。
+ - entry 也没有内存副本:删除该 entry。
+ - 整个 key-version 已无 live 副本:删除 `kv_routes`;启用 prefix index 时,再异步清理对应索引。
+ - 失败 source 清理与主动 batch 驱逐共用同一个条件删除函数。
+- **重新选路**
+ - `GetRevokeResp` 返回后,请求方重新发送 `GetStartReq`。
+ - 下一次可能命中另一份内存副本、SSD 副本、同 key 新版本,或返回 miss。
+ - 陈旧 SSD source 最多触发 3 次重新选路,避免无限循环。
+ - SSD stage RPC 失败和不合法的 `ssd_stage_len` 都进入同一套 revoke 与重选流程。
+- **allocation 与崩溃清理**
+ - 远端 staging 保存在 `source_allocation`;本地回填时该字段为 `None`。
+ - `GetDone`、`GetRevoke` 或 in-flight TTL 驱逐都会释放远端 staging。
+ - requester 在 in-flight 阶段崩溃:节点 tomb 阻止副本 promotion 和旧节点复用。`inflight_gets` 最迟由 60 秒 TTL 释放 target/staging guard。
+ - requester 在 `GetDone` 后离开:master 的 member-left 清理移除该节点的 `get_holding`;正常路径则由 owner 的 holder delete-ack batch 释放。
+ - 远端 SSD owner 崩溃:其进程内 staging bytes 消失。master 中对应的 staging guard 随 `GetRevoke` 或 in-flight TTL 释放,节点 tomb 使旧 SSD route 不再可选。
+
+在 master 接受 `GetDoneReq` 之前发生的读取或传输失败会走 `GetRevoke`,target guard 不进入 `get_holding`,用户也不会拿到半成品 payload。
+
+另一个边界发生在 master 已提交 `GetDone`、但响应随后丢失时。此时 target guard 已写入 `get_holding`,requester 却可能没有收到 `holder_id`。
+
+当前通用 holder 协议没有按 `get_id` 幂等查询已提交结果的接口。这类未交付 holder 无法由随后的 `GetRevoke` 立即回收,只能等待能够识别该 holding 的后续清理,例如 requester member-left。这不影响 SSD 数据完整性,但属于当前完成协议的资源回收边界。
+
+下图展示成功完成、失败 revoke 和超时清理三条路径。远端路径由 `owner-ssd-source` 代替 requester 调用 `GetDoneReq`,但最终 target 始终位于 requester segment。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant external_user as external-user
+ participant owner_requester as owner-requester
+ participant master_route as master-route
+ participant owner_ssd as owner-ssd-source
+
+ owner_requester->>owner_ssd: SsdStageReadReq 或本地 stage 调用
+ alt SSD stage 成功,target 已完整
+ owner_ssd-->>owner_requester: 本地回填完成或最后一个 chunk 已 push
+ alt SSD source 就在 requester owner
+ owner_requester->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,释放其他 in-flight 引用
+ master_route-->>owner_requester: GetDoneResp(holder_id)
+ else SSD source 在其他 owner
+ owner_ssd->>master_route: GetDoneReq
+ master_route->>master_route: target guard 转入 get_holding,释放 staging 和其他 in-flight 引用
+ master_route-->>owner_ssd: GetDoneResp(holder_id, allocation_mode)
+ owner_ssd-->>owner_requester: SsdStageReadResp(done_*)
+ end
+ owner_requester-->>external_user: ExternalMemHolderInfo;external 本地构造 MemHolder
+ else SSD stage 失败
+ owner_ssd-->>owner_requester: stage error
+ owner_requester->>master_route: GetRevokeReq(drop_ssd_source = true)
+ master_route->>master_route: remove inflight_gets and release target / staging references
+ alt source_kind = Ssd 且 route put_id 仍匹配
+ master_route->>master_route: 清空当前 owner entry.ssd,必要时删除 entry / route
+ else source 类型或版本已变化
+ master_route->>master_route: 保留当前 route
+ end
+ master_route-->>owner_requester: GetRevokeResp
+ loop 陈旧 SSD source 最多重新选路 3 次
+ owner_requester->>master_route: GetStartReq
+ master_route-->>owner_requester: 其他 live source / 新版本 / miss
+ end
+ owner_requester-->>external_user: 重试结果、miss 或达到上限后的 error
+ end
+
+ opt 请求未进入 GetDone 或 GetRevoke
+ master_route->>master_route: 60 s in-flight TTL 释放 target / staging guard
+ end
+ opt requester 在 GetDone 后离开
+ master_route->>master_route: member-left 清理该节点的 get_holding
+ end
+```
+
+### 7.3 删除、驱逐与重启
+
+- **覆盖写与 delete**:覆盖写生成新 `put_id`,旧 SSD late commit 因版本不匹配被忽略。显式 delete 移除整个 `kv_routes` 条目;节点 tomb 清理则删除该节点在 `node_replicas` 中的整个 entry。
+- **两层独立驱逐**:内存驱逐只清空 `memory`;SSD ring 驱逐或读取失败只清空 `ssd`。两个字段都为空时才删除 owner entry,整个 route 没有副本时才删除 `kv_routes`。
+- **物理 bytes 不等于可读副本**:route 被删除或版本不匹配后,旧 bytes 即使还在 shard 中也不会被公共 `get` 命中。陈旧 route 被选中时,`pin_read` 会复查 committed 状态和 offset,失败后 revoke source 并重新 `GetStart`。
+- **owner 重启**:shard 以 `create + truncate` 打开,并重建空的本地 SSD cache 和 ring。当前没有 WAL、checkpoint 或 shard 扫描。控制面尚未完成 tomb 清理时,读取兜底会撤销失效 SSD source,再重新选路或返回 miss。
+
+## 8. 性能测试
+
+本节先给出核心结果,再说明计数边界与实验设置,最后分别展开 CPU、GPU 和 SSD 路径证据。性能数字只在对应小节定义的硬件、数据集、输出模式和统计窗口内成立。
+
+### 8.1 核心结果
+
+- **CPU / GPU 使用同一 pressure 口径**:CPU 主图包含 192 条记录,覆盖原生引用 / handle 与 `bytes`、4 个 payload、4 个并发度、Fluxon、两种 Mooncake 拓扑和 SSD 关/开;GPU 主图包含 72 条记录,覆盖 `cuda`、3 个 payload、4 个并发度、3 种拓扑和 SSD 关/开。264 条记录全部为 `SUCCESS`、0 error、来源完整且 unknown 为 0。
+- **miss 是结果,不是过滤条件**:两侧都使用约 2.5 GiB 数据集和 2 GiB DRAM。柱高只统计 hit payload,红点显示 `hit / (hit + miss)`。SSD-off 的 CPU 命中率为 Fluxon **89.112%–94.390%**、Mooncake 独立 owner **23.759%–66.879%**、Mooncake 每进程 **22.488%–68.655%**;GPU 对应范围为 **89.326%–93.267%**、**34.557%–62.223%** 和 **35.430%–64.782%**。
+- **SSD-on 也保留实际 miss**:CPU 的 Fluxon 和 Mooncake 独立 owner 在 SSD-on 下均为 100% 命中,Mooncake 每进程为 **72.830%–100%**;GPU 对应为 100%、100% 和 **59.855%–100%**。因此 SSD-on/off 柱差同时包含命中集合、介质来源和系统调度差异,不能解释成单一 SSD 开销。
+- **SSD-on 的 hit 同时来自两层**:CPU 中 Fluxon 的 SSD 来源占 hit 的 **17.550%–48.650%**,Mooncake 独立 owner 为 **62.065%–82.385%**,Mooncake 每进程为 **59.341%–83.011%**;GPU 对应为 **20.242%–46.508%**、**64.837%–77.135%** 和 **56.073%–84.198%**。
+- **GPU 多并发已进入同一张主图**:Fluxon 在 SSD-off 下从 c2 到 c16 提高到 5.83、5.83 和 4.68 倍,SSD-on 下提高到 6.50、7.22 和 6.80 倍,分别对应 4、8、16 MiB。Mooncake 的两种拓扑也按相同的六柱顺序展示,不再另画一张来源图。
+- **同路径 Foyer 消融下原生 SSD 仍然更快**:64 条测量记录对应 32 个原生/Foyer 配对,全部为 `SUCCESS`、0 error、0 unknown、0 persist failure,两侧命中率均为 100%。当前 Foyer adapter 的 hit payload 带宽是原生实现的 **14.8%–65.9%**,配对中位数为 **37.2%**,32 个配对中没有胜出;P95 是原生实现的 **2.30–8.46 倍**。这是当前接入路径的结果,不外推为 Foyer 库的通用性能结论。
+
+这些数字只覆盖下文定义的单机、固定容量、Zipf 分布、30 秒窗口和各自 fast path。实验不包括跨机器 RDMA、多物理盘条带化、重复运行的统计置信区间或真实业务 trace。
+
+### 8.2 计数边界:原生引用 / handle、`bytes` 与 `cuda`
+
+Mooncake 没有 Fluxon 意义上的 holder。`holder` 只是 benchmark 配置 `kv_get_output=holder` 的归一化模式名,Fluxon 和 Mooncake 实际返回 `MemHolder` 与 `BufferHandle`。
+
+Mooncake 常规 `Store.get()` 直接返回 Python `bytes`,因此结果包含完整 payload copy。`Store.get_buffer()` 返回 native `BufferHandle`,无需创建 Python `bytes` 即可取得 `ptr()`、`size()` 或 `memoryview`。原生引用 / handle 组把计数边界放在 host 内存引用返回之后。
+
+#### 两侧原生引用的调用与生命周期差异
+
+benchmark 中的实际分支等价于:
+
+```python
+if backend == "MOONCAKE":
+ # Internal adapter calls Mooncake Store.get_buffer().
+ result = mooncake_store.get_buffer_blocking(key) # Fetch a native BufferHandle.
+else:
+ result = fluxon_store.get_blocking(key) # Fetch a native MemHolder.
+
+native_ref = result.unwrap() # Keep the native host reference alive while counting success.
+```
+
+两者只在本次 benchmark 的**计数边界**上对齐,类型语义并不相同:
+
+| 对比维度 | Fluxon | Mooncake | 对齐方式 |
+| --- | --- | --- | --- |
+| 实际返回类型 | `MemHolder` | `mooncake.store.BufferHandle` | 本文直接写真实类型,不写“Mooncake holder”。 |
+| 底层调用 | `KvClient.get_blocking()` | `Store.get_buffer()`,由 benchmark adapter 包成 `get_buffer_blocking()` | 都在 native host buffer ready 后返回。 |
+| 数据访问 | `MemHolder.access()` | `ptr()`、`size()`、`memoryview(BufferHandle)` | 原生引用模式不 materialize;bytes / CUDA 模式再继续消费。 |
+| 生命周期语义 | 参与 Fluxon 的 `holder_id`、`get_holding` 和释放流程。 | 由 Mooncake Python binding 的 `BufferHandle` 管理返回 buffer 的生命周期。 | 只要求对象存活到本次操作计数完成。 |
+| 不对齐的部分 | Fluxon holder 协议。 | 没有 Fluxon 的 `holder_id/get_holding/revoke` 协议。 | 不声称 API 或生命周期协议等价。 |
+
+原生引用模式仍然包含后端选择 SSD route 时从 SSD 到 host 的搬运。它只省去随后将完整 payload 物化为 Python `bytes` 的额外 copy。`cuda` 从同一个 native holder 取得 pageable host pointer,再交给 Fluxon 与 Mooncake 共用的双槽 H2D 流水线。只有对应 CUDA event 完成后,这次操作才计为成功。
+
+下图覆盖一次 benchmark `get` 从发起到达到对应计数边界的过程。`external-backend-adapter` 是 benchmark 侧的统一适配层,封装完整的 Fluxon 或 Mooncake get;它不表示两套系统共享 Fluxon 的 owner/master 架构。
+
+- **`external-benchmark-worker`**:发起 KV get,按 `holder/bytes/cuda` 模式消费结果,并在该模式的完成点计数。
+- **`external-backend-adapter`**:在同一调用面上包住 Fluxon 或 Mooncake 的完整 get,并返回已驻留 host 的 `MemHolder` 或 `BufferHandle`。
+- **`external-host-output`**:表示 pageable host 上的 native reference 生命周期,以及 `bytes` 模式的完整 payload 物化。
+- **`external-pinned-slot`**:每个 benchmark worker 复用的两个 pinned host slot 之一。
+- **`external-cuda-runtime`**:对应 slot 的 CUDA stream 和 event,负责 `cudaMemcpyAsync` 提交与完成通知。
+- **`external-gpu`**:接收 payload 的 GPU device buffer;这次实验不计 D2H 或 kernel 执行。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant external_worker as external-benchmark-worker
+ participant external_backend as external-backend-adapter
+ participant external_host as external-host-output
+ participant external_pinned as external-pinned-slot
+ participant external_cuda as external-cuda-runtime
+ participant external_gpu as external-gpu
+
+ external_worker->>external_backend: get(key)
+ external_backend-->>external_worker: MemHolder or BufferHandle with host-resident payload
+ external_worker->>external_host: retain native reference until this operation is counted
+
+ alt holder output
+ external_worker->>external_worker: validate native reference and count success
+ external_worker->>external_host: release native reference
+ else bytes output
+ external_worker->>external_host: materialize the full payload as Python bytes
+ external_host-->>external_worker: bytes ready
+ external_worker->>external_worker: validate bytes and count success
+ external_worker->>external_host: release native reference
+ else cuda output
+ external_worker->>external_pinned: host memcpy from pageable payload into the next free slot
+ external_worker->>external_cuda: cudaMemcpyAsync(pinned slot, device buffer)
+ external_cuda->>external_gpu: start H2D DMA
+ par current H2D
+ external_gpu-->>external_cuda: matching CUDA event completes
+ external_cuda-->>external_worker: H2D complete
+ and next KV request
+ external_worker->>external_backend: get(next key)
+ external_backend-->>external_worker: next native host reference
+ end
+ external_worker->>external_worker: count current success after its event
+ external_worker->>external_host: release current native reference
+ end
+```
+
+| benchmark 输出模式 | Fluxon 映射 | Mooncake 映射 | 共同计数边界 |
+| --- | --- | --- | --- |
+| `holder`(归一化模式名) | `get_blocking()` 返回 `MemHolder`。 | `get_buffer_blocking()` 调用 Mooncake `Store.get_buffer()`,返回 `BufferHandle`;该对象暴露 `ptr()`、`size()` 和 buffer protocol。 | 原生引用已返回;不创建完整 Python `bytes`。 |
+| `bytes` | `MemHolder.access()` 解码出 payload `bytes`,校验长度并触碰首尾字节。 | 从 `memoryview(BufferHandle)` 定位 payload,再调用 `bytes(...)` 完整复制,校验长度并触碰首尾字节。 | 完整 Python `bytes` 已生成;不含 GPU copy。 |
+| `cuda` | 从 `MemHolder` 中取得 CPU DLPack payload 指针。 | 使用 `BufferHandle.ptr() + payload_offset` 取得 host 指针。 | 对应 CUDA event 已完成;不含 D2H 和 kernel 执行。 |
+
+来源计数与输出计数共用同一个 warmup 后统计窗口,但两套系统的观测方式不同:
+
+| 后端 | DRAM / SSD 归因方式 | 完整性判定 | 边界 |
+| --- | --- | --- | --- |
+| Fluxon | Rust get 路径保留 `GetSourceKind::Memory` / `GetSourceKind::Ssd`,经 external holder 传到 benchmark;每个成功操作单独计数。 | 每个 hit 都必须有且只有一个 source kind。 | 表示本次 get 选中并完成的 route source;它不是 NVMe 设备层 IO trace。 |
+| Mooncake | 在统计窗口起止点采样 `get_offload_rpc_read_count()`;`0.3.11.post1` 的单 key `get_buffer()` 在选中 `LOCAL_DISK` 后会进入唯一的 offload-RPC 计数入口。counter 增量记为 SSD hit,成功 hit 减该增量记为 DRAM hit。 | counter 必须单调,增量不能超过该节点成功 hit。 | 这是窗口 counter 推断。本次 CPU 和 GPU 有效记录的采样时刻与窗口边界最大偏差分别为 13.047 ms 和 1.912 ms。 |
+
+Fluxon / Mooncake coordinator 只在每个节点的 `memory + ssd + unknown == hit`、观测完整且 `unknown == 0` 时发布完整来源结果。如果任一节点的计数不自洽,该节点的全部 hit 都归入 unknown,不根据剩余量猜测来源。miss 不分配 DRAM / SSD source,单独进入命中率分母。CPU 的 192 条记录和 GPU 的 72 条记录均为完整来源、unknown 为 0。
+
+分层逻辑读带宽是整个统计窗口的平均值:`tier logical read GiB/s = tier hit 数 × 原始 payload bytes ÷ 窗口秒数 ÷ 2^30`。CPU 和 GPU 的统计窗口均为 30 秒。本次实验使用固定 payload,因此可以从来源操作数精确换算逻辑读取量。柱高不包含 miss;红点使用 `hit ÷ (hit + miss)`,两者共同描述本次 pressure workload。
+
+bootstrap 在统计窗口前完成,窗口内的 workload 只包含 get。因此,已纳入记录的 DRAM 和 SSD 逻辑写带宽均为 `0.000 GiB/s`。
+
+SSD 回填写入 host memory 的流量不重复计入 DRAM 逻辑写带宽。物理 DRAM 流量、SSD 读放大和回填写放大需要通过硬件计数器或设备 trace 观测。
+
+#### CUDA 双槽 pipeline 与阶段指标口径
+
+`cuda` 模式为每个 benchmark 线程固定复用 2 组 pinned host buffer、stream、event 和 GPU buffer,不增加新的 YAML 参数。线程先把 pageable source 复制到空闲 pinned slot,再调用 `cudaMemcpyAsync`。提交后不等待 event,下一次 KV get 可以和 H2D 重叠;两个 slot 都在途时,才等待最早提交的 event。
+
+统计窗口结束时会 drain 所有在途 slot,但完成时间超出窗口的操作仍会被过滤。Mooncake 的 benchmark get 路径直接调用 `get_buffer_blocking()`,不再额外调用一次 `get_size()`。
+
+两边只在 host pointer 的提取方式上不同,之后都进入同一个 H2D 流水线。`cuda_host_stage_us` 记录 pageable source 到复用 pinned slot 的 copy,`cuda_submit_us` 记录 `cudaMemcpyAsync` 调用,`cuda_h2d_event_us` 记录覆盖 H2D 的 CUDA event 区间。
+
+`cuda_pipeline_residence_us` 从开始占用 slot 到前台确认 event 完成,包含 host staging、H2D 和完成轮询。它与下一次 KV get 存在重叠,不能和其他阶段机械相加。benchmark 会把 `MemHolder` 或 `BufferHandle` 连同相关 view 保留到 event 完成,使两边释放 native reference 的时点与成功计数边界一致。
+
+### 8.3 实验设置
+
+主图 case 在同一台测试机上串行执行,硬件为 Intel Xeon Platinum 8468、Samsung NVMe(XFS)和 NVIDIA H100 80 GB,GPU 链路为 PCIe 5.0 x16。bootstrap 后只读,workload 使用固定 value size 和 Zipf 分布。
+
+CPU 和 GPU 主图统一使用 SSD-pressure 数据集:数据集约 2.5 GiB,DRAM 贡献为 2 GiB;SSD-on 再贡献 16 GiB SSD,SSD-off 不贡献 SSD。数据集、请求分布、worker 布局和统计窗口保持一致。由于数据集大于 DRAM,miss 和成功 hit 集合的变化就是实验要观察的结果;图同时报告命中率与 hit payload 带宽,不把开关差解释成纯介质开销。
+
+| 组别 | DRAM 存储贡献 / 原始数据集 | payload / keyspace | worker 布局 | 总时长 / warmup / 统计窗口 |
+| --- | --- | --- | --- | --- |
+| CPU Fluxon / Mooncake pressure | 2 GiB / 约 2.5 GiB | `1/4/8/16 MiB - 128 B` / `2560/640/320/160`;原生引用 / `bytes` | c2=`1×2`、c4=`1×4`、c8=`2×4`、c16=`4×4` | 35 / 5 / 30 s |
+| CPU Fluxon SSD backend 消融 | 2 GiB / 约 2.5 GiB;两侧再配置 16 GiB SSD | 与 CPU pressure 相同;`MemHolder` / `bytes` | 与 CPU Fluxon / Mooncake pressure 相同 | 35 / 5 / 30 s |
+| GPU pressure 开关矩阵 | 2 GiB / 约 2.5 GiB | `4/8/16 MiB - 128 B` / `640/320/160`;`cuda` | c2=`1×2`、c4=`1×4`、c8=`2×4`、c16=`4×4` | 35 / 5 / 30 s |
+
+主图使用的 Fluxon wheel SHA-256 前缀为 `11118d3`,Mooncake 为 `0.3.11.post1`。同路径 SSD backend 消融使用 Fluxon wheel SHA-256 前缀 `4896d0e`,其中集成 Foyer 0.22.3 作为 test-only owner backend。后文的早期 GPU SSD 写盘证据使用 Fluxon wheel SHA-256 前缀 `54de857`,不与主图或 backend 消融记录合并。
+
+表中的 DRAM 存储贡献是 KV 可分配容量,不等同于进程 RSS。主图对齐的是 2 GiB 逻辑 value 容量和 16 GiB 逻辑 SSD 容量;Fluxon 与 Mooncake 的 envelope、allocator、索引和元数据不同,物理 DRAM 占用并不相等。逻辑 payload 带宽也不包含后端编码和元数据。
+
+同路径 SSD backend 消融中的 2 GiB 仍是 Fluxon owner 上层 DRAM。集成 Foyer 另外保留 1 B 内部 memory capacity,并关闭该层的 memory admission;这 1 B 不替代或扣减 Fluxon 的 2 GiB DRAM。
+
+CPU 和 GPU 的 cN 都表示所有 benchmark 进程合计 N 个 worker 线程。Fluxon / Mooncake 的 c8 和 c16 分别使用 2 个和 4 个 benchmark 进程,因此结果同时包含多进程绕开 Python GIL 后带来的并行度;GPU 每个线程固定复用两个 pinned host、stream、event 和 device buffer slot。
+
+CPU 和 GPU 主图中的 Mooncake 都覆盖两种拓扑:
+
+- **`DEDICATED_OWNER`**:一个常驻 owner 贡献 2 GiB DRAM 和 16 GiB SSD,所有 benchmark 进程都是 zero-contribution。
+- **`PER_BENCHMARK_PROCESS`**:不启动独立 owner,每个 benchmark 进程同时作为请求方和存储 endpoint。2 GiB DRAM 和 16 GiB SSD 按进程数等分:c2/c4 每个实例为 2 GiB + 16 GiB,c8 为 1 GiB + 8 GiB,c16 为 512 MiB + 4 GiB。
+
+在 SSD-on 侧,每个 storage instance 使用独立 SSD 目录,但都位于同一块 Samsung NVMe 上。两种拓扑从 c2 到 c16 都保持 2 GiB DRAM + 16 GiB SSD 的集群逻辑总容量;SSD-off 侧保持同样的 2 GiB DRAM,去掉 16 GiB SSD 贡献。
+
+Fluxon / Mooncake 每个拓扑的两侧使用相同的 DRAM 贡献、pressure 数据集、requester buffer、worker 布局和 transport。排除用于隔离 case 的 cluster ID、目录和端口后,Fluxon 只增删 owner `owner_kv_ssd`;Mooncake 只增删 `--enable_offload=true` 和 16 GiB SSD 贡献。
+
+Mooncake 的 CPU requester buffer 在 SSD 关/开两侧均为 256 MiB,GPU 每个 benchmark 进程两侧均为 2 GiB。两侧也都使用 `tcp_pool_nodelay_backport`。
+
+#### bootstrap、transport 与容量排除项
+
+每个 case 都按单 PUT 串行 bootstrap。1、4、8、16 MiB payload 在每次 PUT 后分别等待 6、25、50、100 ms,把 payload 提交速率上限控制在约 160 MiB/s。该速率不足以在 Mooncake 的一个 10 秒 offload heartbeat 周期内填满 2 GiB DRAM 层,从而避免 bootstrap 成功与否取决于首次 heartbeat 和 DRAM 写满的先后顺序。800 MiB/s 的启动探针只用于发现这个竞态,没有进入最终结果。
+
+主图构建和早期 GPU SSD 写盘证据所用构建都对 Mooncake 设置 `MC_TCP_ENABLE_CONNECTION_POOL=1`,并在 client connect 与 server accept 两端补 `TCP_NODELAY`。结果中的 transport profile 记为 `tcp_pool_nodelay_backport`。
+
+在只开启 connection pool、未补 `TCP_NODELAY` 的预跑中,`batch_get_into_offload_object_internal` 均值从 `2.22 ms` 增至 `7.37 ms`,P95 出现约 `46 ms` 台阶。最终结果不混入默认短连接或只开 connection pool 的运行,也不把这个 backport 口径写成原版 `0.3.11.post1` 的默认表现。
+
+Mooncake SSD offload 的单 slice 上限是 `0xFFFFF0` bytes。CPU 和 GPU 的 16 MiB 组都将 benchmark payload 减去 128 bytes,加入 91-byte FlatDict envelope 后仍低于该上限。
+
+Fluxon / Mooncake 主图和 Fluxon SSD backend 消融均关闭 capacity guard,Mooncake 的 eviction high watermark 设为 0.80。SSD 容量不是这些实验的限制因素。主图只纳入状态为 `SUCCESS`、0 error、来源计数完整且 unknown 为 0 的运行;pressure 中的 miss 在 SSD 关/开两侧都保留并进入命中率。
+
+### 8.4 CPU 性能对比:`MemHolder` / `BufferHandle` 与 `bytes`
+
+下图的两行分别是原生引用 / handle 和 `bytes`,四列分别是 1/4/8/16 MiB。每个 payload 分面覆盖 c2/c4/c8/c16;每个并发度固定按 Fluxon、Mooncake 独立 owner、Mooncake 每进程 × SSD 关/开排列六根柱。蓝、橙、青分别区分三种实现组,浅色表示 DRAM、深色表示 SSD,红点使用右轴表示总命中率。各 payload 分面使用独立左轴,范围写在右上角。
+
+
+
+CPU 的 192 条记录覆盖 `2 输出模式 × 4 payload × 4 并发度 × 3 实现组 × 2 SSD 状态`。`fluxon_test_stack/single_node_ssd_bench/plot_kv_ssd_cpu_sweep.py` 与 GPU 脚本共同调用同目录下的 `kv_ssd_pressure_chart.py`。输入文件按命令行顺序覆盖同 case key;最终矩阵缺一条、存在 error、来源不完整或 unknown 非零都会直接失败。
+
+| 实现组 | SSD-off 命中率 | SSD-on 命中率 | SSD-on 中 SSD 来源占 hit |
+| --- | --- | --- | --- |
+| Fluxon | 89.112%–94.390% | 100% | 17.550%–48.650% |
+| Mooncake 独立 owner | 23.759%–66.879% | 100% | 62.065%–82.385% |
+| Mooncake 每进程 | 22.488%–68.655% | 72.830%–100% | 59.341%–83.011% |
+
+结果支持三个直接判断:
+
+- **命中率和 hit payload 带宽必须一起看**:SSD-off 会产生快速 miss;Mooncake 每进程的部分 SSD-on case 也有 miss。柱高不包含这些 miss,因此单看柱高会遗漏成功集合的变化。
+- **开关差不是单项 SSD 开销**:Fluxon 原生引用的 SSD-on hit 带宽相对 SSD-off 变化为 -88.8%–-21.1%,`bytes` 为 -45.8%–+2.2%;Mooncake 两种拓扑同时包含正负变化。差值混合了命中率、DRAM/SSD 来源、回填、输出物化和调度,不能拆成一个内部阶段。
+- **输出模式需要分开比较**:原生引用 / handle 在 host reference ready 时计数,`bytes` 还包含完整 payload 物化。两行都按每次 hit 的完整逻辑 payload 计带宽;handle 行并没有读取或复制同等字节,因此其数值可以超过物理 DRAM 带宽,不能跨行用柱高计算复制效率或模式倍率。
+
+统计窗口内只有 get,因此已完成记录的 DRAM 和 SSD 逻辑写带宽均为 `0.000 GiB/s`。图中报告逻辑 payload 流量,不代表物理 DRAM 或 NVMe 带宽。
+
+#### 8.4.1 同一 Fluxon 路径下的 SSD backend 消融
+
+[Foyer](https://github.com/foyer-rs/foyer/tree/v0.22.3) 是一个面向高性能和高并发场景的 Rust 内存/磁盘混合缓存库。它的 `HybridCache` 在一套接口下组合内存 cache 与磁盘 storage,并提供块式磁盘引擎、可替换的缓存算法和 IO engine。
+
+对 Fluxon 而言,Foyer 是一个现成的通用实现参照。这组消融为一个直接的工程取舍提供依据:Fluxon 原生 SSD backend 在目标 workload 和既有数据路径中的功能与性能表现,是否足以支持继续自研和维护。若通用库已经满足需求,直接复用 Foyer 可以减少存储引擎本身的开发与维护。
+
+要比较 SSD 存储层,不能直接把 Fluxon 的整套 cache 替换掉。Fluxon 的内存路径同时包含 master `route`、owner DRAM 副本、allocation 与 holder 生命周期、跨 owner 传输,以及 SSD 回填后的 promotion。如果连上层内存 cache 也替换为 Foyer,公共语义和数据路径会一起改变,结果无法归因到 SSD backend。因此,实验保留 Fluxon 的内存层及其上层路径,将替换边界统一收敛到 owner 内部的 `KvSsdStorage`:一侧使用 Fluxon 原生的 SSD 持久化与读取实现,另一侧接入 Foyer `HybridCache` 的 storage tier,并关闭 Foyer 自身的 memory admission。
+
+Foyer 分支通过 test-only 配置启用:
+
+```yaml
+test_spec_config:
+ kv_ssd_storage_backend: foyer
+```
+
+默认值仍为 `native`。Foyer 分支当前只接受一个 SSD root,并在配置阶段拒绝 `kv_ssd_uring_mode: iovec`。两组都调用相同的公共 KV API,经过相同的 FlatDict、route、transport、PyO3/GIL 边界和 benchmark 进程布局;写入 SSD backend 的都是 Fluxon target 中包含 FlatDict envelope 的完整编码 value。owner 上层 DRAM 都是 2 GiB,SSD 都是 16 GiB,且两种 SSD backend 都使用 direct I/O。图中的逻辑带宽仍只按原始 payload bytes 计数。
+
+Foyer 内部另设 1 B memory capacity、1 个 memory shard,并用 filter 关闭 memory admission。这里关闭的是 SSD backend 内部可能形成的额外内存层,不影响 Fluxon owner 的 2 GiB DRAM。专项测试在 persist 和重复 get 后观测到 Foyer memory usage 为 0,所有读取来源均为 Foyer storage tier。
+
+| 对比项 | Fluxon 原生 SSD | Fluxon Foyer SSD |
+| --- | --- | --- |
+| SSD 组织 | Fluxon ring、定长 shard 和 `io_uring`。 | Foyer 0.22.3 `HybridCache`,`WriteOnInsertion`、`PsyncIoEngine` 和 64 MiB block。 |
+| 读取落点 | 满足对齐条件时直接读入 Fluxon target;远端路径可让 SSD chunk read 与 push 重叠。 | Foyer 先返回包含完整 value 的 `Vec`,再复制到 Fluxon target;远端路径取得完整 value 后才逐 chunk 交给 push。 |
+| persist 并发 | 使用原生 writer queue 和 device worker。 | forced storage admission;`enqueue → wait → may_contains` 串行化,避免 Foyer byte queue 满时静默丢弃写入。 |
+| 驱逐收敛 | ring 覆盖后批量通知 master 删除对应 SSD route。 | Foyer 0.22.3 没有向当前集成暴露等价的 SSD eviction callback;陈旧 route 只能在读取失败时撤销。 |
+
+这个 workload 在 bootstrap 后只读,因此 Foyer persist 串行化不进入 30 秒统计窗口。约 2.5 GiB 数据集也小于 16 GiB SSD 容量;本次结果不评估 Foyer 容量驱逐后的 route 收敛。图中的浅色仍表示 Fluxon 上层 DRAM route,深色表示 Fluxon SSD route;它不把 Foyer 内部 memory cache 作为第三层来源。
+
+图按 `MemHolder` / `bytes` 分成两行,每行覆盖 1 / 4 / 8 / 16 MiB;每个并发度放置原生与 Foyer 两根柱。蓝/紫区分 SSD 实现,浅/深区分 DRAM 与 SSD 来源,红点表示命中率。
+
+
+
+PNG 由 `fluxon_test_stack/single_node_ssd_bench/plot_kv_ssd_foyer_ablation.py` 读取原始汇总生成。脚本要求 64 条记录的 case key 完整,校验容量、并发布局、统计窗口、来源计数和配对控制项后才绘图。这里的 64 是测量记录数,即 32 个原生/Foyer 配对,不表示 64 种部署。
+
+所有配对的命中率都是 100%,因此这组差距没有 miss 数量优势可以解释。P95 的同向变化表明差距同时出现在延迟侧。`bytes` 会在两侧共同追加完整 payload 物化,其中位比值高于 `MemHolder` 与公共物化成本稀释 backend 相对差异相容,但单次矩阵不足以分解这部分因果贡献。
+
+当前两条读取路径的明确差异是:Foyer adapter 先取得完整 `Vec` 再复制到 Fluxon target;原生实现可以直接读入 target,远端时还保留 chunk read 与 push 重叠。同时还存在 `PsyncIoEngine` / `io_uring`、完整 value 交付和调度方式等差异。这些差异共同属于当前 adapter 对比,本次数据无法把柱高差逐项分配给某个内部阶段。
+
+配对 case 对相同的 thread id 和 operation index 使用同一套确定性 Zipf key 选择。统计边界仍是固定 30 秒窗口,不是固定操作数;较慢的一侧会停在同一确定性序列的更短前缀,实测 DRAM/SSD 来源比例也可能随之变化。因此,这组数据回答指定 pressure workload 下的整体 backend 表现,不把柱高比值解释成单次纯 SSD get 的加速比。
+
+### 8.5 GPU 性能对比:多并发 SSD 开关与 Mooncake 存储拓扑
+
+上一节的 CPU 测试以 KV 数据到达 CPU 内存为计数终点,分别观察原生引用 / handle 和完整 `bytes` 物化。AI 推理或训练实际消费 KV 数据时,数据通常还需要继续搬到 GPU。因此,本节把计数终点延伸到对应 CUDA event 完成,比较多并发下 SSD 开关和 Mooncake 两种存储拓扑对 GPU 可用数据吞吐的影响。该边界包含 H2D 传输,不包含 GPU kernel 执行或 D2H。
+
+GPU 与 CPU 使用同一种画法,只少了原生引用 / `bytes` 两行。4、8、16 MiB 各占一个分面;每个分面都覆盖 c2/c4/c8/c16,每个并发度再按 Fluxon、Mooncake 独立 owner、Mooncake 每进程 × SSD 关/开排列六根柱。蓝、橙、青区分三种实现,浅色表示 DRAM、深色表示 SSD,红点显示命中率;六组 case 都从 native host buffer 进入同一条 `cuda` H2D 路径。
+
+
+
+GPU PNG 由 `fluxon_test_stack/single_node_ssd_bench/plot_kv_ssd_gpu_sweep.py` 读取一个或多个 pressure 汇总,并与 CPU 脚本共同调用同目录下的 `kv_ssd_pressure_chart.py`。脚本校验最终 72 条记录完整后直接使用 Pillow 绘制;各 payload 分面使用独立左轴,命中率共用 0%–100% 右轴。
+
+| 拓扑 | SSD-off 命中率 | SSD-on 命中率 | SSD-on 中 SSD 来源占 hit |
+| --- | --- | --- | --- |
+| Fluxon | 89.326%–93.267% | 100% | 20.242%–46.508% |
+| Mooncake 独立 owner | 34.557%–62.223% | 100% | 64.837%–77.135% |
+| Mooncake 每进程 | 35.430%–64.782% | 59.855%–100% | 56.073%–84.198% |
+
+下表把图中的并发扩展收敛为 c16/c2 hit payload 带宽倍率,三个 payload 的顺序都是 4 / 8 / 16 MiB:
+
+| 拓扑 | SSD-off c16/c2 | SSD-on c16/c2 |
+| --- | --- | --- |
+| Fluxon | 5.83 / 5.83 / 4.68 倍 | 6.50 / 7.22 / 6.80 倍 |
+| Mooncake 独立 owner | 1.33 / 1.50 / 1.10 倍 | 1.73 / 1.37 / 1.35 倍 |
+| Mooncake 每进程 | 2.67 / 2.01 / 1.23 倍 | 3.02 / 2.07 / 1.77 倍 |
+
+Fluxon 的 SSD-on hit 带宽相对 SSD-off 变化为 -54.6%–-27.9%,Mooncake 独立 owner 为 -32.0%–+37.3%,Mooncake 每进程为 -62.6%–-17.5%。这些变化必须与上表的命中率一起解释。c16 下,每进程相对独立 owner 的 hit 带宽在 SSD-off 下分别高 125.7%、107.3% 和 97.9%,SSD-on 下高 82.4%、64.5% 和 57.9%;这些是包含 endpoint 本地性、进程数和 transport 调度的整体拓扑结果,不能归因到单个内部环节。
+
+主矩阵体现了三个结果:
+
+- **Fluxon 能随并发扩展**:4、8、16 MiB 从 c2 到 c16 分别提高到 7.18、7.68 和 5.33 倍。
+- **16 MiB 在 c8 后开始收益递减**:c8 到 c16 只再提高 14.0%,backend get 均值从 `3.082 ms` 增至 `7.367 ms`,P95 从 `24.007 ms` 增至 `51.150 ms`。H2D 之前的路径已开始制约吞吐。
+- **Mooncake 拓扑会影响结果**:每进程贡献模式在 c16 下相对独立 owner 分别提高 91.3%、103.7% 和 66.8%。两种模式的逻辑容量相同,差异主要出现在 GPU copy 之前。来源诊断显示 8/16 MiB 的每进程贡献模式具有更高 SSD 命中率,因此更高吞吐无法由更高 DRAM 命中率解释;独立 owner 串行点、endpoint 本地性和 transport 调度成本仍未拆分。
+
+c16 下,Fluxon 相对两种 Mooncake 拓扑中更快的一种,在 4、8、16 MiB 组中分别为 3.83、5.61 和 6.73 倍。这些比值只覆盖第 8.2–8.3 节定义的计数边界、单机 fast path 和固定容量。
+
+## 9. 设计结论
+
+Fluxon KV 将本地 SSD 定位为运行期回填层,不改变用户侧 `put`、`get` 和 `delete` 契约。master 维护 key-version 级 `route`,并统一调度用于最终 value 副本和临时传输的内存 allocation。owner 承载真实 bytes,管理 SSD 文件位置、IO 和 ring 生命周期。
+
+`put` 操作在内存副本发布后返回,SSD persist 在后台完成。`get` 操作优先读取内存。没有可读内存副本时,本地 SSD 回填复用 requester target,远端回填则按 chunk 从 source staging push 到 requester target。两条路径最后都进入同一个 `GetDone` 和 holder 交付链。
+
+同路径消融进一步表明,在本文固定的 pressure workload 下,当前 Foyer adapter 的 hit payload 带宽中位数为原生 SSD 的 37.2%,P95 为 2.30–8.46 倍,32 个配对中没有胜出。两侧均为 100% 命中,但 Foyer adapter 多了完整 `Vec` 到 Fluxon target 的复制,也没有原生路径的 direct-target 与 chunk read/push 重叠。这一结果限定于当前 adapter,不代表 Foyer 库的一般性能。
+
+当前 SSD 层不提供冷启动恢复,也不向用户暴露独立 API。专项测试验证了强制回填和陈旧 route 兜底,并报告限定条件下的端到端逻辑 payload 表现。这些结果不代表裸 SSD 带宽,也不外推到跨机器、多 owner 或多盘场景。
diff --git "a/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 3 - \346\226\207\346\241\243\345\206\231\344\275\234\350\247\204\347\272\246.md" "b/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 3 - \346\226\207\346\241\243\345\206\231\344\275\234\350\247\204\347\272\246.md"
index 75ed5b7..448abb0 100644
--- "a/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 3 - \346\226\207\346\241\243\345\206\231\344\275\234\350\247\204\347\272\246.md"
+++ "b/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 3 - \346\226\207\346\241\243\345\206\231\344\275\234\350\247\204\347\272\246.md"
@@ -5,9 +5,16 @@
- 让读者能在最短时间内抓住稳定结论。
- 让文档和代码、公开接口、实际行为保持一致。
+需要把这些规则落实为逐轮审校和 one-shot 提示词时,参见 [技术文档审校](./开发者%20-%205%20-%20技术文档审校.md)。
+
## 1. 通用规则
- 先写结论,再写展开。读者应在开头 30 秒内知道“这篇文档在回答什么问题”。
+- 开头保持在一个抽象层,只交代问题、核心要点和正文结构;局部实现、实验口径和分项证据留在对应章节。
+- 标题、导语和表头使用与内容匹配的标签。摘要写“核心要点”,能力盘点写“当前状态”,只有经过论证的判断才写“结论”。
+- 按主体归并职责和动作。介绍多个角色时,集中写完一个角色再写下一个角色,避免读者来回寻找同一主体的职责。
+- 重要的架构判断应在附近说明原因或机制,尤其是职责归属、生命周期和数据方向;不要只给结果,让理由散落到后文。
+- 主张强度必须与证据强度一致。已验证的行为和结果可以直接陈述;设计目标或预期收益使用“有望”“预计”等限定;证据不足的结果应删除或明确限定。
- 优先使用工程上自然的术语,少用自造概念。除非真的必要,不要写“根对象”“第一层分支”“authority object”这类词。
- 面向读者写,不要把作者的思维过程原样摊开。删除“这一节不讨论什么”“这个分支为什么属于上一层”这类模板套话。
- 如果一段文字可以改成表格、列表或图,就不要硬写成长段线性描述。
diff --git "a/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 5 - \346\212\200\346\234\257\346\226\207\346\241\243\345\256\241\346\240\241.md" "b/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 5 - \346\212\200\346\234\257\346\226\207\346\241\243\345\256\241\346\240\241.md"
new file mode 100644
index 0000000..e0158a0
--- /dev/null
+++ "b/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 5 - \346\212\200\346\234\257\346\226\207\346\241\243\345\256\241\346\240\241.md"
@@ -0,0 +1,218 @@
+# 开发者 - 5 - 技术文档审校
+
+本文把上游 `copy-editing` skill 中适合技术写作的检查方法整理为 Fluxon 文档审校流程。它用于技术事实和结构已经确认后的文字审校,不替代仓库文档规约,也不能改变公共接口、运行行为或性能结论。
+
+## 1. 来源与适用边界
+
+| 项目 | 内容 |
+| --- | --- |
+| 上游项目 | [`coreyhaines31/marketingskills`](https://github.com/coreyhaines31/marketingskills) |
+| 上游 skill | [`copy-editing`](https://github.com/coreyhaines31/marketingskills/tree/0ba2a7fafc7b0827a261bd518e87cbda18e6675f/skills/copy-editing) |
+| skill 版本 | `2.0.0` |
+| 固定提交 | `0ba2a7fafc7b0827a261bd518e87cbda18e6675f` |
+| 上游定位 | 审校已有的营销与转化文案,保留核心信息和作者语气。 |
+| 本文用途 | 提取清晰度、语气、理由、证据和具体性检查,适配 Fluxon 技术文档。 |
+| 许可 | MIT,完整声明见文末。 |
+
+使用时按以下优先级处理冲突:
+
+1. 当前任务要求、仓库 `AGENTS.md` 和[文档写作规约](./开发者%20-%203%20-%20文档写作规约.md)。
+2. 代码、公共接口、测试和已验证的运行行为。
+3. 本文的审校流程。
+
+文字更顺不能成为修改技术含义的理由。遇到事实不确定、接口与文档不一致或证据不足时,先核对实现,再决定修改文档还是代码。
+
+## 2. 上游七轮检查如何用于技术文档
+
+上游 skill 把审校拆成七轮。Fluxon 只直接采用其中四轮,改写两轮,跳过一轮:
+
+| 上游检查 | Fluxon 用法 | 处理方式 |
+| --- | --- | --- |
+| Clarity | 检查长句、指代、未定义术语、上下文缺口和被限定条件埋住的主结论。 | 直接采用。 |
+| Voice and Tone | 统一正式程度、角色名、组件名和中英文术语,保持自然的工程表达。 | 直接采用。 |
+| So What | 对架构选择补充原因、影响和代价,回答“为什么这样设计”。 | 改写为工程动机检查,不写营销收益。 |
+| Prove It | 用代码路径、类型签名、测试、指标或受限实验支撑行为与性能判断。 | 直接采用。 |
+| Specificity | 写清范围、抽象层、前提、失败条件和不包含的路径。 | 直接采用。 |
+| Heightened Emotion | 渲染痛点、焦虑、愿景或情绪。 | 跳过。技术文档优先准确和可验证。 |
+| Zero Risk | 营销 CTA、保证和风险逆转。 | 只保留用户操作中的前置条件、失败结果和下一步,不引入营销 CTA。 |
+
+上游的英文词数、主动语态和短段落建议都是启发式规则。中文句长、代码标识和复杂所有权关系应按可读性判断,不机械套用固定阈值。
+
+## 3. 审校流程
+
+### 3.1 先固定技术事实
+
+审校前先确认:
+
+- 文档类型和目标读者。
+- 公共契约、当前实现和专用 fast path 的边界。
+- 关键类型、配置键、返回值和失败语义是否与代码一致。
+- 行为、所有权和性能判断覆盖的完整路径。
+- 是否存在需要同步修改的中英文页面。
+
+如果这些问题还没有答案,先做技术 review。copy editing 不能替代实现核对。
+
+### 3.2 第一轮:结构与清晰度
+
+先检查读者能否在开头快速回答三个问题:
+
+1. 这篇文档解决什么问题。
+2. 最重要的稳定信息是什么。
+3. 正文将按什么顺序展开。
+
+随后逐节检查:
+
+- 标题是否直接表达本节判断或任务。
+- 每节是否先给当前抽象层的稳定信息,再进入字段、分支和数字。
+- 开头列出的轴是否在正文中逐项回应。
+- 同一事实是否被角色表、链路表、图例和总结重复解释。
+- 局部实现细节是否过早出现在导读或架构概览中。
+
+### 3.3 第二轮:语气与术语
+
+- 一个概念只保留一个规范名称、拼写和大小写。
+- 角色职责用明确主语表达,例如“master 维护 `route`”“owner 管理本地 SSD”。
+- 删除“稳定结论”“当前结论”等与实际内容不匹配的标签,直接写“核心要点”“当前状态”或具体判断。
+- 避免模板腔、宣传语和没有对象的“提升”“增强”“优化”。
+- 保留必要的英文代码术语,不为了中文化而改写公共类型或字段名。
+
+### 3.4 第三轮:理由、证据与边界
+
+每个重要判断都检查四件事:
+
+| 检查项 | 要回答的问题 |
+| --- | --- |
+| 理由 | 为什么选择这条职责边界、数据方向或生命周期。 |
+| 机制 | 哪个对象、字段或调用链实现了该行为。 |
+| 范围 | 判断覆盖到哪一步、哪个抽象层和哪些分支。 |
+| 排除项 | 哪些成本、路径、恢复能力或部署条件没有包含在内。 |
+
+架构判断应把原因放在判断附近。例如,内存 allocation 归 master 调度时,应立即说明它同时覆盖最终 value 副本和跨 owner 传输临时内存;不能只列职责归属。
+
+性能判断还必须绑定硬件、数据集、并发、输出边界、统计窗口和运行次数。端到端逻辑 payload 带宽不能写成裸 SSD 带宽。
+
+### 3.5 第四轮:收缩与去重
+
+按以下顺序处理重复内容:
+
+1. 保留首次出现的稳定判断。
+2. 保留能显著降低理解成本的一张表或一张图。
+3. 后续章节只展开新增机制、条件或失败路径。
+4. 总结回收公共契约、核心数据流和当前边界,不复述实验表格。
+
+不要为了让每节“看起来完整”而重复角色定义。已经在总览中定义的角色,后续图前只补充该图中特有的含义。
+
+### 3.6 第五轮:发布前回查
+
+完成一轮修改后,从后向前回查,确认后续编辑没有破坏前面的判断:
+
+- 术语、章节号和交叉引用仍然一致。
+- 表格列数、代码围栏、折叠块和 Mermaid 图可以解析。
+- 中英文页面表达相同的契约和边界。
+- 所有精确标识仍使用正确名称。
+- 修改没有引入新的兼容路径、配置入口或未验证事实。
+- 文档站构建通过。
+
+## 4. 协作审校与 one-shot 示例
+
+### 4.1 从表面问题回溯根因
+
+审校意见应同时给出位置、问题、原因和建议改法。只说“读起来不顺”不足以支持修改。
+
+| 项目 | 示例 |
+| --- | --- |
+| 位置 | 开头的实验说明。 |
+| 问题 | 在读者理解文章结构前提前展开统计口径。 |
+| 原因 | 局部证据抢占导读层级。 |
+| 改法 | 开头只保留全文结构,把统计条件留在实验节。 |
+
+对于明确、局部且不会改变技术语义的问题,可以直接修改并回查。会改变契约、结论范围或章节主线的修改,应先说明判断依据和影响。
+
+一轮完整审校还应归纳多个表面问题背后的共同原因。这样才能修正全文,并把经验复用于下一篇文档。下面以 KV SSD 文章的一组实际修改为例:
+
+| 表面问题 | 根因 | 审校决策 |
+| --- | --- | --- |
+| 用“先记住四个稳定结论”引出内容摘要。 | 标签强度超过内容;摘要尚未构成经过论证的结论。 | 改为“四个核心要点”。 |
+| 先写 owner 的物理资源,再写 master,最后又回到 owner 的 SSD 调度。 | 同一主体的职责被拆散,读者需要来回重组角色边界。 | 按主体归并,先集中说明 master,再集中说明 owner。 |
+| 只写“内存 allocation 调度归 master”。 | 职责判断缺少就地理由,读者无法判断该边界是否覆盖完整。 | 紧跟说明内存 allocation 同时包含最终 value 副本和临时传输分配,两者都与 `route` 和 in-flight 状态联动。 |
+| 开头提前解释第 9 节的实验分组、命中条件和统计口径。 | 局部证据与导读不在同一抽象层,细节遮住了全文结构。 | 开头只保留问题、核心要点和阅读路径,把实验条件留在第 9 节。 |
+| 把“覆盖更大的运行期工作集”直接写成既成结果。 | 设计预期被写成已验证结果,主张强度超过现有证据,也没有直接落到读者关心的运行收益。 | 保留公共 API 不变这一范围,改为“有望提高缓存命中率,并更充分地利用存储带宽”。 |
+
+这些修改共同服务于两个目标:让读者按正确层级建立系统模型,并确保每个判断都不越过其证据边界。
+
+### 4.2 可复用的 one-shot 示例
+
+下面的提示词适用于技术事实已经核对、需要完成一轮结构与语言审校的文档:
+
+```text
+目标文档:<文档路径>
+
+请对目标技术文档完成一轮审校并直接修改。保持公共 API、实现事实、实验数字和已有结论边界不变。
+
+重点检查:
+1. 开头只说明问题、核心要点和全文结构,不提前展开局部实现或实验方法。
+2. 标题、导语和表头的标签必须匹配内容,不把摘要或能力状态统称为“结论”。
+3. 按主体归并职责;介绍多个角色时,集中写完一个角色再写下一个角色。
+4. 每个重要架构判断附近都要说明原因或机制,尤其是职责归属、生命周期和数据方向。
+5. 主张强度必须匹配证据:已验证结果可以直接陈述;设计目标或预期收益使用“有望”“预计”等限定;证据不足的结果不写成事实。
+6. 删除重复的角色说明、错层细节和不增加新信息的总结,同时保留必要的范围、前提和排除项。
+7. 保持术语、类型名、接口名、章节号、交叉引用、表格和代码围栏一致。
+
+完成后:
+- 用“表面问题 → 根因 → 修改决策”概括主要改动,不逐句复述。
+- 如存在对应的中英文页面,同步更新。
+- 回查公共契约、技术事实和性能口径,并运行仓库规定的文档站构建。
+```
+
+## 5. 不采用的上游做法
+
+- 不用情绪渲染、FOMO、痛点放大或销售话术增强技术文章。
+- 不为每项实现强行补“用户收益”。只在理由影响理解或取舍时说明结果。
+- 不添加没有来源的数字、时间承诺、案例或比较级。
+- 不把英文“每句不超过 25 个词”等经验机械转换为中文字符限制。
+- 不要求所有句子改成主动语态;所有权和数据流清晰度优先。
+- 不使用虚构专家 persona 打分代替代码、测试和同层级链路核对。
+
+## 6. 更新上游快照
+
+本文固定到上游提交 `0ba2a7f`。更新时:
+
+1. 对比上游 `skills/copy-editing/SKILL.md` 和 `references/` 的变化。
+2. 只吸收适合技术文档、且不与 Fluxon 规约冲突的部分。
+3. 同步更新中英文文档、skill 版本和固定提交。
+4. 重新执行文档站构建。
+
+不要自动用上游内容覆盖本文;上游目标是营销文案,本文目标是准确、可验证的技术文档。
+
+## 7. 第三方许可
+
+本文基于 Corey Haines 的 `copy-editing` skill 整理,并针对 Fluxon 技术文档进行了修改。上游采用 MIT License。
+
+
+MIT License
+
+```text
+MIT License
+
+Copyright (c) 2025 Corey Haines
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+
diff --git "a/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 6 - Code Review \350\247\204\347\272\246.md" "b/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 6 - Code Review \350\247\204\347\272\246.md"
new file mode 100644
index 0000000..437593b
--- /dev/null
+++ "b/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 6 - Code Review \350\247\204\347\272\246.md"
@@ -0,0 +1,114 @@
+# 开发者 - 6 - Code Review 规约
+
+Code Review 规约必须把架构判断转换成可执行约束。单独写“职责要清晰”或“生命周期顺序要正确”只是结论;完整规约还要说明何时适用、必须做什么、如何验收、产生什么收益,以及如何限制修复本身的副作用。
+
+## 1. 规约条目的固定结构
+
+本文每条规约都包含以下字段:
+
+| 字段 | 要回答的问题 |
+| --- | --- |
+| 触发场景 | 出现什么代码形态、依赖或行为变化时应用本规约? |
+| 必须动作 | Author 和 Reviewer 必须建立或修改什么约束? |
+| 验收标准 | 哪些可观察后置条件成立才算完成? |
+| 预期收益 | 规约降低什么风险,或获得什么工程能力? |
+| 副作用边界 | 为实现规约不得额外引入什么复杂度、耦合或行为变化? |
+| 验证证据 | 用什么测试、类型约束或运行结果证明验收标准? |
+
+文中的“必须”对应合并前要求;“应”允许在 PR 中给出明确理由后偏离;“可以”表示不影响合并的实现选择。
+
+## 2. 按改动场景选择规约
+
+Reviewer 先按改动信号匹配规约,不要求所有 PR 机械套用全部条目。
+
+| 改动信号 | 主要适用规约 |
+| --- | --- |
+| 公共层开始访问内部字段,或资源在一个模块创建、另一个模块清理 | R1 职责与所有权、R4 可组合契约、R5 最小副作用修复 |
+| 新增 `close`、后台任务、线程、进程、runtime 或嵌套 handle | R2 依赖生命周期、R3 状态 authority |
+| 多个参与者共享 `closed`、event、lease 或 cleanup 状态 | R1 职责与所有权、R3 状态 authority |
+| 新增 adapter、替换 backend、父模块包装子模块 | R1 职责与所有权、R4 可组合契约、R5 最小副作用修复 |
+| close 与在途操作并发,或外层锁跨越内部阻塞调用 | R2 依赖生命周期、R3 状态 authority |
+| 纯局部算法改动,不改变边界、资源或生命周期 | 不强制建立完整生命周期模型;按正确性和局部测试 Review |
+
+## 3. 可执行规约
+
+### R1:跨模块状态和资源必须有唯一 owner
+
+| 字段 | 规约 |
+| --- | --- |
+| 触发场景 | 改动跨越公共层、组合层和内部模块;新增共享资源;出现重复 close;或外层直接修改内部状态。 |
+| 必须动作 | 列出受影响状态和资源的创建者、借用者、共享者与最终释放者。每个资源指定唯一 final-release authority。公共层只表达稳定契约;组合层只安排模块顺序;内部模块维护自己的状态、不变量和 cleanup。 |
+| 验收标准 | 每个资源都能回答“谁最后释放”;借用方不会释放 owner 资源;外层不通过内部字段完成 cleanup;替换内部实现时,公共层和组合层的生命周期逻辑不变。 |
+| 预期收益 | 职责高内聚,避免重复释放和无人释放,缩小修改传播范围,使模块可独立测试和替换。 |
+| 副作用边界 | 不为形式上的分层拆出无状态薄模块;不扩大公共 API;不把原本局部的资源提升为全局共享;不顺带迁移无关职责。 |
+| 验证证据 | ownership 表或类型关系、公共契约测试,以及证明 borrower close 不破坏 owner 资源、独占 owner close 完成释放、共享 authority 遵守约定释放条件的测试。 |
+
+### R2:有依赖关系的生命周期必须按偏序执行
+
+| 字段 | 规约 |
+| --- | --- |
+| 触发场景 | 对象依赖其他 runtime、transport、存储、worker、线程、进程或 handle 才能工作;新增启动、关闭、重启或热替换流程。 |
+| 必须动作 | 写出依赖 DAG。若 `A` 依赖 `B`,初始化满足 `B → A`,销毁满足 `A → B`。关闭依次建立 admission barrier、传播 wake/cancel、收敛 in-flight、join 后台任务、逆依赖释放、发布完成。锁层级和 callback 方向必须与依赖方向兼容。 |
+| 验收标准 | 关闭开始后不再接收新操作;被依赖资源释放前,所有依赖方已经静默;close 返回后,该生命周期边界内不再有任务、callback 或 handle 使用其拥有的资源;重复 close 能到达同一终态。 |
+| 预期收益 | 获得确定性启停,避免 use-after-close、关闭死锁、悬挂后台任务和依赖提前释放。 |
+| 副作用边界 | 只排序真实依赖;互不依赖的分支可以并行关闭。不要为了统一顺序引入全局锁或全局 coordinator;wake/cancel 只作用于本次关闭拥有的范围。 |
+| 验证证据 | 真实生命周期测试、并发 close 与在途操作测试、超时测试,以及 close 返回后无后台活动的完成屏障测试。 |
+
+### R3:停止命令、进行状态和完成证明必须由明确 authority 管理
+
+| 字段 | 规约 |
+| --- | --- |
+| 触发场景 | 一个布尔值或 event 同时被多个对象读写;停止请求与资源释放异步;close 可重试;或不同层都能发布 closed。 |
+| 必须动作 | 为每个状态标明作用域、唯一 writer 和语义。区分停止命令、`Closing` 过程与 `Closed` 完成证明。同一作用域优先使用单调状态机;不同作用域的信号和完成状态分别由各自 owner 管理。只发信号的接口命名为 `request_shutdown()` 或等价语义。 |
+| 验收标准 | 其他参与者发出 stop 不会让本模块跳过 cleanup;只有 owner 能发布本模块完成;状态只向终态推进;失败或重试不会重新开放入口,也不会永久跳过未完成步骤。 |
+| 预期收益 | 消除共享布尔值的语义歧义,使关闭可重试、可等待、可观测,并避免提前返回和资源泄漏。 |
+| 副作用边界 | 不为同一个事实增加多个 source of truth;简单同步对象不引入多余状态;能用有限 enum 表达时不扩散布尔组合;不保留旧状态别名形成兼容分支。 |
+| 验证证据 | 状态转换测试、多参与者先后发 stop 的测试、部分构造和部分 cleanup 失败后的重试测试。 |
+
+### R4:子模块生命周期契约必须能够被父模块直接组合
+
+| 字段 | 规约 |
+| --- | --- |
+| 触发场景 | 父模块包装子模块、新增 adapter 或 backend、一个 close 需要调用多个子模块,或错误需要跨层传播。 |
+| 必须动作 | 为子模块定义生命周期前置条件、成功后置条件和失败后置条件。create / release、register / unregister、spawn / join、subscribe / cancel 必须成对。父模块只调用子模块契约并安排顺序,不复制其 cleanup 实现。独立 cleanup 即使部分失败也继续执行,并保留主错误和 teardown error。 |
+| 验收标准 | 父模块不检查子模块内部字段就能决定下一步;子模块 close 返回后满足父模块逆序释放所需条件;替换一个符合契约的实现不要求修改父模块;失败结果能说明哪些后置条件尚未满足。 |
+| 预期收益 | 局部正确性可以组合为系统正确性,backend 可替换,错误因果不丢失,模块可以独立演进。 |
+| 副作用边界 | 不为假设中的未来实现提前设计通用框架;只抽象已经稳定的共同语义;特化 fast path 留在模块内部;不把所有错误压成无信息的统一返回值。 |
+| 验证证据 | contract test、替代实现或 test double 的一致性测试、部分子模块关闭失败时其余 cleanup 仍执行的测试。 |
+
+### R5:修复必须落在不变量的 authority 处,并限制改动半径
+
+| 字段 | 规约 |
+| --- | --- |
+| 触发场景 | 修复方案准备在外层增加判断、直接置空内部字段、增加全局 flag、兼容分支或新的配置入口,以绕过内部职责或生命周期问题。 |
+| 必须动作 | 找到被破坏不变量的 owner,在 owner 内修复状态、cleanup 或契约;组合层只调整必要顺序。明确本次非目标,删除被新契约取代的 workaround,并只回归受影响边界。 |
+| 验收标准 | 修复后跨边界知识减少或不增加;没有新增重复入口、并行配置通道或第二套生命周期路径;无关公共行为保持不变。 |
+| 预期收益 | 修复根因而非症状,降低回归范围,避免兼容层和条件分支持续累积。 |
+| 副作用边界 | 不借机重写无关模块;不以“统一架构”为由扩大迁移范围;若公共契约必须变化,单独说明迁移和影响,不能夹带在内部修复中。 |
+| 验证证据 | 修复前反例、修复后不变量测试、受影响调用方回归,以及 diff 中没有新增旁路的检查。 |
+
+## 4. Review 输出与合并标准
+
+Finding 应引用规约编号,并包含触发场景、违反的验收标准、影响和要求守住的副作用边界:
+
+```text
+[R3][blocking] 共享 stop signal 被当成了当前模块 cleanup 的完成证明。
+
+触发场景:另一个参与者先设置共享状态。
+违反标准:当前资源 owner 的 cleanup 被跳过,close 不再是 completion barrier。
+必须动作:分离共享停止命令与本模块完成状态,并由本模块发布完成。
+副作用边界:不要新增第二套公共 close API,也不要让外层直接清理内部资源。
+```
+
+| 标记 | 合并标准 |
+| --- | --- |
+| `[blocking]` | 已有合理触发路径会破坏规约验收标准,修复后才能合并。 |
+| `[major]` | 关键场景缺少约束或证据,补齐实现或验证后合并。 |
+| `[minor]` | 不影响规约验收标准的局部维护性或诊断问题,可以明确接受后续处理。 |
+| `[question]` | 触发场景、authority 或契约不清楚,回答后再定级。 |
+
+- **Request changes**:存在未解决的 `[blocking]` / `[major]`,或无法确定适用规约的 owner、依赖与验收标准。
+- **Approve**:所有匹配规约的必须动作已完成,验收标准有证据,预期收益成立且副作用保持在声明边界内。
+- **Comment**:仅剩澄清问题或不影响验收标准的建议。
+
+文档类 PR 还应遵守[文档写作规约](./开发者%20-%203%20-%20文档写作规约.md)和[技术文档审校](./开发者%20-%205%20-%20技术文档审校.md)。
diff --git "a/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 7 - \344\272\213\344\273\266\350\256\242\351\230\205\344\270\216\345\205\250\351\207\217\345\277\253\347\205\247\350\247\204\347\272\246.md" "b/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 7 - \344\272\213\344\273\266\350\256\242\351\230\205\344\270\216\345\205\250\351\207\217\345\277\253\347\205\247\350\247\204\347\272\246.md"
new file mode 100644
index 0000000..73d8531
--- /dev/null
+++ "b/fluxon_doc_cn/dev_doc/\345\274\200\345\217\221\350\200\205 - 7 - \344\272\213\344\273\266\350\256\242\351\230\205\344\270\216\345\205\250\351\207\217\345\277\253\347\205\247\350\247\204\347\272\246.md"
@@ -0,0 +1,84 @@
+# 开发者 - 7 - 事件订阅与全量快照规约
+
+当组件通过“全量查询 + 增量事件”维护本地投影时,只允许使用下面两种顺序之一:
+
+1. 通用顺序:`subscribe/listen 就绪 → 拉取全量快照 → 安装快照 → 回放已缓冲的增量事件 → 发布 ready`。
+2. Revision 耦合顺序:数据源提供类似 etcd 的原子 revision 契约时,使用 `snapshot@R → watch@R+1`。
+
+不满足 revision 耦合契约时,必须使用通用顺序。每次 `listen` 建立并确认就绪后,都必须进行一次全量同步;该要求同时覆盖首次启动和所有重新建立 `listen` 的恢复路径。
+
+调用了 `listen()` 或启动了后台任务,不代表订阅已就绪。只有当数据源已确认订阅,或调用方已取得可用的 stream handle / cursor 时,才算 `listen` 建立完成。
+
+## 1. 适用范围
+
+本规约适用于任何由远端权威状态派生本地投影的场景,包括:
+
+- 集群成员与服务发现。
+- 路由表、配置镜像和资源索引。
+- controller / informer 类本地 cache。
+- 需要重建 watch 流的本地 cache。
+
+本地投影的 owner 对快照安装、事件应用和 ready 状态负责。数据源仍是业务状态的唯一事实来源。
+
+## 2. 通用顺序:listen 就绪后全量同步
+
+```mermaid
+sequenceDiagram
+ participant projection as local-projection-owner
+ participant source as authoritative-source
+
+ projection->>source: subscribe / listen
+ source-->>projection: subscription ready (stream / cursor)
+ projection->>source: fetch full snapshot
+ source-->>projection: snapshot (optional revision R)
+ projection->>projection: install snapshot
+ loop buffered and live events
+ source-->>projection: incremental event
+ projection->>projection: apply by revision or idempotent version check
+ end
+ projection->>projection: publish ready
+```
+
+| 阶段 | 必须动作 | 验收条件 |
+| --- | --- | --- |
+| 订阅 | 等待数据源确认订阅已建立。fire-and-forget `spawn()` 不构成就绪证明。 | 自订阅就绪起的每次状态变化都能进入该订阅流。 |
+| 快照 | 订阅就绪后拉取一次全量状态。 | 快照覆盖数据源定义的一致性时点上的完整对象集。 |
+| 安装 | 由本地投影 owner 原子替换基线,或在唯一 writer 中完成替换。 | 增量处理不会与快照安装无序地并发写入同一投影。 |
+| 回放 | 应用订阅就绪后缓冲的事件。有 revision 时只接受快照 revision 之后的事件;无 revision 时必须使用 generation / version 检查或幂等应用。 | 重复或陈旧事件不会回退快照中的新状态。 |
+| Ready | 快照已安装,且订阅 backlog 已追至定义好的收敛点后,再发布 ready。 | ready 的读者不会观察到只有部分快照的中间态。 |
+
+## 3. 失败与重同步
+
+- **订阅失败**:不开始全量拉取,不发布 ready。重试必须从建立新订阅开始。
+- **快照失败**:当次同步不成立。关闭或废弃当前订阅,再按完整顺序重试,避免无界缓冲。
+- **当前事件流失效**:lag、断线或 source epoch 变化都会使旧流失去完整性证明。撤销 ready 并废弃旧流;采用通用顺序时,新 `listen` 就绪后必须全量同步;采用 revision 耦合顺序时,重新执行 `snapshot@R → watch@R+1`。
+- **关闭**:先停止接收新事件,再 cancel / wake 并 join 事件任务,最后释放 stream 和投影依赖。close 成功返回后不得再有任务写入该投影。
+
+固定 `sleep` 只能作为超时边界或调试手段,不能作为订阅就绪、快照安装或 backlog 收敛的证明。
+
+## 4. 第二种顺序:snapshot@R → watch@R+1
+
+如果数据源明确提供原子 revision 契约,可以使用以下顺序:
+
+`snapshot at revision R → watch from revision R + 1`
+
+该顺序必须同时满足:
+
+- 快照返回可用的 revision / cursor。
+- watch API 保证从指定 revision 后的第一个事件开始交付。
+- 数据源保证快照与事件日志使用同一个有序 revision 空间。
+- 测试覆盖 snapshot / watch 交界处的并发更新。
+
+普通的 `fetch_all()` 加无起始 revision 的 `listen()` 不满足该顺序的前提。
+
+## 5. Review 与验证清单
+
+- [ ] 订阅就绪是可等待的 completion barrier,没有用 `spawn()` 成功替代订阅成功。
+- [ ] 通用顺序中的每次 `listen` 建立完成后都会拉取一次全量快照,包括首次启动和所有恢复路径。
+- [ ] 代码只实现两种合法顺序;不存在新 `listen` 直接沿用旧快照的第三条分支。
+- [ ] 使用 `snapshot@R → watch@R+1` 时,数据源和测试满足全部 revision 契约。
+- [ ] 快照安装和事件应用有唯一 writer,或有可证明的 revision 顺序。
+- [ ] 事件发生在订阅前、订阅后且快照前、快照后三个窗口时,最终投影都正确。
+- [ ] 重复事件和陈旧事件不会回退状态。
+- [ ] ready 只在快照与 backlog 都收敛后发布。
+- [ ] 测试用明确的状态条件等待,不用固定 `sleep` 代替同步。
diff --git a/fluxon_doc_en/blog/blog_1_Built for AI Data Movement - Fluxon Distributed Key-Value Cache, RPC, Message Queue, and File Object Cache Acceleration Layer.md b/fluxon_doc_en/blog/blog_1_Built for AI Data Movement - Fluxon Distributed Key-Value Cache, RPC, Message Queue, and File Object Cache Acceleration Layer.md
new file mode 100644
index 0000000..c15f2c4
--- /dev/null
+++ b/fluxon_doc_en/blog/blog_1_Built for AI Data Movement - Fluxon Distributed Key-Value Cache, RPC, Message Queue, and File Object Cache Acceleration Layer.md
@@ -0,0 +1,196 @@
+# Fluxon: A Rust Data Plane for KV Cache, RPC, Message Queues, and S3-Compatible Object Caching
+
+
+
+As GPU compute power continues to scale, the bottleneck in AI systems is expanding from individual operators into the data plane. Inference services need to reuse `KV Cache`, `latent cache`, and prefix cache across requests, processes, and nodes. Training pipelines need to pass intermediate state across heterogeneous resource pools. Model files, sample data, and `Checkpoint` data need to move reliably across remote access, local caching, and cross-cluster migration paths.
+
+Traditional approaches usually introduce separate caching systems, message queues, file systems, object storage gateways, and observability pipelines for these problems. Each system has its own connection, capacity, reclamation, routing, and observability logic. As model scale, request concurrency, training pipelines, and dataset size continue to grow, data-plane overhead keeps expanding and gradually consumes CPU, I/O, memory, and operational effort.
+
+Fluxon is a storage and transport integrated distributed system for the AI training and inference data plane. It brings distributed key-value caching, `RPC`, message queues, and an `S3`-compatible file object cache acceleration layer into one data plane acceleration foundation, so these high-frequency data objects can reuse one set of caching, transport, lease, capacity governance, and observability capabilities.
+
+## Origin: Pain from Elastic VAE Decoupling
+
+One early engineering motivation for Fluxon came from cross-resource-pool data handoff in `VAE` decoupled heterogeneous training. We wanted `Producer` and `Consumer` components to scale independently in different resource pools and hand off intermediate `Payload` objects asynchronously, instead of binding training components again through a fixed communication group.
+
+In this scenario, the boundary of `NCCL` is clear. It is a strong tool for synchronous collective communication inside a fixed member set, and it fits high-performance communication inside a training process group. However, when dynamic membership, asynchronous handoff, backpressure, message retention, and elastic scheduling across resource pools are required, the fixed-member communication model couples together the training components that we wanted to decouple.
+
+We also tried to use high-performance transport and caching systems such as `MooncakeStore`, which target `KV Cache`, for large `Payload` movement in training pipelines. They have clear value in specific cache scenarios. When directly generalized into a common AI training data plane, however, new engineering constraints appear. Under high load and long-running stability tests, if low-level memory lifetime and transport state are not handled completely, the business side may see process crashes or task interruptions. When the `RDMA` network jitters, training pipelines often struggle to recover if there is no automatic fallback to paths such as `TCP`. In addition, if low-level exceptions cannot be captured reliably and propagated upstream, `Producer` / `Consumer` components can enter inconsistent states, block for a long time, or require repeated manual diagnosis.
+
+These experiences made us realize that the AI data plane cannot only pursue peak performance on one segment of the transport path. It also needs disciplined error propagation, highly available network fallback, unified object lifecycle management, and a resource governance model that supports dynamic business membership.
+
+Because existing systems struggled to cover elastic decoupling, high-performance movement, and failure fallback at the same time, Fluxon chose to redesign this path from the data plane acceleration foundation.
+
+## Why a Unified AI Data Plane Is Needed
+
+Data objects in AI workloads are becoming larger, hotter, and more frequently moved across boundaries.
+
+On the inference side, high-frequency caches are no longer temporary objects inside a single process. In multi-replica inference services, world models, multimodal models, and long-context scenarios, these caches often need reuse across requests, processes, and nodes. If each instance independently manages cache residency, reclamation, and transport, GPU memory and host memory can be exhausted quickly by redundant data and duplicate residency.
+
+Training-side data flow is also becoming more complex. Cross-resource-pool `Producer` / `Consumer` handoff scenarios, represented by VAE decoupled heterogeneous training, can place `Producer` and `Consumer` components on different machines, different resource pools, or even different sub-clusters. If intermediate `Payload` objects can only move through traditional MQ, file landing, or object storage across multiple systems, the data path becomes longer and capacity governance becomes harder.
+
+The same applies on the storage side. High-resolution video, trajectory samples, model files, and `Checkpoint` data need to support remote access, local caching, `S3` forwarding, and cross-cluster migration at the same time. If file object caching and `KV` caching are split into two systems, large objects are repeatedly split, copied, landed, and re-indexed across systems.
+
+Fluxon focuses on the full AI data-plane lifecycle: how objects are allocated, where they are placed, how they are transferred across nodes, when they are evicted, how business processes connect to them, and how issues are located when something goes wrong.
+
+## Why Patchwork AI Data Planes Hit a Bottleneck
+
+A common traditional AI data-plane pattern is to use one KV or custom cache for high-frequency caching, send intermediate handoff through MQ or object storage, attach file data through another file system or object cache, and collect observability status from each component separately.
+
+This approach can assemble functionality quickly at an early stage, but several problems appear as the system grows.
+
+**First, local scenario experience is hard to transfer.** `MooncakeStore` provides a specialized design for `KV Cache` scenarios by binding cache semantics and `RDMA` transport to a specific path. In more general data-plane scenarios, low-level transport and capacity governance are hard to move over directly.
+
+**Second, unified resource control and scheduling are incomplete.** Framework-level caches such as `SGLang` `L2` keep in-framework indexes to provide lowest-latency access. External caches such as `MooncakeStore` as `L3` handle reuse across instances. These two layers often live in the same host `CPU` memory, while `L2` memory is hard to include in unified indexing, placement, and eviction governance. This also increases cache-crossing and object-handoff overhead.
+
+**Third, there is no shared-memory fast path for colocated processes.** When `MooncakeStore` is used as `L3`, for example, the data path is usually organized around `RDMA` / `TCP`, which fits cross-node pooling and remote prefetch. If colocated `Worker` or `Producer` / `Consumer` processes cannot directly enter the shared-memory data plane, object handoff still detours through the network transport stack.
+
+**Fourth, AI Infra lacks a dynamically elastic communication plane.** Collective communication libraries such as `NCCL` are strong tools for synchronous communication inside a fixed member set. In VAE decoupled heterogeneous training, cross-resource-pool `Producer` / `Consumer` handoff needs dynamic membership, asynchronous handoff, backpressure, message retention, and large `Payload` placement. A fixed-member communication model couples training components again and amplifies connection and recovery complexity.
+
+**Fifth, business processes are coupled with data-plane resource governance.** When business processes start, stop, scale, or fail dynamically, and also contribute capacity, manage object lifecycles, and maintain cross-node connections, data-plane capacity and connection topology fluctuate with business lifecycles. This affects cache reuse, failure recovery, and operational diagnosis.
+
+**Sixth, object lifecycle management is hard to converge.** When caches, messages, and file object access each maintain references, leases, eviction, routing, and reclamation state, it becomes difficult for a team to determine in one place whether an object is still being consumed, when it can be released, when it needs migration, or when indexes need rebuilding. The more components there are, the more state spreads across the business framework, cache layer, and transport layer.
+
+**Seventh, observability pipelines are fragmented.** If cache hits, `Owner` queues, transport paths, object materialization, and business-process latency are spread across separate systems, it is hard to locate the bottleneck quickly when a performance issue occurs. Teams often have to stitch clues together across multiple metric sets and logs, and diagnosis cost grows with the number of components.
+
+Fluxon is designed around these problems. It separates data-plane resources, object lifecycles, cross-node transport, and business integration into explicit abstractions, then governs them inside one data plane acceleration foundation.
+
+## Fluxon's Approach
+
+Fluxon is built on a storage and transport integrated data plane acceleration foundation and exposes three types of entrypoints upward:
+
+| Entrypoint | Target Scenario | Capability |
+| --- | --- | --- |
+| Distributed key-value cache / `RPC` | Inference cache, state sharing, service-to-service calls, tensor object reuse | Unified key-value read/write and inter-node `RPC`, serving high-frequency state caches and tensor object reuse |
+| Message queue | Cross-resource-pool `Producer` / `Consumer` intermediate-state handoff, such as VAE decoupled heterogeneous training, and data-processing pipelines | Dynamically elastic `AI Infra` communication plane that reuses the key-value cache data plane for large `Payload` objects |
+| File object cache acceleration layer | AI data, model files, `Checkpoint` data, remote object access, `S3` forwarding | `S3`-compatible file object cache acceleration layer that reuses the data plane acceleration foundation with `KV/MQ`, so tensors, message `Payload` objects, and file objects enter a unified caching and acceleration path |
+
+All three entrypoints reuse the same caching, transport, lease, capacity governance, object lifecycle, and observability capabilities. This means high-frequency data objects in these scenarios can all receive unified caching and acceleration inside the same data plane acceleration foundation. AI workloads do not need to build multiple separate data-plane paths.
+
+For colocated resource governance, Fluxon prefers to organize cache layers by real physical resources. Host `CPU` memory should first enter shared memory and unified object lifecycle governance, reducing the extra paths introduced by artificial `L2` / `L3` splits inside business frameworks. Cross-machine, cross-cluster, and remote object access should use distributed transport paths.
+
+This is the main difference between Fluxon and a single-point cache, single-point message queue, or single-point file interface. The core of Fluxon is the data plane acceleration foundation, and the APIs are the entrypoints that this foundation exposes for different scenarios.
+
+## Architecture Layers: Clear Role Boundaries
+
+Fluxon abstracts the control plane, data-plane resources, and business integration into three core roles. This layering decouples resource governance from business lifecycles and removes topology and connection bottlenecks for AI data-plane scale-out in large clusters.
+
+Master, the control plane, uniformly manages memory allocation, object placement, eviction, routing, and leases. It converges key decisions into the control plane, keeps object lifecycles consistent across multi-process and multi-node scenarios, and enables precise memory reclamation.
+
+Owner Client, the data-plane resource provider, is a resident node that contributes the shared memory pool and carries inter-Owner communication. Business processes connect to their local Owner, and the Owner performs cross-machine transport. This local proxy plus cross-machine backbone structure avoids connection storms caused by direct interconnection between business processes. Cross-machine connections are strictly converged between Owners, so network topology complexity, routing state, and data movement paths remain easier to control as the cluster grows.
+
+External Client, the business integration layer, carries dynamic access from inference services, MQ `Producer` / `Consumer`, FluxonFS, FluxonOps, and other components, and does not contribute cluster capacity. Because External Client does not provide physical capacity, frequent business-side start/stop, abnormal restart, or elastic scaling does not directly trigger data-plane capacity migration or `Rebalance` churn. Elasticity of compute services is isolated from stability of the data foundation, and the business layer can implement stateless scale-out more easily.
+
+Together, these three roles build the base stability and scalability of the AI data plane. Master, as the decision maker, converges global state and prevents object placement, routing, and reclamation logic from spreading into business processes. Owner Client, as the provider and carrier, anchors physical capacity and the cross-machine backbone, keeping cross-machine connections at the data-plane resource layer. External Client, as the accessor, carries elastic business workloads without disturbing the underlying topology. Once these boundaries are clear, Fluxon can let cache, message, and file object access truly reuse the same scalable data plane acceleration foundation without giving up the single-machine fast path.
+
+## Data Paths: Local Shared Memory, Cross-Node P2P, and Automatic Relay
+
+Fluxon covers local object handoff, cross-node object movement, and relay forwarding under complex network topologies.
+
+On the local path, business processes first connect to the Owner shared memory pool and reduce object-handoff cost through SHM / Busy Polling / Epoll UDS. High-frequency data objects can avoid unnecessary copying and reconstruction.
+
+On the cross-node path, Owners transfer data through P2P transport. The deployment can select `RDMA`, `TCP`, `QUIC`, and other paths, and Fluxon supports automatic relay forwarding across nodes and sub-clusters. When the source Owner and target Owner cannot communicate directly through the preferred link, the data plane can still complete transport through a relay path. Business processes do not need to understand complex network topology. They only connect to the local Owner, and cross-machine data movement converges to Owner-to-Owner paths. This layered architecture reduces cross-machine connection spread between business processes and further improves system scalability.
+
+This design allows Fluxon to serve multi-process reuse inside one machine, cross-node reuse inside a cluster, and cross-cluster data flow at the same time. The former focuses on shared memory and object handoff, while the latter focuses on connection convergence, routing adaptation, and cross-machine transport.
+
+## Core Engine: Rust for a Low-Overhead, Controllable Data Plane Acceleration Foundation
+
+Rising GPU compute and growing cluster scale make I/O and CPU explicit bottlenecks in AI systems. Connection handling, protocol encoding and decoding, high-concurrency transport, shared-memory management, and observability collection all sit on data-plane hot paths. If these hot paths are filled with interpreted execution, runtime scheduling, cross-language-boundary copies, and uncontrolled memory copying, the time saved on the GPU side can be consumed by data-plane overhead.
+
+Fluxon implements these key paths in Rust, with the goal of bringing concurrency safety, memory lifetimes, and system-call boundaries under stronger engineering constraints.
+
+- Concurrency: CPU-intensive paths are not constrained by the GIL, making it easier to use multi-core resources fully.
+- Predictable latency: no GC pauses, reducing unpredictable jitter on hot paths.
+- Long-running safety: ownership, lifetimes, and the type system constrain shared memory, connection state, and object references.
+- Auditable code: strongly typed interfaces and explicit state machines make the low-level data plane easier to review by humans and tools.
+
+This system-level control of the low-level data plane is the foundation for efficient and safe movement of high-frequency data objects on the same data plane acceleration foundation. For the low-level data plane, this matters more than simply pursuing peak performance of one interface.
+
+## Unified Observability
+
+Fluxon's observability foundation uses GreptimeDB, which is also built in Rust. This matches Fluxon's low-level philosophy: GreptimeDB converges the three observability pillars, Metrics, Logs, and Traces, into one engine, just as Fluxon converges cache, message, and file object access into one data plane acceleration foundation.
+
+Fluxon collects metrics through the Prometheus protocol, combines them with tracing and structured logs, and provides a built-in GUI that clearly presents cluster topology, member status, key latencies, and queue depths.
+
+For a data-plane system, observability is part of governance, not a nice-to-have. Only when an issue can be located precisely to Owner, External Client, transport path, queue waiting, or object processing can the data-plane foundation be truly governable. Unified observability is a key part of closing the loop for Fluxon's data plane acceleration foundation.
+
+## FluxonKV: Distributed Key-Value Cache and RPC on a Shared Data Plane Acceleration Foundation
+
+Fluxon `KV/RPC` targets world model inference caches, state sharing, service-to-service calls, and tensor object reuse. In scenarios such as multi-view latent-space prediction, state extrapolation, and prefix-cache reuse, it covers a more general AI data plane than a single `KV Cache` scenario.
+
+KV and RPC share the same parameter organization, caching, and communication path. State storage, object reuse, and service-to-service calls do not need two separate paths. High-frequency calls and large-object reuse can be completed inside the same role model.
+
+
+
+On the read path, Fluxon prioritizes local fast-path hits and advances metadata synchronization asynchronously in the background. The system reduces duplicate residency and memory waste across multiple cache tiers through hot-object reuse, and uses batched reclamation to converge fragmented control-plane interactions into batch operations. This reduces control-plane traffic and compute overhead, improving overall throughput and long-running behavior.
+
+For inference systems, this means high-frequency state caches, tensor objects, and service calls can enter the same data plane acceleration foundation without repeated conversion between a cache system and an RPC system.
+
+## FluxonMQ: Dynamically Elastic Communication Plane for AI Data Flow
+
+Fluxon `MQ` targets cross-resource-pool `Producer` / `Consumer` intermediate-state handoff and data-processing pipelines. VAE decoupled heterogeneous training is one representative scenario. When `Producer` and `Consumer` components are distributed across different machines, different resource pools, or even different sub-clusters, `MQ` converges message retention, capacity governance, and cross-cluster placement into a unified messaging layer.
+
+Traditional `MQ` systems are usually built on `TCP` and disk logs. Their original design is not oriented toward large `Tensor Payload` objects, so they lack a native high-speed `RDMA` data path. When intermediate state reaches dozens of `MB` or even `GB`, general-purpose `MQ` systems struggle to satisfy low-latency handoff, capacity governance, and high-bandwidth movement at the same time.
+
+Collective communication such as `NCCL` fits synchronous communication inside a fixed member set. Cross-resource-pool `Producer` / `Consumer` handoff needs dynamic membership, asynchronous handoff, backpressure, message retention, and large `Payload` placement. Specialized cache systems such as `MooncakeStore` perform strongly for `KV Cache` reuse, but their core cache-eviction semantics do not directly replace the `MQ` requirement that a message must remain before it is consumed. Their transport paths also usually depend on deployment-side choices between `RDMA` / `TCP`, making it difficult for them to take on automatic fallback, cross-cluster relay, and elastic handoff inside a data plane acceleration foundation.
+
+The core design philosophy here is: keep the control plane light, retaining only message shells, member topology, and Offset; keep the data plane heavy, moving large Payload objects directly through the KV data plane. This means Fluxon does not need to build a second large-object transport path for the message queue.
+
+`Producer` / `Consumer` components join dynamically as `External Client` instances without changing cluster capacity. They can scale with business load, while `Owner` remains stable as the resident data-plane resource provider.
+
+`Lease` binds message retention to the message channel, giving pre-consumption data retention an explicit time boundary. In cross-resource-pool and cross-sub-cluster scenarios, `Payload` placement can use the consumer-side location to shorten the prefetch path as much as possible.
+
+## FluxonFS: S3-Compatible File Object Cache Acceleration Layer
+
+Fluxon `FS` is positioned as an `S3`-compatible file object cache acceleration layer. It targets AI data, model files, `Checkpoint` data, high-resolution video, and trajectory samples, covering remote access, cache hits, `S3` forwarding, and cross-cluster migration.
+
+The key point of FS is reusing `KV/RPC` caching and communication capabilities. Files are split into `KeyValue` shards and enter Fluxon's caching, transport, and capacity-governance paths. In this way, files, objects, and KV caches converge from three fragmented systems into different entrypoints of the data plane acceleration foundation.
+
+For AI data platforms, this path can reduce the cost of switching file data across remote access, local caching, and cross-cluster migration. The upper layer still uses file object semantics, while the lower layer reuses unified data-plane capabilities.
+
+## Benchmark
+
+The current public benchmark charts cover three paths: RPC, KV, and FS, the file object cache acceleration layer. The following data is generated from specific test scenarios and focuses on architectural benefits and performance boundaries across different data paths.
+
+Note: Benchmark data is generated under specific topology and Payload sizes. Actual business gains depend on network environment, object size, and access pattern.
+
+### RPC Benchmark
+
+The RPC Benchmark compares ZeroRPC, Fluxon TCP Thread, and Fluxon RDMA under a 4 KB echo Payload, covering throughput and end-to-end latency. The charts show that Fluxon's RPC path significantly reduces latency and improves aggregate throughput in both P1 and P8 panels.
+
+
+
+This result corresponds to the service-to-service call path. It shows that the parameter organization, routing, and communication foundation shared by RPC and KV can carry high-frequency small-Payload calls. End-to-end time for actual business handlers will still be affected by application logic.
+
+### KV Benchmark
+
+The KV Benchmark shows three scenarios: READ_AFFINITY, READ_ZIPF, and PUT_ONLY. Read-heavy scenarios are the current public result's strong area, especially for explaining the benefits from locality, hot-object reuse, and cross-node object location.
+
+
+
+In the pure-write PUT_ONLY scenario, the current performance constraint is mainly in the inflight metadata deduplication path rather than Payload transport itself. This is also one of the core directions for later optimization.
+
+### FS Benchmark: File Object Cache Acceleration
+
+`FS Benchmark` compares Fluxon `FS` with `Alluxio`, covering small-file read, large-file read, small-file write, and large-file write under cache warmup. The most prominent area in the chart is large-file write. Small-file read already shows an advantage, large-file read performance is roughly on par, and small-file write performance still has room for further optimization.
+
+
+
+This result corresponds to the file object cache acceleration layer. Fluxon FS benefits from reusing the KV/RPC data plane, while small-file writes are still affected by upper-layer open, write, close, and commit flows.
+
+## Why Open Source
+
+Fluxon open-sources a complete data plane acceleration foundation for the AI data plane: Rust core implementation, Python interfaces, distributed key-value cache, RPC, message queue, `S3`-compatible file object cache acceleration layer, deployment toolchain, test stack, and Benchmark are all organized in one project.
+
+We want developers to directly see how Fluxon organizes the control plane, data plane, business integration, observability, and tests, and to understand the data plane acceleration foundation behind these interfaces. AI infrastructure is moving from single-model, single-service, and single-cluster forms toward more complex data-flow patterns. Cache, message, and file object access all need to be considered again inside one data-plane path.
+
+Fluxon is open-sourced under Apache License 2.0. We hope to work with the community on AI inference caching, heterogeneous training, file object caching, cross-node transport, shared memory, Rust data planes, and observability systems.
+
+## Next Steps
+
+Fluxon is still evolving quickly. In the short term, we will continue to optimize `KV Cache` integration in `SGLang`. To meet `L2` requirements for extremely low-latency access, the interface layer will also gain new capabilities that make cooperation among in-framework indexing, local shared memory, and the data plane acceleration foundation more direct.
+
+In the longer term, Fluxon aims to become the data plane acceleration foundation inside AI systems: bringing AI workload data objects and cross-node transport into one governable and observable path.
+
+We expect algorithm engineers and model-serving developers to spend more energy on model innovation itself, instead of repeatedly paying for duplicated low-level data movement and reactive patches. Fluxon is designed to serve as this data plane acceleration foundation and support more complex, freer data movement in the AI era.
+
+Fluxon is developed by the AI Infra team at the Artificial Intelligence Research Institute of China Telecom (TeleAI), led by China Telecom Chief Scientist Professor Xuelong Li. Fluxon is open-sourced under Apache License 2.0. GitHub repository: https://github.com/Tele-AI/Fluxon. Developers interested in AI inference caching, heterogeneous training, Rust data planes, and distributed systems are welcome to participate.
diff --git a/fluxon_doc_en/dev_doc/Developer - 3 - Documentation Writing Rules.md b/fluxon_doc_en/dev_doc/Developer - 3 - Documentation Writing Rules.md
index 4577714..4616200 100644
--- a/fluxon_doc_en/dev_doc/Developer - 3 - Documentation Writing Rules.md
+++ b/fluxon_doc_en/dev_doc/Developer - 3 - Documentation Writing Rules.md
@@ -5,9 +5,16 @@ This page defines how Fluxon user docs, developer docs, and design docs should b
- Let readers reach the stable conclusion as quickly as possible.
- Keep docs aligned with code, public contracts, and actual behavior.
+For a sweep-by-sweep workflow and a reusable one-shot prompt, see [Technical Documentation Copy Editing](./Developer%20-%205%20-%20Technical%20Documentation%20Copy%20Editing.md).
+
## 1. General Rules
- Lead with the conclusion, then expand. A reader should know what the page answers within the first 30 seconds.
+- Keep the opening at one abstraction level. State only the problem, core points, and document map; leave local implementation details, measurement methodology, and per-section evidence in their corresponding sections.
+- Use labels that match the content in headings, introductions, and table headers. Call a summary “core points,” a capability inventory “current status,” and reserve “conclusion” for a claim supported by the preceding argument.
+- Group responsibilities and actions by subject. When introducing multiple roles, finish one role before moving to the next so readers do not have to reconstruct a subject's responsibilities from scattered clauses.
+- Place the rationale or mechanism close to an important architecture claim, especially for responsibility boundaries, lifetimes, and data direction. Do not state only the outcome and scatter its reason later in the document.
+- Match claim strength to evidence strength. State verified behavior and results directly; qualify design goals or expected benefits with terms such as “expected,” “aims to,” or “may”; remove or explicitly bound outcomes that lack supporting evidence.
- Prefer natural engineering terms. Avoid invented or template-heavy wording such as `root object`, `first-level branch`, or `authority object` unless the term is truly necessary.
- Write for readers, not as a dump of the author's thinking process. Remove template filler such as "this section does not discuss" or "why this branch belongs to the previous layer."
- If a paragraph can become a table, list, or diagram, do that instead of forcing a long linear explanation.
diff --git a/fluxon_doc_en/dev_doc/Developer - 5 - Technical Documentation Copy Editing.md b/fluxon_doc_en/dev_doc/Developer - 5 - Technical Documentation Copy Editing.md
new file mode 100644
index 0000000..74fd2b2
--- /dev/null
+++ b/fluxon_doc_en/dev_doc/Developer - 5 - Technical Documentation Copy Editing.md
@@ -0,0 +1,218 @@
+# Developer - 5 - Technical Documentation Copy Editing
+
+This document adapts the parts of the upstream `copy-editing` skill that are useful for technical writing into a Fluxon documentation review workflow. Use it after the technical facts and structure are established. It does not replace the repository documentation rules and cannot change public APIs, runtime behavior, or performance conclusions.
+
+## 1. Source and scope
+
+| Item | Details |
+| --- | --- |
+| Upstream project | [`coreyhaines31/marketingskills`](https://github.com/coreyhaines31/marketingskills) |
+| Upstream skill | [`copy-editing`](https://github.com/coreyhaines31/marketingskills/tree/0ba2a7fafc7b0827a261bd518e87cbda18e6675f/skills/copy-editing) |
+| Skill version | `2.0.0` |
+| Pinned commit | `0ba2a7fafc7b0827a261bd518e87cbda18e6675f` |
+| Upstream purpose | Edit existing marketing and conversion copy while preserving its core message and voice. |
+| Purpose here | Adapt its clarity, voice, rationale, evidence, and specificity checks to Fluxon technical documentation. |
+| License | MIT; the complete notice appears at the end of this document. |
+
+Resolve conflicts in this order:
+
+1. The current task, repository `AGENTS.md`, and the [documentation writing rules](./Developer%20-%203%20-%20Documentation%20Writing%20Rules.md).
+2. Code, public contracts, tests, and verified runtime behavior.
+3. This copy-editing workflow.
+
+Smoother prose is not a reason to change technical meaning. If a fact is uncertain, an interface disagrees with the documentation, or the available evidence is insufficient, verify the implementation before deciding whether to change the documentation or the code.
+
+## 2. Applying the seven upstream sweeps to technical documentation
+
+The upstream skill divides editing into seven sweeps. Fluxon directly adopts four, adapts two, and skips one:
+
+| Upstream sweep | Fluxon use | Decision |
+| --- | --- | --- |
+| Clarity | Check long sentences, references, undefined terms, missing context, and conclusions buried under qualifications. | Adopt. |
+| Voice and Tone | Keep formality, role names, component names, and terminology consistent while using natural engineering language. | Adopt. |
+| So What | Explain the reason, effect, and cost of an architecture choice; answer why the design works this way. | Adapt to engineering rationale; do not add marketing benefits. |
+| Prove It | Support behavior and performance claims with code paths, type signatures, tests, metrics, or bounded experiments. | Adopt. |
+| Specificity | State the scope, abstraction level, preconditions, failure conditions, and excluded paths. | Adopt. |
+| Heightened Emotion | Amplify pain, anxiety, aspiration, or emotional impact. | Skip. Technical documentation prioritizes accuracy and verifiability. |
+| Zero Risk | Add marketing CTAs, guarantees, and risk reversals. | Keep only operational preconditions, failure results, and next steps in user documentation. Do not add marketing CTAs. |
+
+The upstream limits for English word counts, active voice, and short paragraphs are heuristics. Judge Chinese sentence length, code identifiers, and complex ownership relationships by readability instead of applying fixed thresholds mechanically.
+
+## 3. Review workflow
+
+### 3.1 Establish the technical facts first
+
+Before editing, identify:
+
+- The document type and target reader.
+- The boundaries between the public contract, current implementation, and specialized fast paths.
+- Whether key types, configuration keys, return values, and failure semantics match the code.
+- The complete path covered by behavior, ownership, and performance claims.
+- The Chinese or English counterpart that must be updated at the same time.
+
+If these points are unresolved, perform a technical review first. Copy editing cannot replace implementation verification.
+
+### 3.2 Sweep one: structure and clarity
+
+First check whether a reader can answer three questions from the opening:
+
+1. What problem does this document address?
+2. What is the most important stable information?
+3. In what order will the document develop the topic?
+
+Then review each section:
+
+- Does the heading state the section's decision or task directly?
+- Does the section lead with stable information at its current abstraction level before fields, branches, and measurements?
+- Does the body answer the same set of concerns introduced at the beginning?
+- Is the same fact repeated in a role table, flow table, diagram legend, and summary?
+- Does local implementation detail appear too early in the introduction or architecture overview?
+
+### 3.3 Sweep two: voice and terminology
+
+- Use one canonical name, spelling, and capitalization for each concept.
+- Give role boundaries explicit subjects, such as “master maintains `route`” and “owner manages local SSD.”
+- Remove labels such as “stable conclusions” or “current conclusions” when the content is a summary or capability state. Use “core points,” “current status,” or the concrete statement instead.
+- Avoid template language, promotional language, and unsupported claims to “improve,” “enhance,” or “optimize.”
+- Keep required English code terms. Do not translate public types or fields merely to make the prose look more localized.
+
+### 3.4 Sweep three: rationale, evidence, and boundaries
+
+Check four properties for every important claim:
+
+| Property | Question to answer |
+| --- | --- |
+| Rationale | Why does this responsibility boundary, data direction, or lifetime exist? |
+| Mechanism | Which object, field, or call path implements the behavior? |
+| Scope | Which step, abstraction level, and branches does the claim cover? |
+| Exclusions | Which costs, paths, recovery capabilities, or deployment conditions are outside the claim? |
+
+Place the reason close to the architecture claim. For example, when the master schedules memory allocations, immediately explain that these allocations cover both final value replicas and temporary cross-owner transfer memory. Listing ownership alone is insufficient.
+
+Bind performance claims to hardware, datasets, concurrency, output boundaries, measurement windows, and run counts. End-to-end logical payload bandwidth must not be described as raw SSD bandwidth.
+
+### 3.5 Sweep four: contraction and deduplication
+
+Resolve repetition in this order:
+
+1. Keep the first stable statement of the fact.
+2. Keep one table or diagram when it materially lowers the cost of understanding.
+3. In later sections, expand only new mechanisms, conditions, or failure paths.
+4. In the summary, return to the public contract, core dataflow, and current boundaries without repeating experiment tables.
+
+Do not repeat role definitions merely to make every section appear self-contained. Once a role is defined in the overview, later diagram introductions should explain only meanings specific to that diagram.
+
+### 3.6 Sweep five: pre-publication recheck
+
+After a pass, work backward and confirm that later edits did not invalidate earlier decisions:
+
+- Terms, section numbers, and cross-references remain consistent.
+- Tables, code fences, disclosure blocks, and Mermaid diagrams parse correctly.
+- Chinese and English pages express the same contracts and boundaries.
+- Exact identifiers still use their real names.
+- The edit did not introduce a compatibility path, configuration entry, or unverified fact.
+- The documentation site builds successfully.
+
+## 4. Collaborative review and one-shot example
+
+### 4.1 Trace surface issues to root causes
+
+An editing comment should identify the location, problem, reason, and proposed change. “This reads awkwardly” alone is not enough to justify an edit.
+
+| Item | Example |
+| --- | --- |
+| Location | The experiment paragraph in the opening. |
+| Problem | It introduces measurement details before the reader understands the document structure. |
+| Reason | Local evidence occupies the introduction's abstraction level. |
+| Proposed change | Keep only the document map in the opening and move measurement conditions to the experiment section. |
+
+For clear, local issues that do not change technical meaning, edit directly and recheck the result. Before changing a contract, claim scope, or section narrative, explain the evidence and impact.
+
+A complete pass should also identify the common causes behind multiple surface issues. This lets the editor correct the full document and reuse the lesson on the next one. The following table uses a set of actual edits to the KV SSD article:
+
+| Surface issue | Root cause | Editing decision |
+| --- | --- | --- |
+| “First, remember four stable conclusions” introduces a summary. | The label is stronger than the content; a summary is not yet a supported conclusion. | Use “four core points.” |
+| The prose states the owner's physical resources, switches to the master, and then returns to the owner's SSD scheduling. | One subject's responsibilities are fragmented, forcing readers to reconstruct the role boundary. | Group by subject: explain the master completely, then the owner. |
+| The text only says that the master schedules memory allocations. | The responsibility claim lacks a nearby rationale, so readers cannot tell whether the boundary is complete. | Immediately state that memory allocations cover both final value replicas and temporary transfer memory, and that both interact with `route` and in-flight state. |
+| The opening explains Section 9 experiment groups, hit conditions, and measurement methodology. | Local evidence and the introduction occupy different abstraction levels, hiding the document map. | Keep only the problem, core points, and reading path in the opening; leave experiment conditions in Section 9. |
+| “Cover a larger runtime working set” is presented as an already realized result. | A design expectation is stated as a verified result, making the claim stronger than the evidence and leaving the reader-facing runtime benefit implicit. | Preserve the unchanged-public-API scope and say that the design is expected to raise cache hit rates and make fuller use of storage bandwidth. |
+
+These edits serve two shared goals: help readers build the system model at the right level and keep every claim within its evidence boundary.
+
+### 4.2 Reusable one-shot example
+
+Use the following prompt after the technical facts have been verified and the document needs a complete structural and language review:
+
+```text
+Target document:
+
+Perform one complete copy-editing pass on the target technical document and edit it directly. Preserve public APIs, implementation facts, benchmark numbers, and existing claim boundaries.
+
+Check the following:
+1. The opening states only the problem, core points, and document map. It does not preload local implementation detail or experiment methodology.
+2. Labels in headings, introductions, and table headers match their content. Do not call summaries or capability states “conclusions.”
+3. Group responsibilities by subject. When introducing multiple roles, finish one role before moving to the next.
+4. Place the reason or mechanism next to every important architecture claim, especially responsibility boundaries, lifetimes, and data direction.
+5. Match claim strength to evidence. State verified results directly; qualify design goals or expected benefits with “expected,” “aims to,” or “may”; do not present unsupported outcomes as facts.
+6. Remove repeated role descriptions, details at the wrong abstraction level, and summaries that add no information. Preserve necessary scope, preconditions, and exclusions.
+7. Keep terminology, type names, API names, section numbers, cross-references, tables, and code fences consistent.
+
+After editing:
+- Summarize the main changes as “surface issue → root cause → editing decision” instead of listing every sentence edit.
+- Update the corresponding Chinese or English page when one exists.
+- Recheck public contracts, technical facts, and performance methodology, then run the repository's required documentation-site build.
+```
+
+## 5. Upstream practices not adopted here
+
+- Do not use emotional amplification, FOMO, exaggerated pain, or sales language in technical articles.
+- Do not force a user benefit onto every implementation fact. Explain consequences only when they clarify rationale or trade-offs.
+- Do not add unsourced numbers, time commitments, case studies, or comparative claims.
+- Do not mechanically convert English guidance such as “no more than 25 words per sentence” into Chinese character limits.
+- Do not force every sentence into active voice; ownership and dataflow clarity take priority.
+- Do not use scores from invented expert personas as a substitute for code, tests, or same-level end-to-end tracing.
+
+## 6. Updating the upstream snapshot
+
+This document is pinned to upstream commit `0ba2a7f`. To update it:
+
+1. Compare upstream `skills/copy-editing/SKILL.md` and `references/` with the pinned revision.
+2. Import only changes that apply to technical documentation and do not conflict with Fluxon rules.
+3. Update the Chinese and English documents, skill version, and pinned commit together.
+4. Rebuild the documentation site.
+
+Do not overwrite this document automatically with upstream content. The upstream target is marketing copy; this document targets accurate, verifiable technical writing.
+
+## 7. Third-party license
+
+This document is based on Corey Haines's `copy-editing` skill and has been modified for Fluxon technical documentation. The upstream work is licensed under the MIT License.
+
+
+MIT License
+
+```text
+MIT License
+
+Copyright (c) 2025 Corey Haines
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+
diff --git a/fluxon_doc_en/dev_doc/Developer - 6 - Code Review Guidelines.md b/fluxon_doc_en/dev_doc/Developer - 6 - Code Review Guidelines.md
new file mode 100644
index 0000000..0a3109f
--- /dev/null
+++ b/fluxon_doc_en/dev_doc/Developer - 6 - Code Review Guidelines.md
@@ -0,0 +1,114 @@
+# Developer - 6 - Code Review Guidelines
+
+A code-review rule turns an architecture conclusion into an executable constraint. “Responsibilities should be clear” or “lifecycle order should be correct” is only a conclusion; a complete rule also states when it applies, what must be done, how completion is accepted, what benefit it creates, and how to limit the fix's side effects.
+
+## 1. Required structure of a rule
+
+Every rule in this document contains these fields:
+
+| Field | Question to answer |
+| --- | --- |
+| Trigger | Which code shape, dependency, or behavior change activates this rule? |
+| Required action | Which constraint must the author and reviewer establish or change? |
+| Acceptance criteria | Which observable postconditions mean the work is complete? |
+| Expected benefit | Which risk is reduced or engineering capability is gained? |
+| Side-effect boundary | Which extra complexity, coupling, or behavior change must the fix avoid? |
+| Evidence | Which test, type constraint, or runtime result proves acceptance? |
+
+“Must” is a pre-merge requirement. “Should” permits deviation with an explicit rationale in the PR. “May” is an implementation choice that does not affect merge.
+
+## 2. Select rules by change scenario
+
+The reviewer routes change signals to relevant rules instead of mechanically applying every rule to every PR.
+
+| Change signal | Primary rules |
+| --- | --- |
+| A public layer starts accessing internal fields, or one module creates a resource and another cleans it up | R1 responsibility and ownership, R4 composable contracts, R5 minimal-side-effect fix |
+| New `close`, background work, thread, process, runtime, or nested handle | R2 dependency lifecycle, R3 state authority |
+| Multiple participants share `closed`, an event, a lease, or cleanup state | R1 responsibility and ownership, R3 state authority |
+| New adapter, backend replacement, or parent wrapping a child | R1 responsibility and ownership, R4 composable contracts, R5 minimal-side-effect fix |
+| Close races with an in-flight operation, or an outer lock spans an inner blocking call | R2 dependency lifecycle, R3 state authority |
+| A purely local algorithm change does not alter boundaries, resources, or lifecycle | Do not force a complete lifecycle model; review local correctness and tests |
+
+## 3. Executable rules
+
+### R1: Cross-module state and resources have one owner
+
+| Field | Rule |
+| --- | --- |
+| Trigger | A change crosses public, composition, and internal layers; adds a shared resource; introduces duplicate close; or lets an outer layer mutate internal state. |
+| Required action | List the creator, borrower, sharer, and final releaser of affected state and resources. Assign one final-release authority to each resource. The public layer expresses stable contracts, the composition layer orders modules, and the internal module maintains its state, invariants, and cleanup. |
+| Acceptance criteria | Every resource answers “who releases last”; a borrower does not release owner resources; outer code does not manipulate internal fields to perform cleanup; replacing internals leaves public and composition lifecycle logic unchanged. |
+| Expected benefit | Cohesive responsibility, no double release or leak, a smaller change-propagation radius, and independently testable and replaceable modules. |
+| Side-effect boundary | Do not split stateless thin modules merely to create layers, expand public APIs, promote a local resource to global sharing, or move unrelated responsibilities. |
+| Evidence | An ownership table or type relation, public contract tests, and tests proving borrower close preserves owner resources, exclusive-owner close completes release, and shared authority follows its declared release condition. |
+
+### R2: Dependent lifecycles execute in partial order
+
+| Field | Rule |
+| --- | --- |
+| Trigger | An object depends on a runtime, transport, store, worker, thread, process, or handle; or a change adds startup, shutdown, restart, or hot replacement. |
+| Required action | Write the dependency DAG. If `A` depends on `B`, initialization satisfies `B → A` and destruction satisfies `A → B`. Shutdown establishes an admission barrier, propagates wake or cancel, converges in-flight work, joins background tasks, releases in reverse dependency order, and publishes completion. Lock hierarchy and callback direction remain compatible with dependency direction. |
+| Acceptance criteria | Shutdown admits no new operations; all dependents are quiescent before a dependency releases; after close returns no task, callback, or handle inside that lifecycle boundary uses resources it owns; repeated close reaches the same terminal state. |
+| Expected benefit | Deterministic lifecycle without use-after-close, shutdown deadlock, orphaned background work, or premature dependency release. |
+| Side-effect boundary | Order only real dependencies; independent branches may close concurrently. Do not introduce a global lock or coordinator merely to unify order; wake or cancel remains scoped to resources owned by this shutdown. |
+| Evidence | Real lifecycle tests, close racing with in-flight work, timeout tests, and completion-barrier tests showing no activity after close returns. |
+
+### R3: Stop commands, in-progress state, and completion proof have explicit authorities
+
+| Field | Rule |
+| --- | --- |
+| Trigger | Multiple objects read and write one boolean or event; stop request and release are asynchronous; close is retryable; or several layers can publish closed. |
+| Required action | Assign every state a scope, sole writer, and meaning. Separate the stop command, `Closing` progress, and `Closed` completion proof. Prefer one monotonic state machine within one scope; signals and completion in different scopes remain owned by their respective modules. Name a signal-only API `request_shutdown()` or an equivalent explicit contract. |
+| Acceptance criteria | Another participant's stop does not suppress this module's cleanup; only the owner publishes module completion; state advances only toward a terminal state; failure or retry neither reopens admission nor permanently skips unfinished work. |
+| Expected benefit | No ambiguity in shared booleans, retryable and awaitable shutdown, better observability, and no premature return or resource leak. |
+| Side-effect boundary | Do not add multiple sources of truth for one fact, introduce unnecessary states for a simple synchronous object, spread boolean combinations when a finite enum suffices, or keep old state aliases as compatibility branches. |
+| Evidence | State-transition tests, tests where participants issue stop in different orders, and retry tests after partial construction or cleanup failure. |
+
+### R4: Child lifecycle contracts compose directly in parents
+
+| Field | Rule |
+| --- | --- |
+| Trigger | A parent wraps a child, an adapter or backend is added, one close invokes several children, or errors cross layers. |
+| Required action | Define child lifecycle preconditions, success postconditions, and failure postconditions. Pair create / release, register / unregister, spawn / join, and subscribe / cancel. Parents invoke child contracts and order them without copying cleanup. Independent cleanup continues after partial failure, preserving the primary error and teardown errors. |
+| Acceptance criteria | A parent decides the next step without inspecting child internals; child close provides the conditions required for reverse-order parent release; replacing a conforming implementation requires no parent change; failure reports which postconditions remain unsatisfied. |
+| Expected benefit | Local correctness composes into system correctness, backends remain replaceable, failure causality survives, and modules evolve independently. |
+| Side-effect boundary | Do not build a generic framework for hypothetical future implementations. Abstract only stable common semantics, keep specialized fast paths internal, and do not flatten all errors into an information-free result. |
+| Evidence | Contract tests, consistency tests with an alternate implementation or test double, and tests proving remaining cleanup runs after one child close fails. |
+
+### R5: Fix at the invariant authority and limit the change radius
+
+| Field | Rule |
+| --- | --- |
+| Trigger | A proposed fix adds an outer conditional, clears internal fields directly, introduces a global flag, compatibility branch, or configuration entry to bypass an internal responsibility or lifecycle defect. |
+| Required action | Locate the owner of the violated invariant and fix state, cleanup, or contract there; the composition layer changes only required ordering. State non-goals, remove workarounds superseded by the new contract, and limit regression testing to affected boundaries. |
+| Acceptance criteria | Cross-boundary knowledge decreases or does not grow; no duplicate entrypoint, parallel configuration channel, or second lifecycle path is added; unrelated public behavior remains unchanged. |
+| Expected benefit | Root-cause correction with a smaller regression radius and no accumulation of compatibility layers and conditional branches. |
+| Side-effect boundary | Do not rewrite unrelated modules or expand migration under “architecture unification.” If a public contract must change, describe migration and impact separately instead of hiding it in an internal fix. |
+| Evidence | A pre-fix counterexample, post-fix invariant tests, affected-caller regression, and review showing that the diff adds no bypass. |
+
+## 4. Review output and merge criteria
+
+A finding cites the rule and includes the trigger, violated acceptance criterion, impact, and side-effect boundary:
+
+```text
+[R3][blocking] A shared stop signal is treated as proof that this module completed cleanup.
+
+Trigger: another participant sets the shared state first.
+Violated criterion: cleanup owned by this module is skipped, so close is no longer a completion barrier.
+Required action: separate shared stop intent from module-local completion, and let this module publish completion.
+Side-effect boundary: do not add a second public close API or let the outer layer release internal resources directly.
+```
+
+| Label | Merge criterion |
+| --- | --- |
+| `[blocking]` | A plausible trigger violates acceptance criteria; fix before merge. |
+| `[major]` | A critical scenario lacks a constraint or evidence; implement or verify before merge. |
+| `[minor]` | A local maintainability or diagnostic issue does not affect acceptance criteria and may be explicitly deferred. |
+| `[question]` | Trigger, authority, or contract is unclear; classify after clarification. |
+
+- **Request changes**: an unresolved `[blocking]` or `[major]` remains, or the applicable rule's owner, dependency, or acceptance criteria cannot be determined.
+- **Approve**: required actions for all matched rules are complete, evidence satisfies acceptance criteria, expected benefits hold, and side effects stay inside the declared boundary.
+- **Comment**: only clarification questions or suggestions that do not affect acceptance remain.
+
+Documentation PRs must also follow the [Documentation Writing Rules](./Developer%20-%203%20-%20Documentation%20Writing%20Rules.md) and [Technical Documentation Copy Editing](./Developer%20-%205%20-%20Technical%20Documentation%20Copy%20Editing.md).
diff --git a/fluxon_doc_en/dev_doc/Developer - 7 - Event Subscription and Full Snapshot Guidelines.md b/fluxon_doc_en/dev_doc/Developer - 7 - Event Subscription and Full Snapshot Guidelines.md
new file mode 100644
index 0000000..446d867
--- /dev/null
+++ b/fluxon_doc_en/dev_doc/Developer - 7 - Event Subscription and Full Snapshot Guidelines.md
@@ -0,0 +1,84 @@
+# Developer - 7 - Event Subscription and Full Snapshot Guidelines
+
+When a component maintains a local projection from a full query plus incremental events, it must use one of exactly two orderings:
+
+1. General ordering: `subscription/listener ready → fetch full snapshot → install snapshot → replay buffered incremental events → publish ready`.
+2. Revision-coupled ordering: when the source provides an atomic revision contract such as etcd's, use `snapshot@R → watch@R+1`.
+
+The general ordering is mandatory unless the revision-coupled contract is available. Every `listen` that is established and confirmed ready must be followed by one full synchronization. This requirement applies both at initial startup and on every recovery path that establishes a new `listen`.
+
+Calling `listen()` or spawning a background task does not prove that the subscription is ready. A `listen` is established only after the source has acknowledged the subscription or the caller has obtained a usable stream handle or cursor.
+
+## 1. Scope
+
+This rule applies to local projections derived from remote authoritative state, including:
+
+- Cluster membership and service discovery.
+- Route tables, configuration mirrors, and resource indexes.
+- Controller- or informer-style local caches.
+- Local caches whose watch streams may need to be rebuilt.
+
+The local projection owner owns snapshot installation, event application, and its ready state. The source remains the sole source of truth for business state.
+
+## 2. General ordering: full synchronization after listen readiness
+
+```mermaid
+sequenceDiagram
+ participant projection as local-projection-owner
+ participant source as authoritative-source
+
+ projection->>source: subscribe / listen
+ source-->>projection: subscription ready (stream / cursor)
+ projection->>source: fetch full snapshot
+ source-->>projection: snapshot (optional revision R)
+ projection->>projection: install snapshot
+ loop buffered and live events
+ source-->>projection: incremental event
+ projection->>projection: apply by revision or idempotent version check
+ end
+ projection->>projection: publish ready
+```
+
+| Phase | Required action | Acceptance criterion |
+| --- | --- | --- |
+| Subscribe | Wait until the source confirms that the subscription exists. A fire-and-forget `spawn()` is not readiness proof. | Every state change after subscription readiness can enter this subscription stream. |
+| Snapshot | Fetch the complete state once after the subscription is ready. | The snapshot covers the complete object set at the source-defined consistency point. |
+| Install | Let the local projection owner atomically replace the baseline, or perform replacement through its sole writer. | Incremental processing cannot race with snapshot installation as unordered writers to the same projection. |
+| Replay | Apply events buffered since subscription readiness. With revisions, accept only events after the snapshot revision. Without revisions, use generation or version checks, or make application idempotent. | Duplicate or stale events cannot roll back newer state from the snapshot. |
+| Ready | Publish ready only after the snapshot is installed and the subscription backlog reaches a defined convergence point. | A ready reader cannot observe a partially installed snapshot. |
+
+## 3. Failure and resynchronization
+
+- **Subscription failure**: do not start the full fetch or publish ready. A retry starts by establishing a new subscription.
+- **Snapshot failure**: the synchronization attempt is invalid. Close or discard the current subscription and retry the complete sequence so buffering cannot grow without a bound.
+- **Invalid current event stream**: lag, disconnect, or a source-epoch change invalidates the old stream's completeness proof. Revoke ready and discard the old stream. Under the general ordering, perform a full synchronization after the new `listen` becomes ready. Under the revision-coupled ordering, repeat `snapshot@R → watch@R+1`.
+- **Shutdown**: stop event admission, cancel or wake and join the event task, then release the stream and projection dependencies. No task may write the projection after successful close returns.
+
+A fixed `sleep` may bound a timeout or aid debugging. It cannot prove subscription readiness, snapshot installation, or backlog convergence.
+
+## 4. Second ordering: snapshot@R → watch@R+1
+
+If the source explicitly provides an atomic revision contract, the following sequence is valid:
+
+`snapshot at revision R → watch from revision R + 1`
+
+This ordering requires all of the following:
+
+- The snapshot returns a usable revision or cursor.
+- The watch API guarantees delivery beginning with the first event after the requested revision.
+- The source uses one ordered revision space for the snapshot and event log.
+- Tests cover a concurrent update at the snapshot/watch boundary.
+
+An ordinary `fetch_all()` followed by `listen()` without a starting revision does not satisfy this ordering's prerequisites.
+
+## 5. Review and verification checklist
+
+- [ ] Subscription readiness is an awaitable completion barrier; successful `spawn()` is not treated as successful subscription.
+- [ ] Under the general ordering, every established `listen` is followed by one full snapshot fetch, both at initial startup and on every recovery path.
+- [ ] The code implements only the two valid orderings; there is no third branch that attaches a new `listen` directly to an old snapshot.
+- [ ] When `snapshot@R → watch@R+1` is used, the source and tests satisfy the complete revision contract.
+- [ ] Snapshot installation and event application have one writer or a provable revision order.
+- [ ] The final projection is correct when an event occurs before subscription, after subscription but before snapshot, and after snapshot.
+- [ ] Duplicate and stale events cannot roll state backward.
+- [ ] Ready is published only after both the snapshot and backlog converge.
+- [ ] Tests wait for explicit state conditions instead of using a fixed `sleep` as synchronization.
diff --git a/fluxon_py/_api_ext_chan/mpmc.py b/fluxon_py/_api_ext_chan/mpmc.py
index 4ddbc1e..f2e6c15 100644
--- a/fluxon_py/_api_ext_chan/mpmc.py
+++ b/fluxon_py/_api_ext_chan/mpmc.py
@@ -1,7 +1,7 @@
from concurrent.futures import thread
from itertools import count
from math import log
-import os, time, struct, threading, json, random, typing, etcd3, copy, fcntl
+import os, time, struct, threading, json, random, typing, etcd3, copy, fcntl, uuid
from typing import Callable, Dict, Optional, Tuple, Any, List, Set, Union, cast
from abc import ABC, abstractmethod
@@ -95,7 +95,8 @@
MPMC_ATTACH_PAYLOAD_KEEPALIVE_RETRIES = 3
LOCAL_MEMBER_ID_RANGE_SIZE = 32
MPMC_CREATE_LOCK_TTL_SECONDS = 10
-MPMC_CREATE_LOCK_TIMEOUT_SECONDS = 10.0
+MPMC_CREATE_LOCK_TIMEOUT_SECONDS = 30.0
+_MPMC_CREATE_IN_PROGRESS_MESSAGE = "MPSC channel creation is already in progress"
def new_etcd_client(api: KvClient) -> Result[etcd3.Etcd3Client, ApiError]:
@@ -411,9 +412,9 @@ def _new_mpmc_role_key_prefix(mpmc_id: str, role: ChanRole) -> str:
Get the key prefix for storing MPMC channel role.
"""
if role == ChanRole.PRODUCER:
- return f"/mpmc_channels/producer/{mpmc_id}"
+ return f"/mpmc_channels/producer/{mpmc_id}/"
elif role == ChanRole.CONSUMER:
- return f"/mpmc_channels/consumer/{mpmc_id}"
+ return f"/mpmc_channels/consumer/{mpmc_id}/"
else:
raise ValueError(f"Invalid role: {role}")
@@ -449,6 +450,21 @@ def _new_mpmc_ready_channels_prefix(mpmc_id: str) -> str:
return f"/mpmc_channels/ready/{mpmc_id}/" # we need the / at the end for extracting mpsc_id from key
+def _new_mpmc_create_reservations_prefix(mpmc_id: str) -> str:
+ """Get the prefix for in-flight sub-MPSC creation reservations."""
+ return f"/mpmc_channels/create_reservations/{mpmc_id}/"
+
+
+def _new_mpmc_create_reservation_key(
+ mpmc_id: str, role: ChanRole, member_id: int
+) -> str:
+ """Get the member-owned reservation key for sub-MPSC creation."""
+ return (
+ f"{_new_mpmc_create_reservations_prefix(mpmc_id)}"
+ f"{role.value}/{member_id}"
+ )
+
+
def _extract_mpsc_id_from_ready_key(key: bytes, mpmc_id: str) -> str:
"""
Extract MPSC channel ID from a ready channel key.
@@ -758,17 +774,12 @@ def get_meta(self) -> Result[Dict[str, Any], ApiError]:
meta_object = json.loads(meta_data.decode())
return Result.new_ok(meta_object)
- def get_mpsc_channels(self) -> Result[List[str], ApiError]:
- """
- Get all MPSC channel IDs in this MPMC channel.
-
- Returns:
- Result[List[str]]: List of MPSC channel IDs
- """
+ def _read_mpsc_channels_snapshot(self) -> Tuple[List[str], Optional[bytes]]:
+ """Read and validate the published sub-MPSC list and its raw value."""
channels_key = _new_mpmc_mpsc_channels_key(self.mpmc_id)
channels_data, _ = self.etcd_client.get(channels_key)
if channels_data is None:
- return Result.new_ok([])
+ return [], None
raw = json.loads(channels_data.decode())
if not isinstance(raw, list):
raise ValueError(f"invalid mpsc_channels value for mpmc_id={self.mpmc_id}: {raw!r}")
@@ -784,6 +795,11 @@ def get_mpsc_channels(self) -> Result[List[str], ApiError]:
raise ValueError(f"invalid mpsc_id element for mpmc_id={self.mpmc_id}: {item!r}")
channels.append(item)
+ return channels, channels_data
+
+ def get_mpsc_channels(self) -> Result[List[str], ApiError]:
+ """Get all published MPSC channel IDs in this MPMC channel."""
+ channels, _ = self._read_mpsc_channels_snapshot()
return Result.new_ok(channels)
def get_remote_ready_channels(self) -> List[str]:
@@ -840,6 +856,7 @@ def try_claim_ready_channel(self, mpsc_id: str) -> Result[bool, ApiError]:
raise ValueError(f"mpmc_member_id is None for mpmc_id={self.mpmc_id}")
ready_key = _new_mpmc_ready_channel_key(self.mpmc_id, mpsc_id)
+ expected_owner = str(self.mpmc_member_id).encode()
try:
success, _ = self.etcd_client.transaction(
compare=[
@@ -848,7 +865,7 @@ def try_claim_ready_channel(self, mpsc_id: str) -> Result[bool, ApiError]:
success=[
self.etcd_client.transactions.put(
ready_key,
- str(self.mpmc_member_id).encode(),
+ expected_owner,
self.mpmc_member_lease,
)
],
@@ -856,10 +873,38 @@ def try_claim_ready_channel(self, mpsc_id: str) -> Result[bool, ApiError]:
)
return Result.new_ok(bool(success))
except Exception as e:
+ reconciliation_errors: List[str] = []
+ for attempt in range(1, 4):
+ try:
+ ready_owner, _ = self.etcd_client.get(ready_key)
+ if ready_owner == expected_owner:
+ logging.warning(
+ "Ready claim response was lost but authority confirms ownership: "
+ "mpmc_id=%s mpsc_id=%s member_id=%s",
+ self.mpmc_id,
+ mpsc_id,
+ self.mpmc_member_id,
+ )
+ return Result.new_ok(True)
+ if ready_owner is not None:
+ reconciliation_errors.append(
+ f"attempt={attempt}: observed_other_owner={ready_owner!r}"
+ )
+ except Exception as read_error:
+ reconciliation_errors.append(
+ f"attempt={attempt}: {read_error}"
+ )
+ if attempt < 3:
+ time.sleep(0.05 * attempt)
+
+ # An absent read cannot fence the original create-if-absent txn.
+ # Revoke this member lease so a delayed put cannot become live.
+ self._fail_close_member(reason="ambiguous_ready_claim")
return Result.new_error(
ChanBindError(
f"Failed to claim ready key for mpmc_id={self.mpmc_id}, "
- f"mpsc_id={mpsc_id}: {e}"
+ f"mpsc_id={mpsc_id}: {e}; reconciliation_errors="
+ f"{reconciliation_errors}"
)
)
@@ -869,7 +914,24 @@ def _best_effort_delete_ready_channel(self, mpsc_id: str, *, reason: str) -> Non
ready_key = _new_mpmc_ready_channel_key(self.mpmc_id, mpsc_id)
try:
- self.etcd_client.delete(ready_key)
+ expected_owner = str(self.mpmc_member_id).encode()
+ success, _ = self.etcd_client.transaction(
+ compare=[
+ self.etcd_client.transactions.value(ready_key)
+ == expected_owner
+ ],
+ success=[self.etcd_client.transactions.delete(ready_key)],
+ failure=[],
+ )
+ if not success:
+ logging.debug(
+ "Ready key cleanup skipped after %s because ownership changed: "
+ "mpmc_id=%s mpsc_id=%s member_id=%s",
+ reason,
+ self.mpmc_id,
+ mpsc_id,
+ self.mpmc_member_id,
+ )
except Exception as e:
logging.warning(
"Failed to delete ready key after %s for mpmc_id=%s mpsc_id=%s: %s",
@@ -879,6 +941,45 @@ def _best_effort_delete_ready_channel(self, mpsc_id: str, *, reason: str) -> Non
e,
)
+ def _bind_claimed_existing_unready_consumer(
+ self,
+ api: KvClient,
+ chan_config: Dict[str, int],
+ mpsc_id: str,
+ ) -> Result[Union[MPSCChanConsumer, MPSCChanProducer], ApiError]:
+ """Bind a consumer after this member has atomically claimed its ready key."""
+ try:
+ mpsc_consumer = MPSCChanConsumer(
+ api,
+ mpsc_id,
+ chan_config,
+ self.etcd_client,
+ self.mpmc_member_lease,
+ self.mpmc_global_lease,
+ override_payload_lease_id=self.payload_lease_id,
+ parent_mpmc_id_opt=self.mpmc_id,
+ parent_mpmc_member_id_opt=self.mpmc_member_id,
+ )
+ except Exception as e:
+ self._best_effort_delete_ready_channel(
+ mpsc_id,
+ reason="existing_unready_bind_failure",
+ )
+ return Result.new_error(
+ ChanBindError(
+ f"Failed to bind claimed existing MPSC consumer for "
+ f"mpmc_id={self.mpmc_id}, mpsc_id={mpsc_id}: {e}"
+ )
+ )
+
+ mpsc_consumer._mpmc_ready_claimed = True
+ logging.debug(
+ "Bound claimed existing unready MPSC consumer for mpmc_id=%s, mpsc_id=%s",
+ self.mpmc_id,
+ mpsc_id,
+ )
+ return Result.new_ok(mpsc_consumer)
+
def _try_bind_existing_unready_consumer(
self,
api: KvClient,
@@ -906,40 +1007,219 @@ def _try_bind_existing_unready_consumer(
)
continue
+ return self._bind_claimed_existing_unready_consumer(
+ api,
+ chan_config,
+ mpsc_id,
+ )
+
+ return None
+
+ def _get_create_reservation_count(self) -> int:
+ """Count live member-leased sub-MPSC creation reservations."""
+ prefix = _new_mpmc_create_reservations_prefix(self.mpmc_id)
+ return sum(1 for _ in self.etcd_client.get_prefix(prefix))
+
+ def _best_effort_delete_create_reservation(
+ self,
+ reservation_key: str,
+ reservation_token: bytes,
+ *,
+ reason: str,
+ ) -> Tuple[bool, bool]:
+ """Fence this create attempt with a linearized reservation CAS.
+
+ Returns ``(linearized, deleted_owned_token)``. A linearized compare is
+ enough to fence a pending publish, while an ambiguous reservation-create
+ RPC additionally requires ``deleted_owned_token=True`` or member-lease
+ revocation because a late create could otherwise appear after an absent
+ compare.
+ """
+ errors: List[str] = []
+ for attempt in range(1, 4):
try:
- mpsc_consumer = MPSCChanConsumer(
- api,
- mpsc_id,
- chan_config,
- self.etcd_client,
- self.mpmc_member_lease,
- self.mpmc_global_lease,
- override_payload_lease_id=self.payload_lease_id,
- parent_mpmc_id_opt=self.mpmc_id,
- parent_mpmc_member_id_opt=self.mpmc_member_id,
- )
- except Exception as e:
- self._best_effort_delete_ready_channel(
- mpsc_id,
- reason="existing_unready_bind_failure",
+ success, _ = self.etcd_client.transaction(
+ compare=[
+ self.etcd_client.transactions.value(reservation_key)
+ == reservation_token
+ ],
+ success=[self.etcd_client.transactions.delete(reservation_key)],
+ failure=[],
)
- return Result.new_error(
- ChanBindError(
- f"Failed to bind claimed existing MPSC consumer for "
- f"mpmc_id={self.mpmc_id}, mpsc_id={mpsc_id}: {e}"
+ if not success:
+ logging.debug(
+ "Create reservation cleanup skipped after %s because ownership changed: "
+ "mpmc_id=%s key=%s",
+ reason,
+ self.mpmc_id,
+ reservation_key,
)
+ return True, bool(success)
+ except Exception as e:
+ errors.append(f"attempt={attempt}: {e}")
+ if attempt < 3:
+ time.sleep(0.05 * attempt)
+
+ # A live keepalive would otherwise preserve the stale reservation
+ # indefinitely. Stop this member so its lease can be revoked or expire.
+ logging.error(
+ "Failed to delete owned create reservation after %s; closing MPMC member: "
+ "mpmc_id=%s key=%s errors=%s",
+ reason,
+ self.mpmc_id,
+ reservation_key,
+ errors,
+ )
+ self._fail_close_member(
+ reason=f"reservation_cleanup_failure:{reason}",
+ )
+ return False, False
+
+ def _fail_close_member(self, *, reason: str) -> None:
+ """Stop keepalive and revoke this member lease after an ambiguous create."""
+ self.shutdown_ctl.closed = True
+ self._lm_mpmc_member = None
+ lease_id = int(self.mpmc_member_lease.id)
+ try:
+ self.etcd_client.revoke_lease(lease_id)
+ except Exception as e:
+ if "requested lease not found" not in str(e).lower():
+ logging.error(
+ "Failed to revoke MPMC member lease after %s: "
+ "mpmc_id=%s member_id=%s lease_id=%s error=%s",
+ reason,
+ self.mpmc_id,
+ self.mpmc_member_id,
+ lease_id,
+ e,
)
- mpsc_consumer._mpmc_ready_claimed = True
- logging.debug(
- "Bound claimed existing unready MPSC consumer for mpmc_id=%s, mpsc_id=%s",
- self.mpmc_id,
- mpsc_id,
+ @staticmethod
+ def _release_failed_new_mpsc(
+ mpsc_object: Union[MPSCChanConsumer, MPSCChanProducer],
+ *,
+ reason: str,
+ ) -> None:
+ """Release a process-local MPSC handle after publication failed."""
+ try:
+ mpsc_object.release_local_handle().unwrap()
+ except Exception as e:
+ logging.warning(
+ "Failed to release new MPSC local handle after %s: %s",
+ reason,
+ e,
)
- return Result.new_ok(mpsc_consumer)
+ def _reconcile_new_mpsc_publish(
+ self,
+ mpsc_id: str,
+ chan_role: ChanRole,
+ ) -> Optional[bool]:
+ """Resolve an ambiguous publish RPC using linearizable authority reads."""
+ successful_absent_reads = 0
+ for attempt in range(1, 6):
+ try:
+ current_mpscs, _ = self._read_mpsc_channels_snapshot()
+ if mpsc_id not in current_mpscs:
+ successful_absent_reads += 1
+ elif chan_role == ChanRole.PRODUCER:
+ return True
+ else:
+ ready_key = _new_mpmc_ready_channel_key(self.mpmc_id, mpsc_id)
+ ready_owner, _ = self.etcd_client.get(ready_key)
+ expected_owner = str(self.mpmc_member_id).encode()
+ if ready_owner == expected_owner:
+ return True
+ logging.error(
+ "Published MPSC list contains %s but ready ownership is inconsistent: "
+ "mpmc_id=%s expected=%r actual=%r",
+ mpsc_id,
+ self.mpmc_id,
+ expected_owner,
+ ready_owner,
+ )
+ return None
+ except Exception as e:
+ logging.warning(
+ "Publish reconciliation attempt %s/5 failed for mpmc_id=%s "
+ "mpsc_id=%s: %s",
+ attempt,
+ self.mpmc_id,
+ mpsc_id,
+ e,
+ )
+ if attempt < 5:
+ time.sleep(0.1)
+
+ if successful_absent_reads > 0:
+ return False
return None
+ def _abort_unpublished_new_mpsc(
+ self,
+ mpsc_object: Union[MPSCChanConsumer, MPSCChanProducer],
+ *,
+ reason: str,
+ ) -> None:
+ """Release a failed create and delete its unpublished distributed metadata."""
+ mpsc_id = mpsc_object.chan_id
+ self._release_failed_new_mpsc(mpsc_object, reason=reason)
+
+ meta_key = _new_etcd_meta_key(mpsc_id)
+ abort_key = f"/channels/aborted/{mpsc_id}"
+ channel_prefix = f"/channels/{mpsc_id}/"
+ allocator_prefix = f"dist_id_allocator/channels/{mpsc_id}/"
+ cluster_lease_key = f"cluster_lease/id_allocator/channels/{mpsc_id}"
+ cleanup_errors: List[str] = []
+ for attempt in range(1, 4):
+ try:
+ meta_data, _ = self.etcd_client.get(meta_key)
+ global_long_lease_id: Optional[int] = None
+ if meta_data is not None:
+ try:
+ meta_obj = json.loads(meta_data.decode())
+ except Exception as meta_error:
+ logging.warning(
+ "Failed to decode unpublished MPSC meta before cleanup: "
+ "mpmc_id=%s mpsc_id=%s error=%s",
+ self.mpmc_id,
+ mpsc_id,
+ meta_error,
+ )
+ else:
+ lease_id_value = meta_obj.get("global_long_lease_id")
+ if isinstance(lease_id_value, int) and lease_id_value > 0:
+ global_long_lease_id = lease_id_value
+
+ # Fence every Rust-side create/bind write before deleting state.
+ # Channel ids are monotonic and never reused, so this marker is
+ # intentionally permanent until a full `/channels` reset.
+ self.etcd_client.put(abort_key, b"1")
+ self.etcd_client.delete_prefix(channel_prefix)
+ self.etcd_client.delete_prefix(allocator_prefix)
+ self.etcd_client.delete(meta_key)
+ self.etcd_client.delete(cluster_lease_key)
+ if global_long_lease_id is not None:
+ try:
+ self.etcd_client.revoke_lease(global_long_lease_id)
+ except Exception as revoke_error:
+ if "requested lease not found" not in str(revoke_error).lower():
+ raise
+ return
+ except Exception as e:
+ cleanup_errors.append(f"attempt={attempt}: {e}")
+ if attempt < 3:
+ time.sleep(0.05 * attempt)
+
+ logging.error(
+ "Failed to clean unpublished MPSC distributed metadata: mpmc_id=%s "
+ "mpsc_id=%s reason=%s errors=%s",
+ self.mpmc_id,
+ mpsc_id,
+ reason,
+ cleanup_errors,
+ )
+
def _ensure_member_lease_alive(self) -> Result[OkNone, ApiError]:
lease_id = int(self.mpmc_member_lease.id)
endpoint = self._etcd_endpoints[0] if self._etcd_endpoints else None
@@ -1127,6 +1407,89 @@ def try_existing_channels(
return Result.new_ok(mpsc_object)
create_error = create_result.unwrap_error()
+ if (
+ isinstance(create_error, ChanCreateError)
+ and create_error.message == _MPMC_CREATE_IN_PROGRESS_MESSAGE
+ ):
+ # A reservation replaces the old behavior where contenders waited
+ # behind a long-held create lock. Wait outside that lock for the
+ # publisher, or retry reservation if the owner failed and cleaned up.
+ wait_deadline = time.monotonic() + MPMC_CREATE_LOCK_TIMEOUT_SECONDS
+ while time.monotonic() < wait_deadline:
+ if self.shutdown_ctl.closed:
+ return Result.new_error(
+ ChannelClosedError(
+ message="MPMC channel is closed.",
+ channel_id=self.mpmc_id,
+ )
+ )
+
+ self._refresh_local_ready_state()
+ ready_channels = self.get_ready_channels()
+ unready_channels = self.unready_channels
+ existing_result = try_existing_channels(
+ ready_channels,
+ unready_channels,
+ )
+ if existing_result is not None:
+ return existing_result
+
+ try:
+ in_flight_creates = self._get_create_reservation_count()
+ except Exception as e:
+ return Result.new_error(
+ ChanCreateError(
+ f"Failed to inspect in-flight MPSC creation for "
+ f"MPMC channel {self.mpmc_id}: {e}"
+ )
+ )
+
+ should_retry_create = in_flight_creates == 0
+ if producer is None:
+ try:
+ active_consumers = self._get_active_consumer_count()
+ except Exception as e:
+ return Result.new_error(
+ ChanCreateError(
+ f"Failed to count active consumers while waiting for "
+ f"MPMC channel {self.mpmc_id}: {e}"
+ )
+ )
+ published_channel_count = len(
+ set(ready_channels).union(unready_channels)
+ )
+ should_retry_create = active_consumers > (
+ published_channel_count + in_flight_creates
+ )
+
+ if should_retry_create:
+ create_result = self.try_create_mpsc_channel(
+ api,
+ chan_config,
+ chan_role,
+ )
+ if create_result.is_ok():
+ mpsc_object = create_result.unwrap()
+ if producer is not None:
+ assert isinstance(mpsc_object, MPSCChanProducer)
+ producer._record_mpsc_producer(mpsc_object)
+ return Result.new_ok(mpsc_object)
+
+ create_error = create_result.unwrap_error()
+ if not (
+ isinstance(create_error, ChanCreateError)
+ and create_error.message == _MPMC_CREATE_IN_PROGRESS_MESSAGE
+ ):
+ break
+
+ time.sleep(0.05)
+ else:
+ create_error = ChanCreateError(
+ f"{_MPMC_CREATE_IN_PROGRESS_MESSAGE} after waiting "
+ f"{MPMC_CREATE_LOCK_TIMEOUT_SECONDS:.0f}s for "
+ f"MPMC channel {self.mpmc_id}"
+ )
+
if (
producer is not None
and isinstance(create_error, ChanCreateError)
@@ -1161,23 +1524,30 @@ def try_create_mpsc_channel(
chan_config: Dict[str, int],
chan_role: ChanRole,
) -> Result[Union[MPSCChanConsumer, MPSCChanProducer], ApiError]:
+ """Reserve, construct, and publish a new sub-MPSC.
+
+ The create lock protects only authority reads, reservation accounting,
+ and the final list update. Rust-backed MPSC construction runs outside
+ the lock because it may perform lease probes and other network I/O.
"""
- Try to create a new MPSC channel and add it to this MPMC channel.
- Uses etcd lock to ensure atomic creation.
- Producer: only create if this is the first channel
- Consumer: only create if active consumer count > existing MPSC channels
-
- Args:
- api(KvClient): KV store API (required for creating MPSC objects)
- api(KvClient): KV store API (required for creating MPSC objects)
- chan_config(Dict[str, int]): Channel configuration (required for creating MPSC objects)
- chan_role(ChanRole): Channel role (PRODUCER or CONSUMER)
-
- Returns:
- Result[Union[MPSCChanConsumer, MPSCChanProducer]]: New MPSC object
- """
+ if chan_role not in (ChanRole.PRODUCER, ChanRole.CONSUMER):
+ return Result.new_error(
+ ChanCreateError(f"Invalid channel role: {chan_role}")
+ )
+ if self.mpmc_member_id is None:
+ return Result.new_error(
+ ChanCreateError(
+ f"Cannot create MPSC for mpmc_id={self.mpmc_id}: member id is missing"
+ )
+ )
+
lock_key = f"/mpmc_channels/{self.mpmc_id}/create_lock"
-
+ claimed_existing_mpsc_id: Optional[str] = None
+ reservation_key: Optional[str] = None
+ reservation_token: Optional[bytes] = None
+ reservation_create_confirmed = False
+
+ # Phase 1: re-read authority state and reserve one creation slot.
try:
with EtcdLock(
self._etcd_endpoints,
@@ -1185,153 +1555,280 @@ def try_create_mpsc_channel(
MPMC_CREATE_LOCK_TTL_SECONDS,
MPMC_CREATE_LOCK_TIMEOUT_SECONDS,
):
- # Get current channels
mpsc_result = self.get_mpsc_channels()
if not mpsc_result.is_ok():
error = mpsc_result.unwrap_error()
- if error is not None:
- return Result[Union[MPSCChanConsumer, MPSCChanProducer], ApiError].new_error(error)
- else:
- return Result[Union[MPSCChanConsumer, MPSCChanProducer], ApiError].new_error(ChanCreateError("Failed to get channels"))
-
+ return Result.new_error(
+ error
+ if error is not None
+ else ChanCreateError("Failed to get channels")
+ )
+
current_mpscs = mpsc_result.unwrap()
-
- # Role-specific constraints
+ in_flight_creates = self._get_create_reservation_count()
+
if chan_role == ChanRole.PRODUCER:
- # Producer: only create if this is the first channel
if current_mpscs:
- return Result[Union[MPSCChanConsumer, MPSCChanProducer], ApiError].new_error(ChanCreateError("Producer can only create the first channel"))
- elif chan_role == ChanRole.CONSUMER:
- # Consumer: lock-free snapshots are not authoritative. Re-read
- # under the create lock and claim any existing unready MPSC
- # before deciding to allocate a new one.
+ return Result.new_error(
+ ChanCreateError("Producer can only create the first channel")
+ )
+ if in_flight_creates > 0:
+ return Result.new_error(
+ ChanCreateError(_MPMC_CREATE_IN_PROGRESS_MESSAGE)
+ )
+ else:
+ # The lock-free caller already tried this path. Re-check under
+ # the lock so a concurrently published unready channel wins
+ # over allocating another one, but bind it after releasing the lock.
ready_channel_set = set(self.get_remote_ready_channels())
current_unready_mpscs = [
mpsc_id for mpsc_id in current_mpscs if mpsc_id not in ready_channel_set
]
- if current_unready_mpscs:
- logging.debug(
- "Consumer create-lock recheck found existing unready MPSCs for "
- "mpmc_id=%s: %s",
- self.mpmc_id,
- current_unready_mpscs,
- )
- existing_consumer_res = self._try_bind_existing_unready_consumer(
- api,
- chan_config,
- current_unready_mpscs,
- )
- if existing_consumer_res is not None:
- return existing_consumer_res
-
- # Only create if the lock-protected recheck still found no
- # claimable unready channel and active consumers outnumber
- # the existing sub-MPSC count.
- active_consumers = self._get_active_consumer_count()
- if active_consumers <= len(current_mpscs):
- return Result[Union[MPSCChanConsumer, MPSCChanProducer], ApiError].new_error(ChanCreateError("Not enough active consumers to create new channel"))
- else:
- return Result[Union[MPSCChanConsumer, MPSCChanProducer], ApiError].new_error(ChanCreateError(f"Invalid channel role: {chan_role}"))
-
- # Create MPSC object (let it handle its own ID allocation)
- if chan_role == ChanRole.PRODUCER:
- logging.debug(f"Creating new MPSC producer for MPMC channel {self.mpmc_id}")
- mpsc_producer = MPSCChanProducer(
- api,
- None,
- chan_config,
- self.etcd_client,
- # Tie producer membership keys to per-member lease so restarts do not collide.
- self.mpmc_member_lease,
- # Keep channel meta stable under the shared/global lease.
- self.mpmc_global_lease,
- # IMPORTANT: Always reuse the MPMC-shared kv payload lease for
- # newly created sub MPSC channels so all producers bind payload
- # keys under the SAME lease. Not doing this would cause subtle
- # payload lease "split" across sub-channels even though they
- # belong to the same MPMC. This is not a fallback; it's a
- # required invariant for MPMC semantics.
- override_payload_lease_id=self.payload_lease_id,
- parent_mpmc_id_opt=self.mpmc_id,
- parent_mpmc_member_id_opt=self.mpmc_member_id,
- )
- # Record the created channel
- mpsc_id = mpsc_producer.chan_id
- current_mpscs.append(mpsc_id)
- channels_key = _new_mpmc_mpsc_channels_key(self.mpmc_id)
- self.etcd_client.put(channels_key, json.dumps(current_mpscs).encode(), self.mpmc_global_lease)
- return Result.new_ok(mpsc_producer)
- elif chan_role == ChanRole.CONSUMER:
- logging.debug(f"Creating new MPSC consumer for MPMC channel {self.mpmc_id}")
- mpsc_consumer: Optional[MPSCChanConsumer] = None
- try:
- mpsc_consumer = MPSCChanConsumer(
- api,
- None,
- chan_config,
- self.etcd_client,
- # Tie consumer membership keys to per-member lease so restarts do not collide.
- self.mpmc_member_lease,
- # Keep channel meta stable under the shared/global lease.
- self.mpmc_global_lease,
- # Match producer-side semantics: new sub MPSC must reuse
- # the shared kv payload lease of the parent MPMC.
- override_payload_lease_id=self.payload_lease_id,
- parent_mpmc_id_opt=self.mpmc_id,
- parent_mpmc_member_id_opt=self.mpmc_member_id,
- )
- except Exception as e:
- logging.error(f"Fatal error creating MPSC consumer for MPMC channel {self.mpmc_id}: {e}")
- return Result[
- Union[MPSCChanConsumer, MPSCChanProducer], ApiError
- ].new_error(
- ChanCreateError(
- f"Failed to create MPSC consumer when try_create_mpsc_channel: {e}"
+ for mpsc_id in current_unready_mpscs:
+ claim_res = self.try_claim_ready_channel(mpsc_id)
+ if not claim_res.is_ok():
+ return Result.new_error(claim_res.unwrap_error())
+ if claim_res.unwrap():
+ claimed_existing_mpsc_id = mpsc_id
+ break
+
+ if claimed_existing_mpsc_id is None:
+ active_consumers = self._get_active_consumer_count()
+ if active_consumers <= len(current_mpscs) + in_flight_creates:
+ if in_flight_creates > 0:
+ return Result.new_error(
+ ChanCreateError(_MPMC_CREATE_IN_PROGRESS_MESSAGE)
+ )
+ return Result.new_error(
+ ChanCreateError(
+ "Not enough active consumers to create new channel"
+ )
)
- )
- assert mpsc_consumer is not None
- mpsc_id = mpsc_consumer.chan_id
- channels_key = _new_mpmc_mpsc_channels_key(self.mpmc_id)
- ready_key = _new_mpmc_ready_channel_key(self.mpmc_id, mpsc_id)
- published_mpscs = current_mpscs.copy()
- published_mpscs.append(mpsc_id)
- success, _ = self.etcd_client.transaction(
+
+ if claimed_existing_mpsc_id is None:
+ reservation_key = _new_mpmc_create_reservation_key(
+ self.mpmc_id,
+ chan_role,
+ self.mpmc_member_id,
+ )
+ reservation_token = uuid.uuid4().hex.encode()
+ reserved, _ = self.etcd_client.transaction(
compare=[
- self.etcd_client.transactions.create(ready_key) == 0
+ self.etcd_client.transactions.create(reservation_key) == 0
],
success=[
self.etcd_client.transactions.put(
- channels_key,
- json.dumps(published_mpscs).encode(),
- self.mpmc_global_lease,
- ),
- self.etcd_client.transactions.put(
- ready_key,
- str(self.mpmc_member_id).encode(),
+ reservation_key,
+ reservation_token,
self.mpmc_member_lease,
- ),
+ )
],
failure=[],
)
- if not success:
- try:
- mpsc_consumer.release_local_handle().unwrap()
- except Exception as e:
- logging.debug(f"close leaked MPSC consumer error: {e}")
- return Result[
- Union[MPSCChanConsumer, MPSCChanProducer], ApiError
- ].new_error(
- ChanCreateError(
- f"Failed to publish claimed MPSC consumer {mpsc_id} for "
- f"MPMC channel {self.mpmc_id}"
- )
+ reservation_create_confirmed = True
+ if not reserved:
+ return Result.new_error(
+ ChanCreateError(_MPMC_CREATE_IN_PROGRESS_MESSAGE)
)
- mpsc_consumer._mpmc_ready_claimed = True
- logging.debug(f"Created new MPSC consumer {mpsc_id} for MPMC channel {self.mpmc_id}")
- return Result.new_ok(mpsc_consumer)
-
except Exception as e:
- return Result[Union[MPSCChanConsumer, MPSCChanProducer], ApiError].new_error(ChanCreateError(f"Failed to create MPSC channel: {e}"))
+ if reservation_key is not None and reservation_token is not None:
+ _, deleted_owned_token = self._best_effort_delete_create_reservation(
+ reservation_key,
+ reservation_token,
+ reason="reserve_lock_exit_failure",
+ )
+ if not reservation_create_confirmed and not deleted_owned_token:
+ # An unresolved create RPC may still arrive after a cleanup
+ # compare that observed the key as absent. Revoking the
+ # member lease prevents that late put from becoming live.
+ self._fail_close_member(
+ reason="ambiguous_reservation_create",
+ )
+ if claimed_existing_mpsc_id is not None:
+ self._best_effort_delete_ready_channel(
+ claimed_existing_mpsc_id,
+ reason="claim_lock_exit_failure",
+ )
+ return Result.new_error(
+ ChanCreateError(f"Failed to reserve MPSC channel creation: {e}")
+ )
+
+ if claimed_existing_mpsc_id is not None:
+ return self._bind_claimed_existing_unready_consumer(
+ api,
+ chan_config,
+ claimed_existing_mpsc_id,
+ )
+
+ assert reservation_key is not None
+ assert reservation_token is not None
+
+ # Phase 2: run the expensive Rust-backed constructor without the create lock.
+ try:
+ if chan_role == ChanRole.PRODUCER:
+ logging.debug(
+ "Creating new MPSC producer outside create lock for MPMC channel %s",
+ self.mpmc_id,
+ )
+ mpsc_object: Union[MPSCChanConsumer, MPSCChanProducer] = MPSCChanProducer(
+ api,
+ None,
+ chan_config,
+ self.etcd_client,
+ self.mpmc_member_lease,
+ self.mpmc_global_lease,
+ override_payload_lease_id=self.payload_lease_id,
+ parent_mpmc_id_opt=self.mpmc_id,
+ parent_mpmc_member_id_opt=self.mpmc_member_id,
+ )
+ else:
+ logging.debug(
+ "Creating new MPSC consumer outside create lock for MPMC channel %s",
+ self.mpmc_id,
+ )
+ mpsc_object = MPSCChanConsumer(
+ api,
+ None,
+ chan_config,
+ self.etcd_client,
+ self.mpmc_member_lease,
+ self.mpmc_global_lease,
+ override_payload_lease_id=self.payload_lease_id,
+ parent_mpmc_id_opt=self.mpmc_id,
+ parent_mpmc_member_id_opt=self.mpmc_member_id,
+ )
+ except Exception as e:
+ self._best_effort_delete_create_reservation(
+ reservation_key,
+ reservation_token,
+ reason="constructor_failure",
+ )
+ return Result.new_error(
+ ChanCreateError(
+ f"Failed to construct new MPSC {chan_role.value} for "
+ f"MPMC channel {self.mpmc_id}: {e}"
+ )
+ )
+
+ # Phase 3: publish against the latest list, then delete the reservation
+ # in the same transaction. The raw-list compare also protects against a
+ # stale lock holder overwriting a newer list generation.
+ mpsc_id = mpsc_object.chan_id
+ published = False
+ publish_error: Optional[Exception] = None
+ try:
+ with EtcdLock(
+ self._etcd_endpoints,
+ lock_key,
+ MPMC_CREATE_LOCK_TTL_SECONDS,
+ MPMC_CREATE_LOCK_TIMEOUT_SECONDS,
+ ):
+ current_mpscs, current_channels_value = (
+ self._read_mpsc_channels_snapshot()
+ )
+ channels_key = _new_mpmc_mpsc_channels_key(self.mpmc_id)
+ compares = [
+ self.etcd_client.transactions.value(reservation_key)
+ == reservation_token
+ ]
+ if current_channels_value is None:
+ compares.append(
+ self.etcd_client.transactions.create(channels_key) == 0
+ )
+ else:
+ compares.append(
+ self.etcd_client.transactions.value(channels_key)
+ == current_channels_value
+ )
+
+ success_ops = [
+ self.etcd_client.transactions.put(
+ channels_key,
+ json.dumps(current_mpscs + [mpsc_id]).encode(),
+ self.mpmc_global_lease,
+ )
+ ]
+ if chan_role == ChanRole.CONSUMER:
+ ready_key = _new_mpmc_ready_channel_key(self.mpmc_id, mpsc_id)
+ compares.append(
+ self.etcd_client.transactions.create(ready_key) == 0
+ )
+ success_ops.append(
+ self.etcd_client.transactions.put(
+ ready_key,
+ str(self.mpmc_member_id).encode(),
+ self.mpmc_member_lease,
+ )
+ )
+ success_ops.append(
+ self.etcd_client.transactions.delete(reservation_key)
+ )
+
+ published, _ = self.etcd_client.transaction(
+ compare=compares,
+ success=success_ops,
+ failure=[],
+ )
+ except Exception as e:
+ publish_error = e
+
+ if not published and publish_error is not None:
+ reconciled = self._reconcile_new_mpsc_publish(mpsc_id, chan_role)
+ if reconciled is True:
+ published = True
+
+ if not published:
+ fenced, _ = self._best_effort_delete_create_reservation(
+ reservation_key,
+ reservation_token,
+ reason="publish_failure",
+ )
+ detail = "transaction compare failed" if publish_error is None else str(publish_error)
+ reconciled_after_fence = (
+ self._reconcile_new_mpsc_publish(mpsc_id, chan_role)
+ if fenced
+ else None
+ )
+ if reconciled_after_fence is True:
+ published = True
+ elif fenced and reconciled_after_fence is False:
+ self._abort_unpublished_new_mpsc(
+ mpsc_object,
+ reason="publish_failure",
+ )
+ else:
+ # Without a completed reservation CAS, a delayed publish may
+ # still commit. Preserve distributed metadata and stop this
+ # member instead of risking deletion of a published channel.
+ self._release_failed_new_mpsc(
+ mpsc_object,
+ reason="ambiguous_publish",
+ )
+ self.shutdown_ctl.closed = True
+ self._lm_mpmc_member = None
+ return Result.new_error(
+ ChanCreateError(
+ f"Could not fence and reconcile MPSC {mpsc_id} publication "
+ f"for MPMC channel {self.mpmc_id}: {detail}"
+ )
+ )
+
+ if not published:
+ return Result.new_error(
+ ChanCreateError(
+ f"Failed to publish new MPSC {mpsc_id} for MPMC channel "
+ f"{self.mpmc_id}: {detail}"
+ )
+ )
+
+ if isinstance(mpsc_object, MPSCChanConsumer):
+ mpsc_object._mpmc_ready_claimed = True
+ logging.debug(
+ "Created and published new MPSC %s for MPMC channel %s",
+ mpsc_id,
+ self.mpmc_id,
+ )
+ return Result.new_ok(mpsc_object)
def _get_active_consumer_count(self) -> int:
"""
diff --git a/fluxon_py/config.py b/fluxon_py/config.py
index 5861f64..7f13bf8 100644
--- a/fluxon_py/config.py
+++ b/fluxon_py/config.py
@@ -7,6 +7,7 @@
from typing import Dict, Any, List, Union, Tuple
from abc import abstractmethod
from collections.abc import Mapping
+from decimal import Decimal, InvalidOperation
from enum import Enum
import sys
import yaml
@@ -24,6 +25,86 @@ def debug_print(*args):
_YAML_KEY_TYPES = (str, int, float, bool)
_YAML_SCALAR_TYPES = (str, int, float, bool, type(None))
+_U64_MAX = 2**64 - 1
+_SIZE_BYTE_UNITS = {
+ "": 1,
+ "b": 1,
+ "byte": 1,
+ "bytes": 1,
+ "kb": 1024,
+ "kib": 1024,
+ "mb": 1024**2,
+ "mib": 1024**2,
+ "gb": 1024**3,
+ "gib": 1024**3,
+ "tb": 1024**4,
+ "tib": 1024**4,
+}
+_CAPACITY_ALIGNMENT_BYTES = 16 * 1024 * 1024
+
+
+def _parse_size_bytes(value: Any, path: str) -> int:
+ if isinstance(value, bool):
+ raise ValueError(f"{path} must be bytes or a size string, got bool")
+ if isinstance(value, int):
+ if value < 0:
+ raise ValueError(f"{path} must be >= 0")
+ return value
+ if not isinstance(value, str):
+ raise ValueError(f"{path} must be bytes or a size string, got {type(value).__name__}")
+
+ raw = value
+ match = re.fullmatch(r"\s*([0-9]+(?:\.[0-9]+)?|\.[0-9]+)\s*([A-Za-z]*)\s*", raw)
+ if match is None:
+ raise ValueError(f"{path} size must look like 16777216, 512MB, or 1.5GB")
+ number_text, unit_text = match.groups()
+ unit = unit_text.lower()
+ multiplier = _SIZE_BYTE_UNITS.get(unit)
+ if multiplier is None:
+ raise ValueError(
+ f"{path} size unit must be one of B, KB, MB, GB, TB, KiB, MiB, GiB, TiB"
+ )
+
+ try:
+ byte_value = Decimal(number_text) * Decimal(multiplier)
+ except InvalidOperation as exc:
+ raise ValueError(f"{path} size has invalid number: {raw}") from exc
+ bytes_int = int(byte_value)
+ if bytes_int < 0:
+ raise ValueError(f"{path} must be >= 0")
+ if bytes_int > _U64_MAX:
+ raise ValueError(f"{path} exceeds u64 bytes")
+ return bytes_int
+
+
+def _align_capacity_down(value: int) -> int:
+ return value // _CAPACITY_ALIGNMENT_BYTES * _CAPACITY_ALIGNMENT_BYTES
+
+
+def _normalize_size_bytes_fields(cfg: Dict[str, Any]) -> None:
+ contrib = cfg.get("contribute_to_cluster_pool_size")
+ if isinstance(contrib, dict):
+ if "dram" in contrib and contrib["dram"] is not None:
+ contrib["dram"] = _align_capacity_down(
+ _parse_size_bytes(contrib["dram"], "contribute_to_cluster_pool_size.dram")
+ )
+ vram = contrib.get("vram")
+ if isinstance(vram, dict):
+ for gpu_id, size in list(vram.items()):
+ vram[gpu_id] = _align_capacity_down(
+ _parse_size_bytes(size, f"contribute_to_cluster_pool_size.vram.{gpu_id}")
+ )
+
+ spec = cfg.get("fluxonkv_spec")
+ if isinstance(spec, dict):
+ large_limit_size = spec.get("large_limit_size")
+ if large_limit_size is not None:
+ if not isinstance(large_limit_size, list):
+ raise ValueError("fluxonkv_spec.large_limit_size must be a list in owner mode")
+ spec["large_limit_size"] = [
+ _parse_size_bytes(size, f"fluxonkv_spec.large_limit_size[{idx}]")
+ for idx, size in enumerate(large_limit_size)
+ ]
def _to_plain_yaml_obj(value: Any, path: str) -> Any:
@@ -67,9 +148,9 @@ def _yaml_template():
rdma_device_names: # Explicit RDMA devices for protocol config (['{str}'](optional))
pprof_duration_seconds: # Dump pprof flamegraph after N seconds (int(optional))
contribute_to_cluster_pool_size: # Capacity contributed to cluster pool (dict(optional))
- dram: 1677721600 # - DRAM contribution (int(multiple of 16777216))
+ dram: 1677721600 # - DRAM contribution (size_bytes(multiple of 16777216))
vram: # - VRAM contribution per GPU (dict(dynamic_key))
- '{gpu_id}': 1677721600 # - Capacity for a given GPU id (int(multiple of 16777216))
+ '{gpu_id}': 1677721600 # - Capacity for a given GPU id (size_bytes(multiple of 16777216))
test_spec_config: # Test-only config overrides (dict(optional))
disable_observability: false # Disable observe / OTLP background tasks (bool(optional))
disable_master_replica_cache: false # Disable master replica cache maintenance (bool(optional))
@@ -93,6 +174,8 @@ def _yaml_template():
side_transfer_worker_count: 0 # Owner-side worker count for side-transfer fanout (int(optional))
side_transfer_worker_p2p_port_base: # Optional owner-side worker port base (int(optional))
side_transfer_role: # worker (str(optional))
+ kv_ssd_storage_backend: # native|foyer (str(optional))
+ kv_ssd_uring_mode: # single_buffer|iovec; native backend only (str(optional))
# Notes:
# - Zero-contribution mode is selected when contribute_to_cluster_pool_size is missing,
# or when dram is 0 and all VRAM entries are 0.
@@ -100,16 +183,21 @@ def _yaml_template():
# specific part, only one of [mooncake_spec,fluxonkv_spec] is required
mooncake_spec: # mooncake 特定配置 (dict(optional))
+ local_hostname: # Mooncake transfer/offload endpoint host (str(optional))
local_buffer_size: # 本地缓冲区大小 (int(multiple of 16777216))
metadata_server: # 元数据服务器地址 ('{str}://{str}:{int}/metadata')
master_server_address: # 主服务器地址 ('{str}:{int}')
etcd_addresses: # etcd地址列表, 注意是列表!!! (['{str}:{int}'])
+ enable_ssd_offload: false # Enable Mooncake SSD offload for this store (bool(optional))
+ ssd_offload_path: # Local SSD offload directory path (str(optional))
+ skip_get_size_on_get: false # Skip Mooncake get_size() before get(); intended for benchmark reads (bool(optional))
fluxonkv_spec: # fluxon kv specific config (dict(optional))
etcd_addresses: # Etcd address list ((None|['{str}:{int}']))
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: # Owner-mode KV SSD byte limits aligned with large_file_paths (['{size_bytes}'](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)
@@ -146,6 +234,8 @@ def _normalize_test_spec_config(raw: Any, ctx: str) -> Dict[str, Any]:
"side_transfer_worker_count",
"side_transfer_worker_p2p_port_base",
"side_transfer_role",
+ "kv_ssd_storage_backend",
+ "kv_ssd_uring_mode",
}
unknown = sorted(set(raw.keys()) - allowed_keys)
if unknown:
@@ -172,6 +262,35 @@ def _normalize_test_spec_config(raw: Any, ctx: str) -> Dict[str, Any]:
raise ValueError(f"{ctx}.{key} must be a bool")
out[key] = value
+ kv_ssd_storage_backend = raw.get("kv_ssd_storage_backend")
+ if kv_ssd_storage_backend is not None:
+ if not isinstance(kv_ssd_storage_backend, str):
+ raise ValueError(f"{ctx}.kv_ssd_storage_backend must be a string")
+ allowed_kv_ssd_storage_backends = {"native", "foyer"}
+ if kv_ssd_storage_backend not in allowed_kv_ssd_storage_backends:
+ raise ValueError(
+ f"{ctx}.kv_ssd_storage_backend must be one of {sorted(allowed_kv_ssd_storage_backends)}, got {kv_ssd_storage_backend!r}"
+ )
+ out["kv_ssd_storage_backend"] = kv_ssd_storage_backend
+
+ kv_ssd_uring_mode = raw.get("kv_ssd_uring_mode")
+ if kv_ssd_uring_mode is not None:
+ if not isinstance(kv_ssd_uring_mode, str):
+ raise ValueError(f"{ctx}.kv_ssd_uring_mode must be a string")
+ allowed_kv_ssd_uring_modes = {"single_buffer", "iovec"}
+ if kv_ssd_uring_mode not in allowed_kv_ssd_uring_modes:
+ raise ValueError(
+ f"{ctx}.kv_ssd_uring_mode must be one of {sorted(allowed_kv_ssd_uring_modes)}, got {kv_ssd_uring_mode!r}"
+ )
+ out["kv_ssd_uring_mode"] = kv_ssd_uring_mode
+
+ if out.get("kv_ssd_storage_backend") == "foyer" and out.get(
+ "kv_ssd_uring_mode", "single_buffer"
+ ) != "single_buffer":
+ raise ValueError(
+ f"{ctx}.kv_ssd_uring_mode is only valid with {ctx}.kv_ssd_storage_backend=native"
+ )
+
transport_mode = raw.get("transport_mode")
transport_mode_was_explicit = transport_mode is not None
side_transfer_role_raw = raw.get("side_transfer_role")
@@ -311,7 +430,7 @@ def _is_zero_contribution_fluxonkv_config(cfg: Dict[str, Any]) -> bool:
if not isinstance(contrib, dict):
raise ValueError("contribute_to_cluster_pool_size must be a mapping when provided")
- dram = int(contrib["dram"])
+ dram = _parse_size_bytes(contrib["dram"], "contribute_to_cluster_pool_size.dram")
vram_raw = contrib.get("vram")
# Missing vram is normalized to "no GPU contribution".
if vram_raw is None:
@@ -322,8 +441,8 @@ def _is_zero_contribution_fluxonkv_config(cfg: Dict[str, Any]) -> bool:
vram = vram_raw
vram_is_zero = True
- for _, value in vram.items():
- if int(value) != 0:
+ for gpu_id, value in vram.items():
+ if _parse_size_bytes(value, f"contribute_to_cluster_pool_size.vram.{gpu_id}") != 0:
vram_is_zero = False
break
if dram == 0 and not vram_is_zero:
@@ -365,6 +484,7 @@ def _validate_fluxonkv_contract(cfg: Dict[str, Any]) -> None:
"redis_compat",
"sub_cluster",
"large_file_paths",
+ "large_limit_size",
]
for key in forbidden_spec_keys:
if key in spec:
@@ -376,7 +496,7 @@ def _validate_fluxonkv_contract(cfg: Dict[str, Any]) -> None:
raise ValueError(
"contribute_to_cluster_pool_size is required for owner mode (non-zero contribution)"
)
- if int(contrib["dram"]) == 0:
+ if _parse_size_bytes(contrib["dram"], "contribute_to_cluster_pool_size.dram") == 0:
raise ValueError("owner mode requires non-zero contribute_to_cluster_pool_size.dram")
if "etcd_addresses" not in spec:
@@ -404,6 +524,19 @@ 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"
)
+ if "large_limit_size" in spec and spec["large_limit_size"] is not None:
+ large_limit_size = spec["large_limit_size"]
+ if not isinstance(large_limit_size, list):
+ raise ValueError("fluxonkv_spec.large_limit_size must be a list in owner mode")
+ if len(large_limit_size) != len(large_file_paths):
+ raise ValueError(
+ "fluxonkv_spec.large_limit_size length must match fluxonkv_spec.large_file_paths length"
+ )
+ for idx, limit in enumerate(large_limit_size):
+ limit_bytes = _parse_size_bytes(limit, f"fluxonkv_spec.large_limit_size[{idx}]")
+ if limit_bytes < 512:
+ raise ValueError(f"fluxonkv_spec.large_limit_size[{idx}] must be >= 512")
+
class FluxonKvClientConfig():
"""Configuration class for KV Cache stores that reads from YAML config files."""
@@ -424,6 +557,7 @@ def __init__(self, config_dict: Dict[str, Any]):
plain["test_spec_config"] = _normalize_test_spec_config(
plain.get("test_spec_config"), "test_spec_config"
)
+ _normalize_size_bytes_fields(plain)
# Backend selection contract:
# - Exactly one backend spec must be provided (no fallback inference).
@@ -491,6 +625,15 @@ def mooncake_spec_local_buffer_size(self):
if "mooncake_spec" not in self.config_dict:
return None
return self.config_dict["mooncake_spec"]["local_buffer_size"]
+
+ @property
+ def mooncake_spec_local_hostname(self) -> str:
+ if "mooncake_spec" not in self.config_dict:
+ return self.instance_key
+ raw = self.config_dict["mooncake_spec"].get("local_hostname")
+ if raw is None:
+ return self.instance_key
+ return str(raw)
@property
def mooncake_spec_metadata_server(self):
@@ -509,6 +652,25 @@ def mooncake_spec_etcd_addresses(self):
if "mooncake_spec" not in self.config_dict:
return None
return self.config_dict["mooncake_spec"]["etcd_addresses"]
+
+ @property
+ def mooncake_spec_enable_ssd_offload(self) -> bool:
+ if "mooncake_spec" not in self.config_dict:
+ return False
+ return bool(self.config_dict["mooncake_spec"].get("enable_ssd_offload", False))
+
+ @property
+ def mooncake_spec_ssd_offload_path(self) -> str:
+ if "mooncake_spec" not in self.config_dict:
+ return ""
+ raw = self.config_dict["mooncake_spec"].get("ssd_offload_path", "")
+ return "" if raw is None else str(raw)
+
+ @property
+ def mooncake_spec_skip_get_size_on_get(self) -> bool:
+ if "mooncake_spec" not in self.config_dict:
+ return False
+ return bool(self.config_dict["mooncake_spec"].get("skip_get_size_on_get", False))
@property
def fluxonkv_spec_etcd_addresses(self):
@@ -656,7 +818,7 @@ def ensure_zero_contribution_for_channel(self) -> None:
f"got {type(cfg).__name__}"
)
- dram = int(cfg["dram"])
+ dram = _parse_size_bytes(cfg["dram"], "contribute_to_cluster_pool_size.dram")
vram_raw = cfg.get("vram")
if vram_raw is None:
vram = {}
@@ -671,7 +833,7 @@ def ensure_zero_contribution_for_channel(self) -> None:
"Message-queue semantics require dynamic join/leave; non-zero contribution causes instability."
)
for gpu_id, size in vram.items():
- if int(size) != 0:
+ if _parse_size_bytes(size, f"contribute_to_cluster_pool_size.vram.{gpu_id}") != 0:
raise ValueError(
f"For channel storage, contribute_to_cluster_pool_size must be zero-contribution. Current value: {cfg}. "
f"Non-zero vram entry detected: gpu_id={gpu_id}. "
@@ -750,8 +912,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, bool, size_bytes, None
+ if type_str in ["str", "int", "bool", "size_bytes", "None"]:
return type_str, {}
debug_print("type_str ", type_str, "not matched to any type")
@@ -871,6 +1033,15 @@ def raise_validation_error(msg: str):
raise_validation_error(f"Value must be multiple of {multiple}, got {value}")
else:
return None
+
+ elif type_name == "size_bytes":
+ try:
+ bytes_value = _parse_size_bytes(value, path)
+ except ValueError as exc:
+ if raise_err:
+ raise_validation_error(str(exc))
+ return None
+ return bytes_value
elif type_name == "bool":
if not isinstance(value, bool):
@@ -944,6 +1115,25 @@ def raise_validation_error(msg: str):
return None
format_str = params["format"]
+ if format_str == "{int}":
+ for i, item in enumerate(value):
+ if not isinstance(item, int) or isinstance(item, bool):
+ if raise_err:
+ raise_validation_error(f"Expected int in list at index {i}, got {type(item).__name__}")
+ else:
+ return None
+ return value
+ if format_str == "{size_bytes}":
+ for i, item in enumerate(value):
+ try:
+ _parse_size_bytes(item, f"{path}[{i}]")
+ except ValueError as exc:
+ if raise_err:
+ raise_validation_error(str(exc))
+ else:
+ return None
+ return value
+
regex_pattern = _convert_format_to_regex_pattern(format_str)
for i, item in enumerate(value):
if not isinstance(item, str):
diff --git a/fluxon_py/fluxon_fs/__init__.py b/fluxon_py/fluxon_fs/__init__.py
index 8dea96c..cde459a 100644
--- a/fluxon_py/fluxon_fs/__init__.py
+++ b/fluxon_py/fluxon_fs/__init__.py
@@ -20,6 +20,12 @@
)
from .patcher import FluxonFsPatcher
from .bootstrap import install_patcher_from_master
+from .video import (
+ FluxonFsVideoReadRequest,
+ FluxonFsVideoReadResult,
+ FluxonFsVideoReader,
+ FluxonFsVideoReaderPool,
+)
def publish_export(
@@ -91,6 +97,10 @@ def unpublish_export(
"parse_master_config_from_file",
"parse_master_panel_config_from_file",
"FluxonFsPatcher",
+ "FluxonFsVideoReadRequest",
+ "FluxonFsVideoReadResult",
+ "FluxonFsVideoReader",
+ "FluxonFsVideoReaderPool",
"install_patcher_from_master",
"publish_export",
"unpublish_export",
diff --git a/fluxon_py/fluxon_fs/patcher.py b/fluxon_py/fluxon_fs/patcher.py
index 1575b34..8ec3813 100644
--- a/fluxon_py/fluxon_fs/patcher.py
+++ b/fluxon_py/fluxon_fs/patcher.py
@@ -807,6 +807,36 @@ def mount_remote_dir(
) -> None:
self._agent.mount_remote_dir(local_mount_dir_abs, export_name)
+ def open_video_reader(
+ self,
+ *,
+ export_name: str,
+ relpath: str,
+ height: int,
+ width: int,
+ num_threads: int = 8,
+ ) -> Any:
+ from .video import FluxonFsVideoReader
+
+ return FluxonFsVideoReader._open(
+ agent=self._agent,
+ export_name=export_name,
+ relpath=relpath,
+ height=height,
+ width=width,
+ num_threads=num_threads,
+ request_identity=self._request_identity,
+ )
+
+ def open_video_reader_pool(self, *, max_readers: int = 32) -> Any:
+ from .video import FluxonFsVideoReaderPool
+
+ return FluxonFsVideoReaderPool(
+ agent=self._agent,
+ request_identity=self._request_identity,
+ max_readers=max_readers,
+ )
+
def publish_export(
self,
*,
diff --git a/fluxon_py/fluxon_fs/video.py b/fluxon_py/fluxon_fs/video.py
new file mode 100644
index 0000000..1bbb23d
--- /dev/null
+++ b/fluxon_py/fluxon_fs/video.py
@@ -0,0 +1,532 @@
+from __future__ import annotations
+
+from collections import OrderedDict
+from collections.abc import Sequence
+from dataclasses import dataclass
+from threading import RLock
+from typing import Any, Optional, Tuple
+
+
+class FluxonFsVideoReader:
+ """Video reader backed by FluxonFS range reads."""
+
+ def __init__(self, inner: Any) -> None:
+ self._inner = inner
+ self._closed = False
+
+ @classmethod
+ def _open(
+ cls,
+ *,
+ agent: Any,
+ export_name: str,
+ relpath: str,
+ height: int,
+ width: int,
+ num_threads: int,
+ request_identity: Optional[Tuple[str, str]],
+ ) -> "FluxonFsVideoReader":
+ export_name = _require_non_empty_str(export_name, "export_name")
+ relpath = _require_non_empty_str(relpath, "relpath")
+ height = _require_positive_int(height, "height")
+ width = _require_positive_int(width, "width")
+ num_threads = _require_positive_int(num_threads, "num_threads")
+ inner = agent.open_video_reader(
+ export_name,
+ relpath,
+ height,
+ width,
+ num_threads,
+ request_identity,
+ )
+ return cls(inner)
+
+ def read_frames_numpy(self, indices: Sequence[int]) -> Any:
+ if self._closed:
+ raise RuntimeError("FluxonFsVideoReader is closed")
+ if not isinstance(indices, Sequence):
+ raise TypeError(f"indices must be a sequence of int, got {type(indices)}")
+ frame_indices = []
+ for idx in indices:
+ if type(idx) is not int:
+ raise TypeError(f"indices must contain int values, got {type(idx)}")
+ if idx < 0:
+ raise ValueError("indices must be non-negative")
+ frame_indices.append(int(idx))
+ return self._inner.read_frames_numpy(frame_indices)
+
+ def stats(self) -> dict[str, int]:
+ if self._closed:
+ raise RuntimeError("FluxonFsVideoReader is closed")
+ stats = self._inner.stats()
+ if not isinstance(stats, dict):
+ raise TypeError(f"native video stats must be dict, got {type(stats)}")
+ out: dict[str, int] = {}
+ for key, value in stats.items():
+ if not isinstance(key, str):
+ raise TypeError(f"native video stats keys must be str, got {type(key)}")
+ if type(value) is not int:
+ raise TypeError(f"native video stats values must be int, got {type(value)}")
+ out[key] = int(value)
+ return out
+
+ def close(self) -> None:
+ if self._closed:
+ return
+ self._inner.close()
+ self._closed = True
+
+ def __enter__(self) -> "FluxonFsVideoReader":
+ if self._closed:
+ raise RuntimeError("FluxonFsVideoReader is closed")
+ return self
+
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
+ self.close()
+
+
+@dataclass(frozen=True)
+class FluxonFsVideoReadResult:
+ """Result for one pooled FluxonFS video decode."""
+
+ array: Any
+ reader_stats: dict[str, int]
+ pool_stats: dict[str, int]
+ reader_cache_hit: bool
+ batch_stats: dict[str, int]
+
+
+@dataclass(frozen=True)
+class FluxonFsVideoReadRequest:
+ """Request for one FluxonFS video clip decode."""
+
+ export_name: str
+ relpath: str
+ height: int
+ width: int
+ num_threads: int
+ indices: Sequence[int]
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "export_name", _require_non_empty_str(self.export_name, "export_name"))
+ object.__setattr__(self, "relpath", _require_non_empty_str(self.relpath, "relpath"))
+ object.__setattr__(self, "height", _require_positive_int(self.height, "height"))
+ object.__setattr__(self, "width", _require_positive_int(self.width, "width"))
+ object.__setattr__(self, "num_threads", _require_positive_int(self.num_threads, "num_threads"))
+ object.__setattr__(self, "indices", tuple(_validate_indices(self.indices)))
+
+
+@dataclass(frozen=True)
+class _VideoReaderKey:
+ export_name: str
+ relpath: str
+ height: int
+ width: int
+ num_threads: int
+ request_identity: Optional[Tuple[str, str]]
+
+
+@dataclass
+class _PooledReader:
+ key: _VideoReaderKey
+ reader: FluxonFsVideoReader
+ active: bool = False
+ retired: bool = False
+
+
+class _ReaderLease:
+ def __init__(
+ self,
+ *,
+ pool: "FluxonFsVideoReaderPool",
+ reader_id: int,
+ entry: _PooledReader,
+ cache_hit: bool,
+ ) -> None:
+ self.pool = pool
+ self.reader_id = reader_id
+ self.entry = entry
+ self.cache_hit = cache_hit
+
+ def __enter__(self) -> "_ReaderLease":
+ return self
+
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
+ self.pool._release(self.reader_id, self.entry)
+
+
+class FluxonFsVideoReaderPool:
+ """Process-local LRU pool for FluxonFS video readers."""
+
+ def __init__(
+ self,
+ *,
+ agent: Any,
+ request_identity: Optional[Tuple[str, str]],
+ max_readers: int = 32,
+ ) -> None:
+ self._agent = agent
+ self._request_identity = _validate_request_identity(request_identity)
+ self._max_readers = _require_positive_int(max_readers, "max_readers")
+ self._lock = RLock()
+ self._entries: OrderedDict[int, _PooledReader] = OrderedDict()
+ self._next_reader_id = 1
+ self._closed = False
+ self._open_count = 0
+ self._close_count = 0
+ self._evict_count = 0
+ self._cache_hits = 0
+ self._cache_misses = 0
+
+ def read_frames_numpy(
+ self,
+ *,
+ export_name: str,
+ relpath: str,
+ height: int,
+ width: int,
+ num_threads: int,
+ indices: Sequence[int],
+ ) -> Any:
+ result = self.read_frames_numpy_with_stats(
+ export_name=export_name,
+ relpath=relpath,
+ height=height,
+ width=width,
+ num_threads=num_threads,
+ indices=indices,
+ )
+ return result.array
+
+ def read_frames_numpy_with_stats(
+ self,
+ *,
+ export_name: str,
+ relpath: str,
+ height: int,
+ width: int,
+ num_threads: int,
+ indices: Sequence[int],
+ ) -> FluxonFsVideoReadResult:
+ key = _VideoReaderKey(
+ export_name=_require_non_empty_str(export_name, "export_name"),
+ relpath=_require_non_empty_str(relpath, "relpath"),
+ height=_require_positive_int(height, "height"),
+ width=_require_positive_int(width, "width"),
+ num_threads=_require_positive_int(num_threads, "num_threads"),
+ request_identity=self._request_identity,
+ )
+ frame_indices = _validate_indices(indices)
+
+ with self._acquire(key) as lease:
+ reader = lease.entry.reader
+ before = reader.stats()
+ array = reader.read_frames_numpy(frame_indices)
+ after = reader.stats()
+
+ return FluxonFsVideoReadResult(
+ array=array,
+ reader_stats=_stats_delta(before, after),
+ pool_stats=self.stats(),
+ reader_cache_hit=lease.cache_hit,
+ batch_stats={
+ "read_many_group_size": 1,
+ "read_many_group_frames": len(frame_indices),
+ "read_many_group_index": 0,
+ },
+ )
+
+ def read_many_numpy_with_stats(
+ self,
+ requests: Sequence[FluxonFsVideoReadRequest],
+ ) -> list[FluxonFsVideoReadResult]:
+ read_requests = _validate_read_requests(requests)
+ if not read_requests:
+ return []
+
+ grouped: OrderedDict[_VideoReaderKey, list[tuple[int, FluxonFsVideoReadRequest]]] = OrderedDict()
+ for pos, request in enumerate(read_requests):
+ key = _VideoReaderKey(
+ export_name=request.export_name,
+ relpath=request.relpath,
+ height=request.height,
+ width=request.width,
+ num_threads=request.num_threads,
+ request_identity=self._request_identity,
+ )
+ grouped.setdefault(key, []).append((pos, request))
+
+ output: list[Optional[FluxonFsVideoReadResult]] = [None] * len(read_requests)
+ for key, group in grouped.items():
+ with self._acquire(key) as lease:
+ reader = lease.entry.reader
+ before = reader.stats()
+ counts = [len(request.indices) for _, request in group]
+ combined_indices: list[int] = []
+ for _, request in group:
+ combined_indices.extend(request.indices)
+ combined_array = reader.read_frames_numpy(combined_indices)
+ after = reader.stats()
+
+ _validate_combined_array(combined_array, len(combined_indices))
+ reader_stats_parts = _split_stats_delta(_stats_delta(before, after), counts)
+ pool_stats = self.stats()
+ cursor = 0
+ for group_index, ((pos, _request), reader_stats) in enumerate(zip(group, reader_stats_parts)):
+ count = counts[group_index]
+ array = combined_array[cursor : cursor + count]
+ cursor += count
+ output[pos] = FluxonFsVideoReadResult(
+ array=array,
+ reader_stats=reader_stats,
+ pool_stats=pool_stats,
+ reader_cache_hit=lease.cache_hit,
+ batch_stats={
+ "read_many_group_size": len(group),
+ "read_many_group_frames": len(combined_indices),
+ "read_many_group_index": group_index,
+ },
+ )
+
+ return _unwrap_results(output)
+
+ def stats(self) -> dict[str, int]:
+ with self._lock:
+ active = sum(1 for entry in self._entries.values() if entry.active)
+ return {
+ "max_readers": self._max_readers,
+ "current_readers": len(self._entries),
+ "active_readers": active,
+ "open_count": self._open_count,
+ "close_count": self._close_count,
+ "evict_count": self._evict_count,
+ "reader_cache_hits": self._cache_hits,
+ "reader_cache_misses": self._cache_misses,
+ }
+
+ def close(self) -> None:
+ readers_to_close: list[FluxonFsVideoReader] = []
+ with self._lock:
+ if self._closed:
+ return
+ self._closed = True
+ for reader_id, entry in list(self._entries.items()):
+ entry.retired = True
+ if entry.active:
+ continue
+ readers_to_close.append(entry.reader)
+ self._entries.pop(reader_id, None)
+ self._close_count += 1
+ for reader in readers_to_close:
+ reader.close()
+
+ def __enter__(self) -> "FluxonFsVideoReaderPool":
+ with self._lock:
+ if self._closed:
+ raise RuntimeError("FluxonFsVideoReaderPool is closed")
+ return self
+
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
+ self.close()
+
+ def _acquire(self, key: _VideoReaderKey) -> _ReaderLease:
+ with self._lock:
+ if self._closed:
+ raise RuntimeError("FluxonFsVideoReaderPool is closed")
+ for reader_id, entry in self._entries.items():
+ if entry.key == key and not entry.active and not entry.retired:
+ entry.active = True
+ self._entries.move_to_end(reader_id)
+ self._cache_hits += 1
+ return _ReaderLease(
+ pool=self,
+ reader_id=reader_id,
+ entry=entry,
+ cache_hit=True,
+ )
+ self._cache_misses += 1
+
+ reader = FluxonFsVideoReader._open(
+ agent=self._agent,
+ export_name=key.export_name,
+ relpath=key.relpath,
+ height=key.height,
+ width=key.width,
+ num_threads=key.num_threads,
+ request_identity=key.request_identity,
+ )
+
+ with self._lock:
+ if self._closed:
+ reader.close()
+ raise RuntimeError("FluxonFsVideoReaderPool is closed")
+ reader_id = self._next_reader_id
+ self._next_reader_id += 1
+ entry = _PooledReader(key=key, reader=reader, active=True)
+ self._entries[reader_id] = entry
+ self._open_count += 1
+ readers_to_close = self._evict_idle_locked()
+
+ for old_reader in readers_to_close:
+ old_reader.close()
+ return _ReaderLease(pool=self, reader_id=reader_id, entry=entry, cache_hit=False)
+
+ def _release(self, reader_id: int, entry: _PooledReader) -> None:
+ readers_to_close: list[FluxonFsVideoReader] = []
+ with self._lock:
+ entry.active = False
+ if entry.retired or self._closed:
+ removed = self._entries.pop(reader_id, None)
+ if removed is not None:
+ readers_to_close.append(removed.reader)
+ self._close_count += 1
+ elif reader_id in self._entries:
+ self._entries.move_to_end(reader_id)
+ readers_to_close.extend(self._evict_idle_locked())
+ for reader in readers_to_close:
+ reader.close()
+
+ def _evict_idle_locked(self) -> list[FluxonFsVideoReader]:
+ readers_to_close: list[FluxonFsVideoReader] = []
+ while len(self._entries) > self._max_readers:
+ evicted = False
+ for reader_id, entry in list(self._entries.items()):
+ if entry.active:
+ continue
+ entry.retired = True
+ self._entries.pop(reader_id, None)
+ readers_to_close.append(entry.reader)
+ self._evict_count += 1
+ self._close_count += 1
+ evicted = True
+ break
+ if not evicted:
+ break
+ return readers_to_close
+
+
+def _validate_indices(indices: Sequence[int]) -> list[int]:
+ if not isinstance(indices, Sequence):
+ raise TypeError(f"indices must be a sequence of int, got {type(indices)}")
+ frame_indices = []
+ for idx in indices:
+ if type(idx) is not int:
+ raise TypeError(f"indices must contain int values, got {type(idx)}")
+ if idx < 0:
+ raise ValueError("indices must be non-negative")
+ frame_indices.append(int(idx))
+ return frame_indices
+
+
+def _validate_read_requests(
+ requests: Sequence[FluxonFsVideoReadRequest],
+) -> list[FluxonFsVideoReadRequest]:
+ if not isinstance(requests, Sequence):
+ raise TypeError(f"requests must be a sequence of FluxonFsVideoReadRequest, got {type(requests)}")
+ out: list[FluxonFsVideoReadRequest] = []
+ for request in requests:
+ if not isinstance(request, FluxonFsVideoReadRequest):
+ raise TypeError(
+ "requests must contain FluxonFsVideoReadRequest values, "
+ f"got {type(request)}"
+ )
+ out.append(request)
+ return out
+
+
+def _validate_request_identity(value: Optional[Tuple[str, str]]) -> Optional[Tuple[str, str]]:
+ if value is None:
+ return None
+ if not isinstance(value, tuple) or len(value) != 2:
+ raise TypeError("request_identity must be a (username, password) tuple or None")
+ username, password = value
+ if not isinstance(username, str) or not isinstance(password, str):
+ raise TypeError("request_identity values must be str")
+ return (username, password)
+
+
+def _stats_delta(before: dict[str, int], after: dict[str, int]) -> dict[str, int]:
+ out: dict[str, int] = {}
+ for key, value in after.items():
+ out[key] = int(value) - int(before.get(key, 0))
+ return out
+
+
+def _split_stats_delta(delta: dict[str, int], weights: list[int]) -> list[dict[str, int]]:
+ parts = [dict[str, int]() for _ in weights]
+ if not weights:
+ return parts
+ for key, value in delta.items():
+ for index, part in enumerate(_split_int_by_weights(int(value), weights)):
+ parts[index][key] = part
+ return parts
+
+
+def _split_int_by_weights(value: int, weights: list[int]) -> list[int]:
+ if not weights:
+ return []
+ if value == 0:
+ return [0 for _ in weights]
+
+ sign = -1 if value < 0 else 1
+ abs_value = abs(value)
+ positive_weights = [max(0, int(weight)) for weight in weights]
+ total_weight = sum(positive_weights)
+ if total_weight <= 0:
+ positive_weights = [1 for _ in weights]
+ total_weight = len(weights)
+
+ shares: list[int] = []
+ remainders: list[tuple[int, int]] = []
+ assigned = 0
+ for index, weight in enumerate(positive_weights):
+ raw = abs_value * weight
+ share, remainder = divmod(raw, total_weight)
+ shares.append(share)
+ remainders.append((remainder, index))
+ assigned += share
+
+ remaining = abs_value - assigned
+ for _, index in sorted(remainders, reverse=True)[:remaining]:
+ shares[index] += 1
+ return [sign * share for share in shares]
+
+
+def _validate_combined_array(array: Any, expected_frames: int) -> None:
+ shape = getattr(array, "shape", None)
+ if not isinstance(shape, tuple) or len(shape) < 1:
+ raise TypeError(f"native video batch must expose tuple shape, got {type(shape)}")
+ if int(shape[0]) != expected_frames:
+ raise RuntimeError(
+ "native video batch returned unexpected frame count: "
+ f"expected={expected_frames} actual={shape[0]}"
+ )
+
+
+def _unwrap_results(
+ results: list[Optional[FluxonFsVideoReadResult]],
+) -> list[FluxonFsVideoReadResult]:
+ out: list[FluxonFsVideoReadResult] = []
+ for result in results:
+ if result is None:
+ raise RuntimeError("missing FluxonFS video batch result")
+ out.append(result)
+ return out
+
+
+def _require_non_empty_str(value: Any, name: str) -> str:
+ if not isinstance(value, str):
+ raise TypeError(f"{name} must be str, got {type(value)}")
+ out = value.strip()
+ if not out:
+ raise ValueError(f"{name} must be non-empty")
+ return out
+
+
+def _require_positive_int(value: Any, name: str) -> int:
+ if type(value) is not int:
+ raise TypeError(f"{name} must be int, got {type(value)}")
+ out = int(value)
+ if out <= 0:
+ raise ValueError(f"{name} must be positive")
+ return out
diff --git a/fluxon_py/kvclient/fluxon.py b/fluxon_py/kvclient/fluxon.py
index 1325e3d..4545ddc 100644
--- a/fluxon_py/kvclient/fluxon.py
+++ b/fluxon_py/kvclient/fluxon.py
@@ -283,6 +283,10 @@ def access(self) -> Result[Dict[str, Union[int, float, bool, str, bytes, DLPacke
return Result.new_error(res.unwrap_error())
return Result.new_ok(res.unwrap())
+ def _benchmark_source_kind(self) -> str:
+ """Return the storage source selected for this benchmark GET."""
+ return str(self._inner_holder._benchmark_source_kind())
+
# release() intentionally omitted for now.
@@ -368,11 +372,13 @@ def put(
reject_if_inflight_same_key = (
bool(opts.reject_if_inflight_same_key) if opts is not None else False
)
+ reject_if_exists = bool(opts.reject_if_exists) if opts is not None else False
inner_res = self._client.put(
key,
ptrs,
lease_id=lease_id,
reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exists=reject_if_exists,
)
if not inner_res.is_ok():
err = inner_res.unwrap_error()
@@ -416,11 +422,13 @@ def put_blocking(
reject_if_inflight_same_key = (
bool(opts.reject_if_inflight_same_key) if opts is not None else False
)
+ reject_if_exists = bool(opts.reject_if_exists) if opts is not None else False
inner_res = self._client.put_blocking(
key,
ptrs,
lease_id=lease_id,
reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exists=reject_if_exists,
)
if not inner_res.is_ok():
return Result.new_error(inner_res.unwrap_error())
@@ -469,11 +477,13 @@ 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_exists = bool(opts.reject_if_exists) if opts is not None else False
inner_res = self._client.put_blocking(
key,
ptrs,
lease_id=lease_id,
reject_if_inflight_same_key=reject_if_inflight_same_key,
+ reject_if_exists=reject_if_exists,
)
if not inner_res.is_ok():
return Result.new_error(inner_res.unwrap_error())
diff --git a/fluxon_py/kvclient/kvclient_interface.py b/fluxon_py/kvclient/kvclient_interface.py
index f50db0f..8714519 100644
--- a/fluxon_py/kvclient/kvclient_interface.py
+++ b/fluxon_py/kvclient/kvclient_interface.py
@@ -29,9 +29,11 @@ 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_exists: create the key only when it does not already exist.
"""
lease_id: Optional[int] = None
reject_if_inflight_same_key: bool = False
+ reject_if_exists: bool = False
def support_mooncake(self) -> Tuple[bool, List[str]]:
"""
@@ -41,7 +43,7 @@ def support_mooncake(self) -> Tuple[bool, List[str]]:
(supported: bool, unsupported_fields: list[str])
Notes:
- - Mooncake is write-once; currently does not support lease binding.
+ - Mooncake natively supports reject_if_exists and does not support lease binding.
"""
unsupported: List[str] = []
if self.lease_id is not None:
diff --git a/fluxon_py/kvclient/mooncake.py b/fluxon_py/kvclient/mooncake.py
index 4457cc3..00ce21a 100644
--- a/fluxon_py/kvclient/mooncake.py
+++ b/fluxon_py/kvclient/mooncake.py
@@ -135,20 +135,25 @@ def __init__(self, config: "FluxonKvClientConfig"):
self._rwlock = ReadWriteLock()
self._renew_lock = threading.Lock()
self._closed = False
+ self._skip_get_size_on_get = config.mooncake_spec_skip_get_size_on_get
# config = self._config
device_name = ""
if config.protocol_type == "rdma":
device_name = config.protocol_rdma_device_names
- server_name = config.instance_key
+ server_name = config.mooncake_spec_local_hostname
logging.info(
"=============== Mooncake store setup args ===============\n"
+ f"instance_key: {config.instance_key}\n"
f"server_name: {server_name}\n"
f"metadata_server: {config.mooncake_spec_metadata_server}\n"
f"master_server_address: {config.mooncake_spec_master_server_address}\n"
f"contribute_to_cluster_pool_size: {config.contribute_to_cluster_pool_size}\n"
f"local_buffer_size: {config.mooncake_spec_local_buffer_size}\n"
+ f"enable_ssd_offload: {config.mooncake_spec_enable_ssd_offload}\n"
+ f"ssd_offload_path: {config.mooncake_spec_ssd_offload_path}\n"
+ f"skip_get_size_on_get: {config.mooncake_spec_skip_get_size_on_get}\n"
f"protocol_type: {config.protocol_type}\n"
f"device_name: {device_name}\n"
f"==============================================================\n"
@@ -165,7 +170,7 @@ def __init__(self, config: "FluxonKvClientConfig"):
def setup_store() -> None:
try:
- retcode = self._store.setup(
+ setup_args = [
server_name,
config.mooncake_spec_metadata_server,
config.contribute_to_cluster_pool_size["dram"], # Use DRAM only
@@ -173,7 +178,19 @@ def setup_store() -> None:
config.protocol_type,
device_name,
config.mooncake_spec_master_server_address,
- )
+ ]
+ if (
+ config.mooncake_spec_enable_ssd_offload
+ or config.mooncake_spec_ssd_offload_path
+ ):
+ setup_args.extend(
+ [
+ None,
+ config.mooncake_spec_enable_ssd_offload,
+ config.mooncake_spec_ssd_offload_path,
+ ]
+ )
+ retcode = self._store.setup(*setup_args)
except Exception as e: # pragma: no cover - defensive
retcode = -1
errmsg = f"init_mooncake setup raised exception: {e}"
@@ -416,6 +433,7 @@ def put(
max_calls=1,
period=5,
)
+ reject_if_exists = bool(opts.reject_if_exists) if opts is not None else False
if not self._initialized:
return Result.new_error(
@@ -429,7 +447,7 @@ def put(
def put_operation(key: str, values: Tuple[bytes, ...]):
"""
- Put operations explicitly remove old data before writing new data.
+ Put a value once, removing old data first only when overwrite is enabled.
Args:
key(str): The input key.
@@ -450,29 +468,30 @@ def debug_values():
return values_debug
def try_put(key: str, values: Tuple[bytes, ...]) -> Result[OkNone, ApiError]:
- """Try one force-delete-then-put Mooncake write attempt."""
+ """Try one Mooncake write attempt."""
try:
- with self._rwlock.read_lock():
- remove_retcode = self._store.remove(key, True)
+ if not reject_if_exists:
+ with self._rwlock.read_lock():
+ remove_retcode = self._store.remove(key, True)
- logging.debug(
- f"[put_operation] Force remove retcode: {remove_retcode} for key: {key}"
- )
- if remove_retcode != 0:
- remove_error = try_new_error_from_mooncake(
- remove_retcode,
- f"Remove operation failed for key '{key}'",
- key=key,
+ logging.debug(
+ f"[put_operation] Force remove retcode: {remove_retcode} for key: {key}"
)
- if not isinstance(remove_error, KeyNotFoundError):
- logging.warning(
- "=============== Mooncake store delete-before-put failed ===============\n"
- f"key: {key}\n"
- f"values: {debug_values()}\n"
- f"error: {remove_error}\n"
- "==============================================================\n"
+ if remove_retcode != 0:
+ remove_error = try_new_error_from_mooncake(
+ remove_retcode,
+ f"Remove operation failed for key '{key}'",
+ key=key,
)
- return Result.new_error(remove_error)
+ if not isinstance(remove_error, KeyNotFoundError):
+ logging.warning(
+ "=============== Mooncake store delete-before-put failed ===============\n"
+ f"key: {key}\n"
+ f"values: {debug_values()}\n"
+ f"error: {remove_error}\n"
+ "==============================================================\n"
+ )
+ return Result.new_error(remove_error)
with self._rwlock.read_lock():
if len(values) == 1:
@@ -573,15 +592,28 @@ def try_get(key: str) -> Result[MemHolder, ApiError]:
try:
# https://github.com/kvcache-ai/Mooncake/blob/e475a369fe45d528135b2b318d7e9464e1846222/docs/source/mooncake-store-api/python-binding.md?plain=1#L682
with self._rwlock.read_lock():
- datasize = self._store.get_size(key)
- if datasize < 0:
- logging.warning(f"[get_operation] Get failed with retcode:{datasize}")
+ if self._skip_get_size_on_get:
+ data: Optional[bytes] = self._store.get(key)
+ if data is None:
+ return Result.new_error(
+ KeyNotFoundError(
+ f"Get failed for key '{key}': Mooncake returned no bytes",
+ details={"key": key},
+ key=key,
+ )
+ )
+ mem_holder = SimpleMemHolder(data)
+ return Result.new_ok(mem_holder)
+ else:
+ datasize = self._store.get_size(key)
+ if datasize < 0:
+ logging.warning(f"[get_operation] Get failed with retcode:{datasize}")
- return Result.new_error(
- try_new_error_from_mooncake(
- datasize, f"Get failed for key '{key}'"))
- logging.debug("[get_operation] The key exists.")
- data: Optional[bytes] = self._store.get(key)
+ return Result.new_error(
+ try_new_error_from_mooncake(
+ datasize, f"Get failed for key '{key}'"))
+ logging.debug("[get_operation] The key exists.")
+ data = self._store.get(key)
# mooncake store always return bytes
assert data is not None, "Data should have bytes after exists checking!"
@@ -618,6 +650,47 @@ def get_blocking(self, key: str) -> Result[MemHolder, ApiError]:
return Result.new_error(get_result.unwrap_error())
return get_result.unwrap().wait()
+ def get_buffer_blocking(self, key: str) -> Result[Any, ApiError]:
+ """Return Mooncake's lifetime-bound native host buffer for a key."""
+ if not self._initialized:
+ return Result.new_error(
+ GeneralError(
+ message="Store not initialized when get_buffer_blocking(). Call setup() first."
+ )
+ )
+
+ def try_get_buffer(buffer_key: str) -> Result[Any, ApiError]:
+ try:
+ with self._rwlock.read_lock():
+ buffer_handle = self._store.get_buffer(buffer_key)
+ if buffer_handle is None:
+ return Result.new_error(
+ KeyNotFoundError(
+ f"Get failed for key '{buffer_key}': Mooncake returned no buffer",
+ details={"key": buffer_key},
+ key=buffer_key,
+ )
+ )
+ return Result.new_ok(buffer_handle)
+ except Exception as exc:
+ return Result.new_error(exception_to_error(exc))
+
+ return self.retry_operation(try_get_buffer, key)
+
+ def _benchmark_offload_rpc_read_count(self) -> int:
+ """Return Mooncake's cumulative peer offload-RPC read count."""
+ if not self._initialized:
+ raise RuntimeError(
+ "Store not initialized when reading the benchmark offload counter"
+ )
+ count = self._store.get_offload_rpc_read_count()
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
+ raise TypeError(
+ "Mooncake get_offload_rpc_read_count() must return a non-negative int; "
+ f"got {count!r}"
+ )
+ return int(count)
+
def get_size(self, key: str) -> Result[int, ApiError]:
"""
diff --git a/fluxon_py/tests/fluxon_fs_transfer_tikv_support.py b/fluxon_py/tests/fluxon_fs_transfer_tikv_support.py
index 7e38d04..19f0139 100644
--- a/fluxon_py/tests/fluxon_fs_transfer_tikv_support.py
+++ b/fluxon_py/tests/fluxon_fs_transfer_tikv_support.py
@@ -52,9 +52,9 @@
SHM_TEST_DIR_BASE = Path("/dev/shm/fxfs_t")
SHM_TEST_DIR_CLEANUP_LOCK_PATH = SHM_TEST_DIR_BASE / ".cleanup.lock"
TRANSFER_REPORT_BASE = Path("/tmp/fluxon_fs_transfer_reports")
-ETCD_HARNESS_WORK_ROOT = Path("/mnt/nvme0/fluxon_fs_transfer_etcd")
+ETCD_HARNESS_WORK_ROOT = Path("/tmp/fluxon-example/fluxon_fs_transfer_etcd")
ETCD_HARNESS_LOCK_NAME = ".lock"
-TIKV_HARNESS_WORK_ROOT = Path("/mnt/nvme0/fluxon_fs_transfer_tikv")
+TIKV_HARNESS_WORK_ROOT = Path("/tmp/fluxon-example/fluxon_fs_transfer_tikv")
TIKV_HARNESS_LOCK_NAME = ".lock"
TIKV_HARNESS_PD_LEASE_SECS = 60
TIKV_HARNESS_UNIFIED_READPOOL_MAX_THREADS = 4
diff --git a/fluxon_py/tests/test_api_chan_mpmc/test_create_reservation_contract.py b/fluxon_py/tests/test_api_chan_mpmc/test_create_reservation_contract.py
new file mode 100644
index 0000000..84e1909
--- /dev/null
+++ b/fluxon_py/tests/test_api_chan_mpmc/test_create_reservation_contract.py
@@ -0,0 +1,620 @@
+#!/usr/bin/env python3
+"""Contract tests for MPMC sub-channel creation reservations."""
+
+from __future__ import annotations
+
+import importlib
+import json
+import sys
+import threading
+import types
+import unittest
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional
+from unittest import mock
+
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+# This contract exercises only Python coordination logic. Keep importing that
+# logic independent from the native extension and replace all native entrypoints
+# used by the module before loading it.
+_pyo3_loader = importlib.import_module("fluxon_py.tool.pyo3")
+_pyo3_loader._FLUXON_PYO3_MODULE_LAZY = types.SimpleNamespace(
+ MpscContext=object,
+ LeaseManagerHandle=object,
+ EtcdLock=object,
+)
+
+from fluxon_py._api_ext_chan import mpmc # noqa: E402
+
+
+@dataclass(frozen=True)
+class _Compare:
+ kind: str
+ key: str
+ expected: object
+
+
+@dataclass(frozen=True)
+class _Operation:
+ kind: str
+ key: str
+ value: Optional[bytes] = None
+
+
+class _CompareOperand:
+ def __init__(self, kind: str, key: str) -> None:
+ self.kind = kind
+ self.key = key
+
+ def __eq__(self, expected: object) -> _Compare: # type: ignore[override]
+ return _Compare(self.kind, self.key, expected)
+
+
+class _FakeTransactions:
+ def create(self, key: str) -> _CompareOperand:
+ return _CompareOperand("create", key)
+
+ def value(self, key: str) -> _CompareOperand:
+ return _CompareOperand("value", key)
+
+ def put(self, key: str, value: bytes, _lease: object) -> _Operation:
+ return _Operation("put", key, value)
+
+ def delete(self, key: str) -> _Operation:
+ return _Operation("delete", key)
+
+
+class _FakeMetadata:
+ def __init__(self, key: str) -> None:
+ self.key = key.encode()
+
+
+class _FakeLeaseInfo:
+ TTL = 60
+
+
+class _FakeEtcd:
+ def __init__(self) -> None:
+ self.values: Dict[str, bytes] = {}
+ self.transactions = _FakeTransactions()
+ self.fail_publish = False
+ self.raise_after_publish_commit = False
+ self.defer_publish_until_cleanup = False
+ self.pending_publish_ops: Optional[List[_Operation]] = None
+ self.defer_reservation_create_until_revoke = False
+ self.pending_reservation_ops: Optional[List[_Operation]] = None
+ self.raise_after_ready_claim_commit = False
+ self.publish_attempts = 0
+ self.revoked_lease_ids: List[int] = []
+
+ def get(self, key: str):
+ value = self.values.get(key)
+ return value, _FakeMetadata(key) if value is not None else None
+
+ def get_prefix(self, prefix: str):
+ return [
+ (value, _FakeMetadata(key))
+ for key, value in sorted(self.values.items())
+ if key.startswith(prefix)
+ ]
+
+ def get_lease_info(self, _lease_id: int) -> _FakeLeaseInfo:
+ return _FakeLeaseInfo()
+
+ def delete(self, key: str) -> bool:
+ return self.values.pop(key, None) is not None
+
+ def put(self, key: str, value: bytes) -> None:
+ self.values[key] = value
+
+ def delete_prefix(self, prefix: str) -> int:
+ keys = [key for key in self.values if key.startswith(prefix)]
+ for key in keys:
+ del self.values[key]
+ return len(keys)
+
+ def revoke_lease(self, lease_id: int) -> None:
+ self.revoked_lease_ids.append(lease_id)
+ self.pending_reservation_ops = None
+
+ def transaction(self, *, compare, success, failure):
+ del failure
+ is_publish = any(
+ item.kind == "put" and item.key.endswith("/mpsc_channels")
+ for item in success
+ )
+ if is_publish:
+ self.publish_attempts += 1
+ if self.fail_publish:
+ return False, []
+ if self.defer_publish_until_cleanup:
+ self.pending_publish_ops = list(success)
+ raise RuntimeError("injected delayed publish response loss")
+
+ is_reservation_create = any(
+ item.kind == "put" and "/create_reservations/" in item.key
+ for item in success
+ )
+ if is_reservation_create and self.defer_reservation_create_until_revoke:
+ self.pending_reservation_ops = list(success)
+ raise RuntimeError("injected delayed reservation create response loss")
+
+ is_reservation_cleanup = any(
+ item.kind == "delete" and "/create_reservations/" in item.key
+ for item in success
+ )
+ if is_reservation_cleanup and self.pending_publish_ops is not None:
+ self._apply_operations(self.pending_publish_ops)
+ self.pending_publish_ops = None
+
+ matches = all(self._compare_matches(item) for item in compare)
+ if not matches:
+ return False, []
+ self._apply_operations(success)
+ if is_publish and self.raise_after_publish_commit:
+ raise RuntimeError("injected response loss after publish commit")
+ is_ready_claim = (
+ not is_publish
+ and any(
+ item.kind == "put" and "/mpmc_channels/ready/" in item.key
+ for item in success
+ )
+ )
+ if is_ready_claim and self.raise_after_ready_claim_commit:
+ raise RuntimeError("injected response loss after ready claim commit")
+ return True, []
+
+ def _apply_operations(self, operations: List[_Operation]) -> None:
+ for operation in operations:
+ if operation.kind == "put":
+ assert operation.value is not None
+ self.values[operation.key] = operation.value
+ elif operation.kind == "delete":
+ self.values.pop(operation.key, None)
+ else: # pragma: no cover - catches incomplete fake coverage
+ raise AssertionError(f"unsupported operation: {operation!r}")
+
+ def _compare_matches(self, item: _Compare) -> bool:
+ if item.kind == "create":
+ return (0 if item.key not in self.values else 1) == item.expected
+ if item.kind == "value":
+ return self.values.get(item.key) == item.expected
+ raise AssertionError(f"unsupported comparison: {item!r}")
+
+
+@dataclass(frozen=True)
+class _LockCall:
+ endpoints: List[str]
+ name: str
+ ttl_seconds: int
+ timeout_seconds: float
+
+
+class _FakeLock:
+ def __init__(self, tracker: "_LockTracker") -> None:
+ self.tracker = tracker
+
+ def __enter__(self) -> "_FakeLock":
+ self.tracker.depth += 1
+ self.tracker.max_depth = max(self.tracker.max_depth, self.tracker.depth)
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
+ del exc_type, exc_value, traceback
+ self.tracker.depth -= 1
+ if self.tracker.depth < 0: # pragma: no cover - fake invariant
+ raise AssertionError("negative fake lock depth")
+
+
+class _LockTracker:
+ def __init__(self) -> None:
+ self.calls: List[_LockCall] = []
+ self.depth = 0
+ self.max_depth = 0
+
+ def new_lock(
+ self,
+ endpoints: List[str],
+ name: str,
+ ttl_seconds: int,
+ timeout_seconds: float,
+ ) -> _FakeLock:
+ self.calls.append(
+ _LockCall(
+ list(endpoints),
+ name,
+ ttl_seconds,
+ timeout_seconds,
+ )
+ )
+ return _FakeLock(self)
+
+
+class _ReleaseResult:
+ def unwrap(self) -> None:
+ return None
+
+
+class _FakeMpscState:
+ def __init__(self, lock_tracker: _LockTracker) -> None:
+ self.lock_tracker = lock_tracker
+ self.constructor_lock_depths: List[int] = []
+ self.instances: List[object] = []
+ self.next_channel_id = 1000
+ self.fail_constructor = False
+ self.on_construct: Optional[Callable[[], None]] = None
+
+
+def _new_fake_mpsc_consumer_type(state: _FakeMpscState):
+ class _FakeMpscConsumer:
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ del kwargs
+ state.constructor_lock_depths.append(state.lock_tracker.depth)
+ if state.fail_constructor:
+ raise RuntimeError("injected MPSC constructor failure")
+
+ requested_id = args[1]
+ if requested_id is None:
+ state.next_channel_id += 1
+ requested_id = str(state.next_channel_id)
+ self.chan_id = str(requested_id)
+ self._mpmc_ready_claimed = False
+ self.release_count = 0
+ state.instances.append(self)
+
+ callback = state.on_construct
+ state.on_construct = None
+ if callback is not None:
+ callback()
+
+ def get_chan_id(self) -> str:
+ return self.chan_id
+
+ def release_local_handle(self) -> _ReleaseResult:
+ self.release_count += 1
+ return _ReleaseResult()
+
+ return _FakeMpscConsumer
+
+
+class _FakeLease:
+ def __init__(self, lease_id: int) -> None:
+ self.id = lease_id
+
+
+def _new_channel(etcd: _FakeEtcd, member_id: int) -> mpmc.MPMCChannel:
+ channel = object.__new__(mpmc.MPMCChannel)
+ channel.mpmc_id = "7"
+ channel.etcd_client = etcd
+ channel._etcd_endpoints = ["127.0.0.1:2379"]
+ channel.mpmc_member_id = member_id
+ channel.mpmc_member_lease = _FakeLease(100 + member_id)
+ channel._lm_mpmc_member = None
+ channel.mpmc_global_lease = _FakeLease(1)
+ channel.payload_lease_id = 10
+ channel.shutdown_ctl = types.SimpleNamespace(closed=False)
+ channel.ready_channels = []
+ channel.unready_channels = []
+ channel._ready_channels_lock = threading.Lock()
+ channel.new_ready_channels_callback = None
+ channel.remove_ready_channels_callback = None
+ return channel
+
+
+def _add_active_consumers(etcd: _FakeEtcd, count: int) -> None:
+ prefix = mpmc._new_mpmc_role_key_prefix("7", mpmc.ChanRole.CONSUMER)
+ for member_id in range(1, count + 1):
+ etcd.values[f"{prefix}{member_id}"] = b"active"
+
+
+def _reservation_keys(etcd: _FakeEtcd) -> List[str]:
+ prefix = mpmc._new_mpmc_create_reservations_prefix("7")
+ return sorted(key for key in etcd.values if key.startswith(prefix))
+
+
+class TestCreateReservationContract(unittest.TestCase):
+ def test_ready_claim_response_loss_reconciles_owned_key(self) -> None:
+ etcd = _FakeEtcd()
+ etcd.raise_after_ready_claim_commit = True
+ channel = _new_channel(etcd, member_id=11)
+
+ result = channel.try_claim_ready_channel("91")
+
+ self.assertTrue(result.is_ok())
+ self.assertTrue(result.unwrap())
+ ready_key = mpmc._new_mpmc_ready_channel_key("7", "91")
+ self.assertEqual(etcd.values[ready_key], b"11")
+ self.assertFalse(channel.shutdown_ctl.closed)
+
+ def test_ambiguous_ready_claim_with_other_owner_revokes_member(self) -> None:
+ etcd = _FakeEtcd()
+ channel = _new_channel(etcd, member_id=11)
+ ready_key = mpmc._new_mpmc_ready_channel_key("7", "91")
+ etcd.values[ready_key] = b"22"
+
+ with mock.patch.object(
+ etcd,
+ "transaction",
+ side_effect=RuntimeError("injected ambiguous ready claim"),
+ ):
+ result = channel.try_claim_ready_channel("91")
+
+ self.assertFalse(result.is_ok())
+ _ = result.unwrap_error()
+ self.assertTrue(channel.shutdown_ctl.closed)
+ self.assertEqual(etcd.revoked_lease_ids, [111])
+ self.assertEqual(etcd.values[ready_key], b"22")
+
+ def test_existing_unready_consumer_does_not_construct_create_lock(self) -> None:
+ etcd = _FakeEtcd()
+ channels_key = mpmc._new_mpmc_mpsc_channels_key("7")
+ etcd.values[channels_key] = json.dumps(["91"]).encode()
+ channel = _new_channel(etcd, member_id=11)
+ locks = _LockTracker()
+ mpsc_state = _FakeMpscState(locks)
+ fake_consumer = _new_fake_mpsc_consumer_type(mpsc_state)
+
+ with mock.patch.object(mpmc, "EtcdLock", locks.new_lock), mock.patch.object(
+ mpmc, "MPSCChanConsumer", fake_consumer
+ ):
+ result = channel.get_next_available_channel(object(), {})
+
+ self.assertTrue(result.is_ok())
+ bound = result.unwrap()
+ self.assertEqual(bound.chan_id, "91")
+ self.assertTrue(bound._mpmc_ready_claimed)
+ self.assertEqual(locks.calls, [])
+ self.assertEqual(mpsc_state.constructor_lock_depths, [0])
+
+ def test_reservation_caps_concurrent_create_and_success_cleans_it(self) -> None:
+ etcd = _FakeEtcd()
+ _add_active_consumers(etcd, 1)
+ primary = _new_channel(etcd, member_id=11)
+ contender = _new_channel(etcd, member_id=12)
+ locks = _LockTracker()
+ mpsc_state = _FakeMpscState(locks)
+ fake_consumer = _new_fake_mpsc_consumer_type(mpsc_state)
+ contender_results: List[Any] = []
+
+ def try_contending_create() -> None:
+ contender_results.append(
+ contender.try_create_mpsc_channel(
+ object(), {}, mpmc.ChanRole.CONSUMER
+ )
+ )
+
+ mpsc_state.on_construct = try_contending_create
+ with mock.patch.object(mpmc, "EtcdLock", locks.new_lock), mock.patch.object(
+ mpmc, "MPSCChanConsumer", fake_consumer
+ ):
+ result = primary.try_create_mpsc_channel(
+ object(), {}, mpmc.ChanRole.CONSUMER
+ )
+
+ self.assertTrue(result.is_ok())
+ _ = result.unwrap()
+ self.assertEqual(len(contender_results), 1)
+ self.assertFalse(contender_results[0].is_ok())
+ self.assertEqual(
+ contender_results[0].unwrap_error().message,
+ mpmc._MPMC_CREATE_IN_PROGRESS_MESSAGE,
+ )
+ self.assertEqual(mpsc_state.constructor_lock_depths, [0])
+ self.assertEqual(len(mpsc_state.instances), 1)
+ self.assertEqual(_reservation_keys(etcd), [])
+ channels_value = etcd.values[mpmc._new_mpmc_mpsc_channels_key("7")]
+ self.assertEqual(json.loads(channels_value.decode()), ["1001"])
+ self.assertEqual(locks.depth, 0)
+
+ def test_waiter_retries_when_capacity_opens_with_other_reservations_alive(self) -> None:
+ etcd = _FakeEtcd()
+ channel = _new_channel(etcd, member_id=11)
+ expected_consumer = types.SimpleNamespace(chan_id="1001")
+ first_result = mpmc.Result.new_error(
+ mpmc.ChanCreateError(mpmc._MPMC_CREATE_IN_PROGRESS_MESSAGE)
+ )
+ second_result = mpmc.Result.new_ok(expected_consumer)
+
+ with mock.patch.object(
+ channel,
+ "_ensure_member_lease_alive",
+ return_value=mpmc.Result.new_ok(mpmc.OK_NONE),
+ ), mock.patch.object(
+ channel,
+ "_refresh_local_ready_state",
+ return_value=None,
+ ), mock.patch.object(
+ channel,
+ "_get_create_reservation_count",
+ return_value=1,
+ ), mock.patch.object(
+ channel,
+ "_get_active_consumer_count",
+ return_value=2,
+ ), mock.patch.object(
+ channel,
+ "try_create_mpsc_channel",
+ side_effect=[first_result, second_result],
+ ) as try_create:
+ result = channel.get_next_available_channel(object(), {})
+
+ self.assertTrue(result.is_ok())
+ self.assertIs(result.unwrap(), expected_consumer)
+ self.assertEqual(try_create.call_count, 2)
+
+ def test_constructor_failure_cleans_reservation(self) -> None:
+ etcd = _FakeEtcd()
+ _add_active_consumers(etcd, 1)
+ channel = _new_channel(etcd, member_id=11)
+ locks = _LockTracker()
+ mpsc_state = _FakeMpscState(locks)
+ mpsc_state.fail_constructor = True
+ fake_consumer = _new_fake_mpsc_consumer_type(mpsc_state)
+
+ with mock.patch.object(mpmc, "EtcdLock", locks.new_lock), mock.patch.object(
+ mpmc, "MPSCChanConsumer", fake_consumer
+ ):
+ result = channel.try_create_mpsc_channel(
+ object(), {}, mpmc.ChanRole.CONSUMER
+ )
+
+ self.assertFalse(result.is_ok())
+ error = result.unwrap_error()
+ self.assertIn("injected MPSC constructor failure", error.message)
+ self.assertEqual(mpsc_state.constructor_lock_depths, [0])
+ self.assertEqual(_reservation_keys(etcd), [])
+ self.assertNotIn(mpmc._new_mpmc_mpsc_channels_key("7"), etcd.values)
+
+ def test_ambiguous_reservation_create_revokes_member_lease(self) -> None:
+ etcd = _FakeEtcd()
+ etcd.defer_reservation_create_until_revoke = True
+ _add_active_consumers(etcd, 1)
+ channel = _new_channel(etcd, member_id=11)
+ locks = _LockTracker()
+ mpsc_state = _FakeMpscState(locks)
+ fake_consumer = _new_fake_mpsc_consumer_type(mpsc_state)
+
+ with mock.patch.object(mpmc, "EtcdLock", locks.new_lock), mock.patch.object(
+ mpmc, "MPSCChanConsumer", fake_consumer
+ ):
+ result = channel.try_create_mpsc_channel(
+ object(), {}, mpmc.ChanRole.CONSUMER
+ )
+
+ self.assertFalse(result.is_ok())
+ error = result.unwrap_error()
+ self.assertIn("Failed to reserve MPSC channel creation", error.message)
+ self.assertTrue(channel.shutdown_ctl.closed)
+ self.assertEqual(etcd.revoked_lease_ids, [111])
+ self.assertIsNone(etcd.pending_reservation_ops)
+ self.assertEqual(_reservation_keys(etcd), [])
+ self.assertEqual(mpsc_state.instances, [])
+
+ def test_publish_failure_releases_handle_and_cleans_reservation(self) -> None:
+ etcd = _FakeEtcd()
+ etcd.fail_publish = True
+ _add_active_consumers(etcd, 1)
+ channel = _new_channel(etcd, member_id=11)
+ locks = _LockTracker()
+ mpsc_state = _FakeMpscState(locks)
+ fake_consumer = _new_fake_mpsc_consumer_type(mpsc_state)
+
+ def seed_new_mpsc_metadata() -> None:
+ mpsc_id = mpsc_state.instances[-1].chan_id
+ etcd.values[mpmc._new_etcd_meta_key(mpsc_id)] = json.dumps(
+ {"global_long_lease_id": 501}
+ ).encode()
+ etcd.values[f"/channels/{mpsc_id}/consumer/consumer_1"] = b"member"
+ etcd.values[f"dist_id_allocator/channels/{mpsc_id}/consumers"] = b"1"
+ etcd.values[f"cluster_lease/id_allocator/channels/{mpsc_id}"] = b"501"
+
+ mpsc_state.on_construct = seed_new_mpsc_metadata
+
+ with mock.patch.object(mpmc, "EtcdLock", locks.new_lock), mock.patch.object(
+ mpmc, "MPSCChanConsumer", fake_consumer
+ ):
+ result = channel.try_create_mpsc_channel(
+ object(), {}, mpmc.ChanRole.CONSUMER
+ )
+
+ self.assertFalse(result.is_ok())
+ _ = result.unwrap_error()
+ self.assertEqual(etcd.publish_attempts, 1)
+ self.assertEqual(len(mpsc_state.instances), 1)
+ self.assertEqual(mpsc_state.instances[0].release_count, 1)
+ self.assertEqual(_reservation_keys(etcd), [])
+ self.assertNotIn(mpmc._new_mpmc_mpsc_channels_key("7"), etcd.values)
+ self.assertNotIn(mpmc._new_etcd_meta_key("1001"), etcd.values)
+ self.assertFalse(any(key.startswith("/channels/1001/") for key in etcd.values))
+ self.assertFalse(
+ any(
+ key.startswith("dist_id_allocator/channels/1001/")
+ for key in etcd.values
+ )
+ )
+ self.assertEqual(etcd.values["/channels/aborted/1001"], b"1")
+ self.assertEqual(etcd.revoked_lease_ids, [501])
+
+ def test_committed_publish_response_loss_is_reconciled_as_success(self) -> None:
+ etcd = _FakeEtcd()
+ etcd.raise_after_publish_commit = True
+ _add_active_consumers(etcd, 1)
+ channel = _new_channel(etcd, member_id=11)
+ locks = _LockTracker()
+ mpsc_state = _FakeMpscState(locks)
+ fake_consumer = _new_fake_mpsc_consumer_type(mpsc_state)
+
+ with mock.patch.object(mpmc, "EtcdLock", locks.new_lock), mock.patch.object(
+ mpmc, "MPSCChanConsumer", fake_consumer
+ ):
+ result = channel.try_create_mpsc_channel(
+ object(), {}, mpmc.ChanRole.CONSUMER
+ )
+
+ self.assertTrue(result.is_ok())
+ consumer = result.unwrap()
+ self.assertEqual(consumer.release_count, 0)
+ self.assertTrue(consumer._mpmc_ready_claimed)
+ self.assertEqual(_reservation_keys(etcd), [])
+ channels_value = etcd.values[mpmc._new_mpmc_mpsc_channels_key("7")]
+ self.assertEqual(json.loads(channels_value.decode()), ["1001"])
+ ready_key = mpmc._new_mpmc_ready_channel_key("7", "1001")
+ self.assertEqual(etcd.values[ready_key], b"11")
+
+ def test_late_publish_commit_before_cleanup_fence_is_not_aborted(self) -> None:
+ etcd = _FakeEtcd()
+ etcd.defer_publish_until_cleanup = True
+ _add_active_consumers(etcd, 1)
+ channel = _new_channel(etcd, member_id=11)
+ locks = _LockTracker()
+ mpsc_state = _FakeMpscState(locks)
+ fake_consumer = _new_fake_mpsc_consumer_type(mpsc_state)
+
+ with mock.patch.object(mpmc, "EtcdLock", locks.new_lock), mock.patch.object(
+ mpmc, "MPSCChanConsumer", fake_consumer
+ ):
+ result = channel.try_create_mpsc_channel(
+ object(), {}, mpmc.ChanRole.CONSUMER
+ )
+
+ self.assertTrue(result.is_ok())
+ consumer = result.unwrap()
+ self.assertEqual(consumer.release_count, 0)
+ self.assertTrue(consumer._mpmc_ready_claimed)
+ self.assertIsNone(etcd.pending_publish_ops)
+ channels_value = etcd.values[mpmc._new_mpmc_mpsc_channels_key("7")]
+ self.assertEqual(json.loads(channels_value.decode()), ["1001"])
+ ready_key = mpmc._new_mpmc_ready_channel_key("7", "1001")
+ self.assertEqual(etcd.values[ready_key], b"11")
+
+ def test_successful_create_uses_30_second_timeout_for_both_lock_phases(self) -> None:
+ etcd = _FakeEtcd()
+ _add_active_consumers(etcd, 1)
+ channel = _new_channel(etcd, member_id=11)
+ locks = _LockTracker()
+ mpsc_state = _FakeMpscState(locks)
+ fake_consumer = _new_fake_mpsc_consumer_type(mpsc_state)
+
+ with mock.patch.object(mpmc, "EtcdLock", locks.new_lock), mock.patch.object(
+ mpmc, "MPSCChanConsumer", fake_consumer
+ ):
+ result = channel.try_create_mpsc_channel(
+ object(), {}, mpmc.ChanRole.CONSUMER
+ )
+
+ self.assertTrue(result.is_ok())
+ _ = result.unwrap()
+ self.assertEqual(mpmc.MPMC_CREATE_LOCK_TIMEOUT_SECONDS, 30.0)
+ self.assertEqual(len(locks.calls), 2)
+ self.assertEqual([call.timeout_seconds for call in locks.calls], [30.0, 30.0])
+ self.assertEqual(mpsc_state.constructor_lock_depths, [0])
+ self.assertEqual(_reservation_keys(etcd), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/fluxon_py/tests/test_config.py b/fluxon_py/tests/test_config.py
index 6de5180..e243f3d 100644
--- a/fluxon_py/tests/test_config.py
+++ b/fluxon_py/tests/test_config.py
@@ -48,6 +48,8 @@ def _build_checks(selected_test_id: Optional[str]) -> List[Tuple[str, Callable[[
("fluxonkv_sub_cluster_config", test_fluxonkv_sub_cluster_config),
("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_large_limit_size_contract", test_fluxonkv_large_limit_size_contract),
+ ("fluxonkv_size_string_contract", test_fluxonkv_size_string_contract),
("fluxonkv_external_forbids_large_file_paths", test_fluxonkv_external_forbids_large_file_paths),
("fluxonkv_p2p_relay_removed", test_fluxonkv_p2p_relay_removed),
("fluxon_client_config_yaml_shape", test_fluxon_client_config_yaml_shape),
@@ -55,6 +57,7 @@ def _build_checks(selected_test_id: Optional[str]) -> List[Tuple[str, Callable[[
("fluxonkv_runtime_defaults_are_internal", test_fluxonkv_runtime_defaults_are_internal),
("fluxonkv_removed_rdma_config_keys", test_fluxonkv_removed_rdma_config_keys),
("fluxonkv_test_spec_config", test_fluxonkv_test_spec_config),
+ ("mooncake_ssd_offload_config", test_mooncake_ssd_offload_config),
("fluxon_pyo3_import_authority", test_fluxon_pyo3_import_authority),
]
if selected_test_id is None:
@@ -310,6 +313,76 @@ def test_fluxonkv_owner_requires_large_file_paths():
print(f"❌ FAIL: test_fluxonkv_owner_requires_large_file_paths - {e}")
+def test_fluxonkv_large_limit_size_contract():
+ """Ensure KV SSD limits align one-to-one with large_file_paths."""
+ try:
+ valid = _owner_fluxonkv_base_config(tag="large_limit_size_valid")
+ valid["fluxonkv_spec"]["large_limit_size"] = [1048576]
+ rendered = FluxonKvClientConfig(valid).to_fluxon_kv_client_config_yaml_str()
+ loaded = yaml.safe_load(rendered)
+ assert loaded["fluxonkv_spec"]["large_limit_size"] == [1048576]
+
+ mismatch = _owner_fluxonkv_base_config(tag="large_limit_size_mismatch")
+ mismatch["fluxonkv_spec"]["large_file_paths"] = [
+ "/tmp/kvcache_large/large_limit_size_mismatch_a",
+ "/tmp/kvcache_large/large_limit_size_mismatch_b",
+ ]
+ mismatch["fluxonkv_spec"]["large_limit_size"] = [1048576]
+ try:
+ FluxonKvClientConfig(mismatch)
+ print("❌ FAIL: test_fluxonkv_large_limit_size_contract - length mismatch should be rejected")
+ return
+ except ValueError:
+ pass
+
+ too_small = _owner_fluxonkv_base_config(tag="large_limit_size_too_small")
+ too_small["fluxonkv_spec"]["large_limit_size"] = [1]
+ try:
+ FluxonKvClientConfig(too_small)
+ print("❌ FAIL: test_fluxonkv_large_limit_size_contract - value below 512 should be rejected")
+ return
+ except ValueError:
+ pass
+
+ external = {
+ "instance_key": "test_external",
+ "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": [1048576],
+ },
+ }
+ try:
+ FluxonKvClientConfig(external)
+ print("❌ FAIL: test_fluxonkv_large_limit_size_contract - external large_limit_size should be rejected")
+ return
+ except ValueError:
+ pass
+
+ print("✅ PASS: test_fluxonkv_large_limit_size_contract")
+ except Exception as e:
+ print(f"❌ FAIL: test_fluxonkv_large_limit_size_contract - {e}")
+
+
+def test_fluxonkv_size_string_contract():
+ """Ensure memory contribution and KV SSD limits accept size strings."""
+ try:
+ cfg = _owner_fluxonkv_base_config(tag="size_string")
+ cfg["contribute_to_cluster_pool_size"]["dram"] = "1.1GB"
+ cfg["contribute_to_cluster_pool_size"]["vram"] = {"0": "0.5GB"}
+ cfg["fluxonkv_spec"]["large_limit_size"] = ["512.9B"]
+ config = FluxonKvClientConfig(copy.deepcopy(cfg))
+ loaded = yaml.safe_load(config.to_fluxon_kv_client_config_yaml_str())
+ assert loaded["contribute_to_cluster_pool_size"]["dram"] == 70 * 16 * 1024 * 1024
+ assert loaded["contribute_to_cluster_pool_size"]["vram"]["0"] == 512 * 1024 * 1024
+ assert loaded["fluxonkv_spec"]["large_limit_size"] == [512]
+
+ print("✅ PASS: test_fluxonkv_size_string_contract")
+ except Exception as e:
+ print(f"❌ FAIL: test_fluxonkv_size_string_contract - {e}")
+
+
def test_fluxonkv_external_forbids_large_file_paths():
"""Ensure zero-contribution external config cannot declare owner-only large_file_paths."""
try:
@@ -504,6 +577,23 @@ def test_fluxonkv_test_spec_config():
assert loaded["test_spec_config"]["enable_iceoryx_logs"] is True
assert "transport_mode" not in loaded["test_spec_config"]
+ foyer_backend = copy.deepcopy(base)
+ foyer_backend["test_spec_config"] = {"kv_ssd_storage_backend": "foyer"}
+ config = FluxonKvClientConfig(foyer_backend)
+ loaded = yaml.safe_load(config.to_fluxon_kv_client_config_yaml_str())
+ assert loaded["test_spec_config"]["kv_ssd_storage_backend"] == "foyer"
+
+ invalid_foyer_uring = copy.deepcopy(foyer_backend)
+ invalid_foyer_uring["test_spec_config"]["kv_ssd_uring_mode"] = "iovec"
+ try:
+ FluxonKvClientConfig(invalid_foyer_uring)
+ print(
+ "❌ FAIL: test_fluxonkv_test_spec_config - foyer with iovec should be rejected"
+ )
+ return
+ except ValueError:
+ pass
+
closed_backend = copy.deepcopy(base)
closed_backend["test_spec_config"]["transport_mode"] = "transfer_only"
closed_backend["test_spec_config"]["rdma_device_names"] = ["mlx5_0"]
@@ -636,6 +726,36 @@ def test_fluxonkv_test_spec_config():
print(f"❌ FAIL: test_fluxonkv_test_spec_config - {e}")
+def test_mooncake_ssd_offload_config():
+ """Test Mooncake SSD offload fields are accepted and preserved."""
+ try:
+ cfg = config_dict()
+ cfg["mooncake_spec"]["enable_ssd_offload"] = True
+ cfg["mooncake_spec"]["ssd_offload_path"] = "/tmp/mooncake-offload"
+ cfg["mooncake_spec"]["skip_get_size_on_get"] = True
+ cfg["mooncake_spec"]["local_hostname"] = "192.0.2.10"
+ config = FluxonKvClientConfig(cfg)
+ assert config.mooncake_spec_enable_ssd_offload is True
+ assert config.mooncake_spec_ssd_offload_path == "/tmp/mooncake-offload"
+ assert config.mooncake_spec_skip_get_size_on_get is True
+ assert config.mooncake_spec_local_hostname == "192.0.2.10"
+ loaded = yaml.safe_load(config.to_yaml_str())
+ assert loaded["mooncake_spec"]["enable_ssd_offload"] is True
+ assert loaded["mooncake_spec"]["ssd_offload_path"] == "/tmp/mooncake-offload"
+ assert loaded["mooncake_spec"]["skip_get_size_on_get"] is True
+ assert loaded["mooncake_spec"]["local_hostname"] == "192.0.2.10"
+
+ default_cfg = config_dict()
+ default_config = FluxonKvClientConfig(default_cfg)
+ assert default_config.mooncake_spec_enable_ssd_offload is False
+ assert default_config.mooncake_spec_ssd_offload_path == ""
+ assert default_config.mooncake_spec_skip_get_size_on_get is False
+ assert default_config.mooncake_spec_local_hostname == "test_instance"
+ print("✅ PASS: test_mooncake_ssd_offload_config")
+ except Exception as e:
+ print(f"❌ FAIL: test_mooncake_ssd_offload_config - {e}")
+
+
def config_dict():
return {
"instance_key": "test_instance",
@@ -718,12 +838,13 @@ def test_to_yaml_str_roundtrip(cfg: dict):
def test_verification(cfg):
"""Test configuration verification for invalid values."""
try:
- invalid_config = copy.deepcopy(cfg)
- invalid_config["contribute_to_cluster_pool_size"]["dram"] = 16777217
- FluxonKvClientConfig(invalid_config)
- print("❌ FAIL: Invalid dram size should be rejected")
- except ValueError:
- print("✅ PASS: Invalid dram size correctly rejected")
+ aligned_config = copy.deepcopy(cfg)
+ aligned_config["contribute_to_cluster_pool_size"]["dram"] = 16777217
+ config = FluxonKvClientConfig(aligned_config)
+ assert config.contribute_to_cluster_pool_size["dram"] == 16777216
+ print("✅ PASS: Unaligned dram size correctly aligned")
+ except Exception as e:
+ print(f"❌ FAIL: Unaligned dram size should be aligned - {e}")
try:
invalid_config = copy.deepcopy(cfg)
@@ -814,12 +935,13 @@ def test_verification(cfg):
print(f"❌ FAIL: Valid GPU configuration should be accepted - {e}")
try:
- invalid_gpu_config = copy.deepcopy(cfg)
- invalid_gpu_config["contribute_to_cluster_pool_size"]["vram"] = {"0": 16777217}
- FluxonKvClientConfig(invalid_gpu_config)
- print("❌ FAIL: Invalid GPU configuration should be rejected")
- except ValueError:
- print("✅ PASS: Invalid GPU configuration correctly rejected")
+ aligned_gpu_config = copy.deepcopy(cfg)
+ aligned_gpu_config["contribute_to_cluster_pool_size"]["vram"] = {"0": 16777217}
+ config = FluxonKvClientConfig(aligned_gpu_config)
+ assert config.contribute_to_cluster_pool_size["vram"]["0"] == 16777216
+ print("✅ PASS: Unaligned GPU configuration correctly aligned")
+ except Exception as e:
+ print(f"❌ FAIL: Unaligned GPU configuration should be aligned - {e}")
if __name__ == "__main__":
diff --git a/fluxon_py/tests/test_fluxon_fs_video.py b/fluxon_py/tests/test_fluxon_fs_video.py
new file mode 100644
index 0000000..6e76b46
--- /dev/null
+++ b/fluxon_py/tests/test_fluxon_fs_video.py
@@ -0,0 +1,313 @@
+import sys
+import unittest
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(REPO_ROOT))
+
+
+def main() -> None:
+ unittest.main()
+
+
+from fluxon_py.fluxon_fs.video import ( # noqa: E402
+ FluxonFsVideoReadRequest,
+ FluxonFsVideoReader,
+ FluxonFsVideoReaderPool,
+)
+
+
+class _FakeNativeVideoReader:
+ def __init__(self) -> None:
+ self.calls = []
+ self.closed = False
+ self.read_at_calls = 0
+ self.remote_read_bytes = 0
+
+ def read_frames_numpy(self, indices):
+ self.calls.append(list(indices))
+ self.read_at_calls += 1
+ self.remote_read_bytes += 1024 * len(indices)
+ return ("frames", tuple(indices))
+
+ def stats(self):
+ return {
+ "read_at_calls": self.read_at_calls,
+ "remote_read_bytes": self.remote_read_bytes,
+ }
+
+ def close(self) -> None:
+ self.closed = True
+
+
+class _FakeArray:
+ def __init__(self, values) -> None:
+ self.values = tuple(values)
+ self.shape = (len(self.values),)
+ self.nbytes = len(self.values)
+
+ def __getitem__(self, item):
+ selected = self.values[item]
+ if isinstance(selected, tuple):
+ return _FakeArray(selected)
+ return _FakeArray((selected,))
+
+ def __eq__(self, other) -> bool:
+ if not isinstance(other, _FakeArray):
+ return False
+ return self.values == other.values
+
+
+class _FakeArrayNativeVideoReader(_FakeNativeVideoReader):
+ def read_frames_numpy(self, indices):
+ self.calls.append(list(indices))
+ self.read_at_calls += 1
+ self.remote_read_bytes += 1024 * len(indices)
+ return _FakeArray(indices)
+
+
+class _FakeAgent:
+ def __init__(self) -> None:
+ self.calls = []
+ self.natives: list[_FakeNativeVideoReader] = []
+
+ def open_video_reader(
+ self,
+ export_name: str,
+ relpath: str,
+ height: int,
+ width: int,
+ num_threads: int,
+ request_identity,
+ ):
+ self.calls.append(
+ (
+ export_name,
+ relpath,
+ height,
+ width,
+ num_threads,
+ request_identity,
+ )
+ )
+ native = _FakeNativeVideoReader()
+ self.natives.append(native)
+ return native
+
+
+class _FakeArrayAgent(_FakeAgent):
+ def open_video_reader(
+ self,
+ export_name: str,
+ relpath: str,
+ height: int,
+ width: int,
+ num_threads: int,
+ request_identity,
+ ):
+ self.calls.append(
+ (
+ export_name,
+ relpath,
+ height,
+ width,
+ num_threads,
+ request_identity,
+ )
+ )
+ native = _FakeArrayNativeVideoReader()
+ self.natives.append(native)
+ return native
+
+
+class TestFluxonFsVideoReaderFacade(unittest.TestCase):
+ def test_open_passes_strong_arguments_to_native_agent(self) -> None:
+ agent = _FakeAgent()
+
+ reader = FluxonFsVideoReader._open(
+ agent=agent,
+ export_name="videos",
+ relpath="a/b.mp4",
+ height=480,
+ width=832,
+ num_threads=8,
+ request_identity=("user", "pass"),
+ )
+
+ self.assertIsInstance(reader, FluxonFsVideoReader)
+ self.assertEqual(
+ agent.calls,
+ [("videos", "a/b.mp4", 480, 832, 8, ("user", "pass"))],
+ )
+
+ def test_read_frames_numpy_validates_indices_and_closes_native(self) -> None:
+ native = _FakeNativeVideoReader()
+ reader = FluxonFsVideoReader(native)
+
+ out = reader.read_frames_numpy([2, 0, 2])
+ self.assertEqual(out, ("frames", (2, 0, 2)))
+ self.assertEqual(native.calls, [[2, 0, 2]])
+ self.assertEqual(
+ reader.stats(),
+ {
+ "read_at_calls": 1,
+ "remote_read_bytes": 3072,
+ },
+ )
+
+ with self.assertRaisesRegex(ValueError, "non-negative"):
+ reader.read_frames_numpy([-1])
+ with self.assertRaisesRegex(TypeError, "int values"):
+ reader.read_frames_numpy([1, "2"]) # type: ignore[list-item]
+ with self.assertRaisesRegex(TypeError, "int values"):
+ reader.read_frames_numpy([True]) # type: ignore[list-item]
+
+ reader.close()
+ self.assertTrue(native.closed)
+ with self.assertRaisesRegex(RuntimeError, "closed"):
+ reader.read_frames_numpy([0])
+ with self.assertRaisesRegex(RuntimeError, "closed"):
+ reader.stats()
+
+ def test_open_rejects_invalid_arguments_before_native_call(self) -> None:
+ agent = _FakeAgent()
+
+ with self.assertRaisesRegex(ValueError, "export_name must be non-empty"):
+ FluxonFsVideoReader._open(
+ agent=agent,
+ export_name="",
+ relpath="a.mp4",
+ height=480,
+ width=832,
+ num_threads=8,
+ request_identity=None,
+ )
+ with self.assertRaisesRegex(ValueError, "height must be positive"):
+ FluxonFsVideoReader._open(
+ agent=agent,
+ export_name="videos",
+ relpath="a.mp4",
+ height=0,
+ width=832,
+ num_threads=8,
+ request_identity=None,
+ )
+ with self.assertRaisesRegex(TypeError, "num_threads must be int"):
+ FluxonFsVideoReader._open(
+ agent=agent,
+ export_name="videos",
+ relpath="a.mp4",
+ height=480,
+ width=832,
+ num_threads=True, # type: ignore[arg-type]
+ request_identity=None,
+ )
+
+ self.assertEqual(agent.calls, [])
+
+
+class TestFluxonFsVideoReaderPool(unittest.TestCase):
+ def test_pool_reuses_idle_reader_and_returns_per_read_stats(self) -> None:
+ agent = _FakeAgent()
+ pool = FluxonFsVideoReaderPool(
+ agent=agent,
+ request_identity=("user", "pass"),
+ max_readers=2,
+ )
+
+ first = pool.read_frames_numpy_with_stats(
+ export_name="videos",
+ relpath="a.mp4",
+ height=480,
+ width=832,
+ num_threads=2,
+ indices=[0, 1],
+ )
+ second = pool.read_frames_numpy_with_stats(
+ export_name="videos",
+ relpath="a.mp4",
+ height=480,
+ width=832,
+ num_threads=2,
+ indices=[2, 3, 4],
+ )
+
+ self.assertFalse(first.reader_cache_hit)
+ self.assertTrue(second.reader_cache_hit)
+ self.assertEqual(len(agent.natives), 1)
+ self.assertEqual(first.reader_stats["read_at_calls"], 1)
+ self.assertEqual(first.reader_stats["remote_read_bytes"], 2048)
+ self.assertEqual(second.reader_stats["read_at_calls"], 1)
+ self.assertEqual(second.reader_stats["remote_read_bytes"], 3072)
+ self.assertEqual(pool.stats()["reader_cache_hits"], 1)
+ self.assertEqual(pool.stats()["reader_cache_misses"], 1)
+
+ pool.close()
+ self.assertTrue(agent.natives[0].closed)
+
+ def test_pool_evicts_lru_idle_reader(self) -> None:
+ agent = _FakeAgent()
+ pool = FluxonFsVideoReaderPool(agent=agent, request_identity=None, max_readers=1)
+
+ pool.read_frames_numpy(
+ export_name="videos",
+ relpath="a.mp4",
+ height=480,
+ width=832,
+ num_threads=2,
+ indices=[0],
+ )
+ first_native = agent.natives[0]
+ pool.read_frames_numpy(
+ export_name="videos",
+ relpath="b.mp4",
+ height=480,
+ width=832,
+ num_threads=2,
+ indices=[0],
+ )
+
+ self.assertTrue(first_native.closed)
+ self.assertEqual(pool.stats()["current_readers"], 1)
+ self.assertEqual(pool.stats()["evict_count"], 1)
+ pool.close()
+
+ def test_pool_read_many_coalesces_same_reader_key(self) -> None:
+ agent = _FakeArrayAgent()
+ pool = FluxonFsVideoReaderPool(agent=agent, request_identity=None, max_readers=2)
+
+ results = pool.read_many_numpy_with_stats(
+ [
+ FluxonFsVideoReadRequest(
+ export_name="videos",
+ relpath="a.mp4",
+ height=480,
+ width=832,
+ num_threads=2,
+ indices=(0, 4),
+ ),
+ FluxonFsVideoReadRequest(
+ export_name="videos",
+ relpath="a.mp4",
+ height=480,
+ width=832,
+ num_threads=2,
+ indices=(2, 6, 8),
+ ),
+ ]
+ )
+
+ self.assertEqual(len(agent.natives), 1)
+ self.assertEqual(agent.natives[0].calls, [[0, 4, 2, 6, 8]])
+ self.assertEqual(results[0].array, _FakeArray((0, 4)))
+ self.assertEqual(results[1].array, _FakeArray((2, 6, 8)))
+ self.assertEqual(sum(result.reader_stats["read_at_calls"] for result in results), 1)
+ self.assertEqual(sum(result.reader_stats["remote_read_bytes"] for result in results), 5120)
+ self.assertEqual(results[0].batch_stats["read_many_group_size"], 2)
+ self.assertEqual(results[1].batch_stats["read_many_group_frames"], 5)
+ pool.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/fluxon_py/tests/test_mooncake_put_contract.py b/fluxon_py/tests/test_mooncake_put_contract.py
new file mode 100644
index 0000000..0ad25e3
--- /dev/null
+++ b/fluxon_py/tests/test_mooncake_put_contract.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import sys
+import threading
+import types
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import nullcontext
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(REPO_ROOT))
+
+
+if "mooncake.store" not in sys.modules:
+ mooncake_module = types.ModuleType("mooncake")
+ mooncake_store_module = types.ModuleType("mooncake.store")
+
+ class _MooncakeDistributedStore:
+ pass
+
+ mooncake_store_module.MooncakeDistributedStore = _MooncakeDistributedStore
+ mooncake_module.store = mooncake_store_module
+ sys.modules["mooncake"] = mooncake_module
+ sys.modules["mooncake.store"] = mooncake_store_module
+
+
+from fluxon_py.api_error import KeyAlreadyExistsError
+from fluxon_py.kvclient.kvclient_interface import PutOptionalArgs
+from fluxon_py.kvclient.mooncake import MooncakeStore
+
+
+class _ReadWriteLock:
+ def read_lock(self):
+ return nullcontext()
+
+ def write_lock(self):
+ return nullcontext()
+
+
+class _NativeStore:
+ def __init__(self, *, put_retcode: int = 0) -> None:
+ self.put_retcode = put_retcode
+ self.calls: list[tuple[object, ...]] = []
+
+ def remove(self, key: str, force: bool) -> int:
+ self.calls.append(("remove", key, force))
+ return 0
+
+ def put(self, key: str, value: bytes) -> int:
+ self.calls.append(("put", key, value))
+ return self.put_retcode
+
+
+class TestMooncakePutContract(unittest.TestCase):
+ def _new_store(self, native_store: _NativeStore) -> tuple[MooncakeStore, list[int]]:
+ store = object.__new__(MooncakeStore)
+ store._initialized = True
+ store._store = native_store
+ store._rwlock = _ReadWriteLock()
+ store._renew_lock = threading.Lock()
+ store._thread_pool = ThreadPoolExecutor(max_workers=1)
+ renew_calls: list[int] = []
+
+ def _unexpected_renew():
+ renew_calls.append(1)
+ raise AssertionError("Mooncake write-once rejection must not renew the store")
+
+ store.renew_store = _unexpected_renew
+ self.addCleanup(store._thread_pool.shutdown, wait=True)
+ return store, renew_calls
+
+ def test_reject_if_exists_put_skips_remove(self) -> None:
+ native_store = _NativeStore()
+ store, renew_calls = self._new_store(native_store)
+
+ result = store.put_blocking(
+ "key",
+ {"payload": b"value"},
+ opts=PutOptionalArgs(reject_if_exists=True),
+ )
+
+ self.assertTrue(result.is_ok())
+ _ = result.unwrap()
+ self.assertEqual([call[0] for call in native_store.calls], ["put"])
+ self.assertEqual(renew_calls, [])
+
+ def test_overwrite_put_removes_before_put(self) -> None:
+ for opts in (None, PutOptionalArgs(reject_if_exists=False)):
+ with self.subTest(opts=opts):
+ native_store = _NativeStore()
+ store, renew_calls = self._new_store(native_store)
+
+ result = store.put_blocking("key", {"payload": b"value"}, opts=opts)
+
+ self.assertTrue(result.is_ok())
+ _ = result.unwrap()
+ self.assertEqual(
+ [call[0] for call in native_store.calls],
+ ["remove", "put"],
+ )
+ self.assertEqual(renew_calls, [])
+
+ def test_reject_if_exists_native_already_exists_does_not_renew(self) -> None:
+ native_store = _NativeStore(put_retcode=-705)
+ store, renew_calls = self._new_store(native_store)
+
+ result = store.put_blocking(
+ "key",
+ {"payload": b"value"},
+ opts=PutOptionalArgs(reject_if_exists=True),
+ )
+
+ self.assertFalse(result.is_ok())
+ self.assertIsInstance(result.unwrap_error(), KeyAlreadyExistsError)
+ self.assertEqual([call[0] for call in native_store.calls], ["put"])
+ self.assertEqual(renew_calls, [])
+
+
+if __name__ == "__main__":
+ raise SystemExit(unittest.main())
diff --git a/fluxon_py/tests/test_pyo3_etcd.py b/fluxon_py/tests/test_pyo3_etcd.py
index 4c79451..afad0a7 100644
--- a/fluxon_py/tests/test_pyo3_etcd.py
+++ b/fluxon_py/tests/test_pyo3_etcd.py
@@ -124,6 +124,36 @@ def test_lock_exclusive_and_context_manager_release(self) -> None:
context_lease_id = int(held_lock.lease_id)
self._assert_lease_not_live(context_lease_id)
+ def test_lock_keeps_lease_alive_past_ttl(self) -> None:
+ lock_name = self.prefix + "long_critical_section_lock"
+ ttl_seconds = 2
+ lock_a = self.fluxon_pyo3.EtcdLock(
+ [self.endpoint], lock_name, ttl_seconds, 1.0
+ )
+ lock_b = self.fluxon_pyo3.EtcdLock(
+ [self.endpoint], lock_name, ttl_seconds, 0.5
+ )
+
+ try:
+ self.assertTrue(lock_a.acquire())
+ lease_id = int(lock_a.lease_id)
+
+ time.sleep(ttl_seconds + 1.5)
+
+ self.assertGreater(self.client.lease_ttl(lease_id), 0)
+ self.assertFalse(lock_b.acquire())
+
+ self.assertTrue(lock_a.release())
+ self.assertTrue(lock_b.acquire(1.0))
+ finally:
+ for lock in (lock_b, lock_a):
+ if not lock.held:
+ continue
+ try:
+ lock.release()
+ except RuntimeError:
+ pass
+
if __name__ == "__main__":
raise SystemExit(unittest.main())
diff --git a/fluxon_rs/Cargo.lock b/fluxon_rs/Cargo.lock
index a4b0ecd..d5bb591 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"
@@ -1034,7 +1063,6 @@ dependencies = [
"bitcode",
"bytes",
"crossbeam",
- "etcd-client",
"fluxon_commu_closed_sdk_consumer",
"fluxon_commu_contract",
"fluxon_framework",
@@ -1232,11 +1260,13 @@ dependencies = [
"fluxon_framework_compiled",
"fluxon_observability",
"fluxon_util",
+ "foyer",
"futures",
"hex",
"hyper 0.14.32",
"iceoryx2",
"iceoryx2-cal",
+ "io-uring",
"kanal",
"lazy_static",
"libc",
@@ -1322,7 +1352,6 @@ dependencies = [
"base64 0.21.7",
"chrono",
"clap",
- "etcd-client",
"fluxon_cli",
"fluxon_framework",
"fluxon_kv",
@@ -1372,6 +1401,7 @@ dependencies = [
"anyhow",
"async-trait",
"bytes",
+ "cc",
"crossbeam-channel",
"etcd-client",
"fluxon_cli",
@@ -1389,6 +1419,7 @@ dependencies = [
"libc",
"limit_thirdparty",
"parking_lot",
+ "pkg-config",
"pyo3",
"serde",
"serde_json",
@@ -1447,6 +1478,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 +1508,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 +1779,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 +1883,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 +2578,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 +2640,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 +2808,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 +2842,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 +2918,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 +3091,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 +3768,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 +4482,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 +5362,15 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "twox-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
+dependencies = [
+ "rand 0.9.2",
+]
+
[[package]]
name = "typenum"
version = "1.18.0"
@@ -5902,3 +6193,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_cli/src/cli_renderer.rs b/fluxon_rs/fluxon_cli/src/cli_renderer.rs
index 71ae73d..c6cffbe 100644
--- a/fluxon_rs/fluxon_cli/src/cli_renderer.rs
+++ b/fluxon_rs/fluxon_cli/src/cli_renderer.rs
@@ -617,13 +617,13 @@ pub fn render_cluster(snapshot: &ClusterSnapshot) -> String {
));
for d in &o.devices {
lines.push(format!(
- "|-- device: {} used={} cap={} util={}",
- d.device, d.used, d.cap, d.util
+ "|-- resource={} device={} used={} cap={} util={}",
+ d.resource_kind, d.device, d.used, d.cap, d.util
));
}
}
}
- push_box(&mut out, "Owner segment usage", &lines);
+ push_box(&mut out, "Owner storage usage", &lines);
out.push('\n');
}
diff --git a/fluxon_rs/fluxon_cli/src/lib.rs b/fluxon_rs/fluxon_cli/src/lib.rs
index 11c9fcd..7fa5a38 100644
--- a/fluxon_rs/fluxon_cli/src/lib.rs
+++ b/fluxon_rs/fluxon_cli/src/lib.rs
@@ -47,12 +47,19 @@ use fluxon_commu::{
RdmaLinkLayer, RdmaPhysState, RdmaPortSnapshot, RdmaPortState, cluster_member_base_prefix,
cluster_member_ext_prefix, cluster_owner_rdma_control_prefix, scan_etcd_prefix_paginated,
};
+use fluxon_util::etcd::{EtcdEndpointSet, ManagedEtcdClient};
use serde::Deserialize;
use std::collections::{BTreeMap, HashMap};
use std::io::{IsTerminal, Read, Write};
const MQ_META_KEY_PREFIX: &str = "/channels/meta/";
+fn managed_etcd_backend(endpoints: &[String]) -> anyhow::Result {
+ Ok(ManagedEtcdClient::acquire(EtcdEndpointSet::new(
+ endpoints.to_vec(),
+ )?))
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
enum MqChanRoleWire {
@@ -231,7 +238,9 @@ pub async fn load_transfer_engine_edges_for_cluster(
etcd_endpoints: &[String],
cluster_name: &str,
) -> anyhow::Result> {
- let mut etcd = EtcdClient::connect(etcd_endpoints.to_vec(), None)
+ let etcd_backend = managed_etcd_backend(etcd_endpoints)?;
+ let mut etcd = etcd_backend
+ .client()
.await
.with_context(|| "connect etcd (transfer_link scan)".to_string())?;
let p2p_prefix = transfer_link_p2p_prefix(cluster_name);
@@ -1185,7 +1194,9 @@ async fn build_fs_cluster_snapshot(
cfg: &MonitorConfig,
warnings: &mut Vec,
) -> anyhow::Result {
- let mut etcd = EtcdClient::connect(cfg.etcd_endpoints.clone(), None)
+ let etcd_backend = managed_etcd_backend(&cfg.etcd_endpoints)?;
+ let mut etcd = etcd_backend
+ .client()
.await
.with_context(|| "connect etcd".to_string())?;
@@ -1402,6 +1413,7 @@ async fn build_fs_cluster_snapshot(
container_memory_limit_bytes: None,
members: Vec::new(),
segment_devices: Vec::new(),
+ storage_devices: Vec::new(),
});
if is_p2p_relay {
@@ -1469,7 +1481,9 @@ pub async fn build_cluster_snapshot_with_prom_query_time(
) -> anyhow::Result {
let mut warnings: Vec = Vec::new();
if cfg.member_kind == MemberKind::Mq {
- let mut etcd = EtcdClient::connect(cfg.etcd_endpoints.clone(), None)
+ let etcd_backend = managed_etcd_backend(&cfg.etcd_endpoints)?;
+ let mut etcd = etcd_backend
+ .client()
.await
.with_context(|| "connect etcd".to_string())?;
let mut mq = build_mq_snapshot(cfg, &mut warnings, &mut etcd).await?;
@@ -1587,7 +1601,9 @@ pub async fn build_cluster_snapshot_with_prom_query_time(
if cfg.member_kind == MemberKind::Fs {
return build_fs_cluster_snapshot(cfg, &mut warnings).await;
}
- let mut etcd = EtcdClient::connect(cfg.etcd_endpoints.clone(), None)
+ let etcd_backend = managed_etcd_backend(&cfg.etcd_endpoints)?;
+ let mut etcd = etcd_backend
+ .client()
.await
.with_context(|| "connect etcd".to_string())?;
@@ -2128,6 +2144,7 @@ pub async fn build_cluster_snapshot_with_prom_query_time(
container_memory_limit_bytes: None,
members: Vec::new(),
segment_devices: Vec::new(),
+ storage_devices: Vec::new(),
});
if is_p2p_relay {
node_entry.is_p2p_relay = true;
@@ -2214,72 +2231,160 @@ pub async fn build_cluster_snapshot_with_prom_query_time(
"missing segment metrics for owner_client: owner_id={} (expected Prom series: kvcache_segment_*_bytes{{node=\"{}\",device}})",
owner_id, owner_id
));
- continue;
+ } else {
+ let mut missing_cap: Vec = Vec::new();
+ let mut missing_used: Vec = Vec::new();
+ let mut cap_zero: Vec = Vec::new();
+ let mut used_gt_cap: Vec = Vec::new();
+ for device in devices {
+ let cap = prom_maps
+ .seg_capacity_bytes_by_node_device
+ .get(&(owner_id.clone(), device.clone()))
+ .copied();
+ let used = prom_maps
+ .seg_used_bytes_by_node_device
+ .get(&(owner_id.clone(), device.clone()))
+ .copied();
+ if cap.is_none() {
+ missing_cap.push(device.clone());
+ }
+ if used.is_none() {
+ missing_used.push(device.clone());
+ }
+ if let Some(cap) = cap {
+ if cap == 0.0 {
+ cap_zero.push(device.clone());
+ }
+ }
+ if let (Some(used), Some(cap)) = (used, cap) {
+ if used > cap {
+ used_gt_cap.push(device.clone());
+ }
+ }
+ n.segment_devices.push(crate::model::SegmentDeviceSnapshot {
+ device: device.clone(),
+ seg_capacity_bytes: cap,
+ seg_used_bytes: used,
+ });
+ n.storage_devices.push(crate::model::StorageDeviceSnapshot {
+ resource_kind: "memory_segment".to_string(),
+ device,
+ capacity_bytes: cap,
+ used_bytes: used,
+ });
+ }
+ n.segment_devices.sort_by(|a, b| a.device.cmp(&b.device));
+
+ if !missing_cap.is_empty() {
+ warnings.push(format!(
+ "segment metrics missing capacity series for owner_id={} devices={}",
+ owner_id,
+ missing_cap.join(",")
+ ));
+ }
+ if !missing_used.is_empty() {
+ warnings.push(format!(
+ "segment metrics missing used series for owner_id={} devices={}",
+ owner_id,
+ missing_used.join(",")
+ ));
+ }
+ if !cap_zero.is_empty() {
+ warnings.push(format!(
+ "segment metrics has cap=0 for owner_id={} devices={}",
+ owner_id,
+ cap_zero.join(",")
+ ));
+ }
+ if !used_gt_cap.is_empty() {
+ warnings.push(format!(
+ "segment metrics has used>cap for owner_id={} devices={}",
+ owner_id,
+ used_gt_cap.join(",")
+ ));
+ }
}
- let mut missing_cap: Vec = Vec::new();
- let mut missing_used: Vec = Vec::new();
- let mut cap_zero: Vec = Vec::new();
- let mut used_gt_cap: Vec = Vec::new();
- for device in devices {
+ let mut ssd_devices: std::collections::BTreeSet = std::collections::BTreeSet::new();
+ for ((node, device), _v) in &prom_maps.kv_ssd_capacity_bytes_by_node_device {
+ if node == &owner_id {
+ ssd_devices.insert(device.clone());
+ }
+ }
+ for ((node, device), _v) in &prom_maps.kv_ssd_used_bytes_by_node_device {
+ if node == &owner_id {
+ ssd_devices.insert(device.clone());
+ }
+ }
+
+ let mut ssd_missing_cap: Vec = Vec::new();
+ let mut ssd_missing_used: Vec = Vec::new();
+ let mut ssd_cap_zero: Vec = Vec::new();
+ let mut ssd_used_gt_cap: Vec = Vec::new();
+ for device in ssd_devices {
let cap = prom_maps
- .seg_capacity_bytes_by_node_device
+ .kv_ssd_capacity_bytes_by_node_device
.get(&(owner_id.clone(), device.clone()))
.copied();
let used = prom_maps
- .seg_used_bytes_by_node_device
+ .kv_ssd_used_bytes_by_node_device
.get(&(owner_id.clone(), device.clone()))
.copied();
if cap.is_none() {
- missing_cap.push(device.clone());
+ ssd_missing_cap.push(device.clone());
}
if used.is_none() {
- missing_used.push(device.clone());
+ ssd_missing_used.push(device.clone());
}
if let Some(cap) = cap {
if cap == 0.0 {
- cap_zero.push(device.clone());
+ ssd_cap_zero.push(device.clone());
}
}
if let (Some(used), Some(cap)) = (used, cap) {
if used > cap {
- used_gt_cap.push(device.clone());
+ ssd_used_gt_cap.push(device.clone());
}
}
- n.segment_devices.push(crate::model::SegmentDeviceSnapshot {
+ n.storage_devices.push(crate::model::StorageDeviceSnapshot {
+ resource_kind: "kv_ssd".to_string(),
device,
- seg_capacity_bytes: cap,
- seg_used_bytes: used,
+ capacity_bytes: cap,
+ used_bytes: used,
});
}
- n.segment_devices.sort_by(|a, b| a.device.cmp(&b.device));
+ n.storage_devices
+ .sort_by(|a, b| match a.resource_kind.cmp(&b.resource_kind) {
+ std::cmp::Ordering::Equal => a.device.cmp(&b.device),
+ o => o,
+ });
- if !missing_cap.is_empty() {
+ if !ssd_missing_cap.is_empty() {
warnings.push(format!(
- "segment metrics missing capacity series for owner_id={} devices={}",
+ "kv ssd metrics missing capacity series for owner_id={} devices={}",
owner_id,
- missing_cap.join(",")
+ ssd_missing_cap.join(",")
));
}
- if !missing_used.is_empty() {
+ if !ssd_missing_used.is_empty() {
warnings.push(format!(
- "segment metrics missing used series for owner_id={} devices={}",
+ "kv ssd metrics missing used series for owner_id={} devices={}",
owner_id,
- missing_used.join(",")
+ ssd_missing_used.join(",")
));
}
- if !cap_zero.is_empty() {
+ if !ssd_cap_zero.is_empty() {
warnings.push(format!(
- "segment metrics has cap=0 for owner_id={} devices={}",
+ "kv ssd metrics has cap=0 for owner_id={} devices={}",
owner_id,
- cap_zero.join(",")
+ ssd_cap_zero.join(",")
));
}
- if !used_gt_cap.is_empty() {
+ if !ssd_used_gt_cap.is_empty() {
warnings.push(format!(
- "segment metrics has used>cap for owner_id={} devices={}",
+ "kv ssd metrics has used>cap for owner_id={} devices={}",
owner_id,
- used_gt_cap.join(",")
+ ssd_used_gt_cap.join(",")
));
}
}
diff --git a/fluxon_rs/fluxon_cli/src/model.rs b/fluxon_rs/fluxon_cli/src/model.rs
index 4b9e14a..7673cd4 100644
--- a/fluxon_rs/fluxon_cli/src/model.rs
+++ b/fluxon_rs/fluxon_cli/src/model.rs
@@ -1020,6 +1020,14 @@ pub struct SegmentDeviceSnapshot {
pub seg_used_bytes: Option,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StorageDeviceSnapshot {
+ pub resource_kind: String,
+ pub device: String,
+ pub capacity_bytes: Option,
+ pub used_bytes: Option,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeSnapshot {
pub node_key: String,
@@ -1035,6 +1043,8 @@ pub struct NodeSnapshot {
pub container_memory_limit_bytes: Option,
pub members: Vec,
pub segment_devices: Vec,
+ #[serde(default)]
+ pub storage_devices: Vec,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1226,6 +1236,7 @@ pub struct OwnerSegmentUsageViewModel {
#[derive(Debug, Clone)]
pub struct OwnerSegmentDeviceUsageViewModel {
+ pub resource_kind: String,
pub device: String,
pub used: String,
pub cap: String,
@@ -1554,6 +1565,10 @@ pub struct MemberTableRowView {
pub seg_used_sort: String,
pub seg_cap_text: String,
pub seg_cap_sort: String,
+ pub ssd_used_text: String,
+ pub ssd_used_sort: String,
+ pub ssd_cap_text: String,
+ pub ssd_cap_sort: String,
}
#[derive(Debug, Clone)]
@@ -1567,6 +1582,7 @@ pub struct OwnerSegmentOwnerRowView {
#[derive(Debug, Clone)]
pub struct OwnerSegmentDeviceRowView {
pub owner_id: String,
+ pub resource_kind: String,
pub device: String,
pub used: String,
pub cap: String,
@@ -1732,26 +1748,30 @@ pub fn build_cluster_view_model(snapshot: &ClusterSnapshot) -> ClusterViewModel
};
let total_used_bytes =
- sum_opt_f64_complete(n.segment_devices.iter().map(|d| d.seg_used_bytes));
+ sum_opt_f64_complete(n.storage_devices.iter().map(|d| d.used_bytes));
let total_cap_bytes =
- sum_opt_f64_complete(n.segment_devices.iter().map(|d| d.seg_capacity_bytes));
+ sum_opt_f64_complete(n.storage_devices.iter().map(|d| d.capacity_bytes));
let (total_used, _total_used_status) = fmt_bytes_auto(total_used_bytes, false);
let (total_cap, _total_cap_status) = fmt_bytes_auto(total_cap_bytes, false);
let total_util = fmt_util_percent(total_used_bytes, total_cap_bytes);
let mut devices: Vec =
- Vec::with_capacity(n.segment_devices.len());
- for d in &n.segment_devices {
- let (used, _used_status) = fmt_bytes_auto(d.seg_used_bytes, false);
- let (cap, _cap_status) = fmt_bytes_auto(d.seg_capacity_bytes, false);
+ Vec::with_capacity(n.storage_devices.len());
+ for d in &n.storage_devices {
+ let (used, _used_status) = fmt_bytes_auto(d.used_bytes, false);
+ let (cap, _cap_status) = fmt_bytes_auto(d.capacity_bytes, false);
devices.push(OwnerSegmentDeviceUsageViewModel {
+ resource_kind: d.resource_kind.clone(),
device: d.device.clone(),
used,
cap,
- util: fmt_util_percent(d.seg_used_bytes, d.seg_capacity_bytes),
+ util: fmt_util_percent(d.used_bytes, d.capacity_bytes),
});
}
- devices.sort_by(|a, b| a.device.cmp(&b.device));
+ devices.sort_by(|a, b| match a.resource_kind.cmp(&b.resource_kind) {
+ std::cmp::Ordering::Equal => a.device.cmp(&b.device),
+ o => o,
+ });
owners.push(OwnerSegmentUsageViewModel {
owner_id,
@@ -1847,6 +1867,28 @@ pub fn build_member_table_rows(snapshot: &ClusterSnapshot) -> Vec Option {
+ let mut sum = 0f64;
+ let mut seen = false;
+ for d in devices.iter().filter(|d| d.resource_kind == resource_kind) {
+ let value = if use_used_bytes {
+ d.used_bytes
+ } else {
+ d.capacity_bytes
+ };
+ let Some(value) = value else {
+ return None;
+ };
+ sum += value;
+ seen = true;
+ }
+ if seen { Some(sum) } else { None }
+ }
+
let visible_roles = snapshot.visible_member_roles.as_ref();
let mut rows: Vec = Vec::new();
for node in &snapshot.nodes {
@@ -1882,6 +1924,18 @@ pub fn build_member_table_rows(snapshot: &ClusterSnapshot) -> Vec Vec OwnerSegmentTab
continue;
};
- let total_used_bytes =
- sum_opt_f64_complete(n.segment_devices.iter().map(|d| d.seg_used_bytes));
+ let total_used_bytes = sum_opt_f64_complete(n.storage_devices.iter().map(|d| d.used_bytes));
let total_cap_bytes =
- sum_opt_f64_complete(n.segment_devices.iter().map(|d| d.seg_capacity_bytes));
+ sum_opt_f64_complete(n.storage_devices.iter().map(|d| d.capacity_bytes));
let (total_used, _total_used_status) = fmt_bytes_auto(total_used_bytes, false);
let (total_cap, _total_cap_status) = fmt_bytes_auto(total_cap_bytes, false);
let total_util = fmt_util_percent(total_used_bytes, total_cap_bytes);
@@ -2068,22 +2125,26 @@ pub fn build_owner_segment_tables(snapshot: &ClusterSnapshot) -> OwnerSegmentTab
total_util,
});
- for d in &n.segment_devices {
- let (used, _used_status) = fmt_bytes_auto(d.seg_used_bytes, false);
- let (cap, _cap_status) = fmt_bytes_auto(d.seg_capacity_bytes, false);
+ for d in &n.storage_devices {
+ let (used, _used_status) = fmt_bytes_auto(d.used_bytes, false);
+ let (cap, _cap_status) = fmt_bytes_auto(d.capacity_bytes, false);
device_rows.push(OwnerSegmentDeviceRowView {
owner_id: owner_id.clone(),
+ resource_kind: d.resource_kind.clone(),
device: d.device.clone(),
used,
cap,
- util: fmt_util_percent(d.seg_used_bytes, d.seg_capacity_bytes),
+ util: fmt_util_percent(d.used_bytes, d.capacity_bytes),
});
}
}
owner_rows.sort_by(|a, b| a.owner_id.cmp(&b.owner_id));
device_rows.sort_by(|a, b| match a.owner_id.cmp(&b.owner_id) {
- std::cmp::Ordering::Equal => a.device.cmp(&b.device),
+ std::cmp::Ordering::Equal => match a.resource_kind.cmp(&b.resource_kind) {
+ std::cmp::Ordering::Equal => a.device.cmp(&b.device),
+ o => o,
+ },
o => o,
});
@@ -2092,3 +2153,152 @@ pub fn build_owner_segment_tables(snapshot: &ClusterSnapshot) -> OwnerSegmentTab
device_rows,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn owner_member(member_id: &str) -> MemberSnapshot {
+ MemberSnapshot {
+ member_id: member_id.to_string(),
+ role: MemberRole::OwnerClient,
+ node_start_time: 0,
+ is_side_transfer_worker: false,
+ hostname: None,
+ accessible_ip: None,
+ shared_mem_dir: None,
+ p2p_listen_port: None,
+ is_p2p_relay: false,
+ rdma_runtime_reported: false,
+ rdma_probe_error: None,
+ rdma_devices: Vec::new(),
+ rdma_ports: Vec::new(),
+ rdma_transfer_engine: None,
+ process_pid: None,
+ process_cmd: None,
+ sub_cluster: None,
+ product_uuid: None,
+ node_cpu_usage_percent: None,
+ node_cpu_logical_cores: None,
+ node_memory_usage_bytes: None,
+ node_memory_total_bytes: None,
+ container_memory_usage_bytes: None,
+ container_memory_limit_bytes: None,
+ process_resident_memory_bytes: None,
+ process_cpu_usage_percent: None,
+ tokio_num_workers: None,
+ tokio_alive_tasks: None,
+ tokio_global_queue_depth: None,
+ tokio_busy_percent: None,
+ tokio_max_worker_busy_percent: None,
+ tokio_park_unpark_rate_hz: None,
+ process_net_tx_mbps: None,
+ process_net_rx_mbps: None,
+ kv_put_rps: None,
+ kv_get_rps: None,
+ kv_put_bps: None,
+ kv_get_bps: None,
+ kv_put_latency_mean_us: None,
+ kv_put_latency_p95_us: None,
+ kv_put_latency_p99_us: None,
+ kv_get_latency_mean_us: None,
+ kv_get_latency_p95_us: None,
+ kv_get_latency_p99_us: None,
+ seg_capacity_bytes: None,
+ seg_used_bytes: None,
+ fs_read_rps: None,
+ fs_write_rps: None,
+ }
+ }
+
+ fn minimal_kv_snapshot(node: NodeSnapshot) -> ClusterSnapshot {
+ ClusterSnapshot {
+ cluster_name: "test".to_string(),
+ member_kind: MemberKind::Kv,
+ etcd_endpoints: Vec::new(),
+ prometheus_base_url: "http://127.0.0.1:9090".to_string(),
+ warnings: Vec::new(),
+ visible_member_roles: None,
+ master_id: None,
+ master_network: None,
+ transfer_engine_edges: Vec::new(),
+ kv_peer_network: Vec::new(),
+ rdma_netdev_network: Vec::new(),
+ fs_mount_fs: Vec::new(),
+ shm_files: Vec::new(),
+ fs_export_registry: Vec::new(),
+ fs_mount_registry: Vec::new(),
+ kv_topology_owner_external_max: Vec::new(),
+ kv_topology_machine_external_max: Vec::new(),
+ kv_topology_sub_cluster_owner_owner_max: Vec::new(),
+ nodes: vec![node],
+ mq: None,
+ total_put_rps: None,
+ total_get_rps: None,
+ total_put_bps: None,
+ total_get_bps: None,
+ total_put_latency_mean_us: None,
+ total_put_latency_p95_us: None,
+ total_put_latency_p99_us: None,
+ total_get_latency_mean_us: None,
+ total_get_latency_p95_us: None,
+ total_get_latency_p99_us: None,
+ }
+ }
+
+ #[test]
+ fn owner_storage_tables_include_memory_segment_and_kv_ssd() {
+ let snapshot = minimal_kv_snapshot(NodeSnapshot {
+ node_key: "node-a".to_string(),
+ hostname: None,
+ accessible_ip: None,
+ shared_mem_dir: None,
+ is_p2p_relay: false,
+ node_cpu_usage_percent: None,
+ node_cpu_logical_cores: None,
+ node_memory_usage_bytes: None,
+ node_memory_total_bytes: None,
+ container_memory_usage_bytes: None,
+ container_memory_limit_bytes: None,
+ members: vec![owner_member("owner-a")],
+ segment_devices: Vec::new(),
+ storage_devices: vec![
+ StorageDeviceSnapshot {
+ resource_kind: "memory_segment".to_string(),
+ device: "seg0".to_string(),
+ capacity_bytes: Some(100.0),
+ used_bytes: Some(40.0),
+ },
+ StorageDeviceSnapshot {
+ resource_kind: "kv_ssd".to_string(),
+ device: "dev:1:/ssd".to_string(),
+ capacity_bytes: Some(900.0),
+ used_bytes: Some(60.0),
+ },
+ ],
+ });
+
+ let tables = build_owner_segment_tables(&snapshot);
+
+ assert_eq!(tables.owner_rows.len(), 1);
+ assert_eq!(tables.owner_rows[0].owner_id, "owner-a");
+ assert_eq!(tables.owner_rows[0].total_used, "100B");
+ assert_eq!(tables.owner_rows[0].total_cap, "1.0KB");
+ assert_eq!(tables.owner_rows[0].total_util, "10.0%");
+
+ let kinds = tables
+ .device_rows
+ .iter()
+ .map(|row| (row.resource_kind.as_str(), row.device.as_str()))
+ .collect::>();
+ assert_eq!(
+ kinds,
+ vec![("kv_ssd", "dev:1:/ssd"), ("memory_segment", "seg0")]
+ );
+
+ let rows = build_member_table_rows(&snapshot);
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0].ssd_used_text, "60B");
+ assert_eq!(rows[0].ssd_cap_text, "900B");
+ }
+}
diff --git a/fluxon_rs/fluxon_cli/src/prom.rs b/fluxon_rs/fluxon_cli/src/prom.rs
index 4406c50..c4d5d1c 100644
--- a/fluxon_rs/fluxon_cli/src/prom.rs
+++ b/fluxon_rs/fluxon_cli/src/prom.rs
@@ -280,6 +280,8 @@ pub struct PromSnapshotMaps {
pub seg_capacity_bytes_by_node_device: HashMap<(String, String), f64>,
pub seg_used_bytes_by_node_device: HashMap<(String, String), f64>,
+ pub kv_ssd_capacity_bytes_by_node_device: HashMap<(String, String), f64>,
+ pub kv_ssd_used_bytes_by_node_device: HashMap<(String, String), f64>,
}
impl PromSnapshotMaps {
@@ -323,6 +325,8 @@ impl PromSnapshotMaps {
get_latency_p99_us: HashMap::new(),
seg_capacity_bytes_by_node_device: HashMap::new(),
seg_used_bytes_by_node_device: HashMap::new(),
+ kv_ssd_capacity_bytes_by_node_device: HashMap::new(),
+ kv_ssd_used_bytes_by_node_device: HashMap::new(),
}
}
}
@@ -1405,5 +1409,24 @@ pub async fn collect_prom_snapshot(
.await,
);
+ out.kv_ssd_capacity_bytes_by_node_device = take_node_device_metric(
+ &q(
+ prom,
+ warnings,
+ "kv_ssd_capacity_bytes_by_node_device",
+ fluxon_observability::keys::PROM_METRIC_KV_SSD_CAPACITY_BYTES,
+ )
+ .await,
+ );
+ out.kv_ssd_used_bytes_by_node_device = take_node_device_metric(
+ &q(
+ prom,
+ warnings,
+ "kv_ssd_used_bytes_by_node_device",
+ fluxon_observability::keys::PROM_METRIC_KV_SSD_USED_BYTES,
+ )
+ .await,
+ );
+
out
}
diff --git a/fluxon_rs/fluxon_cli/src/server.rs b/fluxon_rs/fluxon_cli/src/server.rs
index ed6baec..026b186 100644
--- a/fluxon_rs/fluxon_cli/src/server.rs
+++ b/fluxon_rs/fluxon_cli/src/server.rs
@@ -24,6 +24,7 @@ use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::{RwLock, watch};
+use fluxon_util::etcd::{EtcdEndpointSet, ManagedEtcdClient};
use fluxon_util::{
FluxonCliProxyDescriptorV2, FluxonCliProxyTransportV2, fluxon_cli_proxy_desc_etcd_key_v2,
fluxon_cli_proxy_desc_etcd_service_prefix_v2,
@@ -358,6 +359,7 @@ fn parse_member_kind(s: &str) -> Option {
#[derive(Clone)]
struct AppState {
cfg: Arc,
+ etcd_backend: ManagedEtcdClient,
log_schema_cache: Arc,
proxy_client: hyper::Client, Body>,
registered_panel_proxy_backend: Option,
@@ -1161,7 +1163,7 @@ async fn index(State(st): State>, req: Request) -> Response
}
async fn landing(State(st): State>) -> Response {
- let mut etcd = match EtcdClient::connect(st.cfg.etcd_endpoints.clone(), None).await {
+ let mut etcd = match st.etcd_backend.client().await {
Ok(c) => c,
Err(e) => {
return text_response(
@@ -1184,7 +1186,7 @@ async fn landing(State(st): State>) -> Response {
}
async fn api_clusters(State(st): State>) -> Response {
- let mut etcd = match EtcdClient::connect(st.cfg.etcd_endpoints.clone(), None).await {
+ let mut etcd = match st.etcd_backend.client().await {
Ok(c) => c,
Err(e) => {
return text_response(
@@ -1329,7 +1331,7 @@ async fn topology_page(
}
let prefix = format!("/fluxon_fs_mount_registry/{}/mounts/", cluster_name);
- match EtcdClient::connect(st.cfg.etcd_endpoints.clone(), None).await {
+ match st.etcd_backend.client().await {
Ok(mut etcd) => {
let mut mounts: Vec =
Vec::new();
@@ -1416,7 +1418,7 @@ async fn topology_page(
}
let key = format!("/fluxon_fs_export_registry/{}/snapshot", cluster_name);
- match EtcdClient::connect(st.cfg.etcd_endpoints.clone(), None).await {
+ match st.etcd_backend.client().await {
Ok(mut etcd) => match etcd.get(key.clone(), None).await {
Ok(resp) => {
let mut exports: Vec =
@@ -1540,7 +1542,7 @@ async fn ops_fluxon_kv_update_rdma_devices(
}
};
- let mut etcd = match EtcdClient::connect(st.cfg.etcd_endpoints.clone(), None).await {
+ let mut etcd = match st.etcd_backend.client().await {
Ok(client) => client,
Err(err) => {
return text_response(
@@ -1767,7 +1769,7 @@ async fn cli(
Query(q): Query,
) -> Response {
let Some(cluster_name) = q.cluster_name.as_ref() else {
- let mut etcd = match EtcdClient::connect(st.cfg.etcd_endpoints.clone(), None).await {
+ let mut etcd = match st.etcd_backend.client().await {
Ok(c) => c,
Err(e) => {
return text_response(
@@ -2917,7 +2919,7 @@ async fn proxy_registered_service_impl(
.map(|s| s.to_string())
.unwrap_or_else(|| "".to_string());
- let mut etcd = match EtcdClient::connect(st.cfg.etcd_endpoints.clone(), None).await {
+ let mut etcd = match st.etcd_backend.client().await {
Ok(c) => c,
Err(e) => {
return text_response(
@@ -3353,11 +3355,14 @@ pub async fn serve_http_from_tcp(
listener: std::net::TcpListener,
registered_panel_proxy_backend: Option,
) -> anyhow::Result<()> {
+ let etcd_backend =
+ ManagedEtcdClient::acquire(EtcdEndpointSet::new(cfg.etcd_endpoints.clone())?);
let cfg = Arc::new(cfg);
let log_schema_cache = Arc::new(LogSchemaCache::new());
let proxy_client = new_proxy_client();
let app = build_router(Arc::new(AppState {
cfg,
+ etcd_backend,
log_schema_cache,
proxy_client,
registered_panel_proxy_backend,
@@ -3379,6 +3384,8 @@ pub async fn serve_http_with_shutdown_from_tcp(
where
F: std::future::Future