Use memory for process-local work and tests. Choose Temporal or Restate when runs must survive worker replacement, provided the engine retains their history. An existing engine is a useful starting point, but check the operations and retention your process needs before committing to it. Hatchet is pending and unpublished; it is not an available integration.
Examples use the onboarding definitions and import
arkoren.dev/core/service/workflow. Handle registry and constructor errors
before using their results. A runner registers definitions for execution;
a driver starts and controls runs. Native drivers share a
worker host with scheduler drivers.
Compatibility Matrix
These capabilities describe Arkoren’s adapters, not the vendors’ entire SDKs. Restart survival assumes retained engine data, not protection against engine data loss. Memory clients must share one driver in one process.
| Driver | Restart Survival | Owned Child Runs | Detached Runs | Start Delay | Client-Only Process |
|---|---|---|---|---|---|
| Memory | No | Yes | Yes | Yes | No |
| Temporal | Yes | Yes | Yes | Yes | Yes |
| Restate | Yes | No | Yes | Yes | Yes |
Child owns cancellation only when it creates a run; joining an existing run
must never acquire that ownership. Memory and Temporal preserve this distinction
atomically. Restate returns workflow.ErrUnsupported before submitting child
work. Spawn is detached and is not a cancellation-equivalent fallback.
| Driver | Finished Retention Setting | Retained Pre-Execution Cancellation | Zero-Backoff Retries |
|---|---|---|---|
| Memory | Yes | ||
| Temporal | |||
| Restate |
Cancellation acceptance alone does not establish a retained outcome or permission to reuse an identifier. See Restate retention before depending on duplicate protection for delayed or queued runs.
All three drivers support client and worker observations. Their propagation boundaries differ:
| Driver | Client Calls | Step Attempts | Start-To-Attempt Carrier | Child/Spawn Carrier |
|---|---|---|---|---|
| Memory | Yes | Yes | Yes | |
| Temporal | Yes | Yes | Yes | |
| Restate | Yes | Yes |
Memory
Memory is included in arkoren.dev/core. It runs workflows in local goroutines;
replicas share neither runs nor signals, and restart loses both. It uses the
system clock, so synctest can advance waits and
backoff without real delays. It does not replay coordination code.
Connecting Locally
Build a registry before constructing the driver:
import "arkoren.dev/core/service/workflow/memoryworkflow"
registry, err := workflow.Handlers(Onboard, SendWelcome)
After handling err, wrap the driver in the ordinary facade:
driver := memoryworkflow.New(registry)
client := workflow.NewClient(driver)
There is no startup hook. Register driver.Stop with application cleanup and
give cleanup a deadline. Stop cancels runs and joins workflow and step-attempt
goroutines until they return or its context ends. It cannot kill code that
ignores cancellation; a later Stop can continue joining it. After Stop, new
starts return memoryworkflow.ErrStopped. A nil registry executes nothing.
Local Policy
The only driver setting is memoryworkflow.Builder.Logger; nil selects
slog.Default(). Step retry and timeout policies belong to the definitions,
not the driver. Finished runs remain for their Retention, one hour when zero;
expiry removes results and duplicate-start protection.
Use Starting Runs with this client.
With the confirmation-aware definition, signaling then reading Result returns
the supplied token. Signals buffer at most 1024 undelivered values per run and
signal name; another send returns memoryworkflow.ErrQueueFull without enqueuing
that value. Unknown or finished runs return workflow.ErrNotFound.
See the memoryworkflow API reference.
Temporal
Temporal executes workflow functions as native workflows and steps as activities. Choose it when you use Temporal’s workers and tooling, or need parent-owned child runs. From your application’s module directory, install the driver:
go get arkoren.dev/driver/temporal
Connecting Workers
Create a Temporal host and register your runner before starting that host. Construction does no network I/O:
import "arkoren.dev/driver/temporal/temporalworkflow"
runner, err := temporalworkflow.NewRunner(host, registry)
After checking the constructor error, use its client driver:
client := workflow.NewClient(runner.Driver())
For a separate producer process, set the host’s NoWorker(true) and use
NewDriver, supplying the registry to derive native execution timeouts and
reject unknown workflow names:
driver, err := temporalworkflow.NewDriver(host, func(driver *temporalworkflow.DriverBuilder) {
driver.Registry(registry)
})
Without that optional registry, the runner still enforces MaxDuration, but the
client sets no server execution-timeout backstop. A dialing host needs Start
before calls; a host made with temporal.NewFrom exposes the borrowed client
immediately without replacing worker startup or taking ownership of that client.
Matching Processes
Client and worker must agree on namespace, task queue, codec, and prefix. Both
DriverBuilder.Prefix and RunnerBuilder.Prefix default to arkoren; both also
accept a Logger, defaulting to slog.Default() for adapter records. The
runner’s required positional registry also supplies its driver registry; there
is no RunnerBuilder.Registry setting.
Workflow logging uses Temporal’s replay-aware logger and native level filtering.
Bound and grouped slog.LogValuer attributes resolve before forwarding, preserving
their redaction behavior. Host connection, TLS, and native SDK options belong to
Worker Hosts.
The workflow type is <prefix>-wf-<name>, its step activity is
<prefix>-step-<name>, and the facade run ID is the native workflow ID. IDs are
unique across workflow names in one namespace while history remains. Result,
status, cancel, and signal calls describe and verify the workflow type first;
a wrong workflow name returns ErrNotFound without issuing a command or returning
output. Grant describe permission as well as command permissions. These checks
do not replace application authorization.
Use the run-control examples after host startup. With matching routing, a signaled onboarding run returns its confirmation token. Metadata is stored in memo; tracing uses a separate input envelope and needs no host propagator hook. Deploy carrier-aware workers before traced producers: older workers cannot decode traced envelopes. Pause producers if worker-first rollout is not possible. Legacy untraced histories keep their argument shape.
Retry Limits
Positive step timeouts become activity start-to-close timeouts. Timeout(0)
removes that attempt limit; the workflow’s MaxDuration supplies the native
schedule-to-close bound. A permanent failure becomes non-retryable.
The worker logs an error for each failed step attempt, including attempts that
will be retried; an attempt log does not by itself mean the run failed.
Positive backoff caps apply from the first retry. An uncapped declaration asks
for Go’s largest duration rather than the native implicit cap, but server limits
can reduce it. The tested two-attempt policy on native server 1.29.1 retained
876000h; this is evidence for that server and policy, not a universal maximum.
Inspect the persisted policy if this limit matters.
Multiple attempts with zero initial backoff return workflow.ErrUnsupported
before activity encoding or scheduling, even if the handler would succeed first
time. The driver does not substitute a positive interval or retry the whole
workflow. Single attempts and explicitly positive backoff remain supported.
Older zero-backoff histories recorded an activity command that replacement workers now reject; replay can fail with
TMPRL1100. Keep affected runs on their original workers until completion, or drain them before replacement. Mixed workers on an unversioned task queue are not safe routing. There is no automatic migration or added replay marker; replay retained histories before rollout.
Run Lifetimes
Sleep uses a native timer; waits select over signals, timers, and cancellation.
An internal timer enforces MaxDuration, with the native execution timeout as a
backstop. Step, child-result, existing-child join, and Spawn-acknowledgment waits
end on the parent’s cancellation or duration limit without waiting for their
native futures to finish.
Only an atomically created child is owned by the parent; it receives cancellation or parent-close termination. Joining an existing ID observes its original run and ignores new input, without canceling that run when the parent ends. This ownership decision survives replay without another history command or marker. Spawn is detached but waits for start acknowledgment within the parent’s budget. An acknowledgment error does not prove the child never started: reconcile that same ID before replacing work.
Terminal errors preserve native chain order: an outer timeout remains
ErrTimeout, and an outer application error is not replaced by a timeout cause.
Application error kinds remain matchable; unrelated lookup and transport errors
remain errors. Workflow panics fail the run rather than retrying a workflow task.
Retention and Description are ignored; retention belongs to the namespace.
Poll c.Done() non-blockingly, never by a blocking native channel read.
Native Access
host.Client() exposes go.temporal.io/sdk/client.Client, and c.Raw() exposes
go.temporal.io/sdk/workflow.Context. Native operations bypass facade observations
and do not relax the replay rules.
Local Engine
For a disposable development engine, save this in your application’s compose.yaml:
services:
temporal:
image: temporalio/temporal:1.5.1
command: ["server", "start-dev", "--ip", "0.0.0.0", "--headless"]
ports:
- "127.0.0.1:17233:7233"
Run docker compose up -d, then run
docker compose exec temporal temporal operator cluster health --address 127.0.0.1:7233
until it succeeds. Configure the host’s HostPort("127.0.0.1:17233") and
TaskQueue("onboarding"), then register the runner and
lifecycle hooks. Start a run and read its result
to verify delivery, not merely server health. docker compose down --volumes
removes the disposable engine and history. The CLI image tag does not establish
the native server’s version or duration limits.
See the temporalworkflow API reference.
Restate
Restate calls an HTTP endpoint in your application. Workflow functions become
workflow services, and steps run inline as durable actions. Choose it for durable
execution through that serving model when you do not need parent-owned Child
runs. Install from your application’s module directory:
go get arkoren.dev/driver/restate
Connecting Workers
Configure a reachable endpoint and request-identity keys using the
Restate host. Before host.Start, bind the
workflow services and construct a separate client driver:
import "arkoren.dev/driver/restate/restateworkflow"
runner, err := restateworkflow.NewRunner(host, registry)
After handling that error, create the facade:
driver, err := restateworkflow.NewDriver(host)
client := workflow.NewClient(driver)
Binding does not listen, serve, or register a deployment. Register host Start
and Stop as startup/cleanup hooks and Serve as a runner, as described in
Worker Hosts. A producer-only process uses
ClientOnly(true) and NewDriver, which binds no services.
Matching Processes
Use the same codec and prefix in every client and worker. Both role builders
expose Prefix (default arkoren) and Logger (nil selects slog.Default()).
Service naming removes prefix separators and uppercases the following letter;
the default workflow onboard becomes ArkorenWf_onboard. runner.Services()
returns the bound service names in workflow-name order.
After deployment registration, use run control to start
and signal the confirmation-aware onboarding workflow. Reading the token from
Result verifies endpoint delivery. Duplicate starts return ErrAlreadyStarted;
an undeployed workflow returns ErrNotFound.
Signals resolve durable promises, keeping the first value. For accepted runs still queued or delayed, the adapter forces delivery only after proving pending state. Lookup and output-probe failures remain errors rather than being treated as missing runs. Cancel needs access to admin SQL introspection as well as ingress.
Trace headers use arkoren-trace-*, separate from arkoren-meta-* metadata and
native attempt headers. Upgrade old workers before expecting trace continuity.
Retry Limits
The adapter explicitly supplies initial delay, multiplier, attempts, and maximum
interval. Zero initial delay stays zero; zero maximum requests Go’s largest
duration instead of the SDK default cap. Native delays have millisecond precision
and the multiplier is float32.
Step execution is cooperative. Positive Timeout supplies a context deadline;
after the body returns, late success becomes workflow.ErrTimeout. No adapter
goroutine abandons the body to escape that deadline, so noncooperative code can
prevent return. Zero supplies no attempt deadline. The SDK logs an error for
each failed attempt it retries.
Run Lifetimes
MaxDuration starts a durable timer when execution begins, not at submission.
Blocking runtime operations observe it; it does not forcibly interrupt running
Go code. Native handler inactivity and abort timeouts are fixed at 24 hours.
Cancel closes a local attempt’s Done and requests native cancellation; a running
step or another process observes it at a durable-operation boundary.
Child returns ErrUnsupported before send, attach, or cancellation. A durable
receipt identifies an invocation but cannot atomically distinguish creating it
from joining another owner’s run across replay. A lookup then send is not safe
ownership. Spawn remains supported for explicitly detached work only.
Drain runs that called the earlier
Childimplementation on their original deployment before replacing it. Rejection changes their journal sequence; forced registration does not migrate that history. Completed retained outputs remain readable. Spawn is not an equivalent cancellation fallback.
Cancellation Retention
Retention keeps finished outputs and promises for the configured window; zero
uses Restate’s one-day default. Retained cancellation of delayed or queued runs
additionally requires positive effective retention and persisted activation of
preflight_invocation_termination_retention on the relevant partitions.
With that profile, pre-execution cancellation keeps ErrCanceled, Canceled
status, and duplicate protection for the retention window. Without it, the legacy
profile discards the invocation: Result and Status return ErrNotFound, another
Cancel can return ErrNotFound, and Start can immediately reuse the key. That
native behavior is not permission to repeat business work without reconciliation.
The tested Restate 1.7.12 includes the opt-in feature introduced in 1.7.8. Operators
request it with
RESTATE_EXPERIMENTAL_ENABLE_PREFLIGHT_INVOCATION_TERMINATION_RETENTION=true
and must ensure persisted activation. Activation establishes a minimum server
version: plan the compatibility change before applying it to an existing cluster,
and do not downgrade below 1.7.8 after the feature barrier applies. It cannot
recover already discarded records. See the
native release notes.
The driver neither enables nor verifies this profile. Health proves HTTP
readiness; /version reports requested flags, not partition-barrier activation.
Public admin SQL on 1.7.12 cannot expose the internal feature state needed to
prove activation. None is a retained-cancellation readiness gate, and there is
no local tombstone or silent fallback. VQueues are not required for the feature.
Native Access
c.Raw() exposes restate.WorkflowContext from github.com/restatedev/sdk-go;
keep its calls replay-safe. host.Query accesses admin SQL:
restrict credentials and never expose arbitrary queries to untrusted callers.
Each rollout is a new deployment, so preserve compatibility with runs in flight.
Local Engine
For a fresh disposable server, save this as your application’s compose.yaml.
The flag requests preflight retention; it is not proof of persisted activation
or a safe upgrade procedure for an existing cluster.
services:
restate:
image: docker.restate.dev/restatedev/restate:1.7.12
environment:
RESTATE_EXPERIMENTAL_ENABLE_PREFLIGHT_INVOCATION_TERMINATION_RETENTION: "true"
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "127.0.0.1:18080:8080"
- "127.0.0.1:19070:9070"
Run docker compose up -d, then curl --fail http://localhost:19070/health
until it succeeds. This checks HTTP health only. For an application on your
machine, configure the host to reach ingress/admin and let the server reach back:
The following endpoint skips request-identity verification. Anyone who can reach port 9080 can invoke handlers. Use it only on an isolated development machine; production requires identity keys and network access controls.
import "arkoren.dev/driver/restate"
host := restate.New(func(host *restate.Builder) {
host.Addr(":9080")
host.IngressURL("http://localhost:18080")
host.AdminURL("http://localhost:19070")
host.AdvertisedURL("http://host.docker.internal:9080")
host.Register(true)
host.AllowInsecure(true)
})
Bind the runner, wire the host hooks, and read an onboarding result to confirm
registration and execution. docker compose down --volumes deletes the disposable
server and history.
See the restateworkflow API reference.