Conversation
robinnsc
requested review from
LeeroyHannigan,
amrith,
c33howard,
jcshepherd,
pdf-amzn and
yesyayen
as code owners
October 6, 2026 00:32
8 tasks done
Nothing stopped two `extenddb serve` processes from opening the same SQLite file. The backend serializes writers with a lock inside the server process, so two servers write concurrently and can hit SQLITE_BUSY mid-transaction; and startup recovery (control-plane transitions, GSI and vector index rebuilds, and, with #384, abandoned restores) assumes no other server is running, so a second server starting up would undo the first one's work in progress. Nothing stopped `destroy` from unlinking the file under a running server either, which left that server serving an unlinked inode while a later `init` created a fresh database at the same path. `extenddb serve` on a file database now takes an exclusive flock(2) on `<database>.lock` before opening the database and holds it for the life of the process (the lock lives in the engine, so it is held while any clone is). `init` and `migrate` take the same lock for their duration through the bootstrapper's migration-lock hook, and `destroy` takes it before it removes the file; each refuses, rather than waits, when a server holds it. Read-only commands (`settings`, `manage`, `verify`, `status`) take no lock. A second holder fails with: another extenddb process is already using <db> (lock held on <db>.lock); stop it first, or point this command at a different database The lock file is named after the file SQLite actually opens, not the configured string: the location is parsed with sqlx's own SqliteConnectOptions (so `sqlite:` URL forms, percent-encoding such as `a%20b.sqlite`, and `file:` URIs resolve as the engine resolves them), and the path is canonicalized, so two spellings of one file, a `..` path, or a symlink and its target all share one lock. A disk file whose name happens to contain `mode=memory` is a file. In-memory databases take no lock. The kernel releases the lock when the process exits, including on kill -9, so there is no stale-lock cleanup. On non-Unix platforms no lock is taken and a warning is logged. flock is advisory and its behaviour on network filesystems depends on the server and mount; the docs say to keep SQLite databases on local disk. The directory holding the database must be writable so the lock file can be created. flock through libc rather than std's File::try_lock, which needs Rust 1.89; the workspace MSRV is 1.88. libc is already a dependency of the workspace and listed in every license notices file. Docs: troubleshooting entry for the error with its scope; README and the deployment and design guides now state that one instance per catalog is the supported deployment, that SQLite enforces it, and that PostgreSQL and MongoDB do not (per-instance caches without cross-instance invalidation, every worker on every instance, a liveness-only /health). The deployment guide previously said multiple PostgreSQL instances were consistent because there was no in-process cache, which was not true. Tests: database_file resolution for plain paths, sqlite: URLs, percent-encoding, file: URIs, in-memory forms, and a disk file named like a memory parameter; ServeLock refused on the same file, independent across files, shared across `..` spellings and across file and directory symlinks; the server factory refusing a second server on one file; destroy and migrate refused while a server holds the file and a server refused while migrate holds it. Signed-off-by: Scott Robinson <robinnsc@amazon.com>
robinnsc
force-pushed
the
fix/sqlite-serve-lock
branch
from
October 6, 2026 07:43
aa43f5b to
539df81
Compare
robinnsc
added a commit
that referenced
this pull request
Oct 6, 2026
…; recover abandoned restores PostgreSQL and SQLite backup and restore now reproduce the table that was backed up, with bounded memory and crash recovery. This closes the two restore defects of the 1.0 readiness review's P0-2 (sort keys lost on PostgreSQL, indexes dropped on both). It does not add point-in-time recovery or off-host backup storage; those stay with #335, #336, and #337. Sort keys. `create_backup` and `restore_table_from_backup` on PostgreSQL checked for a column named `sk`, which data tables do not have (`sk_s`, `sk_n`, `sk_b`). Every backup of a table with a sort key lost its sort keys and restoring one left the target in CREATING for good. Backups now store `pk` and `item_data` only; restore derives every key column from the item. Backups written by earlier binaries carry the whole item in `item_data` and restore correctly. Restored, from a new per-backup table definition: global and local secondary indexes with key schemas, projections, and GSI throughput, filled during the copy; billing mode and provisioned throughput (was always 5/5); table class, SSE specification, on-demand throughput. Not restored, as on the service: streams, TTL, tags, deletion protection. A definition captured from a PAY_PER_REQUEST table carries no provisioned throughput for the table or its indexes, even when the catalog still holds values from before a switch from PROVISIONED, so a restored on-demand table reports what a freshly created one does. Catalog 0.0.4. PostgreSQL migration 003 and the SQLite schema add `backup_definitions` (one row per backup, the definition as JSON behind a version marker; `extenddb_storage::backup_definition`) and `table_restores` (one row per restored table: source backup ARN and restore time, not a foreign key to `backups` so the summary outlives the backup, as on the service). Backups and restores from before the upgrade have no rows and behave as before. The migration runners write the compiled catalog version after every walk, so a replayed earlier migration cannot leave the version behind the schema; they refuse to write it when the stored version is newer, and `extenddb migrate` refuses a catalog newer than the binary outright, so an older binary can no longer stamp its version onto a newer schema and then pass its own startup gate. #335 also claims 0.0.4 and migration 003; whichever merges second renumbers. Consistency: - PostgreSQL CreateBackup holds the source table row FOR SHARE from its first catalog read until its data snapshot (REPEATABLE READ) is taken, and locks the source data table ACCESS SHARE before releasing the row. UpdateTable and DeleteTable take the row FOR UPDATE, so the definition recorded is the one in force when the items were read. A GSI whose data table UpdateTable has not built yet is left out. - SQLite CreateBackup reads the table row, its definition, and its items in one write transaction. Every other write on that server waits for it. - A restore registers itself in the same transaction that creates its target (the `table_restores` row on SQLite, the provenance fields on the target's catalog document on MongoDB; PostgreSQL holds the backup row FOR SHARE while it records itself), so DeleteBackup can never observe the target without its source and returns BackupInUseException (HTTP 400) for the whole restore. A restore reads from a snapshot (PostgreSQL) or re-checks under the write lock for each batch (SQLite), so it copies all of a backup or fails with BackupNotFoundException. - DeleteTable on a table being restored returns ResourceInUseException. - DescribeTable reports RestoreSummary for a restored table, in progress while CREATING; the restore response and later DescribeTable calls carry the same RestoreDateTime. MongoDB gets the same two behaviours. Memory and concurrency: backup and restore stream items and buffer at most 500 items or 4 MiB of stored JSON. SQLite restores commit per batch. PostgreSQL runs up to four restores at once per process; a fifth waits up to 30 s and is refused with LimitExceededException. Failure and crash: a failed copy removes its target by table id, claiming it CREATING -> DELETING in one statement so removal and activation cannot both happen; MongoDB removes a partial target the same way, including when target creation itself fails part-way. PostgreSQL restores hold a session advisory lock keyed by account and target name on a dedicated connection; the control-plane pass removes CREATING targets with no scheduled transition, older than 60 s, whose lock is free. SQLite removes the same targets at startup, which is safe only because #391 lets one server hold the file; this change should merge after #391. Refused rather than restored wrongly: backups of tables with vector indexes (both backends now refuse; SQLite previously restored without them), backups of multi-part-key tables, definitions with an unknown version, and RestoreTableFromBackup requests carrying BillingModeOverride, GlobalSecondaryIndexOverride, LocalSecondaryIndexOverride, ProvisionedThroughputOverride, or SSESpecificationOverride, which were silently ignored before. RestoreTableFromBackup validates TargetTableName. New DynamoDbError::BackupInUseException and StorageError::BackupInUse. Docs: docs/dynamodb-limits.md no longer says backup and restore are unsupported; docs/differences-from-dynamodb.md gains a Backup and Restore section (what is restored, the refusals, where backups live, the SQLite write-lock cost) and its vector-index row matches the code; the upgrade manual has a 0.0.4 section and notes the SQLite lock; the admin guide's version literal and migrate guidance are current. Tests: tests/test_backup_restore_fidelity.py (8, any backend: S/N/B hash and sort keys compared item by item, a provisioned table with four GSIs and an LSI including projections, throughput, and ordered index reads, RestoreSummary, and a PROVISIONED table switched to PAY_PER_REQUEST restoring with zero throughput on the table and its GSI); crates/storage-postgres/tests/backup_restore.rs (15, in the PostgreSQL storage-level CI step: old formats, failure cleanup, the consistency barrier, half-built GSIs, DeleteTable and DeleteBackup during a restore, the abandoned-restore sweep, RestoreSummary); SQLite `restore_tests` (11, including the restore intent committed with its target and a 0.0.3 database in the old row format); unit tests for the definition normalization, the override rejections, CatalogVersion ordering, and the newer-catalog refusals in the migrate command and both schema runners. Signed-off-by: Scott Robinson <robinnsc@amazon.com>
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.
What
A SQLite database file has one owner at a time.
extenddb servetakes an exclusiveflock(2)on<database>.lockbefore it opens the database and holds it for the life of the process;initandmigratetake the same lock for as long as they run (through the bootstrapper's migration-lock hook), anddestroytakes it before it unlinks the file. Each refuses, rather than waits, when another process holds it:Read-only commands (
settings,manage,verify,status) take no lock and run alongside a server.crates/storage-sqlite/src/serve_lock.rs:ServeLock, anddatabase_file(), which resolves the configured location to the file SQLite actually opens by parsing it with sqlx's ownSqliteConnectOptions(sosqlite:URL forms, percent-encoding such asa%20b.sqlite, andfile:URIs resolve as the engine resolves them) and returns nothing for in-memory databases. The lock path is the canonicalized database path plus.lock, so two spellings of one file, a..path, or a symlink and its target share one lock.crates/storage-sqlite/src/lib.rs: the server factory takes the lock first, before it opens the database or runs startup recovery, and stores it on the engine (anArcshared by everything in the server).crates/storage-sqlite/src/bootstrapper.rs:acquire_migration_lock/release_migration_locktake and drop the same lock;drop_databasesholds it while removing the file.kill -9, so a crash never leaves a stale lock. The lock file stays on disk; its presence means nothing.sqlite-memory, dev mode) take no lock. On non-Unix platforms no lock is taken and the server logs a warning.flockis advisory; on network filesystems its behaviour depends on the server and mount options, so the docs say to keep SQLite databases on local disk.libc::flockbecause std'sFile::try_lockneeds Rust 1.89 and the workspace MSRV is 1.88. libc is already a workspace dependency and listed in every license notices file.docs/troubleshooting.mdentry for the error with its scope; README,docs/manuals/11-deployment-guide.md, anddocs/manuals/02-design-guide.mdnow say that one instance per catalog is the supported deployment, that SQLite enforces it, and that PostgreSQL and MongoDB do not. The deployment guide previously said multiple PostgreSQL instances were consistent because there was no in-process cache; there are per-instance credential and policy caches with no cross-instance invalidation, every worker runs on every instance, and/healthis liveness only.Why
No issue filed. Raised while reviewing #384, which removes restore targets left
CREATINGat SQLite startup and is only safe if one server at a time holds the file.Nothing stopped two servers from opening the same SQLite file, and the backend is not safe that way: writers are serialized by a lock inside each server process, and startup recovery (pending control-plane transitions, GSI and vector index rebuilds, abandoned restores) assumes no other server is running. Nothing stopped
destroyfrom unlinking the file under a running server either, which left that server serving an unlinked inode while a laterinitcreated a fresh database at the same path.This is the SQLite half of the readiness review's P0-7 ("state that exactly one instance may run against a catalog and enforce it"). PostgreSQL and MongoDB are now documented as single-instance but nothing enforces it there; P0-7 stays open for them.
Testing done
Unit tests in
extenddb-storage-sqlite:database_file(): plain and relative paths,sqlite:andsqlite://URLs with parameters, percent-encoded names,file:URIs (file:/abs,file:///abs,file://localhost/abs,file:rel; a foreign authority is an error), in-memory forms, and a disk file whose name containsmode=memory.ServeLock: a second lock on the same database is refused; free again once dropped; independent across databases; shared across a..spelling of one file and across a file symlink and a directory symlink.destroyandmigrateare refused while a server holds the file (anddestroyleaves the file in place); a server is refused whilemigrateholds it; both succeed once it is released.By hand (previous revision), against a release build: a second
serveon the same file exits 1 with the message above and the first server stays healthy; afterkill -9of the first server a new one starts at once; two dev-mode in-memory servers both start.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, 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. A startup check in the SQLite backend; no wire, trait, auth, on-disk format, or CLI flag change. The only new file on disk is the empty lock file next to the database.
Breaking changes
extenddb serveagainst a SQLite file another server is using, orinit/migrate/destroyagainst a file a server is using, now fails instead of proceeding. Running those concurrently was never safe.cannot open lock file.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.