Skip to main content

arama_cache/core/
migrate.rs

1//! One-time import of a v1 (`file-feature-cache`) database into the v2
2//! (`localcache`) database. See RFC 002, § Data migration.
3//!
4//! The importer is **read-only on the v1 file**. For every v1 row it:
5//!
6//! 1. Skips files that no longer exist on disk, or whose `mtime` (in
7//!    nanoseconds, as v1 stored it) no longer matches — the payload is
8//!    stale and will be recomputed lazily.
9//! 2. Moves the v1 thumbnail (named `<row id>.jpg`) to its v2 name
10//!    (`<blake3(path)[..16]>.jpg`) in the same directory.
11//! 3. Writes the payload into the matching namespace: rows with a
12//!    `video_features` record become video entries; everything else
13//!    becomes an image entry (matching v1 usage, where the startup
14//!    thumbnail pass registered every media file through the image
15//!    writer).
16//!
17//! On success the v1 file is renamed to `<name>.v1.bak`; on failure the
18//! partially-written v2 file is removed so the next run can retry.
19//!
20//! This module is scheduled for removal one release cycle after v2 ships.
21
22use std::path::Path;
23
24use localcache::ConnectionPool;
25
26use crate::core::engine::{
27    CacheConfig, CacheError, DbLocation, IMAGE_PAYLOAD_VERSION, NAMESPACE_IMAGE, NAMESPACE_VIDEO,
28    Result, VIDEO_PAYLOAD_VERSION, cache_options,
29};
30use crate::core::payload::{ImagePayload, VideoPayload};
31use crate::core::thumbnail::thumbnail_dest_for_canonical;
32
33/// Outcome of a migration run.
34#[derive(Debug, Default)]
35pub struct MigrationReport {
36    /// Entries imported into the v2 database.
37    pub imported: usize,
38    /// Entries skipped (file gone, changed, or unreadable payload).
39    pub skipped: usize,
40}
41
42/// Import `v1_db` into `v2_db` when, and only when, the former exists and
43/// the latter does not. Returns `Ok(None)` when there is nothing to do.
44pub fn migrate_v1_if_present(v1_db: &Path, v2_db: &Path) -> Result<Option<MigrationReport>> {
45    if !v1_db.exists() || v2_db.exists() {
46        return Ok(None);
47    }
48
49    match import(v1_db, v2_db) {
50        Ok(report) => {
51            // Keep the v1 file around for one release cycle as a backup.
52            let backup = v1_db.with_extension("sqlite.v1.bak");
53            let _ = std::fs::rename(v1_db, backup);
54            Ok(Some(report))
55        }
56        Err(err) => {
57            // Leave the v1 file untouched and remove the partial v2 file
58            // so that the next startup can retry (or fall back to lazy
59            // recomputation once the v2 file is created normally).
60            let _ = std::fs::remove_file(v2_db);
61            Err(err)
62        }
63    }
64}
65
66fn import(v1_db: &Path, v2_db: &Path) -> Result<MigrationReport> {
67    let conn =
68        rusqlite::Connection::open_with_flags(v1_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
69            .map_err(|e| CacheError::Migration(e.to_string()))?;
70
71    let config = CacheConfig {
72        db_location: DbLocation::Custom(v2_db.to_path_buf()),
73        ..CacheConfig::default()
74    };
75    let image_pool: ConnectionPool<ImagePayload> = ConnectionPool::open(cache_options(
76        &config,
77        NAMESPACE_IMAGE,
78        IMAGE_PAYLOAD_VERSION,
79    ))?;
80    let video_pool: ConnectionPool<VideoPayload> = ConnectionPool::open(cache_options(
81        &config,
82        NAMESPACE_VIDEO,
83        VIDEO_PAYLOAD_VERSION,
84    ))?;
85
86    let mut report = MigrationReport::default();
87
88    let mut stmt = conn
89        .prepare(
90            "SELECT f.id, f.path, f.mtime_ns,
91                    t.thumbnail_path,
92                    i.clip_vector,
93                    v.clip_vector, v.wav2vec2_vector
94             FROM files f
95             LEFT JOIN thumbnails     t ON t.id = f.id
96             LEFT JOIN image_features i ON i.id = f.id
97             LEFT JOIN video_features v ON v.id = f.id",
98        )
99        .map_err(|e| CacheError::Migration(e.to_string()))?;
100
101    let rows = stmt
102        .query_map([], |r| {
103            Ok(V1Row {
104                path: r.get::<_, String>(1)?,
105                mtime_ns: r.get::<_, Option<i64>>(2)?,
106                thumbnail_path: r.get::<_, Option<String>>(3)?,
107                image_clip: r.get::<_, Option<Vec<u8>>>(4)?,
108                video_clip: r.get::<_, Option<Vec<u8>>>(5)?,
109                video_wav: r.get::<_, Option<Vec<u8>>>(6)?,
110            })
111        })
112        .map_err(|e| CacheError::Migration(e.to_string()))?;
113
114    for row in rows {
115        let row = row.map_err(|e| CacheError::Migration(e.to_string()))?;
116
117        if !still_fresh(&row) {
118            report.skipped += 1;
119            continue;
120        }
121
122        let thumbnail_path = relocate_thumbnail(&row);
123
124        // Rows carrying a video_features record are video entries;
125        // everything else is an image entry.
126        let outcome = if row.video_clip.is_some() || row.video_wav.is_some() {
127            video_pool.set(
128                &row.path,
129                &VideoPayload {
130                    thumbnail_path,
131                    clip_vector: row.video_clip.as_deref().map(blob_to_vec),
132                    wav2vec2_vector: row.video_wav.as_deref().map(blob_to_vec),
133                },
134            )
135        } else {
136            image_pool.set(
137                &row.path,
138                &ImagePayload {
139                    thumbnail_path,
140                    clip_vector: row.image_clip.as_deref().map(blob_to_vec),
141                },
142            )
143        };
144
145        match outcome {
146            Ok(()) => report.imported += 1,
147            // A single bad file must not abort the whole migration.
148            Err(_) => report.skipped += 1,
149        }
150    }
151
152    Ok(report)
153}
154
155struct V1Row {
156    path: String,
157    mtime_ns: Option<i64>,
158    thumbnail_path: Option<String>,
159    image_clip: Option<Vec<u8>>,
160    video_clip: Option<Vec<u8>>,
161    video_wav: Option<Vec<u8>>,
162}
163
164/// The payload is only worth importing when the file still exists and
165/// its mtime matches what v1 recorded (nanosecond precision). Changed
166/// files are skipped and recomputed lazily.
167fn still_fresh(row: &V1Row) -> bool {
168    let Ok(meta) = std::fs::metadata(&row.path) else {
169        return false;
170    };
171    let Some(stored_ns) = row.mtime_ns else {
172        return false;
173    };
174    let current_ns = meta
175        .modified()
176        .ok()
177        .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
178        .map(|d| d.as_nanos() as i64);
179    current_ns == Some(stored_ns)
180}
181
182/// Move the v1 thumbnail file (named by row id) to its v2 hash-based
183/// name in the same directory. Returns the new path, or `None` when the
184/// v1 thumbnail is absent on disk.
185fn relocate_thumbnail(row: &V1Row) -> Option<String> {
186    let old = Path::new(row.thumbnail_path.as_deref()?);
187    if !old.exists() {
188        return None;
189    }
190    let dir = old.parent()?;
191    let new = thumbnail_dest_for_canonical(dir, &row.path);
192    if new != old {
193        std::fs::rename(old, &new).ok()?;
194    }
195    Some(new.to_string_lossy().into_owned())
196}
197
198/// Decode a v1 raw little-endian `f32` blob.
199fn blob_to_vec(blob: &[u8]) -> Vec<f32> {
200    blob.chunks_exact(4)
201        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
202        .collect()
203}