What's New in v0.4
New features
Section titled “New features”WorkflowClient
Section titled “WorkflowClient”A new WorkflowClient centralizes all workflow lifecycle operations — submit, cancel, pause, unpause, send signal, and status — in a single, standalone class. Unlike run_durable_workflow() or CheckpointingRunner.run(), the client does not execute tasks. It only creates snapshots and stores signals. A Worker (or CheckpointingRunner.resume()) picks up the work.
This is the recommended API for the distributed model where submissions and lifecycle control happen in a different process from execution (e.g. a web server submitting workflows that workers execute).
from sayiir import WorkflowClient, PostgresBackend
backend = PostgresBackend("postgresql://localhost/sayiir")client = WorkflowClient(backend)
# Submit a workflow — a worker will pick it upstatus = client.submit(workflow, "order-42", {"items": [1, 2, 3]})
# Lifecycle operationsclient.cancel("order-42", reason="Out of stock", cancelled_by="system")client.pause("order-42", reason="Maintenance window")client.unpause("order-42")client.send_signal("order-42", "approval", {"approved": True})status = client.status("order-42")import { WorkflowClient, PostgresBackend } from "sayiir";
const backend = PostgresBackend.connect(process.env.DATABASE_URL!);const client = new WorkflowClient(backend);
// Submit a workflow — a worker will pick it upconst status = client.submit(workflow, "order-42", { items: [1, 2, 3] });
// Lifecycle operationsclient.cancel("order-42", { reason: "Out of stock", cancelledBy: "system" });client.pause("order-42", { reason: "Maintenance window" });client.unpause("order-42");client.sendSignal("order-42", "approval", { approved: true });const current = client.status("order-42");use sayiir_runtime::WorkflowClient;
let backend = PostgresBackend::<JsonCodec>::connect(url).await?;let client = WorkflowClient::new(backend);
// Submit a workflow — a worker will pick it uplet (status, output) = client.submit(&workflow, "order-42", input).await?;
// Lifecycle operationsclient.cancel("order-42", Some("Out of stock".into()), Some("system".into())).await?;client.pause("order-42", Some("Maintenance window".into()), None).await?;client.unpause("order-42").await?;client.send_event("order-42", "approval", payload).await?;let status = client.status("order-42").await?;Idempotent workflow submission
Section titled “Idempotent workflow submission”WorkflowClient supports conflict policies that control what happens when you submit a workflow with an instance_id that already exists. This enables safe retries and at-most-once semantics without application-level deduplication.
| Policy (Python/Rust) | Policy (Node.js) | Behavior |
|---|---|---|
"fail" (default) | "fail" (default) | Raises an error if the instance already exists |
"use_existing" | "useExisting" | Returns the existing instance’s current status — no new work is created |
"terminate_existing" | "terminateExisting" | Cancels the existing instance and creates a fresh one |
# Safe retries — submitting the same ID twice returns the existing statusclient = WorkflowClient(backend, conflict_policy="use_existing")status = client.submit(workflow, "order-42", input) # createsstatus = client.submit(workflow, "order-42", input) # returns existing// Safe retries — submitting the same ID twice returns the existing statusconst client = new WorkflowClient(backend, { conflictPolicy: "useExisting" });const s1 = client.submit(workflow, "order-42", input); // createsconst s2 = client.submit(workflow, "order-42", input); // returns existinguse sayiir_core::workflow::ConflictPolicy;
let client = WorkflowClient::new(backend) .with_conflict_policy(ConflictPolicy::UseExisting);
let (s1, _) = client.submit(&workflow, "order-42", input).await?; // createslet (s2, _) = client.submit(&workflow, "order-42", input).await?; // returns existingThis is particularly useful for:
- HTTP handlers — safely retry a
POST /workflowsendpoint without creating duplicates - Event-driven systems — process the same event multiple times without side effects
- Cron jobs — ensure only one instance per scheduled run
Task prioritization
Section titled “Task prioritization”Tasks now support a priority parameter (1–5) that controls execution order in distributed workers. Lower values are picked up first. The default priority is 3 (Normal).
@task(priority=1) # Critical — picked up firstdef charge_payment(order: dict) -> dict: return process_payment(order)
@task(priority=4) # Low — runs when higher-priority work is donedef generate_report(data: dict) -> dict: return build_report(data)const chargePayment = task("charge-payment", processPayment, { priority: 1 });const generateReport = task("generate-report", buildReport, { priority: 4 });#[task(id = "charge_payment", priority = 1)]async fn charge_payment(order: Order) -> Result<Receipt, BoxError> { process_payment(order).await}Aging prevents starvation: the longer a task waits, the more its effective priority improves. See Task Priority for details.
New exports
Section titled “New exports”Python — new import from sayiir:
WorkflowClient
Node.js — new exports from "sayiir":
WorkflowClient, WorkflowClientOptions
Breaking changes
Section titled “Breaking changes”WorkerHandle: lifecycle methods removed
Section titled “WorkerHandle: lifecycle methods removed”The following methods have been removed from WorkerHandle across all languages:
| Removed method | Replacement |
|---|---|
handle.cancel_workflow() | client.cancel() |
handle.pause_workflow() | client.pause() |
handle.unpause_workflow() | client.unpause() |
handle.send_signal() | client.send_signal() |
WorkerHandle now only provides shutdown() (and join() in Python/Rust) for worker lifecycle control.
Rationale: Lifecycle operations are not inherently tied to a worker — they go through the backend. Having them on WorkerHandle created a confusing coupling and prevented lifecycle control from processes that don’t run workers (e.g. web servers, CLI tools).
Rust: CheckpointingRunner lifecycle methods removed
Section titled “Rust: CheckpointingRunner lifecycle methods removed”The following methods have been removed from CheckpointingRunner:
cancel()pause()unpause()
Use WorkflowClient::from_shared(runner.backend().clone()) to perform lifecycle operations alongside a runner.
Rust crate versions
Section titled “Rust crate versions”Update your Cargo.toml dependencies from 0.3 to 0.4:
| Crate | Version |
|---|---|
sayiir-runtime | 0.4 |
sayiir-persistence | 0.4 |
sayiir-postgres | 0.4 |
Migration
Section titled “Migration”Python
Section titled “Python”handle = worker.start([workflow])handle.cancel_workflow("order-42", reason="Out of stock")handle.pause_workflow("order-42")handle.unpause_workflow("order-42")handle.send_signal("order-42", "approval", {"ok": True})from sayiir import WorkflowClientclient = WorkflowClient(backend)handle = worker.start([workflow])client.cancel("order-42", reason="Out of stock")client.pause("order-42")client.unpause("order-42")client.send_signal("order-42", "approval", {"ok": True})Node.js
Section titled “Node.js”const handle = worker.start();handle.cancelWorkflow("order-42", { reason: "Out of stock" });handle.pauseWorkflow("order-42");handle.unpauseWorkflow("order-42");handle.sendSignal("order-42", "approval", { ok: true });import { WorkflowClient } from "sayiir";const client = new WorkflowClient(backend);const handle = worker.start();client.cancel("order-42", { reason: "Out of stock" });client.pause("order-42");client.unpause("order-42");client.sendSignal("order-42", "approval", { ok: true });runner.cancel("id", reason, by).await?;runner.pause("id", reason, by).await?;runner.unpause("id").await?;let client = WorkflowClient::from_shared(runner.backend().clone());client.cancel("id", reason, by).await?;client.pause("id", reason, by).await?;client.unpause("id").await?;