Section 16/172 menit

16. Real Use Cases

16. Real Use Cases

Use Case 1: In-Flight Deduplication Cache

Cache HTTP yang men-dedup request paralel untuk URL yang sama.

swift
actor ImageCache {
    private var cached: [URL: UIImage] = [:]
    private var inflight: [URL: Task<UIImage, Error>] = [:]

    func image(for url: URL) async throws -> UIImage {
        if let img = cached[url] { return img }
        if let existing = inflight[url] { return try await existing.value }

        let task = Task<UIImage, Error> {
            let (data, _) = try await URLSession.shared.data(from: url)
            guard let img = UIImage(data: data) else {
                throw URLError(.cannotDecodeContentData)
            }
            return img
        }
        inflight[url] = task
        defer { inflight[url] = nil }

        let image = try await task.value
        cached[url] = image
        return image
    }

    func invalidate(_ url: URL) {
        cached.removeValue(forKey: url)
    }
}

Keputusan desain:

  • inflight melacak request berjalan → dedup tanpa lock manual.
  • defer { inflight[url] = nil } membersihkan slot walau ada throw.
  • Cache hit tetap atomic karena actor.

Use Case 2: Batched Logger dengan Custom Executor

Logger yang menulis ke file tanpa memblock caller.

swift
@globalActor
actor LogActor {
    static let shared = LogActor()

    let queue = DispatchSerialQueue(label: "log", qos: .utility)
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        queue.asUnownedSerialExecutor()
    }
}

@LogActor
final class FileLogger {
    private var buffer: [String] = []
    private let url: URL
    private let flushInterval: TimeInterval = 1.0
    private var flushTask: Task<Void, Never>?

    init(url: URL) {
        self.url = url
        scheduleFlush()
    }

    func log(_ message: String) {
        buffer.append("\(Date()): \(message)\n")
        if buffer.count > 100 { flush() }
    }

    private func flush() {
        guard !buffer.isEmpty else { return }
        let lines = buffer.joined()
        buffer.removeAll(keepingCapacity: true)
        if let data = lines.data(using: .utf8) {
            try? data.append(to: url)
        }
    }

    private func scheduleFlush() {
        flushTask = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(nanoseconds: UInt64(1_000_000_000))
                await self?.flush()
            }
        }
    }
}

extension Data {
    func append(to url: URL) throws {
        if FileManager.default.fileExists(atPath: url.path) {
            let handle = try FileHandle(forWritingTo: url)
            try handle.seekToEnd()
            try handle.write(contentsOf: self)
            try handle.close()
        } else {
            try write(to: url)
        }
    }
}

Keputusan desain:

  • Custom executor (DispatchSerialQueue) memastikan I/O konsisten di queue priority .utility.
  • Buffer batched + timer untuk throughput tinggi tanpa overhead I/O per log call.
  • log adalah sync (di-isolated ke @LogActor) → caller hop sekali per panggilan, bukan blocking I/O.

Use Case 3: ViewModel SwiftUI dengan Background Computation

ViewModel yang menggabungkan computation berat di background dengan UI update di main.

swift
@MainActor
@Observable
final class SearchViewModel {
    var query: String = ""
    var results: [SearchResult] = []
    var isLoading: Bool = false

    private let search: SearchService
    private var currentTask: Task<Void, Never>?

    init(search: SearchService) {
        self.search = search
    }

    func updateQuery(_ newQuery: String) {
        query = newQuery
        currentTask?.cancel()

        guard !newQuery.isEmpty else {
            results = []
            return
        }

        isLoading = true
        currentTask = Task {
            do {
                // search.execute jalan di luar MainActor (heavy)
                let found = try await search.execute(query: newQuery)
                guard !Task.isCancelled else { return }

                // ini sudah di MainActor (closure Task mewarisi isolation)
                self.results = found
                self.isLoading = false
            } catch {
                guard !Task.isCancelled else { return }
                self.isLoading = false
            }
        }
    }
}

actor SearchService {
    func execute(query: String) async throws -> [SearchResult] {
        // CPU-bound parsing + I/O — bukan di MainActor
        try await Task.sleep(nanoseconds: 200_000_000)  // simulate I/O
        return [SearchResult(text: "result for \(query)")]
    }
}

struct SearchResult: Sendable, Identifiable {
    let id = UUID()
    let text: String
}

Keputusan desain:

  • ViewModel @MainActor → property access tanpa hop dari View.
  • Search heavy di-isolated ke SearchService actor → off-main.
  • currentTask?.cancel() di setiap update → debounce-by-cancellation alami.
  • Hop ke main hanya sekali saat result siap.

Use Case 4: Database Repository dengan Region-Based Transfer

Repository SwiftData/CoreData yang menerima entity builder dari UI thread.

swift
final class UserBuilder {
    var name: String = ""
    var email: String = ""
    var preferences: [String: Bool] = [:]
}

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

@DatabaseActor
final class UserRepository {
    private var users: [UUID: User] = [:]

    func create(from builder: sending UserBuilder) async -> User {
        // builder sudah ditransfer; caller tidak bisa akses lagi
        let user = User(
            id: UUID(),
            name: builder.name,
            email: builder.email,
            preferences: builder.preferences
        )
        users[user.id] = user
        return user
    }
}

struct User: Sendable, Identifiable {
    let id: UUID
    let name: String
    let email: String
    let preferences: [String: Bool]
}

// Caller di MainActor:
@MainActor
func saveUser(repo: UserRepository) async {
    let builder = UserBuilder()
    builder.name = "Ari"
    builder.email = "ari@example.com"
    builder.preferences = ["darkMode": true]

    let user = await repo.create(from: builder)
    // builder.name = "..."  // ❌ Error: 'builder' was transferred
    print("Created \(user.id)")
}

Keputusan desain:

  • UserBuilder non-Sendable (var properties) — ergonomis untuk UI.
  • sending parameter mengizinkan transfer satu kali ke actor.
  • Region analysis memastikan caller tidak menyentuh builder setelah transfer.

Use Case 5: Rate-Limited API Client

Client API yang menjamin tidak lebih dari N request/detik, lintas semua caller.

swift
actor RateLimitedClient {
    private let limit: Int  // max requests per window
    private let window: TimeInterval  // seconds
    private var timestamps: [Date] = []

    init(limit: Int, window: TimeInterval) {
        self.limit = limit
        self.window = window
    }

    func request<T: Decodable & Sendable>(
        _ url: URL,
        as type: T.Type
    ) async throws -> T {
        try await waitForSlot()
        timestamps.append(.now)

        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(T.self, from: data)
    }

    private func waitForSlot() async throws {
        while true {
            let cutoff = Date().addingTimeInterval(-window)
            timestamps.removeAll { $0 < cutoff }

            if timestamps.count < limit { return }

            // Tunggu hingga slot tertua kadaluarsa
            guard let oldest = timestamps.first else { return }
            let sleepFor = oldest.addingTimeInterval(window).timeIntervalSinceNow
            if sleepFor > 0 {
                try await Task.sleep(nanoseconds: UInt64(sleepFor * 1_000_000_000))
            }
        }
    }
}

Keputusan desain:

  • State timestamps aman karena actor.
  • Re-entrancy aman di sini: kalau Caller A await sleep, Caller B masuk dan ikut antri sesuai cutoff yang baru.
  • T: Sendable di generic constraint memastikan hasil bisa di-return cross-isolation.