Skip to content

fix(sqlite): scan a table's size without holding the write lock - #784

Open
LeeroyHannigan wants to merge 1 commit into
mainfrom
fix/719-size-refresh-lock
Open

LeeroyHannigan wants to merge 1 commit into
mainfrom
fix/719-size-refresh-lock

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

On SQLite, the size refresh worker made writes wait for a full scan of each table, every five minutes.

Cause

refresh_table_size in crates/storage-sqlite/src/metadata.rs took the engine write lock, then read the table id, ran SELECT COUNT(*), COALESCE(SUM(length(item_data)), 0) over the table's data and updated the tables row, 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_size runs 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.
  • A table that is not ACTIVE is skipped. A restore target keeps its table id from CREATING to ACTIVE, 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 with no such table.
  • store_table_size runs the UPDATE, and refresh_table_size takes the write lock only around it. The UPDATE also matches the table_id that was scanned, so a table deleted, or deleted and created again under the same name, since the scan keeps its counts.
  • The worker's 300-second interval is unchanged.

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: a CREATING table 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 UPDATE run without the lock, and the UPDATE without the table_id match. 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 UPDATE instead. Two things have no test. No test fails if the lock moves back above the scan_table_size call in refresh_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 two SELECTs.

Testing

  • cargo +1.97.0 fmt --all -- --check and cargo +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-tree with fix: answer TTL and tag calls during a table's DELETING window #599, the open PR that edits metadata.rs: no conflict in that file.

Fixes #719

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

This branch has not been deployed

No deployments
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.

SQLite: the table size refresh holds the write lock across a full scan of each table every five minutes

1 participant