Driving a flow from outside

Starting, feeding and stopping a flow happens from your normal code — an HTTP handler, a webhook, a CLI. All of it goes through Redis, so the caller doesn't have to be the process that runs the flow.

On this page

Trigger

func (t *Server) Trigger(ctx context.Context, flowId string, data MetaData, opts ...TriggerOption) (string, error)

Start a flow and get back a callId. The MetaData map is the flow's input; inside the flow you read it with ctx.MetaData(k). WithDelay(d) starts it later instead of now.

callId, err := tick.Trigger(ctx, "order/close", gotick.MetaData{    "order_id": order.Id,    "user_id":  order.UserId,}, gotick.WithDelay(5*time.Second))

WithKey

func WithKey(key string) TriggerOption

Name the call with an identifier you already have — an order number, a user id — and address it by that afterwards, instead of storing gotick's callId in a column that means nothing to your business. The scope is (flow, key), so two different flows using the same order number don't collide.

_, err := tick.Trigger(ctx, "order/close", meta, gotick.WithKey(order.Id)) // later, from anywhere, with no callId storederr = tick.CancelByKey(ctx, "order/close", order.Id, "user canceled")

Supersede

Trigger the same (flow, key) again and the previous unfinished call is canceled automatically; only the latest one carries on. The old call lands in a canceled state naming what replaced it — it isn't deleted, so you can still see it in the inspector. The binding is released the moment a call reaches a terminal state, so the same key is free to run again later. Note this is supersede, not dedup: there is no "skip if one is already running" mode.

// each edit supersedes the unfinished run from the edit before itfor _, edit := range edits {    tick.Trigger(ctx, "doc/render", meta, gotick.WithKey(edit.DocId))}// only the last one reaches its final step

SendSignal

func (t *Server) SendSignal(ctx context.Context, callId, key string, value any) (bool, error)func (t *Server) SendSignalByKey(ctx context.Context, flowId, key, signal string, value any) (bool, error)

Hand a waiting flow the thing it's waiting for. The returned bool is whether the signal was accepted — false means that slot was already settled, either by an earlier signal or by the timeout. Treat false as information, not an error: it is exactly how you learn you lost the race.

// in your payment webhook handleraccepted, err := tick.SendSignalByKey(ctx, "order/close", orderId, "paid", Payment{    TradeNo: notify.TradeNo,    Amount:  notify.Amount,})if err != nil {    return err}if !accepted {    // the timeout already won: the order is closed, so refund instead    return refund(ctx, notify.TradeNo)}

Cancel

func (t *Server) Cancel(ctx context.Context, callId, reason string) errorfunc (t *Server) CancelByKey(ctx context.Context, flowId, key, reason string) error

Stop a call wherever it currently is. Three things happen: the flag is checked unconditionally at the top of every round, a sleeping or signal-waiting flow is woken immediately rather than left to its timer, and the context handed to a running Task is canceled so your own code can return early — that last one is polled every 3 seconds. Canceling an unknown call returns ErrRunNotFound, and one that already finished returns ErrRunNotCancelable, so a late cancel can never rewrite a completed result.

err := tick.Cancel(ctx, callId, "user canceled") switch {case errors.Is(err, gotick.ErrRunNotFound):    // never existed, or already cleaned upcase errors.Is(err, gotick.ErrRunNotCancelable):    // already finished — nothing was rewritten}

A note on Client: it only has Trigger(ctx, flowId, data, delay) — no options, so no WithKey, and no SendSignal or Cancel. If a non-worker process needs those, build a Server with NewServerFromConfig and simply never call StartServer. Triggering, signalling and cancelling all go through Redis and work fine without the scheduler running.

Render diagnostics