II Basics

Operations

An operation has a type, a scope of state it works on, and rules it keeps so that the runtime can execute it on the application’s behalf. Three of the types manage entity state; two more do not.

The phases of a transition, an observation and an assignment

§Types

An operation’s type states its relationship to state and to the world outside the component.

TypePhasesPurpose
TransitionRetrieve → Run → CommitReads and changes current state.
ObservationRetrieve → RunReads state.
AssignmentRun → CommitDescribes a change without reading state.
ComputationRunDerives a result; touches no state and has no side effects.
EffectRun, optionally after RetrieveActs beyond the component’s own state.

§Transition

A transition is the only type that goes through all three phases: the runtime retrieves the current state, the implementation changes it, and the runtime commits the result. Between the first phase and the last, another call may have committed a change of its own.

§Creation

Creation is not a separate kind of operation. A transition called without a query has no existing entity to retrieve, so it runs against a new one:

// creates an order
await context.remote.orders.transit({
  input: { customer, total: 1500 }
})

// changes an existing order
await context.remote.orders.transit({
  query: { id: orderId },
  input: { total: 1200 }
})

The same implementation serves both calls. In the first, it receives a blank order and its commit creates the entity; in the second, it receives the current state of the order the query selects. A query that selects nothing is a failure of the call: the operation does not run against an order that is not there.

§Optimistic concurrency control

Each entity carries a VERSION that increases with every committed write. It is how the runtime detects that state changed between a transition’s Retrieve and its Commit.

Suppose two calls reduce the same order’s total at the same moment. Both retrieve version 4; one commits version 5. The other’s commit no longer applies to the state it read. The runtime does not let it overwrite the first change. What happens instead is declared by the operation, and there is no default: a transition that works on an existing entity has to say.

operations:
  reduce:
    concurrency: retry

With retry, the runtime retrieves the fresh state and runs the implementation again. The decision is re-made against version 5, as though the calls had arrived in sequence. With none, the call fails and the conflict is the caller’s to handle.

The runtime can undo part of a lost attempt and not the rest. What the attempt did to its own entity is discarded with the write that lost. A call it made to another operation was served elsewhere and committed there, and the next attempt makes it again; making that harmless is the author’s. A call that says what it is for can be recognized by whoever serves it, whichever attempt makes it. One case is not left to the author: a transition that retries does not call an operation that declares once, and such a call is refused.

Nothing is locked while an operation runs, and a conflict costs a re-run only when one actually occurs. It is also why a transition’s implementation has to be safe to run more than once for a single call.

A caller can take part in the same check. A query may name the version the caller last saw, and the operation then applies only if the entity is still at that version.

§Pessimistic concurrency control

There is none: the runtime never locks persistent state.

§Deciding not to commit

A transition can run, look at the current state, and conclude that nothing is to be written. There are two ways to say so, and in both nothing is stored, no version is consumed, and no event is produced.

Return an error. This is the simplest: the operation refuses, and the refusal is its whole answer. The caller receives the error; see Replies.

export async function transition (input: Input, entry: State) {
  if (entry.balance < input.amount)
    return new Error('INSUFFICIENT_FUNDS')

  entry.balance -= input.amount

  return { balance: entry.balance }
}

Set DISCARD. Where not writing is a successful outcome with an answer of its own, the operation marks the state as discarded and returns that answer:

export async function transition (input: Input, entry: State) {
  if (entry.balance < input.amount) {
    entry.DISCARD = true

    return { balance: entry.balance, charged: false }
  }

  entry.balance -= input.amount

  return { balance: entry.balance, charged: true }
}

DISCARD is a property the state has only while a transition runs. It is never stored.

§Computation

A computation is logic without state. It receives input, may use the context, and returns a result:

export async function computation (input: Input, context: Context) {
  return input.total * context.configuration.rate
}

A component made only of computations declares no entity at all. It is still a component: it has a logical address, a contract, and a place in a composition.

§Effect

An effect is an operation whose purpose is something other than changing its own entity: sending a message, calling an external system, orchestrating calls to other components.

export async function effect (input: Input, context: Context) {
  const order = await context.remote.orders.observe({ query: { id: input.order } })

  await context.remote.billing.charge({ input: { order: order.id, total: order.total } })
  await context.remote.inventory.reserve({ input: { order: order.id } })
}

Declaring such logic as an effect is a statement of intent. The runtime cannot make an effect safe to repeat or undo what it did outside the component, so it treats effects with the caution their type announces. In a component that stores state, an effect can be called for a particular entity; the runtime then supplies that entity’s state, creating the entity if it does not exist yet.

§Scope

The scope is the shape of state an operation is handed in its Retrieve phase.

ScopeThe operation receives
entryOne entity object.
entriesA set of entity objects.
streamA stream of entity objects, read as the storage finds them.
changesetAn empty changeset to fill (assignments).
noneNo state.

The query of a call selects which state; the scope determines how much of it the operation is prepared to work with. An observation over entries lists orders; a transition over entry changes one.

A transition over entries changes several entities as one unit. The set is committed whole or not at all: if any member changed since it was read, nothing is written.

Such a set is always bounded. The call names its members by ids or caps what its criteria select with a limit, and the transition commits exactly the entities it was handed: a limit of two changes two. A change to everything the criteria match is therefore a series of calls, each over a bounded set, repeated until one comes back short:

await context.local.expire({
  query: { criteria: 'status==pending;deadline<1700000000000', limit: 256 }
})

A transition that changes what its criteria read — here, the status — finds fewer entities each time. A limit without sort takes any of the matching entities, not the same ones twice.

§Safety

Operations are either safe or unsafe, by whether they are allowed to change anything.

TypeSafety
ObservationSafe
ComputationSafe
TransitionUnsafe
AssignmentUnsafe
EffectUnsafe

Safety is a property of the type, not of a particular call: a transition is unsafe even when a given call of it changes nothing. This lets the runtime reason about calls before making them. A request that is only supposed to read can be refused the moment it reaches an unsafe operation; see Call Chains.

§Genuine operations

The runtime can retrieve state, retry on conflict, deliver again, run replicas, and move a component across process boundaries only if operations behave predictably. An operation’s implementation is expected to be:

  1. Stateless. It keeps nothing between calls. Running one instance many times gives the same results as running many instances once each.
  2. Deterministic. The same input, state and context produce the same result.
  3. Pure. Its only effects are the state it changes and what it does through the context.
  4. Idempotent. Running it twice for the same call leaves what running it once does.
  5. Non-exceptional. It does not use exceptions for control flow; a refusal is a returned error.

These are the terms of the trade described in Product–Platform Separation. Each rule corresponds to something the runtime does in the operation’s place: statelessness permits replicas, determinism permits retries, purity makes every interaction visible to the runtime — which is also what lets a component be moved from one process to another — idempotency permits delivering a call again, and returned errors let it distinguish a decision from a failure.

§Leaving the model

Two declarations step outside these rules deliberately. Both are part of the model as acknowledged exceptions: the manifest says where they are, so the rest of the application can still be reasoned about by the rules.

§Unmanaged

An unmanaged operation receives the storage’s own client instead of entity state. It exists for reads the query model cannot express:

// orders/operations/totals.ts
export async function unmanaged (input: null, collection: Collection) {
  return await collection
    .aggregate([
      { $match: { DELETED: null } },
      { $group: { _id: '$status', total: { $sum: '$total' } } }
    ])
    .toArray()
}

Everything the runtime provides around state — versioning, concurrency control, events — depends on writes passing through it, so an unmanaged operation is meant to read and never to write.

§Stateful

An operation declared stateful is tied to one process and is called on that process by name. It trades the guarantees of an ordinary call for the ability to reach something a particular process holds; see Stateful Operations.

Search
Search the documentation