Skip to content

Timer events

A timer event ties process flow to the clock: fire at a specific instant, after a delay, or on a repeating cycle — no scheduling loop of your own. The timing lives in a TimerEventDefinition, which you attach to a catching event: a start event (the engine instantiates the process when the timer fires), an intermediate catch event (the track parks until it fires), or a boundary event (an armed timeout on an activity). This page is the developer reference — the definition's constructor, the three mutually-exclusive timing expressions, the trigger option that attaches it, and its runtime behavior.

Taxonomy

BPMN category Event → Definition → Timer (§10.5.3)
Package github.com/dr-dobermann/gobpm/pkg/model/events
Type events.TimerEventDefinition
Embeds the shared event definition (id, GetItemsList, CloneForInstance)
Implements flow.EventDefinitionType() flow.EventTrigger returns flow.TriggerTimer
Attaches to a Start / Intermediate-catch / Boundary event via WithTimerTrigger(ted)
The timing one of three data.FormalExpressions — timeDate, timeCycle, timeDuration

Where it sits in the event family: Events taxonomy.

Constructor

func NewTimerEventDefinition(
    tDate, tCycle, tDuration data.FormalExpression,
    baseOpts ...options.Option,
) (*TimerEventDefinition, error)
Parameter Meaning
tDate fire once at an instant — expression result type Time.
tCycle repeat count — expression result type int (paired with tDuration).
tDuration a delay/interval — expression result type Duration.
baseOpts base-element options (e.g. foundation.WithID).

Three forms are accepted, and nothing else:

Form Meaning
tDate alone an absolute deadline — fire once, at that instant.
tDuration alone a relative deadline — fire once, that long after the timer arms. This is BPMN's timeDuration (§10.5.5, Table 10.101): "wait five minutes, then fire".
tCycle with tDuration a recurrence — fire tCycle times, tDuration apart.

The recurrence is where gobpm departs from the XML notation. BPMN packs both numbers into one ISO 8601 string on timeCycle (R3/PT10H); the engine carries them as two typed expressions instead of a parsed string. That is why tDuration is required alongside tCycle, and why tCycle alone is refused — a repetition count with no interval has nothing to schedule. Both spellings denote the same schedule.

Three errors can come back, each naming the rule it broke:

Condition Error
all three nil "NewTimerEventDefinition: a Timer needs timeDate, timeDuration, or timeCycle with timeDuration" (InvalidParameter)
tDate set alongside tCycle or tDuration "NewTimerEventDefinition: timeDate is mutually exclusive with timeCycle and timeDuration (BPMN Table 10.101)" (InvalidParameter)
tCycle without tDuration "NewTimerEventDefinition: timeCycle needs timeDuration as its interval — a recurrence is carried as (count, interval)" (InvalidParameter)
an expression's ResultType() ≠ its slot's type "expression result isn't desired type" (InvalidObject, with expected_type/expr_type details)

It returns an error — never panics. MustTimerEventDefinition(tDate, tCycle, tDuration, …) is the panic-on-error twin for static wiring.

From an ISO 8601 string

The positional constructor takes evaluated Go values. To write the notation the standard itself uses — and that every BPMN file, modeller and Camunda document carries — pass one string instead:

events.MustISO8601Timer("PT5M")                  // fire once, five minutes on
events.MustISO8601Timer("2011-03-11T12:13:14Z")  // fire once, at an instant
events.MustISO8601Timer("R3/PT10H")              // three times, ten hours apart

The three grammars are disjoint — R… is a recurrence, P… a duration, anything else a date-time — so the form is read from the text and disassembled into the attributes above. A recurrence fills both timeCycle and timeDuration, which is why the decomposition never has to be written by hand. The result is an ordinary TimerEventDefinition: same validation, same waiter.

Timing that depends on the instance

BPMN types all three attributes as Expression, so the value may come from the running process — an SLA read off the order, a due date carried on the case. NewISO8601TimerExpr takes an expression yielding the ISO string, evaluated when the timer arms:

events.MustISO8601TimerExpr(events.Duration, slaExpr)  // e.g. yields "PT2H"

The form is named by the caller rather than detected, because the value does not exist until arming while the attribute it fills is fixed when the process is built. BPMN settles it the same way: <timeDuration>${expr}</timeDuration> has a static element name and a dynamic body. Pass events.Time, events.Duration or events.Cycle.

A malformed dynamic value therefore fails at arm time, naming the offending string, rather than at construction — unavoidable for anything dynamic, and the same shape as every other expression failure.

What the parser refuses

P1Y and P1M (not fixed-length — a month is 28 to 31 days, and Go's calendar arithmetic normalizes overflow rather than clamping), P1W2D (ISO makes the week form exclusive), fractional components, lowercase designators, zero and negative values, and the unbounded recurrence R/PT10H — nothing in the engine can consume an unbounded cycle safely. Each refusal names its reason.

Options

A timer definition is not itself option-configured for timing — the timing is its three positional expressions. The only option you routinely pass is a base-element one:

Option When you reach for it
foundation.WithID(id) pin the definition's id (else it is generated).

The definition is attached to an event with a trigger option — this is where "timer" meets a real node:

Trigger option Effect
events.WithTimerTrigger(ted *TimerEventDefinition) add the timer definition to a Start / Intermediate-catch / Boundary event's config.

WithTimerTrigger is an events.EventOption. The receiving constructor allow-lists flow.TriggerTimer, so it is accepted by NewStartEvent, NewIntermediateCatchEvent, and NewBoundaryEvent, and rejected elsewhere (e.g. an end event) at build time.

A boundary timer is attached through NewBoundaryEvent(name, host, ted, …) rather than a start-event option — see Boundary events.

For the complete, always-current signatures run go doc github.com/dr-dobermann/gobpm/pkg/model/events.

Build the timing expression

A timer's when is a data.FormalExpression that yields the slot's type. Wrap a Go closure with goexpr.Must; for a timeDate it must return a time.Time:

timeExpr := goexpr.Must(
    nil,
    data.MustItemDefinition(values.NewVariable(time.Now().Add(3*time.Second))),
    func(ctx context.Context, ds data.Source) (data.Value, error) {
        return values.NewVariable(time.Now().Add(3 * time.Second)), nil
    },
    foundation.WithID("timer-3s"),
)

Because the when is an expression evaluated by the engine (not a captured constant), each armed instance recomputes it — the closure above returns a fresh time.Now().Add(3 * time.Second) on evaluation.

Timer start event

Feed the expression into a TimerEventDefinition as its timeDate, then attach it to a start event with WithTimerTrigger. From examples/simple-timer/:

timerDef, _ := events.NewTimerEventDefinition(timeExpr, nil, nil)

timerStart, _ := events.NewStartEvent("timer-start",
    events.WithTimerTrigger(timerDef))

endEvent, _ := events.NewEndEvent("end")

proc.Add(timerStart)
proc.Add(endEvent)
flow.Link(timerStart, endEvent)

Running examples/simple-timer/ — register, Run the engine, and the instance auto-starts when the timer fires (no StartLatest call):

Timer process started. Will trigger in 3 seconds...
Timer should have fired by now!

A timer start event needs no explicit StartLatest — the engine instantiates the process itself when the timer fires. You only RegisterProcess and Run the engine, then wait.

Timer into real work

Point the timer start at a task instead of straight to end — the same definition, a longer flow. From examples/timer-event/:

timerEvent, _ := events.NewStartEvent("timer-start",
    events.WithTimerTrigger(timerDef))
serviceTask, _ := activities.NewServiceTask("handle-timeout", op,
    activities.WithoutParams())

flow.Link(timerEvent, serviceTask)
flow.Link(serviceTask, endEvent)

Other flavours reuse the same constructor:

  • DurationNewTimerEventDefinition(nil, nil, durationExpr), where durationExpr has result type Duration, for a "fire N after arming" delay.
  • RecurringNewTimerEventDefinition(nil, cycleExpr, durationExpr), an int count paired with the interval Duration.

Methods & runtime behavior

The engine drives the definition through these — you rarely call them directly:

Method Role
Type() flow.EventTrigger reports flow.TriggerTimer; how allow-lists classify it.
Time() / Cycle() / Duration() read back the timeDate / timeCycle / timeDuration expression.
CloneForInstance() flow.EventDefinition per-instance copy with a fresh id, sharing the (immutable) timer expressions by reference.
GetItemsList() the item definitions the timing expressions carry.

Behavior worth knowing: the definition holds when, and the engine arms it — at registration for a start event, on entry for an intermediate/boundary event. CloneForInstance gives each instance its own registration identity so concurrent instances waiting on the same timer register distinct EventHub waiters — without it one timer occurrence would resume them all (the timer analog of the message per-instance clone). A timer carries no payload, so the clone shares the expressions and only refreshes the id.

With a repository configured, a one-shot timer more than an hour out does not keep a waiter at all: the engine's timer service holds its absolute deadline and the instance dehydrates, so a thousand instances waiting on a two-day timer cost zero goroutines. Shorter one-shots and any repeating timer keep their in-memory waiter and stay resident. See Persistence & recovery.

See also