Stream .backup metadata with SAX instead of building a DOM tree#109107
Stream .backup metadata with SAX instead of building a DOM tree#109107jkartseva wants to merge 7 commits into
Conversation
`BackupImpl::readBackupMetadata` parsed the whole `.backup` manifest into an in-memory `Poco::XML` DOM tree. For large incremental backups the manifest lists millions of files, so the DOM tree alone costs multiple gigabytes. Replace it with a streaming `SAXParser`: header elements are applied when `<contents>` opens, and each `<file>` is folded into the existing maps as its closing tag is seen, instead of being retained in a tree. The on-disk format is unchanged. Callback exceptions are captured and rethrown after parsing, since they must not propagate through the underlying expat callbacks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backs up a table (full, incremental on top of the full one, and into an archive), restores each into a separate table, and checks the restored data matches. This exercises `readBackupMetadata` across the disk and archive read paths and the base-backup dedup fields, so a mis-parse of the `.backup` manifest would fail the RESTORE or restore wrong data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the streaming SAX `ContentHandler` out of the anonymous namespace of
`BackupImpl.cpp` into `Backups/BackupMetadataHandler.{h,cpp}`. The handler was
already fully decoupled from `BackupImpl` (it communicates only through the
`on_header`/`on_file` callbacks), so this is a pure code move that makes it a
named, directly unit-testable unit. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the cases the SQL round-trip test cannot easily construct: header and per-file field extraction, header-applied-before-any-file ordering, empty `<contents>`, per-file map reset, capture-and-short-circuit of callback exceptions (which must not propagate through the expat parser), and a malformed manifest reported by the parser itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Fast test build is compiled without the `minizip` library, so backing up to / restoring from a `.zip` archive fails with `minizip library is disabled` (`SUPPORT_IS_DISABLED`). The archive step differs from the disk path only in where the `ReadBuffer` for `.backup` comes from - the metadata parsing is identical - and archive round-trips are already covered by other tests, so drop it here and keep the full + incremental disk round-trip, which runs everywhere. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=109107&sha=0f61bc54919a89fa269b20d968906d5c9fc561d7&name_0=PR&name_1=Fast%20test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| { | ||
| current_text.clear(); | ||
| const String & name = qname.empty() ? local_name : qname; | ||
| if (name == "contents") |
There was a problem hiding this comment.
If the manifest omits <contents>, on_header never fires, parseMemoryNP still returns successfully, and readBackupMetadata leaves version/uuid unset while treating the backup as empty. That means a damaged .backup can slip past open-time validation and only fail much later with a much less obvious error. Please track whether the header/<contents> section was seen and throw BACKUP_DAMAGED after parsing if it was not; a focused gtest for <config>...header...</config> without <contents> would cover the regression.
There was a problem hiding this comment.
Claude Fable 5 found this same issue
There was a problem hiding this comment.
Pull request overview
This PR reduces memory usage when opening backups by replacing .backup metadata parsing in BackupImpl::readBackupMetadata from a Poco::XML DOM parse to a streaming Poco::XML::SAXParser with a dedicated BackupMetadataHandler, avoiding multi-gigabyte DOM allocations for large manifests.
Changes:
- Added
BackupMetadataHandler(SAXDefaultHandler) to collect header fields and stream<file>entries one at a time. - Switched
BackupImpl::readBackupMetadatato SAX-based parsing and deferred rethrow of callback exceptions after parsing. - Added coverage via a new stateless round-trip backup/restore query test and a new
gtestsuite for handler behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/Backups/BackupImpl.cpp |
Replaces DOM-based .backup parsing with SAX handler callbacks while building the same in-memory maps. |
src/Backups/BackupMetadataHandler.h |
Declares streaming SAX handler interface, callback hooks, and captured-exception contract. |
src/Backups/BackupMetadataHandler.cpp |
Implements SAX event handling for header and per-file leaf extraction with exception capture. |
src/Backups/tests/gtest_backup_metadata_handler.cpp |
Adds unit tests for handler ordering, field reset, exception capture, and malformed XML behavior. |
tests/queries/0_stateless/04494_backup_metadata_round_trip.sh |
Adds end-to-end backup/restore round-trip validation (full + incremental). |
tests/queries/0_stateless/04494_backup_metadata_round_trip.reference |
Expected output for the new round-trip test. |
| /// Callbacks must not throw through expat; a captured exception is rethrown here. | ||
| if (handler.saved_exception) | ||
| std::rethrow_exception(handler.saved_exception); |
There was a problem hiding this comment.
Claude Fable 5 also flagged this same issue
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 193/213 (90.61%) · Uncovered code |
pamarcos
left a comment
There was a problem hiding this comment.
Validated end-to-end against a server built from this branch
Do you have some before/after numbers so that we can compare the amount of memory used in one case and the other? Feel free to assume a very bad scenario where this improvement would shine.
| /// Callbacks must not throw through expat; a captured exception is rethrown here. | ||
| if (handler.saved_exception) | ||
| std::rethrow_exception(handler.saved_exception); |
There was a problem hiding this comment.
Claude Fable 5 also flagged this same issue
| { | ||
| current_text.clear(); | ||
| const String & name = qname.empty() ? local_name : qname; | ||
| if (name == "contents") |
There was a problem hiding this comment.
Claude Fable 5 found this same issue
| version = parse<int>(req("version")); | ||
| if ((version < INITIAL_BACKUP_VERSION) || (version > CURRENT_BACKUP_VERSION)) | ||
| throw Exception( | ||
| ErrorCodes::BACKUP_VERSION_NOT_SUPPORTED, "Backup {}: Version {} is not supported", backup_name_for_logging, version); |
There was a problem hiding this comment.
Feedback from Claude Fable 5:
[src/Backups/BackupImpl.cpp:606, 636-642, 648, 655] Value parsing became lenient where it used to fail closed on damaged manifests. Old XMLUtils::getBool → Poco::AbstractConfiguration::parseBool threw SyntaxException on unrecognized text (and accepted yes/on/case-insensitive True); the new get_bool (636-642) and the inline check for BASE_BACKUP_COPY_S3_CREDENTIALS_FROM_BACKUP (606) silently map anything other than exactly "true"/"1" to false. Old getUInt64/getInt → Poco::NumberParser threw on trailing garbage; DB::parse (648, 655) parses the digit prefix and silently ignores the rest (12x34 → 12; 1garbage → 1). ClickHouse itself only writes canonical values, so round-trips are unaffected — but for a corrupted manifest a silently misread encrypted_by_disk=false means restoring ciphertext as plaintext data instead of raising BACKUP_DAMAGED. Fix: use strict full-consumption parsing (e.g. parseFromString-style with EOF assertion) and throw BACKUP_DAMAGED on unrecognized bool text.
| full_backup="Disk('backups', '$full_id')" | ||
| incr_backup="Disk('backups', '$incr_id')" |
There was a problem hiding this comment.
Here we're testing full and incremental, but what about archive backup?
| if (name == "file") | ||
| { | ||
| if (on_file) | ||
| on_file(file_fields); |
There was a problem hiding this comment.
Feedback from Claude Fable 5:
endElement fires on_file for a at any depth (lines 45-48), and startElement re-fires on_header for any nested (lines 19-23). Concrete trace: n0 — the leaves land at depth 4, so file_fields is fully populated and on_file runs with no header applied and no version check; if <object_key> is present it reaches the LOGICAL_ERROR throw in BackupImpl.cpp instead of BACKUP_DAMAGED. The old DOM code only iterated direct children of /. Note this is not fixed by the "throw if was never seen" check alone — a document that does contain later can still process a misplaced before the header. Since path is already maintained, please gate the callbacks: fire on_header only when opens at depth 1, and on_file only when the path is exactly [root, contents, file].
There was a problem hiding this comment.
Feedback from Claude Fable 5:
If a callback exception was captured and the document is also malformed later on, parseMemoryNP throws the secondary XML error and saved_exception (the root cause, e.g. BACKUP_VERSION_NOT_SUPPORTED) is lost. Consider wrapping the parse in try/catch and preferring saved_exception when rethrowing. Relatedly, after a captured fatal error the parser still scans the remainder of a potentially multi-GB manifest with no way to abort early.

Related:
https://github.com/ClickHouse/clickhouse-private/issues/57471
https://github.com/ClickHouse/clickhouse-private/issues/62693
Description
BackupImpl::readBackupMetadataparsed the entire.backupmanifest into an in-memoryPoco::XMLDOM tree before walking it. For large incremental backups the manifest can list millions of files, so the DOM tree alone consumes multiple gigabytes of RAM and is a large part of the front-loaded cost of opening a base backup.This replaces the DOM parse with a streaming
Poco::XML::SAXParserdriven by a smallBackupMetadataHandler: the header elements are applied when<contents>opens, and each<file>is folded into the existing maps as its closing tag is seen, instead of the whole document being retained in a tree. The on-disk format is unchanged — only the way the manifest is read changes. Callback exceptions are captured and rethrown after parsing, because they must not propagate through the underlying expat callbacks.Validated end-to-end against a server built from this branch: a new SQL round-trip test (full + incremental + archive backup, restored and compared) and a
gtestforBackupMetadataHandlercovering the base-backup dedup fields, header-before-files ordering, callback-exception capture, and malformed input.Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Reduced memory usage when opening a backup (for
RESTORE, or as the base of an incrementalBACKUP). The.backupmetadata is now parsed as a stream instead of being loaded into an in-memory XML document tree, which for large (especially incremental) backups avoids allocating a multi-gigabyte DOM tree.