NavidromeApp/iOS/Data/OptimisticActionQueue.swift

182 lines
5.9 KiB
Swift
Raw Normal View History

import Foundation
/// Handles optimistic UI updates mutations happen locally first,
/// then get pushed to the server with automatic retry.
///
/// Example: starring a song updates the local cache immediately,
/// then queues the API call. If the API fails (offline), it retries
/// on next sync or when connectivity returns.
class OptimisticActionQueue: ObservableObject {
static let shared = OptimisticActionQueue()
/// Pending actions that haven't been confirmed by the server
@Published var pendingCount: Int = 0
private var pendingActions: [PendingAction] = []
private let storageKey = "optimistic_pending_actions"
private var retryTask: Task<Void, Never>?
private init() {
loadPending()
}
// MARK: - Star / Unstar
/// Star a song optimistically updates cache immediately, queues API call
func star(songId: String) {
queueAction(.star(id: songId, type: .song))
DebugLogger.shared.log("Optimistic: starred song \(songId)", category: "Sync")
}
func unstar(songId: String) {
// Check if there's a pending star for this just remove it instead
if removePending(matching: .star(id: songId, type: .song)) {
DebugLogger.shared.log("Optimistic: cancelled pending star for \(songId)", category: "Sync")
return
}
queueAction(.unstar(id: songId, type: .song))
DebugLogger.shared.log("Optimistic: unstarred song \(songId)", category: "Sync")
}
func starAlbum(albumId: String) {
queueAction(.star(id: albumId, type: .album))
}
func unstarAlbum(albumId: String) {
if removePending(matching: .star(id: albumId, type: .album)) { return }
queueAction(.unstar(id: albumId, type: .album))
}
// MARK: - Scrobble
func scrobble(songId: String) {
queueAction(.scrobble(id: songId, timestamp: Int64(Date().timeIntervalSince1970 * 1000)))
}
// MARK: - Process Queue
/// Try to flush all pending actions to the server.
/// Call this on app launch, on connectivity change, or after sync.
func flush() {
guard !pendingActions.isEmpty else { return }
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
retryTask?.cancel()
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
// @MainActor ensures pendingActions is only touched on the main thread
// eliminates the data race between queueAction (main) and the flush Task (pool).
retryTask = Task { @MainActor [weak self] in
guard let self else { return }
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
let actions = self.pendingActions
var remaining: [PendingAction] = []
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
for action in actions {
do {
try await self.execute(action)
DebugLogger.shared.log("Flushed: \(action.description)", category: "Sync")
} catch {
if action.retryCount < 5 {
var retry = action
retry.retryCount += 1
remaining.append(retry)
} else {
DebugLogger.shared.log("Dropped after 5 retries: \(action.description)", category: "Sync")
}
}
}
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
self.pendingActions = remaining
self.pendingCount = remaining.count
self.savePending()
}
}
// MARK: - Execution
private func execute(_ action: PendingAction) async throws {
let client = ServerManager.shared.client
switch action.kind {
case .star(let id, let type):
switch type {
case .song: try await client.star(id: id)
case .album: try await client.star(albumId: id)
case .artist: try await client.star(artistId: id)
}
case .unstar(let id, let type):
switch type {
case .song: try await client.unstar(id: id)
case .album: try await client.unstar(albumId: id)
case .artist: try await client.unstar(artistId: id)
}
case .scrobble(let id, let timestamp):
try await client.scrobble(id: id, time: timestamp)
}
}
// MARK: - Queue Management
private func queueAction(_ kind: ActionKind) {
let action = PendingAction(kind: kind, createdAt: Date(), retryCount: 0)
pendingActions.append(action)
pendingCount = pendingActions.count
savePending()
// Try immediately
flush()
}
private func removePending(matching kind: ActionKind) -> Bool {
if let idx = pendingActions.firstIndex(where: { $0.kind == kind }) {
pendingActions.remove(at: idx)
pendingCount = pendingActions.count
savePending()
return true
}
return false
}
// MARK: - Persistence
private func savePending() {
if let data = try? JSONEncoder().encode(pendingActions) {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
private func loadPending() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let actions = try? JSONDecoder().decode([PendingAction].self, from: data) {
pendingActions = actions
pendingCount = actions.count
}
}
}
// MARK: - Models
struct PendingAction: Codable {
let kind: ActionKind
let createdAt: Date
var retryCount: Int
var description: String {
switch kind {
case .star(let id, let type): return "star \(type.rawValue) \(id)"
case .unstar(let id, let type): return "unstar \(type.rawValue) \(id)"
case .scrobble(let id, _): return "scrobble \(id)"
}
}
}
enum ActionKind: Codable, Equatable {
case star(id: String, type: ItemType)
case unstar(id: String, type: ItemType)
case scrobble(id: String, timestamp: Int64)
enum ItemType: String, Codable {
case song, album, artist
}
}