II Business Logic

Do Work in the Background

The shop emails a receipt when an order is approved. The customer should not wait for the mail provider, and an approval should not fail because the provider is away. Hand the email over as a task: a call nobody waits for.

§Hand the work over

notifications is a component with an effect send that sends an email, given to and text; Call an External Service writes it. approve is the transition of store.orders that charges the customer’s wallet, billing.wallets.debit with the order’s id as ref, and approves the order. It calls send with task: true beside the rest of the request:

// components/store.orders/operations/approve.ts
import type { Context, State } from '../types/index.d.ts'

export async function transition (input: null, entry: State, context: Context) {
  if (entry.status !== 'pending') return new Error('NOT_PENDING')

  const reply = await context.remote.billing.wallets.debit({
    query: { id: entry.customer },
    input: { amount: entry.total, ref: entry.id }
  })

  if (reply instanceof Error) return reply

  entry.status = 'approved'

  await context.remote.notifications.send({
    input: { to: entry.customer, text: `Order ${entry.id} is approved` },
    task: true
  })

  return entry
}

The call answers null as soon as the task is stored, and approve goes on. send runs later, in whichever process of notifications is free, even if that component was down when the task was made.

The task is handed over when that line runs, not when the order is stored. If approve is run again because the order changed underneath it, the task is handed over again; if storing the order fails, the task has been made anyway. send has to tolerate both. Where the work is a consequence of a stored change and must follow it exactly, react to the event instead; Call, Event, or Task compares the three.

Nothing is declared for it. send is an ordinary operation, and the same operation can be called both ways. query works as in any call:

await context.remote.store.orders.archive({ query: { id }, task: true })

§Give a task to your own component

Answer now and do the slow part afterwards, with context.local:

// components/store.orders/operations/cancel.ts
import type { CancelInput, Context, State } from '../types/index.d.ts'

export async function transition (input: CancelInput, entry: State, context: Context) {
  if (entry.status !== 'pending') return new Error('NOT_PENDING')

  entry.status = 'cancelled'
  entry.TRAILERS.reason = input.reason

  await context.local.refund({ query: { id: entry.id }, task: true })

  return entry
}

cancel is the transition Announce a Change writes, which takes a reason and leaves it in TRAILERS; the task is the line added to it.

§Try it

Restart the application and approve a pending order:

$ npm run restart
$ npx toa call store.orders.approve "{ query: { id: '7a1c5e0c2b8f4d0e9a3b6c1d2e4f5a6b' } }"

The approved order is printed at once. Stop notifications and approve another order: the approval still succeeds, and the email is sent when the component is started again.

A task can be made from the command line too:

$ npx toa call notifications.send "{ input: { to: 'ann@example.com', text: 'Hello' }, task: true }"

§Getting the result

The call answers null, which says the task was accepted and nothing about how it will end. A caller that needs the outcome gets it as it would get any other fact about another component: it reads state later, or receives an event.

If the call throws, the task may or may not have been taken. Let the exception through.

§What the task’s operation must handle

It can run more than once. An operation that throws is run again later, from the top, and a process that stops halfway hands the task to another. Write the operation so that running it twice leaves the same state; Handle Repeated Calls shows how. For an effect that makes several outside calls in a row, see Write a Multi-Step Workflow.

A returned error ends it, and nobody reads it. The task is finished and is not tried again. If a refusal has to be visible, write it where somebody will read it. credit of billing.wallets (A Wallet and Its Ledger) puts money back into the customer’s wallet. It declares once, so refund is an effect, which has no write of its own to lose, and not a transition that retries:

// components/store.orders/operations/refund.ts
import type { Context, Entity } from '../types/index.d.ts'

export async function effect (input: null, entry: Entity, context: Context) {
  const reply = await context.remote.billing.wallets.credit({
    query: { id: entry.customer },
    input: { amount: entry.total, type: 'refund', ref: entry.id }
  })

  await context.local.assign({
    query: { id: entry.id },
    input: { refund: reply instanceof Error ? 'failed' : 'done' }
  })
}

Do not throw to refuse. A thrown refusal is retried several times and then set aside for an operator, as though something were broken; see Operating.

It has no deadline. A task may run long after it was made: after an outage, after a deployment. Work that can stop being worth doing checks the state it reads — a refund for an order that is no longer cancelled does nothing.

§Calls that cannot be tasks

Each of these throws where the call is made:

A taskWhy
with timeout or signalnobody waits, so there is nothing to give up
to a stateful operationit is served by one named process
that carries a streamthe stream exists only while its caller writes
to a transition, assignment, effect or unmanaged operation, from an observation or a computation, or under a GET requestwhat may only read may not hand over a write

A task that names an operation its target does not have — a caller deployed ahead of the component it calls — is accepted, and set aside for an operator when the component takes it.

§Slow tasks delay the others

All tasks of a component wait in one line, whichever operation they are for. An operation that takes minutes delays every other task of its component. Where that matters, move the slow operation to a component of its own.

§Summary

You wantYou write
hand work to another componenta context.remote call with task: true
hand work to your own componenta context.local call with task: true
know it was acceptedthe call answered null
know how it endedread state, or receive an event
survive a repeata repeat-safe operation

When a task fits and when an event does is discussed in Tasks.

Search
Search the documentation