VI Shipping

Release a New Version

The shop is deployed and taking orders, and billing.wallets.debit, which takes money from a customer’s wallet, has to change. Releasing a change is toa deploy again. What needs thought is that a deployment is replaced gradually: for a while old and new replicas of a component serve together, and a call goes to whichever takes it. Every kind of change has a way to be made so that both can. Contracts has the reasoning.

§The rule behind every case

Every process starts with a map of the whole application: for each component, its version and what its operations accept. A caller validates a request against the map it was started with, and the callee trusts it. So during a rollout an old caller sends what the old contract allowed to a new replica, and a new caller sends what the new contract allows to an old one. A change is safe to roll when both work.

§You changed a component’s code

Locally, restart, which writes the map again first:

$ npm run restart

A process that runs sources the map does not describe refuses to start:

'store.orders' is composed at a version the component map does not state. Run `toa map`.

For a deployment there is nothing to do but deploy. toa deploy writes the map it deploys, and only the compositions whose components changed are rolled out.

A change inside an operation that keeps its input, output and errors is always safe to roll.

§You added something

Safe to roll, in one release:

  • a new operation or a new event — only the new replicas serve it;
  • an optional input property;
  • an optional entity property.

A call to a new operation that an old replica is handed is sent again until a new one takes it, so during the rollout such a call may be answered a few seconds late. A call to an operation no replica comes to serve — its release failed to start, or a later release removed it — throws after about three and a half minutes:

EndpointException: 'refund' is not served by 'billing.wallets'

A call to a component the caller’s map does not have throws at the call:

Cannot call 'billing.invoices': the component map states nothing of it. Run `toa map`.

§You want a new required input

billing.wallets.debit takes { amount, ref } and must take a required currency. Released at once, an old caller sends requests without currency to new replicas, which run with input.currency undefined.

Do it in two releases. The first makes the property optional and handles its absence:

# components/billing.wallets/manifest.toa.yaml
operations:
  debit:
    input:
      properties:
        amount: { type: integer, minimum: 1 }
        ref: { type: string }
        currency: { type: string }
      required: [amount, ref]
// components/billing.wallets/operations/debit.ts
import type { DebitInput, State } from '../types/index.d.ts'

export async function transition (input: DebitInput, entry: State) {
  const currency = input.currency ?? 'USD'
  // ...
}

The second, once every caller sends currency, makes it required.

§You want to remove or rename an input

The same, the other way round: release callers that stop sending the property, then release the contract without it. A rename is an addition followed by a removal: accept both names, move the callers, drop the old one.

Changing the type or the meaning of a property has no such path; see below.

§You changed the entity

Old and new replicas read and write the same entities.

  • A new optional property is safe: write code that tolerates its absence in entities the old replicas keep writing.
  • A new required property, a removed one or one whose meaning changed is safe only if the previous release can live with what the new one writes. Split it as with inputs — add as optional, backfill, then require — or do not roll.

§You added a migration

A component’s migrations are applied by the component itself when it starts, not by toa deploy:

  • The first new replica to start applies them; the others wait. A migration is applied once per database.
  • They run before the component serves, inside the five-minute start limit. A backfill over a large collection does not belong in a migration; put it in an operation and call it.
  • They run while the previous release is still serving. A migration must leave the data usable by the previous release. Where it cannot, do not roll.
  • Migrations of an evicted component are applied when whatever runs that component starts it.

Migrations are not undone by a rollback.

§You changed an event

A receiver turns the payload into a request to its own operation, and that request is validated there. Add payload properties freely; remove or rename one only after every receiver, and every application outside that reads the event, has stopped using it. A message that no longer fits the receiving operation’s input is not retried: it is set aside. An event that another application reads must stay listed in the Context’s events.

An event that comes from another application is not in the map, and its receiver states the binding itself (React to an Event). Nothing checks the other side, so validate what the operation receives.

§Changes that cannot be rolled

Some changes have no compatible intermediate step: a property that changes type or meaning, a migration whose result the previous release cannot read. For those the two versions must never serve together. Stop the deployment, deploy, and let it start: Deploying a release that cannot be rolled gives the procedure.

§Going back

There is no rollback command. Deploy the previous sources again, or helm rollback; see Rolling back. A rollback is a rollout in the other direction, so the same rules apply: the release you return to must live with the data and the migrations of the one you leave.

§The map

$ npx toa map

reads the Context and writes .map.json beside it. toa compose, toa mono, toa serve and toa call look for the file from the working directory upwards, the way they look for .env. Either file can be named instead:

$ npx toa compose ./components/* --env application/.env --map application/.map.json

Where there is a Context and no map, they refuse to start:

Cannot find '.map.json' from '/home/me/store'. Run `toa map` to write one, or name one with `--map`.

A component run outside any Context needs no map: it can only call what is composed with it.

  • Run toa map whenever a component’s sources change. The env script of the package.json that toa create wrote runs it, and npm run restart runs that script.
  • The map is generated. Add .map.json to .gitignore beside .env*.
  • toa map takes an environment, local by default, and --path and --as like toa env; see The CLI.
  • You never run toa map for a deployment.

The file has one line per component, so grep billing.wallets .map.json shows its version and what callers are held to. It lists every component of the Context: your own, the ones extensions bring, and the ones you evict.

§What a version is made of

A component’s version changes when its sources change, and never otherwise. Nobody maintains it. By default every file in the component’s directory counts, except what is never shipped: node_modules, lockfiles, tests, type declarations, a Dockerfile, and tool and editor output.

A component can adjust this in its manifest:

# components/store.orders/manifest.toa.yaml
files: [operations, events, receivers, lib, rc, package.json]
ignore: ['lib/fixtures/**', '!Dockerfile']
  • files — what the version is made of. Everything in the directory by default.
  • ignore — added to the built-in exclusions. A leading ! puts an excluded file back.

The manifest itself always counts. Use ignore for files that change without changing the component, such as notes or fixtures, so that editing them does not require a new map.

A symbolic link is read as what it points to. Sources shared between components through a link are part of each component’s version. files and ignore select linked sources by the path the link is written at.

§An evicted component

An evicted component is in the map like any other, and its callers are held to what the map says. Whoever builds and runs it must:

  • Deploy it from the same sources the Context was deployed from. Otherwise its contract and the one its callers hold differ.
  • Build it from the same files. A process started from files other than the ones the map was written from is refused at start. If the component is built with its own .dockerignore, make its files and ignore describe exactly what that build copies.

§Reading a deployed map

In a deployment the map is mounted into every pod, compressed:

$ kubectl exec <pod> -- zcat /etc/toa/.map.json.gz | grep billing.wallets

This shows which version of each component the deployment intends to run, and the contract callers are held to.

§What is safe, at a glance

ChangeRoll it?
code inside an operation, same contractyes
new operation, new eventyes
new optional input or entity propertyyes
new required inputin two releases
removed or renamed inputcallers first, then the contract
new property in an event payloadyes
removed property of an event payloadreceivers first
type or meaning of a propertyno — halt
migration the previous release can readyes
migration the previous release cannot readno — halt
Search
Search the documentation