Dealing with non deterministic task execution order

I’ll preface by saying I realize this problem has a larger scope than just TCA, but I’m curious if TCA can provide a solution.

Suppose we want to write to a database off the Main Actor

actor Database {
  func insert(_ value: Int) { print("insert \(value)") }
  func delete(_ value: Int) { print("delete \(value)") }
}

And then we have a Reducer that, for example, receives input from the UI

struct AppReducer: ReducerProtocol {
  struct State: Equatable { … }
  enum Action {
    case insert(Int)
    case delete(Int)
  }
  
  @Dependency(\.database) var database

  var body: some ReducerProtocol<State, Action> {
    Reduce { state, action in
      switch action {
      case let .insert(value):
        return .run { _ in
          await database.insert(value)
        }

      case let .delete(value):
        return .run { _ in
          await database.delete(value)
        }
      }
    }
  }
}

If the reducer receives actions in a certain order, the database may not be called in that respective order due to Actor reentrancy. For example, if the reducer recieves

viewStore.send(.insert(0))
viewStore.send(.delete(0))

the database could execute in the wrong order, printing

delete 0
insert 0

In GCD world we would execute the database writes on a serial queue, but with Swift Concurrency there seems to be no good way to mimic that behavior, since every suspension point entails a non deterministic execution order. I’m wondering how you guys are dealing with this. Is there a fundamentally different way I should be thinking about design patterns with Swift Concurrency? I have run into this problem in other contexts as well, for example, displaying photos in the order they were taken. It applies to anything where there’s some sort of input stream which goes into an async process that produces an output which should be ordered the same as the input.

1 Like

If you need things to happen in a fixed order, then generally you should execute them in that order from a single task. Swift doesn't yet come out of the box with very well-developed mechanisms for this yet, but you might be able to use AsyncChannel and other tools from the swift-async-algorithms package to submit messages to a processing task to process in received order.

Since we would use func send(_ element: T) async on AsyncChannel<T> , doesn't that mean elements may not be sent in the correct order because again we have the suspension point when we await send?

I've heard it mentioned, a few times, that the upcoming feature supporting custom actor executors will allow this. Although I've not seen any elaboration as to why.

Tangentially, I worry that this unintuitive behaviour of actors isn't well-advertised enough, in Swift. In every other language where I've encountered actors, order is deterministic in these sorts of scenarios because the actors have an actual "mailbox" (i.e. message queue)… behaving very much like a GCD serial queue. I'm actually quite curious why Swift's actors don't behave the same way, if not use that same kind of implementation, by default…?

Yeah, in their current form actors don't really "act" so much as they only provide a synchronization barrier for some data they own. One way to build a more traditional message-based model could be to have an actor that spawns a work task and message queue for itself, and have the public methods on the actor send messages and return immediately, letting the task work through the message queue in order.

Wouldn't that require asynchronous and therefore order-undefined calls to those actor methods, though? It seems like the same "chicken and egg" problem as using AsyncChannel.

Or are you talking hypothetically, beyond Swift's current actor design?

Depends on the messaging mechanism you use. AsyncChannel should provide in-order delivery, and suspends senders when the buffer is full (or there is no buffer and the receiver isn't ready), but you could choose to use an unordered queue instead.

@darrenasaro TCA test stores will behave deterministically when serialized to the main executor (which is a configuration option in the current version, and the default in the upcoming 1.0 release).

For application code, if you require an action to be fully-processed before continuing, you can await it:

await viewStore.send(.insert(0)).finish()
viewStore.send(.delete(0))  // won't execute till the above completes
1 Like