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.

fix: retry queue preserves unattempted UUIDs across runs

When --limit cut a run short, a successful result would wipe the entire
retry queue — including UUIDs that were never attempted in that run.
Their attempts/firstFailedAt history was lost and they lost their
retry-first priority on the next run.

RetryQueue.merged now takes the attempted set. Prior entries whose UUID
isn't in that set are carried forward unchanged. Attempted UUIDs drop
(success) or merge (failure) as before. Unlimited `attic backup` runs
still clear the queue fully on full success, because `attempted` covers
the whole pending list.

+97 -46
+4
CHANGELOG.md
··· 25 25 UI can surface how long an asset has been stuck. The legacy 26 26 `failedUUIDs: [String]` payload decodes transparently — existing stores 27 27 are upgraded on next write. 28 + - **Retry queue no longer loses unattempted UUIDs.** When `--limit` cut a 29 + run short, a successful result would wipe the entire queue, including 30 + UUIDs that were never tried. The merge now keys on the attempted set: 31 + unattempted entries survive with their full history. 28 32 - `BackupUpload` now normalizes PhotoKit's full-path identifiers 29 33 (`UUID/L0/001`) to bare UUIDs before appending to `report.errors`, so 30 34 the retry-first partition actually matches failed assets on the next
+25 -25
Sources/AtticCore/BackupPipeline.swift
··· 318 318 debugPrint("Failed to save unavailable assets store: \(error)") 319 319 } 320 320 321 - // Update retry queue: merge this run's failures into the previous queue so 321 + // Update retry queue: merge this run's outcome into the previous queue so 322 322 // attempt counts and firstFailedAt survive across runs. UUIDs we just 323 - // marked unavailable are excluded — retrying them is futile. 323 + // marked unavailable are excluded — retrying them is futile. UUIDs in 324 + // the prior queue that weren't attempted this run (cut off by --limit) 325 + // are preserved so their history doesn't get wiped. 324 326 let retryableErrors = report.errors.filter { !unavailable.contains(normalizeUUID($0.uuid)) } 325 - if retryableErrors.isEmpty { 326 - do { 327 - try retryQueue?.clear() 328 - } catch { 329 - debugPrint("Failed to clear retry queue: \(error)") 330 - } 331 - } else { 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, 327 + let now = formatISO8601(Date()) 328 + let attempted = Set(pending.map(\.uuid)) 329 + let failures: [FailureRecord] = retryableErrors.map { entry in 330 + let bare = normalizeUUID(entry.uuid) 331 + return FailureRecord( 332 + uuid: bare, 333 + classification: failureClassifications[bare] ?? .other, 334 + message: entry.message, 345 335 ) 346 - do { 336 + } 337 + let merged = RetryQueue.merged( 338 + previous: retryQueue?.load(), 339 + attempted: attempted, 340 + failures: failures, 341 + now: now, 342 + ) 343 + do { 344 + if merged.entries.isEmpty { 345 + try retryQueue?.clear() 346 + } else { 347 347 try retryQueue?.save(merged) 348 - } catch { 349 - debugPrint("Failed to save retry queue: \(error)") 350 348 } 349 + } catch { 350 + debugPrint("Failed to update retry queue: \(error)") 351 351 } 352 352 353 353 progress.backupCompleted(
+28 -20
Sources/AtticCore/RetryQueue.swift
··· 122 122 try container.encode(updatedAt, forKey: .updatedAt) 123 123 } 124 124 125 - /// Merge a new set of failures into a previous queue. 125 + /// Merge a run's outcome into a previous queue. 126 126 /// 127 - /// - Assets that failed again have their `attempts` incremented and 128 - /// `lastFailedAt`/`lastMessage` refreshed; `firstFailedAt` is preserved. 127 + /// - `attempted` is the set of bare UUIDs actually processed in this run. 128 + /// UUIDs in the previous queue that weren't attempted (typically cut 129 + /// off by `--limit`) are carried forward unchanged so their 130 + /// `attempts` and `firstFailedAt` history survives. 131 + /// - UUIDs in `attempted` ∩ `failures` are merged: prior `attempts + 1`, 132 + /// preserved `firstFailedAt`, refreshed `lastFailedAt`/`lastMessage`. 133 + /// - UUIDs in `attempted` but not in `failures` are dropped — they 134 + /// succeeded this run, or landed in the unavailable store. 129 135 /// - 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 136 public static func merged( 134 137 previous: RetryQueue?, 138 + attempted: Set<String>, 135 139 failures: [FailureRecord], 136 140 now: String, 137 141 ) -> RetryQueue { 138 - let priorByUUID: [String: RetryEntry] = Dictionary( 139 - uniqueKeysWithValues: previous?.entries.map { ($0.uuid, $0) } ?? [], 140 - ) 142 + let priorEntries = previous?.entries ?? [] 143 + let priorByUUID = Dictionary(uniqueKeysWithValues: priorEntries.map { ($0.uuid, $0) }) 141 144 142 - let entries: [RetryEntry] = failures.map { failure in 145 + // Carry forward prior entries we didn't attempt. 146 + var entries = priorEntries.filter { !attempted.contains($0.uuid) } 147 + 148 + // Merge / create entries for new failures. 149 + for failure in failures { 143 150 if let prior = priorByUUID[failure.uuid] { 144 - return RetryEntry( 151 + entries.append(RetryEntry( 145 152 uuid: failure.uuid, 146 153 classification: failure.classification, 147 154 attempts: prior.attempts + 1, 148 155 firstFailedAt: prior.firstFailedAt, 149 156 lastFailedAt: now, 150 157 lastMessage: failure.message, 151 - ) 158 + )) 159 + } else { 160 + entries.append(RetryEntry( 161 + uuid: failure.uuid, 162 + classification: failure.classification, 163 + attempts: 1, 164 + firstFailedAt: now, 165 + lastFailedAt: now, 166 + lastMessage: failure.message, 167 + )) 152 168 } 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 169 } 162 170 163 171 return RetryQueue(entries: entries, updatedAt: now)
+40 -1
Tests/AtticCoreTests/RetryQueueTests.swift
··· 128 128 129 129 let merged = RetryQueue.merged( 130 130 previous: previous, 131 + attempted: ["uuid-1", "uuid-2"], 131 132 failures: failures, 132 133 now: "2025-01-03T00:00:00Z", 133 134 ) ··· 142 143 #expect(byUUID["uuid-2"]?.firstFailedAt == "2025-01-03T00:00:00Z") 143 144 } 144 145 145 - @Test("`merged` drops UUIDs that aren't in the new failure set") 146 + @Test("`merged` drops attempted UUIDs that succeeded this run") 146 147 func mergedDropsResolvedUUIDs() { 147 148 let previous = RetryQueue( 148 149 failedUUIDs: ["uuid-1", "uuid-2"], ··· 151 152 152 153 let merged = RetryQueue.merged( 153 154 previous: previous, 155 + attempted: ["uuid-1", "uuid-2"], 154 156 failures: [FailureRecord(uuid: "uuid-2", classification: .other, message: "still failing")], 155 157 now: "2025-01-02T00:00:00Z", 156 158 ) ··· 158 160 #expect(merged.failedUUIDs == ["uuid-2"]) 159 161 } 160 162 163 + @Test("`merged` preserves prior entries that weren't attempted this run") 164 + func mergedPreservesUnattemptedEntries() { 165 + let previous = RetryQueue( 166 + entries: [ 167 + RetryEntry( 168 + uuid: "not-attempted", 169 + classification: .transientCloud, 170 + attempts: 5, 171 + firstFailedAt: "2025-01-01T00:00:00Z", 172 + lastFailedAt: "2025-01-04T00:00:00Z", 173 + lastMessage: "still throttled", 174 + ), 175 + RetryEntry( 176 + uuid: "succeeded", 177 + classification: .other, 178 + attempts: 2, 179 + firstFailedAt: "2025-01-02T00:00:00Z", 180 + lastFailedAt: "2025-01-04T00:00:00Z", 181 + ), 182 + ], 183 + updatedAt: "2025-01-04T00:00:00Z", 184 + ) 185 + 186 + let merged = RetryQueue.merged( 187 + previous: previous, 188 + attempted: ["succeeded"], 189 + failures: [], 190 + now: "2025-01-05T00:00:00Z", 191 + ) 192 + 193 + #expect(merged.entries.count == 1) 194 + #expect(merged.entries[0].uuid == "not-attempted") 195 + #expect(merged.entries[0].attempts == 5) 196 + #expect(merged.entries[0].firstFailedAt == "2025-01-01T00:00:00Z") 197 + } 198 + 161 199 @Test("`merged` with nil previous starts everything at attempts = 1") 162 200 func mergedFromNothing() { 163 201 let merged = RetryQueue.merged( 164 202 previous: nil, 203 + attempted: ["uuid-1"], 165 204 failures: [ 166 205 FailureRecord(uuid: "uuid-1", classification: .transientCloud, message: "boom"), 167 206 ],