Batch-sets
A batch-set is Enscrive’s first-class record of a single ingest operation that was split into one or more provider batch jobs (OpenAI, Nebius, Voyage). It owns the full story of a multi-hour async embed — how many sub-batches it split into, which provider batch ids it submitted, how many documents succeeded or failed, which attempts have been made, and whether the results ever promoted to the live corpus.
If you are ingesting thousands of documents at once, every such ingest produces a batch-set. The state machine and the atomic-promote guarantees below are what makes “ingest a million documents overnight” safe — a failure two hours into the embed cannot leak partial results into your live search surface.
Why a first-class record
Previous iterations tracked batch jobs as rows on import_jobs only. That made three things hard:
- Recovery semantics — when a provider batch failed two hours in, there was no single row describing what-was-submitted vs what-has-returned vs what-needs-retrying. Operators had to reconstruct state from logs.
- Cross-provider correlation — a single logical ingest can split into multiple provider batches (OpenAI caps at 50k requests per batch; 100k docs = two batches). No single row owned the set.
- All-or-nothing promote — with only per-job tracking, there was no point in time where you could say “every sub-batch succeeded, now we promote.” The new model moves the promote to the parent-set level so partial results cannot appear in your live corpus.
The state machine
ingesting ──► embedding ──► promoting ──► committed (terminal success)
│
└─► failed_recoverable ──► abandoned (terminal failure)
| State | What it means |
|---|---|
ingesting | Documents have been added to the staging area; chunking/sub-batch planning in progress |
embedding | Provider batch job(s) submitted; awaiting embed results |
promoting | All sub-batches succeeded; writing embeddings to the live corpus |
committed | Everything promoted. committed_revision_id is set. completed_at is written |
failed_recoverable | One or more sub-batches failed with a retryable error class. The staging collection(s) are preserved for 24h (the ttl_expires_at window) so you can retry |
abandoned | Terminal failure — either an explicit operator abandon, or an auto-abandon after ttl_expires_at passes |
The critical property: no state between ingesting and committed makes embeddings visible in the live corpus. The promote is parent-level and atomic — either every sub-batch made it, or nothing did. If sub-batch 6 of 7 fails, the staging collections for sub-batches 1–5 are dropped and the live corpus is unchanged. This is enforced structurally inside embed-svc, not by convention.
What a batch-set row contains
{
"id": "b7ca9a0…",
"corpus_id": "787cfadf…",
"tenant_id": "…",
"environment_id": "…",
"state": "committed",
"document_count": 63926,
"chunk_count": 63926,
"total_tokens": 12400000,
"sub_batch_count": 7,
"attempts": [
{
"started_at": "2026-04-19T15:49:00Z",
"ended_at": "2026-04-19T16:23:12Z",
"outcome": "success",
"failed_sub_batches": []
}
],
"committed_revision_id": "…",
"primary_provider": "openai",
"primary_model": "text-embedding-3-small",
"provider_batch_ids": [
"batch_69e572389…",
"batch_69e572438…",
…
],
"backfill_incomplete": false,
"created_at": "2026-04-19T15:49:00Z",
"completed_at": "2026-04-19T16:23:12Z",
"ttl_expires_at": null
}
Key fields:
| Field | Notes |
|---|---|
state | One of the six states above |
document_count | How many documents were in the original ingest |
chunk_count / total_tokens | Populated once the chunking phase completes (so null while state = "ingesting") |
sub_batch_count | How many provider batches this set split into. One per ~10k chunks by default (UNIFIED_BATCH_CHUNK), with per-provider caps as a safety net |
attempts | Ordered log of every attempt. Each entry records the window, outcome, and which sub-batches failed (with error class + message + retry count) |
committed_revision_id | Null until state = "committed" — at that point it points at the corpus-revision row produced by the promote |
provider_batch_ids | The external ids you can search for on the OpenAI / Nebius / Voyage dashboard to correlate with provider-side views |
backfill_incomplete | True only for batch-sets backfilled from pre-J-024 historical data where not every field could be reconstructed |
ttl_expires_at | Set when transitioning to failed_recoverable; defaults to now() + 24h. After this passes, the Unit 4 reconciliation sweeper transitions to abandoned |
Get one batch-set
GET /v1/batch-sets/{id}
Returns the full BatchSet row above. Tenant-scoped — rows belonging to another tenant return 404 Not Found (no existence leak).
List batch-sets for a corpus
GET /v1/corpora/{id}/batch-sets?limit=50&offset=0
Newest-first, paginated. Default limit = 50, maximum 200. Only batch-sets belonging to the caller’s tenant are returned.
enscrive batch-sets list --corpus enscrive-platform-docs
enscrive batch-sets get 0f9a01c5-…
Retry and abandon
Retry and abandon live under the Jobs endpoint group, not here — a batch-set is linked 1:1 to an import_jobs row via its batch_set_id FK, and operator-facing verbs attach to that job.
POST /v1/jobs/{id}/retry # re-submit the failed sub-batches of the batch-set
POST /v1/jobs/{id}/abandon # explicitly mark abandoned; drops staging collections immediately
A retry re-runs only the sub-batches whose outcome is failed — embedded sub-batches are not re-embedded. The retry appends a new entry to the attempts log so you keep the full history. Retries are bounded by provider error class: only transient and certain provider_5xx classes retry automatically; permanent and validation classes must be abandoned and re-started with corrected input.
An abandon moves the batch-set to abandoned, drops any remaining staging collections so their Qdrant capacity is released, and records the operator-initiated abandonment. Abandon is the right verb when you know the underlying ingest was wrong (bad metadata, wrong voice, wrong corpus) and retrying would just succeed at embedding bad data.
See Jobs — retry and abandon for the full semantics.
Why you might want to inspect a batch-set
- Operator dashboards — the batch-set row is the canonical “what is happening right now with my ingest?” summary. Provider-side dashboards show per-sub-batch progress, but only the batch-set ties them back to your corpus and your document set.
- Post-mortem a failed commit —
attemptsgives you the full failure timeline;failed_sub_batchesgives you the specific error class and message per sub-batch;provider_batch_idslets you cross-reference the provider dashboard for their side of the story. - Cost accounting —
chunk_countandtotal_tokenson the row are the authoritative billing surface for that ingest. Combined with the model’s price, this is the exact dollar figure the batch cost you.
Parallelism and throughput
Sub-batches within one batch-set dispatch in parallel under a concurrency governor (J018_MAX_CONCURRENT_SUB_BATCHES = 10). On a 7-sub-batch ingest you’ll typically see all seven provider batches submitted inside a few seconds of each other, running concurrently.
Promote, by contrast, is sequential and parent-level. The server waits until every sub-batch returns success, then walks the sub-batches in order applying each to the live corpus. A failure at any stage before the final promote leaves the live corpus unchanged.