Uploaded image for project: 'MariaDB Server'
  1. MariaDB Server
  2. MDEV-41398

Optimistic btr_pcur_t restore accepts a stale position after the buffer pool shrinks and grows again

    XMLWordPrintable

Details

    • Can result in unexpected behaviour
    • Hide
      Prevent optimistic cursor restoration to wrongly succeed on a block that was invalidated, withdrawn by a buffer pool shrink and then zero-initialized by a buffer pool growth.
      Show
      Prevent optimistic cursor restoration to wrongly succeed on a block that was invalidated, withdrawn by a buffer pool shrink and then zero-initialized by a buffer pool growth.
    • Q4/2026 Server Maintenance

    Description

      This issue was discovered while working at MDEV-32286 and addressing a review comment in #5625, which attributed at the PR itself what actually inherited from a pre-existing issue with modify_clock usage.
      Please find below the AI-aided description and repro test case: note that the window for this to occur is rather small and requires at least a buffer pool shrink and grow occurring during the "usually short" lifetime of a stored cursor.
      I think this may affect as well some reasoning about modify_clock handling that is ongoing in MDEV-33966's PR #5763.

      Description

      btr_pcur_t::restore_position() tries an optimistic restore first, for BTR_SEARCH_LEAF and BTR_SEARCH_PREV (and before MDEV-34458 also for BTR_MODIFY_LEAF). It accepts the stored position when these checks pass:

      • buf_page_optimistic_fix(): the block descriptor that the cursor remembers (page_cur.block) holds the stored page id (old_page_id).
      • buf_page_optimistic_get(): after the page latch is acquired, block->modify_clock is equal to the stored modify_clock.

      These checks assume that modify_clock only increases during the life of a descriptor. Eviction, record removal, page creation and page reorganization increment it. A shrink followed by a grow breaks this assumption:

      1. buf_pool_t::shrink() withdraws the descriptors at the end of the pool. It moves their pages to other blocks or evicts them, and shrunk() decommits the memory.
      2. buf_pool_t::resize() grows the pool again. It zero-fills the new descriptors (memset in the grow path), which are at the same addresses as the withdrawn ones. So modify_clock restarts at 0.
      3. A read puts the same page into the same descriptor. The page id matches, and modify_clock can be equal to the stored value again. The easiest case is 0: a cursor that was stored on a page that had been read into the descriptor right after an earlier grow.

      The page content can change while the page is out of the descriptor. The restore then accepts a record pointer that points to other data.

      In a debug build, this assertion in btr_pcur_t::restore_position() fails:

      btr0pcur.cc:374: btr_pcur_t::restore_status btr_pcur_t::restore_position(btr_latch_mode, mtr_t*): Assertion `!cmp_rec_rec(old_rec, rec, offsets1, offsets2, index)' failed.
      

      (The line is 388 on 10.11 and 372 on 11.4.)

      How to repeat

      Attached: buf_pool_resize_pcur_restore_debug.test and buf_pool_resize_pcur_restore_debug.opt. The test needs a debug build (debug_sync) and innodb_page_size=16k. Put the files in mysql-test/suite/innodb/t/ and run:

      ./mtr --mem innodb.buf_pool_resize_pcur_restore_debug
      

      buf_pool_resize_pcur_restore_debug.opt

      --innodb-buffer-pool-size=8M
      --innodb-buffer-pool-size-max=16M
      --innodb-lru-scan-depth=100
      

      The test does these steps:

      1. It grows the pool from 8M to 12M. Then it reads single pages (one partition of a 256-partition table for each lookup) until only the new blocks are free. A grow appends the new blocks to the end of the free list, so the next read uses the first new descriptor. The test reads leaf page P of table t into that descriptor, and it checks the placement with INFORMATION_SCHEMA.INNODB_BUFFER_PAGE.BLOCK_ID.
      2. A READ UNCOMMITTED reader scans t and stops in a stored function on pk=3, which is an uncommitted insert of another connection. The cursor stores block B, page P and modify_clock 0.
      3. The insert of pk=3 is rolled back, which removes the record without purge. An insert of pk=103 with the same record size reuses the freed record. The stored offset now holds pk=103.
      4. The pool shrinks to 8M, which moves P out of B. P is flushed and evicted by a scan of a table that is three times the size of the pool.
      5. The pool grows to 12M again, so B is zero-filled. The same counting step reads P into B, with modify_clock 0.
      6. The reader resumes. The optimistic restore succeeds on the record at the stale offset (pk=103), and the assertion fails.

      In 20 of 20 runs on each of 10.11, 11.4 and 11.8, the assertion failed.

      A correct restore makes the reader return 1,2,3,4,...,20,103. Without the debug assertion, the reader continues after pk=103, which is the last record, so it is expected to return 1,2,3. This was not observed, because the test cannot run on a release build.

      Effect in release builds

      The check of the restored record against old_rec is debug-only. The effect depends on the caller and on the data at the stale offset:

      These effects come from reading the code. Only the wrong position itself was reproduced, through the debug assertion.

      • Reads: row_search_mvcc() restores with BTR_SEARCH_LEAF, for consistent reads and for locking reads. When the offset is at the start of another record, the scan continues from there. Rows are skipped or returned twice, with no error. The scan phase of UPDATE and DELETE uses the same restore, so these statements can miss rows or process a row twice.
      • UPDATE and DELETE on 10.11: row_upd_clust_step() restores with BTR_MODIFY_LEAF, which tries the optimistic restore before MDEV-34458. When the result is SAME_ALL, it changes the record at the restored position, and when has_clust_rec_x_lock is set, it does not check the lock again. So another row can be updated or delete-marked, and the change is written to the redo log. From 11.4.3 (2f6df937480), BTR_MODIFY_LEAF always does a search, so this path is not affected.
      • Offset not at the start of a record (the page was reorganized or split while it was away): page_rec_get_next_low() has bounds checks on the next-record offset, but the record header at the stale offset is not validated before rec_get_offsets() and the row conversion use it. The result can be an error, wrong data or a crash.
      • Purge: row_purge_poss_sec() restores with BTR_SEARCH_LEAF, and row_purge_is_unsafe() does not check that the record belongs to the row of the undo record. With a wrong record, purge can remove a delete-marked secondary index entry that a version visible to an active read view still needs, so that read view can miss the row in a secondary index scan. The removal of the clustered index record is protected: row_purge_remove_clust_if_poss_low() compares DB_ROLL_PTR first.

      The window is the life of one stored cursor, for example a long scan or a slow client between two fetches. In that window, the pool must shrink and grow again, and the page must come back into the same descriptor with an equal modify_clock.

      History

      Change First releases Effect on the optimistic restore
      MySQL 5.7 online resize (merged in 10.2.2) 10.2.2 buf_withdraw_clock: buf_pool_is_obsolete() rejects the optimistic restore while a shrink withdraws blocks, and after a shrink that ended after the store.
      MDEV-23693 bc540b8706a: buf_withdraw_clock removed, Buf::Block_hint added 10.2.35, 10.3.26, 10.4.16, 10.5.7 Checks only that the block is in a current chunk and holds the same page id. A new chunk at the address of a freed chunk starts with modify_clock 0, so the same problem is possible if the memory is mapped at the same address. Not reproduced.
      MDEV-33588 a4cda66e2df: Block_hint replaced by buf_page_optimistic_fix() 10.6.18, 10.11.8, 11.4.2 Same checks as before.
      MDEV-34458 2f6df937480: BTR_MODIFY_PREV removed 11.1.6, 11.2.5, 11.4.3 BTR_MODIFY_LEAF no longer tries the optimistic restore. Only the BTR_SEARCH_LEAF and BTR_SEARCH_PREV restores remain exposed. 10.6 and 10.11 keep the old behavior.
      MDEV-29445 b6923420f32: descriptors at fixed addresses, zero-filled on grow 10.11.12, 11.4.6, 11.8.2, 12.0.1 The descriptor always comes back at the same address, so the problem is deterministic. Reproduced.

      Possible fixes

      1. Keep modify_clock monotonic for each descriptor address. Keep a global high-water mark in buf_pool_t. When shrink() withdraws a descriptor, and before shrunk() decommits the memory, set the mark to the maximum of the mark and modify_clock + 1. When the grow path initializes a new descriptor, set its modify_clock to the mark. A stored value is not more than the clock of the descriptor at the time that it was withdrawn, so it is less than any value after the grow. Only shrink() and the grow path access the mark, and both hold buf_pool.mutex, so it is a plain variable with no atomic operations. The increments of modify_clock and the restore path do not change.
      2. Add a resize generation, as buf_withdraw_clock did before MDEV-23693. btr_pcur_t stores it, and buf_page_optimistic_fix() compares it. This adds one load and one comparison to each optimistic restore.

      Option 1 also fixes every other user of the "same descriptor, same page id, same modify_clock" check, for example the clustered leaf hint that MDEV-32286 proposes (not merged).

      Attachments

        Issue Links

          Activity

            People

              alessandro.vetere Alessandro Vetere
              alessandro.vetere Alessandro Vetere
              Alessandro Vetere Alessandro Vetere
              Marko Mäkelä Marko Mäkelä
              Votes:
              0 Vote for this issue
              Watchers:
              2 Start watching this issue

              Dates

                Created:
                Updated:
                Resolved:

                Time Tracking

                  Estimated:
                  Original Estimate - 10d
                  10d
                  Remaining:
                  Remaining Estimate - 0d
                  0d
                  Logged:
                  Time Spent - 1d 6h 25m Time Not Required
                  1d 6h 25m

                  Git Integration

                    Error rendering 'com.xiplink.jira.git.jira_git_plugin:git-issue-webpanel'. Please contact your Jira administrators.