Skip to content

Commit 97589fe

Browse files
committed
feat: v1.0.9 — 15x faster indexing, full-text search & duplicate-file fix
- Per-path asset identity: byte-identical files in different packs keep separate entries; content hash moved to its own column (migrations 008/009) - Eliminated O(n^2) FTS delete during bulk indexing — 164k-file cold index drops from 31+ min to under 2 min - Text search now uses the FTS5 index (prefix MATCH with synonym expansion); LIKE scan kept only as fallback - Batched upserts through a single writer task with BEGIN IMMEDIATE; reconciler batched and moved off the async runtime - New filename sort index: first-page browse ~7ms, deep offsets <100ms - Size caps for MIDI/FLP/als/rpp parsing; .als gzip XML now decoded correctly - Piano-roll meta capped at 10k notes (note counts stay exact)
1 parent 82be9a1 commit 97589fe

14 files changed

Lines changed: 524 additions & 137 deletions

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "stack",
33
"private": true,
4-
"version": "1.0.8",
4+
"version": "1.0.9",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stack"
3-
version = "1.0.8"
3+
version = "1.0.9"
44
description = "Local sample library manager"
55
authors = ["Stack"]
66
edition = "2021"
@@ -44,6 +44,7 @@ hound = "3.5"
4444
symphonia = { version = "0.5", features = ["mp3", "wav", "flac", "ogg", "aiff"] }
4545

4646
regex = "1"
47+
flate2 = "1"
4748
once_cell = "1"
4849
lazy_static = "1.4"
4950
base64 = "0.22"

src-tauri/src/core/hasher.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,17 @@ const CHUNK: usize = 64 * 1024;
1010
const HEAD_BYTES: u64 = 256 * 1024;
1111
const TAIL_BYTES: u64 = 256 * 1024;
1212

13-
/// Content identity hash: file size + first 256KB + last 256KB (if large enough).
13+
/// Stable per-path asset ID. Distinct paths get distinct rows even when their
14+
/// bytes are identical — duplicate-content files (rebranded packs, backup
15+
/// copies) must each keep their own library entry. Content equality lives in
16+
/// the separate `content_hash` column instead of the primary key.
17+
pub fn hash_path(path: &str) -> String {
18+
let mut hasher = Xxh3::new();
19+
hasher.update(path.as_bytes());
20+
format!("{:x}", hasher.digest128())
21+
}
22+
23+
/// Content hash: file size + first 256KB + last 256KB (if large enough).
1424
/// Avoids scanning multi-gigabyte files while still collision-resistant for music files.
1525
pub fn hash_file(path: &Path) -> Result<String> {
1626
let mut file = File::open(path)?;

src-tauri/src/core/indexer.rs

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ use crate::models::{Asset, Pack, ScanProgress};
2222
/// 250 ms gives smooth progress bar updates without hammering the JS thread.
2323
const PROGRESS_THROTTLE_MS: u64 = 250;
2424

25+
/// Upserts are buffered and committed in one transaction per batch. Per-file
26+
/// transactions were the write bottleneck at scale: 164k files = 164k WAL
27+
/// commits + FTS churn, enough lock pressure to keep the adaptive tuner
28+
/// pinned at minimum concurrency.
29+
const UPSERT_BATCH_SIZE: usize = 100;
30+
2531
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
2632
pub enum JobPriority {
2733
Low = 1,
@@ -64,6 +70,11 @@ pub struct Indexer {
6470
/// Buffered `touch_last_seen` IDs — flushed in batches instead of one
6571
/// UPDATE per file, which was causing thousands of individual write locks.
6672
pending_touches: Mutex<Vec<String>>,
73+
/// Buffered parsed assets (+ content hash) awaiting a batched upsert.
74+
pending_upserts: Mutex<Vec<(Asset, Option<String>)>>,
75+
/// Full batches go to a single writer task — serializing upsert
76+
/// transactions instead of racing concurrent flushes for the write lock.
77+
upsert_tx: UnboundedSender<Vec<(Asset, Option<String>)>>,
6778
}
6879

6980
impl Indexer {
@@ -75,6 +86,7 @@ impl Indexer {
7586
concurrency: usize,
7687
) -> Arc<Self> {
7788
let (tx, mut rx) = unbounded_channel::<IndexJob>();
89+
let (upsert_tx, mut upsert_rx) = unbounded_channel::<Vec<(Asset, Option<String>)>>();
7890
let sem = Arc::new(parking_lot::RwLock::new(
7991
Arc::new(Semaphore::new(concurrency.max(1)))
8092
));
@@ -98,6 +110,46 @@ impl Indexer {
98110
max_concurrency,
99111
last_progress_ms: AtomicUsize::new(0),
100112
pending_touches: Mutex::new(Vec::new()),
113+
pending_upserts: Mutex::new(Vec::new()),
114+
upsert_tx,
115+
});
116+
117+
// Dedicated writer: one batch transaction at a time, retried until it
118+
// lands. Dropping a batch loses library entries, so the backoff is
119+
// generous — with IMMEDIATE transactions + busy_timeout a batch only
120+
// fails while another writer is mid-transaction for >10s.
121+
let writer_repo = indexer.asset_repo.clone();
122+
let writer_retries = indexer.lock_retries_window.clone();
123+
tauri::async_runtime::spawn(async move {
124+
while let Some(items) = upsert_rx.recv().await {
125+
for attempt in 0..8u32 {
126+
let repo = writer_repo.clone();
127+
let batch = items.clone();
128+
match tokio::task::spawn_blocking(move || repo.upsert_batch(&batch)).await {
129+
Ok(Ok(())) => break,
130+
Ok(Err(e)) => {
131+
let locked =
132+
e.to_string().to_lowercase().contains("database is locked");
133+
if locked && attempt < 7 {
134+
writer_retries.fetch_add(1, Ordering::Relaxed);
135+
sleep(Duration::from_millis(250 * (attempt as u64 + 1))).await;
136+
continue;
137+
}
138+
tracing::error!(
139+
"dropping upsert batch ({} assets) after {} attempts: {}",
140+
items.len(),
141+
attempt + 1,
142+
e
143+
);
144+
break;
145+
}
146+
Err(e) => {
147+
tracing::error!("upsert writer task join error: {}", e);
148+
break;
149+
}
150+
}
151+
}
152+
}
101153
});
102154

103155
let worker = indexer.clone();
@@ -211,6 +263,8 @@ impl Indexer {
211263

212264
pub fn cancel(&self) {
213265
self.cancelled.store(true, Ordering::Relaxed);
266+
// Parsed-but-unwritten assets are still valid work — persist them.
267+
self.flush_upserts();
214268
let mut c = self.counters.lock();
215269
// Mark all queued as "done" so progress shows complete
216270
c.indexed += c.queued;
@@ -234,10 +288,11 @@ impl Indexer {
234288
drop(c);
235289

236290
// When the last job finishes:
237-
// 1. Flush any remaining buffered touch_last_seen IDs.
291+
// 1. Flush any remaining buffered upserts + touch_last_seen IDs.
238292
// 2. Recount all pack asset_counts in one pass.
239293
// 3. Force a final progress emit so the UI shows 100%.
240294
if finished {
295+
self.flush_upserts();
241296
self.flush_touches();
242297
let pack_repo = self.pack_repo.clone();
243298
tauri::async_runtime::spawn(async move {
@@ -310,6 +365,34 @@ impl Indexer {
310365
});
311366
}
312367

368+
/// Buffer a parsed asset for the next batched upsert. Flushes when the
369+
/// buffer reaches UPSERT_BATCH_SIZE; the scan-end hook flushes the rest.
370+
fn queue_upsert(&self, asset: Asset, content_hash: Option<String>) {
371+
let batch: Option<Vec<(Asset, Option<String>)>> = {
372+
let mut buf = self.pending_upserts.lock();
373+
buf.push((asset, content_hash));
374+
if buf.len() >= UPSERT_BATCH_SIZE {
375+
Some(std::mem::take(&mut *buf))
376+
} else {
377+
None
378+
}
379+
};
380+
if let Some(items) = batch {
381+
let _ = self.upsert_tx.send(items);
382+
}
383+
}
384+
385+
/// Flush any buffered upserts (called at scan end and on cancel).
386+
fn flush_upserts(&self) {
387+
let items: Vec<(Asset, Option<String>)> = {
388+
let mut buf = self.pending_upserts.lock();
389+
std::mem::take(&mut *buf)
390+
};
391+
if !items.is_empty() {
392+
let _ = self.upsert_tx.send(items);
393+
}
394+
}
395+
313396
async fn process_with_retry(&self, job: IndexJob) -> Result<()> {
314397
const MAX_ATTEMPTS: usize = 5;
315398
for attempt in 0..MAX_ATTEMPTS {
@@ -352,7 +435,8 @@ impl Indexer {
352435
// and skip the expensive hash + metadata read. When the file IS newer
353436
// (e.g. user re-saved an FLP in FL Studio), fall through and re-parse
354437
// so the meta JSON — playlist clips, tempo, plugins — stays in sync.
355-
if let Ok(Some((existing_id, updated_at))) = self.asset_repo.path_stamp(&path_str) {
438+
let mut existing_id = None;
439+
if let Ok(Some((row_id, updated_at))) = self.asset_repo.path_stamp(&path_str) {
356440
let mtime_secs = std::fs::metadata(&path)
357441
.ok()
358442
.and_then(|m| m.modified().ok())
@@ -362,18 +446,24 @@ impl Indexer {
362446
if mtime_secs <= updated_at {
363447
// Buffer the touch — flushed in batches of 500 to avoid
364448
// thousands of individual UPDATE statements during re-scans.
365-
self.queue_touch(existing_id);
449+
self.queue_touch(row_id);
366450
return Ok(());
367451
}
452+
existing_id = Some(row_id);
368453
}
369454

370-
// Hash for identity
371-
let id = tokio::task::spawn_blocking({
455+
// Identity is per PATH: keep the row's existing id when re-indexing,
456+
// otherwise derive one from the path. Duplicate-content files at other
457+
// paths keep their own rows; the content hash below is stored as a
458+
// plain column for duplicate detection, never as the primary key.
459+
let id = existing_id.unwrap_or_else(|| hasher::hash_path(&path_str));
460+
let content_hash = tokio::task::spawn_blocking({
372461
let p = path.clone();
373462
move || hasher::hash_file(&p)
374463
})
375464
.await
376-
.map_err(|e| crate::error::StackError::Other(e.to_string()))??;
465+
.map_err(|e| crate::error::StackError::Other(e.to_string()))?
466+
.ok();
377467

378468
// Ensure pack
379469
let (pack_id, pack_name) = self.ensure_pack(&path, job.pack_root.as_deref())?;
@@ -513,12 +603,12 @@ impl Indexer {
513603
updated_at: now,
514604
};
515605

516-
self.asset_repo.upsert(&asset)?;
517-
606+
// Buffered — committed in batches of UPSERT_BATCH_SIZE by queue_upsert.
518607
// Don't recount per-file — pack counts are reconciled at scan end.
519608
// Don't emit the full asset payload per-file either — the UI refreshes
520609
// via the throttled scan-progress event and re-queries when scanning ends.
521610
// This eliminates thousands of IPC serialisation calls during large scans.
611+
self.queue_upsert(asset, content_hash);
522612

523613
Ok(())
524614
}

src-tauri/src/core/reconciler.rs

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ impl Reconciler {
1414
asset_repo: Arc<AssetRepository>,
1515
pack_repo: Arc<PackRepository>,
1616
indexer: Arc<Indexer>,
17+
) -> Result<ReconcileReport> {
18+
// The walk + chunked DB passes are synchronous work over potentially
19+
// hundreds of thousands of files — keep it off the async runtime.
20+
tokio::task::spawn_blocking(move || Self::run_sync(asset_repo, pack_repo, indexer))
21+
.await
22+
.map_err(|e| crate::error::StackError::Other(e.to_string()))?
23+
}
24+
25+
fn run_sync(
26+
asset_repo: Arc<AssetRepository>,
27+
pack_repo: Arc<PackRepository>,
28+
indexer: Arc<Indexer>,
1729
) -> Result<ReconcileReport> {
1830
let start = Instant::now();
1931
let watched = pack_repo.list_watched()?;
@@ -35,26 +47,42 @@ impl Reconciler {
3547
continue;
3648
}
3749
let (files, _) = Scanner::scan(root)?;
50+
51+
// Chunked set-membership instead of one SELECT + one UPDATE per
52+
// file: at 5–10TB library scale the per-file version was ~340k
53+
// round-trips on every app launch.
54+
let paths: Vec<String> = files
55+
.iter()
56+
.map(|f| f.path.to_string_lossy().to_string())
57+
.collect();
58+
let known: std::collections::HashSet<String> =
59+
asset_repo.existing_paths(&paths)?.into_iter().collect();
60+
61+
let seen: Vec<String> = paths
62+
.iter()
63+
.filter(|p| known.contains(*p))
64+
.cloned()
65+
.collect();
66+
asset_repo.touch_last_seen_by_paths(&seen, cutoff)?;
67+
3868
let mut jobs = Vec::new();
39-
for f in files {
40-
let path_str = f.path.to_string_lossy().to_string();
41-
if let Some(existing_id) = asset_repo.path_exists(&path_str)? {
42-
asset_repo.touch_last_seen(&existing_id, cutoff)?;
43-
} else {
44-
new_files += 1;
45-
// For project-kind watched folders the watched root *is*
46-
// the pack root; one pack per project regardless of subdirs.
47-
let pack_root = if wf.kind == "project" {
48-
Some(root.to_path_buf())
49-
} else {
50-
Scanner::detect_pack_root(&f.path, root)
51-
};
52-
jobs.push(crate::core::IndexJob {
53-
path: f.path,
54-
priority: crate::core::JobPriority::Low,
55-
pack_root,
56-
});
69+
for (f, path_str) in files.into_iter().zip(paths.iter()) {
70+
if known.contains(path_str) {
71+
continue;
5772
}
73+
new_files += 1;
74+
// For project-kind watched folders the watched root *is*
75+
// the pack root; one pack per project regardless of subdirs.
76+
let pack_root = if wf.kind == "project" {
77+
Some(root.to_path_buf())
78+
} else {
79+
Scanner::detect_pack_root(&f.path, root)
80+
};
81+
jobs.push(crate::core::IndexJob {
82+
path: f.path,
83+
priority: crate::core::JobPriority::Low,
84+
pack_root,
85+
});
5886
}
5987
if !jobs.is_empty() {
6088
indexer.enqueue_batch(jobs);

0 commit comments

Comments
 (0)