Skip to main content

cedar_policy_symcc/symcc/
solver_pool.rs

1/*
2 * Copyright Cedar Contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! A pool of warm CVC5 solver processes for reusing across queries.
18//!
19//! This module provides [`SolverPool`], which maintains a pool of pre-warmed
20//! solver processes that can be acquired and released efficiently, avoiding
21//! the overhead of spawning a new process for each query.
22//!
23//! # Example
24//!
25//! ```no_run
26//! use cedar_policy_symcc::solver_pool::{SolverPool, SolverPoolConfig};
27//! use cedar_policy_symcc::CedarSymCompiler;
28//!
29//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
30//! // Create a pool with default configuration
31//! let pool = SolverPool::new(SolverPoolConfig::default()).await?;
32//!
33//! // Acquire a solver from the pool
34//! let solver = pool.acquire().await?;
35//!
36//! // Use the solver with CedarSymCompiler
37//! let mut compiler = CedarSymCompiler::new(solver)?;
38//! // ... perform queries ...
39//!
40//! // Solver is automatically returned to the pool when dropped
41//! # Ok(())
42//! # }
43//! ```
44
45use super::smtlib_script::SmtLibScript;
46use super::solver::{Decision, DecisionWithModel, LocalSolver, Solver, SolverError};
47use miette::Diagnostic;
48use std::io;
49use std::pin::Pin;
50use std::sync::Arc;
51use std::task::{Context, Poll};
52use std::time::Duration;
53use thiserror::Error;
54use tokio::io::AsyncWrite;
55use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
56
57/// Errors that can occur when acquiring a solver from a [`SolverPool`].
58#[derive(Debug, Diagnostic, Error)]
59pub enum SolverPoolError {
60    /// The pool's internal semaphore was closed unexpectedly.
61    /// This indicates a bug or that the pool has been corrupted.
62    #[error("solver pool semaphore closed unexpectedly")]
63    SemaphoreClosed,
64
65    /// Timed out waiting for a solver to become available from the pool.
66    /// This occurs when all solvers are in use and `acquire_timeout` is set.
67    #[error("timeout waiting for solver from pool")]
68    AcquireTimeout,
69
70    /// An error occurred while spawning or interacting with the underlying solver.
71    #[error(transparent)]
72    #[diagnostic(transparent)]
73    Solver(#[from] SolverError),
74}
75
76/// A writer that always returns an error, used when a solver has been marked as failed.
77#[derive(Debug, Default)]
78struct FailedWriter;
79
80impl FailedWriter {
81    fn error() -> io::Error {
82        io::Error::other(SolverError::SolverMarkedFailed)
83    }
84}
85
86impl AsyncWrite for FailedWriter {
87    fn poll_write(
88        self: Pin<&mut Self>,
89        _cx: &mut Context<'_>,
90        _buf: &[u8],
91    ) -> Poll<io::Result<usize>> {
92        Poll::Ready(Err(Self::error()))
93    }
94
95    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
96        Poll::Ready(Err(Self::error()))
97    }
98
99    fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
100        Poll::Ready(Err(Self::error()))
101    }
102}
103
104/// Configuration for a [`SolverPool`].
105#[derive(Clone, Debug)]
106pub struct SolverPoolConfig {
107    /// Minimum number of solvers to pre-warm at pool creation.
108    /// Defaults to 1.
109    pub min_solvers: usize,
110    /// Maximum number of concurrent solvers allowed.
111    /// Defaults to 4.
112    pub max_solvers: usize,
113    /// Timeout for acquiring a solver from the pool.
114    /// `None` means wait indefinitely.
115    /// Defaults to `None`.
116    pub acquire_timeout: Option<Duration>,
117}
118
119impl Default for SolverPoolConfig {
120    fn default() -> Self {
121        Self {
122            min_solvers: 1,
123            max_solvers: 4,
124            acquire_timeout: None,
125        }
126    }
127}
128
129/// A pool of warm CVC5 solver processes.
130///
131/// The pool maintains a set of pre-warmed solver processes that can be
132/// efficiently reused across queries. This avoids the overhead of spawning
133/// a new process for each query.
134///
135/// Solvers are acquired using [`SolverPool::acquire`], which returns a
136/// [`PooledSolver`] that implements the [`Solver`] trait. When the
137/// [`PooledSolver`] is dropped, it attempts to reset and return the solver
138/// to the pool. If the reset fails (e.g., due to a parse error that killed
139/// the solver), the solver is discarded.
140#[derive(Debug)]
141pub struct SolverPool {
142    /// Available solvers ready for use
143    available: Arc<Mutex<Vec<LocalSolver>>>,
144    /// Semaphore to limit maximum concurrent solvers
145    semaphore: Arc<Semaphore>,
146    /// Pool configuration
147    config: SolverPoolConfig,
148}
149
150impl SolverPool {
151    /// Creates a new solver pool with the given configuration.
152    ///
153    /// This will pre-warm `config.min_solvers` solver processes eagerly.
154    /// Returns an error if any of the pre-warmed solvers fail to spawn.
155    pub async fn new(config: SolverPoolConfig) -> Result<Self, SolverError> {
156        let semaphore = Arc::new(Semaphore::new(config.max_solvers));
157        let mut solvers = Vec::with_capacity(config.max_solvers);
158
159        // Eagerly spawn min_solvers
160        for _ in 0..config.min_solvers {
161            solvers.push(LocalSolver::cvc5()?);
162        }
163
164        Ok(Self {
165            available: Arc::new(Mutex::new(solvers)),
166            semaphore,
167            config,
168        })
169    }
170
171    /// Acquires a solver from the pool.
172    ///
173    /// If a warm solver is available, it is returned immediately.
174    /// If no solvers are available but the pool hasn't reached `max_solvers`,
175    /// a new solver is spawned.
176    /// If the pool is at capacity, this method blocks until a solver becomes
177    /// available or the `acquire_timeout` is exceeded.
178    ///
179    /// Returns a [`PooledSolver`] that implements [`Solver`] and will be
180    /// returned to the pool when dropped.
181    pub async fn acquire(&self) -> Result<PooledSolver, SolverPoolError> {
182        // Acquire a permit from the semaphore (blocks if at max capacity)
183        let permit = match self.config.acquire_timeout {
184            Some(timeout) => {
185                match tokio::time::timeout(timeout, Arc::clone(&self.semaphore).acquire_owned())
186                    .await
187                {
188                    Ok(Ok(permit)) => permit,
189                    Ok(Err(_)) => return Err(SolverPoolError::SemaphoreClosed),
190                    Err(_) => return Err(SolverPoolError::AcquireTimeout),
191                }
192            }
193            None => Arc::clone(&self.semaphore)
194                .acquire_owned()
195                .await
196                .map_err(|_| SolverPoolError::SemaphoreClosed)?,
197        };
198
199        // Get an available solver from the pool
200        let solver = {
201            let mut available = self.available.lock().await;
202            available.pop()
203        };
204
205        let solver = match solver {
206            Some(s) => s,
207            None => {
208                // No available solver, spawn a new one
209                LocalSolver::cvc5()?
210            }
211        };
212
213        Ok(PooledSolver {
214            solver: Some(solver),
215            pool: Arc::clone(&self.available),
216            permit: Some(permit),
217            failed_writer: FailedWriter,
218        })
219    }
220
221    /// Returns the current number of available (idle) solvers in the pool.
222    pub async fn available_count(&self) -> usize {
223        self.available.lock().await.len()
224    }
225
226    /// Returns the number of permits currently available in the semaphore.
227    /// This indicates how many more solvers can be acquired before blocking.
228    pub fn permits_available(&self) -> usize {
229        self.semaphore.available_permits()
230    }
231}
232
233/// A solver acquired from a [`SolverPool`].
234///
235/// This type implements [`Solver`] by delegating to the underlying [`LocalSolver`].
236/// When dropped, it attempts to reset and return the solver to the pool.
237/// If the reset fails, the solver is discarded.
238#[derive(Debug)]
239pub struct PooledSolver {
240    /// The underlying solver. This is an Option so we can take it in drop().
241    solver: Option<LocalSolver>,
242    /// Reference to the pool's available solvers
243    pool: Arc<Mutex<Vec<LocalSolver>>>,
244    /// Semaphore permit - released after the solver is returned to the pool.
245    /// This is an Option so we can move it into the async drop task.
246    permit: Option<OwnedSemaphorePermit>,
247    /// Writer returned when the solver has been marked as failed
248    failed_writer: FailedWriter,
249}
250
251impl PooledSolver {
252    /// Marks this solver as failed, so it will be discarded instead of returned to the pool.
253    ///
254    /// This is useful when you know the solver is in a bad state (e.g., after a parse error)
255    /// and should not be reused.
256    pub fn mark_failed(&mut self) {
257        self.solver = None;
258    }
259}
260
261impl Solver for PooledSolver {
262    fn smtlib_input(&mut self) -> &mut (dyn tokio::io::AsyncWrite + Unpin + Send) {
263        match &mut self.solver {
264            Some(s) => s.smtlib_input(),
265            None => &mut self.failed_writer,
266        }
267    }
268
269    async fn enable_models(&mut self) -> Result<(), SolverError> {
270        // The `PooledSolver` implementation doesn't need to do anything other
271        // than passthrough.
272        let solver = self
273            .solver
274            .as_mut()
275            .ok_or(SolverError::SolverMarkedFailed)?;
276        solver.enable_models().await
277    }
278
279    async fn check_sat(&mut self) -> Result<Decision, SolverError> {
280        let solver = self
281            .solver
282            .as_mut()
283            .ok_or(SolverError::SolverMarkedFailed)?;
284        solver.check_sat().await
285    }
286
287    async fn check_sat_with_model(&mut self) -> Result<DecisionWithModel, SolverError> {
288        let solver = self
289            .solver
290            .as_mut()
291            .ok_or(SolverError::SolverMarkedFailed)?;
292        solver.check_sat_with_model().await
293    }
294}
295
296impl Drop for PooledSolver {
297    fn drop(&mut self) {
298        let pool = Arc::clone(&self.pool);
299        let solver = self.solver.take();
300        let permit = self.permit.take();
301
302        // Spawn a task to reset and return the solver to the pool.
303        // The permit is moved into the task so it's only released after
304        // the solver is returned to the pool (or discarded).
305        tokio::spawn(async move {
306            if let Some(mut solver) = solver {
307                // Try to reset the solver
308                if solver.smtlib_input().reset().await.is_ok() {
309                    // Reset succeeded, return to pool
310                    pool.lock().await.push(solver);
311                }
312                // If reset failed, the solver is discarded (not returned to pool)
313            }
314            // Permit is released here when the task completes
315            drop(permit);
316        });
317    }
318}
319
320#[cfg(test)]
321mod test {
322    use super::*;
323    use cool_asserts::assert_matches;
324
325    /// Polls an async condition with retries to avoid flaky tests.
326    /// Retries every 10ms for up to 100ms total.
327    async fn poll_until<F, Fut>(mut condition: F)
328    where
329        F: FnMut() -> Fut,
330        Fut: std::future::Future<Output = bool>,
331    {
332        for _ in 0..10 {
333            if condition().await {
334                return;
335            }
336            tokio::time::sleep(Duration::from_millis(10)).await;
337        }
338        panic!("condition not met after 100ms of polling");
339    }
340
341    #[tokio::test]
342    async fn test_pool_creation() {
343        let pool = SolverPool::new(SolverPoolConfig {
344            min_solvers: 2,
345            max_solvers: 4,
346            acquire_timeout: None,
347        })
348        .await
349        .unwrap();
350
351        // Should have 2 pre-warmed solvers
352        assert_eq!(pool.available_count().await, 2);
353        assert_eq!(pool.permits_available(), 4);
354    }
355
356    #[tokio::test]
357    async fn test_acquire_release() {
358        let pool = SolverPool::new(SolverPoolConfig {
359            min_solvers: 1,
360            max_solvers: 4,
361            acquire_timeout: None,
362        })
363        .await
364        .unwrap();
365
366        assert_eq!(pool.available_count().await, 1);
367
368        {
369            let mut solver = pool.acquire().await.unwrap();
370            // Pool should now be empty
371            assert_eq!(pool.available_count().await, 0);
372
373            // Basic solver operation should work
374            let decision = solver.check_sat().await.unwrap();
375            assert_eq!(decision, Decision::Sat);
376        }
377
378        // Wait for the async drop task to return the solver to the pool
379        poll_until(|| async { pool.available_count().await == 1 }).await;
380    }
381
382    #[tokio::test]
383    async fn test_solver_reuse() {
384        let pool = SolverPool::new(SolverPoolConfig {
385            min_solvers: 1,
386            max_solvers: 1,
387            acquire_timeout: None,
388        })
389        .await
390        .unwrap();
391
392        // First acquire
393        {
394            let mut solver = pool.acquire().await.unwrap();
395            solver.smtlib_input().assert("false").await.unwrap();
396            let decision = solver.check_sat().await.unwrap();
397            assert_eq!(decision, Decision::Unsat);
398        }
399
400        // Wait for the async drop task to return the solver to the pool
401        poll_until(|| async { pool.available_count().await == 1 }).await;
402
403        // Second acquire - should reuse the same solver (after reset)
404        {
405            let mut solver = pool.acquire().await.unwrap();
406            // The solver was reset, so this should work fresh
407            solver.enable_models().await.unwrap();
408            solver.smtlib_input().assert("true").await.unwrap();
409            let decision = solver.check_sat_with_model().await.unwrap();
410            assert_matches!(decision, DecisionWithModel::Sat { .. });
411        }
412    }
413
414    #[tokio::test]
415    async fn test_concurrent_acquire() {
416        let pool = Arc::new(
417            SolverPool::new(SolverPoolConfig {
418                min_solvers: 2,
419                max_solvers: 4,
420                acquire_timeout: None,
421            })
422            .await
423            .unwrap(),
424        );
425
426        let handles: Vec<_> = (0..4)
427            .map(|_| {
428                let pool = Arc::clone(&pool);
429                tokio::spawn(async move {
430                    let mut solver = pool.acquire().await.unwrap();
431                    let decision = solver.check_sat().await.unwrap();
432                    assert_eq!(decision, Decision::Sat);
433                })
434            })
435            .collect();
436
437        for handle in handles {
438            handle.await.unwrap();
439        }
440    }
441
442    #[tokio::test]
443    async fn test_pool_exhaustion_blocks() {
444        let pool = Arc::new(
445            SolverPool::new(SolverPoolConfig {
446                min_solvers: 1,
447                max_solvers: 1,
448                acquire_timeout: Some(Duration::from_millis(100)),
449            })
450            .await
451            .unwrap(),
452        );
453
454        // Acquire the only available solver
455        let _solver = pool.acquire().await.unwrap();
456
457        // Trying to acquire another should timeout
458        let result = pool.acquire().await;
459        assert_matches!(result, Err(SolverPoolError::AcquireTimeout));
460    }
461
462    #[tokio::test]
463    async fn test_failed_solver_discarded() {
464        let pool = SolverPool::new(SolverPoolConfig {
465            min_solvers: 1,
466            max_solvers: 1,
467            acquire_timeout: None,
468        })
469        .await
470        .unwrap();
471
472        {
473            let mut solver = pool.acquire().await.unwrap();
474            // Send invalid input to cause a parse error (which kills the solver)
475            solver.smtlib_input().assert("tomato").await.unwrap();
476            // This should fail with a solver error
477            let result = solver.check_sat().await;
478            assert_matches!(result, Err(SolverError::Solver(_)));
479            // Mark as failed so it won't be returned to pool
480            solver.mark_failed();
481        }
482
483        // Wait for the drop to release the permit (proves drop completed)
484        poll_until(|| async { pool.permits_available() == 1 }).await;
485
486        // Pool should be empty since the failed solver was discarded
487        assert_eq!(pool.available_count().await, 0);
488
489        // But we should still be able to acquire (spawns a new one)
490        let mut solver = pool.acquire().await.unwrap();
491        let decision = solver.check_sat().await.unwrap();
492        assert_eq!(decision, Decision::Sat);
493    }
494
495    #[tokio::test]
496    async fn test_methods_after_mark_failed() {
497        use tokio::io::AsyncWriteExt;
498
499        let pool = SolverPool::new(SolverPoolConfig::default()).await.unwrap();
500        let mut solver = pool.acquire().await.unwrap();
501
502        // Mark the solver as failed
503        solver.mark_failed();
504
505        // check_sat should return SolverMarkedFailed error
506        let result = solver.check_sat().await;
507        assert_matches!(result, Err(SolverError::SolverMarkedFailed));
508
509        // check_sat_with_model should return SolverMarkedFailed error
510        let result = solver.check_sat_with_model().await;
511        assert_matches!(result, Err(SolverError::SolverMarkedFailed));
512
513        // Writing to smtlib_input should return an IO error
514        let result = solver.smtlib_input().write_all(b"test").await;
515        assert!(result.is_err());
516
517        // Flushing should also return an IO error
518        let result = solver.smtlib_input().flush().await;
519        assert!(result.is_err());
520
521        // Shutdown should also return an IO error
522        let result = solver.smtlib_input().shutdown().await;
523        assert!(result.is_err());
524    }
525
526    #[tokio::test]
527    async fn test_default_config() {
528        let config = SolverPoolConfig::default();
529        assert_eq!(config.min_solvers, 1);
530        assert_eq!(config.max_solvers, 4);
531        assert!(config.acquire_timeout.is_none());
532    }
533
534    #[tokio::test]
535    async fn test_pooled_solver_with_symcompiler() {
536        use crate::symcc::SymCompiler;
537
538        let pool = SolverPool::new(SolverPoolConfig::default()).await.unwrap();
539
540        // Acquire a solver from the pool
541        let solver = pool.acquire().await.unwrap();
542
543        // Create a SymCompiler with the pooled solver
544        let mut compiler = SymCompiler::new(solver);
545
546        // Perform a simple check using the compiler
547        // We'll check that `assert false` is unsat
548        compiler
549            .solver_mut()
550            .smtlib_input()
551            .set_logic("ALL")
552            .await
553            .unwrap();
554        compiler
555            .solver_mut()
556            .smtlib_input()
557            .assert("(= 1 2)")
558            .await
559            .unwrap();
560        let decision = compiler.solver_mut().check_sat().await.unwrap();
561        assert_eq!(decision, Decision::Unsat);
562    }
563}