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: adaptive backup lanes wired into CLI (1.0.0-beta.6)

- BackupCommand builds a PhotosDatabaseLocalAvailability + AIMDController
and passes both to LadderKitExportProvider, so the backup now runs a
fast local lane and a throttled iCloud lane.
- runBackup takes an optional AdaptiveConcurrencyControlling, polls it
between batches, and emits BackupProgressDelegate.concurrencyChanged
when the limit shifts.
- TerminalRenderer shows lane count beside upload speed; the log
delegate prints a single line when concurrency changes.

+97 -5
+20
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 1.0.0-beta.6 4 + 5 + Adaptive export: separate local-cache and iCloud lanes, with the iCloud lane 6 + throttled by an AIMD controller when PhotoKit pushes back. 7 + 8 + - `AIMDController` (in AtticCore) — additive-increase / multiplicative-decrease 9 + concurrency policy with a sliding 20-outcome window. Backs off the limit by 10 + half when the transient-failure rate exceeds 30%, grows it by 1 when the 11 + rate drops to 5% or below, and clears the window on every limit change so 12 + stale pre-change outcomes don't immediately re-trigger. 13 + - `BackupCommand` wires `PhotosDatabaseLocalAvailability` (from Photos.sqlite's 14 + `ZLOCALAVAILABILITY` flag) + an `AIMDController` into the exporter. Local 15 + assets run at full `maxConcurrency`; iCloud assets are gated by the 16 + controller. 17 + - `BackupProgressDelegate.concurrencyChanged(limit:)` — new delegate callback 18 + emitted between batches whenever the controller adjusts. 19 + - Terminal dashboard shows the current lane count next to upload speed. 20 + - Bumps LadderKit dependency to 0.5.0 for adaptive export and local 21 + availability APIs. 22 + 3 23 ## 1.0.0-beta.5 4 24 5 25 - **Shared-album unavailable tracking** — assets in iCloud Shared Albums whose
+15 -1
Sources/AtticCLI/BackupCommand.swift
··· 45 45 let stagingDir = FileConfigProvider.defaultDirectory.appendingPathComponent("staging") 46 46 try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) 47 47 48 - let exporter = LadderKitExportProvider(stagingDir: stagingDir) 48 + // Adaptive export: partition by local vs iCloud availability, and let 49 + // the AIMD controller throttle the iCloud lane when PhotoKit pushes back. 50 + let localAvailability = Dependencies.loadLocalAvailability() 51 + let adaptiveController = AIMDController() 52 + 53 + let exporter = LadderKitExportProvider( 54 + stagingDir: stagingDir, 55 + localAvailability: localAvailability, 56 + adaptiveController: adaptiveController, 57 + ) 49 58 50 59 // Pre-flight permission check 51 60 try await exporter.checkPermissions() ··· 77 86 networkMonitor: NWPathNetworkMonitor(), 78 87 retryQueue: FileRetryQueueStore(), 79 88 unavailableStore: FileUnavailableAssetStore(), 89 + adaptiveController: adaptiveController, 80 90 ) 81 91 82 92 _ = powerAssertion // prevent unused warning, released in deinit ··· 129 139 130 140 func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) { 131 141 print("Done: \(uploaded) uploaded, \(failed) failed (\(formatBytes(totalBytes)))") 142 + } 143 + 144 + func concurrencyChanged(limit: Int) { 145 + print(" ⚙ Concurrency → \(limit) lane\(limit == 1 ? "" : "s")") 132 146 } 133 147 }
+11
Sources/AtticCLI/Dependencies.swift
··· 74 74 75 75 return assets 76 76 } 77 + 78 + /// Load the set of asset UUIDs whose originals are cached locally (fast 79 + /// lane). Returns `nil` if the Photos.sqlite can't be located or read — the 80 + /// exporter then treats everything as cloud-only, which is safe but slower. 81 + static func loadLocalAvailability() -> (any LocalAvailabilityProviding)? { 82 + guard let dbPath = PhotosLibraryPath.databasePath(for: defaultLibraryURL) else { 83 + return nil 84 + } 85 + let localUUIDs = PhotosDatabase.localAvailableUUIDs(dbPath: dbPath) 86 + return PhotosDatabaseLocalAvailability(localUUIDs: localUUIDs) 87 + } 77 88 } 78 89 79 90 enum CLIError: Error, CustomStringConvertible {
+15 -2
Sources/AtticCLI/LadderKitExportProvider.swift
··· 5 5 /// Bridges LadderKit's PhotoExporter to AtticCore's ExportProviding protocol. 6 6 struct LadderKitExportProvider: ExportProviding { 7 7 private let exporter: PhotoExporter 8 + private let localAvailability: (any LocalAvailabilityProviding)? 9 + private let adaptiveController: (any AdaptiveConcurrencyControlling)? 8 10 9 - init(stagingDir: URL, library: PhotoLibrary = PhotoKitLibrary()) { 11 + init( 12 + stagingDir: URL, 13 + library: PhotoLibrary = PhotoKitLibrary(), 14 + localAvailability: (any LocalAvailabilityProviding)? = nil, 15 + adaptiveController: (any AdaptiveConcurrencyControlling)? = nil, 16 + ) { 10 17 exporter = PhotoExporter( 11 18 stagingDir: stagingDir, 12 19 library: library, 13 20 scriptExporter: AppleScriptRunner(), 14 21 ) 22 + self.localAvailability = localAvailability 23 + self.adaptiveController = adaptiveController 15 24 } 16 25 17 26 func exportBatch(uuids: [String]) async throws -> ExportResponse { 18 - await exporter.export(uuids: uuids) 27 + await exporter.export( 28 + uuids: uuids, 29 + localAvailability: localAvailability, 30 + adaptiveController: adaptiveController, 31 + ) 19 32 } 20 33 21 34 func checkPermissions() async throws {
+11 -1
Sources/AtticCLI/TerminalRenderer.swift
··· 53 53 var pauseStarted: Date? 54 54 var totalPauseDuration: TimeInterval = 0 55 55 var failedAssets: [(filename: String, message: String)] = [] 56 + var concurrencyLimit: Int? 56 57 } 57 58 58 59 // MARK: - BackupProgressDelegate ··· 149 150 render() 150 151 } 151 152 153 + func concurrencyChanged(limit: Int) { 154 + lock.withLock { 155 + state.concurrencyLimit = limit 156 + } 157 + render() 158 + } 159 + 152 160 func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) { 153 161 tickTask?.cancel() 154 162 tickTask = nil ··· 191 199 lines.append(" Progress [\(bar)] \(completed)/\(s.total)") 192 200 lines.append(" Photos \(s.uploadedPhotos) uploaded") 193 201 lines.append(" Videos \(s.uploadedVideos) uploaded") 194 - lines.append(" Speed \(s.isPaused ? "—" : "\(formatBytes(Int(speed)))/s")") 202 + let speedText = s.isPaused ? "—" : "\(formatBytes(Int(speed)))/s" 203 + let lanesSuffix = s.concurrencyLimit.map { " · \($0) lane\($0 == 1 ? "" : "s")" } ?? "" 204 + lines.append(" Speed \(speedText)\(lanesSuffix)") 195 205 lines.append(" Errors \(s.failed)") 196 206 lines.append("") 197 207 if s.isPaused, let pauseStart = s.pauseStarted {
+1 -1
Sources/AtticCore/AtticCore.swift
··· 2 2 /// 3 3 /// Used by both the Attic CLI and the Attic menu bar app. 4 4 public enum AtticCore { 5 - public static let version = "1.0.0-beta.5" 5 + public static let version = "1.0.0-beta.6" 6 6 }
+18
Sources/AtticCore/BackupPipeline.swift
··· 64 64 networkMonitor: (any NetworkMonitoring)? = nil, 65 65 retryQueue: (any RetryQueueProviding)? = nil, 66 66 unavailableStore: (any UnavailableAssetStoring)? = nil, 67 + adaptiveController: (any AdaptiveConcurrencyControlling)? = nil, 67 68 ) async throws -> BackupReport { 68 69 var unavailable = unavailableStore?.load() ?? UnavailableAssets() 69 70 ··· 142 143 // Process in batches (wrapped to save manifest on cancellation) 143 144 let totalBatches = (pending.count + options.batchSize - 1) / options.batchSize 144 145 146 + // Emit an initial concurrency limit for UIs that want to show it, then 147 + // re-emit between batches whenever the AIMD controller adjusts. 148 + var lastEmittedLimit: Int? 149 + if let controller = adaptiveController { 150 + let limit = await controller.currentLimit() 151 + progress.concurrencyChanged(limit: limit) 152 + lastEmittedLimit = limit 153 + } 154 + 145 155 do { 146 156 for batchIndex in 0 ..< totalBatches { 147 157 try Task.checkCancellation() 158 + 159 + if let controller = adaptiveController { 160 + let limit = await controller.currentLimit() 161 + if limit != lastEmittedLimit { 162 + progress.concurrencyChanged(limit: limit) 163 + lastEmittedLimit = limit 164 + } 165 + } 148 166 149 167 let start = batchIndex * options.batchSize 150 168 let end = min(start + options.batchSize, pending.count)
+6
Sources/AtticCore/BackupProgress.swift
··· 34 34 35 35 /// Backup resumed after a pause. 36 36 func backupResumed() 37 + 38 + /// Adaptive export concurrency limit changed (AIMD controller backed off 39 + /// or recovered). 40 + func concurrencyChanged(limit: Int) 37 41 } 38 42 39 43 /// Default no-op implementations for optional delegate methods. ··· 42 46 func assetRetrying(uuid: String, filename: String, attempt: Int, maxAttempts: Int) {} 43 47 func backupPaused(reason: String) {} 44 48 func backupResumed() {} 49 + func concurrencyChanged(limit: Int) {} 45 50 } 46 51 47 52 /// No-op delegate for quiet/test runs. ··· 55 60 public func assetFailed(uuid: String, filename: String, message: String) {} 56 61 public func manifestSaved(entriesCount: Int) {} 57 62 public func backupCompleted(uploaded: Int, failed: Int, totalBytes: Int) {} 63 + public func concurrencyChanged(limit: Int) {} 58 64 }