Bug #121471 InnoDB: online ALTER TABLE UPDATE replay leaks persistent cursor buffer on reuse
Submitted: 10 Oct 8:28
Reporter: Yichang Song (OCA) Email Updates:
Status: Open Impact on me:
None 
Category:MySQL Server: InnoDB storage engine Severity:S3 (Non-critical)
Version:26.7.0 OS:Any
Assigned to: CPU Architecture:Any
Tags: btr_pcur_t, innodb, memory leak, Online DDL

[10 Oct 8:28] Yichang Song
Description:
row_log_table_apply_update() can leak a persistent cursor's saved record buffer while replaying concurrent UPDATE operations during an online table rebuild.

The trigger is an UPDATE that changes a secondary-index key and grows an inline BLOB into an externally stored value in the rebuilt clustered index:

1. The function opens pcur on the clustered index.
2. btr_store_big_rec_extern_fields() writes the external value. The LOB writer calls pcur.store_position(), allocating m_old_rec_buf.
3. The function later passes the same pcur to row_search_index_entry() to remove the old secondary-index entry.
4. row_search_index_entry() calls pcur.open(), which calls init(). init() assigns nullptr to m_old_rec_buf without freeing the existing allocation.
5. The function-level scope guard calls pcur.close() on exit, but the original allocation is no longer reachable.

The scope guard added by commit df130daa135a5c85d2a625ace01b3e2da28e6f4e (Bug#37403052) therefore does not cover this cursor-reuse path. The guard protects function exit, but does not close the previous cursor lifetime before another open().

A synchronized MTR reproduction updates eight rows while ALTER TABLE ... FORCE, ALGORITHM=INPLACE, LOCK=NONE is paused before row-log replay. SQL and data checks pass. On server shutdown, LeakSanitizer reports:

Direct leak of 336 byte(s) in 8 object(s) allocated from:
  rec_copy_prefix_to_buf
  dict_index_copy_rec_order_prefix
  btr_pcur_t::store_position
  lob::BtrContext::store_position
  lob::BtrContext::check_redolog_normal
  lob::btr_store_big_rec_extern_fields
  row_log_table_apply_update

SUMMARY: AddressSanitizer: 336 byte(s) leaked in 8 allocation(s).

How to repeat:
Add the following test as mysql-test/suite/innodb/t/row_log_update_pcur_reuse.test. It requires a Debug build with DEBUG_SYNC and a 16 KiB InnoDB page size.

--source include/have_innodb_16k.inc
--source include/have_debug_sync.inc

CREATE TABLE t1 (
  id INT PRIMARY KEY,
  k INT NOT NULL,
  payload LONGBLOB,
  KEY k (k)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
INSERT INTO t1 VALUES
  (1,1,REPEAT('a',256)), (2,2,REPEAT('a',256)),
  (3,3,REPEAT('a',256)), (4,4,REPEAT('a',256)),
  (5,5,REPEAT('a',256)), (6,6,REPEAT('a',256)),
  (7,7,REPEAT('a',256)), (8,8,REPEAT('a',256));

--connect (ddl,localhost,root,,test)
SET DEBUG_SYNC='row_log_table_apply1_before SIGNAL rebuilt WAIT_FOR updated';
--send ALTER TABLE t1 FORCE, ALGORITHM=INPLACE, LOCK=NONE

--connection default
SET DEBUG_SYNC='now WAIT_FOR rebuilt';
UPDATE t1 SET payload=REPEAT('b',20000), k=k+100;
SET DEBUG_SYNC='now SIGNAL updated';

--connection ddl
--reap
--connection default
--disconnect ddl
SET DEBUG_SYNC='RESET';

SELECT COUNT(*) AS rows_found,
       MIN(OCTET_LENGTH(payload)) AS min_len,
       MAX(OCTET_LENGTH(payload)) AS max_len,
       SUM(payload=REPEAT('b',20000)) AS valid_payloads
FROM t1 FORCE INDEX (k) WHERE k BETWEEN 101 AND 108;
SELECT COUNT(*) AS old_keys FROM t1 FORCE INDEX (k) WHERE k BETWEEN 1 AND 8;
CHECK TABLE t1;
DROP TABLE t1;

Expected SQL results: rows_found=8, min_len=max_len=20000, valid_payloads=8, old_keys=0, CHECK TABLE reports OK.

Run the MTR case with leak detection enabled. The SQL results are correct, but the unfixed server leaks the saved cursor buffers at shutdown.

Suggested fix:
In storage/innobase/row/row0log.cc, replace the function-level scope guard in row_log_table_apply_update() with explicit close calls at the cursor lifetime boundaries:

- Close pcur after the clustered-index update and external-field write, before entering the secondary-index loop.
- Close pcur after a successful ordinary secondary-index deletion, immediately before committing its mtr.
- Close pcur at func_exit_committed, covering the common return path and errors that leave a cursor open.