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}