Skip to content

Added the stream service and its frontend wiring. - #5

Open
moedash wants to merge 8 commits into
moe/AI-198-srv-2-stream-componentfrom
moe/AI-198-srv-3-stream-service
Open

moedash wants to merge 8 commits into
moe/AI-198-srv-2-stream-componentfrom
moe/AI-198-srv-3-stream-service

Conversation

@moedash

@moedash moedash commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

This PR puts the stream behind a gRPC service on the frontend.

StreamService is served next to WorkflowService, so an outside producer or reader reaches a stream the same way it reaches a workflow. The history side hosts the component and the two side-effect tasks; the frontend forwards.

What changed?

  • chasm/lib/stream/proto/v1/{service,request_response}.proto and their generated Go: fifteen RPCs covering standalone streams, streams attached to a workflow, and the two calls history makes on itself while resolving a subscription.
  • chasm/lib/stream/service/: the history-side handler, the frontend forwarder, the CHASM library registration, and the retention and notify-consumers task handlers.
  • chasm/lib/stream/gen/streampb/v1/namespace.go exposes each routed request's namespace to the interceptors. A test drives that off the descriptor, so a new RPC fails there instead of silently opting out of rate limits, validation, authorization and redirection.
  • common/api/metadata.go: every method declares namespace scope with read, write or admin access. Without an entry the default authorizer denies every stream RPC and the no-op one admits any namespace caller. The two internal calls are admin so no namespace-level role reaches them through a frontend.
  • service/frontend/configs/quotas.go: a rate-limit priority per method, the two polls counted against concurrent long polls, ListStreams on the visibility limiter.
  • service/frontend/fx.go registers the stream methods as redirectable, so a call for a namespace active elsewhere is forwarded like a workflow RPC.
  • Request validation bounds stream ids and names like workflow ids, along with offsets and page sizes, before a request is routed. A stream id becomes an execution's business id and a stream name a key in mutable state.
  • chasm/lib/workflow/workflow.go gains the Streams and StreamCursors maps and the accessors the service needs to reach a stream attached to a workflow. The commands that use them come in the next layer.
  • cmd/tools/protogen/main.go deletes only .pb.go files from a gen directory instead of wiping it. namespace.go has to sit in the generated package, because a method must be declared in the package of the type it is on.
  • tests/stream_test.go: the RPC surface end to end against a real cluster.

Why?

The service is the whole external surface. Splitting it from the component keeps the review of "what can a caller do, and who is allowed to" separate from "what does the component guarantee".

How did you test it?

go build ./...
make proto            # tree stays clean, generated files match the protos
make fmt              # tree stays clean
go test ./chasm/lib/stream/... ./chasm/lib/workflow/... ./common/api/... \
  ./common/rpc/interceptor/... ./service/frontend/configs/... -count=1
go test -tags test_dep ./tests/ -run 'TestStream...' -count=1   # the 18 tests in tests/stream_test.go

All green.

Series

Server 3 of 6. Replaces #2.

Previous: #4 moe/AI-198-srv-2-stream-component. Next: #6 moe/AI-198-srv-4-workflow-commands.

Review round

The service is behind stream.enabled, a namespace setting that defaults to off. Every RPC resolves its namespace through one place, so the gate covers the whole surface, and the functional suites turn it on. TruncateStream and DeleteStream are AccessAdmin now: both destroy records another workflow's History depends on, which is why DeleteWorkflowExecution needs an operator.

Admission got the rest of what it was missing. Streams one execution owns share a byte budget, stream.ownedStreamsMaxBytesPerWorkflow, because the per-stream budget multiplied by the stream count comes to far more than limit.mutableStateSize.error, and exceeding that terminates the workflow rather than refusing an append. producer_id and workflow_id are length-checked like the stream id and name. CreateStream settles the lifecycle: retention left unset takes the namespace's, a non-positive one is refused, and one over the namespace's is refused, so a closed stream can no longer sit in the database for good. SubscribeWorkflow refuses a request that sets both a stream id and a stream name.

RegisterStreamConsumer answers stream_absent instead of a bare NotFound when no execution holds the stream, so the subscribe fallback fires on that one condition and not on a registry miss or a shard that has moved. A workflow that owns a stream named x can no longer also consume a standalone stream with id x: they share one cursor keyspace, and the collision is refused rather than silently handed the wrong cursor. AdvanceConsumerHead only matches an external cursor for the same reason.

The notify task no longer strands anything. Validate accepts unconditionally, since turning the task down left the coalescing flag up with nothing to lower it and no later append would schedule another, so a consumer that subscribed afterwards was never woken. Discard lowers the flag too. The fan-out runs through a bounded worker pool with a per-call deadline instead of one serial loop, the routed request carries its namespace, and a transport NotFound no longer releases a consumer's replay floor. A truncate refused by a pin probes the runs holding it and tries again, which is the only way a stream nobody appends to finds out that a pin belongs to a finished run.

ListStreams refuses a query naming TemporalNamespaceDivision. The archetype is a predicate the caller's query replaces rather than one it is anded with, so such a query would list any archetype in the namespace through a stream-scoped read-only RPC.

Known limits, argued rather than fixed:

  • A partial failure in the notify fan-out retries the whole set rather than the consumers that failed. The pushes are idempotent, so this costs work and not correctness.
  • stream.maxConsumeItemsPerTask is clamped to 1000, the largest page a single stream read returns. The setting's description says so.

Review round: api surface

The api branch renamed AppendStreamRecordsCommandAttributes.stream_id to stream_name and SubscribeStreamCommandAttributes.stream_id to stream_name_or_id, and gave WorkflowStreamRecordsAppendedEventAttributes a from_offset and a to_offset in place of a first offset and a count. Field numbers are unchanged, so the wire does not move. The service speaks its own protos and names streams through StreamName and StreamId fields of its own, so this branch carries the merge and needs no adaptation.

Brought onto current main (d8f9c6d86b2c) by merge; the biggest adaptation was taking the stream handler and its tasks to errors.AsType, plus resolving the stream service wiring in service/frontend/service.go.

The stream is reachable over gRPC next to the workflow service: ids, names, offsets and page sizes are bounded on the frontend, every method declares its authorization scope and rate-limit priority, and a call for a namespace active elsewhere is forwarded like a workflow RPC.
Registering the service turned the whole surface on everywhere, so it is off by
default now and the calls that destroy other workflows' data need an operator.
A workflow's owned streams share one byte budget, because their per-stream
budgets multiplied out well past the mutable state limit. The notify task no
longer strands its coalescing flag or drops a consumer's pin on a transport
error.
…3-stream-service

# Conflicts:
#	service/frontend/service.go
Main now builds under Go 1.27, where go fix rewrites this shape, so make fmt
was left dirty on these two files.
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