Summary
The DatabaseSet blanket impl for Arc<AsyncRwLock<T>> (glue/src/stateful/db/mod.rs) holds the exclusive write lock across the entire finalize — both apply_batch (the in-memory index/bitmap mutation) and the durability fsync:
async fn finalize(&self, batches: Self::Merkleized) {
let mut database = self.write().await;
finalize_or_panic(&mut *database, batches, None).await; // apply_batch + sync, both under write()
}
finalize_or_panic calls ManagedDb::finalize, whose concrete impls are self.apply_batch(..).await?; self.sync().await. Any task reading committed state through the same AsyncRwLock (e.g. proof/query reads served from a database while it applies blocks) therefore blocks for the whole finalize, including the fsync — the long, variable pole.
Proposal
Hold the exclusive lock only for apply_batch, then run the fsync under a shared lock:
{ let mut db = self.write().await; db.apply_batch(b).await... } // exclusive, brief
{ let db = self.read().await; db.commit().await... } // shared — fsync off readers
Mechanically: add apply_batch(&mut self) + commit(&self) to ManagedDb (the two primitives the concrete impls already call inherently), make finalize a defaulted combinator over them, and split the blanket + tuple DatabaseSet::finalize into the two lock scopes above. ManagedDb::finalize stays as a convenience default.
Soundness
apply_batch publishes the new logical state in memory; sync is durability-only. A reader entering the apply→commit gap observes the committed-in-memory state, and crash semantics are unchanged (apply_batch was never durable before sync). This assumes a single writer per database (the stateful actor serializes finalizes); concurrent writers are out of scope.
Dependency
Requires storage commit/sync to take &self — i.e. #3118. They were &self in v2026.5.0 but are currently &mut self on main (e.g. qmdb::any::Db::sync(&mut self)), so this is gated on #3118. With &self storage the glue change is small and additive.
Out of scope
Owned snapshots / COW / MVCC / snapshot isolation. apply_batch stays briefly exclusive.
Summary
The
DatabaseSetblanket impl forArc<AsyncRwLock<T>>(glue/src/stateful/db/mod.rs) holds the exclusive write lock across the entirefinalize— bothapply_batch(the in-memory index/bitmap mutation) and the durability fsync:finalize_or_paniccallsManagedDb::finalize, whose concrete impls areself.apply_batch(..).await?; self.sync().await. Any task reading committed state through the sameAsyncRwLock(e.g. proof/query reads served from a database while it applies blocks) therefore blocks for the whole finalize, including the fsync — the long, variable pole.Proposal
Hold the exclusive lock only for
apply_batch, then run the fsync under a shared lock:Mechanically: add
apply_batch(&mut self)+commit(&self)toManagedDb(the two primitives the concrete impls already call inherently), makefinalizea defaulted combinator over them, and split the blanket + tupleDatabaseSet::finalizeinto the two lock scopes above.ManagedDb::finalizestays as a convenience default.Soundness
apply_batchpublishes the new logical state in memory;syncis durability-only. A reader entering the apply→commit gap observes the committed-in-memory state, and crash semantics are unchanged (apply_batchwas never durable beforesync). This assumes a single writer per database (the stateful actor serializes finalizes); concurrent writers are out of scope.Dependency
Requires storage
commit/syncto take&self— i.e. #3118. They were&selfinv2026.5.0but are currently&mut selfonmain(e.g.qmdb::any::Db::sync(&mut self)), so this is gated on #3118. With&selfstorage the glue change is small and additive.Out of scope
Owned snapshots / COW / MVCC / snapshot isolation.
apply_batchstays briefly exclusive.