Skip to content

shensven/swift-sequential-executor

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

52 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

Swift Sequential Executor

English๏ฝœ็ฎ€ไฝ“ไธญๆ–‡

Run async tasks one at a time, on schedule or on demand.

Why Not Just Use Timer

Timer.scheduledTimer(...) is suitable for requirements like "trigger a callback once after a while." But when that callback needs to perform asynchronous work, callers often still have to deal with the concurrency coordination problems themselves.

What SequentialExecutor Provides

  • Runs async tasks on a fixed interval
  • Supports preemptively triggering an immediate async execution
  • Uses a state machine to coordinate interval waiting, async task execution, and immediate trigger requests across different runtime states
  • Provides a state-machine event callback interface for logging, monitoring, or UI integration
  • Full API Documentation

Tip

The core API stays focused on execute, updatePolicy(_:), and runNow().

Everything else stays internal ;-)

Requirements

Platform Swift Version Installation Status
macOS 13.0+
iOS 16.0+
tvOS 16.0+
watchOS 9.0+
visionOS 1.0+
Swift 6.0+ / Xcode 16.0+ Swift Package Manager Apple Tests
Linux Swift 6.0+ Swift Package Manager Linux Tests
Windows Swift 6.1+ Swift Package Manager Windows Tests

Installation

Swift Package Manager

Once your Swift package or Xcode project is set up, add swift-sequential-executor to dependencies in Package.swift, or add it to the package dependency list in Xcode.

The example below uses the published 1.0.0 release:

dependencies: [
    .package(url: "https://github.com/shensven/swift-sequential-executor.git", from: "1.0.0")
]

Then depend on the SequentialExecutor product from your target:

targets: [
    .target(
        name: "YourTarget",
        dependencies: [
            .product(name: "SequentialExecutor", package: "swift-sequential-executor")
        ]
    )
]

Quick Start

import Foundation
import SequentialExecutor

let executor = SequentialExecutor(
    execute: { context in
        print("triggered by \(context.source)")
        try await Task.sleep(for: .seconds(2))
    },
    eventHandler: { event in
        print(event.kind)
    }
)

await executor.updatePolicy(.init(runLoop: .interval(.seconds(5))))
// await executor.runNow()

You can run this from any async context, such as app startup, an async test, or a Task. Each time execution begins, the executor passes the current ExecutionContext into the execute closure; updatePolicy(_:) starts fixed-interval scheduling, and runNow() triggers an immediate execution.

If you do not need the execute parameter to receive a context value from the initializer, you can also use the simpler convenience initializer:

let executor = SequentialExecutor {
    try await Task.sleep(for: .seconds(2))
}

Note: if event handling itself is heavier work, or if you would rather consume events as an async stream, you can subscribe through events() instead:

let executor = SequentialExecutor {
    try await Task.sleep(for: .seconds(2))
}

let eventTask = Task {
    for await event in await executor.events() {
        print(event.kind)
    }
}

await executor.runNow()

If you want to debug fuller runtime behavior, continue with the Example App.

Behavior

From a usage perspective, there are 3 core behaviors to keep in mind:

  • Only one async task runs at a time
  • Tasks can run on a fixed interval or be triggered immediately when needed
  • When a new task needs to take over, the current task is asked to exit through cooperative task cancellation instead of being interrupted forcefully

If you only care about integrating it into your project, this is usually enough. If you want to understand the full state machine design, continue with the coordination model and handoff flow below.

Coordination Model

The executor has 5 main states, and the diagram below shows how they flow:

  • Idle: no task is running and no immediate request is pending
  • Waiting: waiting for the next scheduled trigger
  • ScheduledExecution: a scheduled task is running
  • ImmediateRequestPending: an immediate request has arrived and handoff is in progress
  • ImmediateExecution: an immediately triggered task is running
flowchart TD
    Idle["Idle"]
    Waiting["Waiting"]
    ScheduledExecution["ScheduledExecution"]
    ImmediateRequestPending["ImmediateRequestPending"]
    ImmediateExecution["ImmediateExecution"]

    Idle -->|Enable scheduling| Waiting
    Idle -->|Trigger immediately| ImmediateExecution

    Waiting -->|Interval elapsed| ScheduledExecution
    Waiting -->|Immediate request arrives| ImmediateRequestPending
    Waiting -->|Disable scheduling| Idle

    ScheduledExecution -->|Task finishes, scheduling still enabled| Waiting
    ScheduledExecution -->|Task finishes, scheduling disabled| Idle
    ScheduledExecution -->|Immediate request arrives| ImmediateRequestPending

    ImmediateRequestPending -->|Handoff completes| ImmediateExecution

    ImmediateExecution -->|Task finishes, scheduling still enabled| Waiting
    ImmediateExecution -->|Task finishes, scheduling disabled| Idle
    ImmediateExecution -->|A newer immediate request arrives| ImmediateRequestPending
Loading
  • If the interval is updated while in Waiting, the executor remains in Waiting
  • If a newer immediate request arrives while in ImmediateRequestPending, the state does not change, but the older pending request yields to the newest one
Handoff Flow

When you trigger an immediate run, the executor does not pile a new task on top of the current one. It first clears the current state, then hands control over to the new run.

More specifically:

  • If the executor is still waiting for the next scheduled trigger, that wait ends first
  • If a task is already running, the executor asks it to exit safely through cooperative cancellation
  • The new immediate task starts only after the previous task has actually finished
  • If multiple immediate requests arrive during handoff, the latest one takes over and older pending requests yield
  • If the current task does not cooperate with cancellation, the new immediate task has to keep waiting
  • After the immediate task finishes, the executor goes back to waiting if scheduling is still enabled

The sequence diagram below shows a typical path where a task is already running and an immediate trigger arrives:

sequenceDiagram
    participant Caller
    participant Executor
    participant CurrentTask as current task
    participant NextTask as next immediate task

    Note over Executor,CurrentTask: Scheduling is active and the current task is still running

    Caller->>Executor: Trigger immediately
    Executor->>CurrentTask: Request cancellation
    CurrentTask-->>Executor: Exit safely
    Executor->>NextTask: Start immediate task
    Note over NextTask: Run async task

    alt Task finishes normally
        NextTask-->>Executor: Task finished
    else Task throws
        NextTask-->>Executor: Task failed
    else Task is cancelled
        NextTask-->>Executor: Task cancelled
    end

    opt Scheduling is still enabled
        Executor-->>Executor: Return to waiting
    end
Loading

Example App

The repository includes a SwiftUI example app at Examples/SequentialExecutorExample.

You can use it to debug and observe the runtime behavior of SequentialExecutor, including scheduling loop changes, immediate execution, cancellation coordination, and the emission order of lifecycle events. The example keeps visible state event-driven, which makes it easier to inspect waiting and execution timeline changes directly.

License

swift-sequential-executor is released under the MIT License. See LICENSE for details.

About

๐Ÿ”„ Run async tasks one at a time, on schedule or on demand

Topics

Resources

License

Stars

Watchers

Forks

Languages