Skip to content

Commit d5737da

Browse files
committed
fix(server): keep SQLite commits durable except SSH session issuance
WAL with synchronous=NORMAL can roll back acknowledged commits after a power loss or kernel crash, including SSH session revocations and other authorization-tightening writes. Run the main pool with synchronous=FULL so every acknowledged write is durable; in WAL mode that is a single fsync of the WAL per commit. Add Store::create_relaxed for inserts that are safe to lose, and use it only for SSH session issuance: a dropped token just fails validation. On file-backed SQLite it runs on a dedicated single-connection pool with synchronous=NORMAL. Both pools share one WAL, so the next FULL commit also makes earlier relaxed commits durable. Postgres treats it as an ordinary durable MustCreate insert. Signed-off-by: Mrunal Patel <mrunalp@gmail.com>
1 parent 5cced0e commit d5737da

8 files changed

Lines changed: 253 additions & 54 deletions

File tree

‎architecture/gateway.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -648,15 +648,20 @@ Gateway and Sandbox Protocol token responses follow the same convention: a
648648
present expiration timestamp carries the absolute deadline, while absence means
649649
the issued token does not expire.
650650

651-
On-disk SQLite databases run in WAL journal mode with `synchronous=NORMAL`.
651+
On-disk SQLite databases run in WAL journal mode with `synchronous=FULL`.
652652
The adapter switches the file to WAL on a single connection before the pool
653653
opens, then applies both settings to every pooled connection. WAL lets readers
654-
proceed while a writer commits, and `NORMAL` removes the per-commit `fsync`,
654+
proceed while a writer commits and reduces each commit to one WAL `fsync`,
655655
which matters because gateway hot paths such as SSH session issuance and
656-
revocation are many small autocommit writes. The trade-off is that a power
657-
loss or kernel crash can roll back the most recent transactions; the database
658-
remains consistent. Deployments that need stronger durability or multiple
659-
replicas use Postgres. WAL requires a local filesystem with working shared
656+
revocation are many small autocommit writes. `synchronous` stays at `FULL`
657+
because some of those writes tighten authorization: under `NORMAL`, a power
658+
loss could roll back an acknowledged SSH session revocation and make the token
659+
valid again. Writes that are safe to lose, currently only SSH session issuance
660+
through `Store::create_relaxed`, use a second single-connection pool with
661+
`synchronous=NORMAL`. Losing a minted token only invalidates it, and because
662+
both pools share one WAL, the next `FULL` commit also makes earlier relaxed
663+
commits durable. Deployments that need multiple replicas use Postgres, where
664+
`create_relaxed` is an ordinary durable insert. WAL requires a local filesystem with working shared
660665
memory, so the SQLite file must not live on a network mount, and backups must
661666
use `sqlite3 .backup` or `VACUUM INTO` rather than copying the main file alone.
662667

‎crates/openshell-server/src/grpc/sandbox.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2597,7 +2597,9 @@ pub(super) async fn handle_create_ssh_session(
25972597
// Ensure metadata is valid (defense in depth - should always be true for server-constructed metadata)
25982598
super::validation::validate_object_metadata(session.metadata.as_ref(), "ssh_session")?;
25992599

2600-
// Use MustCreate to atomically ensure the session token is unique
2600+
// `create_relaxed` fails if the token already exists, like MustCreate, but
2601+
// skips the per-commit fsync on SQLite. Losing a freshly minted token in a
2602+
// crash only makes it invalid; revocation stays on the durable `put_if`.
26012603
let session_labels = session.object_labels();
26022604
let session_labels_json = if session_labels.as_ref().is_none_or(HashMap::is_empty) {
26032605
None
@@ -2609,14 +2611,13 @@ pub(super) async fn handle_create_ssh_session(
26092611
};
26102612
state
26112613
.store
2612-
.put_if(
2614+
.create_relaxed(
26132615
SshSession::object_type(),
26142616
&token,
26152617
session.object_name(),
26162618
session.object_workspace(),
26172619
&session.encode_to_vec(),
26182620
session_labels_json.as_deref(),
2619-
WriteCondition::MustCreate,
26202621
)
26212622
.await
26222623
.map_err(|e| Status::internal(format!("persist ssh session failed: {e}")))?;

‎crates/openshell-server/src/persistence/mod.rs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,38 @@ impl Store {
384384
))
385385
}
386386

387+
/// Create an object that is safe to lose in a crash.
388+
///
389+
/// Behaves like [`Self::put_if`] with [`WriteCondition::MustCreate`], but
390+
/// the file-backed `SQLite` store commits it with `synchronous=NORMAL`, so
391+
/// a power loss or kernel crash shortly after the call returns may roll
392+
/// the insert back. Use it only for objects whose absence denies access,
393+
/// such as newly minted SSH session tokens. Writes that revoke or tighten
394+
/// anything must use [`Self::put_if`], which is always durable.
395+
#[tracing::instrument(
396+
name = "store",
397+
skip_all,
398+
fields(otel.name = "store.create_relaxed", otel.status_code = tracing::field::Empty, object_type = %object_type, object.id = %id, object.name = %name, workspace = %workspace)
399+
)]
400+
pub async fn create_relaxed(
401+
&self,
402+
object_type: &str,
403+
id: &str,
404+
name: &str,
405+
workspace: &str,
406+
payload: &[u8],
407+
labels: Option<&str>,
408+
) -> PersistenceResult<WriteResult> {
409+
store_dispatch_traced!(self.create_relaxed(
410+
object_type,
411+
id,
412+
name,
413+
workspace,
414+
payload,
415+
labels
416+
))
417+
}
418+
387419
/// Delete an object by id with compare-and-swap support.
388420
///
389421
/// # Arguments

‎crates/openshell-server/src/persistence/postgres.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,29 @@ ON CONFLICT (object_type, workspace, name) WHERE name IS NOT NULL DO UPDATE SET
180180
Ok(())
181181
}
182182

183+
/// Create an object; Postgres commits are always durable, so this is
184+
/// [`Self::put_if`] with [`WriteCondition::MustCreate`].
185+
pub async fn create_relaxed(
186+
&self,
187+
object_type: &str,
188+
id: &str,
189+
name: &str,
190+
workspace: &str,
191+
payload: &[u8],
192+
labels: Option<&str>,
193+
) -> PersistenceResult<WriteResult> {
194+
self.put_if(
195+
object_type,
196+
id,
197+
name,
198+
workspace,
199+
payload,
200+
labels,
201+
WriteCondition::MustCreate,
202+
)
203+
.await
204+
}
205+
183206
#[allow(clippy::too_many_arguments)]
184207
pub async fn put_if(
185208
&self,

‎crates/openshell-server/src/persistence/sqlite.rs‎

Lines changed: 118 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ use super::{DELETE_MANY_BATCH_SIZE, DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE}
4141
#[derive(Debug, Clone)]
4242
pub 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)]
125172
pub(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

Comments
 (0)