@@ -22,6 +22,12 @@ use crate::models::{Asset, Pack, ScanProgress};
2222/// 250 ms gives smooth progress bar updates without hammering the JS thread.
2323const 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 ) ]
2632pub 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
6980impl 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 }
0 commit comments