Fix SwiftData persistence concurrency stability

This commit is contained in:
diyaa
2026-05-29 00:06:57 +02:00
parent e5027152ab
commit ebc187f4a1
19 changed files with 554 additions and 132 deletions
@@ -1,4 +1,5 @@
import Foundation
import SwiftData
import XCTest
@testable import VelodyPersistence
import VelodyDomain
@@ -254,6 +255,119 @@ final class LocalCatalogServiceTests: XCTestCase {
XCTAssertEqual(allTracks.count, 2)
}
func testConcurrentScanCallsDoNotCrashAndRetainCatalogEntries() async throws {
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
let service = DefaultLocalCatalogService(repository: repository)
let folderURL = URL(fileURLWithPath: "/Music")
try await withThrowingTaskGroup(of: Void.self) { group in
for index in 0..<12 {
group.addTask {
_ = try await service.reconcileScanResults(
[
self.makeScannedTrack(
title: "Track \(index)",
path: "/Music/Track-\(index).mp3",
sha256: "sha-\(index)",
modifiedAt: Date(timeIntervalSince1970: 6_000 + Double(index))
),
],
in: folderURL,
scannedAt: Date(timeIntervalSince1970: 6_100 + Double(index))
)
}
}
try await group.waitForAll()
}
let storedTracks = try await repository.loadLocalTracks(
origin: .localScan,
includeDeleted: true
)
let activeTracks = try await repository.loadLocalTracks(
origin: .localScan,
includeDeleted: false
)
XCTAssertEqual(storedTracks.count, 12)
XCTAssertLessThanOrEqual(activeTracks.count, 1)
}
func testConcurrentSaveCallsDoNotCrash() async throws {
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
try await withThrowingTaskGroup(of: Void.self) { group in
for index in 0..<24 {
group.addTask {
try await repository.saveLocalTrack(
self.makeLocalTrack(
id: "track-\(index)",
title: "Track \(index)",
path: "/Music/Track-\(index).mp3",
sha256: "sha-save-\(index)",
observedAt: Date(timeIntervalSince1970: 7_000 + Double(index))
)
)
}
}
try await group.waitForAll()
}
let storedTracks = try await repository.loadLocalTracks(
origin: .localScan,
includeDeleted: false
)
XCTAssertEqual(storedTracks.count, 24)
}
func testUploadStatusPersistenceSurvivesReload() async throws {
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
let observedAt = Date(timeIntervalSince1970: 8_000)
var track = makeLocalTrack(
id: "upload-track",
title: "Uploadable",
path: "/Music/Uploadable.mp3",
sha256: "sha-uploadable",
observedAt: observedAt
)
try await repository.saveLocalTrack(track)
track.uploadStatus = .uploaded
track.remoteTrackId = "remote-track-42"
track.lastUploadError = nil
track.updatedAt = Date(timeIntervalSince1970: 8_100)
try await repository.saveLocalTrack(track)
let persistedTrack = try await repository.findTrack(trackID: track.id)
let reloadedTrack = try XCTUnwrap(persistedTrack)
XCTAssertEqual(reloadedTrack.uploadStatus, .uploaded)
XCTAssertEqual(reloadedTrack.remoteTrackId, "remote-track-42")
XCTAssertNil(reloadedTrack.lastUploadError)
}
func testRepositoryAndCatalogServiceAreActorTypes() throws {
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
let service = DefaultLocalCatalogService(repository: repository)
assertActorIsolation(repository)
assertActorIsolation(service)
}
func testRepositoryDoesNotStoreModelContext() throws {
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
let storesModelContext = Mirror(reflecting: repository).children.contains { child in
child.value is ModelContext
}
XCTAssertFalse(storesModelContext)
}
private func makeScannedTrack(
title: String,
path: String,
@@ -270,4 +384,36 @@ final class LocalCatalogServiceTests: XCTestCase {
fileModifiedAt: modifiedAt
)
}
private func makeLocalTrack(
id: String,
title: String,
path: String,
sha256: String,
observedAt: Date
) -> LocalTrack {
LocalTrack(
id: id,
origin: .localScan,
title: title,
artist: "Artist",
album: "Album",
durationSeconds: 180,
localFilePath: path,
sha256: sha256,
uploadStatus: .localOnly,
remoteTrackId: nil,
lastUploadError: nil,
fileModifiedAt: observedAt,
lastScannedAt: observedAt,
isDeleted: false,
deletedAt: nil,
createdAt: observedAt,
updatedAt: observedAt
)
}
private func assertActorIsolation<T: Actor>(_ value: T) {
XCTAssertNotNil(value as AnyObject)
}
}