mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #4489] [RFC]: Object identity is unpinned between transfer submission and payload issue; wrong-object bytes can reach the wire despite 4/4 lease checks #1139
Labels
No labels
ascend
auto-closed
bug
bug
CI/Build
Common
documentation
enhancement
feature request
good first issue
help wanted
high priority
MetaX
nightly-failure
pull-request
PyTorch Backend
question
RFC
Roadmap
stale
Store
Tests
Transfer Engine
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/Mooncake#1139
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @binxuwang2002-lang on GitHub (Oct 7, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4489
Changes proposed
Summary
A transfer does deliver the bytes of an object other than the one named in its
descriptor into the requester's registered buffer, and reports the read as a
failure while doing so. A regression test attached below reproduces it
deterministically. The root cause is that object
identity is captured when the descriptor is created and consumed when the payload
is issued, with nothing holding it in force across the gap. The post-transfer
lease check protects the consumer from being told the read succeeded; it cannot
protect the wire, because the check runs after the bytes have already been
written into the destination buffer and discarding a result does not erase that
buffer. This issue asks whether a transfer-scoped hold on object identity belongs
in Mooncake Store, and if so, at which layer.
Setup and scope
Snapshot:
c3fa13ecaf1038df933dcb784ff8ffe026381100(2026-09-30T02:40:11Z). The tree we worked from is a plain source snapshot
without
.git, so the anchor was established by per-file git blob SHA-1 againstthe GitHub Contents API rather than by a git ref. Two files under test
(
mooncake-store/src/client_service.cpp,mooncake-store/src/master_service.cpp)match that commit exactly. The rest of the tree was not verified, so treat this
as a pin on those two files rather than on the repository state.
mainhasadvanced slightly since (
client_service.cppis 6 lines longer as of 2026-10-04).All
IsLeaseExpiredcounts and line numbers below are from the pinned snapshot.Configuration: memory replicas, the transfer engine, single node with two
clients. The trust model is a shared pool with multiple tenants, so isolation
between tenants is in scope. The repro below runs to completion on this snapshot;
its build and test logs are in the attached verification record.
Observation A: deterministic repro
The repro is
0002-repro-test.patch, a regression test. Sequence:X(single replica, known byte pattern) into a segment that hasbeen filled so that exactly one object-sized gap remains.
BatchQueryobtainsX'sQueryResultwhile its lease is live.Xand put objectYso thatYreuses the same slot.
Ycarries a different byte pattern.BatchGetusing theQueryResultfrom step 2.Two things hold in the same run, and that is the point:
(
LEASE_EXPIRED). The post-transfer lease check does its job.Y's bytes and does notcontain
X's. The bytes of a different object were delivered.Assertion A is the property the existing post-transfer check provides. Assertion
B is the property no check at this layer can provide after the fact, because the
check inspects the result and discarding a result does not undo what was written
into the destination buffer.
The reuse this test produces is the same shape as the one the natural path
produces. In production the lease deadline passes, the ordinary eviction path
reclaims the slot, and the next Put lands on it. The test only removes the timing
dependence: it pins the placement to the segment the descriptor names, it calls
Remove(key, force)to reclaim without waiting out the clock, and it asserts as aprecondition that the replacement object really did land on the vacated slot,
endpoint and offset, before issuing the transfer. A run in which that
precondition fails never exercises the reuse and is not counted. The test passes
in isolation and inside the full
client_integration_testsuite, so the resultdoes not depend on run order.
What #3996 already covers, and what it does not
PR #3996 ("Check read leases after grouped batch gets", merged 2026-09-11) added
the post-transfer lease check to
Client::BatchGetWhenPreferSameNode, which isthe fourth and last read path that was missing one. That was a real gap and it is
now closed: on the pinned snapshot
IsLeaseExpiredappears on all four readpaths, two in
client_service.cppat 1436 and 1481 (whole-object and rangedgets) and two at 1650 and 1902 (grouped batch gets and
BatchGet).Two properties are worth separating, because they are not the same property:
the transfer is reported
LEASE_EXPIRED. This is covered. The 2026 leasefixes and #3996 together cover all four read paths.
requester's registered buffer are the bytes of the object the descriptor
names. This is not covered, and it cannot be covered by a check placed after
the transfer, because the check inspects the result and discarding a result
does not undo what was written into the buffer.
#3996's own regression test shows how far the reporting layer reaches, and it is
worth being precise about what it does and does not establish.
BatchGetChecksLeasesWithPreferredNodeputs three keys that all carry the samebyte pattern, hands
BatchGetaQueryResultwhose deadline was set in the pastfor one of them, and asserts
EXPECT_EQ(memcmp(expired_dst, value.data(), value.size()), 0)— the destinationbuffer for that key holds
value, which is the object's own content. There is noeviction in that test and no other object takes the slot; the object is still
there. So it establishes that an expired lease does not stop the transfer from
completing and writing into the destination buffer. It does not establish that
the bytes delivered belong to a different object, because in that test they do
not.
The step from "the transfer completes anyway" to "the bytes belong to someone
else" needs a reuse to happen inside the window, and that is what the regression
test attached below adds. On the pinned snapshot that step is reachable: with the
slot reused while the descriptor is queued, the destination buffer receives the
replacement object's bytes. So the two tests are complementary. #3996 covers the
first step and does it well; the attached test covers the second.
The attached test also states both facts in one run: the read is reported as
a failure, so the reporting layer works, and the destination buffer holds another
object's bytes, so the reporting layer is not what protects the wire. Our own
test comment is explicit that this is the increment, not a re-statement of
#3996's.
On the pinned snapshot there are zero transfer-scoped holds in
client_service.cppandmaster_service.cpp, and zero validations at payloadissue. Admission grants a lease; completion checks a lease; the moment the payload
is issued is unguarded.
Observation B: workload statistics
Three runs, two snapshots. B.1 and B.2 come from different snapshots, and B.3 adds
a third run, on current main. Each figure carries its own denominator
and convention. They are not comparable to each other and no ratio between
them should be read as an effect size: one is per observed eviction, one is per
completed transfer, one is per guard firing, one is per successful delivery under
a deliberately disabled guard. Each row stands alone.
B.1 Natural load, 2026-07 snapshot
Snapshot
f20b7061097e4e2fda825f4106f215c71f13274a. Harness described in0003-measurement-bypass-EXPERIMENTAL.patch. The guard is the shipped one.LEASE_EXPIREDThe six delivered events are reported as a count because no delivery denominator
survived for that run. They establish that conversion happens; they do not
establish how often.
B.2 Constructed burst, current main, guard shipped
Snapshot
c3fa13ecaf1038df933dcb784ff8ffe026381100. 30 minutes, BurstGPT replay,16 tenants, 32x oversubscription, tc 800mbit on loopback, and a probe injecting
2,000 ms between the descriptor capture and the transfer so the window opens on
every request. 18,262 reads.
Two quantities, both measured per guard firing, and both from the same run:
LEASE_EXPIRED, counted in the probe processkey_hash(requested key), generation in the reseed-allowed pairPer-event identity was checked against the requested key for all 791 firings, not
sampled. Three controls accompany that zero: the comparison reuses the harness's
own
key_hashrather than a second implementation, so the two cannot driftapart; 716 requests that returned not-found carried no payload at all, which
rules out "the buffer always has something in it"; and all 791 fired requests
name the requesting tenant's own objects.
So on current main, with the shipped guard, the payload lands before the check
791 times, and in every one of those cases what landed was the requested
object's own bytes at a reseeded generation. This is the same behaviour
BatchGetChecksLeasesWithPreferredNodeasserts, repeated 791 times under load.B.3 Conversion rate on current main: bounded, not observed
The current-main conversion rate is bounded and not observed, not zero and
not measured:
3/791 = 3.79 per 1000 firings
current-main load number: the six delivered events in B.1, which are from the
2026-07 snapshot and labelled as such, and the deterministic repro in
Observation A, which establishes current existence but says nothing about rate
That arm has been run once before, on the 2026-07 snapshot: 4 of 65 successful
deliveries carried the wrong object, Wilson 95% [24.19, 147.82] per 1000
deliveries. That figure is a snapshot figure and is not a current-main rate, but
it establishes that the arm measures a non-zero conversion when one exists, so a
zero on current main would be informative rather than merely absent.
On current main the bypass arm has now been run once (30 min, same
configuration as the 2026-07 arm):
B.4 Interpreting the three together
What the three runs jointly support is narrow and is worth stating narrowly. The
window opens under natural load on the 2026-07 snapshot at 6.08 per 1000
completions, and it opens on every request under the constructed burst on current
main. Payload lands before the check runs, on current main, 791 times out of 791
firings. Whether what lands is ever a different object is bounded at 3.79 per
1000 firings on current main and is directly observed only on the 2026-07
snapshot, where it happened six times. The consumer-side property, that a read
whose lease expired is not reported as a success, is not in question and is not
what this issue is about.
Root cause: the window
Two observations about the current state. Admission is guarded (lease granted).
Completion is guarded (post-transfer check on all four read paths after the 2026
fix and #3996). Issue is not guarded at all, and the eviction path holds no
transfer-scoped reference to a descriptor that has been submitted but whose
payload has not been issued.
real_client.cppcarries five lease checks (4175,4588, 4703, 6421, 6494), all on the admission and metadata-construction side;
client_service.cppcarries four (1436, 1481, 1650, 1902), all after transfercompletion. None of them sits between descriptor creation and payload issue.
Design options
(a) Master-side transfer-scoped hold. Extend the lifetime of the lease, or
introduce a hold alongside it, so that it spans from descriptor submission to
transfer-completion signal rather than expiring at a wall-clock deadline. Reclaim
of a slot is refused while a hold names it. Cost: metadata growth proportional to
in-flight transfers, and a completion signal path that the eviction scheduler has
to observe.
(b) Issue-time validation. The transfer engine or the endpoint re-checks the
object generation or an epoch before the payload goes out. Cost: a check on the
fast path, where the whole point of the current design is that the fast path does
no bookkeeping.
(c) Quarantine or delayed reuse. A reclaimed slot is not handed to a new
object until an upper bound has elapsed. Cost: capacity held idle, and the bound
has to be derived from transfer-time distributions.
The two goals should be discussed separately, because the options do not cover
them equally. Consumer correctness is what the post-transfer check provides
today and what #3996 completed across all four read paths. Wire isolation —
the guarantee that the bytes placed on the wire and into the destination buffer
belong to the object named by the descriptor — requires a guard at or before the
issue point. Option (a) achieves both. Option (b) achieves wire isolation without
holding reclaim. Option (c) reduces the exposure window without closing it.
We have an instrumentation harness and are happy to run maintainer-proposed
configurations.
Questions to maintainers
correctness the contract?
other's keys, does that change whether this is in scope?
size, a fast-path check, and capacity against the width of the exposure
window.
Attachments
0001-observability-logging.patch— log-only instrumentation on the transfercompletion and eviction paths, default off, no behaviour change.
0002-repro-test.patch— the regression test from Observation A. Assertion Ais the existing post-transfer check; assertion B is the delivery of wrong-object
bytes. The schedule is forced through the public
Remove(key, force)API, whichdocuments itself as skipping the lease check, and the test asserts as a
precondition that the replacement object landed on the vacated slot before it
issues the transfer.
0003-measurement-bypass-EXPERIMENTAL.patch— a guard bypass used only tomeasure how often the window converts when nothing discards the result. The
bypass counts and quarantines; bytes are inspected by an out-of-band checker
and never reported as a successful read to the caller. Not for production,
not intended to be merged as-is.
We are not proposing a fix in this issue. If the maintainers consider wire
isolation in scope, the next step would be to agree on which of the three options
carries the acceptable cost, and we would be glad to measure whichever is
preferred.
Before submitting a new issue...
@github-actions[bot] commented on GitHub (Oct 7, 2026):
Thanks for opening this issue, @binxuwang2002-lang!
232507676A maintainer will triage this when possible. To help us respond faster, please include:
Useful links: Documentation · Contributing guide
@he-yufeng commented on GitHub (Oct 7, 2026):
Confirmed the mechanism reading against current main (the report pins
c3fa13ec; main has since movedclient_service.cppby 6 lines, and the picture is unchanged):IsLeaseExpiredchecks now sit atclient_service.cpp:1442/1487/1656/1908, the same four the report lists at 1436/1481/1650/1902. All four run after the transfer has completed and the slices are filled, i.e. they inspect the result.Remove(key, force)does skip the lease check (master_service.cpp:7397), so the repro's scheduling primitive behaves as described.Two pieces of current-main context that matter for the design discussion:
All four read paths also run
VerifyObjectChecksumpost-transfer (1424, 1627, 1835, 1882), binding the delivered bytes to theobject_checksumcaptured at admission. It is off by default (MOONCAKE_STORE_CHECKSUM,src/config/client_object_checksum_config.cpp:14). With checksums enabled, the repro's assertion A surfaces asCHECKSUM_MISMATCHbefore the lease check even runs, so an honest consumer is told the bytes are foreign. It still says nothing about what remains in the registered buffer afterwards, so the wire-isolation question stands exactly as the report frames it. The checksum just moves the honest-consumer property from timing to identity, when it is on.The eviction-side half of option (a) already exists.
hard_pinnedmetadata is honored at every reclaim point I can find (master_service.cpp:1813, 8350, 11511, 11969, 12075, 12264, 12318, 12485) and is preserved across metadata rebuilds (6114-6119). What option (a) actually needs to build is the lifecycle: take the hold at query or admission, release it on completion, and give it a TTL for the crashed-client case. That reframes the cost. Metadata growth is a counter per replica, not a list. The real costs are the completion-signal path and the semantics of the TTL firing.On that TTL, one asymmetry is worth stating: a hold deadline that expires mid-transfer fails closed (reclaim refused, capacity pressure, visible in metrics), where the current lease deadline fails open (foreign bytes delivered, silent). If the hold ends up being "a second lease with different failure semantics", that is still a strict improvement in failure mode, and it makes option (a) cheaper to reason about than it first looks.
On the options themselves: (b) needs identity at the source before the RDMA read, which means a generation in the descriptor plus a source-side metadata read on the issue path. That is exactly the bookkeeping this fast path is designed to avoid, and the checksum binding already covers the honest-consumer side of identity from the other end. (c) spends capacity to shrink a window it cannot close. There is also an unlisted (d): transfer into a scratch buffer and copy into the destination only after the post-transfer checks pass. It closes the channel completely, at the cost of an extra memcpy and giving up true zero-copy on reads. For deployments where that bandwidth trade is acceptable it is the cheapest correct answer, and it is a useful cost reference when judging (a).
On the three questions: today's shipped contract is consumer correctness (the 2026 lease fixes, #3996, and the optional checksum). Whether wire isolation is an explicit goal is a maintainer-scope call, but the repro's segment-shaping precondition is precisely the primitive a tenant would use to pull another tenant's bytes on purpose, and the report demonstrates the schedule is controllable. In a shared-pool deployment that reads as a disclosure channel rather than a correctness nuance, so my lean is in scope, at minimum as a documented trust-model boundary. @stmatengss @doujiang24 this needs your call on the trust-model question. On fast-path cost, my ordering from the mechanism side is (a) if the completion signal is acceptable, then (d) where zero-copy is not the point, then (c) as mitigation.
Happy to review a design once there is a direction. The harness work here is solid; keeping the per-run denominators separate is what makes it convincing.
@binxuwang2002-lang commented on GitHub (Oct 8, 2026):
Thanks for the independent pass over current main. The +6 drift matches our pin exactly — same four sites, same ordering.
On the checksum: agreed on the mechanism, with one boundary worth keeping explicit. All our runs use the default configuration, i.e. checksum off. When it is on, a rebound slot surfaces as CHECKSUM_MISMATCH, which moves the honest-consumer property from timing to identity — that framing is right. But the check runs after the bytes land, and it says nothing about what remains in the registered buffer. So the two claims stay separable: "the consumer is told" holds when checksums are on; "the wire stays clean" is untouched either way. Worth stating plainly because the second claim is the one a shared-pool deployment actually cares about.
On hard_pinned: agreed, and this reframes option (a) usefully. If the metadata bit is already honored at the reclaim points and survives rebuilds, what is missing is purely the lifecycle — acquire at query, release at completion, TTL for the crashed-client case. Your fail-closed/fail-open asymmetry is the right way to put it: a hold that expires mid-transfer refuses reclaim and shows up in metrics, while a lease that expires mid-transfer delivers foreign bytes silently. Even "a second lease with different failure semantics" would be a strict improvement in failure mode.
On (d): fair addition, and the strongest of the four on the wire-isolation axis. The cost is regime-dependent though: the memcpy is bandwidth-bounded, for large transfers it approaches the cost of the transfer itself, the scratch pool consumes capacity that could otherwise hold objects, and the copy sits on the read's critical path. Where zero-copy is not the selling point, it is likely the cheapest correct answer — and a useful cost reference when judging (a), as you said.
One precision on our scope, since the trust-model question is now with the owners: we demonstrated a controllable schedule and wrong-bytes delivery in a shared-pool deployment (16 tenants). We have not built an end-to-end directed exfiltration PoC, and we don't want to overstate that step. The shared pool is the deployment shape we measured.
We stay in measurement mode. If there is a configuration or regime you would find more persuasive — checksum on, different tenant counts, larger objects — we are happy to run it and report numbers under whatever framing the owners settle on for the trust-model question.