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: AIMD concurrency controller in AtticCore

Moves the iCloud-lane backoff policy out of LadderKit (which now exposes
only the observation-only protocol) and into attic, where "how aggressively
to back off" is an orchestration decision alongside the retry queue and
manifest.

- AIMDController actor conforms to AdaptiveConcurrencyControlling.
Sliding window of the last 20 outcomes; halves on >30% transient-failure
rate, grows by 1 on <=5%, ignores permanent failures. Window clears on
every limit change so stale pre-change outcomes can't immediately re-trip.
- 3-field Config (initial/min/max); thresholds and window size are internal
constants — callers don't tune them today.
- Tests cover backoff, recovery, permanent-failure ignore, floor/ceiling,
and the sliding-window burst-straddle case that a tumbling window would
miss.

Also flips Package.swift's ladder dep from `from: "0.4.0"` to a local path
for development; revert to `from: "0.5.0"` after tagging upstream. Updates
the BackupPipelineTests mock for ExportError's new classification-based
initializer.

+174 -2
+1 -1
Package.swift
··· 11 11 dependencies: [ 12 12 .package(url: "https://github.com/adam-fowler/aws-signer-v4.git", from: "3.0.0"), 13 13 .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"), 14 - .package(url: "https://github.com/tijs/ladder.git", from: "0.4.0"), 14 + .package(path: "../ladder"), 15 15 .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), 16 16 ], 17 17 targets: [
+81
Sources/AtticCore/AdaptiveConcurrency.swift
··· 1 + import Foundation 2 + import LadderKit 3 + 4 + /// AIMD (additive-increase, multiplicative-decrease) concurrency controller. 5 + /// 6 + /// Observation-only — implements ``AdaptiveConcurrencyControlling`` from 7 + /// LadderKit. The exporter polls ``currentLimit()`` between dispatches and 8 + /// reports each ``ExportOutcome`` via ``record(_:)``; this actor maintains a 9 + /// sliding window of the last N non-permanent outcomes and adjusts the limit 10 + /// when the transient-failure rate crosses a threshold. 11 + /// 12 + /// - On every outcome after the window fills, the rate is re-evaluated. 13 + /// - `rate > 0.30` → limit halves (clamped to `minLimit`); window clears. 14 + /// - `rate <= 0.05` → limit grows by 1 (clamped to `maxLimit`); window clears. 15 + /// - Permanent failures (shared-album unavailable) are ignored — not a signal 16 + /// about iCloud lane health. 17 + /// 18 + /// Clearing the window on every limit change prevents stale pre-change 19 + /// outcomes from immediately re-tripping the new limit. 20 + public actor AIMDController: AdaptiveConcurrencyControlling { 21 + public struct Config: Sendable { 22 + public var initialLimit: Int 23 + public var minLimit: Int 24 + public var maxLimit: Int 25 + 26 + public init(initialLimit: Int = 6, minLimit: Int = 1, maxLimit: Int = 12) { 27 + self.initialLimit = initialLimit 28 + self.minLimit = minLimit 29 + self.maxLimit = maxLimit 30 + } 31 + } 32 + 33 + private static let windowSize = 20 34 + private static let backoffThreshold = 0.30 35 + private static let recoverThreshold = 0.05 36 + private static let decreaseFactor = 0.5 37 + 38 + public let config: Config 39 + private var limit: Int 40 + private var window: [Bool] = [] 41 + 42 + public init(config: Config = Config()) { 43 + self.config = config 44 + self.limit = max(config.minLimit, min(config.maxLimit, config.initialLimit)) 45 + } 46 + 47 + public func currentLimit() -> Int { limit } 48 + 49 + public func record(_ outcome: ExportOutcome) { 50 + switch outcome { 51 + case .permanentFailure: 52 + return 53 + case .success: 54 + observe(transientFailure: false) 55 + case .transientFailure: 56 + observe(transientFailure: true) 57 + } 58 + } 59 + 60 + private func observe(transientFailure: Bool) { 61 + window.append(transientFailure) 62 + if window.count > Self.windowSize { 63 + window.removeFirst() 64 + } 65 + guard window.count >= Self.windowSize else { return } 66 + 67 + let failures = window.lazy.filter { $0 }.count 68 + let rate = Double(failures) / Double(window.count) 69 + 70 + if rate > Self.backoffThreshold { 71 + let target = max(config.minLimit, Int((Double(limit) * Self.decreaseFactor).rounded(.down))) 72 + if target != limit { 73 + limit = target 74 + window.removeAll(keepingCapacity: true) 75 + } 76 + } else if rate <= Self.recoverThreshold, limit < config.maxLimit { 77 + limit += 1 78 + window.removeAll(keepingCapacity: true) 79 + } 80 + } 81 + }
+91
Tests/AtticCoreTests/AdaptiveConcurrencyTests.swift
··· 1 + import Foundation 2 + import LadderKit 3 + import Testing 4 + 5 + @testable import AtticCore 6 + 7 + @Suite("AIMDController") 8 + struct AIMDControllerTests { 9 + @Test("Starts at initial limit within bounds") 10 + func initialLimit() async { 11 + let c = AIMDController(config: .init(initialLimit: 6, minLimit: 1, maxLimit: 12)) 12 + #expect(await c.currentLimit() == 6) 13 + } 14 + 15 + @Test("Clamps initial limit to min/max") 16 + func clampsInitial() async { 17 + let lo = AIMDController(config: .init(initialLimit: 0, minLimit: 1, maxLimit: 12)) 18 + #expect(await lo.currentLimit() == 1) 19 + let hi = AIMDController(config: .init(initialLimit: 20, minLimit: 1, maxLimit: 12)) 20 + #expect(await hi.currentLimit() == 12) 21 + } 22 + 23 + @Test("High transient failure rate halves the limit") 24 + func backoffOnFailures() async { 25 + let c = AIMDController(config: .init(initialLimit: 8, minLimit: 1, maxLimit: 12)) 26 + // 10 failures then 10 successes: once the window fills (20 items), 27 + // the rate is 0.5 → backoff. 28 + for _ in 0 ..< 10 { await c.record(.transientFailure) } 29 + for _ in 0 ..< 10 { await c.record(.success) } 30 + #expect(await c.currentLimit() == 4) 31 + } 32 + 33 + @Test("Permanent failures are ignored") 34 + func permanentIgnored() async { 35 + let c = AIMDController(config: .init(initialLimit: 8, minLimit: 1, maxLimit: 12)) 36 + for _ in 0 ..< 40 { await c.record(.permanentFailure) } 37 + #expect(await c.currentLimit() == 8) 38 + } 39 + 40 + @Test("Clean sliding window grows limit additively") 41 + func recover() async { 42 + let c = AIMDController(config: .init(initialLimit: 4, minLimit: 1, maxLimit: 12)) 43 + // 20 successes fills the window with rate 0 → +1. 44 + for _ in 0 ..< 20 { await c.record(.success) } 45 + #expect(await c.currentLimit() == 5) 46 + } 47 + 48 + @Test("Sliding window catches a burst that straddles tumbling boundaries") 49 + func slidingCatchesStraddlingBurst() async { 50 + // With a *tumbling* window this would miss: 6 failures late in window 51 + // N + 6 early in window N+1 yield rates of 0.30 in each, under the 52 + // threshold. Sliding eval on every outcome catches the true 60% rate. 53 + let c = AIMDController(config: .init(initialLimit: 8, minLimit: 1, maxLimit: 12)) 54 + // First fill the window with 14 successes + 6 failures (rate 0.30 — 55 + // not > threshold, no change). 56 + for _ in 0 ..< 14 { await c.record(.success) } 57 + for _ in 0 ..< 6 { await c.record(.transientFailure) } 58 + #expect(await c.currentLimit() == 8) 59 + // Now slide in 6 more failures; oldest successes drop, rate climbs 60 + // above 0.30 and triggers backoff. 61 + for _ in 0 ..< 6 { await c.record(.transientFailure) } 62 + #expect(await c.currentLimit() == 4) 63 + } 64 + 65 + @Test("Window clears on limit change to avoid immediate re-trigger") 66 + func windowClearsOnChange() async { 67 + let c = AIMDController(config: .init(initialLimit: 8, minLimit: 1, maxLimit: 12)) 68 + // Force backoff. 69 + for _ in 0 ..< 10 { await c.record(.transientFailure) } 70 + for _ in 0 ..< 10 { await c.record(.success) } 71 + #expect(await c.currentLimit() == 4) 72 + // Immediately feeding another failure must not re-backoff — the window 73 + // is empty post-change. It takes another full window to re-trigger. 74 + await c.record(.transientFailure) 75 + #expect(await c.currentLimit() == 4) 76 + } 77 + 78 + @Test("Respects minLimit floor") 79 + func minLimitFloor() async { 80 + let c = AIMDController(config: .init(initialLimit: 2, minLimit: 2, maxLimit: 12)) 81 + for _ in 0 ..< 40 { await c.record(.transientFailure) } 82 + #expect(await c.currentLimit() == 2) 83 + } 84 + 85 + @Test("Respects maxLimit ceiling") 86 + func maxLimitCeiling() async { 87 + let c = AIMDController(config: .init(initialLimit: 3, minLimit: 1, maxLimit: 3)) 88 + for _ in 0 ..< 40 { await c.record(.success) } 89 + #expect(await c.currentLimit() == 3) 90 + } 91 + }
+1 -1
Tests/AtticCoreTests/BackupPipelineTests.swift
··· 510 510 errors.append(LadderKit.ExportError( 511 511 uuid: uuid, 512 512 message: "Shared-album asset unavailable", 513 - unavailable: true, 513 + classification: .permanentlyUnavailable, 514 514 )) 515 515 } else if let asset = availableAssets[uuid] { 516 516 let path = stagingDir.appendingPathComponent(asset.filename)