Repository navigation
fix(transport): send each RPC frame in one write and set TCP_NODELAY - #533
Conversation
Every workspace RPC opens a new connection, and both the client and the owner wrote each frame as two writes: the four-byte length prefix, then the body. Neither socket disabled Nagle's algorithm, so the body waited until the peer acknowledged the prefix, and the peer's delayed ACK held that acknowledgement for about 40 ms. One RPC paid this once per direction: find_workspaces took about 100 ms for about 3 ms of work. Both sides now build the length prefix and the payload into one buffer and send it with one write, and both set TCP_NODELAY: the client right after connecting, the owner at the start of each accepted connection. The owner sets it in the per-connection handler, not in the accept loop, so a socket-option failure closes that one connection instead of stopping the owner. The legacy-client rejection frame takes the same single-buffer path. The wire format is unchanged. Tests pin both properties: a recording writer sees exactly one write per request frame and per response frame, and the client socket and the accepted socket report TCP_NODELAY. Signed-off-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012QGJnvVtHwDgtDs9poK5yN
|
CI status:
Generated by Claude Code |
Related Issue
Closes #532
Summary
TLDR. Each workspace RPC frame now goes out in one write, and both the client and the owner set TCP_NODELAY. On loopback, a single-RPC call drops from about 100 ms to about 10 ms. The bytes on the wire are unchanged.
Problem. The client and the owner wrote every frame as two writes: first the 4-byte length, then the body. Neither socket disabled Nagle's algorithm, so the body waited until the peer acknowledged the 4-byte segment, and the peer's delayed ACK held that acknowledgement for about 40 ms. A request and its response paid this once each.
Consequences. The native CLI, the Python SDK and the Rust SDK all use this transport and open a new connection per RPC, so every call spent about 90 ms waiting idle. Operations made of several RPCs paid it several times:
publish_bytesof 64 KiB took about 540 ms.Example. One
find_workspacescall, seen from the client:Solution.
crates/nokv-client/src/transport.rs): the request frame, length plus body, is built in one buffer and sent with one write. The buffer is built before connecting, so an oversized request still fails without touching the network. TCP_NODELAY is set right after the connection opens.crates/nokv-server/src/server.rs): response frames and the legacy-client rejection frame are built in one buffer and sent with one write. TCP_NODELAY is set at the start of the per-connection handler.Gain. Median latency on the stage2a stack (full numbers under Claims and Evidence):
b8d59c4find_workspacesreadof 64 KiBpublish_bytesof 64 KiBTradeoffs.
MAX_FRAME_BYTES), so the worst case is one extra transient copy of that size per frame in flight. Metadata frames are usually a few hundred bytes.BufWriterwas not used: with its default 8 KiB buffer it sends the 4-byte length on its own whenever the body is larger than the buffer, which brings the two writes back for exactly the large frames. Vectored writes would avoid the copy, but they need a hand-written loop for partial writes becauseWrite::write_all_vectoredis not stable.Caveat for load tests (not caused by this PR). Faster RPCs raise the request rate at the owner. Every metadata write checks the shard-wide
System(commit_clock)against the read version it was planned at (WriteReadVersionMismatchincrates/nokv-meta/src/workspace/engine.rs). On a mismatch the owner makes up to 8 attempts per RPC and the SDK up to 3 RPC attempts, with no backoff between them. With 16 concurrent writers on 16 different workbenches of one shard, 20 publishes each, publishes fail withmetadata write read-version mismatch:Main already fails this way. This PR removes the ~90 ms per-RPC pause that was spacing the writers out, so they collide more often. Expect these errors in concurrent load tests until the contention is fixed separately.
Not in this PR. Most of the remaining ~10 ms of a single-RPC call is the owner's accept loop: the listener is non-blocking and the loop sleeps up to 10 ms between polls. The time from connect to the handshake reply measured p50 7.0 ms and p99 10.5 ms over 1000 samples. Every RPC opens a new connection, so every RPC pays it. Reusing connections in the client would remove it, together with the per-RPC connect and handshake. That needs its own design: an idle timeout on the owner, accounting for the owner's connection permits, safe retry when a reused connection turns out to be closed, and fork safety in the Python SDK.
Scope
Change Size And Review
Code Contract (Code Changes Only)
docs/development/code_contract.md. Onlynokv-clientandnokv-serverchange, with no new dependency. Each keeps its own private frame builder becausenokv-clientmust not depend onnokv-server.errors.rsand carry stable error kinds when crossing package boundaries. N/A: no new errors.RootId -> LogicalShardIdaffinity, one active writer per shard, owner-epoch fencing, and explicit shard-local atomicity; no split root or cross-shard transaction is implied. N/A: no placement or routing change.nokv-agent, routing and workflows innokv-client, and the concrete backend plus native full CLI wiring innokv. N/A: no Agent-interface change.nokv mcpsidecar as a supported NoKV surface. N/A: no guidance change.Claims and Evidence
Evidence level and status. Product level (Python SDK, user-visible latency). Gate 8 status: NOT QUALIFIED. This is a diagnostic before/after comparison: moto stands in for the pinned RustFS because Docker was not available, it ran on one machine, and the timing harness is an ad hoc SDK script that is not committed. It compares two commits under the same recipe and is not a headline number.
Profile. Commits
b8d59c4(main at the time) and9b1fb6e(this change on top of it), clean worktrees; each owner binary carries its commit. The branch was then rebased ontod780327asb47e97d, whichgit range-diffreports as patch-identical to9b1fb6e; the rebase was not re-measured. Rust 1.97.0, release builds ofnokvand of the Python wheel from the same commit. Linux 6.18 x86_64, 4 vCPU, 15 GiB RAM, virtual disk. Stack fromscripts/workbench/loopx_stage2a_stack.py up --object-store moto: one owner, one logical shard, one root,local_waldurability (owner default), etcd 3.7.1 (CI-pinned archive), moto S3 on loopback. Each variant ran on two fresh stacks, in the order main, PR, main, PR.Sequential workload. One client, 5 warm-up calls per operation, then 200
find_workspaces(limit=100)calls, 200readcalls of one 64 KiB artifact (the SDKreadhas no block cache, so every call fetches from the object store), and 100publish_bytescalls of 64 KiB of random bytes, each to a new path. Latency in ms; errors are failures after the SDK's own retries.find_workspacesfind_workspacesfind_workspacesfind_workspacesread64 KiBread64 KiBread64 KiBread64 KiBpublish_bytes64 KiBpublish_bytes64 KiBpublish_bytes64 KiBpublish_bytes64 KiBConcurrent workload. 16 threads, each with its own client and its own new workbench, started together, 20
publish_bytesof 64 KiB each. Latency of successful calls in ms. Every error wasmetadata write read-version mismatch.Accept-loop wait (PR stack). 1000 raw handshakes with random gaps between them, timing connect to handshake reply: p50 7.0 ms, p95 10.0 ms, p99 10.5 ms, max 13.5 ms, mean 6.3 ms.
Retries are not visible through the Python SDK, so retry counts are not reported. The raw evidence is the tables above and the strace lines in the Example; the per-sample data was not kept outside the measurement machine.
Validation
All Rust and contract checks below ran on the head of this PR,
b47e97d.cargo fmt --all -- --check: passcargo clippy --workspace --all-targets -- -D warnings: passcargo clippy -p nokv-server --all-targets --features restore-crash-test-support -- -D warnings: passcargo test --workspace: pass, 1164 tests, 0 failedcargo test -p nokv-server --features restore-crash-test-support: pass, 188 tests, 0 failedpython3 scripts/workbench/workbench_contract_test.py: pass, 8 testspytest crates/nokv-python/testsagainst a wheel built from9b1fb6e(before the rebase): pass, 31 testsgit diff --check origin/main..HEAD: passlocal_wal_recovery_gate.py,live_workbench.py,restore_composition_gate.py,object_namespace_recovery_gate.py,fork_restore_recovery_gate.py). Docker is not available in this environment; CI runs them.Contributor Sign-off
Signed-off-bytrailer.🤖 Generated with Claude Code
https://claude.ai/code/session_012QGJnvVtHwDgtDs9poK5yN
Generated by Claude Code