Results, Audit Artifacts, and Resume
Production runs often need two different outputs: a convenient batch summary and
an append-only checkpoint that survives interruption. BatchResult serialization
serves the first use case; an artifact store (JsonlArtifactStore or
SqliteArtifactStore) serves the second.
Completion order and input order
Batch processing and process_stream() publish results in completion order by
default. This keeps fast items visible while a slow or retrying item is still in
flight and preserves existing behavior.
Use preserve_order=True when collecting a batch in input order:
result = await process_prompts(
strategy,
[("slow", "slow prompt"), ("fast", "fast prompt")],
preserve_order=True,
)
The processor assigns an internal submission_index when each item is accepted.
It does not derive order from item_id, so duplicate IDs are safe (they're accepted,
with a one-time UserWarning). To reorder an
existing batch without mutating it, call ordered = result.in_input_order().
This raises ValueError if any result predates submission indexes; it never
guesses.
Each LLMWorkItem object is a single submission. Duplicate IDs on distinct
objects are supported, but submitting the identical mutable object twice raises
ValueError; create a new work item for each queue entry.
process_stream() intentionally remains completion ordered. Ordered streaming
would block all later results behind one slow early item and require a reorder
buffer that can grow without a safe bound.
JSON result serialization
Result mappings use the named async-batch-llm-result schema and an integer
schema version. They include terminal status, submission index, error category,
replay state, token usage, complete attempt/item timing, and batch termination
metadata.
from pathlib import Path
from async_batch_llm import BatchResult
value = result.to_json()
Path("summary.json").write_text(value, encoding="utf-8")
restored = BatchResult.from_json(
Path("summary.json").read_text(encoding="utf-8")
)
result.to_jsonl("summary.jsonl")
restored_lines = BatchResult.from_jsonl("summary.jsonl")
JSONL result files contain one complete versioned WorkItemResult record per
line. A zero-result batch uses one versioned batch-metadata record so termination
state still round-trips. These are summary exports, not resumable checkpoints;
use an artifact store for checkpoint/resume.
The safe encoder accepts normal JSON primitives plus dataclasses, Pydantic
models, enums, datetime/date/time, UUIDs, filesystem paths, tuples, and
sets. Tuples and sets normalize to JSON lists; dataclasses and Pydantic models
normalize to mappings; dates, UUIDs, and paths normalize to strings. Sets are
ordered deterministically. Without explicit decoders, those normalized values
remain JSON-native mappings, lists, and strings after deserialization.
Application-specific values need an explicit encoder:
WorkItemResult.from_dict() and the BatchResult.from_*() methods accept
output_decoder and context_decoder hooks for trusted type reconstruction.
Output decoding runs only for successful results whose output was included and
is not None; failed, omitted, and null outputs bypass it. Context decoding runs
only for non-null context. Artifact inspection and replay use the same rules.
Deserialization never imports a class named by untrusted data. Exceptions are
stored only as module name, class name, and redacted message; the restored
runtime exception is None. Tracebacks and raw exception objects are never
persisted. Framework-controlled error text and values under structured
authorization/API-key/token keys are redacted. Other user-controlled strings
round-trip without text rewriting, so applications must keep credentials out
of prompts, outputs, context, and metadata.
Unsupported values, malformed input, and future schema versions raise
ResultSerializationError; values are never silently replaced with repr().
A reader on the same schema version can also reject a value that a newer release
added; see Compatibility across releases.
Middleware and replay
For each accepted item, the processor establishes its total deadline and runs
before_process once before preparing artifact identity or looking up an old
result. Retries use this effective request without repeating preprocessing.
Current filtering therefore always wins over historical success.
A replacement may change prompt, context, and strategy, but cannot change the
accepted item ID. Submission indexes are preserved. Fingerprints, inferred
provider/model identity, strategy class, lookup, and audit fields describe the
effective item; append uses the exact fingerprint prepared before execution.
Explicit ArtifactIdentity values still override inferred identity. Automatic
stores still require one consistent inferred identity per run, regardless of
worker preparation order. Identity and input serialization errors produce a
failed artifact_preparation_error result for that item during processing, after
middleware. Other accepted items continue; store I/O, format, and checkpoint-write
failures still propagate for ordinary execution checkpoints. To stop immediately
on identity or input errors, include artifact_preparation_error in
GuardrailConfig.abort_on_error_categories; it remains opt-in so isolated bad
inputs do not discard valid neighboring results.
Replay retains historical output, usage, and timing, binds current effective
context and the current submission index, and neither calls strategy prepare()
nor appends a duplicate record. Capacity configuration and admission-scope binding
still run once per effective strategy. A completed lookup is used even if a
deadline or abort arrives concurrently. after_process runs once for a newly executed success,
before append, and does not rerun on replay. Changes to after_process that leave
effective inputs unchanged require an application_version or parser_version
bump in ArtifactIdentity. Middleware functions are not automatically hashed.
Filtered items produce a current-run middleware_filtered result. Preprocessing
filtering and invalid middleware requests do not open the store or append records.
Aborts, budget stops, and total-item/batch
deadlines are checkpointed for audit when an artifact
key can be prepared, with replay_eligible=False. Interrupted artifact preparation
is not restarted solely for audit, and unrepresentable inputs cannot be checkpointed.
Artifact errors during batch-abort, batch-budget, or batch-deadline audit preparation or append
are logged, preserving the controlled stop and its results even when that audit
record cannot be persisted. A per-item deadline is an ordinary failed result in
an otherwise running batch: its checkpoint errors still propagate, although its
record remains ineligible for replay.
An abort can append one audit row per accepted queued item before the run returns. These writes use the store's normal locking and persistence path, so large queues can add substantial abort-drain time. Audit preparation and append are not bounded by the expired guardrails; a store that blocks on open or write can delay draining indefinitely.
Both backends exclude legacy abort/deadline records during replay as well, allowing
an older compatible success to be reused. A filter record explicitly appended via
the store API has replay_eligible=False, including under REUSE_ALL. Both JSONL
and SQLite also reject legacy filter records bearing the exact historical
Skipped by middleware error, even when those records were marked replayable.
Configuration failures (token_estimator_required, token_estimation_error,
token_estimate_exceeds_limit and quota_scope_error) are likewise checkpointed with
replay_eligible=False, and legacy records carrying those categories are excluded, so
fixing the configuration and resuming re-executes the item even under REUSE_ALL.
Unlike abort and deadline audit records, their checkpoint errors propagate.
quota_scope_error fails before an artifact key is prepared, so it is not
checkpointed at all.
Both stores fall back to an older compatible result when a newer record is excluded.
Both retain the existing version-1 schema. Newly executed
results are still checkpointed before publication or post-processing callbacks.
Resumable JSONL artifacts
An artifact begins with a version-1 manifest followed by versioned item records. The manifest records UTC creation time, package version, canonical identity, its SHA-256 fingerprint, and optional user metadata. Each terminal item record contains input fingerprints, current submission index, strategy class, identity/provenance, safe result data, timing, token use, error category, replay eligibility, and optional caller-calculated cost.
The minimal form needs only a path (v0.20.0):
from async_batch_llm import JsonlArtifactStore, ResumePolicy, process_prompts
result = await process_prompts(
strategy,
prompts,
artifact_store=JsonlArtifactStore("runs/customer-tagging.jsonl"),
resume=ResumePolicy.REUSE_SUCCESSES,
)
The run closes the store when it finishes: process_prompts(), process_stream()
and processor context exit all close the store passed in, and a closed store raises
ArtifactIOError if used for another run. Construct a new store instance, on the
same path, for each run. Path-based inspection (read_results) still works
afterward.
When no ArtifactIdentity is given, provider and model are inferred from
ordinary model-backed strategies at run start (built-in models map to their
provider names; custom models use their class name) and the remaining identity
fields default to "unversioned". Prompt — and, by default, context — still
participate in the per-item compatibility fingerprint, so a changed prompt or
a changed model never silently replays a stale result.
Automatic identity covers the provider and the model. OpenAIModel adds its API
surface when it is the Responses API, and DeepSeekStrategy adds its API surface and
strict response schema. Sampling and request parameters
(temperature, reasoning effort, extra_body, and other request kwargs) are not part
of identity or the item fingerprint, so changing them does not invalidate replay.
For example, v0.27 changed the default temperature from 0.0 to None (the
provider default); a v0.26 checkpoint resumed with REUSE_SUCCESSES replays
successes produced at temperature=0.0 without warning, except for OpenAIModel
on the new Responses default, whose identity changed. When a request parameter
must invalidate replay, pass an explicit ArtifactIdentity that records it (for
example extra={"temperature": 0.0}) or bump prompt_version.
Automatic identity is homogeneous within one live store instance/run. The first
prepared item pins its inferred identity; every later item is inferred and must
match before artifact lookup, append, or provider work begins. Use separate
stores for heterogeneous strategies, or pass one deliberate explicit
ArtifactIdentity that describes the mixed/routed run. A later store instance
may pin a different automatic identity and append to the same physical
artifact—older identities remain audit history and only compatible records
participate in replay.
CallableStrategy is intentionally stricter: an arbitrary function cannot
safely reveal its provider, model, route, parser, or application version. Pass
identity=ArtifactIdentity(...) to CallableStrategy or directly to
JsonlArtifactStore. Omitting both fails before the invocation callback runs;
ABL never derives identity from a lambda, closure, object ID, memory address, or
unstable repr(). An explicit store identity takes precedence over the
strategy's identity.
When to use the full identity: versioned production pipelines. An
explicit ArtifactIdentity lets a prompt-template change, parser change, or
application release invalidate replay even when the literal prompt text is
unchanged (e.g. context assembled outside the prompt, or a new parser reading
the same outputs):
from async_batch_llm import (
ArtifactIdentity,
JsonlArtifactStore,
ResumePolicy,
process_prompts,
)
store = JsonlArtifactStore(
"runs/customer-tagging.jsonl",
identity=ArtifactIdentity(
provider="openai",
model="example-model",
prompt_version="v3",
parser_version="v2",
application_version="2026.07",
),
)
result = await process_prompts(
strategy,
prompts,
artifact_store=store,
resume=ResumePolicy.REUSE_SUCCESSES,
)
An explicit identity is the caller-owned compatibility boundary. It may intentionally describe a mixed-strategy run, but the caller must change it whenever routing, provider, model, parser, prompt policy, or application semantics should invalidate replay.
A terminal record is flushed before its result is returned or yielded. Set
fsync=True for an operating-system durability barrier after every record;
flush-only is the default. Readers ignore every final line without a newline,
even if it contains complete JSON, and never modify the file during inspection.
Writable preparation validates retained records, truncates the incomplete tail,
and builds replay state only from retained records. A dropped valid item is
executed again; its zero-based stored sequence may be reused, remaining unique
among retained records. Warnings report dropped bytes, a SHA-256 and the item ID
when available, without logging raw payloads. Repair is also fsynced when enabled.
On macOS, fsync() flushes host buffers to the drive but does not force the
drive to write its cache to stable storage (that needs F_FULLFSYNC, which the
store does not issue). fsync=True there does not guarantee against data loss
from an OS crash or power loss.
A file containing only a recognizable canonical manifest prefix can be repaired and initialized again. Unrecognized files, unsupported versions, and malformed complete or middle records fail without truncation. After any write failure, the JSONL instance rejects later appends; close it before opening a new instance for recovery. JSONL still supports only one writer per file. Cross-process writer locking remains outside this release; do not resume a file another process is currently writing.
Compatibility matching
Replay requires all of the following to match:
- item ID;
- SHA-256 prompt fingerprint;
- context fingerprint when
context_in_identity=True(the default); - combined input fingerprint; and
- complete
ArtifactIdentityfingerprint and supported artifact schema.
Matching only an item ID is never sufficient. Changing provider, model,
prompt/parser/application version, identity extra, prompt, or participating
context invalidates the old record. When several records match, the newest
complete record wins.
Context is canonically JSON-encoded before a provider call. Supply encoder=
or context_fingerprinter= when an application context is not supported; the
store raises ArtifactSerializationError rather than silently excluding that
identity component.
Sensitive structured values are redacted from persisted identity, context, output, and metadata mappings. Their original values still feed the one-way context/identity fingerprint, so a credential change invalidates replay without writing the credential itself to the artifact.
Compatibility across releases
The test suite keeps artifacts and serialized results written by published releases
readable. tests/test_legacy_fixtures.py checks that files written by v0.18, v0.21,
v0.24.1 and v0.26 (JSONL and, from v0.21, SQLite artifacts, plus BatchResult JSON
and JSONL) still load, replay, and accept new records.
Two caveats apply:
- Replay exclusions apply retroactively. A newer release that stops replaying a category (for example the configuration failures above) also ignores older records carrying it, and re-executes those items.
- Forward compatibility is not guaranteed. An older release on the same schema
version can reject a value that a newer release added. For example, a
BatchResultserialized withtermination.kind == "budget_exceeded"(v0.27) can't be read by v0.26, althoughRESULT_SCHEMA_VERSIONis still 1.
Resume policies
ResumePolicy.NONEnever reuses old results but still checkpoints new ones.ResumePolicy.REUSE_SUCCESSESreuses the newest compatible success and reruns failures, missing items, and stale items.ResumePolicy.REUSE_ALLalso reuses the newest compatible terminal failure.
Replayed items do not call the provider and are not appended a second time.
They retain historical output, timing, error, and token use, receive the current
run's submission_index and current work-item context, and set
replayed_from_artifact=True. Persisted historical context is never decoded or
substituted during replay. Historical
tokens remain visible on the item for audit but are excluded from newly consumed
provider-token statistics returned by processor.get_stats(). In contrast,
BatchResult aggregate token fields are computed from all returned results and
therefore include replayed historical usage. This makes live processor stats a
"spent this run" view and the collected batch an auditable result-history view.
For a callable, compatible replay likewise bypasses the invocation callback. Changing its explicit identity invalidates replay and executes it again.
When a billed response fails parsing or validation, raise
TokenTrackingError(token_usage=...) from the callback. That attempt's usage is
retained and added to later successful or terminal usage. ABL cannot account
for transport retries hidden inside an upstream gateway unless the gateway
reports them.
Setting include_output=False makes a success record audit-only and therefore
ineligible for replay. An ordinary provider failure remains reusable under REUSE_ALL because it
does not need a successful output. Guardrail audit, filtered, and artifact
serialization failures are never replayed, and neither are configuration failures
(see Middleware and replay).
A JSONL iterator started before the same store's first writable preparation can overlap tail truncation and subsequent appends. In that narrow case, it may see new records within its original byte range. Finish inspection before starting writable use if a strict snapshot is required.
Item-local artifact serialization failures
Built-in JSONL and SQLite stores validate and UTF-8 encode records before writing.
An invalid output, metadata, persisted context, or cost-calculator result makes
that item fail with artifact_serialization_error while neighboring items finish.
The returned result retains its output for salvage and its token usage and timing.
The checkpoint is a minimal failure record: output, metadata, raw prompt, raw
context, and calculated cost are omitted. It cannot replay even under REUSE_ALL,
so fixing an encoder or persistence configuration allows the item to run again.
Error messages identify the failing JSON path where applicable. Lone surrogates
are rejected in application data. Stored diagnostic error and exception text
escapes them as \ud800-style sequences; in-memory error text is preserved.
Canonical encoding and fingerprints for valid Unicode do not change.
Serialization failures do not invoke middleware on_error or retry the provider
within the same run. abort_on_error_categories={"artifact_serialization_error"}
can stop a run after that item's failure checkpoint. Existing guardrail audit
results keep their original category if their payload needs the same minimal
projection. Only batch-abort, batch-budget, and batch-deadline audit writes remain best-effort;
a failed total-item-deadline checkpoint remains fatal. I/O errors, invalid artifact
format, unprepared store keys, custom-store failures, and failure to serialize or
write even the minimal checkpoint still propagate under those same rules.
Subclassing the built-in stores is not a supported extension interface. In
particular, overriding append does not intercept their private minimal-failure
write path. Implement the public ArtifactStore protocol for custom write or
redaction policies.
A provider success event may already have reached lifecycle observers before a checkpoint serialization failure is discovered. Until terminal-event handling is unified, observer success counts can differ from final result and processor counts on this path. No second terminal event is emitted.
Inspection decode-error messages identify one-based record positions on both
backends. JSONL's stored record_sequence still starts at zero; SQLite's starts
at one. This changes diagnostics only, not stored keys or replay ordering.
Privacy controls
Prompt and context hashes are always stored for matching, but raw prompts and raw context are excluded by default. Raw provider responses are never stored. The independent options are:
include_output=True(default);include_metadata=True(default);include_prompt=False(default); andinclude_context=False(default).
Outputs and metadata can themselves contain sensitive application data. Review their shape before enabling artifacts, or disable either field. Raw prompt and context persistence must be explicitly enabled.
Generate, review, apply
Artifacts support a provider-free review/apply phase:
from async_batch_llm import JsonlArtifactStore
review = JsonlArtifactStore.read_results(
"runs/customer-tagging.jsonl",
successes_only=True,
)
for item in review.results:
await apply_approved_output(item.item_id, item.output)
Inspection restores raw context only when the writer explicitly used
include_context=True. Without a context_decoder, the restored value remains
JSON-native; a supplied decoder is applied only when stored context exists.
Context omitted by the default privacy policy, including an original None,
inspects as None.
Replay never substitutes that historical audit context for the live input and does not invoke the stored-context decoder. It always attaches the current work item's context and submission index to the replayed result.
For asynchronous inspection through an open store, use
async for item in store.iter_results(successes_only=True).
Costs are optional and caller-supplied because this package does not maintain a provider price database:
store = JsonlArtifactStore(
"run.jsonl",
identity=identity,
cost_calculator=lambda item: calculate_cost_from_current_prices(item),
)
The artifact stores null when there is no calculator.
Indexed SQLite artifacts for large restartable runs
SqliteArtifactStore (v0.21) stores the same logical version-1 records in a
standard-library SQLite database with indexed replay lookup and bounded
iteration. It exists for 100k–1M-item restartable runs where scanning or
retaining a JSONL history on open becomes the bottleneck; JSONL remains the
recommended portable, human-inspectable audit format.
from async_batch_llm import ResumePolicy, SqliteArtifactStore, process_stream
store = SqliteArtifactStore("runs/customer-tagging.sqlite")
async for item in process_stream(
strategy,
prompts,
artifact_store=store,
resume=ResumePolicy.REUSE_SUCCESSES,
):
await sink(item)
Everything documented above for JSONL applies unchanged: identity resolution
and explicit ArtifactIdentity, the complete compatibility fingerprint, the
three resume policies, replay accounting, privacy controls
(include_output / include_metadata / include_prompt /
include_context), custom encoders/decoders and context_fingerprinter, and
optional cost_calculator. The two backends share one codec, so a record
that would replay from JSONL replays from SQLite under the same conditions.
Context-free items match through a NULL-safe indexed predicate. There is no
automatic JSONL→SQLite import; opt in by creating a new store, and existing
JSONL files remain fully supported.
| Backend | Best fit | Reopen behavior | Write behavior | Human inspection |
|---|---|---|---|---|
| JSONL | Small/medium runs, portable audit log | Resume scans/indexes complete history; iter_results() streams line pages |
One flushed line per record | Excellent |
| SQLite | 100k–1M restartable runs | Indexed replay and bounded keyset iteration — no history decode on open | Batched transactions with WAL checkpoints | Use SQLite tools/API |
“100k+” is a recommendation, not a cliff — the crossover depends on record size and environment.
Batched commits and durability
Appends are grouped into transactions (commit_batch_size, default 100,
within commit_interval_seconds, default 0.01). An append() — and therefore
the processor's publication of that item's result — completes only after its
transaction commits, preserving checkpoint-before-result-publication. A
transaction failure rolls back without exposing partial rows.
SqliteDurability selects the synchronization policy:
BALANCED(default) — WAL withsynchronous=NORMAL. A normal process crash retains a consistent database and committed history. A sudden OS or power failure may lose transactions committed since the last WAL sync; the loss window can span more than one transaction.FULL— WAL withsynchronous=FULL; stronger power-loss durability at lower throughput. Exact guarantees always depend on SQLite, the OS, filesystem, and storage hardware. On macOS, SQLite syncs with plainfsync()unless itsfullfsyncpragma is enabled (off by default, and the store does not set it), so neither policy guarantees power-loss durability there.
WAL auto-checkpointing keeps the log at a bounded plateau during healthy
writes; close() drains accepted appends, attempts a truncating checkpoint,
and terminates the store's worker thread. An external long-lived reader can
make that final checkpoint report “busy” — recorded, not an error and not
data loss. SQLite may leave an -shm sidecar after a healthy close, and the
database directory will transiently contain -wal/-shm files during a run.
ABL does not encrypt SQLite artifacts; use filesystem permissions and volume
encryption as your deployment requires.
Cleanup finishes before close() reports an error. A previously unobserved
writer or cancelled-caller operation error is delivered first; a distinct
checkpoint or connection-close error is retained for the next close().
Each store-level error is delivered once, and later closes return silently.
Observing an error never restores a failed store to usability.
Inspection
Inspecting an existing SQLite store before its first run does not pin its model identity. Its first prepared item registers the resolved identity before appending, even if the connection was already opened by inspection. Automatic stores still reject mixed effective identities within one run.
Live store.iter_results() first settles detached errors, prepares the
writable store, and flushes its accepted writes. It then streams a finite
committed high-water snapshot in keyset-paged batches (read_batch_size),
decoding one row at a time. No cursor or read transaction is held across an
async yield, so a slow consumer never blocks the writer or WAL checkpointing.
SqliteArtifactStore.read_results(path) is a separate path-based,
materializing convenience. It opens an internal SQLite mode=ro connection,
never creates a schema or identity, never starts a writer, never changes WAL or
synchronization policy, and never checkpoints an active writer. Normal reads
participate in SQLite locking and change detection, allowing a writer to start
before or during inspection; SQLite may create empty -wal/-shm
coordination sidecars in a writable directory. A clean database in a
non-writable directory uses SQLite's immutable fallback, under the filesystem
guarantee that no writer can start while the read is active. The method
captures a committed high-water sequence and excludes rows committed later; a
subsequent read sees them. Its connection and owned reader thread close after
success, error, or ordinary cancellation. Repeated cancellation remains prompt
and lets the already-running reader thread clean itself up without
synchronously blocking the event loop. Unlike the synchronous JSONL
counterpart, it is an async classmethod because SQLite I/O stays off the
event loop:
from async_batch_llm import SqliteArtifactStore
review = await SqliteArtifactStore.read_results(
"runs/customer-tagging.sqlite",
successes_only=True,
)
Opening a writable store validates schema and version markers, records the current run's distinct identity when needed (a sequential identity history for provenance), and never decodes stored history before a lookup needs it. The read-only path validates the same application ID, versions, tables, columns, required index names, and manifest without recording an identity. Each item row consumed by either path is checked against the supported logical artifact schema before its result or persisted context JSON is decoded; unsupported or malformed row versions fail loudly instead of falling back to an older compatible row.
Process-safety boundary
Both stores are single-process, single-writer designs. JsonlArtifactStore
serializes writes with an asyncio.Lock, guaranteeing complete,
non-interleaved JSON records for concurrent workers sharing one store
instance in one process. SqliteArtifactStore owns one connection, one
worker thread, and one writer task per instance; sequential reopen is
supported and a concurrently open external reader obeys the configured busy
timeout. Neither implements distributed work claiming, leasing, or
cross-process exactly-once execution — use one writer per artifact.
On open, the JSONL store still builds an in-memory replay index over complete
item records for constant-time resume lookup (its iter_results() streams
bounded line pages instead of materializing history, as of v0.21). Very large
audit histories are exactly what SqliteArtifactStore is for.