Skip to content

Waiting for an event

A step can wait for something that happens elsewhere: a payment confirmed by a provider, a document signed, a record approved in another system. The step names the event it waits for. A source delivers the event with criteria saying which instances it is about: the instance's id, the correlation key it was started with, the value of one of its variables, its workflow. The event reaches the step waiting for its name in the instances the criteria select.

An event can also start a workflow: see Starting a workflow on an event. A workflow can send one to other instances: see Sending an event from a workflow. And a step can wait for an event the whole time its instance runs, whatever step the rest is at: see Handlers.

An example

An order is paid on the provider's site. The workflow starts with the order number as its correlation key, sends the customer to pay, then waits:

n_wait_payment:
  type: event
  config:
    event: payment_received
    payload_variable: payment

When the provider reports the payment, a source delivers the event. The provider knows the order number, so the source selects the instance by its correlation key:

$result = \Drupal::service(EventDeliveryInterface::class)->deliverEvent(
  new InstanceCriteria(tenant: $tenant, correlationKey: $order_number),
  new IncomingEvent(
    'payment_received',
    ['amount' => 4200, 'currency' => 'EUR'],
    id: $provider_event_id,
    source: 'payment provider',
  ),
);

The step resumes, payment holds the data, and the outgoing flows route on payment.amount.

Storing the event field by field

By default the event's data is one variable, the payload variable, and a flow reads a part of it by path: payment.amount. That is enough for flow conditions and tokens. A field becomes a variable of its own, a row the variable list shows and a view filters on, a value a task can show by name, when the step stores the data field by field. It takes the same four settings as the HTTP request step, which describes each split and pattern in full:

n_wait_payment:
  type: event
  config:
    event: payment_received
    payload_variable: payment
    store_fields_separately: true
    split_objects: true
    fields_to_store: |
      payment.amount
      payment.currency
      payment.payer.*

A provider delivering this data:

{"amount": 4200, "currency": "EUR", "payer": {"name": "Ada", "email": "ada@example.com"}, "lines": [{"sku": "A1"}]}

gives four variables: payment.amount = 4200, payment.currency = "EUR", payment.payer.name = "Ada" and payment.payer.email = "ada@example.com". payment.lines is not listed, so it is not stored. Each name is the path a flow would read into the whole data, so a condition on payment.amount reads the same whichever the step chooses. Add the payload variable alone, payment, to the lines to keep the whole data as well.

The data is written the same way whether the event resumed the waiting step or was kept for it and taken as the step ran, in the step's completion scope. Nothing deletes a field a later event no longer has: when the step waits again, in a loop for example, a field the next event does not carry keeps its value from before.

Field names come from the source. A name that would make a variable name longer than 255 characters fails the step at once, as storage keeps no longer name and no retry can change that: If this step fails decides what follows, and the event counts as delivered. Store such data whole, or list the fields to keep.

Deliver, or broadcast

An event is delivered to one step, or broadcast to every step waiting for it.

  • deliverEvent($criteria, $event) resumes the one step waiting for the event in the instances the criteria select. When no step waits yet, the event is kept. When several steps wait, in one instance on parallel branches or in several instances, the event is refused: which one it is for is the source's to say.
  • broadcastEvent($criteria, $event) resumes every step waiting for the event at that moment, in every instance the criteria select. It is never kept, so what it reaches never depends on which branch gets there first. When no step waits, it is refused.

Selecting the instances

InstanceCriteria says which running instances an event is about. Every criterion given must hold:

Criterion Selects the instances
instance with this id, in any tenant.
tenant of this tenant. Required without an instance id.
workflow of this workflow.
correlationKey started with this correlation key.
variables whose instance-wide variables hold these values.
unique as the others say, and refuses the call unless they select exactly one.
exceptInstance other than this one. A step sending an event names its own instance, which an event kept for later leaves out too.

A correlation key names a case, not an instance: a parent and its child may share it, and so may two workflows working on the same order. That is fine as long as one step waits for the event. An invoicing instance and a shipping instance both carry order-42, and only the invoicing one waits for payment_received:

// Reaches the invoicing step: it is the only step waiting for the event.
$delivery->deliverEvent(
  new InstanceCriteria(tenant: 'acme', correlationKey: 'order-42'),
  new IncomingEvent('payment_received', $data),
);

A variable selects by its exact value, as JSON holds it: 42 selects the number 42, not the text "42". Only instance-wide variables count, never a value a single branch holds:

$delivery->deliverEvent(
  new InstanceCriteria(tenant: 'acme', workflow: 'invoicing', variables: ['order_id' => 42]),
  new IncomingEvent('payment_received', $data),
);

Criteria naming an instance id suit a source that holds the instance: code on this site, or a callback address the workflow gave the provider, carrying the instance it was made for. Any running instance can be named, whatever its tenant, and instance ids are sequential: a source that takes the id from outside the site makes sure it cannot be guessed or changed, with a signed address.

Sources

The engine delivers events; it does not receive them. A source does, and calls EventDeliveryInterface: a webhook (see Incoming webhooks), a Send an event step, an ECA action, another module's controller. Deciding who may deliver an event is the source's job, with a signature, OAuth or an administrator's configuration. The engine decides where an event may go:

  • only to a running instance the criteria select;
  • only to steps that wait for that event name: the Wait for an event step, or another module's step type implementing EventTaskTypeInterface.

A delivered or broadcast event cannot start an instance, reach another kind of step or change anything else; starting is a call of its own (Starting a workflow on an event). Its data is written to the step's payload variable, whole or field by field, never evaluated, and is refused above 64 KB.

An event that arrives early

A source can be faster than the workflow. A workflow running on the queue may take a while to reach the waiting step, and a provider can answer before the transaction that parks the step is committed. So:

  • A delivered event no step waits for yet is kept with its criteria, for as long as the source asks (keepSeconds: an hour by default, a week at most). The first step to wait for it, in an instance the criteria select, takes it as it runs, and goes on at once. Several kept events are taken in the order they arrived, one per step.
  • An event whose criteria select no running instance is refused at once. Nothing is stored for an instance that does not exist yet, even one that will be started later with that correlation key.
  • A kept event is dropped when its time runs out or its criteria no longer select a running instance. Cron removes what is left, and delivers a kept event to a step that parked at the very moment the event arrived. Should two steps have parked that moment, cron leaves the event to run out rather than choose between them.

Duplicates

A source that gives an event an id gets each id delivered once: the same id is refused while the event is kept, and for a day after it is delivered or first kept. A source retrying a call it already made resumes nothing twice. The id is claimed before anything is resumed, so two copies of one event arriving together cannot both get through. An event that reached nothing, or whose delivery failed before resuming a step, leaves its id free, so the retry can still succeed. A broadcast that failed after resuming some steps keeps them with its id: the source's retry reaches the others, and skips those. The failure is logged with what it reached, under the orchestra channel.

What the source is told

Each method returns an EventDeliveryResult: how many steps the event resumed, whether it was kept, or why it was refused: no running instance selected (no_instance), several selected by unique criteria (not_unique), a duplicate id (duplicate), data too large (too_large), an invalid name (invalid), several steps waiting for a delivered event (several_waiting), or nothing waiting for a broadcast or for an event the source asked not to keep (not_waiting). getResultKey() gives it in one word, the one a webhook answers and a step sending an event writes: resumed, kept, started, or the refusal.

Audit

Each step an event reaches is recorded in the audit trail as Resumed by an event, with the event's name, its id and its source. A step that cannot store the event's data records its failure right after.