Skip to content

Repository files navigation

SharedTaskStore

SharedTaskStore is a Swift package for deduplicating in-flight async work so concurrent callers can reuse the same Task instead of starting duplicate work.

It provides one store for a single shared task and one store for keyed task sharing. Both are actor-based and designed for modern Swift concurrency.

Problem Solved

In async code, it is common for multiple callers to request the same work at nearly the same time:

  • multiple views ask for the same resource
  • a refresh button is tapped repeatedly
  • several tasks request the same user profile by identifier

Without coordination, each caller can create a new Task, which wastes network, CPU, battery, and backend capacity. SharedTaskStore makes concurrent callers await the same in-flight work.

SharedTaskStore vs KeyedSharedTaskStore

Use SharedTaskStore when there is only one logical unit of work to share at a time.

Examples:

  • bootstrapping app configuration
  • refreshing the current session
  • loading a single dashboard payload

Use KeyedSharedTaskStore when work should be deduplicated per identifier while still allowing different identifiers to run independently.

Examples:

  • loading user profiles by user ID
  • downloading images by URL
  • fetching product details by product SKU

Installation

Add SharedTaskStore to your Package.swift dependencies:

dependencies: [
    .package(url: "https://github.com/your-name/SharedTaskStore.git", from: "0.1.0")
]

Then add the product to your target:

.target(
    name: "FeatureModule",
    dependencies: [
        .product(name: "SharedTaskStore", package: "SharedTaskStore")
    ]
)

Simple Shared Network Request Example

import Foundation
import SharedTaskStore

struct SessionPayload: Decodable, Sendable {
    let userID: String
    let featureFlags: [String]
}

enum NetworkError: Error, Sendable {
    case transport
    case decoding
}

actor SessionService {
    private let store = SharedTaskStore<SessionPayload>()

    func loadSession() async throws -> SessionPayload {
        try await store.executeTask {
            Task {
                let url = URL(string: "https://example.com/api/session")!
                let data: Data

                do {
                    (data, _) = try await URLSession.shared.data(from: url)
                } catch {
                    throw NetworkError.transport
                }

                do {
                    return try JSONDecoder().decode(SessionPayload.self, from: data)
                } catch {
                    throw NetworkError.decoding
                }
            }
        }
    }
}

If several callers invoke loadSession() while the same task is already running, they all await the shared in-flight task.

Keyed Example by Identifier

import Foundation
import SharedTaskStore

struct UserProfile: Decodable, Sendable {
    let id: String
    let displayName: String
}

enum UserProfileError: Error, Sendable {
    case transport
    case decoding
}

actor UserProfileService {
    private let store = KeyedSharedTaskStore<String, UserProfile>()

    func profile(for userID: String) async throws -> UserProfile {
        try await store.executeTask(for: userID) {
            Task {
                let url = URL(string: "https://example.com/api/users/\(userID)")!
                let data: Data

                do {
                    (data, _) = try await URLSession.shared.data(from: url)
                } catch {
                    throw UserProfileError.transport
                }

                do {
                    return try JSONDecoder().decode(UserProfile.self, from: data)
                } catch {
                    throw UserProfileError.decoding
                }
            }
        }
    }
}

Concurrent requests for the same userID share work. Requests for different userID values can proceed independently.

Example with concurrentTaskLimit

KeyedSharedTaskStore can optionally limit how many newly created keyed tasks run at once.

import Foundation
import SharedTaskStore

actor ImagePrefetcher {
    private let store = KeyedSharedTaskStore<URL, Data>(concurrentTaskLimit: 4)

    func data(for url: URL) async throws -> Data {
        try await store.executeTask(for: url) {
            Task {
                let (data, _) = try await URLSession.shared.data(from: url)
                return data
            }
        }
    }
}

Important detail:

  • the limit only applies when creating a new task
  • callers that join an already-running task for the same key do not consume another permit

Cancellation and Error Semantics

The stores do not transform task behavior.

  • If the underlying Task throws, callers receive that error.
  • If the task is cancelled, callers observe normal Swift task cancellation behavior.
  • A completed task, whether successful or failed, is removed from the store.
  • waitForCurrentTask() on SharedTaskStore intentionally ignores any thrown error because it is meant for best-effort synchronization.

Actor Isolation and Thread-Safety

Both stores are actors, so reads and writes to internal state are serialized by Swift concurrency.

  • concurrent callers cannot race while inserting or clearing the shared task reference
  • keyed tasks are managed per identifier within actor isolation
  • the optional concurrency limit uses an internal actor-based AsyncSemaphore

AsyncSemaphore is intentionally internal in this package. It exists only to support KeyedSharedTaskStore without expanding the public API surface beyond task-deduplication primitives.

API

public actor SharedTaskStore<Success: Sendable> {
    public init()

    public func task(
        orCreate createTask: @Sendable () -> Task<Success, any Error>
    ) -> Task<Success, any Error>

    public func executeTask(
        orCreate createTask: @Sendable () -> Task<Success, any Error>
    ) async throws -> Success

    public func waitForCurrentTask() async
    public func clear()
}

public actor KeyedSharedTaskStore<Key: Hashable & Sendable, Success: Sendable> {
    public init(concurrentTaskLimit: Int? = nil)

    public func task(
        for key: Key,
        orCreate createTask: @Sendable () -> Task<Success, any Error>
    ) -> Task<Success, any Error>

    public func executeTask(
        for key: Key,
        orCreate createTask: @Sendable () -> Task<Success, any Error>
    ) async throws -> Success

    public func clear(task: Task<Success, any Error>, for key: Key)
}

Tests

The package includes XCTest coverage for:

  • reuse of a single in-flight task under concurrency
  • cleanup after successful and failed completion
  • best-effort waiting without error propagation
  • task sharing per key
  • independence across different keys
  • protection against clearing a newer task with an older completion
  • enforcement of concurrentTaskLimit

Run the test suite with:

swift test

License

This package is released under the MIT License. See LICENSE.

About

A Swift concurrency package for deduplicating in-flight async work with shared and keyed task stores.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages