Skip to main content

cognee_database/
connection.rs

1use std::time::Duration;
2
3use sea_orm::{ConnectOptions, Database, DatabaseConnection};
4use sea_orm_migration::MigratorTrait;
5
6use crate::migrator::Migrator;
7use crate::types::DatabaseError;
8
9/// Relational connection-pool sizing, applied by [`connect`].
10///
11/// `POOL_MAX_CONNECTIONS` and `POOL_MIN_CONNECTIONS` are sqlx's own pool
12/// defaults, kept deliberately rather than invented. No benchmark motivates a
13/// different ceiling, and an embedded SQLite database has one writer regardless,
14/// so the pool only ever buys concurrent readers under WAL; a larger ceiling
15/// would add contention, not throughput. `min = 0` lets a file database fall
16/// back to zero connections when idle and release its `-wal`/`-shm` sidecars.
17/// Only an in-memory database must never drop to zero connections, and that
18/// branch sets `min` explicitly (see [`connect_sqlite`]).
19///
20/// The pool serves only the relational database: the Postgres graph and vector
21/// adapters (`PgGraphAdapter`, `PgVectorAdapter`) open their own separate pools.
22/// `POOL_ACQUIRE_TIMEOUT` surfaces pool exhaustion as a prompt error instead of
23/// a silent hang.
24const POOL_MAX_CONNECTIONS: u32 = 10;
25const POOL_MIN_CONNECTIONS: u32 = 0;
26const POOL_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30);
27const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(600);
28
29/// SQLite lock-wait ceiling, matching Python's `SqlAlchemyAdapter`
30/// (`busy_timeout=120000`, added for the "database is locked" fix in
31/// topoteretes/cognee#2717). sqlx defaults to 5s, which `upsert_provenance_graph`
32/// can exceed on a slow device: it holds the single writer lock across the whole
33/// node+edge batch group, so a second writer waiting on that lock needs a
34/// ceiling above the group's commit time or the wait surfaces as `SQLITE_BUSY`.
35#[cfg(feature = "sqlite")]
36const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(120);
37
38/// How a SQLite URL behaves at connect time, derived from its path and query
39/// parameters. Parameters are matched exactly after splitting the URL, never
40/// by substring over the whole string: URLs are user-supplied, and a file
41/// path that merely contains `mode=memory` must not be misclassified.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43struct SqliteUrlKind {
44    /// The `:memory:` path or the `mode=memory` query parameter.
45    in_memory: bool,
46    /// Explicit `cache=shared`. (sqlx 0.8 internally upgrades plain
47    /// `:memory:` to a uniquely named shared-cache database as well; this
48    /// flag only tracks what the URL asked for.)
49    shared_cache: bool,
50    /// `mode=ro` or `immutable=1|true`: the connection cannot write, so
51    /// journal-mode pragmas must not be issued on it.
52    read_only: bool,
53}
54
55fn classify_sqlite_url(url: &str) -> SqliteUrlKind {
56    let rest = url
57        .strip_prefix("sqlite://")
58        .or_else(|| url.strip_prefix("sqlite:"))
59        .unwrap_or(url);
60    let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
61    let path = path.strip_prefix("file:").unwrap_or(path);
62
63    let mut kind = SqliteUrlKind {
64        in_memory: path == ":memory:",
65        ..SqliteUrlKind::default()
66    };
67    for param in query.split('&') {
68        match param {
69            "mode=memory" => kind.in_memory = true,
70            "cache=shared" => kind.shared_cache = true,
71            "mode=ro" | "immutable=1" | "immutable=true" => kind.read_only = true,
72            _ => {}
73        }
74    }
75    kind
76}
77
78/// True when a SQLite URL points at an in-memory database, in either spelling
79/// (`sqlite::memory:` / `sqlite://:memory:` or `?mode=memory`).
80///
81/// Shared with `cognee-components` (`builtins::database`), which must skip
82/// filesystem preparation (parent directory creation) for such URLs; keeping
83/// one predicate here prevents the layers from diverging on what counts as
84/// in-memory.
85pub fn sqlite_url_is_in_memory(url: &str) -> bool {
86    url.starts_with("sqlite") && classify_sqlite_url(url).in_memory
87}
88
89/// Open a connection to the relational database.
90///
91/// SQLite needs connection-level tuning that sea-orm's `ConnectOptions` cannot
92/// express (journal-mode pragmas, `busy_timeout`, disabling the pool reapers),
93/// so the SQLite path is built directly on the sqlx pool. Server backends go
94/// through sea-orm unchanged.
95pub async fn connect(url: &str) -> Result<DatabaseConnection, DatabaseError> {
96    #[cfg(feature = "sqlite")]
97    if url.starts_with("sqlite") {
98        return connect_sqlite(url).await;
99    }
100
101    let mut opt = ConnectOptions::new(url.to_owned());
102    opt.max_connections(POOL_MAX_CONNECTIONS)
103        .min_connections(POOL_MIN_CONNECTIONS)
104        .acquire_timeout(POOL_ACQUIRE_TIMEOUT)
105        .idle_timeout(POOL_IDLE_TIMEOUT);
106
107    Database::connect(opt)
108        .await
109        .map_err(|e| DatabaseError::ConnectionError(e.to_string()))
110}
111
112/// Build the SQLite connection pool directly on sqlx so per-connection pragmas
113/// and per-pool reaping can be controlled precisely.
114///
115/// - **File-backed, writable:** WAL + `synchronous=FULL` gives real
116///   reader/writer concurrency (writers no longer block readers), which is
117///   what justifies a multi-connection pool for SQLite, while `FULL` keeps
118///   every committed transaction durable across an OS crash or power loss —
119///   `NORMAL` under WAL trades that away, silently losing the last commits on
120///   power loss, which is the wrong default for a memory pipeline. If enabling
121///   WAL fails — typically a network/FUSE filesystem with no shared-memory
122///   support, where `PRAGMA journal_mode=WAL` cannot create the `-shm` sidecar
123///   — the connect retries once with an explicit rollback journal rather than
124///   failing outright, so those deployments still open (they served reads
125///   before this crate configured WAL). If that retry also fails, WAL was not
126///   the cause and the original error is surfaced. `busy_timeout` makes the
127///   inevitable
128///   writer-vs-writer contention wait for the lock rather than failing
129///   immediately with `SQLITE_BUSY`.
130/// - **Read-only (`mode=ro` / `immutable`, or a file that is not writable):**
131///   the connection is opened read-only and no journal-mode pragma is issued.
132///   `PRAGMA journal_mode=WAL` writes to the database file and would fail the
133///   connect on a read-only open, a read-only mount, or a read-only file —
134///   cases that served reads before this crate configured WAL. Writability is
135///   probed on the filesystem, not inferred from the URL alone (see
136///   [`sqlite_path_is_writable`]).
137/// - **In-memory (shared or not):** the database only lives as long as its
138///   connections, so both pool reapers are disabled — sqlx's default
139///   `idle_timeout`/`max_lifetime` would close an idle connection and
140///   reconnect to a fresh, empty database — and at least one connection is
141///   kept open. A non-shared in-memory URL is additionally pinned to exactly
142///   one connection: defensive, since sqlx 0.8 internally rewrites `:memory:`
143///   to a uniquely named shared-cache database, but the invariant that
144///   matters (never drop to zero connections) does not depend on that
145///   implementation detail.
146#[cfg(feature = "sqlite")]
147async fn connect_sqlite(url: &str) -> Result<DatabaseConnection, DatabaseError> {
148    use std::str::FromStr;
149
150    use sea_orm::SqlxSqliteConnector;
151    use sea_orm::sqlx::ConnectOptions as _;
152    use sea_orm::sqlx::sqlite::{
153        SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous,
154    };
155
156    let kind = classify_sqlite_url(url);
157
158    // Statement logging at INFO matches sea-orm's `ConnectOptions` default,
159    // which the Postgres path still goes through; raw sqlx defaults to DEBUG.
160    // `busy_timeout` lets a writer wait for the lock (WAL still serializes
161    // writers) instead of erroring out immediately with `SQLITE_BUSY`.
162    let base_opts = SqliteConnectOptions::from_str(url)
163        .map_err(|e| DatabaseError::ConnectionError(e.to_string()))?
164        .log_statements(log::LevelFilter::Info)
165        .busy_timeout(SQLITE_BUSY_TIMEOUT);
166
167    // In-memory has no file to journal, and sqlx's default WAL is a no-op there.
168    let mut want_wal = false;
169    let mut conn_opts = base_opts.clone();
170    if !kind.in_memory {
171        // Probe the driver's own filename rather than the raw URL: sqlx
172        // percent-decodes the path while parsing, so re-deriving it here would
173        // test a path that does not exist (`my%20app.db`) and wrongly report a
174        // read-only file as writable. The probe touches the filesystem
175        // (open/create/unlink), which can block on a slow or hung mount, so run
176        // it off the async runtime thread.
177        let probe_path = conn_opts.get_filename().to_path_buf();
178        let writable = tokio::task::spawn_blocking(move || sqlite_path_is_writable(&probe_path))
179            .await
180            .map_err(|e| DatabaseError::ConnectionError(e.to_string()))?;
181        if kind.read_only {
182            // The URL explicitly asked for a read-only open (`mode=ro` /
183            // `immutable`): honour it and issue no journal-mode pragma.
184            conn_opts = conn_opts.read_only(true);
185        } else if !writable {
186            // The URL wanted write access but the file/parent is not writable
187            // (read-only mount, permissions, or on Windows a transient
188            // share-lock from another process). Fall back to a read-only open
189            // so `PRAGMA journal_mode=WAL` does not fail the connect — but warn,
190            // because a genuinely write-intended database opened read-only will
191            // fail the first `add`/`cognify` with an opaque "attempt to write a
192            // readonly database" far from here.
193            tracing::warn!(
194                path = %conn_opts.get_filename().display(),
195                "SQLite database is not writable; opening read-only. Writes will fail. \
196                 Check file and parent-directory permissions (and, on Windows, other \
197                 processes holding the file) if this database is meant to be written."
198            );
199            conn_opts = conn_opts.read_only(true);
200        } else {
201            // synchronous=FULL keeps every committed transaction durable across
202            // an OS crash or power loss; WAL still gives reader/writer
203            // concurrency. `want_wal` records that WAL is best-effort: if the
204            // connect fails because the filesystem cannot back WAL (no
205            // shared-memory support, e.g. NFS/FUSE), we retry without it below.
206            want_wal = true;
207            conn_opts = conn_opts
208                .journal_mode(SqliteJournalMode::Wal)
209                .synchronous(SqliteSynchronous::Full);
210        }
211    }
212
213    let mut pool_opts = SqlitePoolOptions::new()
214        .max_connections(POOL_MAX_CONNECTIONS)
215        .min_connections(POOL_MIN_CONNECTIONS)
216        .acquire_timeout(POOL_ACQUIRE_TIMEOUT)
217        .idle_timeout(POOL_IDLE_TIMEOUT);
218
219    if kind.in_memory {
220        // The database lives only as long as its connections, so keep one alive
221        // and disable both reapers.
222        pool_opts = pool_opts
223            .min_connections(1)
224            .idle_timeout(None)
225            .max_lifetime(None);
226        if !kind.shared_cache {
227            pool_opts = pool_opts.max_connections(1);
228        }
229    }
230
231    let sqlx_pool = match pool_opts.clone().connect_with(conn_opts).await {
232        Ok(pool) => pool,
233        // WAL needs a shared-memory `-shm` file, which some filesystems (NFS and
234        // other network/FUSE mounts) cannot provide, so enabling it can fail the
235        // connect. We cannot tell such a WAL/shm failure apart from an unrelated
236        // one (disk full, I/O error, a corrupt header) at this layer, so retry
237        // once with an *explicit* rollback journal — `Delete`, not the
238        // driver's implicit default, so a database persisted in WAL mode is
239        // actively downgraded rather than reopened in WAL. Only warn if that
240        // retry actually succeeds; if it fails too, WAL was almost certainly not
241        // the cause, so surface the ORIGINAL error, which is the real one.
242        Err(original) if want_wal => {
243            let fallback_opts = base_opts
244                .journal_mode(SqliteJournalMode::Delete)
245                .synchronous(SqliteSynchronous::Full);
246            match pool_opts.connect_with(fallback_opts).await {
247                Ok(pool) => {
248                    tracing::warn!(
249                        error = %original,
250                        "Enabling SQLite WAL failed; opened with a rollback journal instead \
251                         (this happens on a network/FUSE filesystem without shared-memory \
252                         support). Reader/writer concurrency is reduced for this database."
253                    );
254                    pool
255                }
256                Err(_) => return Err(DatabaseError::ConnectionError(original.to_string())),
257            }
258        }
259        Err(e) => return Err(DatabaseError::ConnectionError(e.to_string())),
260    };
261
262    Ok(SqlxSqliteConnector::from_sqlx_sqlite_pool(sqlx_pool))
263}
264
265/// Whether WAL can safely be enabled, based on real filesystem writability
266/// rather than the URL alone.
267///
268/// Takes the driver's already-decoded filename
269/// (`SqliteConnectOptions::get_filename`) so an escaped path is probed exactly
270/// as sqlx will open it. WAL writes the database file's header *and* creates
271/// `-wal`/`-shm` sidecars next to it, so both the file and its parent directory
272/// must be writable:
273///
274/// - The file is writable when it can be opened for writing, or when it does
275///   not exist yet (the driver creates it via `mode=rwc`).
276/// - The parent directory is writable when a temporary file can be created in
277///   it. This catches an existing, writable file inside a read-only directory
278///   (`chmod 555`), where the file opens fine but the sidecars cannot be
279///   created and `PRAGMA journal_mode=WAL` fails the connect.
280///
281/// Neither probe truncates or modifies the database.
282#[cfg(feature = "sqlite")]
283fn sqlite_path_is_writable(path: &std::path::Path) -> bool {
284    let file_writable = if path.exists() {
285        std::fs::OpenOptions::new().write(true).open(path).is_ok()
286    } else {
287        true
288    };
289    file_writable && sqlite_parent_dir_is_writable(path)
290}
291
292/// Whether a file can be created next to `path`, probed by actually creating a
293/// uniquely named temporary file (permission bits do not reliably reflect
294/// effective writability across platforms, mounts, and ACLs). `AlreadyExists`
295/// means the directory accepted the create attempt, so it counts as writable.
296#[cfg(feature = "sqlite")]
297fn sqlite_parent_dir_is_writable(path: &std::path::Path) -> bool {
298    let parent = match path.parent() {
299        Some(p) if !p.as_os_str().is_empty() => p,
300        _ => std::path::Path::new("."),
301    };
302    let probe = parent.join(format!(".cognee-wal-probe-{}.tmp", std::process::id()));
303    match std::fs::OpenOptions::new()
304        .write(true)
305        .create_new(true)
306        .open(&probe)
307    {
308        Ok(_) => {
309            let _ = std::fs::remove_file(&probe);
310            true
311        }
312        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => true,
313        Err(_) => false,
314    }
315}
316
317/// Run all pending migrations on an existing connection.
318pub async fn initialize(db: &DatabaseConnection) -> Result<(), DatabaseError> {
319    Migrator::up(db, None)
320        .await
321        .map_err(|e| DatabaseError::QueryError(e.to_string()))
322}
323
324#[cfg(test)]
325mod tests {
326    use super::{SqliteUrlKind, classify_sqlite_url, sqlite_url_is_in_memory};
327
328    #[test]
329    fn detects_in_memory_spellings() {
330        for url in [
331            "sqlite::memory:",
332            "sqlite://:memory:",
333            "sqlite:file:pinned?mode=memory",
334            "sqlite::memory:?cache=shared",
335        ] {
336            assert!(classify_sqlite_url(url).in_memory, "{url}");
337            assert!(sqlite_url_is_in_memory(url), "{url}");
338        }
339    }
340
341    #[test]
342    fn detects_shared_cache_only_when_explicit() {
343        assert!(classify_sqlite_url("sqlite::memory:?cache=shared").shared_cache);
344        assert!(classify_sqlite_url("sqlite:file:x?mode=memory&cache=shared").shared_cache);
345        assert!(!classify_sqlite_url("sqlite::memory:").shared_cache);
346        assert!(!classify_sqlite_url("sqlite:file:x?cache=private").shared_cache);
347    }
348
349    #[test]
350    fn detects_read_only_opens() {
351        assert!(classify_sqlite_url("sqlite://./a.db?mode=ro").read_only);
352        assert!(classify_sqlite_url("sqlite:a.db?immutable=1").read_only);
353        assert!(classify_sqlite_url("sqlite:a.db?immutable=true").read_only);
354        assert!(!classify_sqlite_url("sqlite://./a.db?mode=rwc").read_only);
355        assert!(!classify_sqlite_url("sqlite://./a.db?mode=rw").read_only);
356    }
357
358    #[test]
359    fn file_paths_are_never_misclassified_by_substring() {
360        // Query parameters are matched exactly, so path contents cannot leak
361        // into the classification.
362        let kind = classify_sqlite_url("sqlite:///tmp/mode=memory/app.db?mode=rwc");
363        assert_eq!(kind, SqliteUrlKind::default());
364        assert!(!sqlite_url_is_in_memory(
365            "sqlite:///tmp/mode=memory/app.db?mode=rwc"
366        ));
367    }
368
369    #[test]
370    fn non_sqlite_urls_are_not_in_memory() {
371        assert!(!sqlite_url_is_in_memory("postgres://user:pw@localhost/db"));
372    }
373}