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.
approveonly stores the fact: the order isapproved. It replies.- The component declares an event for “an approved order without an invoice”.
- The same component receives its own event, and the slow part runs in the receiver’s operation, with nobody waiting.
- 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
invoiceableevent, and an order that was not approved has none. A task or a direct call from insideapprovecannot promise either. - It is delivered until
invoicehas 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 inheritedtransit, 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.