I do state management locally in my class using a enum. If a particular async work is not done then I kick it off in a Task and then await that task's result. Once the async function completes, it caches the values and updates the state.
When the async task is already completed we use the cached value and return early after updating state. It is this path that sometimes throws the system off. The task completes but the next piece of code never picks up the state update. So all subsequent calls give me an error because we are not in an expected state. (This DOES NOT happen every time)
So we go from disabled -> starting -> starting -> starting forever, instead of going from disabled -> starting -> started.
Following is a skeleton code I wrote in playground to explain the code structure. I was NOT ABLE to recreate the prod error.
import Foundation
struct AsyncResult {
let resultKey: String
}
enum State {
case disabled
case starting(Task<(), Error>)
case started(AsyncResult)
}
enum FinalResult {
case result1
case result2
}
enum ClientErrors: Error {
case invalidState(State)
}
class Client {
var state: State = .disabled
var defaults = UserDefaults.standard
var key = "resultKey"
private func doSetup() async throws {
if let result = defaults.string(forKey: key){
// Cached value is read, state is updated and we return immediately instead of doing async work.
print("Reusing saved value")
state = .started(AsyncResult(resultKey: result))
return
}
print("Doing setup")
// Simulate time taken by async work needed to set the key
try await Task.sleep(nanoseconds: 2_000_000_000)
try await Task.sleep(nanoseconds: 1_000_000_000)
try await Task.sleep(nanoseconds: 3_000_000_000)
defaults.set("bar", forKey: key)
state = .started(AsyncResult(resultKey: "bar"))
}
func doWork() async throws -> FinalResult {
// Start the async task if not already started
if case .disabled = state {
state = .starting(Task { try await doSetup() })
}
switch state {
case .disabled:
throw ClientErrors.invalidState(.disabled)
case .started(_):
print("Already setup")
break
case .starting(let task):
print("Waiting for task to complete")
// Wait on async task
try await task.value
}
// Async task should be completed else throw an error
guard case .started(let result) = state else {
// This code block runs and throws an error even though a cached value is read and state is updated above
// From logs I see it reusing saved value but this call and all following calls will fail with the same error
// after they enter here
throw ClientErrors.invalidState(state)
}
print("Will use result: \(result)")
// Prod code has more logic for what to return
return FinalResult.result1
}
}
UserDefaults.standard.removeObject(forKey: "resultKey")
var client = Client()
// This is meant to simulate what happens in prod code
// I am unable to get it to throw an error and crash in this playground example
for i in 1...100 {
print("Iteration \(i)")
try! await client.doWork()
if Bool.random() {
print("Resetting client")
client = Client()
}
}