Storage behaviour for ConduitMcp.Ext.Tasks (experimental, unstable — see
that module).
A store holds one t:record/0 per task. ConduitMcp.Ext.Tasks.EtsStore
is the in-memory reference implementation; implement this behaviour to
keep tasks in a database or job queue and configure it with
config :conduit_mcp, :ext_tasks, store: MyStore.
Contract
- No enumeration. There is deliberately no
listcallback: the extension has notasks/list, and a task id is a bearer token. - Owner scoping.
get/2,update/3andcancel/2take the caller's owner (ConduitMcp.Principal.id/1of the request,nilwhen unauthenticated) and must answer{:error, :not_found}when the record'sownerdiffers (===), exactly as for a missing id, so a caller cannot tell "exists but not yours" from "does not exist".get/2also accepts:any, used only by server-side code that already holds the id (the task's own worker). - Atomicity.
update/3,cancel/2andput_status/3are read-modify-write operations. A store must apply each atomically with respect to the others (compare-and-swap, a row lock, a transaction), otherwise a client'stasks/updatecan overwrite a worker'scompletedwithworking. - Terminal states are final.
put_status/3andcancel/2must not change a record whose status iscompleted,failedorcancelled. - Durability.
create/2returns only once aget/2for the id would find the record: the handler answersCreateTaskResultright after it.
cleanup/1 is optional; ConduitMcp.Session.Janitor calls it on an
interval when the application starts the Ext.Tasks janitor.
Summary
Types
The owning principal id (ConduitMcp.Principal.id/1), or nil.
A task status; the last three are terminal.
A stored task. Timestamps are Unix milliseconds; ttl_ms nil means
unlimited. input_requests holds the outstanding MRTR input requests,
input_responses every answer received so far.
A task id: 32 random bytes, url-safe Base64 without padding.
A pure transformation applied by update/3.
Callbacks
Records a cancellation request: a non-terminal record becomes
cancelled; a terminal one is returned unchanged.
Evicts expired records and returns how many were removed. A record
expires ttl_ms after created_at; one with a nil ttl_ms is evicted
only once terminal and older than default_ttl_ms.
Stores a new record. opts carries :max_rows (a row cap, or
:infinity).
Fetches the record owner may see (:any skips the owner check).
Moves a non-terminal record to status, merging fields (worker side;
not owner-scoped). A terminal record is left unchanged:
{:error, :terminal}.
Atomically replaces owner's record with the result of fun; an
{:error, reason} from fun leaves it unchanged and is returned.
:any skips the owner check (server-side callers only).
Types
@type owner() :: String.t() | nil
The owning principal id (ConduitMcp.Principal.id/1), or nil.
@type status() :: String.t()
A task status; the last three are terminal.
@type task() :: %{ task_id: task_id(), status: status(), status_message: String.t() | nil, created_at: integer(), last_updated_at: integer(), ttl_ms: non_neg_integer() | nil, poll_interval_ms: pos_integer() | nil, input_requests: %{optional(String.t()) => map()}, input_responses: %{optional(String.t()) => map()}, result: map() | nil, error: map() | nil, owner: owner() }
A stored task. Timestamps are Unix milliseconds; ttl_ms nil means
unlimited. input_requests holds the outstanding MRTR input requests,
input_responses every answer received so far.
@type task_id() :: String.t()
A task id: 32 random bytes, url-safe Base64 without padding.
A pure transformation applied by update/3.
Callbacks
Records a cancellation request: a non-terminal record becomes
cancelled; a terminal one is returned unchanged.
@callback cleanup(default_ttl_ms :: non_neg_integer()) :: non_neg_integer()
Evicts expired records and returns how many were removed. A record
expires ttl_ms after created_at; one with a nil ttl_ms is evicted
only once terminal and older than default_ttl_ms.
@callback create(task(), opts :: keyword()) :: {:ok, task()} | {:error, :task_limit_reached | term()}
Stores a new record. opts carries :max_rows (a row cap, or
:infinity).
Fetches the record owner may see (:any skips the owner check).
@callback put_status(task_id(), status(), fields :: map()) :: {:ok, task()} | {:error, :not_found | :terminal}
Moves a non-terminal record to status, merging fields (worker side;
not owner-scoped). A terminal record is left unchanged:
{:error, :terminal}.
@callback update(task_id(), owner() | :any, update_fun()) :: {:ok, task()} | {:error, :not_found | term()}
Atomically replaces owner's record with the result of fun; an
{:error, reason} from fun leaves it unchanged and is returned.
:any skips the owner check (server-side callers only).