Argh, now I understand your problem better with the illustrations.
Stepping back from any particular solution, the root issue IMO is not with Task execution order being indeterministic. Instead, it is us trying to do practical fire-and-forget calls to the actors, in response to MainActor UI event callbacks that are non-async. (Living at the edge of structured concurrency, as some might say)
Your other actor-native logic consuming this Database API would not be subject to this problem, because structured concurrency makes sure that those (tasks from the) caller actors will await for your Database actor call to complete before moving onto whatever logic comes next (which may or may not include more Database actor calls).
This is all to say that it should not be necessarily to refactor it into a Command-CommandQueue pattern (a legitimate solution, but argubly heavy handed) for it to work in your case.
There are two approaches I can see:
-
Wait for custom executor — you can provide an Executor with FIFO job guarantee for your Database actors.
-
A interim "serial queue of tasks", where it iterates over async tasks (in the form of () async -> Void closures), but only starts one after the previous one has completed.
You can make such queue as an actor too:
A minimal implementation of such task queue:
final class TaskQueue: @unchecked Sendable {
private var queue: [() async -> Void] = []
private var runningTask: Task<Void, Never>?
private let lock = NSLock()
func enqueue(_ action: @Sendable @escaping () async -> Void) {
withLock {
if runningTask != nil {
// There is a suspended running task.
// Queue the `action` and leave it to that task to eventually drain & execute it.
queue.append(action)
} else {
// There is no running task.
// Mark that we are now running, and execute `action` right away.
runningTask = Task {
await action()
// Iteratively process all the enqueued tasks, that have come concurrently while
// we have been executing or suspending.
while let nextTask = self.dequeue() {
await nextTask()
}
self.withLock { self.runningTask = nil }
}
}
}
}
private func withLock<R>(_ action: () -> R) -> R {
lock.lock()
defer { lock.unlock() }
return action()
}
private func dequeue() -> (() async -> Void)? {
withLock { !queue.isEmpty ? queue.removeFirst() : nil }
}
}
where it can be consumed on MainActor as such:
let database = Database()
let taskQueue = TaskQueue()
let editAction = UIAction { _ in
taskQueue.enqueue {
await database.with { $0.updateRecord() }
}
}
let deleteAction = UIAction { _ in
taskQueue.enqueue {
await database.with { $0.deleteRecord() }
}
}
let createAction = UIAction { _ in
taskQueue.enqueue {
await database.with { $0.insertRecord() }
}
}
So now instead of trying to shoehorn the database API into Command-CommandQueue pattern in order to solve it with AsyncSequence, you get to keep the database API in a typical actor shape. You can also turn this TaskQueue (or whatever name you'd prefer) into a no-op in the future, when you can eventually supply a custom executor to the Database actor.