Files
workouts/Workouts/Sync/WriteBacklog.swift
T
rzen c05e83cff7 Queue document writes durably and surface sync trouble in the UI
iCloud Drive writes now flow through a persistent WriteBacklog sidecar
(drained with backoff, flushed on backgrounding, wiped with the cache on
account change), so a save can never be lost to a transient coordinator
error. A status banner on the workout list surfaces stuck syncing.
Also: the split picker gains a Recent section with day labels, split
rows fold SplitItem into SplitListView, and list rows dim the multiply
sign in sets-by-reps.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-08 12:48:12 -04:00

204 lines
7.7 KiB
Swift

//
// WriteBacklog.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// One queued write: the latest pending version of a single document (or its
/// deletion), plus retry bookkeeping. `timestamp` is the document's `updatedAt`
/// (a delete's deletion stamp) — slot replacement and the import/reconcile
/// supersede checks key on it, newer wins.
struct PendingWrite: Codable, Sendable {
enum Payload: Codable, Sendable {
case split(SplitDocument)
case workout(WorkoutDocument)
case delete(id: String, kind: String, livePath: String)
}
var payload: Payload
var timestamp: Date
/// Paths this document previously lived at (month re-bucketing, replaced
/// pending versions, a delete that superseded a queued save) — best-effort
/// removed after the write lands so the id never survives at two paths.
var stalePaths: Set<String> = []
/// When the slot was first occupied — replacement keeps it, so the TTL
/// measures how long the user's intent has been stuck, not the last edit.
var enqueuedAt: Date
var attempts: Int = 0
var lastAttemptAt: Date?
/// Set when an attempt failed with an error retrying can't fix (disk full).
/// The drain keeps retrying regardless — conditions can change — but the
/// banner escalates immediately.
var faultMessage: String?
var documentID: String {
switch payload {
case .split(let doc): doc.id
case .workout(let doc): doc.id
case .delete(let id, _, _): id
}
}
/// Where a successful write lands; nil for deletes (they only remove).
var targetPath: String? {
switch payload {
case .split(let doc): doc.relativePath
case .workout(let doc): doc.relativePath
case .delete: nil
}
}
var nextAttemptAt: Date {
guard let lastAttemptAt else { return enqueuedAt }
return lastAttemptAt.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: attempts))
}
}
/// Depth-1 per-document write queue: at most one pending write per document id,
/// newest `updatedAt` wins the slot. Pure state + policy — no I/O, no clock of
/// its own — so the whole replacement/retry/TTL table is unit-testable.
/// `SyncEngine` owns the drain loop and persistence.
struct WriteBacklog: Codable, Sendable {
private(set) var slots: [String: PendingWrite] = [:]
var isEmpty: Bool { slots.isEmpty }
var count: Int { slots.count }
/// Highest attempt count across pending writes — drives the banner tiers.
var maxAttempts: Int { slots.values.map(\.attempts).max() ?? 0 }
/// The oldest entry's fault, if any entry has hit an unrecoverable error.
var firstFaultMessage: String? {
entries.compactMap(\.faultMessage).first
}
/// All pending writes, oldest slot first.
var entries: [PendingWrite] {
slots.values.sorted { $0.enqueuedAt < $1.enqueuedAt }
}
func pendingWrite(for id: String) -> PendingWrite? { slots[id] }
/// Newer-wins slot replacement. An incoming write at least as new as the
/// occupant takes the slot (equal = the latest local re-save of the same
/// state; content-identical, so last-in is right). It inherits the
/// occupant's retry bookkeeping — the backlog is failing for environmental
/// reasons, and resetting attempts on every edit would flap the banner —
/// plus its stale paths and its target path when the two differ (the
/// occupant was never written, but an earlier attempt may have landed).
/// An incoming write strictly older than the occupant is dropped.
mutating func enqueue(_ write: PendingWrite) {
let id = write.documentID
guard let existing = slots[id] else {
slots[id] = write
return
}
guard write.timestamp >= existing.timestamp else { return }
var merged = write
merged.stalePaths.formUnion(existing.stalePaths)
if let oldPath = existing.targetPath, oldPath != merged.targetPath {
merged.stalePaths.insert(oldPath)
}
merged.enqueuedAt = existing.enqueuedAt
merged.attempts = existing.attempts
merged.lastAttemptAt = existing.lastAttemptAt
merged.faultMessage = existing.faultMessage
slots[id] = merged
}
mutating func markFailed(id: String, at now: Date, fault: String? = nil) {
guard var entry = slots[id] else { return }
entry.attempts += 1
entry.lastAttemptAt = now
if let fault { entry.faultMessage = fault }
slots[id] = entry
}
/// Remove after a successful write — replacement-safe: a newer write that
/// slotted in while this one was in flight stays queued.
mutating func resolve(id: String, ifTimestampAtMost timestamp: Date) {
guard let entry = slots[id], entry.timestamp <= timestamp else { return }
slots[id] = nil
}
mutating func removeAll() { slots.removeAll() }
/// Drop entries stuck longer than `ttl` — applied when a persisted backlog
/// is loaded, so a slot from a long-dead run can't drain stale state into a
/// container that has long since moved on.
mutating func pruneExpired(now: Date, ttl: TimeInterval = 24 * 60 * 60) {
slots = slots.filter { now.timeIntervalSince($0.value.enqueuedAt) < ttl }
}
/// The next entry whose backoff has elapsed, oldest slot first.
func nextDue(at now: Date) -> PendingWrite? {
entries.first { $0.nextAttemptAt <= now }
}
func earliestNextAttempt() -> Date? {
slots.values.map(\.nextAttemptAt).min()
}
/// Backoff schedule: three quick silent retries (tier 0, ~14 s total), then
/// calm long retries capped at 5 minutes.
static func retryDelay(afterAttempts attempts: Int) -> TimeInterval {
switch attempts {
case ..<1: 0
case 1: 2
case 2: 4
case 3: 8
case 4: 30
case 5: 60
case 6: 120
default: 300
}
}
/// Attempts after which the calm "changes pending" banner shows (~14 s of
/// silent retries have failed).
static let tier1AttemptThreshold = 3
/// Attempts after which the backlog is treated as a fault even without an
/// unrecoverable error (~10 minutes of failures).
static let tier2AttemptThreshold = 8
}
/// UI-facing rollup of the backlog — the banner tiers.
enum WriteQueueState: Equatable {
/// Nothing queued.
case idle
/// Writes queued inside the silent tier-0 window — no UI.
case pending
/// Tier 1: writes keep failing; calm "changes pending" banner.
case retrying
/// Tier 2: unrecoverable error or backoff exhaustion; prominent banner
/// pointing at Diagnostics.
case fault(String)
}
/// Load/persist the backlog sidecar file. Failures are non-fatal by design: an
/// unreadable file loads as an empty backlog (worst case, queued-but-unwritten
/// edits from a previous run are lost — the same loss the queue exists to
/// narrow, never corruption), and a failed persist leaves the previous file in
/// place for the in-memory backlog to overwrite on the next change.
enum WriteBacklogFile {
static func load(from url: URL) -> WriteBacklog {
guard let data = try? Data(contentsOf: url),
let backlog = try? JSONDecoder().decode(WriteBacklog.self, from: data) else {
return WriteBacklog()
}
return backlog
}
static func save(_ backlog: WriteBacklog, to url: URL) {
if backlog.isEmpty {
try? FileManager.default.removeItem(at: url)
return
}
guard let data = try? JSONEncoder().encode(backlog) else { return }
try? data.write(to: url, options: .atomic)
}
}