Skip to content

[FEATURE] Write a completion marker when every chunk for a document stored - #532

Merged
noel-improv merged 5 commits into
awslabs:mainfrom
noel-improv:feat/uploader-completion-marker
Sep 14, 2026
Merged

noel-improv merged 5 commits into
awslabs:mainfrom
noel-improv:feat/uploader-completion-marker

Conversation

@noel-improv

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

Copy link
Copy Markdown
Collaborator

Description

Nothing recorded whether a document's chunks all reached S3, so a truncated prefix and a short document read back identically. S3ChunkUploader._drain caught each chunk's exception, logged it and returned nothing, which #507 documented rather than fixed.

_drain now reports whether every upload landed. A document whose chunks all stored gets a marker holding its chunk ids, their count and a hash over them, written last. A document with a failed chunk carries no marker and is still yielded, exactly as today.

Related issue: #325.

Changes

  • _drain returns whether all of a document's uploads succeeded.
  • _write_completion_marker writes the marker after that check passes. A failed marker write is logged rather than raised. The chunks themselves uploaded, so stopping the run costs more than the re-stage a missing marker causes.
  • Both downloaders exclude marker objects. Neither filtered by extension, and TextNode.from_json returns a node with a generated uuid and empty text for a marker rather than rejecting it, so an unfiltered listing would add one extra node to every read of that document.
  • A document with nothing to write no longer creates a prefix. Previously an index-only document produced a prefix holding just a marker, which reads back as a document with no nodes, whose source_id() is None, and a re-stage cannot build a path from None.

Three duplications collapsed rather than extended:

before after
index-key node filter two forms, one convoluted written_nodes(doc)
node-id hash inline in _doc_suffix node_ids_hash()
KMS vs AES256 four copies EncryptedPut._put

Why the marker name contains a digest

An auto-tuned run emits one source as several SourceDocuments, which share a prefix. With a fixed marker name, the last one written replaces the others. With two documents where the second loses a chunk:

objects : ['_COMPLETE', 'c1.json', 'c2.json', 'c3.json']
marker  : {'chunk_ids': ['c1','c2'], 'count': 2, ...}

Three of four objects are present, with a marker that reports the document as complete. #518 addressed the same collision for the document key by including a hash of the node ids; the marker name now does the same.

Five hex characters is 20 bits, the same budget _doc_suffix uses, and the same population: the SourceDocuments sharing one prefix. Brute-forced against realistic chunk ids, the first name collision appears at 1,672 documents in one prefix, with birthday odds of 0.47% at 100 and 38% at 1,000. So the bound is tens of documents per prefix, which is what the auto-tune path produces when it slices a source into max_batch_size buckets. A name collision loses a completeness record rather than data. The remaining marker still lists one document's chunks, so a reader comparing coverage re-stages rather than reporting the document complete.

Testing

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

19 new tests. 2154 pass across tests/unit.

Each change was checked by mutation testing, not only by the tests passing. Reverting to a fixed marker name fails 2 tests; removing the ServerSideEncryption headers fails 3; yielding an empty document immediately rather than queueing it fails 2. The encryption check matters because that branch had no assertions at all before this, so collapsing four copies of it was otherwise unverifiable.

One pre-existing failure is unrelated and reproduces on a clean tree: test_integ_dependency_compatibility.py errors with No module named pip in this venv.

Checklist

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

Verified against real S3

Uploaded a two-chunk document to noel-graphrag-test and read it back:

coll/aws::live:test/_COMPLETE-23840          <- marker
coll/aws::live:test/aws::live:test:a.json
coll/aws::live:test/aws::live:test:b.json

marker encryption : AES256
read back         : ['aws::live:test:a', 'aws::live:test:b']
phantom chunk?    : False

Two things worth your call

Every ingest now writes one extra object per document, and nothing reads it yet. The restart work is expected to be enabled by a setting, but that setting does not exist yet, and adding one with no code reading it would be configuration no caller can use. The write is therefore unconditional. Say if you would rather it waited until the restart setting exists.

The encryption consolidation is separable from the feature. Reverting the marker would revert it too. I folded it in because writing the marker otherwise meant a fifth copy of the same KMS branch, but it touches S3DocUploader, which this feature does not otherwise change. Happy to split it into its own PR.

Known limitations

Nothing reads the marker yet, so chunk_ids, count and content_hash are stored and never validated. Read behaviour is deliberately out of scope here.

The marker name is the first five characters of the content_hash in its own body, so the two carry the same value at different precision. The name has to be short; the body field is what the acceptance criteria ask for.

A stale marker from an earlier run is not deleted. With per-document names a re-chunked document writes a different name and leaves the old one, which a reader catches by comparing the hash against a live listing. That comparison belongs with the read path.

S3DocUploader writes no marker, but S3DocDownloader skips one. Both uploaders write into the same prefix shape, so the skip is defensive rather than dead.

The marker PUT runs on the consumer's thread rather than the pool, adding one serialized round trip per document. The drain already blocks that thread, so this is additive rather than a change in shape.

Nothing recorded whether a document's chunks all reached S3, so a
truncated prefix and a short document read back the same. _drain
swallowed each chunk's exception and returned nothing, which awslabs#507
documented rather than fixed.

_drain now reports whether every upload landed, and a document whose
uploads all succeeded gets a marker holding its chunk ids, their count
and a hash over them. A failed chunk leaves no marker and still yields
its document, as it does today.

Both downloaders skip the marker key. Neither filtered by extension, and
TextNode.from_json accepts a marker as a node with a generated uuid and
empty text rather than rejecting it, so an unfiltered listing would have
turned the marker into a phantom chunk on every read.

Three duplications collapsed on the way: the index-key node filter, the
node-id hash the document suffix already used, and the KMS-versus-AES256
branch each uploader wrote out per object.
Peer review found the marker's identity was the prefix while its
lifecycle was a SourceDocument, and the two are not one-to-one.

An auto-tuned run emits one source as several SourceDocuments, which
share a prefix. With a fixed marker name the last one written spoke for
all of them: two documents where the second lost a chunk left three of
four objects in the prefix and a marker reading as complete, which
inverts what the marker is for. PR awslabs#518 solved the same collision for
the document key by hashing node ids into it; the marker name now does
the same.

A document with nothing to write no longer creates a prefix holding only
a marker. Such a prefix read back as a document with no nodes, whose
source_id() is None, and a re-stage cannot build a path from None. The
test that asserted the old behaviour was asserting a bug.

Encryption was four copies of one branch with no test on any of them, so
removing the headers altogether kept the suite green. Both branches and
the marker's own encryption are now asserted.
Second review pass caught a regression in the previous fix. Yielding an
empty document as soon as it was seen jumped every document already in
flight, and upload() promises documents come back in the order they went
in. Across randomised interleavings 24 of 30 came back out of order.

It now queues like any other document, with no prefix and no futures, so
the order holds while nothing is written for it. Verified 0 of 30 out of
order after the change.

The two encryption tests asserted nothing when the uploader wrote
nothing, since both looped over a capture list without checking it was
populated. Both now assert the count.

Also: the two downloaders read a listing the same way again, and a
comment claimed S3DocUploader skips documents with nothing to write,
which it does not.
Comments explained the same thing three times over. The rationale for
the marker name belongs in the PR, not wrapped around the constant, and
a one-line predicate does not need a seven-line docstring.

is_completion_marker hand-rolled a basename with rsplit; os.path.basename
was already half-imported in this module.

Net 25 lines of comment removed, no behaviour change.
@noel-improv
noel-improv marked this pull request as ready for review September 10, 2026 16:39

@mykola-pereyma mykola-pereyma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

…space

Markers sat beside the chunks and were recognised by name, but a chunk is keyed
by its node id, so a node id opening with the marker prefix was written as a
chunk and then excluded from the reconstructed document with no error.

Markers now live under a reserved `_markers` segment and are recognised by that
segment rather than by name. No collection has a marker yet, so the layout
change costs nothing; the reader in AN-3520 has to look in `_markers`.
@noel-improv
noel-improv force-pushed the feat/uploader-completion-marker branch from 7a54943 to f424d0d Compare September 12, 2026 00:39
@noel-improv
noel-improv merged commit 59c6614 into awslabs:main Sep 14, 2026
9 checks passed
@noel-improv
noel-improv deleted the feat/uploader-completion-marker branch September 14, 2026 22:27
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.

4 participants