Invisible link to canonical for Microformats

ADR-015 Nursery-Based Structured Concurrency


Status

Accepted

Replaces: ADR-007 (Structured Concurrency Model)

Context

Slug requires a concurrency model that is:

  • Simple and explicit, aligned with Slug’s design philosophy
  • Predictable, avoiding hidden scheduling or implicit parallelism
  • Safe by default, especially for long-running services
  • Portable across implementations (Go interpreter, bytecode VM, future runtimes)
  • Compatible with recursion and tail-call optimization (TCO)

Early designs used an actor-style model and later an async-gated structured concurrency approach. Experience implementing and using these models revealed an unnecessary distinction between asynchrony and ownership.

In practice:

  • All Slug code already executes inside a Task
  • Tasks already require a concurrency context for spawn
  • Gating await or task management behind an “async context” adds cognitive overhead without adding safety

This ADR refines the model by making nurseries the sole unit of concurrency structure.

Decision

Slug adopts a nursery-based structured concurrency model.

  • Concurrency is always available
  • Nurseries define ownership, lifetime, and failure boundaries
  • There is no concept of “async context”

1. Tasks

A Task represents a single unit of execution.

All running code executes inside a Task, including the program entry point (the root task).

Each Task owns:

  • Its lexical environment stack
  • Its call stack
  • Its current nursery stack
  • Cancellation state
  • Completion state and result

Tasks are lightweight, cooperative, and fully managed by the runtime.

2. Root Nursery

The root task begins execution with an implicit root nursery.

This guarantees:

  • spawn is always safe and well-defined
  • await task is always legal
  • No code runs outside structured concurrency

There is no “async mode” or opt-in for concurrency.

3. Nurseries

A nursery is a runtime construct that owns a set of child Tasks.

Nurseries define:

  • Task ownership and lifetime
  • Failure propagation
  • Cancellation scope
  • Optional concurrency limits

Nursery blocks

nursery fn() {
  ...
}

nursery limit 10 fn() {
  ...
}

Entering a nursery block:

  • Pushes a new nursery onto the task’s nursery stack

Exiting a nursery block:

  • Joins all remaining child tasks
  • Propagates the first unhandled child failure unless already handled

Nurseries are not lexical environments and are not derived from variable scope.

4. Dynamic Nursery Context

Each Task maintains a dynamic nursery stack.

Rules:

  • nursery {} pushes a new nursery
  • Exiting the block pops the nursery after joining children
  • spawn always registers the new Task with the current nursery
  • Spawned tasks inherit the parent Task’s current nursery

This ensures correct ownership even when spawning occurs inside:

  • Deep call stacks
  • Module-level functions
  • Higher-order functions

5. Spawning and Awaiting Tasks

Spawning

let t = spawn {
  work()
}
  • spawn always registers the new task with the current nursery
  • The returned value is the Task itself (no separate handle)

Awaiting

let result = await t within 15_000
  • await task waits for that task to complete
  • Awaiting a task consumes it from its owning nursery

Implications:

  • Awaited tasks do not re-propagate errors at nursery exit
  • Duplicate error handling is prevented
  • Nursery child buildup is avoided

await task may be used anywhere, not only inside a nursery block.

6. Error Propagation

  • Tasks may fail with runtime errors (including cancellation and timeouts)
  • Each nursery records the first unhandled child failure
  • On nursery exit, that failure is injected unless already handled

Defer semantics

  • defer onerror may handle an error by returning normally
  • If re-propagation is desired, the handler must explicitly throw err
  • defer onsuccess runs only if the final scope result is successful

Handled errors do not reappear at nursery exit.

7. Interaction with recur (TCO)

  • recur is a function-level control flow operation
  • It does not exit scopes
  • It does not run defers
  • It does not reset or close nurseries

Nurseries persist across recur iterations and only exit on:

  • Return
  • Unhandled error
  • Cancellation

8. Defer Semantics in Recursive Loops

  • defer runs on scope exit, not per iteration
  • In a recur loop, defers execute once, when the loop finally exits
  • Per-iteration cleanup must be placed in a nested block or helper function

Consequences

Positive

  • Clear and teachable mental model
  • No hidden concurrency modes
  • Concurrency structure is explicit and visible
  • Safe by default for server workloads
  • Natural integration with recursion and TCO
  • Portable across runtimes and future VMs

Negative

  • Requires understanding nursery-based ownership
  • Fire-and-forget requires explicit design
  • Misuse of defer in recursive loops requires documentation or linting

Neutral

  • No implicit parallelism; concurrency is always explicit
  • Long-running services require intentional nursery design