# `Flezha.SDK.Tasklet`
[🔗](https://github.com/flezha/flezha_elixir_sdk/blob/v0.1.0/lib/flezha/sdk/tasklet.ex#L1)

A tasklet: one processing stage of a relay's graph, run once per
input and declared with `use`.

    defmodule Echo do
      use Flezha.SDK.Tasklet, name: "echo"

      @impl true
      def run(input, state, ctx) do
        emit(ctx, "echo: " <> input)
        {:ok, state}
      end
    end

`use` declares this behaviour, defines `name/0` and the zero-arity callbacks
its options name, imports the ctx functions, and makes available a `state`
block, which writes `init_state/1` and `state_from_json/1`, and `defchange`,
the one way to change state (`Flezha.SDK.State`); see `__using__/1`. The
author writes `run/3` and any other callback that takes arguments, each
under `@impl true`.

Callbacks. Mandatory: `name/0`, `init_state/1` (the cell key or nil; `use`
answers `nil` for a module that declares no state), `run/3` (input, state,
ctx) returning
`{:ok, new_state} | {:error, reason} | {:error, {:non_retryable, reason}}`.
Optional, with defaults: `cell_keys/0` (static keyset, `[]`; a keyset is
refused as `topology.cells_for_missing` without `cells_for/1`, which names
the keys an input routes to),
`mailbox_capacity/0` (1024, env `FLEZHA_MAILBOX_CAPACITY`, range 1..=1_048_576),
`max_cells/0` (100_000 — the cap on a dynamic node's live Cells; at it
a new key's input is dropped and counted), `idle_ttl_seconds/0` (nil — a
dynamic Cell idle past that many whole seconds is evicted destructively by
its Relay's sweep; nil never evicts). Dynamic typing collapses the Rust
type tokens — Input/Output/State are plain terms.

Persistence: a tasklet that exports `apply_change/2` is changeful — its
state is rebuilt by folding recorded changes. In the `use` form each
`defchange` generates one fold clause and its recorder; without one the
tasklet is the reference's `NoChange`: its state is a plain term nothing
persists. `compact_after_bytes/0` (8 MiB) is the per-Cell WAL
size at which the controller solicits a snapshot; `state_from_json/1` turns
a JSON object read back from the controller (a migration, `init_values`)
into this tasklet's state term, the object itself by default.

Schema: `schema_version/0` (1) is stamped on every record and resolved per
record on restore against `min_readable_schema_version/0` (the version
itself); a record between the floor and the version is handed to
`migrate_state/2` (a snapshot) or `migrate_change/2` (a change) with the
stamped version and the DECODED term — this SDK's body codec is the
language's own — answering `{:ok, term}` at the running version or
`{:error, reason}`. Both default to refusing, so a floor declared without
its migration fails at the first old record.

Time: the clock `Flezha.SDK.Ctx.now/1` serves follows from what the tasklet
writes — event time when it defines `event_time/1` (what that answers for
the input), the logical clock (the Cell's durable change seq) when
`logical_clock/0` answers `true`, the epoch millisecond at dispatch entry
otherwise. A logical clock on a changeless tasklet is refused at
registration as `topology.unservable_clock`, and both clocks at once as `topology.two_clocks`,
which the `use` form refuses at compile time instead. Timers:
`on_timer/3` (a `Flezha.SDK.TimerFiring`, the state, a ctx) answers
what `run/3` answers and is what a timer registered through the ctx fires
into; `max_timers_per_cell/0` (10_000) bounds a Cell's pending set, and an
item whose registrations pass it fails, not retried, when the core settles it.

Saga: `compensate/3` (the outputs the Cell released, the state, a ctx) is
the undo a steps relay runs, in reverse completion order, when it
unwinds after partial progress. A tasklet declaring none has nothing to
undo. A crash-resumed unwind reconstructs completed stages with EMPTY
outputs — a tasklet's collected output was never durable — so a hook that
records an undo must do so unconditionally rather than from what it is
handed.

## The reference form

A module may instead write `@behaviour Flezha.SDK.Tasklet` and every
callback by hand, `name/0` included, recording a change through its own
`apply_change/2` and `Flezha.SDK.Ctx.apply/3`. It registers and runs as a
`use` module does; the `use` form refuses a hand-written `apply_change/2`.

# `run_result`

```elixir
@type run_result() ::
  {:ok, state()} | {:error, term()} | {:error, {:non_retryable, term()}}
```

# `state`

```elixir
@type state() :: term()
```

# `apply_change`
*optional* 

```elixir
@callback apply_change(state(), change :: term()) :: state()
```

# `cell_keys`
*optional* 

```elixir
@callback cell_keys() :: [String.t()]
```

# `cells_for`
*optional* 

```elixir
@callback cells_for(input :: term()) :: [String.t()]
```

# `compact_after_bytes`
*optional* 

```elixir
@callback compact_after_bytes() :: pos_integer()
```

# `compensate`
*optional* 

```elixir
@callback compensate(outputs :: [term()], state(), Flezha.SDK.Ctx.t()) :: run_result()
```

# `event_time`
*optional* 

```elixir
@callback event_time(input :: term()) :: non_neg_integer()
```

# `idle_ttl_seconds`
*optional* 

```elixir
@callback idle_ttl_seconds() :: pos_integer() | nil
```

# `init_state`

```elixir
@callback init_state(cell_key :: String.t() | nil) :: state()
```

# `logical_clock`
*optional* 

```elixir
@callback logical_clock() :: boolean()
```

# `mailbox_capacity`
*optional* 

```elixir
@callback mailbox_capacity() :: pos_integer()
```

# `max_cells`
*optional* 

```elixir
@callback max_cells() :: pos_integer()
```

# `max_timers_per_cell`
*optional* 

```elixir
@callback max_timers_per_cell() :: pos_integer()
```

# `migrate_change`
*optional* 

```elixir
@callback migrate_change(from_version :: pos_integer(), old_change :: term()) ::
  {:ok, term()} | {:error, term()}
```

# `migrate_state`
*optional* 

```elixir
@callback migrate_state(from_version :: pos_integer(), old_state :: term()) ::
  {:ok, state()} | {:error, term()}
```

# `min_readable_schema_version`
*optional* 

```elixir
@callback min_readable_schema_version() :: pos_integer()
```

# `name`

```elixir
@callback name() :: String.t()
```

# `on_timer`
*optional* 

```elixir
@callback on_timer(Flezha.SDK.TimerFiring.t(), state(), Flezha.SDK.Ctx.t()) ::
  run_result()
```

# `run`

```elixir
@callback run(input :: term(), state(), Flezha.SDK.Ctx.t()) :: run_result()
```

# `schema_version`
*optional* 

```elixir
@callback schema_version() :: pos_integer()
```

# `state_from_json`
*optional* 

```elixir
@callback state_from_json(map()) :: state()
```

# `__using__`
*macro* 

Write a tasklet as a declaration rather than as nine one-line callbacks.

    use Flezha.SDK.Tasklet, name: "position", max_cells: 50_000

    @impl true
    def run(_input, state, _ctx), do: {:ok, state}

`name` is required. Every other option is one of the zero-arity callbacks
above, under its own name, and **only the options written define anything**:
a module that does not write `mailbox_capacity` exports none, so
`FLEZHA_MAILBOX_CAPACITY` still decides its mailbox. Callbacks that take
arguments — `run/3`, `cells_for/1`, `event_time/1`, `on_timer/3`,
`compensate/3`, `migrate_state/2`, `migrate_change/2` — stay `def`s the
author writes. An unknown option is a `CompileError` naming it.
`idle_ttl_seconds` takes a positive integer and `logical_clock` a boolean,
any other value being a `CompileError`; defining `event_time/1` beside
`logical_clock: true` is one too, naming both.

`state do field … end` and `defchange` are available here too; see
`Flezha.SDK.State`. A module with neither a `state` block nor its own
`init_state/1` gets `def init_state(_), do: nil`, so a stateless tasklet
writes only `run/3`. `apply_change/2` is not written by hand here:
`defchange` generates it.

`output` and `input` describe a relay, not a tasklet, and are a
`CompileError` naming the relay macro: the §5 short form is written
with `use Flezha.SDK.Relay`.

## Nesting

A tasklet nested inside a relay module inherits that module's imports
lexically, so `use` writes `import Flezha.SDK.ChannelBuilder, only: []` to take
`input/2` and `output/2` back — a nested tasklet defining its own `input/0`
would otherwise clash. A nested tasklet must be written **before** the
`channels/1` that names it, which is how Elixir aliases resolve.

# `cell_keys`

Static cell keyset (`[]` unless the tasklet overrides `cell_keys/0`).

# `changeful?`

```elixir
@spec changeful?(module()) :: boolean()
```

Whether the tasklet journals — it exports `apply_change/2`.

# `clock`

```elixir
@spec clock(module()) :: :wall | :logical | :event_time
```

The clock the tasklet runs on, from what it writes: `:event_time` when it
exports `event_time/1`, `:logical` when `logical_clock/0` answers `true`,
`:wall` otherwise.

# `compact_after_bytes`

```elixir
@spec compact_after_bytes(module()) :: pos_integer()
```

Per-Cell WAL size at which the controller solicits a snapshot (8 MiB unless overridden).

# `compensates?`

```elixir
@spec compensates?(module()) :: boolean()
```

Whether this tasklet declares a saga undo hook. A steps relay that unwinds
runs `compensate/3` on every Cell whose tasklet completed forward; a
tasklet declaring none simply has nothing to undo.

# `default_compact_after_bytes`

```elixir
@spec default_compact_after_bytes() :: pos_integer()
```

The compaction threshold a class need not declare.

# `idle_ttl_ms`

```elixir
@spec idle_ttl_ms(module()) :: pos_integer() | nil
```

The idle window after which a dynamic Cell is evicted, in milliseconds: the
declared `idle_ttl_seconds/0` times 1 000; nil (the default) never evicts.

# `logical_clock?`

```elixir
@spec logical_clock?(module()) :: boolean()
```

Whether the tasklet declares the logical clock — `logical_clock/0` answers `true`.

# `mailbox_capacity`

Per-Cell mailbox capacity as declared: the overridden callback, else the env,
else 1024. The core refuses a capacity outside its range by name.

# `max_cells`

Cap on live dynamic cells (default 100_000).

# `max_timers_per_cell`

```elixir
@spec max_timers_per_cell(module()) :: pos_integer() | nil
```

Cap on a Cell's pending timers, or nil for the core's 10_000.

# `migrate_change`

```elixir
@spec migrate_change(module(), pos_integer(), term()) ::
  {:ok, term()} | {:error, term()} | :undeclared
```

A change stamped `from` as the running version's change; `:undeclared` without a migration.

# `migrate_state`

```elixir
@spec migrate_state(module(), pos_integer(), term()) ::
  {:ok, state()} | {:error, term()} | :undeclared
```

A snapshot stamped `from` as the running version's state; `:undeclared` without a migration.

# `min_readable_schema_version`

```elixir
@spec min_readable_schema_version(module()) :: pos_integer()
```

The oldest schema version the tasklet reads (its own unless declared).

# `on_timer?`

```elixir
@spec on_timer?(module()) :: boolean()
```

Whether the tasklet handles a firing — it exports `on_timer/3`.

# `retired_callbacks`

```elixir
@spec retired_callbacks(module()) :: [{String.t(), String.t()}]
```

The retired callbacks a module exports, each with what replaced it.

# `schema_version`

```elixir
@spec schema_version(module()) :: pos_integer()
```

The schema version stamped on the tasklet's records (1 unless declared).

# `state_from_json`

```elixir
@spec state_from_json(module(), map()) :: state()
```

A JSON object read back from the controller as this tasklet's state term.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
