Contents

kylehughes/coalesced

Coalesce overlapping async calls by key into one in-flight invocation, without caching completed results.

Quick start

api is an actor-based API client. Its fetchUser method has type @Sendable (User.ID) async throws -> User. User.ID is Hashable & Sendable, User is Sendable, and an ID fully identifies the fetch within this API client's scope.

AppServices holds the app's shared dependencies. The app creates one for this API client and stores the coalesced fetchUser closure in it:

import Coalesced

struct AppServices: Sendable {
    let fetchUser: @Sendable (User.ID) async throws -> User
}

let services = AppServices(
    fetchUser: coalesced(api.fetchUser)
)

The app gives services, or a copy, to each feature. The profile and activity features independently request the same userID.

In a profile feature:

let user = try await services.fetchUser(userID)

In an activity feature:

let author = try await services.fetchUser(userID)

If these calls overlap, api.fetchUser runs once. Every caller still waiting receives its User or error. Copying services does not create another coalescing scope; all copies share the fetchUser closure's coalescing scope. When the flight finishes, Coalesced removes it. A later call starts a new fetch.

Installation

Add the latest stable release to your package:

let package = Package(
    name: "App",
    dependencies: [
        .package(url: "https://github.com/kylehughes/Coalesced.git", from: "1.0.0"),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "Coalesced", package: "Coalesced"),
            ]
        ),
    ]
)

In Xcode, choose File > Add Package Dependencies, enter the repository URL, and select version 1.0.0 with the Up to Next Major Version rule. Coalesced has no external SwiftPM dependencies.

Usage

Both overloads accept an @Sendable async throwing operation, including an actor-isolated method reference. They return an ordinary @Sendable closure with the same parameters and result. Every argument and the result must be Sendable. Choose the overload based on which calls can safely share one in-flight invocation.

Automatic keys

coalesced(_:) uses the complete argument pack as the key, so every argument must also be Hashable. Calls match only when every corresponding argument compares equal. Those equality rules must make it safe for the callers to share one invocation. Key equality and hashing must remain stable for the entire flight. Do not mutate key-relevant state on reference arguments, including from inside the operation.

The fetchUser example uses one argument. Zero- and multi-argument operations keep their signatures too:

let refreshConfiguration: @Sendable () async throws -> Configuration =
    coalesced(api.refreshConfiguration)

let searchUsers: @Sendable (String, Int) async throws -> [User] =
    coalesced(api.searchUsers)

With no arguments, every overlapping call has the same empty key. For searchUsers, both the query and page participate in the key. Store each returned closure somewhere all callers that should share work can reach it. Calling coalesced again, even with the same method reference, creates a separate scope.

Custom keys

Use coalesced(_:keyedBy:) when the complete argument pack should not be the key. The keyedBy: closure receives every argument and returns a Hashable & Sendable key. The operation's arguments only need to be Sendable.

[!IMPORTANT] Return equal keys only when every caller sharing that key can safely receive the result or error from an operation run with the full arguments of whichever caller starts the flight. Key equality and hashing must remain stable for the entire flight; do not mutate key-relevant reference state, including from inside the operation.

Suppose User is Sendable but not Hashable. It has a stable id and cached display metadata. api.fetchAvatar builds its request from id alone:

let fetchAvatar: @Sendable (User) async throws -> Avatar =
    coalesced(api.fetchAvatar, keyedBy: { $0.id })

Store this closure with the app's shared services. Every feature that should share avatar requests must use that same closure. Cached display metadata is excluded because it does not affect the operation's request, result, or error. If another property can affect any of them, include it in the key.

The caller that creates a flight supplies the complete User value. Later callers with the same id join that invocation rather than replacing its arguments. See Behavior for cancellation and the remaining execution-context rules.

Behavior

Flights

A flight is the coordinator's shared record for one operation invocation. When a key has no active flight, the first registered caller creates one and schedules its task. Calls with an equal key that arrive before the operation finishes wait for the same result or error. If final-waiter cancellation removes the flight before its task passes the startability check, the operation is never invoked. If the operation throws, every caller still waiting receives that error. Coalesced removes a finished flight and does not cache its result. A later call creates a new flight.

Side effects inside a shared invocation do not happen separately for each waiting caller. Keep any logging, accounting, or mutation that must happen for every caller outside the wrapped operation.

Cancellation

Canceling one caller does not cancel work that another caller still needs. If final-caller cancellation removes the flight before the shared task passes its startability check, the operation is never invoked. Otherwise Coalesced cooperatively cancels the underlying task. The operation may already be running or may still be invoked, and it can continue until it observes cancellation.

If cancellation races with completion, the caller receives CancellationError when cancellation wins. If completion wins, the caller receives the operation's result or error.

Scope and execution context

Each call to coalesced creates an independent coalescing scope. Copies of the same returned closure share that scope; wrapping an operation again creates another.

The caller that creates a flight supplies the operation's argument values, task priority, and task-local values if the operation begins. An actor-isolated operation stays on its actor.

Compatibility

  • Swift 6.3 or later.
  • Xcode 26.5 or later on Apple platforms.
  • iOS 16+, macOS 13+, tvOS 16+, watchOS 9+, and visionOS 1+.
  • On Linux, Coalesced uses Synchronization.Mutex. CI tests Swift 6.3.0 and a selected current Swift 6.3 patch. CI

does not cover every Linux distribution.

  • Windows is not tested and is not part of the declared support.

Documentation

Read the DocC documentation on GitHub Pages.

The design note covers the coordinator's state machine, cancellation races, recursive calls, the context supplied by the caller that creates a flight, and implementation invariants.

The CI charter records the support contract, test profiles, toolchain policy, timeouts, coverage, and sanitizer policy.

Contributions

Coalesced is not accepting source contributions at this time. Report bugs on GitHub.

Provenance

Coalesced was conceived, implemented, tested, and documented by an automated software factory. No human has reviewed its source code.

"Author"

Kyle Hughes

[![Bluesky][bluesky_image]][bluesky_url]<br> [![LinkedIn][linkedin_image]][linkedin_url]<br> [![Mastodon][mastodon_image]][mastodon_url]

[bluesky_image]: https://img.shields.io/badge/Bluesky-0285FF?logo=bluesky&logoColor=fff [bluesky_url]: https://bsky.app/profile/kylehugh.es [linkedin_image]: https://img.shields.io/badge/LinkedIn-0A66C2?logo=linkedin&logoColor=fff [linkedin_url]: https://www.linkedin.com/in/kyle-hughes [mastodon_image]: https://img.shields.io/mastodon/follow/109356914477272810?domain=https%3A%2F%2Fmister.computer&style=social [mastodon_url]: https://mister.computer/@kyle

License

Coalesced is available under the MIT license.

Package Metadata

Repository: kylehughes/coalesced

Default branch: main

README: README.md