Skip to content

fix(sqlite): serialize all writers through the engine write lock - #321

Open
yesyayen wants to merge 4 commits into
ExtendDB:mainfrom
yesyayen:fix/sqlite-writer-serialization
Open

fix(sqlite): serialize all writers through the engine write lock#321
yesyayen wants to merge 4 commits into
ExtendDB:mainfrom
yesyayen:fix/sqlite-writer-serialization

Conversation

@yesyayen

@yesyayen yesyayen commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

What

The SQLite backend's concurrency design (D1, crates/storage-sqlite/src/store.rs) serializes every writer through the engine write_lock and states that SQLITE_BUSY cannot arise from competing writers. Seven writers ran outside the lock:

Writer Runs
create_table_impl (catalog + data-table DDL, two transactions) every CreateTable
create_ttl_index (CREATE INDEX over a populated data table) + TTL metadata TTL worker (60s), UpdateTimeToLive
tag_resource / untag_resource TagResource, UntagResource
refresh_table_size (UPDATE) worker (300s)
idempotency-token cleanup (DELETE) worker (600s)
stream-record cleanup (DELETE) worker (3600s)
delete_backup (bulk DELETE, one row per backed-up item) + update_continuous_backups DeleteBackup, and automatically after every PITR restore

An unserialized writer with a slow commit holds the SQLite file lock past a locked writer's 5s busy_timeout; the locked writer then fails an unrelated request with database is locked, which the engine maps to a 500. This is the run-integration-sqlite failure on main (run 33078534390): a plain PutItem on a fresh table returned InternalServerError and took 7s where neighbors take under 1s (the busy-timeout signature).

All seven writers now take the lock. Contention queues on the in-process mutex, which waits without failing.

Also: the integration jobs now dump the server syslog on failure (journalctl -t extenddb). devtools/run-tests restarts the server daemonized, so its logs go to syslog and the original failure left zero server-side evidence in CI.

Why

Reproduced the mechanism locally at e41f370: an uncoordinated writer holding the file lock fails a plain PutItem with the byte-identical client error, and the server logs storage internal error internal_error=error returned from database: (code: 5) database is locked.

Closes # n/a

Testing done

  • 6 new tests pin the D1 invariant: hold write_lock, spawn the writer, assert no progress until release. Failing-first verified: with the lock removed from create_table_impl, the idempotency cleanup, delete_backup, and update_continuous_backups, the matching tests fail with completed while the engine write lock was held (D1 violation).
  • Deadlock audit: no caller of a newly locked method holds the lock at entry (backup restore takes it only after create_table returns; the PITR path acquires and releases per step).
  • Full wire suite against the fixed release build: pytest 942 passed, comprehensive 331 passed (includes the failed test), Rust integration 496/496.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk format, or public CLI surface, an RFC has been accepted or is linked below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: n/a (enforces the existing D1 design decision; no wire, trait, or on-disk change)

Breaking changes

n/a


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache License 2.0 and I agree to the Developer Certificate of Origin (DCO). See CONTRIBUTING.md for details.

@yesyayen
yesyayen marked this pull request as ready for review August 27, 2026 19:35
//! writer holding the file lock fails a plain `PutItem` with exactly the
//! `InternalServerError` seen in the `run-integration-sqlite` CI flake.
//!
//! Deliberate exclusions from the lock, so the invariant stays auditable:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should-fix set_backfilling and mark_active in data/vector_index.rs still run UPDATE vector_indexes on the engine pool with no write lock (their siblings backfill_batch and reset_data_table on the same struct take it), so a slow locked commit can exhaust their busy_timeout and fail a build step. Take the lock in both, or add them to this exclusion list so the invariant stays auditable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

good call! Confirmed and fixed: both flips now take the write lock, with mark_active holding it for the whole method.

This finding is also a fair point about the pattern itself: the lock is a per-site convention today. Planning a follow-up that packages lock + BEGIN IMMEDIATE behind a single write_tx() helper so an unserialized write transaction cannot be expressed; kept out of this PR to avoid scope creep.

Design decision D1 says the engine serializes all writers through
write_lock, so SQLITE_BUSY cannot arise from competing writers. Five
writers ran outside the lock: CreateTable's two transactions, the TTL
metadata and index DDL, tagging, the table-size refresh, and the
stream-record and idempotency-token cleanup sweeps. Any of them can
hold the SQLite write lock past a locked writer's 5s busy_timeout when
a commit is slow, and the locked writer then fails an unrelated request
with 'database is locked', which the engine maps to a 500.

Measured on 2026-08-27 against a local server: an uncoordinated writer
holding the file lock fails a plain PutItem with the exact
InternalServerError seen in the run-integration-sqlite flake on main
(commit e41f370), including the server-side 'database is locked' line.

Also add a syslog dump step to the integration jobs: devtools/run-tests
restarts the server daemonized, so its logs go to syslog and a failing
job otherwise records no server-side evidence.
…lock

Each test holds the engine write lock, spawns one of the previously
unserialized writers, asserts it makes no progress while the lock is
held, and asserts it completes after release. Failing-first verified:
with the lock removed from create_table_impl and the idempotency
cleanup, the matching tests fail with 'completed while the engine
write lock was held'.
Review round 1 findings on the D1 serialization fix:

- delete_backup ran two unlocked writes on the engine pool, and the
  backup_items delete is a bulk statement (one row per backed-up item),
  which is exactly the slow-commit shape the parent commit eliminates.
  Wire-reachable through DeleteBackup and automatically after every
  point-in-time restore.
- update_continuous_backups ran an unlocked upsert. Same class, one
  small row; the lock is scoped so the trailing read-only describe runs
  after release.
- The store.rs module doc now names the deliberate exclusions (init-time
  bootstrap, the management stores on the separate catalog pool) and the
  residual same-file contention risk that pool carries.
- The test helper comment claimed a 2-connection pool; in-memory engines
  are pinned to a single connection, so the comment now describes the
  actual mechanism.

Two new D1 tests cover the backup writers. Failing-first verified: with
the locks removed, both fail with 'completed while the engine write
lock was held'.
PR review finding: set_backfilling and mark_active ran UPDATE
vector_indexes on the engine pool with no write lock, while their
siblings backfill_batch and reset_data_table on the same struct take
it. A slow locked commit could exhaust their busy_timeout and fail a
build step.

mark_active takes the lock for the whole method, so its empty-flip
branch no longer re-acquires (that would deadlock). One new test pins
both flips, failing-first verified for each independently.
@yesyayen
yesyayen force-pushed the fix/sqlite-writer-serialization branch from 7da76e5 to e7846e2 Compare August 28, 2026 18:33
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.

2 participants