Skip to content

fix: three ways a write could look like it succeeded when it didn't - #1

Merged
liberuum merged 10 commits into
mainfrom
fix/docs-create-orphan
Sep 15, 2026
Merged

liberuum merged 10 commits into
mainfrom
fix/docs-create-orphan

Conversation

@thegoldenmule

Copy link
Copy Markdown
Collaborator

Three fixes to the same failure mode: the CLI calling a write clean when it wasn't.

docs create --parent-folder lost the document. create places the document with a separate follow-up moveNode call, whose result was discarded with let _ = ... and which is never reached when the create times out. So the document was created, left at the drive root, and reported as a connection failure — and the obvious retry silently made a duplicate. It now reports the document id, the drive, and the exact docs move that finishes the job, and never blindly retries.

A read timeout was reported as "Failed to connect". The connection succeeded; only the response timed out, and the server may well have completed the operation. Connect timeouts still retry; read timeouts still do not.

A partly-rejected batch reported total success. A reducer error or a denial does not fail the job — the operation is still written, the rest of the batch applies, the job reaches READ_READY with no error — so jobs wait and docs apply --wait printed READ_READY and exited 0 for a batch that partly failed. They now name each action that did not apply and why, and exit non-zero.

Notes on that last one:

  • It depends on powerhouse#3027 for JobInfo.result to be populated. Until that ships there is nothing to report, and nothing to report is not success: absent, null and {} results all fall back to today's status line and exit code.
  • The one contract change is the exit code: jobs wait (and therefore docs apply --wait) exits 1 when the job's actions did not all apply. jobs status reports the same detail but still exits 0 — it asks a question, it does not perform a write. --format json is unchanged in shape: the job object as the server sent it, now carrying result.
  • jobStatus on a pre-fix server declares result non-null and resolves it null, which errors the whole selection — that is the workaround this removes — so that one query retries without the field, and only when the server is what answered. The jobChanges subscription was always safe: pre-fix servers publish {} there rather than null.

cargo check, cargo test --bin switchboard (64 pass), cargo fmt --check and cargo clippy -- -D warnings are all clean. The 9 failures in tests/cli_integration.rs are the pre-existing no-local-reactor ones — identical on the commit this branch starts from.

🤖 Generated with Claude Code

thegoldenmule and others added 9 commits September 15, 2026 09:54
A request that connected fine and whose mutation the server fully
executed was reported as "Failed to connect to <url>: operation timed
out". The connection succeeded and the work may be done — saying
otherwise sends the caller straight into a retry that double-applies.

Split the timeout arm out: it now says the request reached the server
and the operation may have completed, so check before retrying. The
no-retry-on-timeout policy is unchanged. The catch-all arm stops
claiming "Failed to connect" for errors that are neither.

`is_timeout_error` lets callers of mutating operations key recovery off
the same distinction; tests cover that it survives `.context()` wrapping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…f-succeeds

`docs create --parent-folder` is two server calls: the namespaced
createDocument, then a separate moveNode that actually places the
document. Both failure paths left an orphan at the drive root:

- moveNode's result was thrown away with `let _ = ...`, so a failing
  placement printed "✓ Document created" and the folder it never
  reached. Now it errors with the document id and the exact `docs move`
  that finishes the job.
- A createDocument timeout aborted the command, but the server had
  often already created the document. The error now looks the drive up
  by name and reports the id if it is there — and either way says not
  to re-run create, which is what produces the duplicates.

Also drops the `vars["slug"]` assignment: the mutation declares only
$name and $parentIdentifier, so GraphQL variable coercion ignored it.
It never placed anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A caller that wants to retry a query with a narrower selection — because an
older Switchboard rejects a field this CLI now asks for — had no way to ask
whether the server answered at all. The GraphQL errors arrived as a bare
`bail!`, indistinguishable from a refused connection, so the only safe retry
was a blind one that dials a dead host twice.

Carry them in a `ServerErrors` type instead, matching `is_timeout_error`'s
existing shape, with the same rendering as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reducer error or a denial does not fail the job: the operation is still
written, the rest of the batch still applies, and the job still reaches
READ_READY with no error. So `jobs wait` printed `READ_READY` and exited 0 for
a batch one of whose actions was rejected, and `docs apply --wait` inherited
exactly that — the caller was told its write succeeded when part of it had
been thrown away.

The job now carries the per-action outcomes (powerhouse#3027), so select and
report them: a tally on the status line, a line naming each action that did
not apply and why, and a non-zero exit from `jobs wait` when they did not all
apply. `jobs status` reports the same detail but still exits 0 — it is asking a
question, not performing a write. JSON output is the job object as the server
sent it, `result` included; nothing is reshaped.

Against a Switchboard without that fix there is nothing to report, and nothing
to report is not success: absent, null and `{}` results all fall back to the
plain status line and the old exit code. `jobStatus` there declares `result`
non-null and resolves it null, which errors the whole selection — hence the
workaround this replaces — so that one query retries without the field, but
only when the server is the one that answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A gateway timeout is a successful HTTP exchange, so it fell through to
`bail!("HTTP {status}: {body}")` and `is_timeout_error` said no — there is
no `reqwest::Error` in that chain. That is the most common way a slow
upstream surfaces on a hosted Switchboard, and it meant `docs create`
printed a bare `HTTP 504` with none of the "the document may exist, do not
re-run" guidance the read-timeout path had just been given. Classify
504/408 as maybe-applied, sharing one wording with the socket timeout.

While in the same branch: a GraphQL error can arrive with a non-2xx status
— a query naming a field the schema lacks fails validation, which yoga and
Apollo answer with HTTP 400. Parse the body and report it as `ServerErrors`
so callers keying a narrower-selection fallback off `is_server_error` see
it, instead of an opaque HTTP failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`result.actions` holds one entry per submitted action *that produced an
operation* — an action producing none has no entry. So `allApplied: false`
can arrive over a set of entries that all applied, and the rejected count,
computed as `actions.len() - applied_count()`, came out zero: a job printed
a clean-looking `(2/2 applied)` and then bailed with "0 of 2 submitted
action(s) did not apply". Both numbers were wrong and the message was
nonsense.

Keep the server's verdict separate from the entries, say so in the tally
when it is the only thing objecting, and word the failure off what is
actually known. The count is now labelled "reported action(s)", since the
entries were never the submitted count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`is_server_error` fires on any GraphQL error, so a bad job id or an expired
token sent `jobs status` round the loop twice — and `map_err(|_| e)` then
threw the second, live failure away in favour of the first. Narrow the guard
to errors that actually name `JobInfo.result`, and report the fallback's own
error with the first one as context instead of discarding it.

This also picks up the case the guard used to miss entirely: a schema with
no `JobInfo.result` at all fails validation rather than resolution, which
arrives as HTTP 400. Now that the client classifies such a body as a server
error, those servers get the fallback the doc comment always promised them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
After a `createDocument` timeout the recovery lookup matched any node with
`kind == "file"` and the same name, anywhere in the drive. A drive that
already held a document called "Q1 Invoice" — in any folder — got told
"The create DID take effect ... Do NOT re-run `docs create`", naming an
unrelated id, when the create may never have run at all. That is a
confident false positive that suppresses the correct retry.

Search only the drive root, which is where `createDocument` puts the
document and where it still is until the `--parent-folder` move runs, and
report the match as something to check rather than as proof. The lookup is
also bounded at 10s now: it was issued through the same 120s client against
the server that had just failed to answer, so the user could wait a second
full timeout before seeing any message at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems with the `--parent-folder` failure path, both of which the
create path twenty lines above had already solved for itself.

The message asserted the document "is sitting at the root of drive X". The
move can fail with exactly the read timeout this branch teaches the create
to distrust, in which case it may have applied — so the same failure mode
got a categorical claim here and a hedged one there. Check
`is_timeout_error` and say what is actually known.

And returning Err skipped the JSON print, so `docs create --parent-folder
... --format json | jq -r .id` produced nothing: the id of a document that
definitely exists survived only as prose inside a stderr error chain. Emit
`{id, folderMoveFailed}` before failing, so automation can recover it.

`--parent-folder` was also missing from all three doc sources; add it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines of comment removed across the four source files this branch
touched. Most of it restated the line below it, re-argued a decision the
code already makes, or narrated a test whose name said the same thing.

What survives is the handful of facts the code cannot state: that a name
match after a create timeout is not proof, that `result.actions` is not the
submitted count, that validation errors arrive non-2xx, and the two socket
tricks in the client tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@liberuum

Copy link
Copy Markdown
Owner

Just gave it a test. This PR is a great improvement!

Thank you!

@liberuum
liberuum merged commit b747ec9 into main Sep 15, 2026
3 checks passed
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