Skip to content

What a long document found - #757

Merged
WaylandYang merged 8 commits into
devfrom
fix/a-bulk-ontology-edit-queues-one-run
Sep 18, 2026
Merged

WaylandYang merged 8 commits into
devfrom
fix/a-bulk-ontology-edit-queues-one-run

Conversation

@WaylandYang

@WaylandYang WaylandYang commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

What an end-to-end run with one long document found. A 333 KB filing (32 chunks) into a fresh base with a 14-class, 28-property ontology, through extraction, kind-word alignment, phrase alignment and materialisation.

A gateway failure is transient, not a rejection

The endpoint answered 502 Bad Gateway for a share of calls. utopia_llm::failure sorted 429 into RateLimited ("it fixes itself") and everything else into Rejected, so a 502 came back as a dead error: the call was not retried, the chunk was skipped, the whole document failed. The first pass lost 8 of 32 chunks that way, the second 6 of 8, and the job has three attempts.

Unavailable is now its own type for 408, 502, 503 and 504, on the reasoning the comment above RateLimited already gives. 500 is not in it: that can be the endpoint's own bug, and retrying only triggers the same crash again.

A request that never arrived is worth sending again

With 502s absorbed, the next pass still lost two chunks to error sending request for url — the request never reached the endpoint. That is Unreachable, which the retry loop did not know either. utopia_llm::transient now names all three classes it is worth waiting for: rate limited, unavailable, and a request that failed to send.

A read timeout is not one of them. READ_TIMEOUT is 300 seconds without a first byte; retrying that five times is 25 minutes, and a call that always times out does not improve by waiting. One chunk of this filing did exactly that, three times in a row, 301 seconds each — dense prose, and reasoning on has a tail past five minutes. That chunk is what the streaming section below is about.

Measured over the run: 6 transient failures absorbed in-call, 0 chunks lost to 502, 2 lost to a request that never sent (before the second fix), and after both fixes the document finished 32/32 with 661 open statements.

A passage that could not be extracted is a row

A chunk that fails leaves a line in the log and a sentence in the job's last_error, and nothing a person can see once the document is done. chunk_unextracted is now a drop reason like the others, recorded per failing chunk with its number and the error, so the Library's drop table says which passage did not make it (0001: a drop is a row, never silence).

A bulk ontology edit queues one alignment run

Seeding 28 properties queued 35 align_phrases runs and 14 classes queued 14 align_types, all serialised behind one lock, all no-ops after the first. Two things were wrong: enqueue_unless_queued only skips a job that is still queued, and on an empty base a run finishes in milliseconds, so even skipping running jobs leaves a gap for the next edit. So enqueue_unless_pending skips queued and running, and the ontology routes queue with a five-second delay, which is the debounce. What a run misses while in flight is caught by the check it already does when it ends, now widened to any stale binding no person decided.

Same 42 elements, measured on a fresh base: 35 + 14 runs before, 2 + 1 after.

Extraction listens to the model think

The read timeout measures silence, and a non-streaming call is silent until the model has generated the whole answer, so with reasoning on the thinking time counts against it. One chunk of this filing crossed 300 seconds three times in a row; the same chunk, streamed, has bytes at 2.6 seconds and finishes at 231, against 227 not streamed. Streaming does not make it faster; it gives the word "silence" its meaning back, so the timeout only kills a request that is genuinely stuck.

chat_at_streaming sends with stream: true and returns the assembled answer, so callers see the same string they saw before. Reasoning deltas are never read, usage still arrives on the last frame, and the retry helper that extraction, both aligners and time resolution share now calls it.

Two things that only a stream can get wrong, both pinned by tests: the last frame is taken even when nothing follows it (an implementation that does not end with a blank line would otherwise lose the tail of the answer), and a stream that stops without [DONE] or a finish_reason is Interrupted, not a short answer. That one matters: half an answer is a valid string, and without the type the missing statements would be silent. It counts as transient, so it is retried.

A 7-chunk 8-K, extracted end to end through the streamed path: 7/7 chunks, 76 statements, no chunk skipped, no call retried, with chunks taking four to six minutes each.

A name is not removed on one click

Not from this run: removing an alias in the entity panel was one hover-revealed click with no confirmation, and it retracts the known_as fact while the interface has no way to add a name back. It now asks in place, with the second click on a different button, following the gesture this product already uses for re-running inference. Not DangerConfirm, which the code reserves for deleting a base.

Checked

utopia-llm 23 tests pass, including the ones pinning what is transient (408/502/503/504 with the endpoint's own words carried through; 400, 401 and 500 not), the tail frame, and the cut stream. Workspace clippy clean, web build and 63 web tests pass, style guard clean. The long document, the counts above, and the alignment that followed it (71 of 149 kind words bound, 40 of 141 signatures bound, 131 typed facts) come from the run described at the top; the streamed 8-K is a second run on a fresh base.

Not in this change

The latency itself. Streaming stops a slow chunk from being killed; it does not make it quicker, and four to six minutes a chunk is what reasoning costs on this endpoint. Capping the reasoning budget per call is the lever, and it needs the judge run again before anyone pulls it.

🤖 Generated with Claude Code

WaylandYang and others added 8 commits September 18, 2026 21:03
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
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.

1 participant