Akter

Commands

A command is a request to change one actor. Its handler is a synchronous function: it gets a draft of the actor's state and the payload, changes the draft, and returns a reply. Akter stores the change, then sends the reply.

Declare commands⁠

TypeScript
import { Effect, Schema } from "effect"
import { Actor } from "@rikalabs/akter"

export class InsufficientFunds extends Schema.TaggedError<InsufficientFunds>()(
  "InsufficientFunds",
  { balance: Schema.Number, requested: Schema.Number },
) {}

export const Account = Actor.make("Account", {
  state: Schema.Struct({
    balance: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(0))),
  }),
  commands: {
    deposit: Actor.command(Schema.Number, { success: Schema.Number }),
    withdraw: Actor.command(Schema.Number, { success: Schema.Number, fails: InsufficientFunds }),
    freeze: Actor.command(),
  },
})

Actor.command(payload, options) takes the payload's schema first. A command without a payload takes only the options, or nothing, like freeze here. The options are:

OptionMeaning
successThe reply's schema. Without it, the command replies with nothing.
failsA tagged error the command can return.
internaltrue hides the command from clients. See Internal commands.
cronRuns the command on a schedule. See Cron.

Reply or fail⁠

TypeScript
export const AccountLive = Account.toLayer({
  deposit: (account, amount) => {
    account.balance += amount
    return account.balance
  },
  withdraw: (account, amount) => {
    account.balance -= amount
    if (account.balance < 0)
      return InsufficientFunds.make({ balance: account.balance + amount, requested: amount })
    return account.balance
  },
  freeze: () => {},
})

Return a value to reply. Return the declared error to fail: the caller receives InsufficientFunds in the Effect's error channel, typed.

When a command fails, its draft is thrown away. In withdraw, the balance goes down before the check, and that's fine: if the command returns the error, the subtraction never happened. The same goes for anything else the command asked for. A message, timer or job staged before the failure is dropped with it.

A handler that throws is a defect. Akter throws away the draft in the same way, records the defect, and the caller gets a CommandDefect error.

Stored before the reply⁠

Akter writes each command's result to the bucket before it sends the reply. Once a caller has seen a reply, the change survives a crash. Akter writes the results of many commands, across many actors, in one write, so a busy server doesn't do one bucket write per command.

Retries don't run twice⁠

Every call carries a command id. When a call times out or the connection drops, the client retries it with the same id, for up to ten minutes. Akter remembers the outcome of every id for ten minutes, so if the first attempt did run, the retry gets the stored reply and the command doesn't run again.

Stay deterministic⁠

Akter sometimes runs a command more than once. After a crash, it rebuilds actors by replaying recorded commands. When a command reads a list page that isn't in memory, Akter loads the page and runs the command again from the start. Each run must reach the same decision, so a command can't read the clock, roll dice or touch the network.

Use the context's recorded values instead:

Instead ofUse
Date.now()ctx.now()
Math.random()ctx.random()
crypto.randomUUID()ctx.id()
fetch, a database, any I/Oa job

now() is the time the command was admitted, the same on every run. id() and random() return the same sequence on every run.

A handler that returns a Promise or an Effect is a defect, so an async handler fails on its first call. Akter can't catch a direct Date.now() call, though. That one is up to you.

The context⁠

A command handler's third argument is its context:

MemberWhat it does
callerWho sent the command. See Access.
selfThis actor's id
now(), id(), random()Recorded time, ids and random numbers
send(Actor, id).command(payload)Sends a command to another actor
schedule(when).command(payload)Runs one of this actor's commands later
start.job(payload, { key })Starts a job
broadcast.name(value)Sends a broadcast to listeners

Everything the context stages happens only if the command succeeds, and only after it's stored. Use the context only while the handler runs; calling it later is a defect.

Message other actors⁠

send delivers a command to another actor, of any type:

TypeScript
import { Effect, Schema } from "effect"
import { Actor } from "@rikalabs/akter"

export const Wallet = Actor.make("Wallet", {
  state: Schema.Struct({
    balance: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(0))),
  }),
  commands: {
    pay: Actor.command(Schema.Struct({ to: Schema.String, amount: Schema.Number })),
    receive: Actor.command(Schema.Number, { internal: true }),
  },
})

export const WalletLive = Wallet.toLayer({
  pay: (wallet, { to, amount }, { send }) => {
    wallet.balance -= amount
    send(Wallet, to).receive(amount)
  },
  receive: (wallet, amount) => {
    wallet.balance += amount
  },
})

A send is one-way. Akter delivers it after the sender's command is stored, keeps trying until the receiver has stored it, and applies it exactly once. The receiving command runs as a separate command on the other actor, not as part of the sender's change. If it fails, the failure is recorded on the receiver and the sender isn't told.

Sends from one actor to another arrive in the order they were sent.

Timers⁠

schedule runs one of the actor's own commands later. Pass a duration, or { at } with a time in epoch milliseconds:

TypeScript
schedule("15 minutes").expire(orderId)
schedule({ at: now() + 60_000 }).remind()

A timer is stored with the command that set it, and fires once. If no server is running when it comes due, it fires late, as soon as one starts.

Cron⁠

A command with a cron option runs on a schedule:

TypeScript
commands: {
  touch: Actor.command(),
  nightly: Actor.command({ cron: "CRON_TZ=Europe/Berlin 0 3 * * *" }),
}

A cron command takes no payload and is internal. It runs on every actor of its type that has handled at least one command; the actor's first command schedules its first tick. The schedule is a five- or six-field cron expression in UTC, the same with a CRON_TZ=<zone> prefix, or an interval like @every 90 seconds.

If servers were down when a tick came due, the missed tick runs once when a server starts, as long as it's no more than an hour late. Older ticks are skipped rather than run in a burst.

Naming⁠

Name commands as instructions, with a verb: deposit, rename, close. Name the commands that receive a job's result as facts, in the past tense: succeededSendWelcome, failedSendWelcome. Broadcasts are facts too: announced, joined.

Internal commands⁠

An internal command can't be called by clients. Only Akter calls it: from a timer or cron, from another actor's send, from a job, or with a job's result. Use internal commands for steps a client shouldn't trigger directly, like receive above.

An internal command's caller is always a System caller that says where the call came from. See Access.