Fix source blob backup ETag comparison failing every blob - #1273
Conversation
Every source blob in every Data Management backup failed with "Source blob changed while it was being backed up." at a 100% rate with zero retries, so user documents, group documents, public documents, and chat attachments were never actually backed up. The job still reported completed_with_warnings, which made the condition easy to miss. _transfer_backup_source_blob compared an ETag obtained from list_blobs() against one obtained from get_blob_properties(). Azure returns those in different transport formats: the List Blobs XML <Etag> element is unquoted while the HTTP ETag response header is an RFC 7232 quoted-string, and azure-storage-blob does not normalize either. The comparison was therefore always unequal. RuntimeError is not retryable, so each blob failed on the first attempt after already being downloaded, encrypted, and uploaded. Normalize both operands before comparing, stripping transport quoting and the optional W/ weak-validator prefix. source_item["source_etag"] still carries the exact value returned by list_blobs(), so the If-Match precondition sent on ranged reads is byte-for-byte unchanged. The guard is preserved: a genuine mid-transfer source change still fails. The migration path has a visually similar comparison but sources both operands from get_blob_properties(), so it is unaffected and left unchanged. Also included: - Batch source blob checkpoints. record_transfer_result previously persisted once per item, producing 19,394 Cosmos writes for a single container and capping throughput near six items per second. Checkpoint on manifest batch size or a 15 second interval instead, still asserting the job lease per item. - Fix functional tests that silently passed under pytest. Tests using the try/except and "return False" template return a value rather than raising, so pytest reported a broken build as 7 passed. Both backup test files now assert directly and return None. Fixes #1271
|
Copilot resolve the merge conflicts in this pull request |
Co-authored-by: paullizer <34814295+paullizer@users.noreply.github.com>
Merge conflicts resolved in 96d7c26. Only Because Verified after merging: |
|
Copilot resolve the merge conflicts in this pull request |
Co-authored-by: paullizer <34814295+paullizer@users.noreply.github.com>
Resolved again in 1792dd9 — Same resolution as before: only
Validation after the merge (installed the missing test deps this time, so the previously unrunnable tests actually execute):
|
Fixes #1271
Problem
Every source blob in every Data Management backup failed with
Source blob changed while it was being backed up.— 100% failure rate, zero retries, across all containers. User documents, group documents, public documents, and chat attachments have never been backed up. The job still reportedcompleted_with_warnings, which made it easy to miss.From job
data_management_partial_20260818T0300Z:user-documentsgroup-documentspublic-documentspersonal-chatRoot cause
_transfer_backup_source_blobcompares two ETags that come from different Azure SDK code paths with different transport formats:source_etaglist_blobs()get_blob_properties_from_generated_code()reads the XML<Etag>element0x8DE...current_source_etagget_blob_properties()BlobProperties(**headers)reads the HTTPETagheader"0x8DE..."The HTTP
ETagheader is an RFC 7232 quoted-string; the List Blobs XML element is not.azure-storage-blob==12.24.1normalizes neither — I grepped the installed package to confirm. So the check was always0x8DE... != "0x8DE...".RuntimeErroris not retryable under_is_retryable_backup_blob_error, so every blob failed on the first attempt — matching the observedRetries / throttles: 0 / 0.Independent confirmation the blobs did not actually change: ranged reads send that same unquoted ETag as an
If-Matchprecondition and Azure accepted them. Only the Python-side comparison was wrong. The guard also runs after staging and commit, so every blob was fully downloaded, encrypted, and uploaded before being discarded — and left behind as an orphanedpendingartifact.Changes
1. ETag normalization — new
_normalize_backup_etagstrips transport quoting and the optionalW/weak-validator prefix, applied only at the comparison site.source_item["source_etag"]deliberately keeps the exact value returned bylist_blobs(), so theIf-Matchprecondition sent to Azure is byte-for-byte unchanged from current production behavior. The guard is preserved — a genuine mid-transfer change still fails.2. Checkpoint batching —
record_transfer_resultpreviously calledpersist()per item: 19,394 Cosmos writes for one container, capping throughput near 6 items/sec and stretching the run to 74 minutes. Newmaybe_persistcheckpoints on manifest batch size (100) or a 15-second interval, whichever comes first. The job lease is still asserted every item, and the existing tailpersist()still flushes the final partial batch. Worst-case re-work after an interrupted run stays bounded at 100 items or 15 seconds.3. Functional tests that silently passed under pytest — while validating this fix I found the repo's standard test template hides failures:
Returning a value instead of raising makes pytest report the test as passed with only a
PytestReturnNotNoneWarning. A deliberately broken build reported7 passedwhile the transfer was genuinely failing. Both backup test files now assert directly and returnNone, with the__main__block preserving standalone output and exit codes. Suite warnings dropped 13 → 1.Scope check
The migration path (
_copy_source_blobs_to_target) has a visually identical comparison, but sourcessource_propertiesfromget_blob_properties(), so both operands are already quoted. Migration is not affected and is left unchanged.Validation
New test
functional_tests/test_data_management_backup_source_blob_etag.pydrives the real_transfer_backup_source_blobagainst in-memory blob clients with an unquoted listing and a quoted fetch.test_etag_normalization_strips_transport_quotingtest_listed_and_fetched_etags_compare_equalIf-Match; both formats equal once normalizedtest_transfer_succeeds_across_list_and_get_etag_formatssucceededtest_genuinely_changed_source_blob_still_failstest_verified_artifact_matches_source_versiontest_checkpoint_interval_is_boundedtest_version_is_at_least_fix_versionRegression probe: neutralizing
_normalize_backup_etagfails 4 of 7 tests, including the end-to-end transfer, reporting the exact production message:Full Data Management suite: 154 passed, 1 failed. That failure (
test_backup_recovery_and_admin_progress_are_bounded_and_sanitized) was confirmed pre-existing onorigin/Developmentin the previous PR and is unrelated.Version
0.250.217→0.250.218Follow-up
#1272 tracks the cosmetic issue where completed jobs still display
Current container: Waitingandrunningstep badges.Documentation
docs/explanation/fixes/SOURCE_BLOB_BACKUP_ETAG_FIX.mddocs/explanation/release_notes.md