Long-running ingestion — interrupt, checkpoint, resume
A local model turns a corpus of any real size into a job that runs for hours, not seconds.
This page composes taguru extract's manifest and
chunk checkpoints with taguru import's atomic,
idempotent batch contract into one operating picture: enumerate the work, run it in bounded
windows, survive a kill at any point, and converge to done without ever paying twice for
the same chunk. Neither reference page's contract changes here — this page names the
pattern and draws the line between what Taguru guarantees and what your runner decides.
The run lifecycle: converging over bounded windows
A concrete run: 112 documents, a corpus large enough that one pass needs several short-lived instances.
taguru extract --dry-run --out batches/ docs/
Three properties make this converge instead of just retrying forever: every work unit is
enumerated before the first model call, so "pending" always has a concrete count; a
success is committed the moment it lands, never batched up and lost together on the next
failure; and a failure marks exactly the one unit that failed, so a rerun's cost is
proportional to what's left, not to what already happened. None of this requires Taguru to
know about "runs" or "jobs" at all — taguru extract's manifest and
chunk checkpoints, plus
taguru import's retract-then-apply
idempotency, are what make each window's work durable on its own.
The split: what Taguru guarantees, what your runner decides
Every pattern on this page composes these guarantees; none of them require Taguru to be aware that a "long job" is happening.
| Taguru provides | Your runner provides |
|---|---|
| Durable writes, source attribution, atomic per-source import (retract-then-apply, nothing partial ever visible) | Splitting a corpus into work units (documents, or PDF sections) and a queue to drive them |
| The extract manifest and chunk checkpoints — free re-run of what's already done | A time or item bound on one process invocation |
Source listing / --dry-run as the completion test | Signal handling and the decision of when a kill is "safe" vs "now" |
| Validation errors, the per-source batch-open marker for torn imports | Progress UI/log, a failure summary, and the policy for what gets retried |
| A re-runnable batch contract — the same file or command is always safe to run again | Deciding when "good enough" is reached and the job is done |
Taguru ships no --max-minutes or --max-items flag, on
extract or import, and none is planned — bounding a run's window
is deliberately left to the runner. The reason it can be: because rerunning
taguru extract/taguru import is free for anything already
done, a bound needs no cooperation from Taguru to be safe. timeout(1), a
cron/scheduler window, or a runner's own argument parsing are all sufficient — see
below.
Work units, checkpoint keys, and deciding what is done
The default unit is one document — one file under taguru extract's input
directory becomes one batch file, whose header names its own source id, which becomes one
row in --out's manifest. The same one-document, one-source-id granularity holds
for the standard ingest connectors: one PDF, DOCX, PPTX file,
or S3 object becomes one ConnectorDocument, one retract-then-apply unit — page,
slide, and table boundaries surface as citation
locators within that one document, not as separate source ids or separate work units.
For a corpus of a few large PDFs where that granularity is too coarse (per-section retries,
per-section deletion), the natural unit shrinks to one section: split each PDF by
heading yourself and feed each section through as its own document with a source id that
names its parent, e.g. manual.pdf#installation — a runner's choice to make when
it wants finer granularity than a connector gives by default, not something Taguru requires.
Each section is then an independent batch, its own manifest row, its own retract-then-apply
unit — a failure in one section never touches another's.
Two distinct completion tests exist, one per stage — do not conflate them:
- Extract stage: a document is done when its entry in
.extract-manifest.jsonmatches the current compute-input fingerprint (content hash × model × prompt version × context × output-shaping flags).taguru extract --dry-runreports this without calling the model. A connector-driven run has its own two-layer version of this same test — see ingest connectors: checkpoints. - Import stage: querying a running server's
GET /contexts/{name}/sources(or the SDK'siter_sources(), see below) tells you exactly which sources have landed. That test is sound only because a failed batch now writes nothing — a partially-applied source can no longer masquerade as a completed one (the fix that closed this gap is issue #187; before it, "source exists" was not a safe checkpoint test for a resumable client). Offline, there is no per-source listing (taguru inspectreports a source count and any surviving import markers, not individual ids) — but you rarely need one there: retract-then-apply makestaguru importidempotent, so simply re-importing the whole--outdirectory after every window is cheap and correct without first figuring out what already landed.
Below document granularity, chunk checkpoints key
each unit by the hash of its own text, not chunk index — so a resumed run's chunk
splits don't have to line up with a prior run's for reuse to be recognized. Combined with
the manifest above, a rerun only ever pays for chunks whose content, model, or
output-shaping settings actually changed. A connector-driven run gets the same property from
a different key: its own checkpoint namespaces (connector:, s3-object:,
s3-inventory:) sit in the same CheckpointStore alongside chunk
checkpoints, never colliding with them — see
ingest connectors: checkpoints.
Stopping: the first signal, the second, and what a rerun sees
taguru extract's Ctrl+C/SIGTERM handling is
cooperative and already covered in full at
the checkpoints reference: the first signal finishes
the chunk in flight, checkpoints it, and exits 130; a second signal forces an
immediate exit, also 130, for when the graceful path itself is stuck. The
LangChain SDK exposes the same shape as an argument, not ambient signal handling: pass
should_stop (a zero-argument callable, or a threading.Event) to
ingest_text()/ingest_documents() — checked between chunks, and
when it fires, IngestOutcome.interrupted is True and nothing
imports. Wiring an actual SIGINT handler that flips the event (first signal)
or exits the process outright (second) is the runner's job — see
below for one shape of it.
| Stop | What survives | What a rerun does |
|---|---|---|
| First signal (safe stop) | every chunk checkpointed before the signal, plus every document/source already imported | resumes the in-flight document mid-chunk; re-attempts nothing already checkpointed or imported |
| Second signal (immediate stop) | same as above, minus whatever chunk was in flight at that exact instant (discarded, not partially checkpointed) | re-attempts only that one in-flight chunk; everything earlier still resumes |
kill -9 (no cooperation possible) | same as the second signal — the process never sees the request to stop, but nothing durable was ever written mid-chunk in the first place | identical to the second-signal case above |
Path one: a bounded loop around the built-in CLI
No custom code — timeout(1) and exit-code dispatch around the two shipped
subcommands.
# 45m and the -k grace period are timeout(1)'s — taguru extract has no time flag of its own.
# -s INT sends the same signal Ctrl+C would (taguru's cooperative first-stage stop);
# -k 30 sends SIGKILL 30s later only if the process is still stuck on the graceful path.
timeout -k 30 -s INT 45m taguru extract --context sake --description "酒蔵の知識" \
--out batches/ docs/ # --description is ignored once the context already exists
rc=$?
taguru import batches/ # apply whatever this window finished — safe even if extract above was cut short
# rc: 130 window's time ran out mid-run → requeue the identical command
# 1 some documents failed, the rest completed → rerun retries only the failures
# (the manifest records only successes, so a plain rerun already does this)
# 0 nothing left to extract → converged, the job is done
case "$rc" in
130) echo "window expired, $(taguru extract --dry-run --out batches/ docs/ | grep -c 'would extract') unit(s) still not extracted" ;;
1) echo "some documents failed — see stderr above; rerun to retry them" ;;
0) echo "converged" ;;
esac
--dry-run's per-document line is "{source}: would extract (…) → …"
for anything not yet in the manifest, and "{source}: unchanged, skipped (--force
re-extracts)" for the rest — counting the former is the honest "how much is left"
signal, since neither line literally says "pending".
Bounding by count instead of time means passing a slice of the not-yet-extracted
files as explicit arguments — taguru extract accepts individual file paths
alongside directories, so
taguru extract --out batches/ $(taguru extract --dry-run --out batches/ docs/ | sed -n 's/: would extract.*//p' | head -20)
processes at most 20 documents this invocation; run lifecycle's relation-label convergence
(extract.html's prompt section) happens within one
invocation, so slicing this way is safe, just less able to converge vocabulary across the
whole corpus in one shot. Avoid set -e in a wrapping shell script around this
block — exit 130 is an expected, not exceptional, outcome here.
Path two: a custom runner on the LangChain SDK
When the work unit is finer than "one document" (e.g. sections split from a handful of
PDFs) or the deployment needs its own progress UI, a small runner around
TaguruIngester gets the same properties. The flags below are this sample's
own — nothing here is a Taguru API.
# runner.py — one process invocation, bounded by --max-minutes / --max-items.
# Both flags belong to this script's own argparse, not to taguru_langchain.
import argparse, signal, threading, time
from langchain_openai import ChatOpenAI
from taguru import Taguru
from taguru_langchain import TaguruIngester, FilesystemCheckpointStore
parser = argparse.ArgumentParser()
parser.add_argument("--max-minutes", type=float, default=45.0)
parser.add_argument("--max-items", type=int, default=None)
args = parser.parse_args()
stop = threading.Event()
def on_signal(signum, frame):
# first SIGINT/SIGTERM: stop cooperatively between chunks;
# second: fall through to the default handler and exit immediately.
if stop.is_set():
signal.signal(signum, signal.SIG_DFL)
raise KeyboardInterrupt
stop.set()
signal.signal(signal.SIGINT, on_signal)
signal.signal(signal.SIGTERM, on_signal)
client = Taguru() # $TAGURU_URL / $TAGURU_API_TOKEN
done = set(client.context("sake").iter_sources()) # the completion test — import stage
ingester = TaguruIngester(
context="sake",
llm=ChatOpenAI(model="gpt-4.1", temperature=0),
checkpoint_store=FilesystemCheckpointStore(".taguru-checkpoints"),
on_event=lambda e: print(e.kind, getattr(e, "source", "")),
)
deadline = time.monotonic() + args.max_minutes * 60
failures = []
corpus = load_corpus() # yours — a list of LangChain Document, one per section/document
pending = [doc for doc in corpus if doc.metadata["source"] not in done]
if args.max_items:
pending = pending[: args.max_items] # this run's own count bound
for doc in pending:
if stop.is_set() or time.monotonic() >= deadline:
break
try:
outcome = ingester.ingest_text(doc.page_content, source=doc.metadata["source"], should_stop=stop)
if outcome.interrupted:
break # chunk checkpoints already saved what completed; exit and requeue
except Exception as exc:
failures.append((doc.metadata["source"], exc)) # one failure never aborts the job
print(f"{len(pending) - len(failures)} succeeded, {len(failures)} failed, "
f"{len(corpus) - len(done) - len(pending)} not reached this window")
for source, exc in failures:
print(f" FAILED {source}: {exc}")
Everything durable here is Taguru's: checkpoint_store persists each accepted
chunk before the next one starts, and a document that ultimately fails keeps its checkpoint
for the next attempt (see the SDK's own
checkpoint/resume section). Everything bounding the window —
--max-minutes, --max-items, the signal handler, the per-document
try/except, the failure summary — is this sample's own policy,
freely rewritten to fit a different scheduler or retry budget.
Progress you can watch: attempts, tokens, retries
Both producers expose the same shape of per-attempt visibility, deliberately mirrored field
for field (see the diagnostics reference for the full
schema). The CLI's --diagnostics-out FILE writes one JSONL record per LLM
attempt, flushed incrementally — tail -f diagnostics.jsonl | jq against a
running job shows each attempt's state, elapsed_seconds, and
provider_metadata.finish_reason/output_tokens as it happens,
including the corrective-retry attempts a single silent ingest_text() call
would otherwise hide.
The SDK's on_event callback (issue
#177) emits the same information
as typed events: document_started, chunk_started,
attempt_started, attempt_failed (with parse_error,
elapsed_seconds, provider_metadata), chunk_completed
(reused=True when a checkpoint satisfied it — no LLM call at all),
import_started/import_completed, and the embedding-refresh
events. It must stay synchronous and non-blocking; a callback exception is caught and
reported via warnings.warn rather than corrupting the ingest it was observing.
Failures, torn imports, and re-submission
Extract-side failure is per-document: the run exits 1, the failing document's
stderr line (or diagnostics record) names why, every other document continues, and the
failed document keeps its chunk checkpoints — a rerun resumes it rather than starting over.
Import-side failure is where "torn" becomes possible in principle:
validate-then-apply already rejects a malformed file
before anything is written, but a fault reaching mid-batch (capacity, disk) between the
four durable steps (retract → passage → associations → aliases) is caught by a per-source
batch-open marker, written before the first step and removed only after the last.
A surviving marker is named at the server's next boot and by taguru inspect,
verbatim:
sake: WARNING — the import of source 'docs/aomine.md' never completed; its truth
may be half-applied — re-import its batch file or retract the source
Both repairs are exact, because retract-then-apply already makes them so: re-import the
original (or corrected) batch file, or retract the source outright and leave it absent.
Over HTTP, POST /import reports the same fault as 409 (conflict)
or 507 (capacity) with a message naming what landed — the retraction still
makes the corrected retry exact. The one operational rule that keeps this from happening
under a concurrent runner: never race two imports of the same source — one writer
at a time per source, enforced by the runner, not by Taguru.
When sources change or disappear
A document edited between runs is just a manifest mismatch: its content hash no longer matches the recorded compute inputs, so it re-extracts, and the resulting batch's retract-then-apply replaces the old source's facts wholesale on import — no separate "diff" step exists or is needed.
A document removed from the corpus is different: nothing in Taguru garbage-collects
a source that stops being extracted. The manifest only ever grows entries; it has no notion
of "no longer part of this corpus." Detecting and retiring stale membership is the runner's
job — diff the corpus's current file list (or section list) against
iter_sources()/GET /contexts/{name}/sources, and for anything
present in Taguru but absent from the corpus, emit a header-only pure retraction
batch (a taguru_batch header with no operation lines) naming that source — see
"one file = one source's complete truth".
Where each guarantee is specified
This page is a composition, not a new contract — the normative source for each piece:
- Extract manifest — extract.html#manifest; chunk checkpoints and cooperative stop — extract.html#checkpoints; diagnostics sidecar — extract.html#diagnostics.
- One-source-one-batch idempotency — import.html#one-file-one-source; validate-then-apply and the batch-open marker — import.html#validate; HTTP status semantics — import.html#http.
- LangChain SDK progress events (issue #177) and checkpoint/resume (issue #179) — Python SDK README · TypeScript SDK README.
- Atomic per-source import (the fix that makes source listing a sound completion test) — issue #187.
Taguru