mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(library): sweep orphan tracks after successful full resync (#861)
* fix(library): sweep orphan tracks after successful full resync Full resync only upserted server tracks and left server-deleted rows live in SQLite. Add IS-7 mark-and-sweep via track.resync_gen: stamp rows on ingest during a re-sync, soft-delete unstamped rows when IS-6 completes. Delta tombstone reconcile is unchanged. * docs(release): CHANGELOG and credits for PR #861 resync orphan sweep * fix(library): satisfy clippy unnecessary_min_or_max in orphan sweep execute() returns usize; drop redundant max(0) so CI clippy -D warnings passes.
This commit is contained in:
@@ -29,6 +29,11 @@ pub struct InitialSyncCursor {
|
||||
/// future field add doesn't break old cursors.
|
||||
#[serde(default)]
|
||||
pub strategy_state: StrategyState,
|
||||
/// Active full-resync generation for mark-and-sweep orphan cleanup
|
||||
/// (IS-7). `Some(n)` when re-syncing an already-indexed server;
|
||||
/// persisted so resume keeps the same generation.
|
||||
#[serde(default)]
|
||||
pub resync_gen: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -73,6 +78,7 @@ impl InitialSyncCursor {
|
||||
library_scope,
|
||||
ingested_count: 0,
|
||||
strategy_state,
|
||||
resync_gen: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +126,7 @@ mod tests {
|
||||
library_scope: Some("lib-1".into()),
|
||||
ingested_count: 2500,
|
||||
strategy_state: StrategyState::LinearOffset { offset: 2500 },
|
||||
resync_gen: Some(2),
|
||||
};
|
||||
let json = serde_json::to_value(&c).unwrap();
|
||||
let back: InitialSyncCursor = serde_json::from_value(json.clone()).unwrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `InitialSyncRunner` — spec §6.3 IS-1 … IS-6. PR-3b lands the runner,
|
||||
//! `InitialSyncRunner` — spec §6.3 IS-1 … IS-7. PR-3b lands the runner,
|
||||
//! cursor persistence, and the N1/S1/S2 ingest loops. S3 (file-tree)
|
||||
//! is enumerated but returns `StrategyUnsupported`. IS-4 artist pass +
|
||||
//! IS-5 watermarks run after the bulk loop completes.
|
||||
@@ -29,6 +29,7 @@ use super::mapping::{navidrome_song_to_track_row, subsonic_song_to_track_row};
|
||||
use super::progress::{IngestBatchMetrics, NoopProgress, Progress, ProgressEvent};
|
||||
use super::strategy::IngestStrategy;
|
||||
use crate::bulk_ingest::{restore_track_secondary_indexes, suspend_track_secondary_indexes};
|
||||
use crate::dto::track_index_nonempty;
|
||||
use crate::repos::{RemapStats, SyncStateRepository, TrackRepository, TrackRow};
|
||||
use crate::store::LibraryStore;
|
||||
use crate::store::WriteOpTiming;
|
||||
@@ -207,6 +208,7 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
});
|
||||
|
||||
let mut cursor = self.load_or_init_cursor(&sync_state)?;
|
||||
self.ensure_resync_generation(&mut cursor, &sync_state)?;
|
||||
let mut report = InitialSyncReport {
|
||||
strategy: Some(cursor.strategy.clone()),
|
||||
ingested_count: cursor.ingested_count,
|
||||
@@ -265,8 +267,19 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
self.persist_cursor(&sync_state, &cursor)?;
|
||||
}
|
||||
|
||||
// IS-6 — phase=ready, clear cursor, stamp watermarks.
|
||||
// IS-6 — phase=ready, optional IS-7 orphan sweep, clear cursor, stamp watermarks.
|
||||
let finished_at = now_unix_ms();
|
||||
if let Some(gen) = cursor.resync_gen {
|
||||
let swept = TrackRepository::new(self.store)
|
||||
.sweep_resync_orphans(&self.server_id, gen)
|
||||
.map_err(SyncError::Storage)?;
|
||||
if swept > 0 {
|
||||
self.progress.emit(ProgressEvent::Tombstoned {
|
||||
deleted_count: swept,
|
||||
checked_count: swept,
|
||||
});
|
||||
}
|
||||
}
|
||||
let local_count = crate::dto::count_local_tracks(self.store, &self.server_id)
|
||||
.map_err(SyncError::Storage)?;
|
||||
sync_state
|
||||
@@ -419,9 +432,36 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_batch_timed(&self, rows: &[TrackRow]) -> Result<WriteOpTiming, SyncError> {
|
||||
fn ensure_resync_generation(
|
||||
&self,
|
||||
cursor: &mut InitialSyncCursor,
|
||||
sync_state: &SyncStateRepository<'_>,
|
||||
) -> Result<(), SyncError> {
|
||||
if cursor.resync_gen.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let is_resync = sync_state
|
||||
.has_last_full_sync_at(&self.server_id, &self.library_scope)
|
||||
.map_err(SyncError::Storage)?
|
||||
|| track_index_nonempty(self.store, &self.server_id).map_err(SyncError::Storage)?;
|
||||
if !is_resync {
|
||||
return Ok(());
|
||||
}
|
||||
let gen = TrackRepository::new(self.store)
|
||||
.next_resync_gen(&self.server_id)
|
||||
.map_err(SyncError::Storage)?;
|
||||
cursor.resync_gen = Some(gen);
|
||||
self.persist_cursor(sync_state, cursor)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_batch_timed(
|
||||
&self,
|
||||
rows: &[TrackRow],
|
||||
resync_gen: Option<i64>,
|
||||
) -> Result<WriteOpTiming, SyncError> {
|
||||
TrackRepository::new(self.store)
|
||||
.upsert_batch_initial_ingest_timed(rows)
|
||||
.upsert_batch_initial_ingest_timed(rows, resync_gen)
|
||||
.map_err(SyncError::Storage)
|
||||
}
|
||||
|
||||
@@ -430,8 +470,9 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
rows: &[TrackRow],
|
||||
label: &str,
|
||||
offset: u32,
|
||||
resync_gen: Option<i64>,
|
||||
) -> Result<(RemapStats, WriteOpTiming), SyncError> {
|
||||
let timing = self.write_batch_timed(rows)?;
|
||||
let timing = self.write_batch_timed(rows, resync_gen)?;
|
||||
let total_ms = timing.total_ms();
|
||||
if total_ms >= 500 {
|
||||
crate::app_eprintln!(
|
||||
@@ -667,7 +708,8 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (_stats, _timing) = self.write_batch_logged(&rows, "N1", offset)?;
|
||||
let (_stats, _timing) =
|
||||
self.write_batch_logged(&rows, "N1", offset, ctx.cursor.resync_gen)?;
|
||||
ctx.report.ingested_count = ctx.report.ingested_count.saturating_add(rows.len() as u32);
|
||||
|
||||
let next_offset = offset.saturating_add(self.batch_size);
|
||||
@@ -923,7 +965,8 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
));
|
||||
}
|
||||
let row_count = rows.len() as u32;
|
||||
let (_stats, write_timing) = self.write_batch_logged(&rows, "S1", offset)?;
|
||||
let (_stats, write_timing) =
|
||||
self.write_batch_logged(&rows, "S1", offset, ctx.cursor.resync_gen)?;
|
||||
ctx.report.ingested_count = ctx.report.ingested_count.saturating_add(row_count);
|
||||
|
||||
let next_offset = offset.saturating_add(self.batch_size);
|
||||
@@ -1094,7 +1137,8 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
));
|
||||
}
|
||||
if !rows.is_empty() {
|
||||
let (_stats, _timing) = self.write_batch_logged(&rows, "S2", album_offset)?;
|
||||
let (_stats, _timing) =
|
||||
self.write_batch_logged(&rows, "S2", album_offset, cursor.resync_gen)?;
|
||||
report.ingested_count = report
|
||||
.ingested_count
|
||||
.saturating_add(rows.len() as u32);
|
||||
@@ -1367,6 +1411,68 @@ mod tests {
|
||||
assert_eq!(count, 7);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn full_resync_sweeps_orphans_not_seen_in_ingest() {
|
||||
let server = MockServer::start().await;
|
||||
mount_search3_pages(&server, /*total*/ 3, /*batch*/ 10).await;
|
||||
mount_minimal_artists(&server).await;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let sync_state = SyncStateRepository::new(&store);
|
||||
sync_state.ensure("s1", "").unwrap();
|
||||
sync_state
|
||||
.set_last_full_sync_at("s1", "", 1)
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.with_conn_mut("misc", |c| {
|
||||
for id in ["tr_stale_a", "tr_stale_b"] {
|
||||
c.execute(
|
||||
"INSERT INTO track (server_id, id, title, album, duration_sec, deleted, synced_at, raw_json, resync_gen) \
|
||||
VALUES ('s1', ?1, 'stale', 'Al', 1, 0, 1, '{}', 1)",
|
||||
rusqlite::params![id],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let subsonic = test_subsonic(&server.uri());
|
||||
InitialSyncRunner::new(
|
||||
&store,
|
||||
&subsonic,
|
||||
"s1",
|
||||
"",
|
||||
flags(CapabilityFlags::SUBSONIC_SEARCH3_BULK | CapabilityFlags::SCAN_STATUS_AVAILABLE),
|
||||
)
|
||||
.with_sleep_disabled()
|
||||
.run()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let live: i64 = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE server_id = 's1' AND deleted = 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(live, 3);
|
||||
|
||||
let stale_deleted: i64 = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE id IN ('tr_stale_a', 'tr_stale_b') AND deleted = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(stale_deleted, 2);
|
||||
}
|
||||
|
||||
// ── Per-batch progress is emitted during ingest ───────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
Reference in New Issue
Block a user