VII Patterns

Reply Before the Work Is Done

An approved order needs an invoice: a PDF produced by a document service that takes several seconds, sometimes a minute. The customer pressed “Pay” and should see “approved” at once, not a spinner for as long as the document service takes.

§What goes wrong with the obvious ways

Doing it inside approve. store.orders.approve is the operation that debits the customer’s wallet and sets the order’s status (Call, Event, or Task). With the rendering in it, the reply waits for the document. A slow document service makes every approval slow, and one that is away makes every approval fail.

Replying first and continuing in the same process. An operation that starts the rendering without await and returns has made a promise nobody keeps: when the process is restarted — a deployment is enough — the order is approved and has no invoice, forever, and nothing in the application knows it.

Handing over a task from inside approve. Closer, but the task is not tied to the approval: it is stored when it is handed over, before the order’s change is. If the change is then not stored, an invoice is rendered for an order that was never approved.

§The solution

Split the request into the part that decides and the part that takes time.

  1. approve only stores the fact: the order is approved. It replies.
  2. The component declares an event for “an approved order without an invoice”.
  3. The same component receives its own event, and the slow part runs in the receiver’s operation, with nobody waiting.
  4. The result — or the failure — is written to the order, and the client is told.
client ── PUT ──> store.orders.approve        stores status: approved      ──> 200, at once
                     ~store.orders.invoiceable                              after the reply
                        └──> store.orders.invoice
                               ├─ call ──> documents.render                 seconds
                               └─ store.orders.assign { invoice }
                                    ~store.orders.sync ──> the customer's stream

The state says what is wanted. An order that is approved and has no invoice is the work order; there is no separate queue to keep in step with it.

documents stands for the component that speaks to the document service (A Gateway to an External Service builds one of that kind): its render takes an order’s lines and answers { url }, or returns an error.

The manifest of store.orders, which Create a Component writes, gets an invoice property, an invoice operation, an input for the inherited assign, a receiver and a realtime section. What is left out here — create, the routes — stays as it is:

# components/store.orders/manifest.toa.yaml
namespace: store
name: orders

entity:
  properties:
    customer: { type: string }
    items: { type: array }
    total: { type: integer, minimum: 0 }
    status: { type: string, enum: [pending, approved, picked, shipped, cancelled] }
    invoice:
      type: object
      properties:
        url: { type: string }
        error: { type: string }
  required: [customer, items, total, status]
  blank:
    status: pending

operations:
  approve:
    concurrency: retry
    query: true
    errors: [NOT_PENDING, INSUFFICIENT_FUNDS]
  invoice:
    type: effect
    scope: entry
    query: true
  assign:
    input:
      type: object
      properties:
        invoice: { type: object }
      additionalProperties: false

receivers:
  store.orders.invoiceable: invoice

realtime:
  sync:
    key: customer
    expose: [id, status, invoice, VERSION]

The event compares the state before and after the change, so it is published when an order becomes one that wants an invoice, and not on every later change of it:

// components/store.orders/events/invoiceable.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'

function wanted (order: Entity | null) {
  return order?.status === 'approved' && order.invoice === undefined
}

export function condition (event: Event<Entity>) {
  return !wanted(event.origin) && wanted(event.state)
}

The receiver addresses the order the event is about:

// components/store.orders/receivers/store.orders.invoiceable.ts
import type { Entity } from '../types/index.d.ts'

export function request (order: Entity) {
  return { query: { id: order.id } }
}

And the slow part is an ordinary operation:

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

export async function effect (input: null, entry: Entity, context: Context) {
  if (entry.status !== 'approved' || entry.invoice !== undefined) return

  const document = await context.remote.documents.render({
    input: { order: entry.id, customer: entry.customer, items: entry.items, total: entry.total }
  })

  const invoice = document instanceof Error
    ? { error: document.code }
    : { url: document.url }

  await context.local.assign({ query: { id: entry.id }, input: { invoice } })
}

The inherited assign refuses an input until the manifest says which properties it may set (Use the Inherited Operations); here it is invoice and nothing else.

approve itself is the one in Call, Event, or Task: it debits the wallet by a call, sets the status and returns.

Try it on a pending order; the id here is an example:

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

The approved order is printed at once, without invoice. A few seconds later:

$ npx toa call store.orders.observe "{ query: { id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11' } }"
{
  id: '5b1ad5d8a4c54e1dbb2a4a0b6e1f0c11',
  status: 'approved',
  invoice: { url: 'https://documents.example.com/5b1a…0c11.pdf' },
  // …
}

The pieces are described in Announce a Change, React to an Event and Use the Inherited Operations.

§Telling the client

The client got 200 with an order that has no invoice yet. It learns the rest in one of two ways.

It is told. The realtime section above routes every change of an order to the stream of its customer. The client opens its stream once and receives the order again when invoice appears:

{ "event": "store.orders.sync", "data": { "id": "5b1a…0c11", "status": "approved", "invoice": { "url": "https://…" }, "VERSION": 3 } }

See Push Changes to the Browser. The client opens the stream first and reads the order second, and drops an event whose VERSION is older than the one it holds.

It asks again. A client without a stream reads the order until invoice is there. This is enough for a back-office page and wasteful for a phone.

Either way the client shows three states, and the entity has all three: no invoice — being prepared; invoice.url — ready; invoice.error — failed.

§Why an event of your own

The work that must follow a stored change is started by an event of that change, even when the same component does the work.

  • The event is recorded together with the change. An approved order has its invoiceable event, and an order that was not approved has none. A task or a direct call from inside approve cannot promise either.
  • It is delivered until invoice has handled it. A restart in the middle of rendering only postpones the invoice.
  • The condition is about the state, not about approve. If staff later approve an order through another operation, or a migration script uses the inherited transit, the invoice is produced all the same, with nothing to remember.

A task is the right tool when nothing was stored — the “contact us” form. Work made of several outside steps, each of which must not be repeated once done, can be written as a multi-step workflow started the same way, from the receiver.

§What can still go wrong

The event is delivered twice. The second run finds entry.invoice set and returns. Without that first line the customer would be sent to a second document; with it, a repeat costs one read. The line also covers an order that was cancelled in the meantime.

The process stops after rendering and before assign. The event is delivered again, the order still has no invoice, and the document is rendered a second time. That is acceptable because rendering the same order twice gives the same document. Where the outside step must not happen twice — a payment, a parcel booking — pass the provider a key it recognises a repeat by; see The Same Message Twice.

The document service refuses. render returned an error: the order has no address the service can print. Nobody is waiting for invoice to reply, so a returned error would vanish. The operation writes invoice: { error } instead, and the client shows it. A failure of background work that nobody records leaves the client waiting forever.

The document service is away. render throws, and invoice lets the exception through. The event is delivered again after a pause, several times; if the service stays away, the event is set aside for an operator, who puts it back when the service returns (Look After a Running Application). Do not catch the exception to record a failure: an outage is not the order’s fault, and it passes.

Somebody else changes the order while the document is rendered. The result is written with assign, which sets invoice and touches nothing else, so a note added to the order in those seconds is not overwritten. Had invoice been a transition holding the order for the whole rendering, it would have conflicted with every other writer.

A request arrives for something the job has not produced yet. “Send me the invoice again” while there is none is a refusal the caller can understand:

// components/store.orders/operations/resend.ts
if (entry.invoice?.url === undefined) return new Error('NOT_READY')

The work is slow enough to delay other reactions. Events and tasks of one component wait in line for its processes. If rendering takes minutes, move it to a component of its own — billing.invoices receiving store.orders.approved — so that the orders component stays quick.

§Rules of thumb

  • An operation that serves a client stores what is wanted and replies. It does not start work it does not wait for.
  • Describe “work is wanted” as a condition on the state, and let an event carry it.
  • The operation that does the work looks at the state first and returns if there is nothing left to do.
  • Write the result with an assignment of exactly the properties the job owns.
  • Write a failure into the state. Let an outage throw.
  • Give the client the three states — wanted, done, failed — and a stream to hear about them.
Search
Search the documentation