[FIX] Surface a failed document upload instead of hanging S3 staging - #522
[FIX] Surface a failed document upload instead of hanging S3 staging#522noel-improv wants to merge 3 commits into
Conversation
…ng staging _upload_batch polls until it has seen one item per submitted document, but three except blocks could each leave that count unreachable, so a failed upload became a run that never returned. What hid it was _upload_doc swallowing its own S3 error and returning None: the count advanced, and the document was yielded and counted as staged. The two are a pair, so removing the swallow alone turns a silent success into a hang. Every submitted document now puts exactly one item on the queue, a failure marker when the upload raised. The publisher puts its count in a finally so a producer that dies still releases the consumer, the consumer also breaks out when its producer is gone, and the first failure is raised once the batch drains. A document that failed to write is no longer yielded. Confirmed against a real boto3 client: a ClientError from S3 propagates, nothing is reported as staged, and the call returns.
7571fda to
d2554c2
Compare
…alled the queue The class carried a second _task_complete_callback that released the semaphore without putting anything on the queue. Nothing has ever wired it up, and connecting it would reproduce the staging hang this change fixes, on every document rather than only on a failed upload.
| doc = future.result(timeout=1.0) | ||
| queue.put(doc) | ||
| queue.put(future.result(timeout=1.0)) | ||
| except Exception as e: |
There was a problem hiding this comment.
should this catch BaseException like we do in _doc_publisher?
| count += 1 | ||
| yield item | ||
| if isinstance(item, _UploadFailed): | ||
| failure = failure or item.cause |
There was a problem hiding this comment.
Only the first upload failure in a batch is surfaced, can we store and report all document failures?
| except queue.Empty: | ||
| # A producer that died before putting its count would otherwise | ||
| # keep the consumer here for the rest of the run. | ||
| if not thread.is_alive(): |
There was a problem hiding this comment.
breaking out here leaves failure as None and count < target_count, so the generator just ends normally. The if failure is not None: raise failure below is skipped, and upload() goes on to report "Finished writing N source documents" for a batch that was actually truncated.
That's the exact silent partial-success mode this PR set out to eliminate — it's just narrowed to the abnormal path where the producer dies before publishing its count (e.g. hard-killed thread, or put(count) itself never completing).
Suggest raising instead of returning silently when we break on a dead producer:
except queue.Empty:
# A producer that died before putting its count would otherwise
# keep the consumer here for the rest of the run.
if not thread.is_alive():
if failure is None:
failure = RuntimeError(
f'Upload producer died before completing '
f'[count: {count}, target_count: {target_count}]'
)
break
That way a truncated batch surfaces as an error rather than a success, keeping the guarantee consistent with the normal-path _UploadFailed handling.
Breaking out of the consumer when the producer is gone left the batch short with nothing raised, so upload() reported a truncated batch as written. That is the silent partial success surfacing upload failures was meant to remove, narrowed to the path where the producer stops before reporting its count. Also from review: - The done-callback catches BaseException, matching _doc_publisher. An interrupt in a worker strands the consumer the same way a ClientError does. - Every document failure is kept rather than only the first, and the count is logged. The first is still raised, since 3.10 has no ExceptionGroup. The dead-producer test asserted only that the consumer did not hang, which is what let this through; it now asserts the raise as well.
d35c120 to
3105d41
Compare
Description
A failed document upload during S3 staging either hangs the run with no exception, or is reported as successfully staged. Both are silent, so a failed upload looks like a slow run or a clean one.
_upload_batchpolls until it has seen one item per submitted document and treatsqueue.Emptyas "keep waiting", never checking whether its producer is still alive. Threeexcept Exception: logblocks could each leave that count unreachable.What stops it firing today is
_upload_docswallowing its own S3 exception and returningNone. The callback puts thatNone, the count advances, and the document is yielded and counted in the "Finished writing N source documents" total. The two defects are a pair: removing the swallow alone turns a silent success into a hang.Changes
One invariant — every submitted document puts exactly one item on the queue.
_get_callback_fnalways puts, a failure marker when the upload raised, and releases the semaphore in afinally._submit_proxyreports whether it submitted, so the publisher counts only what the consumer will see._doc_publisherputs its count in afinallyand catchesBaseException, so a producer that dies still releases the consumer._upload_batchbreaks out when the queue is empty and the producer is gone, and raises the first failure once the batch drains._upload_docstops swallowing.The queue poll drops from 60s to 1s. It paces the liveness check rather than the work, so a dead producer is noticed in about a second instead of a minute.
Problem
Related issue (if any): #
Introduced in
4b8743c0("Improved batch extract and S3BasedDocs for large ingests") rather than by a targeted change. #418 is the same symptom in the same path from a different cause and is already fixed.Testing
pytest) — 2,112 intests/unitFive tests, red before green, covering a failing document mid-batch, a dead producer, and the all-succeed path. No test previously failed a document mid-batch, which is the gap that hid this.
Checked against a real boto3 S3 client rather than a mock:
put_objectreturns a genuineClientError, the error propagates to the caller, no document is reported as staged, and the call returns instead of hanging.One pre-existing failure is unrelated and reproduces on a clean tree:
test_integ_dependency_compatibility.pyerrors withNo module named pipin this venv.Checklist
A caller that relied on a failed upload being silently skipped will now see the exception. That is the intended change.