Sources/OSS/Internal/ExpiringValue.swift (99 lines of code) (raw):

import Foundation /// Type holding a value and an expiration value. /// /// When accessing the value you have to provide a closure that will update the /// value if it has expired or is about to expire. The type ensures there is only /// ever one value update running at any one time. If an update is already running /// when you call `getValue` it will wait on the current update function to finish. actor ExpiringValue<T: Sendable> { enum State { /// No value is stored case noValue /// Initial call waiting on a value to be generated. Cannot use `waitingOnValue`` in /// initial call as it means we would have to setup it up before all stored properties /// have been initialized case initialWaitingOnValue(Task<(T, Date), Error>) /// Waiting on a value to be generated case waitingOnValue(Task<T, Error>) /// Is holding a value case withValue(T, Date) /// Is holding a value, and there is a task in progress to update it case withValueAndWaiting(T, Date, Task<T, Error>) /// Error case error(Error) } var state: State let threshold: TimeInterval init(threshold: TimeInterval = 2) { self.threshold = threshold state = .noValue } init(_ initialValue: T, expires: Date, threshold: TimeInterval = 2) { self.threshold = threshold state = .withValue(initialValue, expires) } init(threshold: TimeInterval = 2, getExpiringValue: @escaping @Sendable () async throws -> (T, Date)) { self.threshold = threshold let task = Task { try await getExpiringValue() } state = .initialWaitingOnValue(task) } func getValue(getExpiringValue: @escaping @Sendable () async throws -> (T, Date)) async throws -> T { let task: Task<T, Error> switch state { case .noValue: task = try getValueTask(getExpiringValue) state = .waitingOnValue(task) case let .initialWaitingOnValue(task): return try await withTaskCancellationHandler { switch await task.result { case let .success(result): self.state = .withValue(result.0, result.1) return result.0 case let .failure(error): self.state = .error(error) throw error } } onCancel: { task.cancel() } case let .waitingOnValue(waitingOnTask): task = waitingOnTask case let .withValue(value, expires): if expires.timeIntervalSinceNow < 0 { // value has expired, create new task to update value and // return the result of that task task = try getValueTask(getExpiringValue) state = .waitingOnValue(task) } else if expires.timeIntervalSinceNow < threshold { // value is about to expire, create new task to update value and // return current value let task = try getValueTask(getExpiringValue) state = .withValueAndWaiting(value, expires, task) return value } else { return value } case let .withValueAndWaiting(value, expires, waitingOnTask): if expires.timeIntervalSinceNow < 0 { // as value has expired wait for task to finish and return result task = waitingOnTask } else { // value hasn't expired so return current value return value } case let .error(error): throw error } return try await withTaskCancellationHandler { switch await task.result { case let .success(value): return value case let .failure(error): self.state = .error(error) throw error } } onCancel: { task.cancel() } } /// Create task that will return a new version of the value and a date it will expire /// - Parameter getExpiringValue: Function return value and expiration date func getValueTask(_ getExpiringValue: @escaping @Sendable () async throws -> (T, Date)) throws -> Task<T, Error> { try Task.checkCancellation() return Task { let (value, expires) = try await getExpiringValue() self.state = .withValue(value, expires) return value } } func cancel() { switch state { case let .initialWaitingOnValue(task): task.cancel() case let .waitingOnValue(task), let .withValueAndWaiting(_, _, task): task.cancel() default: break } } }