Packages

A concurrent programming library for Gleam that provides Elixir-like Task module functionality.

Current section

Files

Jump to
taskle src taskle.gleam
Raw

src/taskle.gleam

import gleam/bool
import gleam/erlang/process.{type Down, type Monitor, type Pid, type Subject}
import gleam/list
import gleam/string
/// A task represents an asynchronous computation running in a separate BEAM process.
/// Tasks are created with `async` and their results are retrieved with `await`.
/// Only the process that created a task can await its result or cancel it.
pub opaque type Task(a) {
Task(pid: Pid, monitor: Monitor, owner: Pid, subject: Subject(TaskMessage(a)))
}
pub type Error {
/// The task didn't complete within the specified timeout.
Timeout
/// The task process crashed with the given reason.
Crashed(reason: String)
/// Attempted to await or cancel a task from a different process than the one that created it.
NotOwner
}
pub type TaskMessage(a) {
TaskResult(value: a)
}
type AwaitMsg(a) {
TaskMsg(TaskMessage(a))
DownMsg(Down)
}
/// Creates an asynchronous task that runs the given function in a separate process.
///
/// The task is unlinked from the calling process, meaning that if the task crashes,
/// it won't cause the calling process to crash. Only the process that creates the
/// task can await its result or cancel it.
///
/// ## Examples
///
/// ```gleam
/// let task = taskle.async(fn() {
/// // This runs in a separate process
/// process.sleep(1000)
/// 42
/// })
/// ```
pub fn async(fun: fn() -> a) -> Task(a) {
let owner = process.self()
let subject = process.new_subject()
let pid =
process.spawn_unlinked(fn() {
let result = fun()
process.send(subject, TaskResult(result))
})
let monitor = process.monitor(pid)
Task(pid:, monitor:, owner:, subject:)
}
/// Waits for a task to complete with a timeout in milliseconds.
///
/// Only the process that created the task can await its result. If called from
/// a different process, returns `Error(NotOwner)`.
///
/// ## Examples
///
/// ```gleam
/// case taskle.await(task, 5000) {
/// Ok(value) -> io.println("Success: " <> int.to_string(value))
/// Error(taskle.Timeout) -> io.println("Task timed out after 5 seconds")
/// Error(taskle.Crashed(reason)) -> io.println("Task failed: " <> reason)
/// Error(taskle.NotOwner) -> io.println("Cannot await task from different process")
/// }
/// ```
pub fn await(task: Task(a), timeout: Int) -> Result(a, Error) {
let Task(pid: task_pid, monitor:, owner:, subject:) = task
use <- bool.guard(process.self() != owner, Error(NotOwner))
let selector =
process.new_selector()
|> process.select_specific_monitor(monitor, DownMsg)
|> process.select_map(subject, TaskMsg)
case process.selector_receive(from: selector, within: timeout) {
Ok(TaskMsg(TaskResult(value))) -> {
process.demonitor_process(monitor)
Ok(value)
}
Ok(DownMsg(process.ProcessDown(pid: _, monitor: _, reason: reason))) -> {
case reason {
process.Normal -> Error(Crashed("normal"))
process.Killed -> Error(Crashed("killed"))
process.Abnormal(reason) -> Error(Crashed(string.inspect(reason)))
}
}
Ok(DownMsg(process.PortDown(..))) -> {
Error(Crashed("port_down"))
}
Error(Nil) -> {
process.kill(task_pid)
process.demonitor_process(monitor)
Error(Timeout)
}
}
}
/// Cancels a running task.
///
/// Only the process that created the task can cancel it. If called from
/// a different process, returns `Error(NotOwner)`.
///
/// ## Examples
///
/// ```gleam
/// let task = taskle.async(fn() {
/// process.sleep(10_000)
/// "done"
/// })
///
/// // Cancel the task
/// case taskle.cancel(task) {
/// Ok(Nil) -> io.println("Task cancelled")
/// Error(taskle.NotOwner) -> io.println("Cannot cancel task from different process")
/// }
/// ```
pub fn cancel(task: Task(a)) -> Result(Nil, Error) {
let Task(pid:, monitor:, owner:, ..) = task
use <- bool.guard(process.self() != owner, Error(NotOwner))
process.kill(pid)
process.demonitor_process(monitor)
Ok(Nil)
}
/// Processes a list of items in parallel, applying the given function to each item.
///
/// Returns when all tasks complete or when any task fails/times out. If any task
/// fails, all remaining tasks are cancelled.
///
/// ## Examples
///
/// ```gleam
/// let numbers = [1, 2, 3, 4, 5]
///
/// case taskle.parallel_map(numbers, fn(x) { x * x }, 5000) {
/// Ok(results) -> {
/// // results = [1, 4, 9, 16, 25]
/// io.debug(results)
/// }
/// Error(taskle.Timeout) -> io.println("Some tasks timed out")
/// Error(taskle.Crashed(reason)) -> io.println("A task crashed: " <> reason)
/// }
/// ```
pub fn parallel_map(
list: List(a),
fun: fn(a) -> b,
timeout: Int,
) -> Result(List(b), Error) {
let tasks = list.map(list, fn(item) { async(fn() { fun(item) }) })
await_all(tasks, timeout)
}
fn await_all(tasks: List(Task(a)), timeout: Int) -> Result(List(a), Error) {
case tasks {
[] -> Ok([])
_ -> {
let start_time = system_time_nanoseconds()
await_all_loop(tasks, [], timeout, start_time)
}
}
}
fn await_all_loop(
tasks: List(Task(a)),
results: List(a),
timeout: Int,
start_time: Int,
) -> Result(List(a), Error) {
case tasks {
[] -> Ok(list.reverse(results))
[task, ..rest] -> {
let elapsed = system_time_nanoseconds() - start_time
let remaining_timeout = timeout - elapsed / 1_000_000
use <- bool.lazy_guard(remaining_timeout <= 0, fn() {
list.each(rest, cancel)
Error(Timeout)
})
case await(task, remaining_timeout) {
Ok(result) ->
await_all_loop(rest, [result, ..results], timeout, start_time)
Error(err) -> {
list.each(rest, cancel)
Error(err)
}
}
}
}
}
/// Creates an asynchronous task that is not linked to the calling process.
///
/// This is identical to `async`, but provided for clarity when you specifically
/// want to emphasize that the task is unlinked. Use this for fire-and-forget
/// scenarios where you don't want the parent process to be affected if the task crashes.
///
/// ## Examples
///
/// ```gleam
/// let fire_and_forget = taskle.async_unlinked(fn() {
/// // This task won't affect the parent process if it crashes
/// risky_operation()
/// })
/// ```
pub fn async_unlinked(fun: fn() -> a) -> Task(a) {
let owner = process.self()
let subject = process.new_subject()
let pid =
process.spawn_unlinked(fn() {
let result = fun()
process.send(subject, TaskResult(result))
})
let monitor = process.monitor(pid)
Task(pid:, monitor:, owner:, subject:)
}
/// Returns the process ID of the task's underlying BEAM process.
///
/// Useful for debugging or process monitoring.
///
/// ## Examples
///
/// ```gleam
/// let task = taskle.async(fn() { 42 })
/// let process_id = taskle.pid(task)
/// io.debug(process_id)
/// ```
pub fn pid(task: Task(a)) -> Pid {
let Task(pid: pid, ..) = task
pid
}
@external(erlang, "erlang", "system_time")
fn system_time_nanoseconds() -> Int