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
72impl SqliteExecutionNonceStore {
73 pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteExecutionNonceStoreError> {
76 let path = path.as_ref();
77 if let Some(parent) = path.parent() {
78 if !parent.as_os_str().is_empty() {
79 fs::create_dir_all(parent)?;
80 }
81 }
82 let manager = SqliteConnectionManager::file(path);
83 let pool = Pool::builder().max_size(8).build(manager)?;
84 let store = Self { pool };
85 store.run_migrations()?;
86 Ok(store)
87 }
88
89 pub fn open_in_memory() -> Result<Self, SqliteExecutionNonceStoreError> {
91 let manager = SqliteConnectionManager::memory();
92 let pool = Pool::builder().max_size(1).build(manager)?;
93 let store = Self { pool };
94 store.run_migrations()?;
95 Ok(store)
96 }
97
98 fn run_migrations(&self) -> Result<(), SqliteExecutionNonceStoreError> {
99 let conn = self
100 .pool
101 .get()
102 .map_err(|e| SqliteExecutionNonceStoreError(format!("pool acquire: {e}")))?;
103 conn.execute_batch(
104 r#"
105 PRAGMA journal_mode = WAL;
106 PRAGMA synchronous = FULL;
107 PRAGMA busy_timeout = 5000;
108
109 CREATE TABLE IF NOT EXISTS chio_execution_nonces (
110 nonce_id TEXT PRIMARY KEY,
111 consumed_at INTEGER NOT NULL,
112 expires_at INTEGER NOT NULL
113 );
114
115 CREATE INDEX IF NOT EXISTS idx_chio_execution_nonces_expires_at
116 ON chio_execution_nonces(expires_at);
117 "#,
118 )?;
119 Ok(())
120 }
121
122 pub fn try_reserve(
126 &self,
127 nonce_id: &str,
128 now: i64,
129 expires_at: i64,
130 ) -> Result<bool, SqliteExecutionNonceStoreError> {
131 let mut conn = self
132 .pool
133 .get()
134 .map_err(|e| SqliteExecutionNonceStoreError(format!("pool acquire: {e}")))?;
135
136 let tx = conn.transaction()?;
137
138 tx.execute(
143 "DELETE FROM chio_execution_nonces WHERE expires_at <= ?1",
144 params![now],
145 )?;
146
147 let rows = tx.execute(
151 r#"
152 INSERT INTO chio_execution_nonces (nonce_id, consumed_at, expires_at)
153 VALUES (?1, ?2, ?3)
154 ON CONFLICT(nonce_id) DO NOTHING
155 "#,
156 params![nonce_id, now, expires_at],
157 )?;
158 tx.commit()?;
159 Ok(rows > 0)
160 }
161}
162
163fn now_secs() -> i64 {
164 i64::try_from(
165 SystemTime::now()
166 .duration_since(UNIX_EPOCH)
167 .map(|d| d.as_secs())
168 .unwrap_or(0),
169 )
170 .unwrap_or(i64::MAX)
171}
172
173impl ExecutionNonceStore for SqliteExecutionNonceStore {
174 fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError> {
175 let now = now_secs();
182 let expires_at = now.saturating_add(RETENTION_GRACE_SECS);
183 self.try_reserve(nonce_id, now, expires_at)
184 .map_err(|e| KernelError::Internal(format!("sqlite execution nonce store: {e}")))
185 }
186
187 fn reserve_until(&self, nonce_id: &str, nonce_expires_at: i64) -> Result<bool, KernelError> {
188 let now = now_secs();
195 let retention = nonce_expires_at.saturating_add(RETENTION_GRACE_SECS);
196 let baseline = now.saturating_add(RETENTION_GRACE_SECS);
197 let expires_at = retention.max(baseline);
198 self.try_reserve(nonce_id, now, expires_at)
199 .map_err(|e| KernelError::Internal(format!("sqlite execution nonce store: {e}")))
200 }
201}
202
203#[cfg(test)]
204#[allow(clippy::expect_used, clippy::unwrap_used)]
205mod tests {
206 use super::*;
207
208 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
209 let nonce = SystemTime::now()
210 .duration_since(UNIX_EPOCH)
211 .expect("time before epoch")
212 .as_nanos();
213 std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
214 }
215
216 #[test]
217 fn fresh_nonce_is_reserved() {
218 let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
219 assert!(<SqliteExecutionNonceStore as ExecutionNonceStore>::reserve(&store, "a").unwrap());
220 }
221
222 #[test]
223 fn duplicate_nonce_is_rejected() {
224 let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
225 assert!(store.try_reserve("a", 1_000, 1_100).unwrap());
226 assert!(!store.try_reserve("a", 1_001, 1_100).unwrap());
227 }
228
229 #[test]
230 fn expired_row_is_pruned_and_slot_reusable() {
231 let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
232 assert!(store.try_reserve("a", 1_000, 1_030).unwrap());
233 assert!(store.try_reserve("a", 2_000, 2_030).unwrap());
237 }
238
239 #[test]
240 fn persists_across_reopen() {
241 let path = unique_db_path("chio-exec-nonce");
242 {
243 let store = SqliteExecutionNonceStore::open(&path).unwrap();
244 assert!(store
245 .try_reserve("persistent-nonce", 1_000, 1_000_000_000)
246 .unwrap());
247 }
248 let reopened = SqliteExecutionNonceStore::open(&path).unwrap();
249 assert!(!reopened
250 .try_reserve("persistent-nonce", 1_001, 1_000_000_000)
251 .unwrap());
252 let _ = fs::remove_file(path);
253 }
254}