Conversation
ActivationManager#claimed_instance_ids joins claimed messages to instances, and its WHERE clause has conditions on instance columns only. The claimed-messages table is a short queue, so it is often empty when ANALYZE or PRAGMA optimize runs. sqlite_stat1 then has no row for it, and SQLite assumes that the table is large. The planner used the analyzed instances table as the outer loop and scanned every instance on each worker poll. A production app saw 36.7 to 74 ms per call on a SQLite copy of 71,863 instances and no claimed messages, against 0.67 ms on MySQL. SQLite keeps CROSS JOIN as a fixed join order, so the query now starts from the claimed messages without help from statistics. PostgreSQL and MySQL treat CROSS JOIN with an equality in WHERE as an inner join. Their EXPLAIN ANALYZE plans and costs did not change, with 0 and with 200 claimed rows. The ids and their order do not change. A row in sqlite_stat1 that marks the table as small also fixes the plan, but the next ANALYZE of an empty queue deletes it.
sqlite_stat1 records only the average row count for each effect status. When most effects are complete, SQLite estimated that status = 'processing' matched most of the effects table. It then read every effect_recoveries row in primary key order to skip the ORDER BY sort, and looked up each effect. A production app saw 5.75 ms per call on SQLite against 0.68 ms on MySQL. This is not a missing index. Only a recovery retirement writes retired_at, so a recovery row keeps it empty after a normal completion, and the recovery filter matches almost every row. CROSS JOIN alone does not fix it: when every effect has one status, SQLite scans the whole effects table. unlikely() is too weak at 500,000 effects. On SQLite the status test now carries likelihood(..., 0.000001), which overrides the statistics estimate for that term, so the plan searches idx_so_effects_poll first. The PostgreSQL and MySQL SQL does not change.
Ship the SQLite join-order fixes for the claimed-message scan and the effect recovery candidate query as a patch release, so an app that pins ~> 0.16.0 gets them with a bundle update.
|
The two SQLite plan tests ran ANALYZE and then deleted every row in sqlite_stat1. A run against a SQLite database with its own statistics lost them, so later queries in that database could choose different plans. restoring_sqlite_statistics now saves each sqlite_stat table before the block, puts the saved rows back, reloads the statistics, and drops the statistics tables that the block created.
Owner
Author
|
@greptileai Please review the latest commit f02ec5d. It answers the finding about the plan-test cleanup: the tests now save and restore SQLite statistics. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Two polling queries chose bad plans on SQLite. A production app measured them on MySQL and on a SQLite copy of the same data:
ActivationManager#claimed_instance_idsEffectRecoveryCoordinator#recovery_candidatesOn SQLite, the first query alone cost about 0.2 of a CPU core all the time and slowed actor delivery. The two queries have different causes, so they get different fixes.
Claimed-message scan: empty table at
ANALYZEtimeThe query joins claimed messages to instances, and its
WHEREclause has conditions on instance columns only. The claimed-messages table is a short queue, so it is often empty whenANALYZEorPRAGMA optimizeruns.sqlite_stat1then has no row for it, and SQLite assumes that the table is large. The planner used the analyzed instances table as the outer loop:The query now uses
CROSS JOINwith the equality inWHERE. SQLite keepsCROSS JOINas a fixed join order:On generated data with 71,863 instances and no claimed messages, the call went from 2.33 ms to 0.029 ms on a laptop. PostgreSQL and MySQL treat
CROSS JOINwith an equality as an inner join. TheirEXPLAIN ANALYZEplans and costs are the same before and after, with 0 and with 200 claimed rows (PostgreSQL 17, MySQL 8.4). The ids and their order do not change.A
sqlite_stat1row that marks the table as small also fixes the plan, but the nextANALYZEof an empty queue deletes it.Recovery candidates: skewed status statistics
This is not the empty-table problem, and it is not a missing index.
sqlite_stat1records only the average row count for each effectstatus. When most effects are complete, SQLite estimated thatstatus = 'processing'matched most of the effects table. It then read every recovery row in key order to skip the sort:An index on
retired_at IS NULL AND recovery_operation IS NOT NULLdoes not help. Only a recovery retirement writesretired_at, so almost every recovery row matches that filter.What I tried on generated SQLite data:
CROSS JOINunlikely()likelihood(..., 0.000001)On SQLite the status test now carries
likelihood(..., 0.000001). SQLite uses that value in place of thesqlite_stat1estimate for the term, so the plan starts withSEARCH solid_objects_effects USING INDEX idx_so_effects_poll (status=?). The PostgreSQL and MySQL SQL does not change.Tests
The tests came first. Each SQLite plan test failed on the old code with the production plan:
Each plan test also asserts its cause:
sqlite_stat1has no claimed-messages row, and the poll index statistics give 3,000 rows for each status.The behaviour tests fix today's results on every adapter. They passed before and after the change:
claim_scan_limit.claim_scan_limit.Validation
bundle exec rake(SQLite)SOLID_OBJECTS_DATABASE_URL=postgresql://... bundle exec rake test(17)SOLID_OBJECTS_DATABASE_URL=mysql2://... bundle exec rake test(8.4)SOLID_OBJECTS_DATABASE_URL=trilogy://... bundle exec rake test(8.4)The two SQLite plan tests add one skip each on PostgreSQL and MySQL.
Effects
caseas an array, so each fragment has its owncase.likelihood()exists in SQLite since 3.8.1.Release
This PR prepares 0.16.1. An app that pins
~> 0.16.0gets it with a bundle update.The JS port has the same recovery query, and it gets the same fix in cardmagic/solid-objects-js#61. JS has no claimed-message scan.
Not in this PR
Recovery rows stay after their effects are deleted.
solid_objects_effect_recoverieshas a foreign key to instances only. The message pruner deletes old effects through the message cascade, but it leaves their recovery rows. So the table grows with every effect that setson_recoveryoron_status.