Rust SDK¶
The Rust SDK is the crate ankka. A Rust service is built to a WebAssembly module that the ankka runtime
loads into its own process: the runtime owns the journal, sharding, projections, timers, HTTP and the agent
loop, and your functions decide what each command does. This page lists what each component kind is made
of. Services in other languages explains the
model, and WebAssembly ABI is what the crate speaks to the runtime.
Installing¶
The crate is on crates.io as ankka. Every ankka release publishes it at the same version, so pin the
version of the platform you deploy to:
[lib]
crate-type = ["cdylib", "rlib"] # the module, and the library the tests link
[dependencies]
ankka = "0.8.0"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
ankka = { version = "0.8.0", features = ["testkit"] } # the integration testkit, which needs Docker
A module is built for wasm32-unknown-unknown (rustup target add wasm32-unknown-unknown) with
cargo build --release --target wasm32-unknown-unknown; a project made by ankka init --language rust
names that build cargo module. The crate depends on prost, serde and serde_json; the testkit
feature adds a Docker client and an HTTP client, so a service's own dependencies carry no Docker tooling.
The release profile should set panic = "abort": a panic is a trap the runtime reports, not an unwind a
module has any use for.
A module has no network, no file system and no clock of its own. std::net, std::fs, std::env and
SystemTime::now do nothing useful in one. Everything is asked of the runtime: other components through
the context's client, other services through ctx.services(),
the time and random bytes through ctx.now() and ctx.random(), and
configuration through config.
Codec and types¶
Every value that crosses to the runtime is encoded by serde's derives, under the rules every ankka language shares, so one journal is readable from all of them; Serialization has the rules.
| Rust type | On the wire |
|---|---|
a struct with Serialize, Deserialize |
a JSON object, every field written; #[serde(rename = "productId")] for the stored spelling |
an enum with #[serde(tag = "type")] |
the case's object with "type": "<Case>"; an enum without the attribute is refused, naming it |
String, i32, i64, f64, bool |
at top level text/plain, as the other SDKs write them; inside a record, JSON |
f64 inside JSON |
as Scala writes it: 1.0, 1.0E10 |
Option<T> |
null when absent |
Vec<T>, BTreeMap<String, T> |
a JSON array, a JSON object |
ankka::Instant |
ISO-8601 in UTC with 0, 3, 6 or 9 fractional digits |
ankka::Duration |
ISO-8601, PT1.5S |
ankka::LocalDate, ankka::LocalDateTime |
ISO-8601 strings |
ankka::Bytes |
raw bytes at top level |
Done, () |
the empty done and unit payloads |
A value is stored under its type's own name unless its component names a manifest: STATE_MANIFEST,
EVENT_MANIFEST and ROW_MANIFEST on the traits. A codec of your own implements ankka::codec::Codec.
The shape every component shares¶
A component is a unit struct that implements its kind's trait: associated types for its state and
messages, COMPONENT_ID, and a table of handlers whose first argument is the wire name.
pub struct ShoppingCart;
impl EventSourcedEntity for ShoppingCart {
type State = Cart;
type Event = ShoppingCartEvent;
const COMPONENT_ID: &'static str = "shopping-cart";
fn empty_state(cart_id: &str) -> Cart { Cart::empty(cart_id) }
fn apply(cart: Cart, event: &ShoppingCartEvent) -> Cart { /* the fold */ }
fn handlers() -> Handlers<ShoppingCart> {
Handlers::new()
.command("add-item", ShoppingCart::add_item) // fn(&Cart, LineItem, &Context) -> Effect<ShoppingCartEvent, Done>
.query("get-cart", ShoppingCart::get_cart) // fn(&Cart, (), &Context) -> ReadOnlyEffect<Cart>
}
}
command accepts a handler returning Effect; query accepts only ReadOnlyEffect, so a query that
persists does not compile. The function's name is yours; the wire name ("add-item") is the platform's.
empty_state is given the entity's id, since a state commonly carries it. Components are registered, and
named to the client, by value: .register(ShoppingCart).
Event sourced entity¶
| Part | API |
|---|---|
| Trait | EventSourcedEntity |
| Associated items | type State, type Event, COMPONENT_ID; optionally SHAPE, STATE_MANIFEST, EVENT_MANIFEST |
| Must define | empty_state(entity_id) -> State, apply(state, &event) -> State, handlers() -> Handlers<Self> |
| May define | snapshot_every() -> u32 |
| Declarations | Handlers::new().command(name, f), .query(name, f); f takes the state, the input and the Context |
| Effects | effects::persist(e), persist_all(events), delete_entity(), then .and(e), .delete_entity(), .expire_after(d), .then_reply(\|state\| r), .then_reply_value(r), .then_no_reply(); effects::reply(r), effects::error(code, message), effects::no_reply() |
| Types | a command returns Effect<E, R>; a query returns ReadOnlyEffect<R>; .into() turns a ReadOnlyEffect refusal into an Effect |
| Replication | const REPLICATED: bool = true; effects effects::hand_over_to(installation), replicate_to(installation), stop_replicating_to(installation), or the same on a Persist; ctx.installation(), ctx.is_home(); apply_at(state, &event, &EventContext) -> State, which calls apply unless overridden, with installation(), is_local(), sequence(); EventSourcedTestKit::new(id).in_mode(ReplicationMode::Follower { home, installation }) and kit.replay(events, Some(written_at)); CommandOutcome::replication |
apply keeps its signature: an entity that does not read where its events were written changes nothing.
See Event sourced entities and
Replicated entities.
Key value entity¶
| Part | API |
|---|---|
| Trait | KeyValueEntity |
| Associated items | type State, COMPONENT_ID; optionally SHAPE, STATE_MANIFEST |
| Must define | empty_state(entity_id) -> State, handlers() -> KeyValueHandlers<Self> |
| Effects | effects::update_state(s), effects::delete_state(), then .expire_after(d), .then_reply(\|state\| r), .then_reply_value(r), .then_no_reply(); effects::reply(r), effects::error(code, message) |
| Types | a command returns KeyValueEffect<S, R>; a query returns ReadOnlyEffect<R> |
See Key value entities.
View¶
| Part | API |
|---|---|
| Trait | View |
| Associated items | type Row, type Event, COMPONENT_ID; optionally ROW_MANIFEST |
| Must define | source() -> Source (Source::of(ShoppingCart), or Source::topic("name") with .start_from(..), .contract(..), .broker(..), .parallel()), on_event(row, event, ctx) -> ViewEffect<Row> |
| May define | on_deleted(row, ctx), which deletes the row by default; queries(), ["get", "all"] by default; declared(), the view's declared queries (query(name, statement)); version() |
| In a handler | row is the current row or None; ctx.metadata().subject() is the source's id; ctx.standing() is a workflow source's Standing, else None |
| Effects | view::update_row(row), view::delete_row(), view::ignore() |
| Querying | ctx.client().query(CartRows, "by-id", key), query(CartRows, "all", ()), query_by_name(view_id, name, key), or a declared query with ask(view, name, values) / ask_by_name(view_id, name, values, limit) |
| Table name | table_of(id), for a statement to name |
Keyed view¶
| Part | API |
|---|---|
| Trait | KeyedView |
| Must define | type Row, COMPONENT_ID, sources() -> Sources<Self> (Sources::new().on::<E>(Source::of(...), handler)) |
| May define | declared(), version() |
| In a handler | fn(event, ctx) -> KeyedViewEffect<Row>; ctx.rows().get(key), ctx.rows().ask(name, values) |
| Effects | update_row(key, row), delete_row(key), update_rows(...), delete_rows(keys), ignore(), and(...) |
| Testing | KeyedViewTestKit::<C>::new() |
See Views.
Consumer¶
| Part | API |
|---|---|
| Trait | Consumer |
| Associated items | type Message, COMPONENT_ID |
| Must define | source() -> Source, on_message(message, ctx) -> ConsumerEffect |
| May define | on_deleted(ctx), which ignores by default; produces_to() -> Option<&str>, a topic to publish to, or produces() -> Option<Publication> (Publication::to(topic).contract(..).broker(..)) |
| In a handler | ctx.entity_id(), the source entity's id; ctx.sequence(), the change's sequence number; ctx.standing(), a workflow source's Standing; ctx.written_at() and ctx.local_origin(), where a replicated entity's change was written; ctx.client() |
| Replicated sources | fn mirrored_changes() -> bool { true }, on a Consumer or a GraphConsumer, gives one that publishes changes written at other installations too |
| Effects | consumer::produce(value), consumer::produce_with(value, metadata), consumer::produce_all(messages), consumer::done(), consumer::ignore() |
| One of several messages | consumer::message(value), then .key(key) to publish it under a record key other than its subject and .metadata(metadata) for its headers; Outgoing::of(payload) takes a payload already encoded |
Delivery is at least once, and a handler that panics has the message delivered again. A consumer that
produces needs ANKKA_KAFKA_BOOTSTRAP_SERVERS on the runtime. produce_all publishes its messages in
order, and the change is handled when the broker has accepted all of them; an empty list is handled at
once. ConsumerEffect has a variant for it, ProduceAll, which a match on the effect must cover. See
Consumers.
Graph consumer¶
A consumer that publishes its source as graph deltas, in ankka::graph. It is registered as a consumer —
.register(CartGraph) — and can publish nothing but deltas.
| Part | API |
|---|---|
| Trait | GraphConsumer |
| Associated items | type Message, COMPONENT_ID, TOPIC |
| Must define | source() -> Source, on_message(message, ctx) -> GraphEffect |
| May define | on_deleted(ctx), which ignores by default |
| Elements | graph::node(id), graph::edge(id, type, from, to), graph::tombstone_node(id), graph::tombstone_edge(id, type, from, to); then .label(label), .property(name, value), .at(version) |
| Effects | graph::publish(elements), graph::done(), graph::ignore() |
| A refusal | a panic when the result is dispatched, naming the element and the fault; graph::resolve(elements, sequence) gives the same as a Result whose error is a Refused with its why |
In ankka::graph |
What it is |
|---|---|
Element |
An element as described, and as read: kind(), id(), version(), labels(), edge_type(), from(), to(), properties(), get(name), key() |
Value |
A property value: Text, Bool, Integer, Float, List, with From for &str, String, bool, i32, i64, f64 and Vec of them. Float(2.0) equals Integer(2), as the reader of the topic stores it. |
read(value, key) |
Reads a record's value into an Element; given a key, also checks it is the delta's element key |
node_key(id), edge_key(id), SCHEMA_NAME |
The element keys and the contract's name |
The builders are chainable and cannot fail: an element is checked when the result is dispatched. A label on an edge or a property on a tombstone is refused there too. See Publish a graph.
Workflow¶
| Part | API |
|---|---|
| Trait | Workflow |
| Associated items | type State, COMPONENT_ID; optionally SHAPE, STATE_MANIFEST |
| Must define | empty_state(entity_id), handlers() -> WorkflowHandlers<Self>, steps() -> Steps<Self> |
| May define | settings() -> WorkflowSettings |
| Declarations | WorkflowHandlers::new().command(name, f), .query(name, f); Steps::new().step(name, f), f taking the state, the step's input and the Context |
| Command effects | workflow::update_state(s), workflow::transition_to(step), then .transition_to(step), .transition_to_with(step, input), .then_reply(\|state\| r), .then_reply_value(r); effects::reply(r), effects::error(code, message) |
| Step effects | step_effects::update_state(s), then .then_transition_to(step), .then_transition_to_with(step, input), .then_pause(), .then_pause_for(duration, on_timeout_step), .then_end(); or directly step_effects::transition_to(step), end(), fail(CommandError::new(code, message)) |
| Settings | WorkflowSettings::new().timeout(d).default_step_timeout(d).default_recovery(r).step_timeout(step, d).step_recovery(step, r) |
| Recovery | Recovery::retries(n).failover_to("step") |
A step may call other components through ctx.client(). A step that panics is retried as its recovery says,
then failed over; a step that returns fail(...) ends the workflow. A step runs on an instance of the module
of its own, so a step waiting on another component holds nothing a command needs. See
Workflows.
Timed action and timers¶
| Part | API |
|---|---|
| Trait | TimedAction |
| Associated items | COMPONENT_ID |
| Must define | actions() -> Actions<Self> |
| Declarations | Actions::new().action(name, f), f: fn(Input, &Context) -> Result<(), CommandError> |
| In a handler | ctx.metadata() carries ankka.timer, ankka.attempts and ankka.due; ctx.due() is the due time the run is for |
| Scheduling | ctx.client().schedule(timer_id, Duration::of_seconds(30), Reminder, None, "remind", input), or schedule_by_name(...); ctx.client().schedule_recurring(timer_id, delay, period, Cleanup, "sweep", input), or schedule_recurring_by_name(...); ctx.client().cancel(timer_id) |
Scheduling twice under one id replaces the earlier timer, except that a recurring timer set again for
the same handler with the same period keeps its next due time; a handler's Err is retried on the
runtime's schedule. A refused period is an Err from schedule_recurring, not a trap.
See Timers.
Agent¶
| Part | API |
|---|---|
| Trait | Agent |
| Associated items | COMPONENT_ID; optionally ROLE |
| Must define | handlers() -> AgentHandlers<Self> |
| May define | tools() -> Tools<Self>, guardrails() -> Guardrails<Self>, max_tool_call_steps() |
| Declarations | AgentHandlers::new().command(name, f); Tools::new().tool(name, description, schema, f), f: fn(Args, &Context) -> Result<String, String>; Guardrails::new().guardrail(name, f), f: fn(Stage, &str, &Context) -> Result<(), String> |
| Tool schemas | Schema::object().string(name, description).integer(...).number(...).boolean(...).string_array(...).optional_string(...): the JSON Schema the model sees, with a description per field |
| Effects | agent::system_message(t), agent::user_message(t), then .context(t), .model(name), .tools([...]), .guardrails([...]), .session_memory(bool), .json_shape(hint), .then_reply(); agent::error(code, message) |
A handler returns a plan; the runtime runs the model loop, calls the module back for each tool with the
model's arguments decoded as Args, and checks guardrails. A tool's Err is a message for the model, not a
failure. Agents answer whole: a module cannot stream a reply. See Agents.
Autonomous agent¶
| Part | API |
|---|---|
| Trait | AutonomousAgent |
| Associated items | COMPONENT_ID, DESCRIPTION; optionally INSTRUCTIONS, MODEL (a model named in the runtime's configuration) |
| Must define | accepts() -> Vec<TaskAcceptance> |
| May define | tools() -> Tools<Self>, guardrails() -> Guardrails<Self>, settings() -> Option<AutonomousSettings> |
| Task types | TaskType::<R>::new(name, description, schema) for a result decoded as R; TaskType::text(name, description) for a result that is text, which the model gives as {"result": "..."}; .rule(name, f), f: fn(&R, &Context) -> Verdict |
| Verdicts | Verdict::Accepted, Verdict::rejected(reason) |
| Acceptance | TaskAcceptance::new(task_type, max_iterations), or TaskAcceptance::of(task_type) for a budget of ten |
| Settings | AutonomousSettings::new().approaching_budget_at(0.8).repeated_failure_at(3).max_consecutive_failures(5).dependency_stuck_after_millis(300_000); each left unset keeps the runtime's default |
The runtime runs the whole loop: the model, the task records and the instance's own record. It calls the
module for three things only — a tool, a guardrail, and a result the model completed a task with — each for
one task, which ctx.task_id() names. A result is decoded as its type's R; one that does not decode goes
back to the model as a mistake to correct, and so does the first rule's rejection. A rule that panics has
decided nothing: the module traps and the runtime checks the result again, so a rule must not rely on
anything the module kept, since a module keeps nothing between calls. A tool can run more than once for one
request of the model after a crash, so a tool with a side effect should tolerate that.
A task type is a value built by a function, so the agent's declaration and every caller share one:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Answer {
pub answer: String,
pub sources: Vec<String>,
}
/// The one task type the answerer takes: an answer, and what it was drawn from.
pub fn answer() -> TaskType<Answer> {
TaskType::new(
"answer",
"Answer a question, citing what you looked up",
Schema::object()
.string("answer", "the answer")
.string_array("sources", "what the answer was drawn from"),
)
.rule("cites-sources", |a: &Answer, _: &Context| {
if a.sources.is_empty() {
Verdict::rejected("sources must not be empty")
} else {
Verdict::Accepted
}
})
.rule("steady", steady)
}
steady is a second rule, which panics the first time it sees one particular answer and records that it
has, to show a rule's fault being checked again.
The agent names what it accepts, its tools and its guardrails; Tools and Guardrails are an agent's:
pub struct ConformanceAnswerer;
impl AutonomousAgent for ConformanceAnswerer {
const COMPONENT_ID: &'static str = "answerer";
const DESCRIPTION: &'static str = "Answers questions";
fn accepts() -> Vec<TaskAcceptance> {
vec![TaskAcceptance::new(answer(), 4)]
}
fn tools() -> Tools<ConformanceAnswerer> {
Tools::new().tool(
"lookup",
"Looks up how many things were recorded under an id.",
Schema::object().string("id", "the id things were recorded under"),
ConformanceAssistant::lookup,
)
}
fn guardrails() -> Guardrails<ConformanceAnswerer> {
Guardrails::new().guardrail("no-secrets", ConformanceAssistant::no_secrets)
}
}
A caller creates tasks and drives instances through the client. run_single_task creates the task and
starts an instance of its own on it; task(id) reads and cancels one:
fn run_task(request: &Request, instructions: String) -> Result<Ran, HttpProblem> {
AutonomousEndpoint::only_answer(request)?;
let client = request.client();
let task_id = client
.autonomous_agent(ConformanceAnswerer)
.run_single_task(&answer(), instructions)?;
let task = client.task(&task_id).get_as(&answer())?;
let instance_id = task
.assignee
.map(|(_, instance)| instance)
.unwrap_or_default();
Ok(Ran {
task_id,
instance_id,
})
}
An instance the caller names takes queued work with assign, and is suspended, resumed, terminated and
read the same way:
fn assign(request: &Request, task_ids: Vec<String>) -> Result<Accepted, HttpProblem> {
let instance = request
.client()
.autonomous_agent(ConformanceAnswerer)
.instance(request.path("instance"));
let assignment = instance.assign(task_ids)?;
Ok(Accepted {
accepted: assignment.accepted,
})
}
| Call | Does |
|---|---|
client.tasks().create(&task_type, instructions) |
creates a pending task and answers its id; NewTask::new(instructions).id(..).depends_on([..]).attachment(..) in place of the instructions sets the rest. A dependency that does not exist is refused before anything is written, and one that has already failed or been cancelled cancels the new task at once |
client.task(id).get(), .get_as(&task_type) |
the task's record as a TaskSnapshot: status, result (as JSON, or decoded as the type's R), reason, iterations, assignee, the whole record |
client.task(id).wait(reads) |
reads the record until the task has ended, at most reads times; Timeout naming its status otherwise |
client.task(id).cancel(), .cancel_because(reason) |
cancels it, and takes it off the instance it was assigned to |
client.autonomous_agent(Agent).run_single_task(&task_type, task) |
creates the task and starts it on an instance the platform names; answers the task's id at once |
client.autonomous_agent(Agent).instance(id) |
.assign(ids) (an Assignment of accepted and refused ids), .suspend(), .resume(), .terminate(), .state() (an AgentState: phase, current_task, iteration, queued) |
A module has no clock to sleep on, so wait reads the record as fast as the runtime answers: it suits a task
that is nearly done, and a longer wait belongs to a workflow step retried on a timer, or to the caller. A
module cannot subscribe to an instance's notifications, since a module answers every call whole; read the
task's record instead. An id the caller does not choose is made from the runtime's clock and the call's
trace, a module having no source of randomness, and one that names an existing task is made again.
AutonomousAgentTestKit::<C>::new(task_id) runs the parts a module decides, with no loop and no model:
run_tool(name, arguments), check_guardrail(name, stage, text), check_rule(&task_type, rule, &result),
and check_result(&task_type, &result) or check_result_json(type_name, json) for the whole check the runtime
asks for. with_service(build()) answers their calls to the service's entities in memory. See
Autonomous agents.
HTTP endpoint¶
| Part | API |
|---|---|
| Trait | Endpoint |
| Associated items | ENDPOINT_ID, PREFIX |
| Must define | acl() -> Acl (Acl::AllowAll, Acl::DenyAll, Acl::Authenticated or Acl::Callers(vec![...])), routes() -> Routes<Self> |
| Declarations | Routes::new().get(template, f), .delete(template, f), .post(template, f), .put(...), .patch(...); .with_acl(acl) after a route replaces the endpoint's for it |
| Handlers | get and delete: fn(&Request) -> Result<R, HttpProblem>; post, put, patch: fn(&Request, Body), the body decoded as Body, () for none. R is any serializable value or a Response; Done and () answer 204 |
| Callers | CallerMatcher::Internet, CallerMatcher::service(name), CallerMatcher::Service { name, project }, CallerMatcher::AnyInProject, CallerMatcher::SelfService, CallerMatcher::Granted; a request's caller() is Caller::Gateway, Caller::Service, Caller::Machine or Caller::Local |
| In a handler | request.path(name), query(name), header(name), body_as::<T>(), principal() (subject, name, email, email_verified, roles, claims, issuer), caller() (Caller::Gateway, Caller::Service { project, name } or Caller::Local), metadata(), client() |
| Responses | Response::json(v), text(s), html(s), bytes(content_type, b), redirect(location), no_content(), then .status(n), .header(name, value) |
| Errors | HttpProblem::new(status, message); a CommandError from a call becomes its code's status with ? |
The module never binds an HTTP port: the runtime serves the routes and hands each request over. acl is
required, as in the other SDKs: an endpoint without one does not compile. A route's id is
"METHOD template". Routes cannot stream. See HTTP endpoints.
Calling components¶
let cart: Cart = ctx.client().invoke(ShoppingCart, "cart-1", "get-cart", ())?;
ctx.client().send(Recorder, "r1", "note", note)?; // for a handler that never replies
let rows: Vec<CartRow> = ctx.client().query(CartRows, "all", ())?;
let answer: String = ctx.client().invoke_by_name(Kind::Agent, "assistant", session, "ask", question)?;
| Call | Does |
|---|---|
invoke(Component, entity_id, name, input) |
calls a handler and waits for its reply, as R |
send(Component, entity_id, name, input) |
dispatches the call and carries on; nothing it answers comes back. The way to call a handler that never replies |
invoke_by_name(kind, component_id, entity_id, name, input) |
the same, for a component this service does not declare |
invoke_stream(...) |
a streaming handler's tokens, delivered whole once the stream ends |
query(View, name, key), query_by_name(...) |
a view's rows |
schedule(...), schedule_recurring(...), cancel(timer_id) |
timers; schedule_recurring since protocol 1.12 |
await_end(Workflow, workflow_id, timeout), invoke_then_await_end(Workflow, workflow_id, name, input, timeout), await_end_by_name(...) |
waits for a workflow's end and answers its state, as S; a failed or deleted workflow is an Err whose code is WorkflowFailed and whose details hold step and reason. Since protocol 1.18. The instance is held for as long as the wait, so a module waits from a step, a route or a tool, not a command |
A call blocks the handler until the runtime answers; a refusal is an Err(CommandError) whose code is the
refusal's. Inside a handler ctx.client() carries the request's trace, so the call is a child span. See
Calling components.
Configuration¶
ankka::config("NAME") answers a variable the service's descriptor set, or None. It answers None for
every name the platform reserves — model keys, the database's credentials, the cluster's and the runtime's
own settings — whether or not it is set, since those belong to the runtime, never the module.
Guest shapes¶
A stateful-kind component — an event sourced entity, a key value entity or a workflow — is stateless by
default: the runtime hands it its state on every call and it keeps nothing between calls. Declared
Shape::Stateful, it is handed its state once, when its instance is loaded, and keeps it until the runtime
unloads it, which saves decoding the state on every command.
impl EventSourcedEntity for ShoppingCart {
const SHAPE: Shape = Shape::Stateful;
// ...
}
// or, chosen when the service is built:
Service::new("cart").register_as(ShoppingCart, Shape::Stateful)
The runtime holds the state in both shapes, so a module that faults loses nothing. Choose stateful for a state that is large or costly to decode; stateless otherwise.
Secret store¶
The service's secret store, for a value it must keep and never record, such as a credential a person
gave it (Secrets a service keeps). ctx.secrets() answers Some(Secrets) in an
endpoint, a consumer, a timed action, an agent, an autonomous agent's tools and a workflow step, and
None in an entity, a view and a workflow's command handler. put(name, value), get(name) (an
Option<String>) and delete(name) each return a Result whose error is the runtime's CommandError.
The runtime holds the store and its key; a module never sees the key, and config("ANKKA_SECRET_KEY") is
None. The three imports, get_secret, put_secret and delete_secret, are reached through a function
of their own, so a module that never keeps a secret imports none of them and runs on a runtime from before
protocol 1.4. Natively, a unit test talks to an in-memory store that applies the runtime's rules, unless a
NativeHost answers. The integration test kit sets a generated ANKKA_SECRET_KEY on the runtime it
starts.
Calling other services¶
A handler calls a route of another service by that service's name, and the runtime makes the call as this
service, so the other service's access rules can admit it by name
(Calling other services). ctx.services() answers Some(Services) in an
endpoint's route, a workflow's step, a consumer, a timed action, an agent's handler, tool and guardrail,
and an autonomous agent's task rule. It answers None in an entity, a view and a workflow's command
handler: a call to another service waits for as long as that service takes, and a command that waited
would hold every other command to the same entity behind it.
// A consumer may call another service; an entity's context answers `None` here. The call
// is made by the runtime as this service, so the other service's ACL can admit it by name.
let services = ctx.services().expect("a consumer may call another service");
let answer =
match services
.service(&service)
.request("GET", &path, RequestOptions::default())
{
// Whatever the service answered, a refusal included: its status and its body.
Ok(response) => Answer {
status: i32::from(response.status),
body: String::from_utf8_lossy(&response.body).into_owned(),
},
// No answer came: the service was not found, was not the one named, or did not
// answer in time. What that means here is the handler's to decide.
Err(error) => Answer {
status: 0,
body: error.to_string(),
},
};
services.service(name) is a service of the same project and service_in(project, name) one of another.
A ServiceClient has get, post and put, which send and read JSON through the crate's codec,
get_text, delete, and the raw request(method, path, RequestOptions), which answers a
ServiceResponse — status, content type, body and headers — for every status. with_headers adds headers
to every call a client makes. Every call blocks the handler until the service answers.
A call fails with a ServiceError:
| Error | When | Was anything sent |
|---|---|---|
Unresolvable { service, reason } |
no such service was found | no |
IdentityMismatch { service, detail } |
what answers for the name is not the service asked for | no |
Unanswered { service, reason } |
the connection was refused or broke, or no answer came in time | perhaps |
CallFailed { service, status, body } |
the service answered with a status outside 2xx, to a typed helper | yes, and it answered |
Refused(CommandError) |
the runtime refused the request, or the crate did: a body over 4,000,000 bytes, an answer that does not decode | no |
A ServiceError converts into a CommandError, so ? works in a handler that answers one.
The rule about where a call may be made is the runtime's, not the crate's. ctx.services() being
None is the earlier answer, with the better message. A command that reaches the client some other way is
stopped by the runtime: the call does not return, the command is answered with a fault naming the import
and the command, and the entity keeps the state it had.
A module that never calls another service imports nothing for it. One that does needs a runtime that
speaks protocol 1.10, and an earlier runtime refuses the module at start, naming the import request.
In a unit test, ScriptedServices plays the other services: answer(name, |request| …) says what a
service answers, unresolvable, unanswered and mismatch make a call fail in each way, requests() is
every request made, and run(|| …) runs the code under test with them in place. A call to a service with
no script fails the test, naming the service.
The time and random bytes¶
ctx.now() is the runtime's clock, read when it is called, from any handler. Two reads in one handler may
differ, so a handler that needs one time reads it once. When an event is applied again the time is still
the present: an event's time belongs in the event.
ctx.random(&mut buf) fills a buffer of any length with random bytes from the runtime's secure source,
from any handler. An id made from them in a command belongs in the event the command persists, because
applying the event again does not run the command again.
Natively there is no runtime: now() is the machine's clock and random the system's source. A unit test
fixes either with the test kit's with_clock(instant, || …) and with_random(&bytes, || …).
A module that reads the time or asks for random bytes imports now or random, and needs a runtime that
speaks protocol 1.10; an earlier runtime refuses the module at start, naming the import. A module built with
an earlier version of the crate reads the time the runtime states on every call, and still runs.
Running a service¶
fn build() -> Service {
Service::new("cart").register(ShoppingCart).endpoint(CartApi)
}
ankka::service!(build);
service! writes every function the runtime calls and a panic hook that sends a panic's message to the
runtime's log before the module traps; it appears once in a crate. Service::build reports every problem
at once — a duplicate id or wire name, a shared endpoint prefix, a Callers ACL naming nobody — and the
runtime refuses a module with problems, logging each. Locally, the ankka repository's
docker compose --profile wasm up -d runs the runtime with a module mounted, which
ANKKA_WASM_MODULE_PATH points at yours.
Testing¶
| Kit | Drives |
|---|---|
EventSourcedTestKit::<C>::new(id) |
One entity: command(name, input) answers a CommandOutcome with events, new_state, retention, reply::<R>() (a Result<R, CommandError>) and error(); state(). |
KeyValueEntityTestKit::<C>::new(id) |
One key value entity. |
WorkflowTestKit::<C>::new(id) |
One workflow: command, run_step, run_until_pause, run_to_end, resume, state. |
ViewTestKit::<C>::new() |
A view's on_event(key, event) and on_deleted(key), and row(key). |
ConsumerTestKit::<C>::new() |
A consumer's on_message(subject, message) and on_deleted(subject). .at(sequence) sets the change's sequence number, .with_service(build()) answers its client calls in memory, and ConsumerTestKit::<C>::messages(&effect) reads what an effect publishes as Published { payload, key, metadata }. |
GraphConsumerTestKit::<G>::new() |
A graph consumer's on_message(subject, sequence, message) and on_deleted(subject, sequence), each returning the Elements published, read back from their bytes; records(…) gives the raw records. |
TimedActionTestKit::<C>::new() |
A timed action's fire(name, input). |
AgentTestKit::<C>::new(session, ScriptedModel::new()) |
An agent's plan, tools and guardrails, against a scripted model that fails when the script runs out. |
AutonomousAgentTestKit::<C>::new(task_id) |
An autonomous agent's tools, guardrails and task rules for one task, with no loop. |
EndpointTestKit::<E>::new() |
An endpoint's routes by method and path, with no runtime; with_service(build()) answers its calls to the service's entities in memory. |
AnkkaTestKit::start(Module::build()?) |
The whole module in the real runtime image and a throwaway Postgres (feature testkit, Docker). restart() starts a new runtime on the same database. |
The kits that call other components take with_service(build()) too. Unit kits run natively with
cargo test and still round-trip every value through the codec. ScriptedServices, with_clock and
with_random stand in for other services, the runtime's clock and its random bytes, beside any kit. See
Testing.
Developing the crate¶
From sdks/rust in the ankka repository:
cargo test --workspace # the crate, the fixtures, the example
cargo build -p shopping-cart --release --target wasm32-unknown-unknown # the example's module
cargo test -p shopping-cart --features slow # through the real runtime; needs Docker
./conformance.sh # the platform's conformance suite, both shapes
./scripts/proto.sh # refresh the crate's copy of the protocol
The crate carries a copy of the protocol so it builds from crates.io alone; scripts/proto.sh refreshes it.
The conformance suite needs sbt and runs the platform's suite against the example's reference module, once
in each guest shape. See Adding a language SDK.