Bug #121061 InnoDB wrong result for a full-column secondary index on an externally stored field during MVCC lookup
Submitted: 6 Aug 8:37
Reporter: Xu Leo Email Updates:
Status: Open Impact on me:
None 
Category:MySQL Server: InnoDB storage engine Severity:S2 (Serious)
Version:8.0.46 OS:Any
Assigned to: CPU Architecture:Any
Tags: external field, innodb, MVCC, secondary index, wrong result

[6 Aug 8:37] Xu Leo
Description:
  InnoDB may silently omit a visible row during a secondary-index lookup when
  row_sel_sec_rec_is_for_clust_rec() validates a full-column secondary-index
  field against an externally stored clustered field.

  The testcase provides an explicit READ UNCOMMITTED versus READ COMMITTED
  control using the same table, row, index, selected columns, and predicate:

  - READ UNCOMMITTED, current clustered version: row is incorrectly omitted.
  - READ COMMITTED, current clustered version: row is returned.
  - REPEATABLE READ, old clustered version: row is incorrectly omitted.
  - READ COMMITTED, old clustered version: row is incorrectly omitted.

  READ UNCOMMITTED forces the secondary-to-clustered consistency check even
  when the current clustered version is used. READ COMMITTED does not require
  that check for the current version, so the row is returned.

  When READ COMMITTED or REPEATABLE READ must reconstruct an old clustered
  version, the same consistency check is executed and the row is incorrectly
  omitted.

  This contrast shows that the stored data and search predicate are valid.
  The wrong result occurs specifically when
  row_sel_sec_rec_is_for_clust_rec() is executed.

  Physical layout:

  The table uses ROW_FORMAT=DYNAMIC. The indexed VARCHAR column is actually
  stored externally in the clustered record.

  For a full-column secondary index:

    dict_field_t::prefix_len == 0

  The records contain:

    clustered record:
      local field data plus a 20-byte external-field reference

    secondary index record:
      complete 2000-byte indexed value

  row_sel_sec_rec_is_for_clust_rec() materializes an externally stored
  clustered value only inside the ifield->prefix_len > 0 branch.

  Because a full-column index has prefix_len == 0, it skips external-value
  materialization and calls cmp_data_data() with the clustered local/reference
  bytes and the complete secondary-index value.

  The comparison reports a false mismatch. The caller then discards the
  otherwise visible row.

  This is a silent wrong-result issue. I have not observed persistent table or
  index corruption.

  Environment:
  - MySQL 8.0.46-debug
  - Source commit: 9fc8d60003b4eee32767c1e30942f222c3508c31
    (one commit after mysql-8.0.46)
  - Linux x86_64
  - Default 16KB InnoDB page size

  Runtime reproduction was confirmed on the version above.

  Source inspection shows the same missing full-column external-field
  materialization in:
  - mysql-8.4.11
  - mysql-9.7.2
  - mysql-26.7.0
  - current upstream trunk

  I have not run the testcase against binaries of those later versions.

How to repeat:
  How to repeat:

  1. Copy the attached full_column_external_sec_index.test to:

     mysql-test/suite/innodb/t/full_column_external_sec_index.test

  2. Copy the attached expected result to:

     mysql-test/suite/innodb/r/full_column_external_sec_index.result

  3. Build an unmodified MySQL server and run:

     ./build/mysql-test/mtr --suite=innodb \
       full_column_external_sec_index 

  4. Observe "Result content mismatch".

  All queries use the same predicate:

     WHERE c = REPEAT('a', 2000)

  Expected and actual results:

  Isolation level   Clustered version   Expected   Actual
  ----------------------------------------------------------------
  REPEATABLE READ   current             1 b        1 b
  READ UNCOMMITTED  current             1 b        empty
  READ COMMITTED    current             1 b        1 b
  REPEATABLE READ   old                 1 b        empty
  READ COMMITTED    old                 1 b        empty

  There are no concurrent writes during the READ UNCOMMITTED and
  READ COMMITTED current-version controls. Both queries read the same committed
  row. Therefore, READ UNCOMMITTED omitting the row is not permitted by its
  weaker visibility rules.

  The REPEATABLE READ old-version section establishes its snapshot before
  another connection commits an update to an unrelated column.

  The READ COMMITTED old-version section reads while another transaction has
  an uncommitted update to an unrelated column.

Suggested fix:
  Preserve the existing prefix-index, spatial-index, and multi-value-index
  handling.

  For an ordinary, non-virtual, full-column secondary-index field
  (ifield->prefix_len == 0), when the corresponding clustered field is
  externally stored, materialize the complete clustered value before calling
  cmp_data_data().

  For example, use lob::btr_rec_copy_externally_stored_field() with the
  clustered record, clustered offsets, and clustered field position.

  If materialization fails, keep the existing non-match behavior.

  The existing row_sel_sec_rec_is_for_blob() helper should not be reused for
  this case because it is prefix-specific and asserts prefix_len > 0.

  The attached full_col_index.patch contains my proposed fix for this issue.
[6 Aug 8:38] Xu Leo
reproduce testcase and the fix codes

Attachment: full_col_index.patch (application/octet-stream, text), 1003 bytes.

[6 Aug 8:39] Xu Leo
reporduce testcase

Attachment: full_column_external_sec_index.test (application/octet-stream, text), 2.31 KiB.

[6 Aug 8:39] Xu Leo
reporduce testcase result

Attachment: full_column_external_sec_index.result (application/octet-stream, text), 1.83 KiB.