Creating many “clients” vs wrapping a “client” in an actor

i have a server that has some integrations with third-party APIs, and accordingly, has a few “client” singletons that it uses to interact with them. the “clients” have varying shapes, but the thing they all have in common is they all wrap a NIO ClientBootstrap.

struct ParticularClient
{
    let bootstrap:ClientBootstrap

    ...
}

this, of course, means ParticularClient cannot be Sendable, because ClientBootstrap is not Sendable.

that’s okay if you only need to use the client from one concurrency domain, for example, iterating an AsyncSequence of requests:

mutating
func respond(to requests:AsyncStream<Server.DisplayRequest>) async throws
{
    //  create clients here
    for await request:Server.DisplayRequest in requests
    {
        try Task.checkCancellation()
        //  can use clients here
    }
}

but if you want to say, perform some long-running operation in the background that also uses the client, you’re out of luck because the client is a singleton and cannot be shared across concurrency domains.

there are two possible directions i can think of here:

  1. make the clients actors, which will make them share-able again. but using actor isolation just to synchronize access to a ClientBootstrap feels like overkill. after all, why must accessing a ClientBootstrap require synchronization in the first place?

  2. make separate, dedicated clients for each long-running operation, which implies creating multiple ClientBootstraps to connect to the same API.

which approach is better?

I just stumbled over the very same annoyance of the ClientBootrap not being sendable - including the same thought process about using an actor being overkill.

For now I just spin up new (ephemeral) bootstrap on connect and store the resulting channel, which in my case is no issue at all because the intended usage is a long-lasting connection. I also believe that creating a bootstrap is not exactly a "heavy" operation, so it's probably rarely worth adding a ton of threadsafety complexity around it.

I'm not quite sure I understand what you mean by "separate, dedicated clients" in this context, because im my mind this has nothing to do with bootstraps but rather channels.

1 Like

Because they aren't Sendable. They're a giant pile of mutable state without synchronzation: you really, genuinely cannot use them in multiple concurrency domains. These not being Sendable is not an oversight, it's an intentional design decision.

This is accurate: ClientBootstraps are cheap, feel free to create plenty.

We've got tentative plans to deprecate-and-replace the current bootstraps, because they aren't really serving their purpose well anymore. In particular the builder pattern has aged poorly. The next versions will likely be value types, which makes this entire problem go away.

5 Likes

i see, i was creating NIOSSLContext in the same function, as i was conceptualizing SSL context creation and bootstrap creation as “expensive, do-once” tasks to be grouped together. but if ClientBootstraps are cheap to make, i might as well separate the SSL context creation from the bootstrap creation.

the documentation really emphasizes the importance of creating ClientBootstrap once and reusing it, i think this advice is unhelpful and caused me to spend a lot of time optimizing something that didn’t need to be optimized.

in this context, a “client” is just a type that wraps some value parameters (API key, account ID, etc.) and a ClientBootstrap. a “client” can be reused multiple times to connect to the same server.

I says that you "usually" re-use them. I don't think that can be read as 'really emphasising the importance of creating them once'. In any case: Let's fix the docs! We can just change the sentence to "You may re-use" instead of 'usually'.

WDYT? docs: ClientBootstrap reuse is not important but permissible on the same thread/EL by weissi · Pull Request #2520 · apple/swift-nio · GitHub

2 Likes

looks good! left a couple comments regarding grammar as well :slight_smile: