stored, searchable, and degraded
This is the guide integrators most need and most often skip. It is about one fact: a write that Tether accepted is not immediately findable by search, and what to do about it.
Every number and response below was measured against real Workers AI and real Vectorize, not estimated.
The three signals
| Signal | Where | Means |
|---|---|---|
stored: true |
write responses | durably committed to D1 |
searchable: false |
write responses | not yet in the vector index |
degraded: true |
/search, /context |
this space has writes still waiting to be handed to the index |
via: "recent" |
/context anchors |
this anchor is one of them |
The window, measured
Writing an anchor enqueues its indexing work. A cron drains that queue, embeds the content, and upserts a vector. Two delays stack:
| Stage | Measured |
|---|---|
| Write → drain claims it | up to 60s (one-minute cron floor) |
| Upsert accepted → actually queryable | p50 28.8s, p95 44.5s |
So an accepted write can be unsearchable for roughly 105 seconds in the
worst case. In a capture for these guides the observed end-to-end time from
POST /v1/anchors to a hit in /v1/search was 23.2 seconds, with the
drain triggered manually.
Note the second row carefully: Vectorize acknowledges an upsert long before the vector is queryable. A successful upsert does not mean "searchable now," and nothing in Tether treats it that way.
What this looks like
Immediately after writing two anchors, with no drain in between — same credential, same instant, both endpoints:
POST /v1/search
{ "results": [], "degraded": true, "requestId": "req_d654aeb7-…" }
POST /v1/context
{
"anchors": [
{ "id": "anc_…E1K8CA", "content": "Ontra prefers concise summaries with no preamble.",
"score": null, "via": "recent", "…": "…" },
{ "id": "anc_…DQFS65", "content": "We deploy on Thursdays after the release review.",
"score": null, "via": "recent", "…": "…" }
],
"evidence": [],
"graphRevision": "1789870850497",
"tokenCount": 33,
"tokenizer": "tether-upper-bound-v1",
"degraded": true
}
Same data, same moment. /v1/search finds nothing because there is no vector
to match. /v1/context reads the anchors straight out of D1 and labels them
via: "recent" with score: null.
After the drain and the index catching up, the same /v1/context query
returns them via: "query" with real scores, and degraded is false.
degraded: false does not mean "searchable"
This is the sharp edge, and the one measurement that surprised us.
degraded counts one thing: outbox rows still waiting to be drained. The
drain marks a row done when Vectorize accepts the upsert — and Vectorize
applies upserts asynchronously, returning a mutationId rather than a
promise that the vector is live. The second row of the table above is that
gap, and degraded does not cover it.
Measured against the deployed Worker, two anchors written and then polled:
degraded flips true → false |
t+59s |
/v1/search first returns them |
t+80s |
A 21-second window where degraded is false and the write is still not
findable. So:
degraded: true→ definitely incomplete.degraded: false→ drained, probably complete. Not a guarantee.
Neither value is a signal to poll on. If you need a specific write to be
visible, read it by id — GET /v1/anchors/{id} is immediately consistent —
or retrieve through /v1/context, whose read-through covers both halves of
the window, not just the first.
What to do
Retrieve through /v1/context, and you mostly do not need to care. The
read-through covers the window. This is why /v1/context is the endpoint to
build an agent on.
Do not poll after a write. It is the instinct this guide exists to
prevent. You will burn your rate limit — 120 requests per 60 seconds, per
credential — and get nothing that /v1/context would not have given you
immediately.
Do not treat degraded: true as an error. It is not a failure. The
results are valid; they may be incomplete. Show them.
Do surface it if completeness matters. For an agent answering from memory,
degraded: true is worth a hedge — "based on what I have indexed so far."
Do not assume via: "recent" means relevant. Those anchors were not
matched, they were included because they cannot be matched yet. An unrelated
note written a minute ago will appear. They are capped at five, ranked after
genuine matches, and carry score: null so you can always tell them apart.
If it stays degraded
degraded: true that never clears means the queue is not draining. Likely
causes, in order:
- The scheduled worker is not deployed or its cron is not registered. A
deployment with no
scheduledhandler accepts writes and never indexes them. - Embeddings or the index are failing. Rows retry with exponential backoff
and are parked as
failedafter five attempts rather than blocking the queue behind them. - A genuine backlog. The drain claims a bounded batch per minute.
A permanently failed row means an anchor is stored and will never be
searchable. It is still readable by id, and still returned by /v1/context
only while inside the read-through window — after that it is invisible to
retrieval. That is a real failure mode and worth alerting on.
Deletion has the same shape
DELETE /v1/anchors/:id returns 202, not 204, for exactly the same
reason: D1 rows are removed synchronously, vectors asynchronously. Poll
GET /v1/deletions/:id until stages.vectors is done. Guide 03 covers it.
The asymmetry is deliberate. A write that is not yet indexed is invisible to search, which is safe. A delete that is not yet applied would leave content findable, which is not — so the read path refuses deleted content from D1 immediately, regardless of what the index still holds.