Skip to main content

lean_rs_host/host/
pool.rs

1//! Session pooling for amortising `Lean.importModules` across reused
2//! environments.
3//!
4//! Re-importing the Lean prelude is the dominant FFI cost on the host
5//! stack — measured on a dev macOS rig at roughly 4×–5× the cost of
6//! reusing an existing session (see the `session_reuse_amortises_import`
7//! timing note in `host/tests.rs`). [`SessionPool`] keeps a bounded
8//! free-list of previously imported `Lean.Environment` values keyed by
9//! their imports list; on [`SessionPool::acquire`], a matching entry is
10//! popped and rewrapped under the caller-supplied
11//! [`crate::host::LeanCapabilities`] borrow, and on
12//! [`PooledSession::drop`], the environment goes back to the pool (or is
13//! released if capacity is full).
14//!
15//! ## Capability-agnostic storage
16//!
17//! Entries store the bare imported environment as
18//! `Obj<'lean>` (a refcounted handle to the Lean
19//! `Environment` value), not a full [`crate::LeanSession`]. The session
20//! borrows from the capability via `'c`; storing one in the pool would
21//! tie the pool's lifetime to a single capability borrow. Storing the
22//! bare environment instead lets each [`SessionPool::acquire`] thread a
23//! fresh capability borrow without touching `'lean`. Environments are
24//! Lean values bound to the runtime, not to the capability that imported
25//! them, so this rewrapping is semantically free.
26//!
27//! ## Capacity policy
28//!
29//! [`SessionPool::with_capacity`] sets a hard upper bound on the
30//! free-list size. On release, if the pool is at capacity, the
31//! environment is dropped immediately (its `Obj<'lean>`
32//! `Drop` runs `lean_dec` and the underlying allocation is freed). The
33//! free list is FIFO on `take` and LRU on `push`, so the most
34//! recently-released environment is the next to be reused — hot OS
35//! caches stay warm. There is no eviction-by-age or eviction-by-distinct-key
36//! policy beyond the capacity bound.
37//!
38//! [`SessionPool::drain`] explicitly drops every cached free-list entry
39//! without discarding the pool itself. It releases the Rust-owned
40//! environment references the pool is holding; it does not reset Lean's
41//! process-global runtime state, module initializer flags, interned
42//! names, compacted `.olean` regions, or allocator arenas.
43//!
44//! ## Threading
45//!
46//! [`SessionPool`] is `!Send + !Sync` (inherited from the contained
47//! `Obj<'lean>` and the `RefCell` that wraps the free list). The pool
48//! is a per-thread reuse helper; cross-thread pooling is explicitly
49//! out of scope. Per-pool stats are `Cell<PoolStats>` —
50//! single-threaded but uniform with the per-session
51//! [`crate::host::session::SessionStats`] story.
52
53use core::cell::{Cell, RefCell};
54
55use crate::host::cancellation::{LeanCancellationToken, check_cancellation};
56use crate::host::capabilities::LeanCapabilities;
57use crate::host::progress::LeanProgressSink;
58use crate::host::session::LeanSession;
59use lean_rs::LeanRuntime;
60use lean_rs::Obj;
61use lean_rs::error::LeanResult;
62
63// -- PoolStats: pool-level reuse metrics ---------------------------------
64
65/// Cumulative metrics for one [`SessionPool`].
66///
67/// Snapshot via [`SessionPool::stats`]. Counters never reset — to
68/// compute a delta, take two snapshots and subtract.
69///
70/// `imports_performed + reused == acquired` by construction: every
71/// [`SessionPool::acquire`] call increments `acquired` exactly once
72/// plus either `imports_performed` (cache miss) or `reused` (cache
73/// hit). Similarly, `released_to_pool + released_dropped` counts every
74/// [`PooledSession::drop`] firing. `released_to_pool` is cumulative:
75/// an entry counted there may later be removed by [`SessionPool::drain`],
76/// which records the removal in `drained`.
77#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
78pub struct PoolStats {
79    /// Number of fresh `Lean.importModules` calls performed because no
80    /// pooled environment matched the requested imports list.
81    pub imports_performed: u64,
82    /// Number of acquire calls that found a matching pooled environment
83    /// and reused it instead of re-importing.
84    pub reused: u64,
85    /// Total acquire calls (== `imports_performed + reused`).
86    pub acquired: u64,
87    /// Number of release events that pushed the environment back onto
88    /// the free list.
89    pub released_to_pool: u64,
90    /// Number of release events that dropped the environment because
91    /// the pool was at capacity.
92    pub released_dropped: u64,
93    /// Number of explicit [`SessionPool::drain`] calls.
94    pub drains: u64,
95    /// Number of cached environments dropped by explicit drains.
96    pub drained: u64,
97}
98
99// -- ImportsKey: hashable cache key for the imports list -----------------
100
101/// Free-list key: the ordered imports list a pooled environment was
102/// imported with.
103///
104/// Order matters because `Lean.importModules` is order-sensitive — a
105/// later import can shadow an earlier one. Equality is structural and
106/// canonical (the same `&[&str]` always produces the same key).
107#[derive(Clone, Eq, PartialEq)]
108struct ImportsKey(Vec<String>);
109
110impl ImportsKey {
111    fn from_slice(imports: &[&str]) -> Self {
112        Self(imports.iter().map(|&s| s.to_owned()).collect())
113    }
114}
115
116// -- PooledEntry: one slot on the free list ------------------------------
117
118struct PooledEntry<'lean> {
119    imports_key: ImportsKey,
120    environment: Obj<'lean>,
121}
122
123// -- PoolInner: RefCell-protected free list ------------------------------
124
125struct PoolInner<'lean> {
126    /// FIFO on take, LIFO on push (newest entries near the back; the
127    /// most-recently-released entry matching a given imports key is the
128    /// one acquire pops). The list scan is linear, which is fine for
129    /// the small capacities this pool is sized for — pooling is for
130    /// amortising imports across O(10s) of sessions, not for managing
131    /// thousands.
132    free: Vec<PooledEntry<'lean>>,
133}
134
135impl<'lean> PoolInner<'lean> {
136    /// Pop the most recently released entry whose imports key matches.
137    fn take_matching(&mut self, key: &ImportsKey) -> Option<Obj<'lean>> {
138        let idx = self.free.iter().rposition(|entry| &entry.imports_key == key)?;
139        Some(self.free.remove(idx).environment)
140    }
141}
142
143// -- SessionPool ---------------------------------------------------------
144
145/// A capacity-bounded reuse pool of imported Lean environments.
146///
147/// Built with [`Self::with_capacity`]; environments enter the pool
148/// through [`PooledSession::drop`] (returning a previously-acquired
149/// session). Pool entries are capability-agnostic: a single pool may be
150/// shared across multiple [`LeanCapabilities`] values, as long as they
151/// share the same runtime.
152///
153/// Neither [`Send`] nor [`Sync`] (inherited from the contained
154/// `Obj<'lean>` values).
155pub struct SessionPool<'lean> {
156    runtime: &'lean LeanRuntime,
157    capacity: usize,
158    inner: RefCell<PoolInner<'lean>>,
159    stats: Cell<PoolStats>,
160}
161
162impl<'lean> SessionPool<'lean> {
163    /// Build an empty pool with hard upper bound `capacity` on stored
164    /// environments.
165    ///
166    /// A `capacity` of 0 disables reuse — every [`Self::acquire`] call
167    /// imports fresh and every release drops the environment. This is
168    /// useful for tests that want metrics without recycling, and as the
169    /// degenerate point that proves the pool's metrics agree with
170    /// repeated `caps.session(..., None, None)` calls.
171    ///
172    /// The `runtime` borrow witnesses `'lean` and is stored so the pool
173    /// itself outlives every entry on its free list — even after every
174    /// [`PooledSession`] has been dropped, the pool retains a usable
175    /// runtime reference.
176    #[must_use]
177    pub fn with_capacity(runtime: &'lean LeanRuntime, capacity: usize) -> Self {
178        Self {
179            runtime,
180            capacity,
181            inner: RefCell::new(PoolInner {
182                free: Vec::with_capacity(capacity),
183            }),
184            stats: Cell::new(PoolStats::default()),
185        }
186    }
187
188    /// Acquire a session targeting `imports` under `caps`.
189    ///
190    /// If a pooled environment was previously released with the same
191    /// `imports` list (order-sensitive), it is rewrapped under the
192    /// supplied capability borrow and returned — no `Lean.importModules`
193    /// runs. Otherwise the pool calls
194    /// [`LeanCapabilities::session`] internally to perform a fresh
195    /// import. Either way, the resulting [`PooledSession`] returns the
196    /// underlying environment to the pool on `Drop`.
197    ///
198    /// `caps` must come from the same [`LeanRuntime`] the pool was
199    /// constructed with; this is structurally enforced by the shared
200    /// `'lean` lifetime parameter.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`lean_rs::LeanError::Cancelled`] if `cancellation` is
205    /// already cancelled before the pool can reuse or import an
206    /// environment.
207    ///
208    /// Returns [`lean_rs::LeanError::LeanException`] if a fresh import is
209    /// required and the Lean-side `lean_rs_host_session_import` shim
210    /// raises through `IO`. Cached environments never re-fail.
211    pub fn acquire<'p, 'c>(
212        &'p self,
213        caps: &'c LeanCapabilities<'lean, 'c>,
214        imports: &[&str],
215        cancellation: Option<&LeanCancellationToken>,
216        progress: Option<&dyn LeanProgressSink>,
217    ) -> LeanResult<PooledSession<'lean, 'p, 'c>> {
218        let _span = tracing::debug_span!(
219            target: "lean_rs",
220            "lean_rs.host.pool.acquire",
221            imports_len = imports.len(),
222            imports_first = imports.first().copied().unwrap_or("<empty>"),
223        )
224        .entered();
225        check_cancellation(cancellation)?;
226        debug_assert!(
227            core::ptr::eq(self.runtime, caps.host().runtime()),
228            "pool runtime and capability runtime must agree; the shared 'lean parameter normally enforces this",
229        );
230        let key = ImportsKey::from_slice(imports);
231        let (session, hit) = {
232            let mut inner = self.inner.borrow_mut();
233            if let Some(env) = inner.take_matching(&key) {
234                self.bump_reused();
235                (LeanSession::from_environment(caps, env), true)
236            } else {
237                drop(inner);
238                let session = caps.session(imports, cancellation, progress)?;
239                self.bump_imported();
240                (session, false)
241            }
242        };
243        tracing::debug!(target: "lean_rs", hit = hit, "lean_rs.host.pool.acquire.result");
244        Ok(PooledSession {
245            pool: self,
246            imports_key: key,
247            session: Some(session),
248        })
249    }
250
251    /// Snapshot the accumulated pool metrics.
252    ///
253    /// Counters never reset; subtract two snapshots to measure activity
254    /// over an interval. See [`PoolStats`] for the field invariants
255    /// (e.g. `imports_performed + reused == acquired`).
256    #[must_use]
257    pub fn stats(&self) -> PoolStats {
258        self.stats.get()
259    }
260
261    /// Number of environments currently sitting on the free list.
262    ///
263    /// This is the count of warm imports available for the next
264    /// [`Self::acquire`] without going through `Lean.importModules`.
265    /// Explicit drains and cache hits both remove entries from this
266    /// count; releases may add entries back up to [`Self::capacity`].
267    #[must_use]
268    pub fn len(&self) -> usize {
269        self.inner.borrow().free.len()
270    }
271
272    /// `true` iff [`Self::len`] is zero; every subsequent
273    /// [`Self::acquire`] will perform a fresh import.
274    #[must_use]
275    pub fn is_empty(&self) -> bool {
276        self.len() == 0
277    }
278
279    /// Configured hard upper bound on the free list.
280    ///
281    /// Set by [`Self::with_capacity`]. A pool releasing a
282    /// [`PooledSession`] while at capacity drops the environment
283    /// instead of pushing it back; that release shows up in
284    /// [`PoolStats::released_dropped`] rather than `released_to_pool`.
285    #[must_use]
286    pub fn capacity(&self) -> usize {
287        self.capacity
288    }
289
290    /// Drop every cached environment currently retained by the pool.
291    ///
292    /// Returns the number of free-list entries removed. Each removed
293    /// entry drops its owned `Obj<'lean>` environment, which releases
294    /// one Lean refcount via `lean_dec`.
295    ///
296    /// Checked-out [`PooledSession`] values are not affected: they own
297    /// their sessions until drop, and may return their environments to
298    /// this same pool later if capacity permits. A later [`Self::drain`]
299    /// call can remove those returned entries.
300    ///
301    /// This is a cache-eviction API, not a runtime recycle API. It does
302    /// not reset Lean's process-global runtime state, initialized module
303    /// flags, interned names, compacted `.olean` regions, or allocator
304    /// arenas, and should not be treated as an RSS reset.
305    pub fn drain(&self) -> usize {
306        let mut inner = self.inner.borrow_mut();
307        let drained = inner.free.len();
308        inner.free.clear();
309
310        let mut s = self.stats.get();
311        s.drains = s.drains.saturating_add(1);
312        s.drained = s.drained.saturating_add(u64::try_from(drained).unwrap_or(u64::MAX));
313        self.stats.set(s);
314
315        tracing::debug!(
316            target: "lean_rs",
317            drained = drained,
318            "lean_rs.host.pool.drain",
319        );
320        drained
321    }
322
323    fn bump_reused(&self) {
324        let mut s = self.stats.get();
325        s.reused = s.reused.saturating_add(1);
326        s.acquired = s.acquired.saturating_add(1);
327        self.stats.set(s);
328    }
329
330    fn bump_imported(&self) {
331        let mut s = self.stats.get();
332        s.imports_performed = s.imports_performed.saturating_add(1);
333        s.acquired = s.acquired.saturating_add(1);
334        self.stats.set(s);
335    }
336
337    fn release(&self, key: ImportsKey, env: Obj<'lean>) {
338        let mut inner = self.inner.borrow_mut();
339        let mut s = self.stats.get();
340        let kept = inner.free.len() < self.capacity;
341        if kept {
342            inner.free.push(PooledEntry {
343                imports_key: key,
344                environment: env,
345            });
346            s.released_to_pool = s.released_to_pool.saturating_add(1);
347        } else {
348            // Drop `env`: its `Obj` Drop runs `lean_dec` and the
349            // environment allocation is freed if the refcount reaches 0.
350            drop(env);
351            s.released_dropped = s.released_dropped.saturating_add(1);
352        }
353        self.stats.set(s);
354        tracing::trace!(
355            target: "lean_rs",
356            kept = kept,
357            "lean_rs.host.pool.release",
358        );
359    }
360}
361
362impl core::fmt::Debug for SessionPool<'_> {
363    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
364        f.debug_struct("SessionPool")
365            .field("capacity", &self.capacity)
366            .field("len", &self.len())
367            .field("stats", &self.stats.get())
368            .finish()
369    }
370}
371
372// -- PooledSession -------------------------------------------------------
373
374/// A [`LeanSession`] borrowed from a [`SessionPool`].
375///
376/// Behaves as a [`LeanSession`] through [`core::ops::Deref`] /
377/// [`core::ops::DerefMut`] — every session method is reachable directly:
378///
379/// ```ignore
380/// let pool = lean_rs::SessionPool::with_capacity(runtime, 4);
381/// let mut sess = pool.acquire(&caps, &["MyLib"], None, None)?;
382/// let kind = sess.declaration_kind("MyLib.thing", None)?;
383/// // dropping `sess` returns the imported environment to the pool
384/// ```
385///
386/// On `Drop`, the underlying imported environment is returned to the
387/// pool (or released if the pool is at capacity). Per-session
388/// [`crate::host::session::SessionStats`] are scoped to the lifetime of
389/// this checkout — they start at zero on every acquire and are
390/// inaccessible after release.
391///
392/// Three lifetimes: `'lean` (runtime), `'p` (pool borrow), `'c`
393/// (capability borrow). Neither [`Send`] nor [`Sync`] (inherited from
394/// the contained [`LeanSession`]).
395pub struct PooledSession<'lean, 'p, 'c> {
396    pool: &'p SessionPool<'lean>,
397    imports_key: ImportsKey,
398    /// `Option` so [`Drop`] can take the session by value without
399    /// resorting to `ManuallyDrop`. Always `Some` between
400    /// construction and `Drop`.
401    session: Option<LeanSession<'lean, 'c>>,
402}
403
404impl<'lean, 'c> core::ops::Deref for PooledSession<'lean, '_, 'c> {
405    type Target = LeanSession<'lean, 'c>;
406
407    // PROOF OBLIGATION: `session` is initialised to `Some` at the only
408    // construction site (`SessionPool::acquire`) and is taken to `None`
409    // exactly once, inside `Drop::drop`. `Deref::deref` is only callable
410    // through a `&self` borrow, which is not possible during `Drop`, so
411    // observing `None` here is structurally impossible.
412    #[allow(clippy::expect_used, reason = "see PROOF OBLIGATION above")]
413    fn deref(&self) -> &Self::Target {
414        self.session
415            .as_ref()
416            .expect("session is Some between PooledSession::acquire and Drop::drop")
417    }
418}
419
420#[allow(
421    single_use_lifetimes,
422    clippy::elidable_lifetime_names,
423    reason = "the named lifetimes line up with `Deref::Target = LeanSession<'lean, 'c>` above; \
424              elision flips the inferred bound and breaks the trait-signature check"
425)]
426impl<'lean, 'c> core::ops::DerefMut for PooledSession<'lean, '_, 'c> {
427    // Same PROOF OBLIGATION as the `Deref` impl above: `DerefMut::deref_mut`
428    // is unreachable from inside `Drop::drop`, so `session` is always
429    // `Some` here.
430    #[allow(clippy::expect_used, reason = "see PROOF OBLIGATION on Deref impl")]
431    fn deref_mut(&mut self) -> &mut LeanSession<'lean, 'c> {
432        self.session
433            .as_mut()
434            .expect("session is Some between PooledSession::acquire and Drop::drop")
435    }
436}
437
438impl Drop for PooledSession<'_, '_, '_> {
439    fn drop(&mut self) {
440        if let Some(session) = self.session.take() {
441            let env = session.into_environment();
442            self.pool.release(self.imports_key.clone(), env);
443        }
444    }
445}
446
447impl core::fmt::Debug for PooledSession<'_, '_, '_> {
448    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
449        f.debug_struct("PooledSession").finish_non_exhaustive()
450    }
451}