Files
Archive/src-tauri/crates/archive-db/src/lib.rs
T
강 한andClaude Opus 4.8 394d1b9805 Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램
Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼
사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드,
인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포.

- archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram
- archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트)
- archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수
- archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡
- 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널

Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:04:06 +09:00

240 lines
7.8 KiB
Rust

//! SQLite 인덱스 접근 계층.
//!
//! 아키텍처: **단일 writer 스레드**(crossbeam 채널로 잡 수신, 배치 트랜잭션)
//! + 읽기전용 커넥션 풀. WAL 모드라 읽기는 쓰기에 블록되지 않는다.
pub mod migrations;
use crossbeam_channel::{unbounded, Sender};
use rusqlite::{Connection, OpenFlags};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::thread::JoinHandle;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("sqlite 오류: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("DB가 이미 닫혔습니다")]
Closed,
}
pub type Result<T> = std::result::Result<T, DbError>;
type WriteJob = Box<dyn FnOnce(&mut Connection) + Send + 'static>;
pub struct Db {
path: PathBuf,
write_tx: Option<Sender<WriteJob>>,
writer: Option<JoinHandle<()>>,
readers: Mutex<Vec<Connection>>,
pub fts_enabled: bool,
}
impl Db {
/// DB를 열고(없으면 생성) 마이그레이션을 적용한 뒤 writer 스레드를 시작한다.
pub fn open(db_path: &Path) -> Result<Db> {
let mut conn = Connection::open(db_path)?;
apply_writer_pragmas(&conn)?;
let fts_enabled = migrations::migrate(&mut conn)?;
let (tx, rx) = unbounded::<WriteJob>();
let writer = std::thread::Builder::new()
.name("db-writer".into())
.spawn(move || {
while let Ok(job) = rx.recv() {
job(&mut conn);
}
// 종료 정리: 체크포인트 + 통계 갱신
let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA optimize;");
})
.expect("db-writer 스레드 생성 실패");
Ok(Db {
path: db_path.to_path_buf(),
write_tx: Some(tx),
writer: Some(writer),
readers: Mutex::new(Vec::new()),
fts_enabled,
})
}
/// writer 스레드에서 클로저를 실행하고 결과를 동기적으로 기다린다.
pub fn with_write<T, F>(&self, f: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
{
let tx = self.write_tx.as_ref().ok_or(DbError::Closed)?;
let (rtx, rrx) = std::sync::mpsc::sync_channel::<rusqlite::Result<T>>(1);
tx.send(Box::new(move |conn| {
let _ = rtx.send(f(conn));
}))
.map_err(|_| DbError::Closed)?;
rrx.recv().map_err(|_| DbError::Closed)?.map_err(Into::into)
}
/// 결과를 기다리지 않는 쓰기(스캔 배치 등 대량 파이프라인용).
pub fn write_detached<F>(&self, f: F) -> Result<()>
where
F: FnOnce(&mut Connection) + Send + 'static,
{
let tx = self.write_tx.as_ref().ok_or(DbError::Closed)?;
tx.send(Box::new(f)).map_err(|_| DbError::Closed)
}
/// 읽기전용 커넥션으로 쿼리를 실행한다 (풀에서 재사용).
pub fn with_read<T>(&self, f: impl FnOnce(&Connection) -> rusqlite::Result<T>) -> Result<T> {
let conn = match self.readers.lock().unwrap().pop() {
Some(c) => c,
None => self.open_reader()?,
};
let result = f(&conn);
// 풀 크기 상한: 커넥션 4개
let mut pool = self.readers.lock().unwrap();
if pool.len() < 4 {
pool.push(conn);
}
drop(pool);
result.map_err(Into::into)
}
fn open_reader(&self) -> Result<Connection> {
let conn = Connection::open_with_flags(
&self.path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
apply_reader_pragmas(&conn)?;
Ok(conn)
}
}
impl Drop for Db {
fn drop(&mut self) {
// 채널을 닫으면 writer 루프가 종료된다
self.write_tx.take();
if let Some(h) = self.writer.take() {
let _ = h.join();
}
}
}
fn apply_writer_pragmas(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
common_pragmas(conn)
}
fn apply_reader_pragmas(conn: &Connection) -> rusqlite::Result<()> {
// journal_mode는 DB 영속 속성이라 읽기전용 커넥션에서는 건드리지 않는다
common_pragmas(conn)
}
fn common_pragmas(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "cache_size", -64000)?;
conn.pragma_update(None, "mmap_size", 268_435_456i64)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", 5000)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn open_temp() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("test.db")).unwrap();
(dir, db)
}
fn insert_fixture_file(db: &Db, name: &str) -> i64 {
db.with_write({
let name = name.to_string();
move |conn| {
conn.execute(
"INSERT OR IGNORE INTO sources(id, kind, name, root) VALUES (1,'local','테스트','C:\\media')",
[],
)?;
conn.execute(
"INSERT OR IGNORE INTO folders(id, source_id, path, name) VALUES (1,1,'C:\\media','media')",
[],
)?;
conn.execute(
"INSERT INTO files(source_id, folder_id, name, ext, kind, size, mtime_ms)
VALUES (1, 1, ?1, 'jpg', 0, 1000, 0)",
[&name],
)?;
Ok(conn.last_insert_rowid())
}
})
.unwrap()
}
/// M0 스파이크 ③: bundled SQLite의 FTS5 trigram 가용성 확인.
#[test]
fn fts5_trigram_smoke() {
let (_dir, db) = open_temp();
assert!(db.fts_enabled, "bundled SQLite에 FTS5 trigram이 없음 — LIKE 폴백 필요");
insert_fixture_file(&db, "가족여행_제주도_2025.jpg");
insert_fixture_file(&db, "회사_워크샵.png");
// 한글 부분 문자열(3자 이상) 매칭
let hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH ?1",
["제주도"],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(hits, 1);
}
#[test]
fn writer_roundtrip_and_read_pool() {
let (_dir, db) = open_temp();
let id = insert_fixture_file(&db, "test.jpg");
let name: String = db
.with_read(|conn| {
conn.query_row("SELECT name FROM files WHERE id = ?1", [id], |r| r.get(0))
})
.unwrap();
assert_eq!(name, "test.jpg");
}
#[test]
fn fts_sync_triggers() {
let (_dir, db) = open_temp();
let id = insert_fixture_file(&db, "바다사진.jpg");
db.with_write(move |conn| {
conn.execute("UPDATE files SET name = '산사진.jpg' WHERE id = ?1", [id])
})
.unwrap();
let old_hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH '바다사진'",
[],
|r| r.get(0),
)
})
.unwrap();
let new_hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH '산사진'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(old_hits, 0);
assert_eq!(new_hits, 1);
}
}