NavidromeApp/iOS/Data/OptimisticActionQueue.swift
Dallas Groot f3b9483b23 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

181 lines
5.9 KiB
Swift

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 }
retryTask?.cancel()
// @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 }
let actions = self.pendingActions
var remaining: [PendingAction] = []
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")
}
}
}
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
}
}