Skip to content

Fix page-tracking incremental backups reading unmodified page ranges between changed-page blocks#1768

Open
giezi wants to merge 1 commit into
percona:trunkfrom
giezi:fix-pagetracking-gap-reads
Open

Fix page-tracking incremental backups reading unmodified page ranges between changed-page blocks#1768
giezi wants to merge 1 commit into
percona:trunkfrom
giezi:fix-pagetracking-gap-reads

Conversation

@giezi

@giezi giezi commented Jul 5, 2026

Copy link
Copy Markdown

Problem

--page-tracking incremental backups read far more data than the number of changed pages reported by the mysqlbackup component. For large tablespaces with sparsely distributed changed pages, the backup effectively reads the file from the first changed page to the last changed page, including all unmodified pages in between.

Production evidence (PXB 8.0.35-34, Percona Server 8.0.44-35, ~5.5 TB datadir)

  • The mysqlbackup component reported 6,399 changed pages total (~100 MB) for one hourly incremental window; the resulting .delta files were only a few hundred KB each.
  • Nevertheless xtrabackup read ~1.4-3+ TB per hourly incremental run at >1 GB/s, with the log timeline showing each large tablespace being read essentially end-to-end. A 1.5 TB tablespace with only 452 changed pages took 20 minutes of continuous sequential reads.
  • ~700 small tablespaces were processed within seconds (correct behavior); only large tablespaces with scattered changed pages (typical append-heavy or randomly-updated tables) exhibit the near-full reads.
  • No entries in full_scan_tables applied: no inplace DDL (MLOG_INDEX_LOAD) and no encryption changes occurred in the backup windows (verified via performance_schema digests and logs).

Root cause

pagetracking::range_get_next_page() is documented as:

Move the current_page_it iterator to point the last page id in current block

When the scan hits the end of the page set, the iterator is correctly stepped back (--page_set->current_page_it). But when the loop breaks because the next changed page is not adjacent, the iterator is left pointing at that next, non-adjacent changed page.

The caller rf_page_tracking_get_next_batch() then computes:

ctxt->filter_batch_end = (*space->current_page_it) + 1;
...
*read_batch_len = ctxt->filter_batch_end * ctxt->page_size - ctxt->offset;

so the read batch spans from the start of the current block through the entire gap up to and including the first page of the next block. Iterated across a tablespace, every gap between changed-page blocks is read.

Example: changed pages {10, 11, 12, 50} result in pages 10-50 being read (41 pages) instead of pages 10-12 and 50 (4 pages). With changed pages near the beginning and end of a multi-TB file, this reads nearly the whole file.

Fix

Step the iterator back to the last page of the current contiguous block before breaking, matching both the documented behavior and the existing end-of-set branch. Read batches then cover exactly the contiguous changed-page ranges.

The UNIV_DEBUG helper verify_skipped_pages() still validates that skipped gap pages contain no modifications in the LSN window.

Notes

  • The same code exists identically in the 8.0, 8.4, and trunk branches; this PR targets trunk and the fix applies cleanly to the release branches.
  • Logic-level patch validated against production observations; I do not have a full build/test environment for PXB, so please run the regression suite (particularly the page-tracking tests) on this change.

Made with Cursor

range_get_next_page() is documented to leave current_page_it pointing at
the last page of the current contiguous block of changed pages. This is
what happens when the end of the page set is reached, but in the case
where the next changed page is simply not adjacent, the iterator is left
pointing at that next (non-adjacent) changed page.

The caller (rf_page_tracking_get_next_batch) then computes

    ctxt->filter_batch_end = (*space->current_page_it) + 1;

which extends the read batch from the start of the current block all the
way to the next changed page, silently reading every unmodified page in
the gap between the two blocks. Iterated over a whole tablespace, the
backup ends up reading from the first changed page to the last changed
page of every .ibd file, gaps included.

For large tablespaces with sparsely distributed changed pages this
degenerates into reading nearly the entire data file and defeats the
purpose of page tracking. Observed in production on a 1.5 TB tablespace:
the mysqlbackup component reported only 452 changed pages (~7 MB), yet
xtrabackup read ~1.4 TB from that file, keeping an hourly incremental
backup busy for 20+ minutes at >1 GB/s read.

Fix: step the iterator back to the last page of the current contiguous
block before breaking, matching the documented behavior and the
end-of-set branch. The read batches then cover exactly the contiguous
changed-page ranges.
@it-percona-cla

it-percona-cla commented Jul 5, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@giezi

giezi commented Jul 5, 2026

Copy link
Copy Markdown
Author

Validation notes (simulation of the batch logic, 3000 randomized page sets plus edge cases, including read-buffer chunking):

  • Correctness: with the fix, read batches cover exactly the union of contiguous changed-page blocks; every changed page is still read in all trials (no under-read), batches are page-aligned, non-overlapping, and the loop always terminates. The UNIV_DEBUG helper verify_skipped_pages() continues to validate exactly the pages that are now skipped.
  • Note on the old behavior: the pre-fix code does not read the full first-to-last span in every case — after a batch ends at the first page of the next block, the iterator advance can skip the remainder of that block's gap. The pathological cases remain, e.g. changed pages {10,11,12,50} read 41 pages instead of 4, and {0, 99999} in a 100k-page file read all 100,000 pages instead of 2. No changed page is ever missed by either version, so this is purely a read-amplification issue, not a correctness issue.
  • Trade-off to consider: for densely scattered changes (e.g. every other page modified), the fix issues more, smaller reads where the old code issued fewer, larger sequential ones. If that pattern matters, a refinement would be to coalesce adjacent blocks when the gap is smaller than a threshold (e.g. the read buffer size), keeping sequential reads for small gaps while still skipping large ones. Happy to amend the PR that way if preferred.
  • Grepped the tree: range_get_next_page() has no other callers besides rf_page_tracking_get_next_batch(), and the pagetracking test suite validates backup/restore correctness rather than read volume, so no test encodes the old iterator semantics.

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