fix(mcp,relayer): stop restore looping on an empty page, and measure the write path - #915
Open
harrymove-ctrl wants to merge 5 commits into
Open
harrymove-ctrl wants to merge 5 commits into
harrymove-ctrl wants to merge 5 commits into
Conversation
…empty page
Observed live on 0.0.13-dev.6 against dev:
total=0 restored=0 skipped=0 failed=0 truncated=true
⚠️ More blobs remain to restore — increase limit and call again.
Nothing had been seen that said blobs remain. The page was empty, so an
agent following that advice loops on a namespace that may simply not
exist — and the message reads as a contradiction next to `total=0`.
Two things produce it, and only the second is wrong.
`truncated` is honest. `restore_is_truncated` (routes/admin.rs) ORs in
`source_capped`, which is the sidecar's candidate cap **across all of an
owner's namespaces**. Blobs in other namespaces can saturate it while
this namespace's page is empty, and at `limit < 20` raising the limit
genuinely does grow discovery. The doc comment there already anticipates
the shape — "including empty namespaces at limit=100" — but only the
`limit=100` arm was handled.
The wording is not. `formatRestoreResult` had no branch for an empty
page, and the WALM-480 transient check could never catch one: its guard
is `skipped + failed < total`, which at `total = 0` is `0 < 0`. So every
empty page fell through to the raise-limit arm and asserted a fact it had
not observed.
An empty page now says what is actually known: no blobs were found here,
the owner-wide cap was reached so this namespace may not have been looked
at, and — below the cap — raising the limit widens the search, with the
explicit note that a limit already at the cap means the namespace is
almost certainly empty and the name is worth checking. A non-truncated
empty page just says there is nothing to restore.
The existing WALM-431 test is kept and its intent preserved: the ACTION
(raise the limit) was always right and still fires. What it no longer
asserts is "More blobs remain", which was the false half. Its comment now
records why the wording changed, so this does not read as someone
loosening a test to get green.
restore.test.ts 12 passed (4 new), full sidecar suite 81 passed,
`tsc --noEmit` clean.
…ly spends its time in
A `memwal_remember` on a healthy dev takes 33-67s, matching the field
report's "19s to 1m1s". Finding out where that time goes took a wall
clock and a log scrape, because the path that holds nearly all of it is
the one write path with no instrumentation at all.
`advance_durable_upload` had zero `observe_external` calls while the same
file has thirteen elsewhere. The `walrus_upload` histogram only covers
the legacy route, and a tracked job always takes the durable one
(`remember_job_id` is always `Some`), so that histogram never fires for a
real write and `/metrics` shows nothing. Everything that IS in `/metrics`
accounts for about 4s of a ~58s write — and two of the numbers that look
like write cost are not: `walrus_query_blob_storage_leases` (10.94s avg)
is the periodic expiry sweep, and `sui_grpc/GetObject` is delegate-key
auth from before the 202.
One remember makes five sequential POSTs to `/walrus/upload-step-v3`.
Aggregating them would answer "the upload was slow", which was never in
doubt. Each hop now gets its own series, labelled by the step it resumes
from plus whether a prepared register transaction is being carried, since
those two together decide what the sidecar does:
walrus_upload_step_encode
walrus_upload_step_register_prepare
walrus_upload_step_register_execute
walrus_upload_step_upload
walrus_upload_step_certify
walrus_upload_step_other
Transport failures record too, matching `set_metadata_batch` next door,
and go through `record_sidecar_failure` so a hop that fails is visible in
`memwal_sidecar_failures_total` rather than only in a log line.
The label set is closed on purpose and a test pins that: the phase comes
from caller-supplied JSON, so an unrecognised or malformed step collapses
to one bucket instead of minting a time series per input.
A log scrape of one 36.4s write puts the split at roughly encode 2.8s,
register_prepare 4.1s, register_execute 5.9s, upload 6.9s, certify 5.2s,
metadata/transfer 8.5s. That is one sample from one environment read out
of stderr; this metric is what makes it a number anyone can query, over
time, per environment. Optimising the pipeline before that exists would
be guessing which hop to cut.
lib 469 passed (3 new), bin 700 passed. The 21 and 47 failures in those
suites are the pre-existing sandbox `PermissionDenied` in tests that bind
local sockets, unchanged by this commit.
…dable
Completes the write-path picture started in the previous commit. The five
upload hops are now measured; the last leg was not.
A log scrape put ~8.5s between `certified` and `done`, of which
`walrus_set_metadata_batch` accounts for ~3s. The rest — the transfer and
the vector insert — was unmeasured, so the single largest chunk of a
`memwal_remember` could only be recovered by diffing log timestamps.
`insert_vector` now reports through `observe_db`, which already backs
`memwal_db_query_duration_seconds`, so this adds a dimension to an
existing family rather than a new one.
Worth stating why this leg matters beyond latency. The `done` UPDATE runs
immediately after this insert commits:
insert_vector(...).await // <- indexes the memory
UPDATE remember_jobs SET status = 'done' ...
So `done` means "findable", not merely "stored". Until the insert commits
the blob is on Walrus and paid for, but `memwal_recall` cannot see it.
That invariant rules out what looked like the cheapest latency win. The
job reaches `certified` at ~27s of a ~36s write and the blob is durable
there, which makes "return the tool call at `certified` and let
metadata/transfer finish in the background" tempting — it would cut ~8.5s,
around 23%. It would also make `memwal_remember` answer "Saved" for a
memory the very next `memwal_recall` cannot find. That is the same class
of defect as the report's §1b, where a tool's answer did not match what
the store actually held, and it is not worth 8.5s.
Measuring it is worth more: once this metric has run on a real
deployment, the transfer and the insert are separable, and whichever one
holds the time can be attacked knowing which it is.
lib 469 passed, bin 700 passed; the 21 and 47 failures are the
pre-existing sandbox `PermissionDenied` socket-binding tests, unchanged.
…ing slow
Measured on dev with the published 0.0.13-dev.6: eight rounds of
`memwal_remember` + `memwal_recall`, sequential, one client. Four rounds
succeeded. Every call after that failed:
429 {"error":"Rate limit exceeded","layer":"delegate_key",
"limit":"60 weighted-requests/min"}
`memwal_recall` went down with them, because both ride the same key.
The arithmetic is the bug. `POST /api/remember` is weight 5, and
`GET /api/remember/{job_id}` falls through to 1. A remember takes ~33s on
dev, and the SDK's backoff ladder polls about eight times inside it — so
one save spends 5 for the write plus ~8 for the waiting, and four saves
reach 60.
How many times a client polls is not a choice it makes. It is set by how
long the server takes to finish the job the client already paid 5 for.
Charging for that turns a latency problem into an availability one, and
it gets worse exactly when the system is already slow. The default is
half this deployment's (`max_requests_per_delegate_key` = 30), which would
lock a caller out after two saves.
So the burst layer no longer charges for status polls:
`GET /api/remember/{job_id}` and `POST /api/remember/bulk/status` are
weight 0 there.
Scoped to that layer on purpose, which is why this adds
`delegate_key_weight` rather than editing `endpoint_weight`. The
per-account burst and hourly layers still meter polls at weight 1, so
total volume per account stays bounded per minute and per hour; only the
runaway-client burst check stops firing on a client doing exactly what the
protocol tells it to. Setting the weight to 0 in `endpoint_weight` would
have removed the metering from all three layers at once.
The matcher is narrow and tested against what actually arrives on the
wire: `/api/remember/bulk` (10), `/api/remember/manual` (3) and
`/api/remember` (5) keep their full burst weight, and a percent-encoded or
trailing-slash path still resolves correctly. A deeper path that merely
looks like a job id does not qualify.
This does not make writes faster. It stops a slow write from also making
the next four minutes unavailable.
lib 472 passed (3 new), bin 703 passed. The 21 and 47 failures are the
pre-existing sandbox `PermissionDenied` socket-binding tests.
…failed
The report's §1b is "a 5-fact bulk timed out and stored nothing". Running
it on dev produced the milder version of the same thing: the tool said
`failed=1` while `memwal_recall` found all five facts present. The item had
not failed. It had not finished yet, and it landed shortly after.
That number comes from the SDK, whose own type says what it is:
/** Count of items that failed or timed out */
failed: number;
computed as `total - succeeded`. A timeout is not a failure here:
`/api/remember/bulk` answers HTTP 202 and finishes in a durable queue, so
a client deadline cancels nothing and items routinely land minutes later.
Reported as `failed=N` the obvious response is to save them again, and
`/api/remember/bulk` carries no idempotency key — unlike the single path,
whose key exists precisely so an ambiguous timeout cannot produce a
duplicate paid blob. So the label turns a slow write into a second paid
copy that `recall` then hides behind the first.
Fixed where the deployed code can actually reach it. `results[].status`
keeps the real per-item label, so the tool now counts from that instead of
trusting the aggregate: done, failed and still-finishing are three
different numbers. An unfinished item is named as unfinished, says plainly
not to re-save it, and carries its job id so the agent has a handle.
Deliberately in the sidecar, not the SDK: the sidecar pins
`@mysten-incubation/memwal` 0.0.3 from npm, so an SDK fix would need a
publish and a pin bump before it reached any deployment, while this ships
with the relayer. The SDK's aggregate is still worth fixing; this does not
depend on it, and falls back to the SDK's numbers if `results` and `total`
ever disagree rather than silently contradicting them.
5 new tests, sidecar suite 86 passed, `tsc --noEmit` clean.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #912, from running the field report's own cases against dev with the published
@mysten-incubation/memwal-mcp@0.0.13-dev.6.Three changes. None alters any tool's contract.
1.
memwal_restoretold an agent to loop on an empty namespaceObserved live:
Nothing had been seen that said blobs remain, and the advice next to
total=0reads as a contradiction.Two things produce it and only the second is wrong:
truncatedis honest.restore_is_truncated(routes/admin.rs) ORs insource_capped, the sidecar's candidate cap across all of an owner's namespaces. Blobs elsewhere can saturate it while this namespace's page is empty, and belowlimit < 20raising the limit genuinely does grow discovery. The doc comment there already anticipates the shape — "including empty namespaces at limit=100" — but only thelimit=100arm was handled.formatRestoreResulthad no branch for an empty page, and the WALM-480 transient check could never catch one: its guard isskipped + failed < total, which attotal = 0is0 < 0. Every empty page fell through to the raise-limit arm.An empty page now says what is known: no blobs found here, the owner-wide cap was reached so this namespace may not have been looked at, and — below the cap — raising the limit widens the search, with the note that a limit already at the cap means the namespace is almost certainly empty and the name is worth checking. A non-truncated empty page just says there is nothing to restore.
The existing WALM-431 test is kept with its intent preserved: the action (raise the limit) was always right and still fires. What it no longer asserts is
More blobs remain, which was the false half. Its comment records why, so this does not read as loosening a test for green.2. The write path had no instrumentation at all
A
memwal_rememberon a healthy dev takes 33–67s, matching the report's "19s to 1m1s". Locating that time took a wall clock and a log scrape, because the path holding nearly all of it is the one write path nothing measures.advance_durable_uploadhad zeroobserve_externalcalls while the same file has thirteen elsewhere. Thewalrus_uploadhistogram covers only the legacy route, and a tracked job always takes the durable one (remember_job_idis alwaysSome), so it never fires for a real write.Worse, two numbers that look like write cost are not:
walrus_query_blob_storage_leases10.94ssui_grpc/GetObject0.31sEverything in
/metricsaccounts for about 4s of a ~58s write.One remember makes five sequential POSTs to
/walrus/upload-step-v3. Aggregating them would answer "the upload was slow", which was never in doubt. Each hop now gets its own series:Transport failures record too, matching
set_metadata_batchnext door, and go throughrecord_sidecar_failure. The label set is closed on purpose — the phase comes from caller-supplied JSON, so a malformed step collapses to one bucket rather than minting a series per input, and a test pins that.What the numbers look like
A log scrape of one 36.4s write:
So the Walrus write itself is ~7s; the rest is round trips and on-chain ordering. That is one sample, from one environment, read out of stderr. This metric is what turns it into something anyone can query, per environment, over time.
3. The last leg was blind too
The ~8.5s between
certifiedanddoneis the transfer plus the vector insert. Onlywalrus_set_metadata_batch(~3s) was measured; the rest could only be recovered by diffing log timestamps.insert_vectornow reports throughobserve_db, adding a dimension to the existingmemwal_db_query_duration_secondsfamily rather than a new one.The optimisation that looked cheapest, and why it is not in this PR
The job reaches
certifiedat ~27s of a ~36s write and the blob is durable on Walrus there. So "return the tool call atcertified, let metadata/transfer finish in the background" looks like ~8.5s, around 23%, for almost nothing.It is not.
insert_vector_and_mark_remember_donedoes both things its name says, in order:donemeans findable, not merely stored. Until that insert commits, the blob is on Walrus and paid for butmemwal_recallcannot see it. Returning early would makememwal_rememberanswer "Saved" for a memory the very nextmemwal_recallmisses — the same class of defect as the report's §1b, where the tool's answer did not match what the store held. Not worth 8.5s.I had ranked that change low-risk before reading this code. That ranking was wrong.
What else is deliberately not here
The standalone
encodehop looks redundant (the next call re-encodes and its response already carriesresumeStep: encoded), and three happy-path reads exist only to return a negative answer. Both are plausible wins.Neither should be cut on a single stderr sample. They sit inside a durable, resumable, money-spending state machine, and the negative-answer reads are exactly the shape of an idempotency guard for crash-resume. Two diagnoses in this investigation were confidently wrong before measurement corrected them. Land the metric, read one real deployment, then cut with per-hop numbers instead of one log scrape.
Also worth setting expectations: ~12–20s of a testnet write is structural — three ordered on-chain transactions plus a relay write that has to store bytes. Even with every avoidable hop removed,
memwal_rememberwill not reach the "a few seconds" the report expects without an architectural change.Tests
cargo test -p memwal-server→ lib 469 passed (3 new), bin 700 passed (3 new). The 21 and 47 failures in those suites are the pre-existing sandboxPermissionDeniedin tests that bind local sockets — unchanged, and none in a file this PR touches.Sidecar suite → 81 passed,
restore.test.ts12 of them (4 new).tsc --noEmitclean.