Hoping to get some comments & feedback on an actor
I wrote. I've been wanting to build a "AuthTokenProvider" using Swift Concurrency. There are a few goals I had:
- Using an
actor
for thread-safety generic over aValue
- Has a single getter method/property to get an up to date
Value
- The value is fetched by a caller-defined async function
- Lazily fetch the first value upon first request
- While fetching, all subsequent requests must also await the fetch value
- Once fetched, cache it in-memory for faster performance in subsequent requests
- Include a way to invalidate the locally cached value, requiring the value to be re-fetched if invalid
- No concurrency warnings!
Here is my first attempt, the a version hosted in GitHub: GitHub - rjchatfield/SerialUpdatingValue: Thread -safe access to a lazily retrieved value, with optional validity checking.
It involves a queue of unsafe continuations. Pretty gnarly.
/// Thread-safe access to a lazily retrieved value, with optional validity checking
public actor SerialUpdatingValue<Value> where Value: Sendable {
// MARK: - Properties
private let isValid: @Sendable (Value) -> Bool
private let getUpdatedValue: @Sendable () async throws -> Value
private var latestValue: Result<Value, Error>?
private var callbackQueue: [@Sendable (Result<Value, Error>) -> Void] = []
private var taskHandle: Task<(), Never>?
// MARK: - Life cycle
/// - Parameters:
/// - isValid: Run against the locally stored `latestValue`, if `false` then value will be updated.
/// - getUpdatedValue: Long-running task to get updated value. Will be called lazily, initially, and if stored `latestValue` is no longer valid
public init(
isValid: @escaping @Sendable (Value) -> Bool = { _ in true },
getUpdatedValue: @escaping @Sendable () async throws -> Value
) {
self.isValid = isValid
self.getUpdatedValue = getUpdatedValue
}
deinit {
/// Not if there is a valid case when an Actor could
taskHandle?.cancel() /// cancel long-running update task
update(.failure(SerialUpdatingValueError.actorDeallocated)) /// flush callbacks
}
// MARK: - Public API
/// Will get up-to-date value
public var value: Value {
get async throws {
/// Using "unsafe" to capture `continuation` outside of scope
try await withUnsafeThrowingContinuation { continuation in
append(callback: { [continuation] result in
continuation.resume(with: result)
})
}
}
}
// MARK: - Private methods
private func append(
callback: @escaping @Sendable (Result<Value, Error>) -> Void
) {
if case .success(let value) = latestValue, isValid(value) {
return callback(.success(value))
} else {
/// There is no valid value, so must get a new value and
latestValue = nil /// clear out invalid value
callbackQueue.append(callback) /// enqueue callback
guard taskHandle == nil else { return } /// task is already running, will be called back from other callback
taskHandle = Task {
let newValue: Result<Value, Error>
do {
newValue = .success(try await getUpdatedValue())
} catch is CancellationError {
return /// Task may be cancelled during dealloc and callbacks will be handled differently
} catch {
newValue = .failure(error)
}
guard !Task.isCancelled else {
return /// Task may be cancelled during dealloc and callbacks will be handled differently
}
update(newValue)
taskHandle = nil
}
}
}
private func update(
_ updatedValue: Result<Value, Error>
) {
latestValue = updatedValue
/// Call all callbacks
let _callbacks = self.callbackQueue
self.callbackQueue = [] /// empty out queue before calling out to avoid possible reentrancy behaviour
for callback in _callbacks {
callback(updatedValue)
}
}
}
Does anyone here see anything wrong with this, or have any suggestions? Perhaps there is some prior art somewhere? All feedback or bikeshedding welcome.
I was inspired by a Tweet reply from @Douglas_Gregor to @layoutSubviews https://twitter.com/dgregor79/status/1486933532005961731
[...] Swift Concurrency has the building blocks for concurrency, but doesn’t have many higher-level APIs to make things like this easy. As a community, we should experiment with what works best, and then codify the best practices in new APIs.
Thanks,
Rob