gRPC Worker Protocol
External workers report progress on dispatched saga actions to the engine over a gRPC stream. This document describes that wire protocol for SDK authors and integrators.
The schema source of truth is proto/liveness.proto
(proto package saga.v1, Go package livenesspb). This doc explains it;
the .proto file remains authoritative for exact wire layout.
Endpointโ
The engine (cmd/engine) serves gRPC on :9090 by default
(WORKFLOW_ENGINE_GRPC_PORT, see internal/config/config.go). The transport is
plaintext (insecure credentials) โ TLS is expected to be terminated by the
service mesh / ingress, not by the engine itself.
A worker's gRPC address is configured via BootstrapConfig.GrpcURL, e.g.
go-saga-orchestration-engine.platform.svc.cluster.local:9090.
Service: WorkerLivenessโ
service WorkerLiveness {
rpc ExecuteStep(stream WorkerEvent) returns (stream EngineEvent);
}
ExecuteStep is a bidirectional stream. The worker is the client: it opens
one stream per action execution, sends WorkerEvent messages, and receives
EngineEvent messages. The full method name is
/saga.v1.WorkerLiveness/ExecuteStep.
Messagesโ
WorkerEvent (worker โ engine)โ
A oneof event carrying exactly one of:
| Variant | Type | Meaning |
|---|---|---|
start | StartJob | Opens the job. Must be the first message. |
heartbeat | Heartbeat | Optional liveness/progress signal while executing. |
complete | Complete | Terminal success. |
error | Error | Terminal failure. |
StartJob
run_id(string) โ saga run UUID.step_id(string) โ the step/action being executed.attempt(int32) โ attempt number (matches the dispatchedActionPayload.attempt).
Heartbeat
progress_pct(int32) โ 0โ100 progress hint.note(string) โ free-text status.
Heartbeats cause no state change today; the engine logs them at debug level. They exist as a hook for long-action timeout extension.
Complete
result_json(bytes) โ JSON object merged into the saga's variables on success.would_change_json(bytes) โ if non-empty, marks this as a dry-run preview (structured "what would change" rather than an applied side effect).
Error
code(string) โ stable error code.message(string) โ human-readable detail.retryable(bool) โ whether the engine should allow a retry.
EngineEvent (engine โ worker)โ
A oneof event carrying exactly one of:
| Variant | Type | Meaning |
|---|---|---|
ack | Acknowledged | Engine accepted the StartJob. |
cancel | CancelRequested | Engine asks the worker to abandon the job. |
Acknowledged โ empty message.
CancelRequested โ reason (string). (Defined in the schema; the current
engine implementation does not yet emit it.)
Lifecycle / handshakeโ
One ExecuteStep stream maps to one action execution. The frame protocol
(enforced server-side in internal/grpc/server.go):
- Worker โ
StartJobwithrun_id,step_id,attempt. A secondStartJobon the same stream is rejected (duplicate StartJob). - Engine โ
Acknowledged. - Worker โ
Heartbeat(zero or more, optional) while the handler runs. - Worker โ
Complete(success) orError(failure). This is terminal. SendingComplete/ErrorbeforeStartJobis rejected (complete/error without start). - Engine processes the terminal message and ends the stream (returns from the RPC). The worker then closes its send side.
On the terminal message the engine:
Completeโ parsesrun_id(UUID), JSON-decodesresult_json(a non-JSON body is preserved under_raw_result), callsstore.CompleteAction(run, attempt, result), then publishessaga.advancevia theAdvancePublisherto wake the paused saga.Errorโ callsstore.FailAction(run, attempt, code, message, retryable), which transitions the run to failed. No advance is published.
Failure / disconnect semanticsโ
Errors at the gRPC layer itself (network drop, decode failures, EOF before a
terminal message) leave the saga in its awaiting-action state. They are not
treated as action failures. Recovery comes from RabbitMQ redelivery of the
dispatch plus the worker's idempotency wrapper โ not from the gRPC stream. A
handler error is reported as an Error message (engine fails the action), which
is distinct from a transport error (stream just breaks and the delivery is
retried).
How the Go worker runtime drives itโ
The reference client lives in clients/go/worker. Workers don't talk to the
proto directly; they register Handlers and the runtime drives the stream.
Flow (runtime.go):
Bootstrapregisters actions over REST, declares a per-service RabbitMQ queue (<service>.actions), and opens one long-lived gRPC client (pb.NewWorkerLivenessClient) toGrpcURL.- For each RabbitMQ delivery,
processDeliverydecodes anActionPayload(run_id,step_id,attempt,action,inputs,dry_run), resolves the handler by the action-name suffix, and callsdriveStream. driveStreamopens anExecuteStepstream and performs the handshake: sendsStartJob, waits for theAcknowledged, runsHandler.Execute, then sendsComplete{result_json}on success orError{code, message, retryable}on failure (codes come from errors implementing thecodedinterface, e.g.worker.Errorf). It alwaysCloseSends the stream.- RabbitMQ ack policy: handler/engine success โ
Ack; a transport-level stream failure โNackwith requeue (retry via redelivery); an undecodable payload or unknown action โNackto the DLQ. A handlerErroris reported on the stream but is not a transport failure, so the delivery is acked.
In v1, the runtime sends no Heartbeats and does not yet act on
CancelRequested; those parts of the schema are forward-looking.