Fix SwiftData persistence concurrency stability
This commit is contained in:
+146
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user