Guides

Writing workflows

A workflow is a plain async function marked as a durable entry point. This guide covers the building blocks beyond the basic activity call. Each replays bit-identically across all five launch languages; the examples pick whichever language reads clearest.

Durable timers

Sleeping in a workflow does not hold a thread. It registers a timer in Core and snapshots the workflow; when the timer fires, Core dispatches the workflow to resume.

import { durable, sleep } from "@hopskip/runtime";

export const trialReminder = durable(async (user: User) => {
  await sleep(14 * 24 * 60 * 60 * 1000); // 14 days, durably
  await activity("send_email", { to: user.email, template: "trial_ending" });
});

Time here is logical event time, so the timer is deterministic on replay.

Signals

Signals deliver external input to a running workflow. Because memory snapshots eliminate ContinueAsNew, signal delivery is never interrupted by the workflow “rolling over.”

from hopskip import durable, signal, activity

@durable
async def subscription(customer):
    active = True
    while active:
        event = await signal("billing_event")   # suspends until a signal arrives
        if event.kind == "cancel":
            active = False
        else:
            await activity("apply_charge", event.amount)
    await activity("close_account", customer.id)

Send one from another service via the WorkflowClient.SendChannelMessage RPC. (Planned CLI surface: a hop signal subcommand does not exist yet; the RPC does, and every SDK client wraps it.)

Queries

A query reads a workflow’s current state without appending to history. It runs against the live in-memory state (or a restored snapshot) and returns synchronously.

export const cart = durable(async function* () {
  const items: Item[] = [];
  // expose read-only state
  query("itemCount", () => items.length);
  // ... signal loop adds/removes items
});

Declaring the query is what exists today; there is no CLI surface for invoking one yet. (hop query was a raw-SQL command over the visibility projection, unrelated to this, and has been removed. See the visibility guide.)

Calling other workflows

A workflow can call another workflow and await its answer, composing large processes from smaller durable units. The callee is an existing workflow instance, addressed by namespace:instance and method name. The caller suspends on a real wait edge until it returns, and Core’s wait graph watches the edge for cycles.

#[durable]
async fn fulfil_order(order: Order) -> Result<()> {
    // Suspends here until the shipping workflow's method returns.
    let shipment = call_workflow("logistics:ship-7f3a", "ship", &order).await?;
    let invoice = call_workflow("billing:invoice-7f3a", "issue", &order).await?;
    Ok(())
}

Two differences from Temporal’s executeChild are worth knowing before you port code across:

  • There is no start-and-detach form yet. call_workflow always blocks until the callee answers, so there is no child handle, no parentClosePolicy, and no signalling a child mid-flight. A designed spawn-and-hold-a-handle form is not built yet.
  • Long histories don’t need children. The usual reason to reach for a child workflow (keeping a parent’s history under a size ceiling) doesn’t apply here. Core crosses an event horizon instead: it snapshots at the next await, seals the old partition, and keeps going, so a while (true) workflow runs for years with a bounded hot log. Compose because the domain has parts, not because history is filling up.

Naming a workflow instance

Give a start an instance name (a business key like customer-1234-onboarding) and two things follow. It becomes addressable, so signals, queries, and call_workflow can reach it. And it becomes singular: by default, a second start of a live instance name attaches to the run already going instead of starting a rival one.

The instance and contention fields ship on the StartWorkflow RPC today; the CLI examples below are planned surface (hop run exposes the basic start form so far):

hop run onboarding --instance customer-1234-onboarding
# ▸ client-42          (started)
hop run onboarding --instance customer-1234-onboarding
# ▸ client-42          (attached to the live run)

That default gives you mutual exclusion with no lock, no lease, and no error handling: “at most one onboarding per customer” is just the instance name. Once the run reaches a terminal state, the same name starts a fresh generation, on the same audit chain.

If you want a different answer to contention, ask for it explicitly:

# Fail rather than attach, while a run is live.
hop run reconcile --instance acct-77 --contention reject

# Wait your turn: accepted now, dispatched when the live run finishes.
hop run reconcile --instance acct-77 --contention queue

A queued start gets its task_id back immediately and is addressable from that moment: you can await its result straight away, and the waiting happens on the start, not on the answer. Contenders run in the order they queued, one per completion. If a queued start declares a budget (request_budget_ms), that budget bounds the wait too: a contender that waits past it fails rather than running arbitrarily late, and it leaves without delaying anyone behind it.

Terminating a running workflow because a new start arrived is deliberately not an ordinary request option. It needs a recorded, attributed override:

hop run onboarding --instance customer-1234-onboarding \
  --contention supersede --override-reason "incident-4711: run wedged on a dead vendor"

Without a reason the start is refused. With one, the reason and your identity are written into the terminated run’s own history before it is terminated, so a workflow that ends this way always carries the record of who ended it and why. Note that superseding is not a rollback: the new run sees nothing of what its predecessor had already done, so anything needing undoing is your workflow’s own compensation logic, same as everywhere else.

A start with no instance still gets a unique identity; it is one nobody else can name.

Streaming

Workflows can yield a stream of chunks (for progress, partial results, or agent token streams) over the yield_chunk ABI import. If the consumer disconnects, the guest observes a StreamDisconnected error and can reroute:

async def stream_processing():
    for chunk in produce_chunks():
        try:
            await yield_chunk(chunk)
        except StreamDisconnectedError:
            await activity("reroute_store", b"disconnected")
            return b"rerouted"
    return b"done"

Cancellation

Cancellation is delivered as a checked signal at await points; you don’t poll a flag. Cleanup runs deterministically before the workflow completes as cancelled.

export const importJob = durable(async (job: Job) => {
  try {
    for (const batch of job.batches) {
      await activity("import_batch", batch); // cancellation checked here
    }
  } finally {
    await activity("cleanup", job.id);        // always runs, even on cancel
  }
});

Compensation that survives the workflow

A finally block is enough for cancellation, because a cancelled workflow is still running when it is cancelled. It is not enough for a saga. The three ways a multi-step transaction most needs unwinding (its execution deadline elapses, an operator terminates it, or the engine’s own policy layer decides its outcome) are exactly the three where your code never reaches finally. Whatever you had queued in a local array is gone, and the card is still charged.

So register the undo with the engine instead. compensate records an activity name plus its arguments in a stack that lives in the log, not in your process:

export const openAccount = durable(async (order: Order) => {
  const txId = `${order.id}-charge`;               // replay-stable
  compensate("refund_card", encode(txId));         // durable before the effect
  await activity("charge_card", encode(txId));     // idempotent on txId

  compensate("release_inventory", encode(order.id));
  await activity("reserve_inventory", encode(order.id));

  await activity("ship_order", encode(order.id));
});

If this workflow times out between the charge and the shipment, Hopskip seals the stack, runs release_inventory and then refund_card (newest registration first), each with a stable idempotency key, and records what the unwind achieved. No try/catch pyramid, and no dependence on this workflow being alive.

Three things are worth knowing before you use it:

  • Register before you act when you can. The classic shape registers the undo after the step, because the undo needs an id the step returns, and an instance killed in that window leaves the effect uncompensated. Choosing the identifier yourself first, as above, closes the gap completely.
  • Say when it should run. compensate defaults to on-failure, the saga meaning: a successful workflow drops its undos unrun. Cleanup that is owed on every outcome (releasing a lease, deleting scratch objects) is { trigger: "always" }. Getting this backwards is silent in both directions, so the choice is explicit at the registration site.
  • Withdraw it if the step commits. compensate returns a handle; handle.cancel() takes it back off the stack. Once the engine has decided the instance is over, the stack is sealed and cancel() returns false without removing anything: a terminated workflow cannot cancel its own cleanup.

A compensation that fails does not stop the rest of the unwind and does not replace your workflow’s original error; it lands in the DLQ where you can replay it after fixing the downstream.

Idempotency

Idempotency is a first-class declarative primitive, not a convention you maintain. Attach a key and Core dedupes:

await activity("charge_card", order.total, { idempotencyKey: order.id });

Determinism is enforced, not remembered

You do not need a mental checklist of “don’t call Date.now().” Those calls are either rebound to deterministic host imports or fail to link. See Determinism & the sandbox. Write ordinary async code; the boundary enforces the rules for you.

Next