Skip to content

feat(db): add db:mysql-optimize (beta), to replace db:size --cleanup - #290

Closed
pjcdawkins wants to merge 6 commits into
mainfrom
prototype/db-mysql-optimize
Closed

pjcdawkins wants to merge 6 commits into
mainfrom
prototype/db-mysql-optimize

Conversation

@pjcdawkins

@pjcdawkins pjcdawkins commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Adds db:mysql-optimize (beta), for MySQL and MariaDB. It finds InnoDB tables with reclaimable disk space and, with --execute, rebuilds them online. It replaces db:size --cleanup, which still works but now prints a deprecation notice, and escapes identifiers.

This is a prototype, so the command may not belong in the CLI.

Closes #124

What it does

  • Reads server settings (SHOW GLOBAL VARIABLES/STATUS), and the disk and /tmp usage of the service from the metrics API.
  • Estimates each table's reclaimable space from its free extents (DATA_FREE) and from a sample of about 1000 rows. DATA_FREE alone misses space freed inside pages: on a test table with 9 of 10 rows deleted, it reported 5 MiB, while a rebuild freed 62–67 MiB.
    • With a single-column integer primary key, rows are read from 50 ranges of 20 rows, at uneven intervals of the key, so that they do not align with a repeating pattern in the data. Each range is an index lookup. Otherwise, the first 1000 rows are read, and the output notes it.
    • Rebuilt size = row bytes × TABLE_ROWS × 1.5. Indexes are scaled by the same ratio as the data, never above their current size.
  • Lists tables over --min-reclaimable (default 100M) and --min-percent (default 20%), with whether the rebuild fits on the disk and in /tmp. The estimate leaves 10% of each volume free, and adds 10% and 8 MiB to the rebuilt size.
  • Flags tables that are probably in a shared tablespace, and tables with FULLTEXT or SPATIAL indexes, which cannot be rebuilt online. Both are skipped.
  • --execute runs ALTER TABLE ... FORCE, ALGORITHM=INPLACE, LOCK=NONE, which fails rather than locking the table. It refuses when the headroom is unknown or too small, or on a Galera cluster of more than one node, unless --force is given.
  • --analyze runs ANALYZE TABLE first, to refresh statistics.

Verification

  • make lint, make test, and the integration tests pass. The db tests also pass with TEST_CLI_GO_AUTH=0. The integration tests use a fake mariadb client and mocked metrics.
  • I ran the queries against MySQL 8.0 and MariaDB 11.4 in Docker, as a user with privileges on its own database only, on six test tables: scattered deletes, bulk deletes, shrunk values, a composite key, sparse IDs, and an unchanged table.
    • The estimates were within 8 MiB of what the rebuilds freed.
    • The disk needed was above the size of each rebuilt file.
    • The sampling queries used range scans of the primary key.
    • The rebuild worked on both servers. On FULLTEXT tables, both servers refused it without locking.

Known limits

  • The shared-tablespace check is a heuristic, because the InnoDB tablespace views need the PROCESS privilege.
  • Sizes are estimates. TABLE_ROWS is approximate, a sample of the first rows can be biased, and the 1.5 factor was measured on the test tables (1.3–1.45), not derived.
  • Sampling reads about 1000 rows from each large table, including any BLOB or TEXT values in them.
  • The /tmp estimate doesn't count the log of concurrent writes during a rebuild (up to innodb_online_alter_log_max_size).
  • The command doesn't check that the server's temporary directory is the /tmp that the metrics measure.
  • It doesn't detect read-only relationships.

🤖 Generated with Claude Code

claude and others added 2 commits November 2, 2026 19:59
A prototype for #124: a MySQL/MariaDB-specific command that lists InnoDB
tables whose rebuild would reclaim disk space, and rebuilds them only
with --execute.

- Reads the server's settings over SQL: version, innodb_file_per_table,
  wsrep_on and wsrep_cluster_size (Galera), tmpdir and innodb_tmpdir.
  On MySQL 8 it sets information_schema_stats_expiry = 0 so statistics
  are fresh; --analyze runs ANALYZE TABLE on the tables found.
- Lists tables with at least --min-reclaimable (100M) and --min-percent
  (20%) of their size reclaimable, and whether each rebuild fits in the
  service's disk and /tmp (from the metrics API), leaving 10% free.
- Flags tables that are probably in a shared tablespace (same DATA_FREE
  as another table, or innodb_file_per_table OFF), and tables with
  FULLTEXT or SPATIAL indexes, which cannot be rebuilt online.
- --execute runs ALTER TABLE ... FORCE, ALGORITHM=INPLACE, LOCK=NONE
  (the same rebuild as OPTIMIZE TABLE, but it fails instead of locking),
  with escaped identifiers. It refuses when the headroom is unknown or
  too small, or on a Galera cluster, unless --force.
- db:size --cleanup still works, with a deprecation notice, and now
  escapes identifiers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnUBGycBKd23tLPMcyf4Rb
DATA_FREE only counts free extents, so it misses space freed inside
pages, e.g. after scattered deletes or updates to shorter values. On
MySQL 8.0 and MariaDB 11.4, a table with 9 of 10 rows deleted reported
5 MiB in DATA_FREE, and a rebuild freed 62-67 MiB.

db:mysql-optimize now samples about 1000 rows of each table that could
reach --min-reclaimable, and estimates the rebuilt size:

- Row bytes are measured with LENGTH() for variable-size columns, with
  fixed sizes for other types and 20 bytes per row for InnoDB headers.
- With a single-column integer primary key, rows are read from 50
  ranges of 20 rows, starting at uneven intervals of the key (a golden
  ratio sequence), so that they do not align with a pattern that
  repeats in the data. Each range is an index lookup. Otherwise, the
  first 1000 rows are read, and the table is noted as such.
- The rebuilt data size is the row bytes x TABLE_ROWS x 1.5. Secondary
  indexes are scaled by the same ratio as the data, capped at their
  current size.
- The reclaimable space is the larger of DATA_FREE and the current size
  (with free extents) minus the rebuilt size.
- The disk needed for a rebuild is the rebuilt size, plus 10% and 8 MiB,
  instead of the table's current size.

The thresholds are now applied after sampling. Tables in a shared
tablespace are not sampled. If the sample cannot be read, only DATA_FREE
is used, with a warning.

Checked against real servers with a user with privileges on its
database only: the estimates were within 8 MiB of the space freed by
rebuilds of six test tables, and the disk needed was above the size of
each rebuilt file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@upsun-dispatch upsun-dispatch Bot 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.

Warning

Changes suggested — 🟡 3 warnings · 🔵 2 minor points

🔍 Full review · 8 files reviewed

Verification
  • sampleRows keeps the original keys of $tables through array_filter, so the sample index written into each query maps back to the right TableCandidate.
  • Identifiers in ALTER/ANALYZE/sample queries go through quoteIdentifier, and string literals in the table/column filters go through quoteString; both handle backticks, quotes and backslashes.
  • latestServiceMetrics asks for the same types, aggregations and /mnt//tmp mountpoints that the existing metrics:all command reads, including disk_limit.max.
  • Without --yes, QuestionHelper::confirm returns its default (true) in non-interactive mode, so --execute runs non-interactively once the safety checks pass, as the integration tests expect.

New PHPUnit tests (legacy/tests/Model/Mysql/MysqlOptimizeTest.php, run by the CI legacy-php job) cover parsing, quoting, the estimates and query building. Integration tests (integration-tests/db_mysql_optimize_test.go, run by the integration-test and integration-test-legacy-auth jobs) drive the command against a fake mariadb script. No test covers a failed rebuild's error output, stale TABLE_ROWS, or shared-tablespace tables below the threshold.

Review details
  • Commit: 9128d6e
  • Model: claude-opus-5-5

Review 1 of 10 for this pull request · View the full run

Comment thread legacy/src/Command/Db/DbSizeCommand.php Resolved
- Show only the database's error when a rebuild fails. The exception's
  message contains the command line, with the database password, and
  on remote hosts it leaves out the error output.
- Count tables as probably in a shared tablespace when they share at
  least 8 MiB of free space, instead of --min-reclaimable. With row
  sampling, tables with less free space than the threshold can be
  candidates, and they were never flagged. Tested with two tables
  created while innodb_file_per_table was OFF on MySQL 8.0 and MariaDB
  11.4: both report 18 MiB, and are now flagged.
- Do not use the sample if TABLE_ROWS is lower than the number of rows
  sampled: statistics are out of date, and the estimate (proportional
  to TABLE_ROWS) could let a rebuild through the disk check.
- With --analyze, analyze every table checked, before sampling, and
  sample once. MariaDB's default ANALYZE TABLE does not scan tables for
  column statistics (use_stat_tables=PREFERABLY_FOR_QUERIES).
- Show the deprecation notice of db:size --cleanup for any database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Size column was the data and indexes only, so the reclaimable space
(which includes DATA_FREE) could be more than the size, and the
reclaimable percentage more than 100%. Size now includes DATA_FREE (about
the size of the table's file), and --min-percent is relative to it.

Without a row sample, the disk needed for a rebuild is still the data and
indexes, as free extents are not copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@upsun-dispatch upsun-dispatch Bot 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.

Warning

Changes suggested — 🟡 1 warning

🔁 Incremental (head + base moved) · 4 files reviewed

Verification
  • reclaimable() now computes size() - rebuilt without adding dataFree a second time, so the double count from the earlier version is gone.
  • The unsampled diskNeeded() returns dataLength + indexLength, which keeps its earlier value now that size() includes free extents (the unit test still asserts 100 MiB).
  • The integration expectations agree with the new formula: for example, cache 4294967296 = 3 GiB + 1 GiB free, and 1073741824/4294967296 is 25%.
  • tablesQuery already filtered on DATA_LENGTH + INDEX_LENGTH + DATA_FREE, so the SQL pre-filter now matches size().

MysqlOptimizeTest::testEstimates now asserts the new size(). The CSV and table expectations in TestDbMysqlOptimizeReport were updated for it. No test covers how size looks for a table in a shared tablespace.

Review details

Review 3 of 10 for this pull request · View the full run

In a shared tablespace, DATA_FREE is the free space of the whole
tablespace, reported on every table in it. Adding it to each table's
size made small tables look as large as the tablespace's free space.

For tables that are probably in a shared tablespace, the size is now
the data and indexes only, and the reclaimable percentage is left
empty, as a rebuild would not reclaim that space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matthiaz

matthiaz commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@pjcdawkins Are you sorting the tables from small -> big when running the optimize?

When mariadb/mysql does a recreate of the table, it will need to copy the data to a new table. So momentarily it needs additional space. If you start with the biggest table, you might not be able to run the recreate due to running out of disk space.
The previous --cleanup command solved this by sorting the tables from small data -> big data. Gradually cleaning up disk space, which greatly improved successrate when disk is very nearly full

Tables were rebuilt in order of reclaimable space, largest first. A
rebuild copies the table, so on a nearly full disk the largest rebuild
is the most likely to fail. Rebuilding the tables that need the least
disk space first frees space for the larger ones, as db:size --cleanup
did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pjcdawkins

Copy link
Copy Markdown
Contributor Author

@matthiaz good point, that's now what the PR does (initially it did largest first)

@pjcdawkins

Copy link
Copy Markdown
Contributor Author

Proposing upsun/ai#42 instead of this PR

@pjcdawkins pjcdawkins closed this Nov 3, 2026
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.

move db:size --cleanup to db:cleanup

3 participants