@@ -41,6 +41,10 @@ use super::{DELETE_MANY_BATCH_SIZE, DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}
4141#[ derive( Debug , Clone ) ]
4242pub struct SqliteStore {
4343 pool : SqlitePool ,
44+ /// Pool for writes whose loss after a crash is harmless; see
45+ /// [`SqliteStore::create_relaxed`]. On-disk stores open it with
46+ /// `synchronous=NORMAL`; in-memory stores share `pool`.
47+ relaxed_pool : SqlitePool ,
4448 #[ cfg_attr( not( any( test, feature = "test-support" ) ) , allow( dead_code) ) ]
4549 in_memory_keepalive : Option < Arc < Mutex < Option < SqliteConnection > > > > ,
4650}
@@ -80,12 +84,20 @@ pub(super) async fn replace_pool_connection(store: &SqliteStore) -> PersistenceR
8084/// writes. `SQLite`'s default rollback journal makes each of those commits pay
8185/// several `fsync` calls and blocks readers while a writer holds the lock, so
8286/// under a burst of forwarded connections the whole store serializes on disk
83- /// latency. WAL mode removes the reader/writer exclusion and, combined with
84- /// `synchronous=NORMAL`, drops the per-commit `fsync`: a power loss or kernel
85- /// crash may roll back the most recent transactions, but the database stays
86- /// consistent. That is the standard WAL configuration and matches the
87- /// single-node scope of the `SQLite` backend; deployments that need stronger
88- /// durability guarantees use the Postgres backend.
87+ /// latency. WAL mode removes the reader/writer exclusion and cuts each commit
88+ /// to a single `fsync` of the WAL file.
89+ ///
90+ /// The main pool keeps `synchronous=FULL` rather than the usual WAL pairing
91+ /// of `NORMAL`. Under `NORMAL` a power loss or kernel crash can roll back
92+ /// transactions that were already acknowledged, and several of those writes
93+ /// tighten authorization: an SSH session revoked just before the crash would
94+ /// come back valid for the rest of its lifetime. `FULL` keeps every
95+ /// acknowledged commit durable. Writes whose loss only ever denies access,
96+ /// such as minting a new SSH session token, go through a separate
97+ /// `synchronous=NORMAL` pool instead ([`SqliteStore::create_relaxed`]). Both
98+ /// pools append to the same WAL file, so the next `FULL` commit's `fsync` also
99+ /// makes every earlier relaxed commit durable, and a crash can never roll back
100+ /// a `FULL` commit.
89101///
90102/// `journal_mode=WAL` is persistent in the database file, but switching into
91103/// it needs exclusive access: if another connection holds the file open, the
@@ -105,7 +117,7 @@ async fn configure_on_disk_durability(
105117) -> PersistenceResult < SqliteConnectOptions > {
106118 let options = options
107119 . journal_mode ( SqliteJournalMode :: Wal )
108- . synchronous ( SqliteSynchronous :: Normal ) ;
120+ . synchronous ( SqliteSynchronous :: Full ) ;
109121 let wal_error = |e : & sqlx:: Error | {
110122 PersistenceError :: Database ( format ! (
111123 "failed to switch SQLite database {} to WAL journal mode (the switch needs \
@@ -121,9 +133,56 @@ async fn configure_on_disk_durability(
121133 Ok ( options)
122134}
123135
136+ /// Insert a new object at resource version 1, failing if it already exists.
137+ async fn insert_new_object (
138+ pool : & SqlitePool ,
139+ object_type : & str ,
140+ id : & str ,
141+ name : & str ,
142+ workspace : & str ,
143+ payload : & [ u8 ] ,
144+ labels : Option < & str > ,
145+ ) -> PersistenceResult < WriteResult > {
146+ let now_ms = current_time_ms ( ) ;
147+ sqlx:: query (
148+ r#"
149+ INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version")
150+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1)
151+ "# ,
152+ )
153+ . bind ( object_type)
154+ . bind ( id)
155+ . bind ( name)
156+ . bind ( workspace)
157+ . bind ( payload)
158+ . bind ( now_ms)
159+ . bind ( labels. unwrap_or ( "{}" ) )
160+ . execute ( pool)
161+ . await
162+ . map_err ( |e| map_db_error ( & e) ) ?;
163+
164+ Ok ( WriteResult {
165+ resource_version : 1 ,
166+ created_at_ms : now_ms,
167+ updated_at_ms : now_ms,
168+ } )
169+ }
170+
124171#[ cfg( test) ]
125172pub ( super ) async fn journal_settings ( store : & SqliteStore ) -> PersistenceResult < ( String , i64 ) > {
126- let mut connection = store. pool . acquire ( ) . await . map_err ( |e| map_db_error ( & e) ) ?;
173+ pool_journal_settings ( & store. pool ) . await
174+ }
175+
176+ #[ cfg( test) ]
177+ pub ( super ) async fn relaxed_journal_settings (
178+ store : & SqliteStore ,
179+ ) -> PersistenceResult < ( String , i64 ) > {
180+ pool_journal_settings ( & store. relaxed_pool ) . await
181+ }
182+
183+ #[ cfg( test) ]
184+ async fn pool_journal_settings ( pool : & SqlitePool ) -> PersistenceResult < ( String , i64 ) > {
185+ let mut connection = pool. acquire ( ) . await . map_err ( |e| map_db_error ( & e) ) ?;
127186 let journal_mode: String = sqlx:: query_scalar ( "PRAGMA journal_mode" )
128187 . fetch_one ( & mut * connection)
129188 . await
@@ -185,18 +244,33 @@ impl SqliteStore {
185244 None
186245 } ;
187246
247+ let relaxed_options =
248+ ( !is_in_memory) . then ( || options. clone ( ) . synchronous ( SqliteSynchronous :: Normal ) ) ;
249+
188250 let pool = pool_options
189251 . connect_with ( options)
190252 . await
191253 . map_err ( |e| map_db_error ( & e) ) ?;
192254
255+ // SQLite serializes writers, so one connection is enough for the
256+ // relaxed pool.
257+ let relaxed_pool = match relaxed_options {
258+ Some ( relaxed_options) => SqlitePoolOptions :: new ( )
259+ . max_connections ( 1 )
260+ . connect_with ( relaxed_options)
261+ . await
262+ . map_err ( |e| map_db_error ( & e) ) ?,
263+ None => pool. clone ( ) ,
264+ } ;
265+
193266 // Tighten the permissions of the database file to owner-only access (0o600).
194267 if let Some ( path) = db_path {
195268 restrict_db_file_permissions ( & path) ?;
196269 }
197270
198271 Ok ( Self {
199272 pool,
273+ relaxed_pool,
200274 in_memory_keepalive,
201275 } )
202276 }
@@ -251,6 +325,7 @@ impl SqliteStore {
251325 /// Do not call from runtime code; this tears down the active pool.
252326 #[ cfg( any( test, feature = "test-support" ) ) ]
253327 pub async fn close ( & self ) {
328+ self . relaxed_pool . close ( ) . await ;
254329 self . pool . close ( ) . await ;
255330 if let Some ( keepalive) = & self . in_memory_keepalive {
256331 let connection = keepalive. lock ( ) . await . take ( ) ;
@@ -294,6 +369,33 @@ ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPD
294369 Ok ( ( ) )
295370 }
296371
372+ /// Create an object with `synchronous=NORMAL` durability.
373+ ///
374+ /// Same semantics as [`Self::put_if`] with [`WriteCondition::MustCreate`],
375+ /// except that a power loss or kernel crash shortly after the call returns
376+ /// may roll the insert back. Use it only for objects whose absence denies
377+ /// access, never for writes that revoke or tighten anything.
378+ pub async fn create_relaxed (
379+ & self ,
380+ object_type : & str ,
381+ id : & str ,
382+ name : & str ,
383+ workspace : & str ,
384+ payload : & [ u8 ] ,
385+ labels : Option < & str > ,
386+ ) -> PersistenceResult < WriteResult > {
387+ insert_new_object (
388+ & self . relaxed_pool ,
389+ object_type,
390+ id,
391+ name,
392+ workspace,
393+ payload,
394+ labels,
395+ )
396+ . await
397+ }
398+
297399 #[ allow( clippy:: too_many_arguments) ]
298400 pub async fn put_if (
299401 & self ,
@@ -309,29 +411,16 @@ ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPD
309411
310412 match condition {
311413 WriteCondition :: MustCreate => {
312- // Insert only - fail if object exists
313- sqlx:: query (
314- r#"
315- INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version")
316- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1)
317- "# ,
414+ insert_new_object (
415+ & self . pool ,
416+ object_type,
417+ id,
418+ name,
419+ workspace,
420+ payload,
421+ labels,
318422 )
319- . bind ( object_type)
320- . bind ( id)
321- . bind ( name)
322- . bind ( workspace)
323- . bind ( payload)
324- . bind ( now_ms)
325- . bind ( labels. unwrap_or ( "{}" ) )
326- . execute ( & self . pool )
327423 . await
328- . map_err ( |e| map_db_error ( & e) ) ?;
329-
330- Ok ( WriteResult {
331- resource_version : 1 ,
332- created_at_ms : now_ms,
333- updated_at_ms : now_ms,
334- } )
335424 }
336425 WriteCondition :: MatchResourceVersion ( expected_version) => {
337426 // Update with version check
0 commit comments