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
enduse 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.
Summary
Functions
Write a tasklet as a declaration rather than as nine one-line callbacks.
Static cell keyset ([] unless the tasklet overrides cell_keys/0).
Whether the tasklet journals — it exports apply_change/2.
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.
Per-Cell WAL size at which the controller solicits a snapshot (8 MiB unless overridden).
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.
The compaction threshold a class need not declare.
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.
Whether the tasklet declares the logical clock — logical_clock/0 answers true.
Per-Cell mailbox capacity as declared: the overridden callback, else the env, else 1024. The core refuses a capacity outside its range by name.
Cap on live dynamic cells (default 100_000).
Cap on a Cell's pending timers, or nil for the core's 10_000.
A change stamped from as the running version's change; :undeclared without a migration.
A snapshot stamped from as the running version's state; :undeclared without a migration.
The oldest schema version the tasklet reads (its own unless declared).
Whether the tasklet handles a firing — it exports on_timer/3.
The retired callbacks a module exports, each with what replaced it.
The schema version stamped on the tasklet's records (1 unless declared).
A JSON object read back from the controller as this tasklet's state term.
Types
Callbacks
@callback cell_keys() :: [String.t()]
@callback compact_after_bytes() :: pos_integer()
@callback compensate(outputs :: [term()], state(), Flezha.SDK.Ctx.t()) :: run_result()
@callback event_time(input :: term()) :: non_neg_integer()
@callback idle_ttl_seconds() :: pos_integer() | nil
@callback logical_clock() :: boolean()
@callback mailbox_capacity() :: pos_integer()
@callback max_cells() :: pos_integer()
@callback max_timers_per_cell() :: pos_integer()
@callback migrate_change(from_version :: pos_integer(), old_change :: term()) :: {:ok, term()} | {:error, term()}
@callback migrate_state(from_version :: pos_integer(), old_state :: term()) :: {:ok, state()} | {:error, term()}
@callback min_readable_schema_version() :: pos_integer()
@callback name() :: String.t()
@callback on_timer(Flezha.SDK.TimerFiring.t(), state(), Flezha.SDK.Ctx.t()) :: run_result()
@callback run(input :: term(), state(), Flezha.SDK.Ctx.t()) :: run_result()
@callback schema_version() :: pos_integer()
Functions
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 defs 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.
Static cell keyset ([] unless the tasklet overrides cell_keys/0).
Whether the tasklet journals — it exports apply_change/2.
@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.
@spec compact_after_bytes(module()) :: pos_integer()
Per-Cell WAL size at which the controller solicits a snapshot (8 MiB unless overridden).
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.
@spec default_compact_after_bytes() :: pos_integer()
The compaction threshold a class need not declare.
@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.
Whether the tasklet declares the logical clock — logical_clock/0 answers true.
Per-Cell mailbox capacity as declared: the overridden callback, else the env, else 1024. The core refuses a capacity outside its range by name.
Cap on live dynamic cells (default 100_000).
@spec max_timers_per_cell(module()) :: pos_integer() | nil
Cap on a Cell's pending timers, or nil for the core's 10_000.
@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.
@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.
@spec min_readable_schema_version(module()) :: pos_integer()
The oldest schema version the tasklet reads (its own unless declared).
Whether the tasklet handles a firing — it exports on_timer/3.
The retired callbacks a module exports, each with what replaced it.
@spec schema_version(module()) :: pos_integer()
The schema version stamped on the tasklet's records (1 unless declared).
A JSON object read back from the controller as this tasklet's state term.