CFA Reference: Task Ownership and Overlapping Calls
Formal CFA AI Toolkit for iOS Documentation · Reference 15 of 26
Every meaningful Task in CFA has an owner that stores it, and every async Feature operation has an explicit overlapping-call policy.
Definitions
- Share execution: Equivalent calls await one retained in-flight task and receive its outcome.
- Queue execution: Distinct commands are admitted to a sequential queue and each caller awaits its own outcome.
- Durable command: Work such as persistence that normally continues after the screen disappears.
- Replaceable work: Work such as a search that is cancelled when superseded.
- Reentrancy: Actors and @MainActor do not serialise whole operations across await; state may change at each suspension.
Rules
- Each async Feature operation defines its policy: share execution or queue execution. Other policies need a documented product reason.
- Never discard a new edit or return another record's result as duplicate suppression.
- A cancelled waiter must not cancel shared work other callers still need.
- Check cancellation before expensive work and again before publishing; a cancelled older operation must never overwrite newer state.
- Prefer async let or task groups when child work belongs to one parent operation; use unstructured Tasks only when the work must outlive the call and is stored.
- Revalidate assumptions after every await.
Decision rules
- When two callers refresh the same data, share execution.
- When each call carries a distinct edit, queue execution.
- When the user types a new search, cancel the previous search task.
- When a save is in progress and the screen closes, let the save continue.
- When an observer must live as long as the app, store it on the app-scoped owner.
Example
The compiled Feature shares one refresh between startLoading() and refresh(). The task is created once, stored, awaited by every caller, and cleared in defer.
Compiled example (excerpt)
import Foundation
import Observation
// MARK: Feature: the authoritative owner of habits
@MainActor
@Observable
final class HabitsFeature {
@ObservationIgnored private var refreshTask: Task<Void, Never>?
private let repository: any HabitsRepository
private(set) var habits: [Habit] = []
private(set) var isLoading = false
private(set) var loadError: String?
init(repository: any HabitsRepository) {
self.repository = repository
}
/// Synchronous launch entry point: initiates work and returns.
func startLoading() {
_ = sharedRefreshTask()
}
/// Every caller awaits the one in-flight refresh.
func refresh() async {
await sharedRefreshTask().value
}
private func sharedRefreshTask() -> Task<Void, Never> {
if let refreshTask { return refreshTask }
let newRefreshTask = Task { [repository] in
await self.performRefresh(using: repository)
}
refreshTask = newRefreshTask
return newRefreshTask
}
private func performRefresh(using repository: any HabitsRepository) async {
defer { refreshTask = nil }
isLoading = true
defer { isLoading = false }
do {
habits = try await repository.fetchHabits()
loadError = nil
} catch {
loadError = error.localizedDescription
}
}
func addHabit(named name: String) async throws {
let cleanedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanedName.isEmpty else { throw HabitError.invalidName }
let habit = Habit(id: UUID(), name: cleanedName)
try await repository.insertHabit(habit)
habits.append(habit)
}
}Compiled with Swift 6 language mode and warnings treated as errors: type-checked on Swift 6.2 and built on Swift 6.4 (Linux toolchains, Foundation and Observation only).
The matching test below checks that a launch refresh and a screen refresh reach storage only once. It uses Swift Testing and was type-checked on Swift 6.2 and built on Swift 6.4 with warnings as errors.
Compiled test (Swift Testing)
import Foundation
import Testing
/// A repository double that records how many fetches reached storage.
actor CountingHabitsRepository: HabitsRepository {
private(set) var fetchCount = 0
private let storedHabits: [Habit]
init(storedHabits: [Habit]) {
self.storedHabits = storedHabits
}
func fetchHabits() async throws -> [Habit] {
fetchCount += 1
return storedHabits
}
func insertHabit(_ habit: Habit) async throws {}
}
@MainActor
struct HabitsFeatureTests {
@Test func overlappingRefreshesShareOneFetch() async throws {
let habit = Habit(id: UUID(), name: "Read")
let repository = CountingHabitsRepository(storedHabits: [habit])
let habitsFeature = HabitsFeature(repository: repository)
habitsFeature.startLoading()
await habitsFeature.refresh()
#expect(habitsFeature.habits == [habit])
#expect(habitsFeature.isLoading == false)
#expect(await repository.fetchCount == 1)
}
@Test func blankNameIsRejected() async {
let habitsFeature = HabitsFeature(repository: LocalHabitsRepository())
await #expect(throws: HabitError.invalidName) {
try await habitsFeature.addHabit(named: " ")
}
#expect(habitsFeature.habits.isEmpty)
}
}
Common mistakes
- Returning early with “already busy” so the second caller gets nothing.
- Starting Task { } in a View with no stored handle.
- Wrapping every call in Task { } instead of keeping Feature methods directly awaitable.
- Serialising independent startup work to quieten the compiler.
