Skip to content

[FEATURE] End a source document when its last part lands - #561

Open
noel-improv wants to merge 12 commits into
awslabs:mainfrom
noel-improv:feat/end-a-source-when-its-last-part-lands
Open

noel-improv wants to merge 12 commits into
awslabs:mainfrom
noel-improv:feat/end-a-source-when-its-last-part-lands

Conversation

@noel-improv

@noel-improv noel-improv commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Description

Follows #560, which skips a source document prefix its markers do not account for. A marker covers one SourceDocument, and extraction emits a source as several of those when its chunks span rounds. Each part was marked as it landed, so the markers under the prefix accounted for exactly the chunks present and a source that lost its later parts read back as a whole document.

The part that ends a source now declares every chunk id stored for that source, and the reader compares the objects present against that declaration.

Reproduced against S3 before the change: a source staged as two rounds read back whole at 4 chunks, and with round two removed it read back as a valid 2-chunk document rather than being skipped.

Changes

  • SourceDocument.final_part says whether a document holds the last of its source's nodes. Defaults to true, so every producer that emits a source as one document is unchanged.
  • ExtractionPipeline names the sources a round leaves with nothing left to extract, and marks only the last document emitted for one of those. Chunks are counted on the way in: a checkpointed run extracts fewer nodes than it was handed, so counting output would leave a resumed source open for good.
  • Both uploaders accumulate the chunk ids they stored per source. The marker on the part that ends a source carries the whole set, and is_complete compares the prefix against that.
  • Running out of stream ends any source still open. A round can finish a source without emitting a document for it, so the closing part never arrives; a run that dies instead never reaches this point and its prefix stays incomplete.
  • A source whose chunk upload failed is dropped rather than closed, so it gets no marker either way, as in [FEATURE] Write a completion marker when every chunk for a document stored #532.

Problem

Three things make a bare per-document marker insufficient:

  • A source can split within one round, not only across rounds. The batch extractor sorts its output by node id and documents are cut on contiguous runs of source id.
  • The JSONL upload pool gives no ordering guarantee between parts, so a closing marker can be written before an earlier part's object.
  • Chunk and marker write failures are logged rather than raised, so a part can be marked over a source whose earlier part partly failed.

A declared set survives all three. A flag on its own does not.

Related issue (if any): #560

Testing

  • Unit tests added/updated
  • Integration tests added (as appropriate)
  • Existing tests pass (pytest)
  • Tested manually (describe below)

Unit tests cover a source split across rounds, a source split within one round by sorted output, a round that drops chunks, each source ending on its own last document, the declared set on both uploaders, a source left open by the end of the stream, and a source whose chunk failed getting no marker. Full unit suite: 2,320 passed.

tests/integration/indexing/load/test_s3_staging_recovery_live.py runs against real S3 and now covers a split source losing its opening part, in both storage formats. 18 cases pass in us-east-1.

Each half was reverted in turn: forcing every part to be final failed the two split tests, and dropping the declared set from the marker failed the three declaration tests.

End to end against Bedrock, S3 and Neo4j: 3 documents extracted with 3 markers and the collection record, an intact build giving 3 __Source__ nodes, and a rebuild after removing one marker giving 2.

Checklist

  • Code follows existing style and conventions
  • License headers present on new files
  • Documentation updated (if applicable)
  • No breaking changes (or clearly documented)

One behaviour worth naming for review: a run that completes normally closes every source still open, declaring what was stored. That is what makes a source whose closing part never arrived readable at all, and it means the end of a run is treated as evidence. A run that dies is not, and leaves the prefix incomplete.

Not in this PR

  • FileBasedDocs writes no markers and is unchanged. The final_part default keeps it behaving as it does today.
  • Reporting skipped documents back to the caller. The warning names each prefix; a restart that acts on the list needs more than that.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

A run killed part way through leaves a source document prefix holding some
of its chunks and no completion marker. The reader merged whatever it found
into one document, so the build saw a short document rather than a missing
one, and a restart had no way to tell the two apart.

The collection decides whether its prefixes must carry a marker. Staging
writes a record under the collection before any document; a reader that
finds the record holds every prefix under it to its markers and skips the
ones they do not account for. A collection staged before markers existed
carries no record and is read as it stands, so nothing already staged is
re-extracted.

Markers started on the per-chunk path only. Without one on the JSONL path a
recorded collection in that format would read back as incomplete for good,
so S3DocUploader writes a marker for each object it stores.
Stages a collection, damages it the way an interrupted run would - the
marker missing, or an object gone from under a marker - and reads it back.
Mocked tests can state that shape but not that S3 produces it: the listing,
the reserved marker segment and the collection record have to line up
against a real endpoint.
…isting

Addresses review of the incomplete-document reader.

Only an empty collection is recorded, so staging into one written before
markers existed leaves its documents reading as they do today rather than
turning incomplete and being extracted a second time. The record is written
before the uploader is kept, so a failed record leaves the next call to try
again instead of staging on unrecorded.

Whether a collection is recorded now comes out of the listing the reader
already performs, rather than a head_object. That drops a call per read and
the ambiguity of a 403 standing in for a 404 under a prefix-limited policy.

A marker name carries the whole digest, so two markers under one prefix
cannot share a name and lose the first. A chunk id is taken by stripping
what the writer added, so an id holding a '/' or a '.' still matches its
marker. Markers are read during the prefetched listing rather than by a
blocking GET per document on the way out.

is_complete now states what a marker does not cover: extraction emits a
source as several SourceDocuments when its chunks span rounds, and each is
marked as it lands, so a source whose later parts were never stored reads
as complete. Closing that needs the extraction pipeline to record when a
source is finished.
@noel-improv
noel-improv force-pushed the feat/end-a-source-when-its-last-part-lands branch from 68c338c to 81af972 Compare September 17, 2026 02:48
@noel-improv noel-improv self-assigned this Sep 17, 2026
…ion prefix

The S3 uploaders joined a source id and a node id onto the collection prefix
without checking either, so a separator in one wrote the object somewhere else.
A node id under the reserved marker segment was worse than a stray write: the
chunk read back as a completion marker, the declared set no longer matched what
the prefix held, and the source document was skipped on every read, including
after a re-stage that wrote the same key again.

FileSystemTap and S3ChunkStore each carried their own id check, one a denylist
and one an allowlist. Both now call one allowlist, which closes the encoded and
Unicode separator variants a denylist misses.
How a separator behaves in a key segment is a property of the service, not of
the string: botocore sends segments unencoded. These stage a hostile source id
and a hostile node id against a real bucket and assert nothing of the document
lands, alongside a generated-id control that still stages and reads back.
Extraction emits a source as several source documents when its chunks span
rounds, and staging marked each part as it arrived. The markers under the
prefix then accounted for exactly the chunks present, so a source that lost
its later parts read back as a whole document.

The pipeline now names the sources a round leaves with nothing left to
extract, and only the last document emitted for one of those ends its
source. Chunks are counted on the way in rather than on the way out, because
a checkpointed run extracts fewer nodes than it was handed and counting
output would leave a resumed source open for good. A source can also split
within one round: the batch extractor sorts its output by node id, and
documents are cut on contiguous runs of source id.

The uploader accumulates the chunk ids it stored for each source, and the
marker on the part that ends a source declares the whole set. That is what
the reader compares against, so a prefix missing an earlier part fails
whatever order the parts were written in, and neither the unordered JSONL
upload pool nor a logged-and-swallowed chunk failure can pass a short prefix
off as whole.

A round can finish a source without emitting a document for it, since a
resumed run drops chunks it has already extracted, so the part that would
have ended the source never arrives. Running out of stream ends those,
declaring what was stored. A run that dies never gets there, which leaves
the prefix incomplete. A source whose chunk upload failed is dropped instead,
so it gets no marker either way.
Three ways a completion marker said more than it knew.

A closing marker declaring an empty chunk set was read as declaring nothing at
all, because the fallback to the part's own ids treated an empty list the same
as a missing field. Such a marker then certified only the part it sat on.

A source that lost a chunk was dropped from the open set, but a later part put
it back, so a surviving final part closed a source whose prefix is missing a
chunk. The loss is recorded now and checked before any marker is written.

The doc uploader ended its open sources per batch rather than per stream, so a
source whose parts straddled a batch cut was closed declaring only the parts
staged up to the cut. If the run then died, the truncated prefix read complete.

A collection that is not recorded because it already holds an object is read
without completeness checking for the rest of its life, which is worth more
than a debug line.
_extract_fixed_batch held its own copy of the tail _emit_extracted runs, and
the copy never set final_part. So when a caller passes pre-chunked nodes and
node_batcher slices one source across worker batches, every part marked itself
as ending its source and declared only its own chunks. A prefix missing the
rest then read as complete.

The copy is gone. _emit_extracted now decides final_part for every caller, and
finished_sources of None means every source it was handed is finished, which
is what the fixed-batch path has: each batch extracts every node fed into it.
EncryptedPut had collected the collection record, the open-source tracking and
the marker write, none of which are encryption. Those move to CompletionMarkers,
which both uploaders inherit alongside it, and EncryptedPut is back to _put.

The tracking itself was written once per uploader with different failure
handling, which is where the closed-a-poisoned-source bug lived. Both now call
_open_source, _end_source and _poison_source on the mixin, so there is one
place to get it right. Ending a source that was never opened returns None
rather than an empty list, so it stays open instead of declaring that it
stores nothing.

The JSONL reader downloaded and parsed every object under a prefix before
deciding the prefix was incomplete and discarding the result. A prefix with no
marker cannot read complete, and the listing already says so, so that case
returns before any download. Its chunk ids live in the object bodies rather
than the keys, so the rest of the check still needs them read.

written_nodes ran twice for the same document in the publisher and twice again
in the worker; it runs once in each now.
@noel-improv
noel-improv force-pushed the feat/end-a-source-when-its-last-part-lands branch from 81af972 to 501c251 Compare September 17, 2026 22:53
@noel-improv
noel-improv requested review from acarbonetto and oussamahansal and removed request for oussamahansal September 17, 2026 23:02
Comment thread lexical-graph/src/graphrag_toolkit/lexical_graph/indexing/load/s3_based_docs.py Outdated
…its own

Every node a part carries can be a vector store artefact, which written_nodes
filters out. The doc publisher still ended the source for such a part, but the
worker writes no marker when there is nothing to store, so nothing closed the
source and the end of the stream had nothing left to close. The prefix then
read incomplete on every later run. The chunk uploader never had this, because
it keys its submit loop on written_nodes.

A part with nothing to store now leaves its source open, and the end of the
stream ends it declaring what the earlier parts stored.
A run that dies mid-source leaves a part marker behind. The resumed run's
checkpoint drops the chunks that part already stored, so the part that ends the
source declares only what this run staged, and the completeness check compared
that against every object under the prefix. The source read incomplete with all
of its chunks present, and the marker key is a digest of the chunk ids, so a
re-run reproduced the same declaration and the document never built.

A part that leaves its source open now counts towards the declaration. This
also closes the other direction: a prefix whose earlier part's objects are gone
used to read complete, because nothing in the declaration accounted for them.
CompletionMarkers annotates _open_sources and _poisoned_sources as supplied by
the host, but pydantic does not collect PrivateAttr from a plain mixin, so a
host that leaves them out fails on first use rather than at construction.

sources_finished_by reads as a query and consumes from outstanding, so it
answers for each round once.
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.

2 participants