Flezha.SDK.Tasklet behaviour (flezha_sdk v0.1.0)

Copy Markdown View Source

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.

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

run_result()

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

state()

@type state() :: term()

Callbacks

apply_change(state, change)

(optional)
@callback apply_change(state(), change :: term()) :: state()

cell_keys()

(optional)
@callback cell_keys() :: [String.t()]

cells_for(input)

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

compact_after_bytes()

(optional)
@callback compact_after_bytes() :: pos_integer()

compensate(outputs, state, t)

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

event_time(input)

(optional)
@callback event_time(input :: term()) :: non_neg_integer()

idle_ttl_seconds()

(optional)
@callback idle_ttl_seconds() :: pos_integer() | nil

init_state(cell_key)

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

logical_clock()

(optional)
@callback logical_clock() :: boolean()

mailbox_capacity()

(optional)
@callback mailbox_capacity() :: pos_integer()

max_cells()

(optional)
@callback max_cells() :: pos_integer()

max_timers_per_cell()

(optional)
@callback max_timers_per_cell() :: pos_integer()

migrate_change(from_version, old_change)

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

migrate_state(from_version, old_state)

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

min_readable_schema_version()

(optional)
@callback min_readable_schema_version() :: pos_integer()

name()

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

on_timer(t, state, t)

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

run(input, state, t)

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

schema_version()

(optional)
@callback schema_version() :: pos_integer()

state_from_json(map)

(optional)
@callback state_from_json(map()) :: state()

Functions

__using__(opts)

(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 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.

cell_keys(module)

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

changeful?(module)

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

Whether the tasklet journals — it exports apply_change/2.

clock(module)

@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(module)

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

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

compensates?(module)

@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()

@spec default_compact_after_bytes() :: pos_integer()

The compaction threshold a class need not declare.

idle_ttl_ms(module)

@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?(module)

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

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

mailbox_capacity(module)

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(module)

Cap on live dynamic cells (default 100_000).

max_timers_per_cell(module)

@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(module, from, old)

@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(module, from, old)

@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(module)

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

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

on_timer?(module)

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

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

retired_callbacks(module)

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

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

schema_version(module)

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

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

state_from_json(module, json)

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

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