How to “ObjectContext” with Swift 6 concurrency

Hello everybody,

A quick preface (trigger warning: domain-driven design inside)

I have a few beefy services with tons of business logic, all neatly organized into testable entity classes and aggregates that are persisted using the unit-of-work pattern - sometimes in transactions (if multiple aggregates are involved), always with automatic optimistic concurrency retries. Code is clean, testability is fantastic, things work perfectly fine, and life could be good - but, alas, it’s all written in typescript… (pause for dramatic effect)

So, I know I want to eventually port it over to swift - or at least write new features/services in swift - and I have been thinking about a) is a unit-of-work (or object context if you will) still a good pattern in 2024 and b) how to actually shape it in Swift 6 with modern concurrency.

Let’s ignore a) for now (I still think it is a nice pattern until proven otherwise).

Oddly enough, SwiftData’s ModelContext is kind of that, but without an async API. So, another way of asking this would be: How to async-ify ModelContext for the server-side?

Imagine I wanted a type like this (for unit-of-work style processing, or more generally an ObjectContext or something like that)

edit: added more details to example code for clarity

protocol Entity: Identifiable, AnyObject {}

final class Context {
    var cache = SomeFancyStorageOfNonSendableEntities()
    let db: SomeDatabaseClient

    // this outer fetch is "not-sendable"
    func fetch<M: Entity>(_ type: M.Type = M.self, byId id: M.ID) async throws -> M? {
        if let cached = cache.get(type, id) {
            return cached
        } else {
            // this fetch is nonisolated and sendable
            let fetched = try await db.fetch(type, byId: id)
            cache.add(fetched)
            return fetched
        }
     }

    // more stuff like fetching by query, adding, removing, ...

    func saveChanges() async throws {
        //save it all to DB, with bells and whistles
    }
}

If I just naively write it like above, it kind of works, I can do this safely (even in Swift 6 I think):

final class A: Entity {
    var id: String = "some new id"
}

final class B: Entity {
    var id: String = "some new id"
}

func myOperation() async throws {
    let context = Context()

    let a = try await context.fetch(A.self, byId: "aaa")
    let b = try await context.fetch(B.self, byId: "bbb")

    doSomeWork(a, b)

    try await context.saveChanges()
}

But now I'd really want to parallelize the fetching, so it would be really nice to spell it like this:

func myFasterOperation() async throws {
    let context = Context()

    async let a = context.fetch(A.self, byId: "someId")
    async let b = context.fetch(B.self, byId: "someId")

    try await doSomeWork(a, b)

    try await context.saveChanges()
}

Clearly, this won't fly because Context isn't sendable.

I would need to tell the compiler that that Context.fetch itself should not actually hop anywhere, and that the "parallelism" introduced by async let should happen "inside" (if at all).

I have been trying to keep up with the various recent evolution pitches and additions around isolation and regions, but it hasn't really clicked yet if/how I could do such a thing. A quick experiment on the latest nightly using isolation ... = #isolation as a parameter did not help.

I can kind of see how making Context an actor might do the trick, but as I understand it it would require myOperation to be isolated to a context parameter (right?).

Will this at all be possible with Swift 6 (without actors)?
Would it just be a bad API design anyway?
Will the idiomatic way for something like this be to fully lean into actors and isolation annotations sprinkled everywhere?

1 Like

This does seem like it should be solveable by constraining fetch to run in the caller's isolation domain. Then sendability is not required for its arguments.

Perhaps an oversight (or just not yet implemented) in isolation?

I figured that might be the case - but I am not so sure what "running in the caller's isolation" actually means when the caller is a plain old async function (or nonisolated as the kids call it these days) and I use async let with it - and how all of that relates to sendable requirements.

I, for one, am sure looking forward to someone at Apple spoon-feeding me all that stuff in the WWDC 2024 swift sessions ; )

1 Like

Such functions logically always run in the caller's context (including isolation domain), just like a regular sync function inherits the same callstack as its parent.

However, maybe this is complicated by the "the main thread is lava" behaviour that Swift has, for better or worse. Where it tries to aggressively hop off the main thread when you do async calls (under the hypothesis that doing so is a net win for program responsiveness). Maybe that imposes sendability requirements where really there shouldn't be any. :man_shrugging:

I'm sure one of the experts will chime in shortly. I'm just musing; the way async stuff & Sendable behave is very complicated (and getting worse in future versions of Swift :pensive:).

This is not true since SE-0338:

async functions that are not actor-isolated should formally run on a generic executor associated with no actor. Such functions will formally switch executors exactly like an actor-isolated function would: on any entry to the function, including calls, returns from calls, and resumption from suspension, they will switch to a generic, non-actor executor. If they were previously running on some actor's executor, that executor will become free to execute other tasks.

IOW the "main thread is lava" behavior you cite is not specific to the main thread, calling into a nonisolated async function from any other isolation domain will give up the calling executor (modulo present/recent work to give better control of isolation inheritance to authors).

2 Likes

Ah yes, one of the complications I alluded to. :confused:

I hadn't realise it had ramifications beyond just for the main actor.

That behaviour is such a bummer.

Here's a maybe spicy take that came to mind while thinking about this a bit further:

Does it ever make sense for async methods on a non-sendable type to NOT inherit the callers isolation?

If that would be the default (ie: hop around on sendable all you want, but just stick to the current isolation on non-sendable types) - wouldn't that kind of always be nicer to deal with?

1 Like

There were legitimate reasons for the behavior to become "hopping off" discussed in swift-evolution/proposals/0338-clarify-execution-non-actor-async.md at main · apple/swift-evolution · GitHub. It's a bit of a problem as either "default" introduces its own problems.

With the current semantics you get the safe default "by default" and you can avoid hopping thanks to the new task executor preference. Nonisolated async functions are able to stay on a preferred executor thanks to this.

2 Likes

There are definitely cases where it is safe to call a nonisolated async method on a non-Sendable type. First, this is always okay from a nonisolated context because no isolation boundary is crossed; the current task is providing isolation of the state used inside that task.

From an actor-isolated context, it's safe to pass a non-Sendable value to the concurrent thread pool if that value cannot be referenced by any actor-isolated storage. This behavior is introduced in SE-0414: Region-based isolation. This proposal is accepted and implemented for Swift 6.0 (it's implemented on main now; the proposal status just needs to be updated) and designed to lift some of the limitations around passing non-Sendable types over isolation boundaries to make non-Sendable types easier to use under full data isolation.

1 Like

As long as Context is not Sendable (or not "thread safe" in more common terms), it's not safe to ever access an instance of it concurrently. Making fetch not do a hop doesn't help with that, because async let still requires that all values in the expression are Sendable so that it can create a separate child task to evaluate the expression in while the parent continues running concurrently. The only thing that I think would be safe with your current API shape is if you create a different Context() instance for each fetch call you're making in an async let.

An alternative API design is for there to be a single fetch method that takes in multiple arguments for the things you want to fetch, then the fetch method itself can parallelize those requests while taking care to not touch any of the mutable state on Context in doing that.

Finally, like you suggest, you could make Context conform to Sendable either by making it an actor directly or by isolating it to some dedicated global actor. Then, you can strategically make parts of that type nonisolated so that they can be evaluated in parallel, and the compiler will verify that you do not touch any of the actor's protected state inside those nonisolated methods.

1 Like

Hmm… so would it be accurate to think of nonisolated as actually meaning taskisolated?

To me it intuitively - from the English words it uses - means "I have no isolation requirements" but as other threads have revealed, that's not it at all.

Thanks for the response!

I do appreciate the control the executor preference gives a ton, but in this case it's not really the "hopping" I care about, but rather the isolation/sendability aspects of it all.

I am having a hard time finding the "correct" language to express this all, terminology intuition is still forming....

Thanks @hborla for the responses!

I think I convoluted "isolation inheritance" with child task mechanics and was hoping it would magically do away with the need for sendability.

To be honest, in my brain I thought of it as "inline this async function" - as in "act like this runs like plain code right here", and I was hoping "isolation ... = #isolation" would maybe do that. When I was saying "do not hop around" I was actually referring to this.

Which brings me to this:
I believe, for this case, this is what I actually want: "inline async".

Making the Context be an actor feels wrong, because

  • I don't really want to send it around - non-sendable is good
  • it is actually fantastic to have statically compiler-ensured that only one task can work on a unit of work
  • it will contain a ton of non-sendable Entity objects (which also "belong to the current task" if you will)

So, I think I want all of non-sendable goodness as a feature - I just want to be able to fetch entities in parallel without having an escalating API surface.

1 Like

I was also toying with the idea, that I could run a function/closure isolated to an "ephemeral actor", and have all methods on the Context inherit it.

Would that give me an async(inline)-esque behavior?

I tried to build this on the nightly, but it crashed on me:

final class Context { // not sendable
    func fetch<M: Entity>(_ type: M.Type = M.self, byId id: M.ID, isolated isolation: any Actor = #isolation) async throws -> M? {  ...  }
}

func myIsolatedTask(isolated isolation: any Actor) async throws {
    let context = Context()

    // would this work? it should all be tied to the isolation, right?
    async let a = context.fetch(A.self, byId: "someId")
    async let b = context.fetch(B.self, byId: "someId")

    try await doSomeWork(a, b)
}

Or, one could maybe think of it in terms of

func myInlineAsyncFunction(isolated isolation = #task)

So making context isolated in general would be nice possibility in overall and if I understand correctly region based isolation is the way to achieve it once Swift 6 released. Which is probably is also an answer to my case here - Cannot understand the nature of data race warnings in Swift 5.10 - #4 by vns

After reading about a similar thought process (tying a non-sendable to an isolation via a stored isolated any Actor value) in another post I kept thinking about this some more.

The language makes it so easy to slap global actor attributes onto things to isolate non-sendable parts together, but so hard for use cases where there is no meaningful "statically known" isolation context (eg: server-side processing)

So, just throwing out ideas here, what if we had:

// automagically tie this to the #isolation of its initializer
// and somehow keep a reference to the actor
@inheritsIsolation
final class Context {
    // async function is tied to the actor captured by the initalizer
    func fetch<M: Entity>(_ type: M.Type = M.self, byId id: M.ID) async throws -> M? { ... }
}

func myOperation() {
    // provide "epemeral" actor isolation
    try await withIsolation { 
        // tied to the current #isolation
        let context = Context()

        // should then work, because all isolated to same actor (right?)
        let a = async let context.fetch(A.self, byId: "aaa") 
        let b = async let context.fetch(B.self, byId: "bbb")
        
        try await doSomeWork(a, b)
    }
}

I'll gladly take an isolated ... = #isolation parameter in the constructor that can be stored in a let and understood by the compiler as well of course ; )

3 Likes

I think there's still some confusion here because these two things are fundamentally in conflict with each other, so let me elaborate on what I mean by that.

If you want to perform two calls to fetch() in parallel, the implementation of fetch() has to be thread safe. This means that each invocation cannot access mutable state that is accessible in both calls, or it needs some synchronization mechanism for accessing that state. If you isolate the entire call to fetch() to the calling actor, those calls will not run in parallel, because actors (including global actors) can only run one task at a time, so those calls will be serialized. That's the purpose of actors - they provide mutually exclusive access to their protected state using the serialization mechanism so that only one task can be running and accessing that state at a time.

If you want to call fetch() twice and run the invocations in parallel, fetch() must either be nonisolated so that it can run on the global concurrent thread pool, or each call must be isolated to a different actor. That is fundamentally in conflict with making fetch() a method on a non-Sendable type, because Swift concurrency requires mutually exclusive access to non-Sendable types - no two isolation domains can have access to the same non-Sendable value at once, because that is fundamentally not thread safe and will lead to data races.

4 Likes

Maybe I did not explain well enough in the initial post what the object context would do.

It will have some sort of cache and a reference to the actual DB client.

The actual "database fetching", ie. the async operation that really needs to be parallelized (and absolutely can be) will happen inside the context.fetch (but conditionally)

final class Context {
    var cache: Cache = Cache() //doesn't matter, some sort of fancy storage
    let db: SomeDatabaseClient

    // this outer fetch is "not-sendable" and would see some actor isolation
    func fetch<M: Entity>(_ type: M.Type = M.self, byId id: M.ID) async throws -> M? {
        if let cached = cache.get(type, id) {
            return cached
        } else {
            // this fetch is nonisolated and sendable
            let fetched = try await db.fetch(type, byId: id)
            cache.add(fetched)
            return fetched
        }
     }
}

Ah, I understand! That's helpful, thanks. I'd still like to better understand your aversion to making Context an actor. An actor will guarantee that access to cache is serialized, and it will let you use an instance of Context() in an async let. With your suggestion to make it easier to isolate the methods of a type to the calling actor, this:

still would not compile, because async let requires all values in the expression to be Sendable because async let will always create a new, nonisolated child task to evaluate the expression in. If you want to change the isolation of a child task, I believe you'll need to switch over to using withTaskGroup { ... } and group.addTask so that you can control the isolation of the closure passed to addTask.

With your current API shape, I think you're going to have to be extremely careful about how you're calling fetch() in order to meet the guarantee that you never access mutable state concurrently without resorting to unsafe opt outs. I think making Context conform to Sendable by formally isolating it is the best way to improve the usability of this API in its current form. Otherwise, you could explore a single API for performing the concurrent fetching based on the arguments.

Thanks for this clarification, the details of async let were a bit mysterious to me.

Is that good though? I am a bit surprised, to be honest, that all this work to have control over isolation will not make a dent in the async let hurdle. At the very least it feels like an unexpected discrepancy between task groups and async let.

Not sure if aversion is the right word, I'll gladly use it if it gets the job done.

I just found that, semantically, the context is not really the "isolation root" here, but rather "the operation" is (or "the task" if you will). And the context, as well as all entities that are loaded through it, belong to the isolation of "the operation". Just imagine having two separate databases you'd want to context-manage in one operation (not that I'll need that - but as a concrete example).

Also, I was worried what it would do to the ergonomics if an actor version of this context caches and passes out all these non-sendabled entities. Clearly I cannot transfer them out, because the whole point of the context is to keep a "bag of references" to them. So, they will have to be isolated to the context, which (I guess) means that any operation that wants to actually work on these entities (typically sync methods/plain logic) will need to run in the context's isolation.

I could also make the context thread-safe with traditional means, but why should it have to be? I think it is a feature to be forced into "one task owns this, one task works on this, no parallel funny-business"-mode. If we had async(inline) or @inheritIsolation it would all semantically click for me, as in "my async functions are not to be sent anywhere, but within them I will call async functions which will".

To summarize, I am still left feeling there is a missing feature set in swift's concurrency story when it comes to tying together non-sendable types into some dynamic isolation without resorting to (the very server-foreign) @MainActor.

2 Likes