Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: retry queue carries attempts/classification across runs

RetryQueue now stores per-asset entries with classification, attempts,
firstFailedAt, lastFailedAt, and lastMessage. Merging across runs
preserves firstFailedAt and bumps attempts, so the UI can surface how
long an asset has been stuck.

Legacy `failedUUIDs: [String]` payloads decode transparently — existing
retry-queue files are upgraded on next write.

Also normalize PhotoKit's full-path identifiers (UUID/L0/001) to bare
UUIDs when appending to report.errors, so the retry-first partitioning
actually matches on the next run.

+324 -16
+10
CHANGELOG.md
··· 17 17 - `BackupProgressDelegate.concurrencyChanged(limit:)` — new delegate callback 18 18 emitted between batches whenever the controller adjusts. 19 19 - Terminal dashboard shows the current lane count next to upload speed. 20 + - Retry queue schema upgrade: each entry now tracks `classification`, 21 + `attempts`, `firstFailedAt`, `lastFailedAt`, and `lastMessage`. Merging 22 + across runs preserves `firstFailedAt` and increments `attempts`, so the 23 + UI can surface how long an asset has been stuck. The legacy 24 + `failedUUIDs: [String]` payload decodes transparently — existing stores 25 + are upgraded on next write. 26 + - `BackupUpload` now normalizes PhotoKit's full-path identifiers 27 + (`UUID/L0/001`) to bare UUIDs before appending to `report.errors`, so 28 + the retry-first partition actually matches failed assets on the next 29 + run. 20 30 - Bumps LadderKit dependency to 0.5.0 for adaptive export and local 21 31 availability APIs. 22 32
+29 -8
Sources/AtticCore/BackupPipeline.swift
··· 111 111 var report = BackupReport() 112 112 var sinceLastSave = 0 113 113 var deferred: [String] = [] 114 + // Classifications for the subset of failures LadderKit reports. Everything 115 + // else (upload errors, network timeouts) defaults to `.other` when the 116 + // retry queue is written. 117 + var failureClassifications: [String: ExportClassification] = [:] 118 + 119 + func normalizeUUID(_ uuid: String) -> String { 120 + uuid.split(separator: "/").first.map(String.init) ?? uuid 121 + } 114 122 115 123 let ctx = UploadContext( 116 124 assetByUUID: assetByUUID, ··· 215 223 } 216 224 } 217 225 for err in combinedErrors { 226 + failureClassifications[normalizeUUID(err.uuid)] = err.classification 218 227 _ = recordIfUnavailable(err) 219 228 } 220 229 let combined = ExportResponse(results: combinedResults, errors: combinedErrors) ··· 250 259 } 251 260 252 261 for err in batchResult.errors { 262 + failureClassifications[normalizeUUID(err.uuid)] = err.classification 253 263 _ = recordIfUnavailable(err) 254 264 } 255 265 ··· 268 278 do { 269 279 let result = try await exporter.exportBatch(uuids: [uuid]) 270 280 for err in result.errors { 281 + failureClassifications[normalizeUUID(err.uuid)] = err.classification 271 282 _ = recordIfUnavailable(err) 272 283 } 273 284 try await uploadExported( ··· 307 318 debugPrint("Failed to save unavailable assets store: \(error)") 308 319 } 309 320 310 - // Update retry queue: save failed UUIDs for next run, or clear on full success. 311 - // Exclude UUIDs we just marked unavailable — retrying them is futile. 312 - let retryableErrors = report.errors.filter { !unavailable.contains($0.uuid) } 321 + // Update retry queue: merge this run's failures into the previous queue so 322 + // attempt counts and firstFailedAt survive across runs. UUIDs we just 323 + // marked unavailable are excluded — retrying them is futile. 324 + let retryableErrors = report.errors.filter { !unavailable.contains(normalizeUUID($0.uuid)) } 313 325 if retryableErrors.isEmpty { 314 326 do { 315 327 try retryQueue?.clear() ··· 317 329 debugPrint("Failed to clear retry queue: \(error)") 318 330 } 319 331 } else { 320 - let failedUUIDs = retryableErrors.map(\.uuid) 321 - let queue = RetryQueue( 322 - failedUUIDs: failedUUIDs, 323 - updatedAt: formatISO8601(Date()), 332 + let now = formatISO8601(Date()) 333 + let failures: [FailureRecord] = retryableErrors.map { entry in 334 + let bare = normalizeUUID(entry.uuid) 335 + return FailureRecord( 336 + uuid: bare, 337 + classification: failureClassifications[bare] ?? .other, 338 + message: entry.message, 339 + ) 340 + } 341 + let merged = RetryQueue.merged( 342 + previous: retryQueue?.load(), 343 + failures: failures, 344 + now: now, 324 345 ) 325 346 do { 326 - try retryQueue?.save(queue) 347 + try retryQueue?.save(merged) 327 348 } catch { 328 349 debugPrint("Failed to save retry queue: \(error)") 329 350 }
+8 -4
Sources/AtticCore/BackupUpload.swift
··· 25 25 sinceLastSave: inout Int, 26 26 pauseRetryCount: Int = 0, 27 27 ) async throws { 28 - // Record export errors 28 + // Record export errors. LadderKit reports full PhotoKit identifiers 29 + // ("UUID/L0/001"); normalize to bare UUID so retry partitioning and the 30 + // assetByUUID lookup line up with the pending list. 29 31 for err in batchResult.errors { 30 - let filename = ctx.assetByUUID[err.uuid]?.originalFilename ?? err.uuid 31 - ctx.progress.assetFailed(uuid: err.uuid, filename: filename, message: err.message) 32 - report.appendError(uuid: err.uuid, message: err.message) 32 + let bareUUID = err.uuid.split(separator: "/").first.map(String.init) ?? err.uuid 33 + let asset = ctx.assetByUUID[bareUUID] ?? ctx.assetByUUID[err.uuid] 34 + let filename = asset?.originalFilename ?? bareUUID 35 + ctx.progress.assetFailed(uuid: bareUUID, filename: filename, message: err.message) 36 + report.appendError(uuid: bareUUID, message: err.message) 33 37 report.failed += 1 34 38 } 35 39
+171 -4
Sources/AtticCore/RetryQueue.swift
··· 1 1 import Foundation 2 + import LadderKit 2 3 3 - /// UUIDs that failed in the most recent backup run, persisted for retry-first priority. 4 - public struct RetryQueue: Codable, Sendable { 5 - public var failedUUIDs: [String] 4 + /// Per-asset retry bookkeeping. Tracks how long a UUID has been failing so 5 + /// the UI (and future policy like "give up after N runs") can act on it. 6 + public struct RetryEntry: Codable, Sendable, Equatable { 7 + public var uuid: String 8 + public var classification: ExportClassification 9 + public var attempts: Int 10 + public var firstFailedAt: String 11 + public var lastFailedAt: String 12 + public var lastMessage: String? 13 + 14 + public init( 15 + uuid: String, 16 + classification: ExportClassification = .other, 17 + attempts: Int = 1, 18 + firstFailedAt: String, 19 + lastFailedAt: String, 20 + lastMessage: String? = nil, 21 + ) { 22 + self.uuid = uuid 23 + self.classification = classification 24 + self.attempts = attempts 25 + self.firstFailedAt = firstFailedAt 26 + self.lastFailedAt = lastFailedAt 27 + self.lastMessage = lastMessage 28 + } 29 + 30 + private enum CodingKeys: String, CodingKey { 31 + case uuid, classification, attempts, firstFailedAt, lastFailedAt, lastMessage 32 + } 33 + 34 + public init(from decoder: Decoder) throws { 35 + let container = try decoder.container(keyedBy: CodingKeys.self) 36 + uuid = try container.decode(String.self, forKey: .uuid) 37 + attempts = try container.decodeIfPresent(Int.self, forKey: .attempts) ?? 1 38 + firstFailedAt = try container.decodeIfPresent(String.self, forKey: .firstFailedAt) ?? "" 39 + lastFailedAt = try container.decodeIfPresent(String.self, forKey: .lastFailedAt) ?? firstFailedAt 40 + lastMessage = try container.decodeIfPresent(String.self, forKey: .lastMessage) 41 + classification = try container.decodeIfPresent( 42 + ExportClassification.self, forKey: .classification, 43 + ) ?? .other 44 + } 45 + 46 + public func encode(to encoder: Encoder) throws { 47 + var container = encoder.container(keyedBy: CodingKeys.self) 48 + try container.encode(uuid, forKey: .uuid) 49 + try container.encode(classification, forKey: .classification) 50 + try container.encode(attempts, forKey: .attempts) 51 + try container.encode(firstFailedAt, forKey: .firstFailedAt) 52 + try container.encode(lastFailedAt, forKey: .lastFailedAt) 53 + try container.encodeIfPresent(lastMessage, forKey: .lastMessage) 54 + } 55 + } 56 + 57 + /// Assets that failed in recent runs, persisted so the next run retries them 58 + /// first and the UI can surface how long something's been stuck. 59 + public struct RetryQueue: Codable, Sendable, Equatable { 60 + public var entries: [RetryEntry] 6 61 public var updatedAt: String 7 62 63 + public init(entries: [RetryEntry], updatedAt: String) { 64 + self.entries = entries 65 + self.updatedAt = updatedAt 66 + } 67 + 68 + /// Convenience initializer for call sites that only care about UUIDs 69 + /// (mainly tests). Each UUID gets a fresh entry with attempts = 1. 8 70 public init(failedUUIDs: [String], updatedAt: String) { 9 - self.failedUUIDs = failedUUIDs 71 + self.entries = failedUUIDs.map { 72 + RetryEntry( 73 + uuid: $0, 74 + classification: .other, 75 + attempts: 1, 76 + firstFailedAt: updatedAt, 77 + lastFailedAt: updatedAt, 78 + ) 79 + } 10 80 self.updatedAt = updatedAt 81 + } 82 + 83 + /// UUIDs in insertion order. Used by the pipeline to partition pending 84 + /// assets so failed ones are retried first. 85 + public var failedUUIDs: [String] { 86 + entries.map(\.uuid) 87 + } 88 + 89 + private enum CodingKeys: String, CodingKey { 90 + case entries, failedUUIDs, updatedAt 91 + } 92 + 93 + public init(from decoder: Decoder) throws { 94 + let container = try decoder.container(keyedBy: CodingKeys.self) 95 + let decodedUpdatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) ?? "" 96 + 97 + let decodedEntries: [RetryEntry] 98 + if let entries = try container.decodeIfPresent([RetryEntry].self, forKey: .entries) { 99 + decodedEntries = entries 100 + } else if let legacy = try container.decodeIfPresent([String].self, forKey: .failedUUIDs) { 101 + // Migrate from pre-beta.6 schema: `failedUUIDs: [String]`. 102 + decodedEntries = legacy.map { 103 + RetryEntry( 104 + uuid: $0, 105 + classification: .other, 106 + attempts: 1, 107 + firstFailedAt: decodedUpdatedAt, 108 + lastFailedAt: decodedUpdatedAt, 109 + ) 110 + } 111 + } else { 112 + decodedEntries = [] 113 + } 114 + 115 + updatedAt = decodedUpdatedAt 116 + entries = decodedEntries 117 + } 118 + 119 + public func encode(to encoder: Encoder) throws { 120 + var container = encoder.container(keyedBy: CodingKeys.self) 121 + try container.encode(entries, forKey: .entries) 122 + try container.encode(updatedAt, forKey: .updatedAt) 123 + } 124 + 125 + /// Merge a new set of failures into a previous queue. 126 + /// 127 + /// - Assets that failed again have their `attempts` incremented and 128 + /// `lastFailedAt`/`lastMessage` refreshed; `firstFailedAt` is preserved. 129 + /// - Brand-new failing UUIDs start at `attempts = 1`. 130 + /// - UUIDs that aren't in the new failure set drop out entirely — they 131 + /// either succeeded or were classified as permanently unavailable and 132 + /// live in the unavailable store now. 133 + public static func merged( 134 + previous: RetryQueue?, 135 + failures: [FailureRecord], 136 + now: String, 137 + ) -> RetryQueue { 138 + let priorByUUID: [String: RetryEntry] = Dictionary( 139 + uniqueKeysWithValues: previous?.entries.map { ($0.uuid, $0) } ?? [], 140 + ) 141 + 142 + let entries: [RetryEntry] = failures.map { failure in 143 + if let prior = priorByUUID[failure.uuid] { 144 + return RetryEntry( 145 + uuid: failure.uuid, 146 + classification: failure.classification, 147 + attempts: prior.attempts + 1, 148 + firstFailedAt: prior.firstFailedAt, 149 + lastFailedAt: now, 150 + lastMessage: failure.message, 151 + ) 152 + } 153 + return RetryEntry( 154 + uuid: failure.uuid, 155 + classification: failure.classification, 156 + attempts: 1, 157 + firstFailedAt: now, 158 + lastFailedAt: now, 159 + lastMessage: failure.message, 160 + ) 161 + } 162 + 163 + return RetryQueue(entries: entries, updatedAt: now) 164 + } 165 + } 166 + 167 + /// A single failure as seen by the pipeline — richer than `BackupReport.errors` 168 + /// because it carries the classification used by `RetryQueue.merged`. 169 + public struct FailureRecord: Sendable, Equatable { 170 + public var uuid: String 171 + public var classification: ExportClassification 172 + public var message: String 173 + 174 + public init(uuid: String, classification: ExportClassification, message: String) { 175 + self.uuid = uuid 176 + self.classification = classification 177 + self.message = message 11 178 } 12 179 } 13 180
+106
Tests/AtticCoreTests/RetryQueueTests.swift
··· 1 1 @testable import AtticCore 2 2 import Foundation 3 + import LadderKit 3 4 import Testing 4 5 5 6 struct RetryQueueTests { ··· 66 67 67 68 let loaded = store.load() 68 69 #expect(loaded?.failedUUIDs == ["new-1", "new-2"]) 70 + } 71 + 72 + @Test("Legacy `failedUUIDs: [String]` payload decodes into entries") 73 + func decodesLegacySchema() throws { 74 + let json = """ 75 + { 76 + "failedUUIDs": ["uuid-1", "uuid-2"], 77 + "updatedAt": "2025-01-15T12:00:00Z" 78 + } 79 + """ 80 + let data = Data(json.utf8) 81 + let queue = try JSONDecoder().decode(RetryQueue.self, from: data) 82 + 83 + #expect(queue.failedUUIDs == ["uuid-1", "uuid-2"]) 84 + #expect(queue.entries.count == 2) 85 + #expect(queue.entries[0].attempts == 1) 86 + #expect(queue.entries[0].classification == .other) 87 + #expect(queue.entries[0].firstFailedAt == "2025-01-15T12:00:00Z") 88 + } 89 + 90 + @Test("New schema roundtrips with classification, attempts, and timestamps") 91 + func roundtripsNewSchema() throws { 92 + let entry = RetryEntry( 93 + uuid: "uuid-1", 94 + classification: .transientCloud, 95 + attempts: 3, 96 + firstFailedAt: "2025-01-01T00:00:00Z", 97 + lastFailedAt: "2025-01-03T00:00:00Z", 98 + lastMessage: "throttled", 99 + ) 100 + let queue = RetryQueue(entries: [entry], updatedAt: "2025-01-03T00:00:00Z") 101 + 102 + let data = try JSONEncoder().encode(queue) 103 + let decoded = try JSONDecoder().decode(RetryQueue.self, from: data) 104 + 105 + #expect(decoded.entries == [entry]) 106 + #expect(decoded.updatedAt == "2025-01-03T00:00:00Z") 107 + } 108 + 109 + @Test("`merged` preserves firstFailedAt and increments attempts across runs") 110 + func mergedIncrementsAttempts() { 111 + let previous = RetryQueue( 112 + entries: [ 113 + RetryEntry( 114 + uuid: "uuid-1", 115 + classification: .transientCloud, 116 + attempts: 2, 117 + firstFailedAt: "2025-01-01T00:00:00Z", 118 + lastFailedAt: "2025-01-02T00:00:00Z", 119 + lastMessage: "throttled", 120 + ), 121 + ], 122 + updatedAt: "2025-01-02T00:00:00Z", 123 + ) 124 + let failures = [ 125 + FailureRecord(uuid: "uuid-1", classification: .transientCloud, message: "still throttled"), 126 + FailureRecord(uuid: "uuid-2", classification: .other, message: "upload failed"), 127 + ] 128 + 129 + let merged = RetryQueue.merged( 130 + previous: previous, 131 + failures: failures, 132 + now: "2025-01-03T00:00:00Z", 133 + ) 134 + 135 + let byUUID = Dictionary(uniqueKeysWithValues: merged.entries.map { ($0.uuid, $0) }) 136 + #expect(byUUID["uuid-1"]?.attempts == 3) 137 + #expect(byUUID["uuid-1"]?.firstFailedAt == "2025-01-01T00:00:00Z") 138 + #expect(byUUID["uuid-1"]?.lastFailedAt == "2025-01-03T00:00:00Z") 139 + #expect(byUUID["uuid-1"]?.lastMessage == "still throttled") 140 + 141 + #expect(byUUID["uuid-2"]?.attempts == 1) 142 + #expect(byUUID["uuid-2"]?.firstFailedAt == "2025-01-03T00:00:00Z") 143 + } 144 + 145 + @Test("`merged` drops UUIDs that aren't in the new failure set") 146 + func mergedDropsResolvedUUIDs() { 147 + let previous = RetryQueue( 148 + failedUUIDs: ["uuid-1", "uuid-2"], 149 + updatedAt: "2025-01-01T00:00:00Z", 150 + ) 151 + 152 + let merged = RetryQueue.merged( 153 + previous: previous, 154 + failures: [FailureRecord(uuid: "uuid-2", classification: .other, message: "still failing")], 155 + now: "2025-01-02T00:00:00Z", 156 + ) 157 + 158 + #expect(merged.failedUUIDs == ["uuid-2"]) 159 + } 160 + 161 + @Test("`merged` with nil previous starts everything at attempts = 1") 162 + func mergedFromNothing() { 163 + let merged = RetryQueue.merged( 164 + previous: nil, 165 + failures: [ 166 + FailureRecord(uuid: "uuid-1", classification: .transientCloud, message: "boom"), 167 + ], 168 + now: "2025-02-01T00:00:00Z", 169 + ) 170 + 171 + #expect(merged.entries.count == 1) 172 + #expect(merged.entries[0].attempts == 1) 173 + #expect(merged.entries[0].firstFailedAt == "2025-02-01T00:00:00Z") 174 + #expect(merged.entries[0].classification == .transientCloud) 69 175 } 70 176 }