Skip to content

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
devfrom
harry/walm-restore-empty-page
Open

harrymove-ctrl wants to merge 5 commits into
devfrom
harry/walm-restore-empty-page

Conversation

@harrymove-ctrl

@harrymove-ctrl harrymove-ctrl commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

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_restore told an agent to loop on an empty namespace

Observed live:

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, and the advice next to total=0 reads as a contradiction.

Two things produce it and only the second is wrong:

  • truncated is honest. restore_is_truncated (routes/admin.rs) ORs in source_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 below 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. 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_remember on 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_upload had zero observe_external calls while the same file has thirteen elsewhere. The walrus_upload histogram covers only the legacy route, and a tracked job always takes the durable one (remember_job_id is always Some), so it never fires for a real write.

Worse, two numbers that look like write cost are not:

metric what it actually is
walrus_query_blob_storage_leases 10.94s the periodic expiry sweep, not the write path
sui_grpc/GetObject 0.31s delegate-key auth, before the 202

Everything in /metrics accounts 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:

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. 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:

hop cost
encode 2.8s
register_prepare 4.1s
register_execute 5.9s
upload (Walrus relay) 6.9s
certify 5.2s
metadata/transfer 8.5s

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 certified and done is the transfer plus the vector insert. Only walrus_set_metadata_batch (~3s) was measured; the rest could only be recovered by diffing log timestamps. insert_vector now reports through observe_db, adding a dimension to the existing memwal_db_query_duration_seconds family rather than a new one.

The optimisation that looked cheapest, and why it is not in this PR

The job reaches certified at ~27s of a ~36s write and the blob is durable on Walrus there. So "return the tool call at certified, let metadata/transfer finish in the background" looks like ~8.5s, around 23%, for almost nothing.

It is not. insert_vector_and_mark_remember_done does both things its name says, in order:

insert_vector(...).await          // indexes the memory
UPDATE remember_jobs SET status = 'done' ...

done means findable, not merely stored. Until that insert commits, the blob is on Walrus and paid for but memwal_recall cannot see it. Returning early would make memwal_remember answer "Saved" for a memory the very next memwal_recall misses — 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 encode hop looks redundant (the next call re-encodes and its response already carries resumeStep: 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_remember will 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 sandbox PermissionDenied in tests that bind local sockets — unchanged, and none in a file this PR touches.

Sidecar suite → 81 passed, restore.test.ts 12 of them (4 new). tsc --noEmit clean.

…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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants