Serialize playlist mutations and re-resolve on credential change

Two changes to a server-side playlist could overlap: the second planned its
write from the snapshot the first had already invalidated, and on Piped —
where a row is a position — its preflight then passed over whatever had
shifted into the slot. Mutations now hold a per-playlist queue from the write
to the end of the refetch that reconciles it. Creation queues on the account's
list instead: it depends on no playlist's contents, and one queue per account
would only make unrelated changes wait.

The playlist detail view kept the authenticated snapshot in its own state, so
signing in as somebody else — or a change of Apple account — left the previous
login's rows, title and destructive affordances on screen. It now discards them
the moment either lands and loads again, which re-resolves ownership and falls
back to the public endpoint.

Also documents two residuals rather than pretending to close them: equal rows
are indistinguishable to a positional API, so verify-before-remove cannot
protect a particular occurrence of a repeated video; and the credential recheck
is a fence, not a lock, because Keychain state cannot be made atomic with a
remote send.
This commit is contained in:
Arkadiusz Fal
2026-09-03 20:43:21 +02:00
parent 2ba9f57d4c
commit cb858edd34
4 changed files with 236 additions and 31 deletions

View File

@@ -822,4 +822,75 @@ struct RemotePlaylistsCacheTests {
#expect(await harness.transport.callCount == 1)
#expect(cache.playlists(for: harness.account.id)?.count == 1)
}
@Test("A second removal waits for the first one's refetch before it preflights")
func removalsOnOnePlaylistAreSerialized() async throws {
let harness = AccountServiceHarness(type: .piped)
defer { harness.tearDown() }
let cache = harness.makeCache()
let snapshot = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .piped)
await harness.transport.setSnapshot(snapshot)
await harness.transport.holdCalls()
let first = Task { [account = harness.account] in
try await cache.removeEntry(snapshot.entries[0], fromPlaylist: "pl", for: account)
}
try await harness.transport.waitForCalls(1)
// Tapped while the first removal's preflight is still out. Its own
// preflight must not be sent yet: it would describe the playlist as it
// was *before* the first removal, and a Piped position taken from that
// names whatever shifts into the slot.
let second = Task { [account = harness.account] in
try await cache.removeEntry(snapshot.entries[1], fromPlaylist: "pl", for: account)
}
// Long enough for a second preflight to have been sent, had one been.
try await Task.sleep(for: .milliseconds(50))
#expect(await harness.transport.callCount == 1)
await harness.transport.release()
try await first.value
try await second.value
// Three calls each, in order, and never interleaved: preflight, remove,
// and the refetch that reconciles it.
#expect(await harness.transport.calls == [
.snapshot(id: "pl", credential: "secret-original"),
.remove(entryID: .piped(playlistID: "pl", position: 0), credential: "secret-original"),
.snapshot(id: "pl", credential: "secret-original"),
.snapshot(id: "pl", credential: "secret-original"),
.remove(entryID: .piped(playlistID: "pl", position: 1), credential: "secret-original"),
.snapshot(id: "pl", credential: "secret-original")
])
}
@Test("Removals from different playlists do not wait for each other")
func removalsOnDifferentPlaylistsInterleave() async throws {
let harness = AccountServiceHarness(type: .piped)
defer { harness.tearDown() }
let cache = harness.makeCache()
let one = makeSnapshot(playlistID: "one", videoIDs: ["aaa"], kind: .piped)
let two = makeSnapshot(playlistID: "two", videoIDs: ["aaa"], kind: .piped)
await harness.transport.setSnapshot(one)
await harness.transport.holdCalls()
let first = Task { [account = harness.account] in
try await cache.removeEntry(one.entries[0], fromPlaylist: "one", for: account)
}
try await harness.transport.waitForCalls(1)
let second = Task { [account = harness.account] in
try await cache.removeEntry(two.entries[0], fromPlaylist: "two", for: account)
}
// Both preflights are out at once: the queue is per playlist, not one
// for the whole account.
try await harness.transport.waitForCalls(2)
#expect(await harness.transport.callCount == 2)
await harness.transport.release()
try await first.value
try await second.value
}
}