2 Commits
Author SHA1 Message Date
sbstp ff9f679fcb proper closing of sqlite connection
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-07 23:27:49 -04:00
sbstp a13bc17629 use memory databases for e2e tests 2026-08-07 23:20:27 -04:00
3 changed files with 45 additions and 34 deletions
+5 -19
View File
@@ -1,13 +1,12 @@
import { test as base, expect, Page } from "@playwright/test";
import { spawn, ChildProcess } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
/**
* Starts a fresh Sustenance server against a unique, throwaway database on a
* unique port for each test, and tears it down afterwards. This gives every
* test a clean DB with no shared state between tests.
* Starts a fresh Sustenance server against an in-memory SQLite database on a
* unique port for each test, and tears it down afterwards. Every test gets a
* clean DB with no shared state between tests, and nothing is written to disk.
*/
export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
server: [
@@ -15,10 +14,6 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
const server = await startServer();
await use({ baseURL: server.baseURL });
await killTree(server.child);
// Clean up the DB files (including -wal / -shm).
for (const suffix of ["", "-wal", "-shm"]) {
fs.rmSync(server.dbPath + suffix, { force: true });
}
},
{ scope: "test", auto: true },
],
@@ -35,12 +30,6 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
/** Starts a server, retrying on a fresh port if the first attempt fails to bind. */
async function startServer() {
for (let attempt = 0; attempt < 5; attempt++) {
const dbPath = path.join(
os.tmpdir(),
`sustenance-e2e-${process.pid}-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}.db`,
);
const port = 20000 + Math.floor(Math.random() * 30000);
const baseURL = `http://localhost:${port}`;
@@ -50,7 +39,7 @@ async function startServer() {
{
env: {
...process.env,
DATABASE_PATH: dbPath,
DATABASE_IN_MEMORY: "1",
REGISTRATION_MODE: "open",
BIND_ADDRESS: `127.0.0.1:${port}`,
PUBLIC_BASE_URL: baseURL,
@@ -72,13 +61,10 @@ async function startServer() {
try {
await waitForServer(baseURL, child);
return { baseURL, child, dbPath };
return { baseURL, child };
} catch (error) {
// The server may have failed to bind (port collision). Clean up and retry.
await killTree(child);
for (const suffix of ["", "-wal", "-shm"]) {
fs.rmSync(dbPath + suffix, { force: true });
}
if (attempt === 4) {
throw new Error(
`server failed to start after retries; last stderr:\n${stderr}\n${error}`,
+12 -1
View File
@@ -42,6 +42,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.init();
let database_path = env::var("DATABASE_PATH").unwrap_or_else(|_| "sustenance.db".into());
let database_in_memory = env::var("DATABASE_IN_MEMORY")
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:3000".into());
// For loopback hosts, advertise `localhost` so WebAuthn works locally (browsers
// reject IP addresses as RP IDs). Access the app via http://localhost:PORT.
@@ -69,7 +72,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
};
// Build the adapters (ports) and wire them into application services.
let db = SqliteDatabase::open(&database_path).await?;
let db = if database_in_memory {
SqliteDatabase::open_in_memory().await?
} else {
SqliteDatabase::open(&database_path).await?
};
let users: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository);
let sessions: Arc<dyn SessionRepository> = Arc::new(SqliteSessionRepository);
let lists: Arc<dyn ListRepository> = Arc::new(SqliteListRepository);
@@ -163,6 +170,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_graceful_shutdown(shutdown_signal())
.await?;
info!("shutdown complete; closing database");
// Explicitly close the pool so SQLite can checkpoint and remove the
// WAL/SHM sidecar files. Without this, the pool's background close task
// races with process exit and the sidecars can be left behind.
db.close().await;
Ok(())
}
+28 -14
View File
@@ -39,26 +39,32 @@ impl SqliteDatabase {
Ok(Self { pool })
}
#[cfg(test)]
/// Opens an in-memory database. Every connection in the pool shares the same
/// in-memory database via a named shared-cache database, so the schema and
/// data are visible across the whole pool. Nothing is persisted to disk.
pub async fn open_in_memory() -> DomainResult<Self> {
// A pool of `:memory:` connections would each get a separate database,
// so use a unique temporary file that shares the schema across the pool.
// A plain `:memory:` database is private to each connection, so a pool
// would get a separate database per connection. Use a uniquely-named
// shared-cache in-memory database instead so the whole pool shares one.
//
// The name must be unique per call: shared-cache in-memory databases are
// keyed by name, so two calls with the same name within a process would
// resolve to the same database and share state. This matters for the
// unit tests, which run many `open_in_memory()` calls in parallel within
// a single process and need isolation from each other. The counter makes
// each call unique. (Shared-cache and in-memory databases are per-process,
// so there is no cross-process collision to guard against.)
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"sustenance-test-{}-{}-{}.db",
std::process::id(),
now(),
unique
));
let path = path.to_str().unwrap();
let filename = format!("file:sustenance-in-memory-{}", unique);
let options = SqliteConnectOptions::new()
.filename(path)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.filename(filename)
.in_memory(true)
.shared_cache(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Memory)
.foreign_keys(true)
.busy_timeout(std::time::Duration::from_secs(5))
.create_if_missing(true);
.busy_timeout(std::time::Duration::from_secs(5));
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
seed_default_categories(&pool).await?;
@@ -68,6 +74,14 @@ impl SqliteDatabase {
}
impl SqliteDatabase {
/// Closes the connection pool, waiting for all connections to be released
/// and closed. This lets SQLite checkpoint and remove the WAL/SHM sidecar
/// files on a clean shutdown; without it, the pool's background close task
/// races with process exit and the sidecars may be left behind.
pub async fn close(&self) {
self.pool.close().await;
}
/// Runs `operation` inside a single transaction, committing on success and
/// rolling back on error. Multiple repositories can participate in the same
/// transaction so their writes commit together atomically.