Files
steipete--codexbar/Sources/CodexBarCLI/CLIServeOperationCoordinator.swift
2026-07-13 12:22:33 +08:00

552 lines
19 KiB
Swift

import Foundation
/// Owns at most one running source operation for each logical serve key.
///
/// Cancellation is advisory in Swift. Timed-out work therefore remains owned
/// until its source body actually exits. A config successor can wait behind it,
/// but can never overlap it.
actor CLIServeOperationCoordinator<Value: Sendable> {
typealias Instant = ContinuousClock.Instant
typealias Now = @Sendable () -> Instant
typealias SleepUntil = @Sendable (Instant) async throws -> Void
struct Snapshot: Equatable, Sendable {
let operationCount: Int
let waiterCount: Int
let timerCount: Int
let isShutDown: Bool
}
private struct Waiter {
let id: UUID
let continuation: CheckedContinuation<Value, Never>
let timeoutValue: Value
}
private enum OperationPhase {
case running
case accepting
case timedOut
}
private struct Operation {
let generation: UInt64
let fingerprint: String
var deadline: Instant?
let makeValue: @Sendable () async -> Value
let acceptValue: @Sendable (Value) async -> Value
var sourceTask: Task<Void, Never>?
var deadlineTask: Task<Void, Never>?
var waiters: [Waiter]
var phase: OperationPhase
}
private struct Slot {
var active: Operation?
var pending: Operation?
}
private struct OperationRequest: Sendable {
let key: String
let fingerprint: String
let deadline: Instant?
let timeoutValue: Value
let makeValue: @Sendable () async -> Value
let acceptValue: @Sendable (Value) async -> Value
}
private enum WaitTarget {
case active
case pending
}
private let now: Now
private let sleepUntil: SleepUntil
// Callers validate route keys and provider keys come from UsageProvider, so
// retained canceled work has a small, closed key space.
private var slots: [String: Slot] = [:]
private var nextGeneration: UInt64 = 0
private var isShutDown = false
init(
now: @escaping Now = { ContinuousClock().now },
sleepUntil: @escaping SleepUntil = { deadline in
try await ContinuousClock().sleep(until: deadline)
})
{
self.now = now
self.sleepUntil = sleepUntil
}
func value(
for key: String,
fingerprint: String,
deadline: Instant?,
timeoutValue: Value,
accept: @Sendable @escaping (Value) async -> Value = { $0 },
operation makeValue: @Sendable @escaping () async -> Value) async -> Value
{
let request = OperationRequest(
key: key,
fingerprint: fingerprint,
deadline: deadline,
timeoutValue: timeoutValue,
makeValue: makeValue,
acceptValue: accept)
guard !self.isShutDown, !self.isExpired(deadline) else { return timeoutValue }
self.expireOverdueOperations(for: key)
guard let slot = self.slots[key], let active = slot.active else {
return await self.installActive(request)
}
if active.fingerprint == fingerprint, active.phase != .timedOut {
guard self.deadlinesAreCompatible(active.deadline, deadline) else { return timeoutValue }
guard self.canJoinAccepting(active, deadline: deadline) else { return timeoutValue }
return await self.awaitValue(for: request, target: .active)
}
if let pending = slot.pending, pending.fingerprint == fingerprint {
guard self.deadlinesAreCompatible(pending.deadline, deadline) else { return timeoutValue }
return await self.awaitValue(for: request, target: .pending)
}
if let pending = self.slots[key]?.pending {
self.timeoutPending(for: key, generation: pending.generation)
}
return await self.installPending(request)
}
func shutdown() {
guard !self.isShutDown else { return }
self.isShutDown = true
let active = self.slots.compactMap { key, slot in
slot.active.map { (key, $0.generation) }
}
let pending = self.slots.compactMap { key, slot in
slot.pending.map { (key, $0.generation) }
}
for (key, generation) in pending {
self.timeoutPending(for: key, generation: generation)
}
for (key, generation) in active {
self.shutdownActive(for: key, generation: generation)
}
}
func snapshot() -> Snapshot {
let operations = self.slots.values.flatMap { slot in
[slot.active, slot.pending].compactMap(\.self)
}
return Snapshot(
operationCount: operations.count,
waiterCount: operations.reduce(0) { $0 + $1.waiters.count },
timerCount: operations.reduce(0) { $0 + ($1.deadlineTask == nil ? 0 : 1) },
isShutDown: self.isShutDown)
}
private func allocateGeneration() -> UInt64 {
self.nextGeneration &+= 1
return self.nextGeneration
}
private func installActive(_ request: OperationRequest) async -> Value {
let generation = self.allocateGeneration()
let waiterID = UUID()
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard !Task.isCancelled, !self.isShutDown, !self.isExpired(request.deadline) else {
continuation.resume(returning: request.timeoutValue)
return
}
var operation = self.makeOperation(
request,
generation: generation,
waiter: Waiter(
id: waiterID,
continuation: continuation,
timeoutValue: request.timeoutValue))
operation.sourceTask = self.makeSourceTask(
key: request.key,
generation: generation,
makeValue: request.makeValue)
operation.deadlineTask = self.makeDeadlineTask(
key: request.key,
generation: generation,
deadline: request.deadline)
self.slots[request.key] = Slot(active: operation, pending: nil)
}
} onCancel: {
Task { await self.cancelWaiter(id: waiterID, for: request.key) }
}
}
private func installPending(_ request: OperationRequest) async -> Value {
guard self.slots[request.key]?.active != nil else {
return await self.installActive(request)
}
let generation = self.allocateGeneration()
let waiterID = UUID()
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard !Task.isCancelled, !self.isShutDown, !self.isExpired(request.deadline) else {
continuation.resume(returning: request.timeoutValue)
return
}
guard var slot = self.slots[request.key], slot.active != nil else {
continuation.resume(returning: request.timeoutValue)
return
}
var operation = self.makeOperation(
request,
generation: generation,
waiter: Waiter(
id: waiterID,
continuation: continuation,
timeoutValue: request.timeoutValue))
operation.deadlineTask = self.makeDeadlineTask(
key: request.key,
generation: generation,
deadline: request.deadline)
slot.pending = operation
self.slots[request.key] = slot
}
} onCancel: {
Task { await self.cancelWaiter(id: waiterID, for: request.key) }
}
}
private func awaitValue(for request: OperationRequest, target: WaitTarget) async -> Value {
let waiterID = UUID()
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard !Task.isCancelled, !self.isShutDown, !self.isExpired(request.deadline),
var slot = self.slots[request.key]
else {
continuation.resume(returning: request.timeoutValue)
return
}
let waiter = Waiter(
id: waiterID,
continuation: continuation,
timeoutValue: request.timeoutValue)
switch target {
case .active:
guard var operation = slot.active,
operation.fingerprint == request.fingerprint,
operation.phase != .timedOut
else {
continuation.resume(returning: request.timeoutValue)
return
}
guard self.canJoinAccepting(operation, deadline: request.deadline) else {
continuation.resume(returning: request.timeoutValue)
return
}
self.tightenDeadline(&operation, to: request.deadline, for: request.key)
operation.waiters.append(waiter)
slot.active = operation
case .pending:
guard var operation = slot.pending,
operation.fingerprint == request.fingerprint
else {
continuation.resume(returning: request.timeoutValue)
return
}
self.tightenDeadline(&operation, to: request.deadline, for: request.key)
operation.waiters.append(waiter)
slot.pending = operation
}
self.slots[request.key] = slot
}
} onCancel: {
Task { await self.cancelWaiter(id: waiterID, for: request.key) }
}
}
private func makeOperation(
_ request: OperationRequest,
generation: UInt64,
waiter: Waiter) -> Operation
{
Operation(
generation: generation,
fingerprint: request.fingerprint,
deadline: request.deadline,
makeValue: request.makeValue,
acceptValue: request.acceptValue,
sourceTask: nil,
deadlineTask: nil,
waiters: [waiter],
phase: .running)
}
private func makeSourceTask(
key: String,
generation: UInt64,
makeValue: @Sendable @escaping () async -> Value) -> Task<Void, Never>
{
Task.detached { [self] in
let value = await makeValue()
await self.sourceProduced(value, for: key, generation: generation)
}
}
private func makeDeadlineTask(
key: String,
generation: UInt64,
deadline: Instant?) -> Task<Void, Never>?
{
guard let deadline else { return nil }
let sleepUntil = self.sleepUntil
return Task.detached { [self] in
do {
try await sleepUntil(deadline)
} catch {
return
}
await self.deadlineReached(for: key, generation: generation)
}
}
private func cancelWaiter(id: UUID, for key: String) {
guard var slot = self.slots[key] else { return }
if var active = slot.active,
let index = active.waiters.firstIndex(where: { $0.id == id })
{
let waiter = active.waiters.remove(at: index)
slot.active = active
self.slots[key] = slot
waiter.continuation.resume(returning: waiter.timeoutValue)
if active.waiters.isEmpty, active.phase == .running {
self.timeoutActive(for: key, generation: active.generation)
}
return
}
guard var pending = slot.pending,
let index = pending.waiters.firstIndex(where: { $0.id == id })
else {
return
}
let waiter = pending.waiters.remove(at: index)
waiter.continuation.resume(returning: waiter.timeoutValue)
if pending.waiters.isEmpty {
pending.deadlineTask?.cancel()
slot.pending = nil
} else {
slot.pending = pending
}
self.store(slot, for: key)
}
private func deadlineReached(for key: String, generation: UInt64) {
if self.slots[key]?.active?.generation == generation {
self.timeoutActive(for: key, generation: generation)
} else if self.slots[key]?.pending?.generation == generation {
self.timeoutPending(for: key, generation: generation)
}
}
private func expireOverdueOperations(for key: String) {
if let active = self.slots[key]?.active,
active.phase == .running,
self.isExpired(active.deadline)
{
self.timeoutActive(for: key, generation: active.generation)
}
if let pending = self.slots[key]?.pending,
self.isExpired(pending.deadline)
{
self.timeoutPending(for: key, generation: pending.generation)
}
}
private func timeoutActive(for key: String, generation: UInt64) {
guard var slot = self.slots[key],
var operation = slot.active,
operation.generation == generation,
operation.phase == .running
else {
return
}
operation.phase = .timedOut
operation.sourceTask?.cancel()
operation.deadlineTask?.cancel()
operation.deadlineTask = nil
let waiters = operation.waiters
operation.waiters.removeAll()
slot.active = operation
self.slots[key] = slot
for waiter in waiters {
waiter.continuation.resume(returning: waiter.timeoutValue)
}
}
private func shutdownActive(for key: String, generation: UInt64) {
guard var slot = self.slots[key],
var operation = slot.active,
operation.generation == generation
else {
return
}
if operation.phase == .running {
self.timeoutActive(for: key, generation: generation)
return
}
guard operation.phase == .accepting else { return }
operation.phase = .timedOut
operation.sourceTask?.cancel()
let waiters = operation.waiters
operation.waiters.removeAll()
slot.active = operation
self.slots[key] = slot
for waiter in waiters {
waiter.continuation.resume(returning: waiter.timeoutValue)
}
}
private func timeoutPending(for key: String, generation: UInt64) {
guard var slot = self.slots[key],
let operation = slot.pending,
operation.generation == generation
else {
return
}
operation.deadlineTask?.cancel()
slot.pending = nil
self.store(slot, for: key)
for waiter in operation.waiters {
waiter.continuation.resume(returning: waiter.timeoutValue)
}
}
private func sourceProduced(_ value: Value, for key: String, generation: UInt64) async {
guard var slot = self.slots[key],
let observed = slot.active,
observed.generation == generation
else {
return
}
if observed.phase == .running, self.isExpired(observed.deadline) {
self.timeoutActive(for: key, generation: generation)
guard let refreshed = self.slots[key] else { return }
slot = refreshed
}
guard var operation = slot.active, operation.generation == generation else { return }
if operation.phase == .timedOut {
slot.active = nil
self.store(slot, for: key)
self.promotePending(for: key)
return
}
// Source completion wins the deadline only on this actor turn. `accept`
// is restricted to bounded in-process projection/cache work: keep the
// slot owned through that commit so a newer generation cannot start and
// then be overwritten by this older result. Shutdown still releases the
// waiters even if a commit is briefly queued on another actor.
operation.phase = .accepting
operation.deadlineTask?.cancel()
operation.deadlineTask = nil
slot.active = operation
self.slots[key] = slot
let acceptedValue = await operation.acceptValue(value)
guard var refreshed = self.slots[key],
let completed = refreshed.active,
completed.generation == generation
else {
return
}
refreshed.active = nil
self.store(refreshed, for: key)
if completed.phase == .accepting {
for waiter in completed.waiters {
waiter.continuation.resume(returning: acceptedValue)
}
}
self.promotePending(for: key)
}
private func promotePending(for key: String) {
guard !self.isShutDown,
var slot = self.slots[key],
slot.active == nil,
var pending = slot.pending
else {
return
}
if self.isExpired(pending.deadline) {
self.timeoutPending(for: key, generation: pending.generation)
return
}
pending.sourceTask = self.makeSourceTask(
key: key,
generation: pending.generation,
makeValue: pending.makeValue)
slot.active = pending
slot.pending = nil
self.slots[key] = slot
}
private func tightenDeadline(_ operation: inout Operation, to requested: Instant?, for key: String) {
guard operation.phase == .running,
let current = operation.deadline,
let requested,
requested < current
else {
return
}
operation.deadlineTask?.cancel()
operation.deadline = requested
operation.deadlineTask = self.makeDeadlineTask(
key: key,
generation: operation.generation,
deadline: requested)
}
private func deadlinesAreCompatible(_ active: Instant?, _ requested: Instant?) -> Bool {
(active == nil) == (requested == nil)
}
private func canJoinAccepting(_ operation: Operation, deadline: Instant?) -> Bool {
guard operation.phase == .accepting,
let activeDeadline = operation.deadline,
let deadline
else {
return true
}
// Source acceptance already canceled the shared timer. An older-budget
// follower cannot safely shorten that completed arbitration while the
// bounded cache commit is in progress, so fail it closed.
return deadline >= activeDeadline
}
private func isExpired(_ deadline: Instant?) -> Bool {
guard let deadline else { return false }
return deadline <= self.now()
}
private func store(_ slot: Slot, for key: String) {
if slot.active == nil, slot.pending == nil {
self.slots[key] = nil
} else {
self.slots[key] = slot
}
}
}