Skip to content

storage: estimate MySQL snapshot row counts for large tables - #37907

Open
peterdukelarsen wants to merge 1 commit into
MaterializeInc:mainfrom
peterdukelarsen:pl/mysql-snapshot-estimate-count
Open

storage: estimate MySQL snapshot row counts for large tables#37907
peterdukelarsen wants to merge 1 commit into
MaterializeInc:mainfrom
peterdukelarsen:pl/mysql-snapshot-estimate-count

Conversation

@peterdukelarsen

@peterdukelarsen peterdukelarsen commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Motivation

Part of SS-360.

Count(*) can be very slow and we can speed it up with using table size statistics.

Description

If size estimate is > 1M just use the size estimate. Otherwise still use count(*).

This broadly matches how Postgres handles grabbing/reporting the full row-count and is inspired by that. Row count is used for Reporting progress in the frontend and if we roll out parallel snapshotting for partitioning data. Accuracy is not critical for correctness.

Mirror the Postgres source's approach to the snapshot size gauge: ask
the optimizer for a row estimate first and only run an exact COUNT(*)
when the estimate is at or below a threshold. Large tables report the
information_schema.tables estimate directly, skipping an O(rows) index
walk during snapshot setup. A NULL or zero estimate falls through to
the exact count.

The threshold is the new mysql_source_snapshot_exact_count_max_rows
dyncfg, defaulting to 1M rows to match the Postgres source's cutoff.
Setting it to 0 forces the estimated path everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@peterdukelarsen
peterdukelarsen marked this pull request as ready for review July 28, 2026 16:10
@peterdukelarsen
peterdukelarsen requested review from a team as code owners July 28, 2026 16:10
@peterdukelarsen

Copy link
Copy Markdown
Contributor Author

Ran a local test to confirm what happens if no stats have been calculated. Looks like we'll get a row reporting 0 rows.

plarsen@Peters-MacBook-Pro ~ % docker run --name mysql-stats-test -d -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD='p@ssw0rd' \
  mysql:8.0 \
  --innodb-stats-auto-recalc=OFF

... login...

mysql> create table bar (a int);
Query OK, 0 rows affected (0.076 sec)

mysql> mysql> select table_name, table_rows from information_schema.tables where table_name='bar';
+------------+------------+
| TABLE_NAME | TABLE_ROWS |
+------------+------------+
| bar        |          0 |
+------------+------------+
1 row in set (0.040 sec)

mysql> insert into bar values (1);
Query OK, 1 row affected (0.007 sec)

mysql> insert into bar values (2);
Query OK, 1 row affected (0.006 sec)

mysql> select table_name, table_rows from information_schema.tables where table_name='bar';
+------------+------------+
| TABLE_NAME | TABLE_ROWS |
+------------+------------+
| bar        |          0 |
+------------+------------+
1 row in set (0.005 sec)

@ublubu ublubu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a glance, the logic is reasonable.

But I'm having trouble understanding the comments.

Comment on lines +1912 to +1913
# 0 forces the estimated-size path for every table, the default forces
# the exact COUNT(*) path for workload-sized tables.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment saying that parallel_workload's tables all have <1M records?

The code here doesn't control either workload size or the default flag value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are going to be way smaller than 1 million records. My experience with parallel workload was that everything has to be extremely small, otherwise Materialize deadlocks quickly. There are a bunch of old issues open for that, but we can retry/revisit.

Comment on lines +218 to +220
/// The largest optimizer-estimated table size for which the MySQL snapshot size
/// gauge is computed with an exact `COUNT(*)`. Larger tables report the
/// `information_schema` estimate directly, skipping the O(rows) count.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// The largest optimizer-estimated table size for which the MySQL snapshot size
/// gauge is computed with an exact `COUNT(*)`. Larger tables report the
/// `information_schema` estimate directly, skipping the O(rows) count.
/// If the optimizer estimates the table has fewer rows than this, compute the exact row count with `COUNT(*)`.
/// Otherwise, report the `information_schema` estimate directly.

Comment on lines +225 to +230
/// is O(total). Worker count is small, so the OFFSET scans dominate. `total` is the row
/// count supplied by the caller. It can be an optimizer estimate for large tables, so
/// the partitions are approximate. An overestimate walks off the end of the index and
/// stops with fewer boundaries, an underestimate leaves a larger final partition, both
/// still correctly partition the table. Returns None if the primary key column type is
/// not supported or the table is too small to split.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm struggling with this comment.

In particular, I'm not sure about:

  • what the OFFSET scans are dominating
  • "this" in "so this is O(total)"
  • "walks off the end of the index and stops with fewer boundaries" (Also, that's a very long sentence.)

Nit: total is the row count supplied by the caller

Comment on lines +300 to +304
/// For every table, read the row count (exact only for small tables) and, for a
/// supported single-column primary key, compute the PK-range split boundaries,
/// concurrently over at most `worker_count` connections. `None` bounds means
/// single-worker fallback for that table. The counts are reused for both the sampling
/// stride and the snapshot size gauge.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mostly reads clearly, but I can't tell what "snapshot size gauge" is. "Sampling stride" feels better, but I'm still not confident what it means either.

.query_first(format!("SELECT COUNT(*) FROM {}", table))
// The optimizer's row estimate for the table. InnoDB keeps it roughly current
// (within the churn since the last stats recalculation), but it can be NULL or
// stale-at-zero, in which case we fall through to the exact count.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is "stale-at-zero"?

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.

3 participants