Repository navigation
fix(sqlite): scan a table's size without holding the write lock - #784
Open
LeeroyHannigan wants to merge 1 commit into
Open
LeeroyHannigan wants to merge 1 commit into
LeeroyHannigan wants to merge 1 commit into
Conversation
refresh_table_size took the engine write lock before it read the table id and ran SELECT COUNT(*), COALESCE(SUM(length(item_data)), 0) over the table's data, and held it until the UPDATE of the tables row. Every write needs that lock, so every five minutes, when the size refresh worker visited each active table in turn, writes waited for a full scan of each table. The scan only reads, and D1 serializes writers only, so it now runs without the lock in scan_table_size. One read transaction reads the table's id and status and then counts its data, so all three come from the same snapshot; a table that is not ACTIVE, such as a restore target still being filled, is skipped, and a table deleted after the lookup can no longer make the scan fail. The lock is taken only for the UPDATE, in store_table_size, which also matches the table_id that was scanned: a table deleted, or deleted and created again under the same name, since the scan keeps its counts. On an in-memory database the engine has a single connection, so a write still waits for that connection while a scan runs, now with the pool's acquire timeout instead of the mutex; the change helps file databases, where reads and the writer use separate connections. Unit tests: the refresh stores the item count and size; the scan runs while a writer holds the lock; a table that is not ACTIVE is neither scanned nor given counts; the refresh does not store its counts until the lock is released; and counts for another table_id are not stored. The last three each fail against one mutant: the status check removed, the UPDATE run without the lock, and the UPDATE without the table_id match. Fixes #719
LeeroyHannigan
requested review from
amrith,
c33howard,
eliseong,
hitesh96db,
jcshepherd,
navareamol,
pdf-amzn and
robinnsc
as code owners
November 3, 2026 03:27
This branch has not been deployed
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.
On SQLite, the size refresh worker made writes wait for a full scan of each table, every five minutes.
Cause
refresh_table_sizeincrates/storage-sqlite/src/metadata.rstook the engine write lock, then read the table id, ranSELECT COUNT(*), COALESCE(SUM(length(item_data)), 0)over the table's data and updated thetablesrow, all under the lock. The worker runs it for every active table in turn every 300 seconds, and every write needs the same lock. In the soak run of #711 one such scan took 6.9 seconds on a 3.9 GB database, under IO pressure and right after that run's stream record delete; it was the only scan slow enough to log (#719).Change
scan_table_sizeruns without the lock: the scan only reads, and D1 (store.rs) serializes writers only. One read transaction reads the table's id and status and then counts its data, so all three come from the same snapshot.ACTIVEis skipped. A restore target keeps its table id fromCREATINGtoACTIVE, so a scan of a target still being filled would otherwise store partial counts over the ones the restore sets. Reading the id in the scan's snapshot also means a table deleted after the lookup can no longer make the scan fail withno such table.store_table_sizeruns theUPDATE, andrefresh_table_sizetakes the write lock only around it. TheUPDATEalso matches thetable_idthat was scanned, so a table deleted, or deleted and created again under the same name, since the scan keeps its counts.An in-memory database lives on the engine's single connection, so there a write still waits for that connection during a scan, now with the pool's acquire timeout instead of the mutex, as reads already did. The change helps file databases, where reads and the writer use separate connections.
Tests
Unit tests in
metadata.rs, on an in-memory engine:refresh_stores_the_item_count_and_size.the_scan_runs_while_a_writer_holds_the_lock: the scan finishes while the test holds the write lock.a_table_that_is_not_active_is_not_counted: aCREATINGtable is not scanned and keeps zero counts.refresh_takes_the_write_lock_to_store_the_counts: while the lock is held the refresh does not finish and the row keeps its zero counts.counts_for_another_table_id_are_not_stored.Three mutants each fail one of the last three tests: the status check removed, the
UPDATErun without the lock, and theUPDATEwithout thetable_idmatch. The tests give a three-row scan or refresh 10 seconds to finish, and the lock test checks only that the refresh has not finished while the lock is held, so a slow host does not fail them.The issue suggested a timed PutItem against a large table; these tests check the scan function, the status check and the guarded
UPDATEinstead. Two things have no test. No test fails if the lock moves back above thescan_table_sizecall inrefresh_table_size, which would bring the stall back: telling the two orders apart needs a sync point between the scan and the lock, which only a test hook in the engine could give. And no test exercises the shared snapshot, because on the single in-memory connection nothing can run between the twoSELECTs.Testing
cargo +1.97.0 fmt --all -- --checkandcargo +1.97.0 clippy -p extenddb-storage-sqlite --all-targets -- -D warnings: clean.cargo +1.88.0 test --locked -p extenddb-storage-sqlite: 233 passed, 1 ignored.git merge-treewith fix: answer TTL and tag calls during a table's DELETING window #599, the open PR that editsmetadata.rs: no conflict in that file.Fixes #719