Recover runs blocked by a missing plugin
A run in flight replays its journal each time it resumes: a decided validation, a timer, a webhook resume, a restart of the endpoint. The replay needs the code that wrote the journal. When the Restate endpoint no longer loads it, the run is blocked:
- the plugin that provides one of the workflow's node types is no longer loaded, or, for a run of journal format 1, the plugin that provides its trigger or declares the event its « Événement reçu » trigger receives (a run of format 2 reads its trigger from its journal);
- for a run of journal format 1, started with
RUN_JOURNAL_FORMAT=1(the default) or by a proxy older than format 2: a plugin version gives the run's event other trigger outputs than the version that started the run (a field added to or removed from the declared event, an output renamed), or gives a task the run has reached another title (a changed translation, another instancelocale). A run of format 2 reads its trigger's outputs and the texts and outputs of its tasks from its journal; - for a run of format 2 whose validation was decided or expired: the loaded plugin version
no longer proposes that validation, so it cannot compute the task's outputs. The step
<node>/outputsfails with « Nœud «» : la version chargée du plugin ne propose plus cette validation. Rétablir la version du plugin qui a démarré la tâche. », which Restate shows as the last failure of the invocation; - the endpoint was rolled back below a core node type the run uses, or below the journal format of the run (see Workflows that use a new core node and Journal formats).
Restate retries the invocation with backoff and without a maximum number of attempts
(worker.invoker.retry-policy of Restate 1.4): after a few attempts, one attempt every 10
to 12 seconds. The run keeps its last persisted state, a validation already decided
included, and goes on at the first attempt after the endpoint loads the same code again.
While the plugin is missing, a new run of the workflow is not blocked: it fails at its
trigger.
This recipe finds the blocked runs, then restores the plugin or ends the runs, and closes
the validation requests a run leaves open in the record, such as a Task in the FHIR
warehouse. The log messages are described in
Observability signals — Run outcome.
Before you start
- Run the Restate commands inside the stack. The Restate server image
(
docker.restate.dev/restatedev/restate:1.4) ships therestateCLI at the server's version, which reaches the admin API onlocalhost:9070without configuration:docker exec <restate-container> restate …, orkubectl execin the Restate pod. The admin API answers the same requests over HTTP on port 9070. Neither has authentication: never publish port 9070 (see Security posture). npx @restatedev/restatedownloads the CLI from the npm registry: it does not run offline.- Keep the endpoint and proxy logs of the last boots: the
plugins chargésline lists thename@versionof each plugin a process loaded.
1. Detect the blocked runs
In Restate
List the run invocations that Restate retries:
restate invocations list --service meridianWorkflow --status backing-off
❯ [2026-09-28 19:19:43.337 +00:00] inv_1kOHVCNtkCO4665x9By7fCZXnBG7iQWGft
Target: meridianWorkflow/<eventId>:<slug>/run
Status: backing-off (9 seconds and 675 ms. Retried 8 time(s). Next retry in …)
Deployment: dp_15MB0NX6tNqSdY5gr4b06VH [required] using restate-sdk-typescript/1.14.5
Error: [570 Journal mismatch] Found a mismatch between the code paths taken during
the previous execution and the paths taken during this execution.
- The mismatch happened while executing 'set state' (index '1')
- Difference:
value: '{"status":"suspended",…}' != '{"status":"failed",…,"error":"Événement « <type> » non déclaré par les plugins chargés."}'
The key between meridianWorkflow/ and /run is the Meridian run id. The same rows, one
per invocation, with SQL:
restate sql "SELECT id, target_service_key AS run, retry_count, last_failure_error_code AS code FROM sys_invocation WHERE target_service_name = 'meridianWorkflow' AND target_handler_name = 'run' AND status = 'backing-off'"
ID RUN RETRY_COUNT CODE
inv_1kOHVCNtkCO4665x9By7fCZXnBG7iQWGft <eventId>:<slug> 13 RT0016
Through the admin API, POST /query runs the same query and answers {"rows": [...]}:
curl -s -X POST http://<restate-admin>:9070/query \
-H 'content-type: application/json' -H 'accept: application/json' \
-d "{\"query\": \"SELECT id, target_service_key, retry_count, last_failure_error_code, last_failure FROM sys_invocation WHERE target_service_name = 'meridianWorkflow' AND target_handler_name = 'run' AND status = 'backing-off'\"}"
restate invocations describe <invocationId> shows the journal of one invocation and its
last failure.
Read the last failure:
| Code | Meaning |
|---|---|
RT0016 (570 Journal mismatch) |
The endpoint no longer runs the code that wrote the journal: the case of this recipe. |
RT0010 (unable to reach the remote endpoint) |
The endpoint is down or unreachable. Every run invocation in progress shows it until the endpoint is back; not a blocked run. |
The Difference gives the state the run holds (left) and the state the endpoint tried to
write (right). When the replay fails the run, the right side is a failed state with the
error: « Événement «
A run that is only waiting is not blocked yet: suspended on a validation, a timer or a
webhook resume while the plugin is missing, it stays suspended in Restate and in
Meridian, and blocks when it resumes. An invocation stays attached to the endpoint for the
inactivity timeout (1 minute by default) before Restate suspends it: a restart of the
endpoint within that minute replays it at once, and blocks it without any decision.
In Meridian
- Endpoint log, component
engine:rejeu interrompu : non-concordance avec le journal du run. Restate retente l'invocation, le run garde son dernier état. Rétablir les versions de l'endpoint et des plugins qui ont démarré le run, ou tuer l'invocation (kill).atwarn, once per invocation and per endpoint process, withworkflow,runIdandinvocationId. When the replay throws (plugin or node type missing),erroris the error that stopped it, and later attempts are logged atdebug. A replay that diverges without an error, such as a run of format 1 on a plugin version that projects the event differently or changes a task title, gets the same line witherror= the first line of the SDK's error ((570) Found a mismatch between the code paths…) andcommand= the journal command that differs (set state [1]), without the compared values. The SDK also writes its own lines to the endpoint's standard error at each attempt ([restate]…WARN: Error when processing a Restate context operation., thenRestateError: (570) Found a mismatch between the code paths…). - Endpoint boot log: the plugin is missing from
plugins chargés, or listed at another version. - Proxy log, each time the proxy loads the workflows, at
error:« <file> » ne peut pas tourner : événement « <type> » non déclaré par les plugins chargés. Vérifier les plugins résolus.or« <file> » ne peut pas tourner : type(s) de nœud absent(s) du catalogue (<type>). Vérifier les plugins résolus.These lines appear only when the proxy lacks the plugin too.GET /api/workflowsthen servesvalid: falseand atriggerProblem(event-unknown,trigger-unknown) for these workflows, andPOST /api/eventsrefuses their event with400« Aucun déclencheur ne revendique l'événement «». ». - Runs:
GET /api/runsand the Executions view (« Exécutions ») keep the blocked run at its last status:suspendedwith its validation (counted by theto resolvetag, « à trancher »),waitingorrunning. The decision that resumes a suspended run is accepted:200after about 6 seconds,validation tranchéein the proxy log, and the run stillsuspendedwith the same validation. A second decision answers409« validation déjà tranchée ». - New runs that reach the endpoint while the proxy still loads the plugin (a partial
rollout, two manifests) end
failedat once, thetriggernode failed with the error, and the endpoint logsrun échoué. - Metrics:
meridian.runs.executedcounts runs that complete or fail, not the attempts of a blocked run. No metric counts blocked runs: watch therejeu interrompuwarning and thebacking-offlist.
Tell a blocked run from a failed run
| Blocked run | Failed run | Killed run | |
|---|---|---|---|
| Status in Meridian | last status (suspended, waiting, running) |
failed, frozen in the run index |
failed, frozen in the run index, error « Invocation arrêtée dans Restate (killed). » |
| Log | endpoint, rejeu interrompu … (warn), once per invocation |
endpoint, run échoué (error), once |
proxy, run arrêté dans Restate : statut figé en échec (info), once |
| Restate | backing-off, RT0016, retry count growing |
completed: the run handler returns the failed state, Restate does not retry |
completed, [409 Aborted] killed |
| Metrics | not counted | meridian.runs.executed, status: failed |
meridian.runs.settled, ending: killed |
2. Decide
- Restore the code that wrote the journals: the plugin at the version the runs started with, or the platform image. Nothing is lost: each blocked run replays at its next attempt and goes on, and a decision taken meanwhile is applied. Published plugin versions are immutable, so the registry still holds that version.
- End the runs when the plugin must not come back or the workflow must not go on. Only a kill ends a blocked run. The steps after the blocking point never run: the pending clinical work (a decided validation, a notification) is handled by other means.
The version to restore is in the endpoint's plugins chargés line of the boot before the
change. meridian-plugin versions <@scope/name> --registry <url> lists the published
versions (see Set up a plugin registry).
3. Restore the plugin
Pin the version in the instance manifest of the endpoint and of the proxy:
plugins: "@acme/vitals": "1.4.2" # the version that wrote the journalsA range can resolve to another version at the next boot. For a plugin loaded through
PLUGINS_PATH, put the same folder back: a plugin listed inplugins:that is also found onPLUGINS_PATHloads from there, whatever the pin, and the boot only warns when that copy is outside it. See Declare and install plugins.Restart the endpoint, then the proxy. The endpoint registers again at the same address (
RESTATE_ENDPOINT_URL): Restate updates the deployment in place, and the blocked invocations, bound to it (Deployment: … [required]), run there.Check the endpoint log:
plugin présent en cacheorplugin téléchargé du registrewith the version, thenplugins chargéslisting<name>@<version>. At its next attempt, within about 12 seconds, each blocked run replays:run terminéwhen it completes, nothing when it reaches its next wait.restate invocations list --service meridianWorkflow --status backing-offno longer lists it, andGET /api/runs/<runId>serves its new status, with the decision taken during the blockage in its trace (validation-resolved).The runs that failed at their trigger during the gap stay
failed. Sending their event again with the sameeventIdstarts nothing (200,created: false). To run one workflow on it, send the event with a neweventIdand the workflow's file in__workflowFile; the other workflows that receive the event are not started again:curl -s -X POST http://<proxy>:4000/api/events -H 'content-type: application/json' \ -d '{"type": "<type>", "eventId": "<new-id>", "__workflowFile": "<slug>.workflow.yaml", "patientId": "<id>", …}'In the console: Executions → Emit an event (« Émettre un événement »), with the workflow in Target workflow (« Workflow cible »).
4. End the runs
Kill each blocked invocation, by target or by invocation id:
restate invocations cancel --kill --yes meridianWorkflow/<runId>/runID TARGET STATUS inv_14DFsDTTZXby16ZYgrYrGANFhULWcSlMUy meridianWorkflow/<runId>/run backing-off Are you sure you want to kill these invocations? Auto-confirming --yes is set. [OK]: Request was sent successfullycurl -s -X PATCH http://<restate-admin>:9070/invocations/<invocationId>/killThe admin API answers
200with an empty body.Check the validation requests these runs leave open in the record (step 5).
Restate then lists the invocation completed, with [409 Aborted] killed as
completion_failure:
restate sql "SELECT target_service_key AS run, status, completion_failure FROM sys_invocation WHERE target_service_key = '<runId>' AND target_handler_name = 'run'"
RUN STATUS COMPLETION_FAILURE
<runId> completed [409 Aborted] killed
The proxy reads how each live run's invocation ended
(GET /restate/workflow/meridianWorkflow/<runId>/output on the ingress, 409 killed for a
killed one) and freezes the killed run in the run index:
- when: at the next settlement pass (
RUN_SETTLE_INTERVAL_MS, every minute by default, see Environment variables). When the run's last state issuspendedorwaiting, also at once when its detail is read (GET /api/runs/<runId>, the run in Executions), a decision is sent for it or its wait is resumed. A run killed while its last state isrunningwaits for the pass; withRUN_SETTLE_INTERVAL_MS=0it is never frozen; - status
failed, error « Invocation arrêtée dans Restate (killed). ». The run's last state, the validation it waited on included, is archived with the status, so its detail stays readable after Restate removes the workflow, one day after the kill (workflow_completion_retentionof themeridianWorkflowservice); - the proxy logs
run arrêté dans Restate : statut figé en échec(componentruns,info) withrunId,workflowandfailure: killed, andmeridian.runs.settledcounts it (ending: killed); - when the run's last state waits on a validation, the proxy withdraws its request before
it freezes the run, whatever the run's journal format: one call per subject of the run
whose kind has a record port, sent one way to the endpoint's
recordservice (closeValidationRequest, outcomewithdrawn) with the idempotency keywithdraw:<runId>:<pendingId>:<kind>, and retried by Restate for up to one hour. A record adapter that implementscloseValidationRequestcloses the request; one that does not, such as thefhiradapter of@posos/fhir0.1.5, leaves it open (step 5). A call the ingress refuses leaves the run live in the index: the proxy logsretrait de la demande de validation impossible : run non figé, repris au règlement suivant(componentruns,warn) withrunId,workflowandkind, and sends again at the next pass.
GET /api/runs, GET /api/runs/<runId> and Executions then show the run failed,
without a pending validation: it leaves the to resolve tag (« à trancher »), and
Accept (« Accepter ») and Reject (« Refuser ») are no longer offered. A decision
through POST /api/runs/<runId>/resolve answers 409 « run terminé : aucune validation
en attente », and no signal reaches Restate; a decision feed rejects it and logs
résolution rejetée with « décision sur un run terminé : validation non appliquée ». A
resume of its wait through POST /api/resume/<runId>/<nodeId> answers 409 « run
terminé : aucune attente en cours ».
A purge (restate invocations purge <invocationId>,
PATCH /invocations/<invocationId>/purge) removes the invocation and its state at once.
Purge only after the proxy has frozen the run (its status is failed in GET /api/runs).
Otherwise the proxy that started the run, the one whose RESTATE_URL is recorded on the
run (restate_url in the run index), freezes it failed with the error « Invocation
absente de Restate : issue du run inconnue. », without its last state (stateLost: true),
and logs run absent de Restate : statut figé en échec at warn. A run started before
the release that records restate_url is never frozen that way: the proxy counts it in
<n> run(s) d'un autre Restate ou sans Restate d'origine, non réglé(s) at each pass (see
Upgrade the platform).
A cancel (restate invocations cancel <invocationId> without --kill,
PATCH /invocations/<invocationId>/cancel, answer 202) does not end a blocked run.
Restate delivers the cancellation to the next attempt, whose replay fails before reading
it, and the invocation stays backing-off. While the plugin is missing, a cancel sent to
a run that is only suspended resumes it into the same retry loop. When the plugin comes
back, a pending cancel ends a run still waiting on its validation (failed, error
Cancelled), and a run whose validation was decided before the cancel goes on to its end.
A run of format 2 withdraws its validation request before the cancel ends it.
5. Close the validation requests left open
A run publishes each validation it waits on to the record of its subjects, through the
record port of each kind (publishValidationRequest). The platform closes that request
through the same port (closeValidationRequest) when the validation ends outside the
record:
- a run of journal format 2 closes it itself, in a durable step, after a decision taken in the console or through the API (accepted or rejected, with its author, time and comment), after its deadline (expired), and when a cancel reaches it while it waits (withdrawn);
- the proxy withdraws it when it freezes a run killed, or ended in failure, in Restate while the run waited on it, whatever the run's format (step 4).
A request stays open in the record, to close by hand, in these cases:
- the run is of format 1 and its validation ended outside the record: a decision with Accept (« Accepter ») or Reject (« Refuser ») or through
POST /api/runs/<runId>/resolve, an expired deadline, a processed cancel; - the record adapter does not implement
closeValidationRequest: thefhiradapter of@posos/fhir0.1.5 does not; - the run was frozen without its last state (
stateLost: true, « Invocation absente de Restate : issue du run inconnue. »): the proxy does not know the validation it waited on, and withdraws nothing; - the closing failed: in a run, the endpoint writes
clôture de la demande de validation échouée : <kind> : <error>to its console (a Restate SDK line,warn), and the run goes on. After a kill, thecloseValidationRequestinvocation of therecordservice isbacking-offwhile it retries (restate invocations list --service record --status backing-off), then ends in failure after one hour and leaves that list: the endpoint logsclôture de la demande de validation abandonnée après les relances : la clore dans le dossier(componentengine,warn) withrunId,pendingIdandkind; - the run of format 2 is blocked after its decision because the loaded plugin version no
longer proposes the validation (see the list at the top): the run closed the request as
decided before it computes the task's outputs. Restore the plugin version that started
the task (Restore the plugin) and the run applies the decision.
When you kill the run instead, the Task keeps the decision and the workflow does not
apply it: tell the care team, as for a
completedorrejectedTask below.
When the patient port uses the fhir adapter, each validation a run waits on is a FHIR
Task (code urn:ehr-lab:validation|decision), identified by
urn:ehr-lab:validation-run|<runId> and urn:ehr-lab:validation-pending|<pendingId>
(see ADR 0002).
For each run in one of these cases, find its Tasks:
curl -s '<fhir-base>/Task?identifier=urn:ehr-lab:validation-run|<runId>'
requested: nobody decided in ehr-lab. After a kill, a cancel or a decision taken elsewhere, ehr-lab still lists the validation, and a decision taken there reaches no run. Set the Task tocancelled: read it, changestatus, write it back.curl -s '<fhir-base>/Task/<id>' > task.json # "status": "requested" → "status": "cancelled" curl -s -X PUT '<fhir-base>/Task/<id>' -H 'content-type: application/fhir+json' --data-binary @task.jsonehr-lab lists only
requestedTasks, and thefhir-validation-decidedbinding reads onlycompletedandrejected: acancelledTask leaves both.completedorrejected: the decision was taken, and the run did not apply it. Tell the care team: the actions the workflow takes after that decision did not happen.
For a run of format 1, a decision taken in the console or through
POST /api/runs/<runId>/resolve does not change the Task: a validation decided with
Accept or Reject keeps its Task requested. Check the Tasks of every run of format
1 you end, whatever the way its validation was decided, and of every run whose record
adapter does not close its requests.
6. Prevent
- Keep a plugin on every endpoint replica while runs of the workflows that use its
triggers, events or nodes are in flight. Before removing it, or before rolling the
endpoint back to an image without one of the core node types a workflow uses:
- unpublish those workflows (Unpublish, « Dépublier », in the workflow's version
panel, or
POST /api/workflows/<slug>/unpublish); this stops new runs only; - wait until none of their runs is
running,suspendedorwaitingin Executions, or cancel them in Restate while the endpoint still loads the plugin (restate invocations cancel --yes meridianWorkflow/<runId>/run): the cancel is then processed, and the run endsfailedwith the errorCancelled. A run of format 2 withdraws its validation request; close the requests of the cancelled runs of format 1 (step 5); - remove the plugin, or roll the endpoint back.
- unpublish those workflows (Unpublish, « Dépublier », in the workflow's version
panel, or
- Publish no workflow on a new trigger or event before every endpoint replica loads the plugin that provides it: a replica without it fails the new runs at their trigger.
- Pin plugin versions exactly in
plugins:, and give the endpoint and the proxy the same manifest. A run of format 2 keeps its trigger's outputs and the texts and outputs of its tasks: a plugin version that changes the declared event, the trigger's outputs or a task title applies to the runs that start after it. A run of format 1 is blocked by such a version when its trigger's outputs change, or the title of a task it has reached: deploy it when no run of format 1 of the workflows concerned is in flight, or roll it back as in Restore the plugin. Every run started withRUN_JOURNAL_FORMAT=2is of format 2. - Keep every endpoint replica on a release that knows the journal format of the runs in
flight: set
RUN_JOURNAL_FORMAT=2on the proxy once every endpoint replica knows format 2, and before rolling the endpoint back below it, setRUN_JOURNAL_FORMAT=1and wait until no run of format 2 is in flight (see Journal formats). - Watch the
rejeu interrompuwarning, and run thebacking-offquery after each deployment of the endpoint.
The rollout order is in Upgrade the platform.
Related
- Observability signals — Run outcome — the log messages of a run's attempts.
- Upgrade the platform — rollout order, core node types, roll back.
- Declare and install plugins and
Set up a plugin registry —
plugins:, pinned versions, the registry. - Why durable execution — journal and replay.
- Declare an event — renaming or removing a declared event, from the plugin author's side.
- ADR 0007: Trigger configured by a declared event — the regime of a vanished event.
- ADR 0008: A run keeps its projection and task texts — journal format 2, and the closing of validation requests.