This guide helps you move a Temporal workflow application to autumn-harvest. It is for a Rust team that runs Temporal today. It gives an accurate, itemized answer to one question: what does it cost to port one workflow type?
Read comparison.md first, if you have not chosen harvest
yet. That page answers whether you should choose harvest, and why. This
page answers how to move.
Every claim below links to shipped, verifiable evidence. The evidence is an issue number you can open on GitHub, or a file in this repository. A claim with no evidence link is a bug in this guide. Please open an issue.
Scope and audience
This guide assumes:
- You write your workflows and activities in Rust today. Or your workflows use a non-Rust Temporal SDK. In that case, you plan to rewrite them in Rust as part of the move. Harvest ships a Rust SDK only. See No equivalent yet.
- You want to port workflow types one at a time, not all at once. The Dual-run cutover playbook below shows how.
- You already know your own Temporal workflow code. This guide does not teach Temporal concepts from scratch. It maps each concept to its harvest equivalent.
Use this guide in three steps:
- Read the Concept mapping table. Find each Temporal primitive your workflows use.
- Run the Workflow-porting checklist against one workflow type. Estimate the cost from the answers.
- Follow the Dual-run cutover playbook to move that workflow type to production without downtime.
Non-goals
This guide does not cover:
- Automatic Temporal history import. Harvest cannot read a Temporal
workflow history. It cannot resume one either. The two engines use
different event models. Temporal's
HistoryEventand harvest'sWorkflowEventare not interchangeable. No conversion between them exists. None is planned. A workflow execution that is in flight on Temporal at cutover stays on Temporal. It runs to completion there. It drains from the Temporal side on its own. Harvest starts only new executions of the ported workflow type. See the Dual-run cutover playbook for the sequence. - Automated code migration (codemods). You port each workflow function by hand. Use the Concept mapping table and the Workflow-porting checklist.
- Migration guides for other workflow engines. This guide covers
Temporal only. See
comparison.mdfor how harvest compares to DBOS, Inngest, Hatchet, and Restate. - Building a feature harvest does not have yet. The No equivalent yet section names each gap plainly. It does not hide any of them.
Concept mapping
Each table below covers one area. "Harvest equivalent" names the Rust method, HTTP route, or CLI command you reach for. "Reference" names the issue or file where that capability shipped. Use the reference to verify the claim yourself.
Workflows and activities
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
@workflow.defn (workflow definition) | #[workflow] | Core primitive (Phase 1) |
@activity.defn (activity definition) | #[activity] | Core primitive (Phase 1) |
proxyActivities / activity options (timeouts, retry) | #[activity(start_to_close = "30s", retry = RetryPolicy::exponential(3, ...))] | See "Translate each timeout name" in the Workflow-porting checklist below |
Signals
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
Signal handler, defineSignal + setHandler (fire-and-forget, push) | ctx.register_signal_handler / register_signal_handler_raw | issue #546 |
Blocking wait for one signal, condition(() => received, timeout?) | ctx.wait_for_signal / ctx.receive_signal / ctx.receive_signal_timeout | issue #476 (deadline variant); core primitive (plain wait) |
Blocking wait on many signals or a custom condition, condition(predicate, timeout?) | ctx.await_condition / ctx.await_condition_timeout | core primitive |
SignalWithStart | signal_with_start_workflow_execution, POST /workflows/{name}/signal-with-start | issue #244 |
| Non-blocking signal check (no first-class Temporal equivalent) | ctx.try_receive_signal / ctx.drain_signals | issue #775 |
| Wait for a signal whose payload matches a predicate (no first-class Temporal equivalent) | ctx.wait_for_signal_matching / ctx.receive_signal_matching | signals chapter |
| Wait on a handle that any caller settles once (Restate awakeable; no first-class Temporal equivalent) | ctx.new_promise / ctx.promise, settled by durable_promise::resolve / reject | durable promises |
| Duplicate-safe signal delivery (Temporal has no first-class dedup key) | Idempotency-Key header on standalone signal delivery | issue #521, issue #753 |
Entity workflow pattern: one run per key, a signal loop, continueAsNew (no first-class Temporal primitive) | autumn_harvest::entity::Entity | ADR 0006 |
Port a Temporal entity workflow to autumn_harvest::entity. Do not copy
its hand-written signal loop. The library loop drains waiting signals into
the continue-as-new input. It also records each checkpoint decision, so a
replay takes the checkpoint at the same signal. A hand-written loop over
should_continue_as_new() can lose a signal. See
ADR 0006.
condition() in Temporal accepts any predicate over workflow state. It is
not limited to one named signal. A predicate such as approved || cancelled can read two independent signals in one wait.
Harvest has a direct equivalent. Use ctx.await_condition when the
original call took no timeout. Use ctx.await_condition_timeout when the
original call took a timeout. Each accepts a closure. The closure reads
local workflow state and returns a bool.
Port a multi-signal condition(predicate, timeout?) call this way.
Register a push handler for each signal (ctx.register_signal_handler). Have each handler set its own flag. Pass a closure that
checks the combined flags to ctx.await_condition or
ctx.await_condition_timeout. await_condition_timeout also takes a
timer ID and a duration in whole seconds. Use the same duration the
original timeout used.
Queries and updates
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
@workflow.query | ctx.register_query_handler, #[query] | issue #234, issue #346 |
@workflow.update | ctx.register_update_handler, #[update] | issue #140, issue #346 |
UpdateWithStart (WithStartWorkflowOperation) | update_with_start_workflow_execution | issue #479 |
Timers and continue-as-new
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
sleep(duration) | ctx.timer(id, seconds) | Core primitive |
| Manual "sleep until a date" math | ctx.sleep_until(id, deadline) | issue #749 |
| Hand-rolled cancellable/renewable wait (no first-class Temporal primitive) | ctx.start_timer / TimerHandle | issue #768 |
continueAsNew() | ctx.continue_as_new(input) | Core primitive |
| Same-workflow-type-only continuation (Temporal requires this) | ctx.continue_as_new_as / ctx.continue_as_new_as_type (cross-type continuation) | issue #803 |
| Hand-rolled output/error carryover between scheduled runs | ctx.last_completion_result() / ctx.last_error() | issue #488 |
Child workflows
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
workflow.executeChild() | ctx.spawn_child_workflow | Core primitive |
ParentClosePolicy (ABANDON / REQUEST_CANCEL / TERMINATE) | ParentClosePolicy | issue #347 |
Hand-rolled Promise.all fan-out over children | ctx.spawn_child_workflow_fan_out (and _collect, _raw variants) | issue #601 |
Hand-rolled Promise.race | ctx.race() | issue #600 |
Promise.race over MIXED awaitables (activity vs timer, child vs signal, …) | ctx.race() over any mix of activity / child / timer / signal branches; futures::join! for the wait-all form (local activities excepted) | see HVG010 in the determinism guide |
| Hand-rolled child-vs-timer race | ctx.execute_child_workflow_timeout (the child-or-deadline wait) | issue #779 |
Schedules
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
| Temporal Schedules API (cron / interval spec) | WorkflowSchedule | Core scheduler |
| Schedule Overlap Policy | OverlapPolicy, including AllowAll | issue #241 |
| Schedule catchup window | CatchupPolicy | issue #484 |
Bounded schedule actions (end_at, action limit) | WorkflowSchedule::end_at / max_runs (bounded schedule runs) | issue #478, issue #543 |
| Calendar-aware skip / backfill | Calendar, the backfill runner | issue #337 |
| Hand-rolled business-day math | ctx.timer_business_days | issue #806 |
| Schedule jitter | WorkflowSchedule::with_jitter | issue #240 |
updateSchedule | PATCH /admin/schedules/{id} (in-place schedule update) | issue #771 |
describeSchedule run history | GET /admin/schedules/{id}/runs | issue #534 |
Versioning and determinism
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
GetVersion | ctx.version(id, min, max) | Core primitive (Phase 2, design decision DD-3) |
Patched / DeprecatePatch | ctx.patched(id) / ctx.deprecate_patch(id) | issue #687 |
workflow.SideEffect | ctx.system_now(), ctx.new_uuid(), ctx.random_*(), ctx.side_effect() | issue #384 |
| No built-in non-determinism linting | Compile-time determinism guardrails HVG001–HVG011, the det_check static analyzer | issue #785 (HVG011), issue #600 / issue #799 (HVG010) |
Replay testing for safe deploys (WorkflowReplayer, same name in the Temporal .NET and PHP SDKs) | autumn_harvest::testing::WorkflowReplayer | autumn-harvest/tests/integration/replayer_tests.rs, Phase 3.5 |
Worker placement and build routing
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
| Local Activity | #[activity(local = true)] | issue #98 |
| Worker Build ID Versioning (Worker Deployment Versions) | Worker build-ID routing | issue #171 |
| Gradual version rollout | Percentage-based build ramp | issue #604 |
Operational lifecycle
| Temporal primitive | Harvest equivalent | Reference |
|---|---|---|
Worker Sessions (Go SDK workflow.CreateSession) | ctx.create_session() | issue #606 |
| Reset Workflow Execution | Workflow reset (event-ID boundary), DAG retry-from-failed-node | issue #148, issue #366 |
| Terminate (forceful) | POST /workflows/{id}/terminate | issue #504 |
| Cancel (cooperative) | POST /workflows/{id}/cancel, ctx.is_cancelled() | Core primitive |
| Workflow Pause (experimental, CLI-only in Temporal) | Per-execution pause/resume (stable, reversible, SDK-level) | issue #383, issue #609 |
| Search Attributes / Visibility | Search attributes | search-attributes.md |
| Durable, named cross-workflow mutual exclusion (no first-class Temporal primitive) | ctx.mutex(key) | issue #691 |
No equivalent yet
Three Temporal capabilities have no harvest equivalent today. This section names each gap plainly. It does not imply a workaround exists.
- Nexus. Nexus gives cross-namespace and cross-cluster RPC between independently deployed workflows. Harvest has no cross-service RPC primitive. Every primitive in the Concept mapping table above operates within one harvest deployment. Suppose your Temporal application uses Nexus to call into a separately owned namespace. That call has no direct harvest port. Use an ordinary API call or a message queue instead of a durable RPC.
- Multi-region / global namespaces. Harvest runs against one or more
Postgres shards. See
sharding.md. Each shard can replicate to a standby region with stock Postgres replication, and an operator runs a fenced failover. Seecross-region-dr.md. Harvest does not fail a region over automatically, and it has no active-active writing. These limits are a real gap for a Temporal deployment that relies on global namespace replication for automatic failover. - Non-Rust SDKs. Harvest ships a Rust SDK only. Suppose your Temporal workflows are in Go, TypeScript, Java, Python, .NET, or PHP. You rewrite them in Rust to move to harvest. No bridge or interop layer exists.
See comparison.md for the current
state of these gaps, and other gaps, including which ones the team plans to
close.
Workflow-porting checklist
Run this checklist against one workflow type before you port it. Each item below is a concrete task, not a vague warning.
-
Run the determinism guardrails. Compile your ported code. Harvest's proc-macro lint (HVG001–HVG011) checks each workflow body. It rejects a direct call to the wall clock. It rejects non-deterministic randomness. It rejects an inline read of a process environment variable. It rejects a spawned background task, direct I/O, and a mutation of process-global state. It rejects an iteration of a
HashMap/HashSetinside a command-emitting loop. Temporal relies on documentation and review discipline for these same rules. Harvest turns most of them into a compile error. Fix each flagged call site. Reach for the matching deterministic primitive in the Concept mapping table. Most often, that primitive isctx.system_now(),ctx.new_uuid(), orctx.side_effect(). -
Map your payload types. Temporal serializes activity and workflow payloads through a configurable
DataConverter. Temporal's default converter is not plain JSON. It is a chain of converters. Temporal tries each payload against the chain in order: a null check, then raw bytes, then Protobuf, then JSON as the final fallback for a plain object. ABuffer, aUint8Array, or a Protobuf message crossing a Temporal boundary is not JSON on the wire. This holds even with no custom codec configured. List every payload type that crosses each activity or workflow boundary before you port it. Harvest serializes every input and output asserde_json::Valuethroughserde. For a type that already went through the JSON fallback, add#[derive(serde::Serialize, serde::Deserialize)]. For a raw-bytes or a Protobuf type, write an explicit conversion to a JSON-serializable shape first. Convert a raw byte buffer to a base64 string, or to a byte array. Convert a Protobuf message to its JSON mapping, through the Protobuf library's own JSON codec, or through a hand-written struct. Suppose you used a custom Temporal payload codec for compression or encryption on top of this chain. Harvest's equivalent is aPayloadCodec(ADR-0003). The two are not the same shape. Port the codec logic, not the wire format. -
Translate each retry policy. Temporal's
RetryPolicyand harvest'sRetryPolicycover the same five fields, under different names:Temporal field Harvest field maximumAttemptsmax_attemptsinitialIntervalinitial_intervalbackoffCoefficientbackoff_coefficientmaximumIntervalmax_intervalnonRetryableErrorTypesnon_retryable_errorsCheck
maximumAttemptsbefore you copy it. In Temporal,0and an omitted field both mean unlimited attempts. Harvest has no such value.max_attemptsis always a hard, finite cap. A copy of0does not give you unlimited retries. It gives you zero.RetryPolicy::next_delaystops the first retry once the attempt count reachesmax_attempts, so a cap of0blocks even that first retry. Leaving the field out is not safe either. Harvest's default cap is3. Set an explicitmax_attemptsfor every activity that used Temporal's unlimited-retry pattern. If the activity should keep retrying until an outer deadline, not until it runs out of attempts, set a highmax_attempts. Pair it withschedule_to_close(checklist item 4, below) as the real bound.Harvest supports four jitter shapes:
JitterPolicy::None,Full,Equal, andDecorrelated. The default isFull, so a ported policy retries anywhere in[0, base]. SetJitterPolicy::Noneto keep exact backoff timing. -
Translate each timeout name. This is the single most common porting mistake. The two engines invert the meaning of two timeout names:
Temporal timeout Bounds Harvest equivalent startToCloseTimeoutOne activity attempt #[activity(start_to_close = "...")]scheduleToCloseTimeoutAn activity across all its retries #[activity(schedule_to_close = "...")]scheduleToStartTimeoutTime an activity waits in the queue before a worker claims it #[activity(schedule_to_start = "...")]heartbeatTimeoutTime between activity heartbeats #[activity(heartbeat_timeout = "...")]Workflow Run Timeout — bounds one run (one continue-as-new segment) One workflow execution #[workflow(execution_timeout = "...")]Workflow Execution Timeout — bounds the whole continue-as-new chain Every run from the first start to the final completion #[workflow(chain_execution_timeout = "...")]Read this table carefully. The two engines name the same pair of concepts in opposite order. Temporal's Run Timeout bounds one run. That is the narrow scope. Its Execution Timeout bounds the whole continue-as-new chain. That is the wide scope. Harvest's naming runs the other way.
execution_timeoutis the narrow one.chain_execution_timeoutis the wide one. Match each name against this table. If you skip this check, you swap the two timeouts. -
Map each Task Queue to a harvest queue. Temporal's Task Queue is a plain string. Both workflow tasks and activity tasks route through it, and a worker polls it. Harvest splits the two. An activity's queue comes from the
queueattribute (#[activity(queue = "...")]). A workflow's own dispatch task uses a separate setting, chosen when you start it: thequeuefield on the start request, orStartWorkflowParams::queue_namefrom Rust code. Set both. If you omit the workflow-start queue, the workflow task lands on"default", even when every one of its activities is routed correctly elsewhere. Cover every queue you use, on both sides, in the worker's queue list inWorkerConfig. Harvest also routes within one queue by worker build ID and by capability label. Use these if your Temporal deployment used Task Queue naming conventions for the same purpose. -
Validate with
WorkflowReplayerandWorkflowTestEnvbefore you ship.autumn_harvest::testing::WorkflowReplayerreplays a recorded event history against your ported workflow function. It reports whether the function is deterministic. This is the harvest analogue of Temporal's own replay tester.autumn_harvest::testing::WorkflowTestEnvdrives a workflow function end-to-end without a database. Use it to assert the function's behavior. It checks which activities the function calls, and what it returns, as a plain#[tokio::test]. The Worked example below uses both.
Dual-run cutover playbook
Port and cut over one workflow type at a time. Do not flip one flag for your whole application.
-
Route new starts by workflow type, not by a global switch. Add a feature flag, or a config table keyed by workflow type name. For each type, the flag says whether a new request starts a Temporal execution or a harvest execution. An in-flight execution of either engine keeps running on the engine that started it. See the history-import non-goal above.
A schedule-driven type needs one more step before this flag does anything. A Temporal Schedule fires new executions directly from the Temporal server. Application code, and the flag above, are never consulted. Flipping the flag routes a caller-initiated start correctly. It does not stop the Temporal Schedule from also firing on its own. If the mapped harvest
WorkflowSchedule(see Schedules above) is enabled at the same time, both engines independently fire the same logical job. Each duplicates whatever that job does: a duplicate charge, a duplicate notification, a duplicate batch. For a scheduled workflow type: pause the Temporal Schedule first. Confirm it is quiesced. Decide what to do with any firings it buffered during that window, drop them or replay them by hand, before you enable the harvest side.CatchupPolicygoverns the harvest schedule's own backlog on its first tick; it has no visibility into firings the Temporal side already buffered, so that decision is yours to make first. Only then create or enable the mappedWorkflowSchedule.POST /admin/schedules/{id}/pausekeeps the harvest side off until you are ready, the same way you paused the Temporal side.A follow-up operation against one already-started execution — a signal, a query, an update, a cancellation — must route by where that execution actually started, never by the flag's current value. The flag only decides where the next new start goes; the sentence above already says an in-flight execution keeps running on the engine that started it, and the same holds for every operation sent to it afterward. Re-deriving "which engine hosts this execution" from today's flag misroutes every such operation for an execution that started before the last flip. A signal or cancel sent to the wrong engine silently does nothing there. A
SignalWithStart-shaped call against the wrong engine does something worse: it starts a new, spurious execution instead of reaching the one you meant. Persist which engine started each execution — a(workflow_name, workflow_id) -> enginerecord, written once, at start time — and route every later operation for that execution by looking it up, never by re-reading the flag. The worked example'scancelsignal, below, is exactly this case: sent to the wrong engine, it leaves the real execution running and uncancelled. Step 7's end-of-lifecycle drain handoff, further down, is this same rule applied once, to a single specific execution — not a separate mechanism. A schedule-driven workflow type needs an extra step before you flip its flag. The Temporal server starts a Temporal Schedule's executions directly. No application code runs in that path. The flag above controls nothing for it.Pick one cutover timestamp. Create the harvest
WorkflowScheduleat that timestamp, already paused.WorkflowSchedule::with_paused(true)sets this in the same insert that creates the schedule. Its first computed slot then falls at or after the cutover timestamp, since nothing was due before a schedule that did not yet exist.An interval schedule re-anchors its phase to that creation moment.
Schedule::Intervalcomputes its first slot asUtc::now()at the actual insert, plus the interval. That is not the next occurrence of the original Temporal schedule's own phase.Neither
WorkflowSchedulenor its create request accepts that moment as an input. You cannot choose it to land on the original phase, only observe it after the fact. Request and database latency move it later still, past whatever instant you were aiming for. UseSchedule::Croninstead when the phase must survive the cutover. A cron expression computes its next slot from its own absolute phase, not from when you happened to create it.Do not create the harvest schedule earlier and leave it paused, waiting for the cutover timestamp. A slot can come due during that wait, and neither engine's catchup policy resolves it cleanly for you. Temporal's
CatchupWindowand harvest'sCatchupPolicyeach govern a missed interval as a whole, not slot by slot. Neither has a mode that reliably fires none of it. Harvest'sCatchupPolicy::SkipAllstill fires the oldest missed slot, for one example.Pause the Temporal Schedule first, then create and unpause the harvest schedule with
POST /admin/schedules/{id}/resume. A small gap between the two calls is still possible. Treat a slot missed inside it as a deliberate, bounded loss, or fire it by hand throughPOST /admin/schedules/{id}/trigger. Keep the gap short enough that this stays rare.A follow-up operation against one already-started execution follows a different rule. A signal, a query, an update, and a cancellation each name one specific execution, not a workflow type. When you do not already know which engine hosts that execution, route the call to whichever one hosts it right now. Never route it by the flag's current value. The flag can flip between an execution's start and a later follow-up call against it.
A workflow id can also outlive one execution.
WorkflowIdReusePolicylets a new execution reuse the same(workflow_name, workflow_id)after the old one closes. That reuse can land on the other engine after a rollback. A terminal execution is not the same thing as a reused id, though. Most terminal executions just finished normally, with nothing after them. Confusing the two is the hazard. Treating every terminal run as "not here, try the other engine" misroutes it. An ordinary completed run still needs its own follow-ups routed correctly, such as a query for its result.Write a
(workflow_name, workflow_id) -> enginerecord before you start the execution, not after. The start and the record write are two separate operations. A crash between them is possible in either order. Writing the record first bounds the damage. At worst, a record names an engine with no matching execution yet. That is a state you can detect and retry. Writing it after leaves the opposite state undetectable. A real execution can exist with no record, defaulting to the wrong engine.A definitive rejection is not the same as an unknown outcome. Validation, authorization, and a reuse-policy conflict all fail synchronously and permanently. Retrying that same call fails the same way every time. Restore the record to whatever it named before this attempt, or delete it if there was none. Do not leave it pointing at an engine where nothing will ever run. A crash or a lost response, unlike this case, is genuinely unknown and worth reconciling by retry.
An application-level flag routes a new request in your own code, whether it sends the request to harvest or, after a rollback, back to Temporal. Write the record at that same decision point. Overwrite any earlier record for the same id. Treat a missing record as Temporal. A flag-routed start there, and a rollback's restart, are both Temporal's by default. Nothing in your system could have started either one on harvest instead.
A schedule-driven type has no hook to write this record for a harvest-fired execution. Harvest's built-in scheduler starts each firing directly. It exposes no callback before or after admission for your own code to run. Capture ownership a different way instead.
Pause the Temporal Schedule first. Then list every execution of that type Temporal has ever started, through Temporal's own visibility API. List open and closed executions, not only the in-flight ones. A query taken before the pause misses two cases. It misses an execution that finishes between the query and the pause. It misses one the schedule fires in that same gap. Pausing first closes both gaps. No new firing can start once Temporal is paused.
Temporal's own visibility can lag behind its executions. This lag is real when the deployment uses an eventually consistent store for it. A just-finished or just-started execution may not appear in the list right away. Wait past that deployment's own indexing delay before you trust the list as complete.
A fixed wait cannot guarantee the list has converged. There is no completion signal to check, and no guaranteed upper bound. Do not treat it as a proof. Reconcile instead, by describing the id directly against Temporal. A per-id describe uses its own live lookup, not the eventually consistent list.
A harvest match is not conclusive either, if that workflow id was ever reused. An older, unrelated harvest execution can already sit under it, from a generation before this schedule-driven type existed. Finding it satisfies a miss-only check without ever probing Temporal. Describe the id against Temporal unconditionally instead, whether or not harvest also has something under it. Treat a match there as Temporal's, regardless of what harvest reports. Fall back to the default classification only once both come back empty.
Treat a follow-up against one of those captured ids as Temporal's, permanently, no matter what state that execution reached. Treat any other id of that type as harvest's. Only harvest's schedule can start a new one once Temporal is paused.
A rollback of a schedule-driven type reverses this capture. Pause the harvest schedule first, then resume the Temporal Schedule.
A follow-up request carries only a workflow id, not a timestamp. "Started after this point" is not something you can test against that id alone. List every execution of that type harvest has ever started instead, the same way the forward capture lists Temporal's: open and closed executions, not only the in-flight ones. Treat a follow-up against one of those captured ids as harvest's, permanently. Treat any other id of that type as Temporal's, since only Temporal's schedule can start one once harvest is paused. The ids captured at the forward cutover keep the classification they already have.
Resuming the Temporal Schedule can also refire the interval harvest just owned. Temporal's own
CatchupWindowdecides whether a paused schedule's missed backlog fires on resume, the same primitive the forward cutover already names. Set it to exclude the harvest-owned interval, or advance the schedule's own recorded state past it, before you resume. Otherwise the resume duplicates every side effect harvest's own firings already committed.Querying by workflow id alone cannot tell two things apart, on a reused id. That id can already carry an older, unrelated execution on the engine the record names. A query then finds that older execution and reads as a hit. The intended new start never actually happened.
AllowDuplicatethen attaches to the old execution instead of creating the intended one.Generate a fresh idempotency key for the start attempt instead. Persist it alongside the pending record, before the start call. Pass it as the
Idempotency-Keyheader. Reconcile by retrying that exact same call with that same key, not by querying the workflow id again. Harvest dedups a repeated key onto its own earlier same-key start. That dedup is scoped to(workflow_name, idempotency_key), regardless of any older execution already under that workflow id.A fresh key still runs the ordinary reuse policy once, the first time harvest sees it. Only a repeat of that same key skips straight to the dedup.
AllowDuplicateandRejectDuplicatedo not create the intended replacement on that first run, if an older terminal execution already sits under the reused id. Each attaches to it, or refuses, and harvest repoints the key at that outcome permanently. The intended new execution never gets created at all.Use
WorkflowIdReusePolicy::TerminateIfRunningfor this call instead. It starts a fresh run unconditionally once the prior one is terminal, which is what a genuine reuse restart needs. Pairing it with the idempotency key is what makes retrying it safe. A fresh key evaluatesTerminateIfRunningfor real, creating the intended execution. A repeated key deduplicates onto whatever that first evaluation produced, without ever re-running its cancel-and-restart logic. Check the response'sstarted_freshfield to tell the two cases apart.This dedup window is bounded, 24 hours by default. Reconcile well inside it.
Do the same on the Temporal side, deliberately. A fresh call to Temporal's start API mints a fresh request id of its own. Only a transport-level retry of that exact call keeps the original id. A reconciliation call written after a crash is a new call, not a retry of the old one. Generate your own request id before the first attempt. Persist it next to the pending record. Supply that same id yourself on every reconciliation call. Temporal's own dedup then applies to your attempt. It can no longer mistake a reconciliation retry for a genuine new start.
This record names the current owner only. It matches harvest's own by-id resolution, which also resolves to the latest run, not a specific historical one. A follow-up against a specific prior generation needs its own engine and execution id, captured from when that generation was current.
Once you know the engine, resolve the current execution on it before you act. Harvest's
/workflows/by-id/{workflow_name}/{workflow_id}route family does this on the harvest side. It resolves a business id to its current run. A stale cached execution id can be left behind by a continue-as-new or a reset. This resolution keeps such a stale id from misdirecting a signal, a query, or a cancellation. Route those three operations through that same by-id family.The family has no update route. Read the resolved execution id from its response, then send the update to
POST /workflows/{id}/update/{update_name}with that id. A continue-as-new between those two calls can still move the id you read out from under the update.admit_updateresolves only a workflow-level retry chain, not a continue-as-new chain, so it will not find the new current run for you there. Keep the gap between the two calls short.Do not retry the update call itself if its result goes missing. Every admission mints a fresh update id, with no dedup key of its own. A retry can run the update's own logic a second time. If you lose the response, read the execution's state instead to confirm whether the update already applied, rather than resending it. Use Temporal's own equivalent business-id resolution on the Temporal side.
This design replaces three separate resolution attempts. A terminal execution keeps routing to the engine that finished it, since nothing overwrote its record. A reused workflow id after a rollback gets a fresh record the moment the rollback's own flag flip routes the new start to Temporal. A schedule-driven type instead captures its Temporal-side ids once, at cutover, since harvest's own scheduler has no hook to write one.
Confirm the previous execution under a reused id is not still active on its own engine before you start a new one elsewhere. Query harvest's by-id resolution above. Check Temporal's own visibility API too. Start the new execution only once both report no active run for that id. Step 7's handoff below already follows this discipline. It waits for
COMPLETEDbefore starting the new execution. Apply the same discipline to any other reused id, including one reused across a rollback.This check-then-start sequence has its own race. Two concurrent requests for the same reused id can each observe no active run, then each start on a different engine. Neither engine's own query is transactional with the other, and neither is transactional with your own start call. Serialize this sequence yourself. Take an application-level lock keyed on
(workflow_name, workflow_id)before you query either engine, and hold it until your own start call returns. Release it only then.A
cancelsignal routed to the wrong engine may not fail loudly. It can do nothing there, while the real execution stays un-cancelled. Against aSignalWithStart-shaped call, a wrong-engine route is worse. It can start a new, spurious execution on that engine instead.The handoff in step 7, below, is a special case of this rule. Its
cancelsignal and its final read both route by the same record. A terminal execution's record still names the engine that ran it. Nothing has started a replacement yet at that point. Apply the rule above to every other follow-up operation during the whole dual-run window, not only to that one case. -
Port and validate one workflow type completely before you flip its flag. Run the Workflow-porting checklist against it. Confirm
WorkflowReplayerreports no non-determinism against a realistic set of recorded, or hand-built, histories. ConfirmWorkflowTestEnvcovers its main branches. -
Order types by dependency, not by size. Cut over a leaf workflow type first. A leaf type has no child workflows, and no downstream workflow that signals it. Cut over a parent workflow type only after every workflow type it spawns as a child already runs live on harvest.
ctx.spawn_child_workflowonly starts another harvest execution. It cannot start, signal, or await a Temporal execution. A ported parent cannot simply tolerate a child type still on Temporal, because nothing in harvest can reach that child at all. If a parent must cut over before every one of its children does, replace each such child spawn with an explicit bridge instead. Write an ordinary harvest activity that calls Temporal's own client API. Have it start the child, then poll for the child's result, or await a callback for it. Give that bridge activity the same idempotency key discipline from step 4, below, so a retried bridge call cannot start the same Temporal child twice. Propagate a harvest cancellation into a Temporal cancel request through the same bridge. Building this bridge is real engineering work. It is not a compatibility check. Prefer the dependency order above whenever you can. -
Make every shared downstream activity idempotent. During the cutover window, both engines may call the same downstream service for different executions. Examples are a payment gateway, an email provider, and a database write. Give every such activity a stable, request-scoped idempotency key.
Derive that key before you route the request to either engine. Do not mint it inside the workflow. A retry of the same logical request can land on a different engine than the first attempt did, once its type's routing flag flips.
ctx.new_uuid()is deterministic only within one harvest execution's own recorded history. It cannot match a key a separate Temporal execution already used for the same request. So pass one caller-supplied key into both engines' version of the activity instead. Reuse the request key you already carry in Temporal, or the request ID your upstream caller (a webhook, an API gateway) already assigns. Reservectx.new_uuid()for a value that never needs to survive a retry across engines, such as a correlation ID scoped to one harvest run. -
Watch both engines with the same discipline during the window. Keep your existing Temporal dashboards running. Stand up the equivalent harvest dashboards before you flip the first flag. On the harvest side, watch the
harvest.workflow.terminalcounter for the ported type's completed/failed/cancelled rate. Also watch its dead letter queue (DLQ) for tasks that ran out of retries. On the Temporal side, watch the open-workflow count for that type through Temporal's own visibility API. It should trend to zero as harvest takes over new starts.comparison.mdcovers the wider observability surface. See alsotelemetry.mdfor the OpenTelemetry surface, anddocs/dashboards/for the starter Grafana pack. A per-type cutover means each type's error rate and latency stay directly comparable across the two engines for the whole window. -
Keep a rollback path per type. A flag flipped back sends new starts to Temporal again. An in-flight harvest execution of that type still runs to completion on harvest. The flip does not migrate it back, for the same reason history import does not run forward. Do not flip a type's flag back once you deregister its Temporal worker code. Keep that worker deployed, even if idle, until you are certain you will not roll back.
This rollback only routes new work. It does not send an already-cut-over execution back to Temporal. That gap rarely matters for a short-lived workflow type, since its harvest execution finishes on its own soon after the flag flips back. It matters for a long-lived entity type. Such an execution can loop forever through
continueAsNewand never finish on its own, so this rollback cannot reach it. See the reverse handoff at the end of step 7, below, for what to do instead. -
Retire the Temporal worker for a type only after its queue is empty on both sides. Confirm zero in-flight Temporal executions of that type. Temporal's own visibility API can confirm this count. Confirm zero pending harvest starts still routed to Temporal for it. Then remove that type's Temporal worker code.
The handoff below is step 1's own follow-up-routing rule, applied once, to a single specific execution: send this operation to the engine this execution actually started on, never to whatever the flag says today.
A long-lived entity workflow may never reach zero on its own. It loops forever through
continueAsNew, and nothing inside that loop ever completes it. The Worked example below is exactly this shape: it keeps renewing a subscription until acancelsignal arrives. Do not wait for such an execution to drain.Do not take a live snapshot of its state. Do not start harvest from that snapshot either. A query against a running execution can go stale immediately. The execution can still advance after you read it. It can process a new signal. It can finish an activity that changes its state. Cancellation does not close this gap. Temporal delivers a cancellation as an interruption at the workflow's next await point. It does not stop an activity already in flight. It does not undo a side effect that activity already committed. Either race can start harvest from a stale value. It can repeat a side effect the old execution already performed. In the worked example, that means charging the same billing cycle twice.
Hand the state across through the workflow's own signal instead. Use the same
cancelsignal shown below. Send it. Wait for the execution to reachCOMPLETEDon its own. It reaches that state at the point in its loop where it already checks the signal. Only then read its final state. The Worked example below shows one way. Eachif (cancelled)branch returns the state where harvest must resume. The two branches differ. The first branch returns the input unchanged. Nothing happened yet this run. The second branch returns the next cycle, not the current cycle.chargeCardalready ran for the current cycle before that branch's check. Returning the current cycle from that branch would charge it again. If your own workflow returns nothing useful, query the now-closed execution for its state instead. A closed execution cannot advance further. Neither read method can go stale. A query against a still-running execution can. Start the harvest execution only after that read, carrying the result forward as its input. The defined point can sit behind a long wait, such as the worked example's 30-day timer. The handoff then takes that long too. Accept that wait. Or, before you begin this type's cutover, change the workflow to race the wait against the signal instead. Harvest'sctx.receive_signal_timeoutis the primitive for that race on the harvest side.Routing the
cancelsignal above is a special case of step 1's general follow-up-routing rule. Route it by the entity's own(workflow_name, workflow_id) -> enginerecord. For this forward handoff, that record is missing or still names Temporal. No start has ever routed this entity to harvest yet. Never route the signal by the flag's current value.Read the final state from that same engine: Temporal, not harvest. The record does not change until a new execution actually starts elsewhere. The harvest execution here only starts after this read, not before it.
Treat each entity's handoff as a deliberate cutover step, not a bulk migration. Each one is a live, stateful run. It is not disposable work.
Build a reverse handoff only if a long-lived entity's cutover must roll back. Mirror the forward handoff above, in the other direction. Send the entity's
cancelsignal to its harvest execution. Wait for that execution to reachCOMPLETED. Then read its final state. Read it from the execution's own return value, if the ported workflow's cancellation branches carry one. This guide's own worked example does not. Both of its cancellation branches return(). The worked example only demonstrates the forward direction. Read the final state instead from a query handler against the closed execution. This is the same fallback named above for a workflow whose return value carries nothing useful. Start a fresh Temporal execution from that read. This guide does not ship this reverse handoff. You build it only if you need one. Decide this before you cut over a long-lived entity type. Confirm you can accept harvest as that entity's permanent home, if its cutover does not go well.
Worked example
The workflow below ports directly. It has one push-based signal, one
activity, one durable timer, and a continueAsNew loop. The Temporal side is
TypeScript. The harvest side is a real, compiling Rust file:
examples/temporal_port_subscription_renewal.rs.
It has its own tests. This repository's CI runs those tests on every change.
Both sides implement the same subscription-renewal entity. Charge a
customer. Wait 30 days for the next billing cycle. Loop via continueAsNew.
Stop the loop if a cancel signal arrived.
Temporal (TypeScript)
import {
proxyActivities,
defineSignal,
setHandler,
sleep,
continueAsNew,
} from '@temporalio/workflow';
import type * as activities from './activities';
const { chargeCard } = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
});
export const cancelSignal = defineSignal('cancel');
export interface SubscriptionState {
subscriptionId: string;
cycles: number;
}
export async function subscriptionRenewal(
state: SubscriptionState,
): Promise<SubscriptionState | void> {
let cancelled = false;
setHandler(cancelSignal, () => {
cancelled = true;
});
// A cancellation already recorded before this run started stops the loop
// immediately. Nothing happened yet this run, so the checkpoint below is
// the input unchanged.
if (cancelled) {
return state;
}
// Derive the key from state.subscriptionId, not from
// workflowInfo().workflowId. Playbook step 4 needs a key the caller
// supplies before routing the request to either engine. subscriptionId
// is that key: it names the same subscription on both sides of a
// dual-run cutover, no matter which engine's own internal id the request
// happens to carry.
const idempotencyKey = `${state.subscriptionId}-cycle-${state.cycles}`;
await chargeCard(idempotencyKey, state.cycles);
// Wait for the next billing cycle. A cancel signal delivered during this
// wait is observed as soon as the wait resolves.
await sleep('30 days');
// chargeCard already ran for this cycle, above. The checkpoint below must
// be the next cycle, not the current one, or a resumed run repeats the
// charge that already happened.
if (cancelled) {
return { subscriptionId: state.subscriptionId, cycles: state.cycles + 1 };
}
await continueAsNew<typeof subscriptionRenewal>({
subscriptionId: state.subscriptionId,
cycles: state.cycles + 1,
});
}
Harvest (Rust)
use autumn_harvest::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubscriptionState {
pub subscription_id: String,
pub cycles: u32,
}
/// A downstream idempotency key, plus the cycle number it applies to. See
/// playbook step 4 in the migration guide: every side-effecting activity
/// call needs its own caller-supplied key, derived from state already in
/// history, so a retried attempt cannot charge the card twice.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChargeRequest {
pub idempotency_key: String,
pub cycles: u32,
}
#[activity(start_to_close = "30s")]
async fn charge_card(_ctx: &ActivityContext, req: ChargeRequest) -> Result<(), String> {
// A real billing call goes here, passing req.idempotency_key through to
// the payment provider's own idempotency-key parameter.
println!(
"charged card for cycle {} (idempotency key: {})",
req.cycles, req.idempotency_key
);
Ok(())
}
#[workflow]
async fn subscription_renewal(
ctx: &WorkflowContext,
state: SubscriptionState,
) -> Result<(), String> {
let cancelled = Arc::new(AtomicBool::new(false));
let handler_flag = cancelled.clone();
ctx.register_signal_handler_raw("cancel", move |_payload| {
handler_flag.store(true, Ordering::SeqCst);
});
// Registration only stores the handler -- it does not dispatch inline.
// This cheap deterministic primitive call flushes it before the check
// below, since nothing else runs first. See the module docs above.
let _ = ctx.system_now();
// A cancellation already recorded before this run started stops the
// loop immediately.
if cancelled.load(Ordering::SeqCst) {
return Ok(());
}
// Derive the key from `subscription_id`, not from `ctx.workflow_id()` or
// the execution id. Playbook step 4 in the migration guide needs a key
// the caller supplies before routing the request to either engine.
// `subscription_id` is that key: it names the same subscription on both
// sides of a dual-run cutover, no matter which engine's own internal id
// the request happens to carry.
let idempotency_key = format!("{}-cycle-{}", state.subscription_id, state.cycles);
let _: () = ctx
.execute_activity(
&charge_card_info(),
ChargeRequest {
idempotency_key,
cycles: state.cycles,
},
)
.await
.map_err(|e| e.to_string())?;
// Wait for the next billing cycle. A cancel signal delivered during
// this wait is dispatched to the handler above as soon as the wait
// resolves, before the check below runs.
ctx.timer("next-billing-cycle", 30 * 24 * 60 * 60)
.await
.map_err(|e| e.to_string())?;
if cancelled.load(Ordering::SeqCst) {
return Ok(());
}
let next = SubscriptionState {
subscription_id: state.subscription_id.clone(),
cycles: state.cycles + 1,
};
ctx.continue_as_new(serde_json::to_value(next).map_err(|e| e.to_string())?)
.await
.map_err(|e| e.to_string())?;
unreachable!("continue_as_new suspends the run and never resolves")
}
What changed, and why
- Signal handler. Temporal's
setHandler(cancelSignal, () => { ... })is push-based and fire-and-forget. It fires whenever a matching signal arrives, at any point in the workflow body. Harvest's direct 1:1 port isctx.register_signal_handler_raw. It is also push-based, and also fire-and-forget. Do not reach forwait_for_signalhere. That is harvest's pull-based primitive. It matches Temporal'scondition()helper instead. It blocks one code point, rather than reacting from anywhere in the workflow body. See the Signals row above. - Routing the
cancelsignal itself. This example assumescancelalready reaches the engine hosting this subscription's execution. A real dual-run cutover cannot assume that. Look up the(workflow_name, workflow_id) -> enginerecord for this specific execution first. See the general follow-up-routing rule in the Dual-run cutover playbook, step 1, above. Acancelsignal sent to the wrong engine does nothing there. It leaves the real execution un-cancelled. That un-cancelled execution then blocks the step 7 drain this playbook depends on. - Dispatch timing. Temporal's
setHandlercallback fires as soon as the signal arrives, mid-await. Harvest's push handler dispatches only on the next history-consulting call the workflow body makes. A signal recorded before this run even started needs a flush point before the firstcancelledcheck, since nothing else has run yet. The port addsctx.system_now()for exactly this. It is a deterministic primitive call, not an activity or a timer. It records one event on the first live pass. Every later replay reads that event back, at no extra cost. A signal that arrives during the laterctx.timer(...)wait needs no such extra call. That wait is itself the flush point. It dispatches the handler the moment it resolves. See the module documentation in the worked example file for the full dispatch-timing contract. - Activity dispatch.
proxyActivitiesplus a plain function call becomesctx.execute_activity(&charge_card_info(), input). The#[activity]macro generates thecharge_card_info()function. It carries thestart_to_closetimeout, and any retry policy. You never pass those by hand at the call site. - Idempotency key. Both sides derive a request-scoped key from
subscriptionId(subscription_idin Rust), right before the billing call. That field carries the entity's own stable id, not the workflow or execution id Temporal or harvest happens to assign this particular run. See playbook step 4, above: the key must come from state the caller supplies before routing the request to either engine, so a re-route during a dual-run cutover cannot mint a fresh key and let a retried charge through twice. - The timer.
sleep('30 days')becomesctx.timer(id, seconds). Every harvest timer needs a stableidstring. Temporal's sleep needs no name. Reuse a descriptive constant, as in"next-billing-cycle"above. continueAsNew. Both sides carry the incrementedcyclesvalue forward. Both sides reset the event history. Harvest'scontinue_as_newtakes aserde_json::Value. You serialize the state explicitly at the call site.
Read the full worked example file for the embedded tests. One test exercises
the non-cancelled path: the activity runs, and the loop continues. One test
exercises the cancelled path: a signal recorded before the run starts stops
the loop before the activity ever runs. One test calls .replay_check(...).
It asserts the recorded history replays deterministically. Run the tests
with:
cargo test -p autumn-harvest --no-default-features --features testing \
--example temporal_port_subscription_renewal
Related
comparison.mdexplains whether you should choose harvest over Temporal, DBOS, Inngest, Hatchet, or Restate, and why. It uses the same evidence-linked standard as this page.docs/getting-started/is the from-scratch tutorial. Use it if you are learning harvest's primitives for the first time, instead of porting existing Temporal code.docs/getting-started/07-reliability-knobs.mdcovers retries, concurrency caps, local activities, queues, versioning, and search attributes. It goes deeper than the Workflow-porting checklist above.docs/getting-started/11-testing.mdcovers unit tests for workflow code andWorkflowReplayerregression coverage.workflow-determinism-guide.mdhas the full HVG001–HVG011 guardrail catalogue. The Workflow-porting checklist points to it.sharding.mdexplains how harvest spreads workflow state across Postgres shards. This relates to the multi-region gap noted above.