engram/lib.rs
1//! # Engram
2//!
3//! Engram is an asynchronous, two-tier cache for Rust applications. It keeps
4//! deserialized values in a weighted [Moka] memory cache (L1) and persists
5//! Serde-serialized bytes in a SQLite WAL database (L2). An L1 eviction never
6//! deletes L2, so values can be recovered after eviction or restart.
7//!
8//! ## Installation
9//!
10//! The crates.io package is named `engram-cache`; its Rust library name is
11//! `engram`:
12//!
13//! ```toml
14//! [dependencies]
15//! engram = { package = "engram-cache", version = "0.1" }
16//! serde = { version = "1", features = ["derive"] }
17//! tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
18//! ```
19//!
20//! ## Quick start
21//!
22//! ```no_run
23//! use std::time::Duration;
24//!
25//! use engram::{Compression, Engram};
26//! use serde::{Deserialize, Serialize};
27//!
28//! #[derive(Clone, Debug, Deserialize, Serialize)]
29//! struct Project {
30//! id: u64,
31//! name: String,
32//! }
33//!
34//! # async fn example() -> engram::Result<()> {
35//! let cache = Engram::builder()
36//! .storage("./cache/engram.db")
37//! .memory_capacity(64 * 1024 * 1024)
38//! .compression(Compression::Zstd { level: 3 })
39//! .build()
40//! .await?;
41//!
42//! let project = Project { id: 123, name: "Engram".into() };
43//! cache.set("project:123", &project, Some(Duration::from_secs(300))).await?;
44//! let cached: Option<Project> = cache.get("project:123").await?;
45//! # Ok(())
46//! # }
47//! ```
48//!
49//! ## How the two tiers behave
50//!
51//! Reads consult L1 first. A miss loads, decompresses, and deserializes the
52//! L2 row, then promotes that value to L1. The [`CacheMetadata`] returned by
53//! [`Engram::get_with_metadata`] identifies the tier that served the read.
54//! [`Engram::get_arc`] and [`Engram::set_arc`] retain an [`std::sync::Arc`] in
55//! L1 and are preferable for large values that should not be cloned.
56//!
57//! A successful write has committed its SQLite transaction to the WAL. A
58//! failed single write invalidates the just-written L1 value before returning
59//! its error. [`Engram::set_many`] writes its entire batch in one transaction
60//! and only updates L1 after that commit succeeds.
61//!
62//! ## Lifetimes and invalidation
63//!
64//! A TTL is optional on every write. `Some(duration)` gives an entry an
65//! absolute expiry; expired entries are never returned and are removed lazily.
66//! `None` makes an entry permanent: it remains in L2 across restarts until
67//! [`Engram::remove`] or [`Engram::clear`] is called. L1 still has a memory
68//! budget, so a permanent entry can be evicted from memory and later restored
69//! from L2.
70//!
71//! Use [`Engram::metadata`] to inspect the tier and expiration without
72//! deserializing L2. Use [`Engram::purge_expired`] for on-demand physical
73//! deletion, or [`EngramBuilder::cleanup_interval`] for periodic cleanup.
74//!
75//! ## Stable schemas for persisted values
76//!
77//! The string-key APIs use Rust's `type_name::<T>()` to reject requests for an
78//! incompatible in-process type. That tag can change after a type rename or a
79//! module move. For cache data intended to survive releases, use
80//! [`CacheKey<T>`] and supply your own stable schema tag. Keep the tag while
81//! the serialized representation remains compatible; bump it for an
82//! intentionally incompatible change.
83//!
84//! ```no_run
85//! use engram::{CacheKey, Engram};
86//! use serde::{Deserialize, Serialize};
87//!
88//! #[derive(Clone, Deserialize, Serialize)]
89//! struct Project { id: u64 }
90//!
91//! # async fn example(cache: Engram) -> engram::Result<()> {
92//! let key = CacheKey::<Project>::new("project:123", "project-record@1");
93//! cache.set_key(&key, &Project { id: 123 }, None).await?;
94//! let project: Option<Project> = cache.get_key(&key).await?;
95//! # Ok(())
96//! # }
97//! ```
98//!
99//! A mismatched tag returns [`Error::TypeMismatch`] rather than attempting to
100//! decode the bytes. The same tag is checked in both L1 and L2.
101//!
102//! ## Concurrency and fetch-through
103//!
104//! Operations for one key are serialized, while unrelated keys can proceed in
105//! parallel. Bulk writes acquire their key locks in sorted order, avoiding
106//! deadlock between overlapping batches. [`Engram::clear`] takes a global
107//! write gate: it waits for active operations, including L2-to-L1 promotions,
108//! and prevents new operations until both tiers are cleared.
109//!
110//! [`Engram::get_or_try_init`] is an opt-in fetch-through helper. It invokes
111//! the initializer only after a miss and coalesces simultaneous
112//! initializations for the same key. Initializer errors are not cached.
113//!
114//! ## Durability and performance
115//!
116//! SQLite uses WAL mode, a five-second busy timeout, and a partial expiry
117//! index. [`SqliteSynchronous::Full`] is the default and asks SQLite for its
118//! strongest synchronization behavior. [`SqliteSynchronous::Normal`] is often
119//! faster, but the most recent committed WAL transactions can be lost after a
120//! power failure or OS crash; SQLite's internal consistency is preserved.
121//!
122//! Compression defaults to Zstandard level 3. Values below 1 KiB are not
123//! compressed, and a larger compressed result is discarded by default. Tune
124//! these behaviors with [`EngramBuilder::compression_threshold`] and
125//! [`EngramBuilder::compress_only_if_smaller`]. L1 capacity is weighted by
126//! serialized byte length unless you use [`Engram::set_with_weight`] or
127//! [`Engram::set_arc_with_weight`] to provide a more representative memory
128//! cost.
129//!
130//! [Moka]: https://docs.rs/moka
131
132mod compression;
133mod config;
134mod engine;
135mod error;
136mod memory;
137mod storage;
138
139pub use config::{Compression, EngramBuilder, SqliteSynchronous};
140pub use engine::{CacheEntry, CacheKey, CacheMetadata, CacheTier, Engram};
141pub use error::{Error, Result};