Skip to content

gRPC service path doesn't propagate saga-id — header-identified sagas can't be started/continued over gRPC #3385

Description

@erikshafer

Context

Following #3369 / #3382 (server-side envelope propagation + tenant detection for Wolverine.Grpc, GH-3368), a question came up about whether starting a saga is similarly unsupported over the gRPC package. Tracing it, there's a real gap, but it's narrower than it first looks.

What already works (no change needed)

A saga is just a HandlerChain (SagaChain), and the gRPC shim forwards each RPC to IMessageBus.InvokeAsync<TResponse> / StreamAsync<T>. So sagas whose identity comes from the message body — [SagaIdentity], OrderId, or a conventional Id on the request DTO — start and continue over gRPC today with zero gRPC-specific code. PullSagaIdFromMessageFrame reads the id straight out of the deserialized request.

The gap

Sagas with no id member on the message resolve identity from the envelope saga-id header via PullSagaIdFromEnvelopeFrame (src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs:25,34), which throws IndeterminateSagaStateIdException when envelope.SagaId is empty. Across a gRPC service hop that value is never populated, for two independent reasons:

  1. Interceptors don't carry saga-id. Client stamps correlation-id/tenant-id/id/parent-id/conversation-id (WolverineGrpcClientPropagationInterceptor.cs:104-113); server reads back only correlation-id/tenant-id (WolverineGrpcServicePropagationInterceptor.cs:109-119).
  2. The invoke path drops it. Executor.InvokeAsync<T> (src/Wolverine/Runtime/Handlers/Executor.cs:166-181) copies bus.TenantId onto the invoked envelope but has no equivalent for saga id. So even a context-level saga id wouldn't reach PullSagaIdFromEnvelopeFrame. (This is why the tenant fix was cheap and this one isn't — the tenant copy already existed.)

Fix surface

# Location Change
1 Client interceptor Stamp EnvelopeConstants.SagaIdKey from ctx.Envelope?.SagaId.
2 Server interceptor Read saga-id; stash on scoped context (needs a settable hook — MessageContext.SetSagaId(object) exists but isn't on IMessageContext).
3 Executor.InvokeAsync<T> / StreamAsync<T> Seed envelope.SagaId from context, mirroring the existing TenantId line.

Proposed direction

Ship a scoped first cut: leave core Executor/IMessageContext untouched, keep documenting message-body identity as the supported path for gRPC-driven sagas, and replace the opaque IndeterminateSagaStateIdException with a clear diagnostic when a header-id saga is invoked over gRPC. Full transparent propagation (all three points above) can follow if there's demand.

Open question

Is the real-world need message-body identity (already works) or header identity? And is the scenario starting a saga from a gRPC call, or continuing one? That decides whether we do the scoped diagnostic or the full core change.

Reproduction

A characterization test that stands up a header-identified saga (no id member on the message) behind a code-first gRPC service on a plain in-memory host reproduces the failure in a single call — the RPC comes back StatusCode.Internal with detail "Could not determine a valid saga state id…". It slots alongside src/Wolverine.Grpc.Tests/ServerPropagation/ (the PropagationEchoGrpcService fixture is the template). Current version: 6.17.2.

Activity

  1. erikshafer commented on Jul 12, 2026

    @erikshafer
    SponsorContributorAuthor

    Will dig into this further soon. Began scaffolding some ideas locally.

  2. erikshafer commented on Jul 12, 2026

    @erikshafer
    SponsorContributorAuthor

    Findings after cloning the HTTP saga tests

    Following the suggestion to mirror the existing HTTP endpoint + saga tests over gRPC, here's what shook out. The short version: sagas work over gRPC the same way they work over HTTP, and the "gap" in the title is real but narrower than it first read — it's the same limitation HTTP endpoints have.

    Message-identified sagas already work over gRPC ✅

    A saga is just a HandlerChain, and the gRPC shim forwards to the same IMessageBus.InvokeAsync pipeline a [WolverinePost] endpoint uses. So a saga whose id rides on the request DTO ([SagaIdentity], OrderId, Id, or a conventional {Saga}Id member) starts and continues over gRPC with zero gRPC-specific code.

    PR #3386 adds a test that clones building_a_saga_and_publishing_other_messages_from_http_endpoint over a gRPC hop — start → persist → continue → complete → state deleted — and it's green. Persistence is asserted via the in-memory saga persistor, so no database is needed.

    Header-identified sagas are the same limitation HTTP has ⚠️

    The only case that breaks is a saga whose id is resolved from the envelope saga-id header (no id member on the message → PullSagaIdFromEnvelopeFrame). Nothing carries saga-id across a gRPC service hop:

    • The propagation interceptors carry correlation-id/tenant-id but not saga-id.
    • Executor.InvokeAsync<T> seeds TenantId onto the invoked envelope but not a saga id.

    …so envelope.SagaId is empty and you get IndeterminateSagaStateIdException (currently surfaced to the client as an opaque StatusCode.Internal). PR #3386 pins this with a characterization test.

    Worth calling out: HTTP endpoints don't exercise the saga-id header path either. That header path is a node-to-node messaging/transport concern (a saga cascades a message, the broker carries saga-id on the envelope) — not an endpoint concern. So "works like HTTP" is fully satisfied without addressing it.

    Proposed resolution

    1. Ship the parity tests (PR gRPC saga coverage: message-identified sagas work like HTTP; header-identified gap pinned (refs #3385) #3386) — proves the common case and gives regression coverage.
    2. Document that gRPC service calls, like HTTP endpoints, use message-body saga identity; the envelope saga-id header isn't propagated across a gRPC service hop.
    3. Downscope this issue to either (a) a clearer diagnostic replacing the opaque Internal when a header-id saga is invoked over gRPC, and/or (b) an optional future enhancement that propagates saga-id symmetrically (interceptors + a context→envelope seed in Executor) if there's real demand.

    Happy to go whichever direction on (3).

    THoughts?

  3. jeremydmiller commented on Jul 13, 2026

    @jeremydmiller
    Member

    @erikshafer — thanks for the thorough write-up, and your read is right. Go with (a): the scoped diagnostic.

    Your argument is the deciding one: message-identified sagas already work over gRPC (your #3386 proves it, and the docs page from #3389 now documents it as the supported path), and header identity is a transport concern that HTTP does not exercise either — so "works like HTTP" is already satisfied. Full three-point saga-id propagation is not worth the complexity until someone turns up with a concrete need for it.

    So the scope is:

    • replace the opaque IndeterminateSagaStateIdException / StatusCode.Internal over a gRPC hop with an actionable message — "a header-identified saga is not supported over a gRPC hop; put the saga identity on the message body", linking the gRPC + Sagas docs page;
    • flip the assertions in starting_a_header_identified_saga_over_grpc_fails_with_opaque_status_today so the characterization test pins the new diagnostic instead of the old opaque failure.

    Targeting 6.18.0. If you would like to take it, it is yours — otherwise say so and we will pick it up. Leave (b) as a future issue if a real use case shows up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions