Skip to content

Added the notification channel linked to a workflow. - #10

Closed
moetemp wants to merge 6 commits into
moe/AI-198-srv-7-notification-channelfrom
moe/AI-198-srv-8-linked-channel
Closed

moetemp wants to merge 6 commits into
moe/AI-198-srv-7-notification-channelfrom
moe/AI-198-srv-8-linked-channel

Conversation

@moetemp

@moetemp moetemp commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

This PR adds the second kind of notification channel, one linked to a workflow and held in that workflow's own mutable state.

What changed?

  • A linked channel is the channel.Channel component as a child of the Workflow component, keyed by channel name in Workflow.LinkedChannels. The owning run is its listener by construction: no subscribe command, no event and no registration. ChannelState gains linked, the owner's pending notification and the counter its scheduled task carries. The fan-out and idle tasks are guarded off for this kind, since the owner is reached in the write that accepts the notification and the channel dies with the run. The transaction close reads the linked channels' state to find a pending notification, which costs only runs that hold a linked channel; every other run answers from the map's length.
  • NotifyChannel with workflow_execution set routes by workflow id to the owner's shard, an empty run id meaning the chain's current run as for a Signal. It reads the owner first: a counter above the latest joins the ring and the owner takes it as pending, while a repeat the owner holds, pending or on a task that has not started, and that every callback listener holds too, writes nothing. The transaction close schedules the Workflow Task through the hook the subscribed kind uses, and TakeChannelNotifications puts the linked pendings on the scheduled event beside the subscribed ones, each with linked_to naming the owner and the receiving run. listener_count answers the owner plus the callback listeners.
  • Callback listeners on a linked channel are handed each notification inside the notify, with the fold-while-in-flight rule and the retained-latest handoff of the independent kind. The callback and backoff task handlers run unchanged on the child, and the JSON body carries linked_to. PollChannel with workflow_execution long-polls the owner's execution over the linked ring with after_counter. DescribeChannel answers kind, linked_to, the listeners and the retained count; a name nobody has notified yet on a running workflow answers CHANNEL_KIND_LINKED with nothing in it rather than NotFound, which is the probe a client uses for the linked kind, and a closed or absent workflow answers NotFound. RegisterChannelListener and UnregisterChannelListener with workflow_execution act on the owner's state. The independent kind answers CHANNEL_KIND_INDEPENDENT and is otherwise unchanged.
  • The routed ChannelService gets NotifyLinkedChannel, RegisterLinkedChannelListener, UnregisterLinkedChannelListener, PollLinkedChannel and DescribeLinkedChannel, taking the same request messages with workflow_execution on the input and routed by its workflow id. The frontend branches on workflow_execution and validates it the way a Signal target is validated. The internal Notification carries linked_to, so the ring, the scheduled event and the callback post agree.
  • Limits: channel.maxLinkedChannelsPerWorkflow bounds how many linked channels one run holds and refuses a notify or registration that would create one past it with ResourceExhausted; channel.linkedRetainedNotifications bounds the linked ring, smaller than an independent channel's since it lives in the run's state; channel.maxListeners, the metadata cap and the notify rate apply as before. The channel counters gain a kind label, independent or linked.
  • Lifecycle: the linked state dies with the run. A continue-as-new successor starts with no linked channels, so callbacks register again and pollers start over on an empty ring, and a reset run keeps only the carried events. A linked notify does not record its request id on the workflow, since the run's request-id table is bounded and a replayed notify folds into what the owner holds.
  • tests/linked_channel_test.go drives the linked kind with the raw client, and unit tests cover the owner-side state in chasm/lib/channel and chasm/lib/workflow.
  • Namespace dynamic config channel.linkedKindEnabled (default on). Off, the five channel calls ignore workflow_execution and reach the independent channel of that name, as before this kind existed, so DescribeChannel on an untouched name with an owner answers NotFound and a client's probe lands on the independent kind. The channels a native stream drives in its owner's state are not affected. It exists so the independent subscribe path can be exercised live on a server that has the linked kind.

Part of AI-198 (epic AI-37).

Why?

Max's review of the channel proposal asked for a channel linked to the listener's own mutable state, and the measurement backed it: with one listener the independent channel costs more writes than a Signal, all of them the channel execution's own. A linked channel makes a notification one write on the owner, the same write that schedules its task, with no subscription event and no registration race, so the task that opens a workflow's first reader can stay retained. The independent kind stays for fan-out to many listeners.

How did you test it?

go build ./... is clean, and golangci-lint and the errortype vet are clean on the touched packages. Unit tests ran for ./chasm/lib/channel/..., ./chasm/lib/workflow/..., ./service/frontend/... and ./service/history/workflow/..., covering the owner's fold rules, the take onto the scheduled event with linked_to, the linked ring bound, the callback handoff and the per-run limit. The functional tests ran with -tags test_dep: a notify carries on the owner's next scheduled event with linked_to and the owner as the listener, an untouched name describes as linked with nothing in it while the independent namesake stays NotFound, the fold while pending and into an unstarted task leaves the owner's state-transition count unchanged, a repeat after the task ran schedules again, continue-as-new is followed by workflow id with an empty successor and NotFound on the closed run, poll with a filter, a page and a long poll, a callback listener posted with linked_to and a late one handed the latest, describe, notify and poll on a closed chain and on an absent workflow answer NotFound, and the limits hold. The independent channel suite and the stream suite pass.

  • Unit Tests
  • Staging
  • End to End Tests

A linked channel lives in the owner's mutable state and the owner is its listener by construction, so a notification is one write on the owner, the one that schedules its task, with no subscribe event and no registration race. The independent kind stays for fan-out to many listeners.
On a server with the linked kind every workflow-owned external stream uses it, so the independent subscribe path could not be exercised live. With channel.linkedKindEnabled off the public channel calls ignore workflow_execution and reach the independent channel of the name, as before the linked kind existed. The channels a native stream drives in its owner's state are not affected.
The public requests address a linked channel's owner as an Execution. The
frontend and the notification conversion map it to the workflow run, and the
tests name the owner that way.
@moetemp

moetemp commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Replaced by #18, #19, #20, #21, #22, #23, #24, #25, #26, #27, #28, #29, #30, #31, #32, #33, #34, #35, #36 and #37.

Same content, split into 20 PRs in the v3 series: the notification channel first, then the streaming interface, then native streams and the rest. The branch stays as a pin.

@moetemp moetemp closed this Oct 3, 2026
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