Skip to content

fix(dsh): make extraction drain resilient to LLM stalls and long batches - #95

Closed
evilh2019 wants to merge 2 commits into
adoresever:mainfrom
evilh2019:fix/extraction-pending-resilience
Closed

fix(dsh): make extraction drain resilient to LLM stalls and long batches#95
evilh2019 wants to merge 2 commits into
adoresever:mainfrom
evilh2019:fix/extraction-pending-resilience

Conversation

@evilh2019

Copy link
Copy Markdown

Problem

The DSH adapter's extractPending loop (in dsh.ts) drains unextracted session messages into the knowledge graph in fixed-size batches:

const messages = getUnextracted(db, sid, 50);

with no length bound on the batch, an unbounded existingNames hint list (getBySession(db, sid).map(n => n.name) — can reach tens of thousands of chars), and a complete() LLM call that had no stream timeout at all.

On any error the whole drain stops and the batch is deliberately left unextracted ("so a later turn/restart can retry"). In practice that means:

  1. A burst of long tool results builds a request that exceeds the model context / stalls the stream.
  2. The stalled stream never emits a finish/error chunk → extractPending hangs (no timeout), or with a provider-side timeout the batch fails.
  3. The failing batch is retried identically on every restart → the backlog is pinned forever; extraction effectively never completes and the DB keeps every raw message with extracted=0.

Observed on a real deployment: a 47K-message session with a backlog stuck at extracted=0 across many restarts; batches of ~18.7K chars timed out while an ~18.6K batch succeeded, confirming the failure is content/stall-driven, not a simple fixed threshold.

Fix

Bounded, resilient drain with guaranteed progress:

  • Size-bounded batches: pull the oldest unextracted messages in order, normalized, capped by accumulated characters (8K) then count (15). A single long message always fits, so the drain always progresses; order is preserved so the markExtracted(upToTurn) max-turn marking stays correct.
  • Bounded existingNames: cap at 150 entries / 3K chars so the hint list cannot blow the prompt.
  • Stream timeout: hard-bound complete() at 180s (previously unbounded — a stall could pin the session's extraction chain forever). AbortController-style race with timer cleanup.
  • Retry → bisect → skip ladder (drainBatch):
    • retry transient failures with backoff (5s then 15s),
    • if still failing, bisect the batch so one poison message cannot sink the rest,
    • last resort: mark a lone failing message extracted (raw message stays in gm_messages) so the drain can never deadlock.

Validation

  • npm test129 tests pass (one pre-existing file-level setup failure on node:sqlite typings, unrelated to this change).
  • Real deployment: the previously-stuck backlog (never completing across restarts) now drains to zero with visible retry → split → DONE sequences in logs; node/edge counts grow steadily.

Small, self-contained change: dsh.ts + rebuilt dist/dsh.js (the repo tracks dist/).

The DSH adapter's extractPending loop pulled a fixed 50 unextracted
messages per batch with no length bound, built an unbounded
existingNames hint list, and on any error left the whole batch
unextracted while stopping the drain. A batch that stalled the LLM
stream (no finish/error chunk) was retried on every restart forever,
pinning the backlog.

- bound each extraction request by accumulated normalized characters
  (8K) then message count (15); a single long message always fits so
  the drain always progresses
- cap the existingNames hint list (150 entries / 3K chars)
- hard-bound the LLM stream with a 180s timeout (previously none)
- retry transient failures with backoff (5s/15s), then bisect the
  batch, and mark a lone failing message extracted as a last resort so
  the drain can never deadlock

Verified: 129 vitest tests pass; a backlog that previously stalled
forever now drains to zero on a real deployment.
…th capping)

Three new adapter tests drive a real in-memory-file store through the
backfill -> scheduleExtract -> drainBatch path:

- a transient LLM failure is retried and the backlog drains to zero
- a permanently failing batch is bisected and each singleton is marked
  extracted (no deadlock), with the drain still progressing
- an oversized message batch is split by accumulated normalized length
  so no single extraction request exceeds the size cap

Also expose extractionStreamTimeoutMs / extractionRetryDelaysMs as
adapter config so hosts can tune (and tests can shorten) the resilience
knobs.
@evilh2019

Copy link
Copy Markdown
Author

Follow-up commit adds three adapter tests (test/dsh-adapter.test.ts) that drive the real backfill → scheduleExtract → drainBatch path against a file-backed store:

  • transient LLM failure → retried → backlog drains to zero (no SKIP)
  • permanently failing batch → bisect → each singleton marked extracted → drain still progresses (no deadlock), verified via gm_messages extracted=0 count
  • oversized message → batch split by accumulated normalized length; every extraction request stays under the size cap

Also exposed / as adapter config so hosts can tune the resilience knobs.

Full suite: 132 tests passed.

@adoresever

Copy link
Copy Markdown
Owner

Thank you @evilh2019 and @penggaolai for finding the extraction-backlog stall and contributing the initial bounded batching, retry, timeout and bisect work.

Maintainer integration #97 preserved both of your commits and their original authorship, then addressed the review blockers found with official DSH and real model testing: lossless single-message segmentation, durable quarantine instead of false extracted flags, exact acknowledgements, cancellation, retry tooling, retention safety, optional Headless services, and the awaited agent/turn-stopping lifecycle drain.

#97 has passed Node 22/24 CI and is merged. I am closing this PR as superseded by that integration, not as a rejection of the contribution. Thanks again.

@adoresever adoresever closed this Aug 31, 2026
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.

3 participants