chio_store_sqlite/
execution_nonce_store.rs1use std::fs;
23use std::path::Path;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use chio_kernel::{ExecutionNonceStore, KernelError};
27use r2d2::Pool;
28use r2d2_sqlite::SqliteConnectionManager;
29use rusqlite::params;
30
31const RETENTION_GRACE_SECS: i64 = 60;
36
37#[derive(Debug)]
39pub struct SqliteExecutionNonceStoreError(String);
40
41impl std::fmt::Display for SqliteExecutionNonceStoreError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(f, "sqlite execution nonce store error: {}", self.0)
44 }
45}
46
47impl std::error::Error for SqliteExecutionNonceStoreError {}
48
49impl From<rusqlite::Error> for SqliteExecutionNonceStoreError {
50 fn from(e: rusqlite::Error) -> Self {
51 Self(e.to_string())
52 }
53}
54
55impl From<std::io::Error> for SqliteExecutionNonceStoreError {
56 fn from(e: std::io::Error) -> Self {
57 Self(e.to_string())
58 }
59}
60
61impl From<r2d2::Error> for SqliteExecutionNonceStoreError {
62 fn from(e: r2d2::Error) -> Self {
63 Self(e.to_string())
64 }
65}
66
67pub struct SqliteExecutionNonceStore {
69 pool: Pool<SqliteConnectionManager>,
70}
71
72const EXECUTION_NONCE_STORE_SUPPORTED_SCHEMA_VERSION: i32 = 0;
74const EXECUTION_NONCE_STORE_SCHEMA_KEY: &str = "execution_nonce";
77const EXECUTION_NONCE_STORE_LEGACY_ANCHOR_TABLES: &[&str] = &["chio_execution_nonces"];
80
81impl SqliteExecutionNonceStore {
82 pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteExecutionNonceStoreError> {
85 let path = path.as_ref();
86 if let Some(parent) = crate::sqlite_parent_dir_to_create(path) {
90 fs::create_dir_all(&parent)?;
91 }
92 let manager = SqliteConnectionManager::file(path);
93 let pool = Pool::builder().max_size(8).build(manager)?;
94 let store = Self { pool };
95 store.run_migrations()?;
96 Ok(store)
97 }
98
99 pub fn open_in_memory() -> Result<Self, SqliteExecutionNonceStoreError> {
101 let manager = SqliteConnectionManager::memory();
102 let pool = Pool::builder().max_size(1).build(manager)?;
103 let store = Self { pool };
104 store.run_migrations()?;
105 Ok(store)
106 }
107
108 fn run_migrations(&self) -> Result<(), SqliteExecutionNonceStoreError> {
109 let conn = self
110 .pool
111 .get()
112 .map_err(|e| SqliteExecutionNonceStoreError(format!("pool acquire: {e}")))?;
113 crate::check_schema_version(
114 &conn,
115 EXECUTION_NONCE_STORE_SCHEMA_KEY,
116 EXECUTION_NONCE_STORE_SUPPORTED_SCHEMA_VERSION,
117 EXECUTION_NONCE_STORE_LEGACY_ANCHOR_TABLES,
118 )
119 .map_err(|error| SqliteExecutionNonceStoreError(error.to_string()))?;
120 conn.execute_batch(
121 r#"
122 PRAGMA journal_mode = WAL;
123 PRAGMA synchronous = FULL;
124 PRAGMA busy_timeout = 5000;
125
126 CREATE TABLE IF NOT EXISTS chio_execution_nonces (
127 nonce_id TEXT PRIMARY KEY,
128 consumed_at INTEGER NOT NULL,
129 expires_at INTEGER NOT NULL
130 );
131
132 CREATE INDEX IF NOT EXISTS idx_chio_execution_nonces_expires_at
133 ON chio_execution_nonces(expires_at);
134 "#,
135 )?;
136 crate::stamp_schema_version(
137 &conn,
138 EXECUTION_NONCE_STORE_SCHEMA_KEY,
139 EXECUTION_NONCE_STORE_SUPPORTED_SCHEMA_VERSION,
140 )
141 .map_err(|error| SqliteExecutionNonceStoreError(error.to_string()))?;
142 Ok(())
143 }
144
145 pub fn try_reserve(
149 &self,
150 nonce_id: &str,
151 now: i64,
152 expires_at: i64,
153 ) -> Result<bool, SqliteExecutionNonceStoreError> {
154 if nonce_id.trim().is_empty() || nonce_id.trim() != nonce_id {
155 return Err(SqliteExecutionNonceStoreError(
156 "nonce_id must be non-empty and unpadded".to_string(),
157 ));
158 }
159 let mut conn = self
160 .pool
161 .get()
162 .map_err(|e| SqliteExecutionNonceStoreError(format!("pool acquire: {e}")))?;
163
164 let tx = conn.transaction()?;
165
166 tx.execute(
171 "DELETE FROM chio_execution_nonces WHERE expires_at <= ?1",
172 params![now],
173 )?;
174
175 let rows = tx.execute(
179 r#"
180 INSERT INTO chio_execution_nonces (nonce_id, consumed_at, expires_at)
181 VALUES (?1, ?2, ?3)
182 ON CONFLICT(nonce_id) DO NOTHING
183 "#,
184 params![nonce_id, now, expires_at],
185 )?;
186 tx.commit()?;
187 Ok(rows > 0)
188 }
189}
190
191fn now_secs() -> i64 {
192 i64::try_from(
193 SystemTime::now()
194 .duration_since(UNIX_EPOCH)
195 .map(|d| d.as_secs())
196 .unwrap_or(0),
197 )
198 .unwrap_or(i64::MAX)
199}
200
201impl ExecutionNonceStore for SqliteExecutionNonceStore {
202 fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError> {
203 let now = now_secs();
208 let estimated_nonce_expiry = now.saturating_add(
209 i64::try_from(chio_kernel::DEFAULT_EXECUTION_NONCE_TTL_SECS).unwrap_or(0),
210 );
211 self.reserve_until(nonce_id, estimated_nonce_expiry)
212 }
213
214 fn reserve_until(&self, nonce_id: &str, nonce_expires_at: i64) -> Result<bool, KernelError> {
215 let now = now_secs();
222 let retention = nonce_expires_at.saturating_add(RETENTION_GRACE_SECS);
223 let baseline = now.saturating_add(RETENTION_GRACE_SECS);
224 let expires_at = retention.max(baseline);
225 self.try_reserve(nonce_id, now, expires_at)
226 .map_err(|e| KernelError::Internal(format!("sqlite execution nonce store: {e}")))
227 }
228
229 fn is_consumed(&self, nonce_id: &str) -> Result<bool, KernelError> {
230 let now = now_secs();
231 let conn = self.pool.get().map_err(|error| {
232 KernelError::Internal(format!(
233 "sqlite execution nonce store pool acquire: {error}"
234 ))
235 })?;
236 conn.query_row(
237 r#"
238 SELECT EXISTS (
239 SELECT 1
240 FROM chio_execution_nonces
241 WHERE nonce_id = ?1 AND expires_at > ?2
242 )
243 "#,
244 params![nonce_id, now],
245 |row| row.get(0),
246 )
247 .map_err(|error| {
248 KernelError::Internal(format!(
249 "sqlite execution nonce store consumed lookup: {error}"
250 ))
251 })
252 }
253}
254
255#[cfg(test)]
256#[allow(clippy::expect_used, clippy::unwrap_used)]
257mod tests {
258 use super::*;
259
260 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
261 let nonce = SystemTime::now()
262 .duration_since(UNIX_EPOCH)
263 .expect("time before epoch")
264 .as_nanos();
265 std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
266 }
267
268 #[test]
269 fn fresh_nonce_is_reserved() {
270 let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
271 assert!(<SqliteExecutionNonceStore as ExecutionNonceStore>::reserve(&store, "a").unwrap());
272 }
273
274 #[test]
275 fn duplicate_nonce_is_rejected() {
276 let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
277 assert!(store.try_reserve("a", 1_000, 1_100).unwrap());
278 assert!(!store.try_reserve("a", 1_001, 1_100).unwrap());
279 }
280
281 #[test]
282 fn padded_nonce_id_is_rejected() {
283 let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
284 let error = store.try_reserve(" nonce", 1_000, 1_100).unwrap_err();
285
286 assert!(
287 error.to_string().contains("nonce_id"),
288 "expected nonce_id validation error, got {error}"
289 );
290 }
291
292 #[test]
293 fn expired_row_is_pruned_and_slot_reusable() {
294 let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
295 assert!(store.try_reserve("a", 1_000, 1_030).unwrap());
296 assert!(store.try_reserve("a", 2_000, 2_030).unwrap());
300 }
301
302 #[test]
303 fn persists_across_reopen() {
304 let path = unique_db_path("chio-exec-nonce");
305 let now = now_secs();
306 let expires_at = now.saturating_add(120);
307 {
308 let store = SqliteExecutionNonceStore::open(&path).unwrap();
309 assert!(store
310 .try_reserve("persistent-nonce", now, expires_at)
311 .unwrap());
312 assert!(store.is_consumed("persistent-nonce").unwrap());
313 }
314 let reopened = SqliteExecutionNonceStore::open(&path).unwrap();
315 assert!(reopened.is_consumed("persistent-nonce").unwrap());
316 assert!(!reopened
317 .try_reserve("persistent-nonce", now, expires_at)
318 .unwrap());
319 let _ = fs::remove_file(path);
320 }
321
322 #[test]
323 fn consumed_lookup_ignores_expired_rows() {
324 let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
325 assert!(store.try_reserve("expired-nonce", 1_000, 1_100).unwrap());
326 assert!(!store.is_consumed("expired-nonce").unwrap());
327 }
328}