Skip to content

feat(temporal-http): Temporal without ext-grpc, over curl or the JSON gateway - #392

Open
gplanchat wants to merge 24 commits into
mainfrom
feat/temporal-http-bridge
Open

gplanchat wants to merge 24 commits into
mainfrom
feat/temporal-http-bridge

Conversation

@gplanchat

@gplanchat gplanchat commented Sep 11, 2026

Copy link
Copy Markdown
Owner

A new package, gplanchat/durable-bridge-temporal-http (src/Bridge/TemporalHttp), and the seam in the Temporal bridge that lets it in. The Temporal server speaks two transports; the bridge spoke one, through the grpc PHP extension. It now speaks both, and the extension becomes optional.

The seam: WorkflowServiceClientInterface

Every collaborator of the bridge took the generated stub Temporal\Api\Workflowservice\V1\WorkflowServiceClient, whose methods hand back a \Grpc\UnaryCall to wait on. That class extends \Grpc\BaseStub, so nothing in the bridge could even be loaded without the extension.

They now take WorkflowServiceClientInterface: one method per RPC the bridge uses (38), returning the response directly and throwing \RuntimeException with the gRPC status as its code on anything but OK. That contract is what GrpcUnary::wait was already producing, so the call sites lose a wrapper rather than gain one, and the places that read NOT_FOUND or ALREADY_EXISTS off the status pair read it off the exception code instead. The per-RPC methods live once, in two traits behind AbstractWorkflowServiceClient; a transport implements a single call().

Three implementations, chosen by the DSN scheme:

DSN Class Port Covers
temporal://, temporal+tls:// with ext-grpc loaded GrpcWorkflowServiceClient 7233 the generated stub over the extension, as before
temporal://, temporal+tls:// without it (or transport=grpc-curl) CurlGrpcWorkflowServiceClient 7233 the same gRPC protocol through PHP curl over HTTP/2: one POST per unary call, the frame in the body, the status in the trailers. Every RPC, workers included, no extension
temporal+http://, temporal+https:// JsonGatewayWorkflowServiceClient 7243 the server's JSON gateway. The client side only: start, signal, query, update, describe, list, history, cancel, terminate, activity completions. No task queue poll and no workflow or Nexus task response, because the server binds none of them over HTTP; calling one throws UNIMPLEMENTED (12) and names the transport to use

The scheme names the wire and the encryption (+tls, +https); tls=1 stays accepted as the older spelling. transport= (auto by default) overrides what the scheme implies: grpc demands the extension and fails without it, grpc-curl forces curl, http is what temporal+http:// sets.

auto is resolved once, at wiring time, by a pure function (WorkflowServiceClientFactory::resolve): the extension when loaded, curl otherwise, an exception naming both remedies when neither is installed. The fallback to curl is logged once per process at INFO, through the PSR-3 logger the host passes (Symfony's logger service, Laravel's bound logger, an optional Magento constructor argument) or error_log for a bare worker. An explicit transport never falls back. durable:execution:diagnose (Symfony) and the two Laravel worker commands print the effective transport, so the question "which one am I on" is answered where it gets asked. No fallback at call time: both transports talk to the same port, a call that fails on one fails on the other, and switching would double the timeouts while hiding the cause.

What was checked

The route table of the JSON gateway is copied from the google.api.http annotations by hand, so JsonGatewayRoutesTest holds it to the protobuf descriptor checked in with the stubs: every route must appear there, and every interface RPC without a route must have no binding. A stub regeneration that moves a route fails there rather than as a 404 in production.

Against temporal server start-dev --http-port 7243:

  • the whole integration suite runs through DURABLE_TEMPORAL_TRANSPORT=grpc-curl, workers included (TemporalServerTestCase and worker.php read the variable). Same result as through ext-grpc;
  • JsonGatewayRoundTripTest starts, describes and reads an execution over the gateway without any worker.

A new CI job, no-grpc, installs PHP without the extension, runs the unit suite, then the whole integration suite through grpc-curl against the dev server (temporalio/setup-temporal, --http-port 7243). .github/workflows/ci.yml is on the supervised list; the job was asked for explicitly.

Unit suite: same count as before, no test removed or skipped. Six unit test files that mocked the stub now mock the interface and no longer need ext-grpc at all.

Rules touched, on purpose

  • composer.json at three levels (root, Temporal bridge, the new package) and composer.lock: a new package cannot exist without them. The Temporal bridge moves ext-grpc from require to suggest and gains psr/log (already in the lock through Symfony) for the fallback line; the factory asserts the extension at runtime for transport=grpc only. The root lock was refreshed offline for the two path packages only.
  • The two RPC traits are 131 and 111 lines; the choice of two traits over one 207-line class was made to stay under the 200-line commit limit.
  • documentation/adr/ untouched. DUR006 already allows "gRPC/HTTP per implementation choices". DUR019 says "exclusively via gRPC": grpc-curl still is gRPC, http is not. Whether DUR019 wants a superseding ADR is a human call.

Not in this PR

  • bin/splitsh-publish.sh and the satellite repository for the new package: the repository and the PAT scope come first, then the line in SPLITS.
  • The bench applications (symfony, sylius, laravel, magento) keep transport=grpc.
  • OperatorServiceClient (Nexus endpoints in the integration tests) keeps the raw stub and GrpcUnary::wait; nothing in src/ uses it.

gplanchat added a commit that referenced this pull request Sep 11, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gplanchat and others added 24 commits September 16, 2026 17:36
One method per activity RPC the bridge uses, each delegating to a single abstract call(), so a
transport implements one method and inherits the rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The other half of the WorkflowService surface the bridge uses: start, signal, query, update,
history, terminate, and the workflow and Nexus task queues.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mentation

Collaborators stop depending on the generated stub: the interface returns responses and throws
\RuntimeException with the gRPC status as its code, which is what GrpcUnary::wait already did.
GrpcWorkflowServiceClient wraps the stub behind it; the abstract base carries the per-RPC methods.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GrpcUnary::wait disappears from the call sites: the client returns the response, and NOT_FOUND
is caught by exception code where it used to be read off the status pair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…face

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…all the interface

The already-started and stale-task-token cases keep their gRPC codes (6 and 5), now read from the
exception instead of the status pair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…face

The fake client returns responses and throws by gRPC code; no more UnaryCall handles. The tests
no longer touch ext-grpc, so the extension requirement goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…k the interface

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ts workers follow

The whole suite, workers included, then runs through whichever client the variable names; the
ext-grpc requirement becomes a runtime skip that only applies to transport=grpc.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… exception

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Length-prefixed frames in and out, the status from the trailers, and the same exception the
ext-grpc path throws. Kept free of curl so this has a test without a server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gplanchat/durable-bridge-temporal-http: path repository, autoload, PHPStan and Psalm paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CurlGrpcWorkflowServiceClient is one HTTP/2 POST per unary call, no ext-grpc, workers included.
JsonGatewayRoutes lists the HTTP bindings of the RPCs the bridge uses; its test holds the table to
the protobuf descriptor checked in with the stubs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Path placeholders are snake_case proto paths over the camelCase JSON; GET routes carry the rest
as dotted query parameters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…suggestion

JsonGatewayWorkflowServiceClient serves the client RPCs over the frontend HTTP port; a worker RPC
throws UNIMPLEMENTED with the transport to use instead. The Temporal bridge no longer requires
ext-grpc in Composer: the factory asserts it at runtime for transport=grpc only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e docs

Start, describe and read an execution over the gateway without any worker; document transport=
in the configuration reference (EN, FR), the bridge README and the bundle option.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
grpc (the default) builds the ext-grpc client; grpc-curl and http name the classes of the sibling
durable-bridge-temporal-http package and fail with a clear message when it is not installed. The
JSON gateway defaults to its own port, 7243.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… GrpcUnary::wait

The operator service has no interface of its own yet; its stub still hands back a call handle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Which client the factory builds is not what they measure, and the ext-grpc one cannot be
instantiated without the extension; the curl one keeps them runnable in a job without it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Unit suite, then the whole integration suite through transport=grpc-curl against the Temporal
dev server with its HTTP port, JSON gateway test included. The only job that can prove the
bridge installs and runs without the extension.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
temporal:// and temporal+tls:// are gRPC, temporal+http:// and temporal+https:// the server JSON
gateway (port 7243 by default). tls=1 and transport= keep working beside the scheme; the Messenger
factory and the nested-inner guard recognise the four schemes and the legacy ones through one
predicate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ged once

The default: ext-grpc when it is loaded, curl otherwise, decided at wiring time by a pure
resolution every branch of which has a test. The fallback is one INFO line per process (PSR-3
when a logger is given, error_log for a bare worker); an explicit transport never falls back.
effectiveTransport() tells a command what a process ended up with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he transport

Symfony passes its logger service, Laravel its bound PSR logger, Magento an optional constructor
argument. durable:execution:diagnose and the two Laravel worker commands print the effective
transport, where the question gets asked. Documentation rewritten in the scheme vocabulary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Its two test doubles extend AbstractWorkflowServiceClient instead of the stub, the dashboard
provider and the two integration tests take the interface. Caught by the sample-app CI jobs,
which the root suites never reach.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gplanchat
gplanchat force-pushed the feat/temporal-http-bridge branch from 6323153 to fdccc49 Compare September 16, 2026 16:23

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant