From 6f6fdf3a20423a4eb9e66f5efd0b50be0feb9a17 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 17:43:28 -0700 Subject: [PATCH 01/12] Added the notification channel library and its history service. A channel is an execution keyed by its name. It keeps the listener table and a ring of recent notifications, and one coalesced fan-out task hands the latest to each workflow listener's shard and to each callback listener. --- chasm/lib/channel/channel.go | 711 +++++++ chasm/lib/channel/channel_test.go | 351 +++ chasm/lib/channel/config.go | 138 ++ .../v1/channel_state.go-helpers.pb.go | 43 + .../gen/channelpb/v1/channel_state.pb.go | 206 ++ .../gen/channelpb/v1/message.go-helpers.pb.go | 117 + .../channel/gen/channelpb/v1/message.pb.go | 404 ++++ .../lib/channel/gen/channelpb/v1/namespace.go | 35 + .../gen/channelpb/v1/namespace_test.go | 38 + .../v1/request_response.go-helpers.pb.go | 1079 ++++++++++ .../gen/channelpb/v1/request_response.pb.go | 1895 +++++++++++++++++ .../channel/gen/channelpb/v1/service.pb.go | 100 + .../gen/channelpb/v1/service_client.pb.go | 368 ++++ .../gen/channelpb/v1/service_grpc.pb.go | 338 +++ .../gen/channelpb/v1/tasks.go-helpers.pb.go | 154 ++ .../lib/channel/gen/channelpb/v1/tasks.pb.go | 277 +++ .../lib/channel/proto/v1/channel_state.proto | 37 + chasm/lib/channel/proto/v1/message.proto | 57 + .../channel/proto/v1/request_response.proto | 182 ++ chasm/lib/channel/proto/v1/service.proto | 52 + chasm/lib/channel/proto/v1/tasks.proto | 30 + chasm/lib/channel/routing.go | 24 + chasm/lib/channel/service/fx.go | 38 + chasm/lib/channel/service/handler.go | 496 +++++ chasm/lib/channel/service/library.go | 86 + chasm/lib/channel/service/ratelimit.go | 58 + chasm/lib/channel/service/tasks.go | 451 ++++ common/metrics/metric_defs.go | 19 + service/history/fx.go | 2 + service/history/history_engine.go | 14 + service/history/history_engine_factory.go | 3 + 31 files changed, 7803 insertions(+) create mode 100644 chasm/lib/channel/channel.go create mode 100644 chasm/lib/channel/channel_test.go create mode 100644 chasm/lib/channel/config.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/channel_state.go-helpers.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/channel_state.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/message.go-helpers.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/message.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/namespace.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/namespace_test.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/request_response.go-helpers.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/request_response.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/service.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/service_client.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/service_grpc.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/tasks.go-helpers.pb.go create mode 100644 chasm/lib/channel/gen/channelpb/v1/tasks.pb.go create mode 100644 chasm/lib/channel/proto/v1/channel_state.proto create mode 100644 chasm/lib/channel/proto/v1/message.proto create mode 100644 chasm/lib/channel/proto/v1/request_response.proto create mode 100644 chasm/lib/channel/proto/v1/service.proto create mode 100644 chasm/lib/channel/proto/v1/tasks.proto create mode 100644 chasm/lib/channel/routing.go create mode 100644 chasm/lib/channel/service/fx.go create mode 100644 chasm/lib/channel/service/handler.go create mode 100644 chasm/lib/channel/service/library.go create mode 100644 chasm/lib/channel/service/ratelimit.go create mode 100644 chasm/lib/channel/service/tasks.go diff --git a/chasm/lib/channel/channel.go b/chasm/lib/channel/channel.go new file mode 100644 index 00000000000..47e8acf8ea4 --- /dev/null +++ b/chasm/lib/channel/channel.go @@ -0,0 +1,711 @@ +package channel + +import ( + "maps" + "net/url" + "slices" + "time" + + commonpb "go.temporal.io/api/common/v1" + enumspb "go.temporal.io/api/enums/v1" + failurepb "go.temporal.io/api/failure/v1" + "go.temporal.io/api/serviceerror" + "go.temporal.io/server/chasm" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + "go.temporal.io/server/common" + "go.temporal.io/server/common/backoff" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Channel is a named pub/sub point in a namespace, held as an execution of its +// own keyed by the channel name. A writer notifies the channel and never +// learns who listens. The channel keeps the listener table and wakes every +// listener: a workflow run through its next scheduled Workflow Task, a +// callback through an HTTP post. A bounded ring of recent notifications serves +// clients that poll. +type Channel struct { + chasm.UnimplementedComponent + + State *channelpb.ChannelState + + // Keyed by sequence, oldest first. Nodes of their own, so a notify writes + // one of them rather than rewriting the whole ring. + Retained chasm.Map[int64, *channelpb.Notification] + + // Keyed by workflow id. One workflow id has one open run, so a later run + // that subscribes replaces the entry of the run before it. + WorkflowListeners chasm.Map[string, *channelpb.WorkflowListener] + + // Keyed by listener id. + CallbackListeners chasm.Map[string, *channelpb.CallbackListener] +} + +// readOnly hides the mutable half of a context. Reaching a data node through a +// mutable context marks it for persistence, and a notify must not rewrite +// every listener it only looked at. +func readOnly(ctx chasm.Context) chasm.Context { + return struct{ chasm.Context }{ctx} +} + +// NewChannel makes an empty channel. The transition that creates it is always +// a notify or a registration, which stamps the first activity. +func NewChannel(_ chasm.MutableContext) *Channel { + return &Channel{ + State: &channelpb.ChannelState{}, + Retained: make(chasm.Map[int64, *channelpb.Notification]), + WorkflowListeners: make(chasm.Map[string, *channelpb.WorkflowListener]), + CallbackListeners: make(chasm.Map[string, *channelpb.CallbackListener]), + } +} + +// ContextMetadata satisfies chasm.RootComponent. A channel has nothing to +// propagate to the request context. +func (c *Channel) ContextMetadata(_ chasm.Context) map[string]string { + return nil +} + +// Terminate drops the listeners, so nothing is woken by a channel an operator +// shut down. What it retained stays readable until it is deleted. +func (c *Channel) Terminate( + mctx chasm.MutableContext, + _ chasm.TerminateComponentRequest, +) (chasm.TerminateComponentResponse, error) { + for id := range c.WorkflowListeners { + delete(c.WorkflowListeners, id) + } + for id := range c.CallbackListeners { + delete(c.CallbackListeners, id) + } + c.scheduleIdleCheck(mctx, DefaultLimits()) + return chasm.TerminateComponentResponse{}, nil +} + +// LifecycleState reports a channel as running until the idle task closes it +// on the way to deleting it. +func (c *Channel) LifecycleState(_ chasm.Context) chasm.LifecycleState { + if c.State.GetClosed() { + return chasm.LifecycleStateCompleted + } + return chasm.LifecycleStateRunning +} + +// ListenerCount is how many listeners the channel holds, of both kinds. +func (c *Channel) ListenerCount() int { + return len(c.WorkflowListeners) + len(c.CallbackListeners) +} + +// CheckNotification refuses a notification the channel cannot accept. The +// frontend checks the same things first; this is what the component itself +// guarantees. +func CheckNotification(n *channelpb.Notification, maxMetadataBytes int) error { + if n == nil { + return serviceerror.NewInvalidArgument("notification is required") + } + if n.GetChannel() == "" { + return serviceerror.NewInvalidArgument("channel is required") + } + if n.GetCounter() <= 0 { + return serviceerror.NewInvalidArgument("notification counter must be greater than zero") + } + if len(n.GetPosition()) > MaxPositionBytes { + return serviceerror.NewInvalidArgumentf( + "notification position exceeds %d bytes", MaxPositionBytes) + } + if size := MetadataSize(n.GetMetadata()); maxMetadataBytes > 0 && size > maxMetadataBytes { + return serviceerror.NewInvalidArgumentf( + "notification metadata is %d bytes, more than the limit of %d", size, maxMetadataBytes) + } + return nil +} + +// MetadataSize is the metered size of a notification's metadata: the keys and +// the payloads as the writer sent them. +func MetadataSize(metadata map[string]*commonpb.Payload) int { + size := 0 + for key, p := range metadata { + size += len(key) + p.Size() + } + return size +} + +// NotifyResult is what a notify reports back to the writer. +type NotifyResult struct { + ListenerCount int + // The notification raised the channel's latest counter, so listeners are + // being told about it. + Advanced bool +} + +// Notify accepts a notification: it joins the retained ring for pollers and, +// when it is the newest the channel has seen, is fanned out to the listeners. +// +// The fan-out is one task at a time. A notify that lands while one is +// outstanding schedules nothing: the task reads the latest when it runs, so a +// burst reaches each listener as its newest notification rather than as one +// delivery per notify. +func (c *Channel) Notify( + mctx chasm.MutableContext, + n *channelpb.Notification, + limits Limits, +) (NotifyResult, error) { + limits = limits.withDefaults() + if err := CheckNotification(n, limits.MaxMetadataBytes); err != nil { + return NotifyResult{}, err + } + n = common.CloneProto(n) + + c.retain(mctx, n, limits.RetainedNotifications) + c.State.AcceptedCount++ + c.touch(mctx) + + advanced := n.GetCounter() > c.State.GetLatest().GetCounter() + if advanced { + c.State.Latest = n + c.scheduleFanOut(mctx) + } + c.scheduleIdleCheck(mctx, limits) + return NotifyResult{ListenerCount: c.ListenerCount(), Advanced: advanced}, nil +} + +// retain appends to the ring and drops the oldest past the bound. +func (c *Channel) retain(mctx chasm.MutableContext, n *channelpb.Notification, bound int) { + if c.Retained == nil { + c.Retained = make(chasm.Map[int64, *channelpb.Notification]) + } + seq := c.State.RetainedNext + c.Retained[seq] = chasm.NewDataField(mctx, n) + c.State.RetainedNext = seq + 1 + for c.State.RetainedNext-c.State.RetainedFirst > int64(bound) { + delete(c.Retained, c.State.RetainedFirst) + c.State.RetainedFirst++ + } +} + +// RetainedCount is how many notifications the ring holds. +func (c *Channel) RetainedCount() int64 { + return c.State.GetRetainedNext() - c.State.GetRetainedFirst() +} + +func (c *Channel) scheduleFanOut(mctx chasm.MutableContext) { + if c.State.FanOutPending || c.ListenerCount() == 0 { + return + } + c.State.FanOutPending = true + mctx.AddTask(c, chasm.TaskAttributes{ScheduledTime: mctx.Now(c)}, &channelpb.ChannelFanOutTask{}) +} + +func (c *Channel) touch(mctx chasm.MutableContext) { + c.State.LastActivityTime = timestamppb.New(mctx.Now(c)) +} + +// FanOut is what the fan-out task delivers and to whom. +type FanOut struct { + Latest *channelpb.Notification + WorkflowListeners []*channelpb.WorkflowListener + // Callback listeners whose newest notification replaced one still waiting + // behind an in-flight delivery. + CallbackFolded int + // Callback listeners a delivery was started for. + CallbackStarted int +} + +// TakeFanOut is the fan-out task's transition. It lowers the coalescing flag in +// the same transition that reads the latest, so a notify that commits after it +// schedules the next task and nothing is missed. +// +// Callback listeners are handed the latest here, since handing it over is a +// write on this execution: a listener with nothing in flight starts a delivery, +// and one that is busy keeps the latest as its pending notification, replacing +// any older one. The workflow listeners come back for the task to reach on +// their own shards. +func (c *Channel) TakeFanOut(mctx chasm.MutableContext, _ struct{}) (FanOut, error) { + c.State.FanOutPending = false + latest := c.State.GetLatest() + out := FanOut{Latest: common.CloneProto(latest)} + if latest == nil { + return out, nil + } + + view := readOnly(mctx) + for _, id := range slices.Sorted(maps.Keys(c.CallbackListeners)) { + if c.CallbackListeners[id].Get(view).GetHandedCounter() >= latest.GetCounter() { + continue + } + listener := c.CallbackListeners[id].Get(mctx) + listener.HandedCounter = latest.GetCounter() + if listener.GetInFlight() != nil { + if listener.GetPending() != nil { + out.CallbackFolded++ + } + listener.Pending = common.CloneProto(latest) + continue + } + listener.InFlight = common.CloneProto(latest) + listener.Attempt = 0 + if err := c.scheduleCallback(mctx, listener); err != nil { + return FanOut{}, err + } + out.CallbackStarted++ + } + + for _, id := range slices.Sorted(maps.Keys(c.WorkflowListeners)) { + out.WorkflowListeners = append(out.WorkflowListeners, + common.CloneProto(c.WorkflowListeners[id].Get(view))) + } + return out, nil +} + +// CallbackDestination keys a callback's deliveries on the outbound queue, +// where rate limits and the circuit breaker are per destination. +func CallbackDestination(cb *commonpb.Callback) (string, error) { + nexus := cb.GetNexus() + if nexus == nil { + return "", serviceerror.NewInvalidArgument("a channel listener callback needs a URL") + } + u, err := url.Parse(nexus.GetUrl()) + if err != nil { + return "", serviceerror.NewInvalidArgumentf("callback URL does not parse: %v", err) + } + return u.Scheme + "://" + u.Host, nil +} + +func (c *Channel) scheduleCallback( + mctx chasm.MutableContext, + listener *channelpb.CallbackListener, +) error { + destination, err := CallbackDestination(listener.GetCallback()) + if err != nil { + return err + } + listener.TaskSequence++ + mctx.AddTask(c, chasm.TaskAttributes{Destination: destination}, &channelpb.ChannelCallbackTask{ + ListenerId: listener.GetListenerId(), + Sequence: listener.GetTaskSequence(), + }) + return nil +} + +// WorkflowRegistration describes a workflow run that subscribed. +type WorkflowRegistration struct { + WorkflowID string + RunID string + FirstExecutionRunID string + Limits Limits +} + +// RegisterWorkflowListener records a run as a listener. A run of the same +// workflow already on record is replaced: one workflow id has one open run, so +// the entry belongs to a run that has closed. +func (c *Channel) RegisterWorkflowListener( + mctx chasm.MutableContext, + reg WorkflowRegistration, +) error { + if reg.WorkflowID == "" || reg.RunID == "" { + return serviceerror.NewInvalidArgument("workflow id and run id are required") + } + limits := reg.Limits.withDefaults() + c.touch(mctx) + if field, ok := c.WorkflowListeners[reg.WorkflowID]; ok { + if field.Get(readOnly(mctx)).GetRunId() == reg.RunID { + return nil + } + listener := field.Get(mctx) + listener.RunId = reg.RunID + listener.FirstExecutionRunId = reg.FirstExecutionRunID + listener.RegisteredTime = timestamppb.New(mctx.Now(c)) + return nil + } + if err := c.checkListenerRoom(limits.MaxListeners); err != nil { + return err + } + if c.WorkflowListeners == nil { + c.WorkflowListeners = make(chasm.Map[string, *channelpb.WorkflowListener]) + } + c.WorkflowListeners[reg.WorkflowID] = chasm.NewDataField(mctx, &channelpb.WorkflowListener{ + WorkflowId: reg.WorkflowID, + RunId: reg.RunID, + FirstExecutionRunId: reg.FirstExecutionRunID, + RegisteredTime: timestamppb.New(mctx.Now(c)), + }) + return nil +} + +func (c *Channel) checkListenerRoom(maxListeners int) error { + if c.ListenerCount() < maxListeners { + return nil + } + return serviceerror.NewResourceExhaustedf( + enumspb.RESOURCE_EXHAUSTED_CAUSE_CONCURRENT_LIMIT, + "channel already has %d listeners, which is the limit", maxListeners) +} + +// RekeyWorkflowListener moves a listener to the run that continued the one on +// record. Left alone when the entry no longer names that run, because the +// successor may have registered itself in the meantime. +func (c *Channel) RekeyWorkflowListener( + mctx chasm.MutableContext, + workflowID, fromRunID, toRunID string, +) { + field, ok := c.WorkflowListeners[workflowID] + if !ok || field.Get(readOnly(mctx)).GetRunId() != fromRunID { + return + } + field.Get(mctx).RunId = toRunID +} + +// ForgetWorkflowListener drops a listener no run of its workflow answers for +// any more, unless a newer run has registered under the same workflow id. +func (c *Channel) ForgetWorkflowListener( + mctx chasm.MutableContext, + workflowID, runID string, + limits Limits, +) { + field, ok := c.WorkflowListeners[workflowID] + if !ok || field.Get(readOnly(mctx)).GetRunId() != runID { + return + } + delete(c.WorkflowListeners, workflowID) + c.scheduleIdleCheck(mctx, limits.withDefaults()) +} + +// CallbackRegistration describes a callback being registered. +type CallbackRegistration struct { + RequestID string + Callback *commonpb.Callback + Limits Limits +} + +// RegisterCallbackListener adds a callback listener and returns its id. A +// registration whose request id the channel has seen returns the listener +// that request made, so a retry adds nothing. +// +// A new listener is told about notifications that arrive after it, not about +// the latest one already accepted. +func (c *Channel) RegisterCallbackListener( + mctx chasm.MutableContext, + reg CallbackRegistration, +) (string, error) { + if reg.RequestID == "" { + return "", serviceerror.NewInvalidArgument("request id is required") + } + if _, err := CallbackDestination(reg.Callback); err != nil { + return "", err + } + limits := reg.Limits.withDefaults() + c.touch(mctx) + + view := readOnly(mctx) + for id, field := range c.CallbackListeners { + if field.Get(view).GetRequestId() == reg.RequestID { + return id, nil + } + } + if err := c.checkListenerRoom(limits.MaxListeners); err != nil { + return "", err + } + if c.CallbackListeners == nil { + c.CallbackListeners = make(chasm.Map[string, *channelpb.CallbackListener]) + } + id := reg.RequestID + c.CallbackListeners[id] = chasm.NewDataField(mctx, &channelpb.CallbackListener{ + ListenerId: id, + Callback: common.CloneProto(reg.Callback), + RequestId: reg.RequestID, + RegisteredTime: timestamppb.New(mctx.Now(c)), + HandedCounter: c.State.GetLatest().GetCounter(), + }) + return id, nil +} + +// UnregisterListener drops a callback listener. An id the channel does not +// hold is not an error, so a retried unregister succeeds. Workflow listeners +// are not dropped this way: the run believes it is subscribed, and only the +// run ending ends that. +func (c *Channel) UnregisterListener( + mctx chasm.MutableContext, + listenerID string, + limits Limits, +) error { + if listenerID == "" { + return serviceerror.NewInvalidArgument("listener id is required") + } + if _, ok := c.WorkflowListeners[listenerID]; ok { + if _, isCallback := c.CallbackListeners[listenerID]; !isCallback { + return serviceerror.NewInvalidArgumentf( + "listener %q is a workflow; it stops listening when its run ends", listenerID) + } + } + c.touch(mctx) + delete(c.CallbackListeners, listenerID) + c.scheduleIdleCheck(mctx, limits.withDefaults()) + return nil +} + +// CallbackDelivery is one attempt at posting a callback listener's in-flight +// notification. +type CallbackDelivery struct { + Callback *commonpb.Callback + Notification *channelpb.Notification + Attempt int32 +} + +// CallbackDeliveryFor reads what the delivery task should post. It reports +// false when the listener is gone or the attempt the task was made for is +// over, so a stale task does nothing. +func (c *Channel) CallbackDeliveryFor( + ctx chasm.Context, + task *channelpb.ChannelCallbackTask, +) (CallbackDelivery, bool) { + field, ok := c.CallbackListeners[task.GetListenerId()] + if !ok { + return CallbackDelivery{}, false + } + listener := field.Get(readOnly(ctx)) + if listener.GetInFlight() == nil || listener.GetTaskSequence() != task.GetSequence() || + listener.GetNextAttemptTime() != nil { + return CallbackDelivery{}, false + } + return CallbackDelivery{ + Callback: common.CloneProto(listener.GetCallback()), + Notification: common.CloneProto(listener.GetInFlight()), + Attempt: listener.GetAttempt(), + }, true +} + +// CallbackOutcome is how one delivery attempt ended. +type CallbackOutcome struct { + ListenerID string + // The task sequence the attempt ran under. + Sequence int64 + // Nil on success. + Err error + Retryable bool + // Applied to a retryable failure. + RetryPolicy backoff.RetryPolicy +} + +// CompleteCallbackDelivery records an attempt. A success or a failure that +// will not succeed on retry ends the in-flight delivery, and the pending +// notification, if one arrived meanwhile, goes out next. A retryable failure +// backs off and tries the same notification again. +// +// It reports whether the attempt delivered. +func (c *Channel) CompleteCallbackDelivery( + mctx chasm.MutableContext, + outcome CallbackOutcome, +) (bool, error) { + field, ok := c.CallbackListeners[outcome.ListenerID] + if !ok { + return false, nil + } + view := field.Get(readOnly(mctx)) + if view.GetInFlight() == nil || view.GetTaskSequence() != outcome.Sequence || + view.GetNextAttemptTime() != nil { + return false, nil + } + listener := field.Get(mctx) + now := mctx.Now(c) + + if outcome.Err != nil && outcome.Retryable { + listener.Attempt++ + delay := outcome.RetryPolicy.ComputeNextDelay(0, int(listener.Attempt), outcome.Err) + next := now.Add(delay) + listener.NextAttemptTime = timestamppb.New(next) + listener.LastAttemptFailure = attemptFailure(outcome.Err, false) + mctx.AddTask(c, chasm.TaskAttributes{ScheduledTime: next}, &channelpb.ChannelCallbackBackoffTask{ + ListenerId: listener.GetListenerId(), + Sequence: listener.GetTaskSequence(), + }) + return false, nil + } + + if outcome.Err != nil { + listener.LastAttemptFailure = attemptFailure(outcome.Err, true) + } else { + listener.LastAttemptFailure = nil + } + listener.InFlight = listener.GetPending() + listener.Pending = nil + listener.Attempt = 0 + listener.NextAttemptTime = nil + if listener.GetInFlight() != nil { + if err := c.scheduleCallback(mctx, listener); err != nil { + return false, err + } + } + return outcome.Err == nil, nil +} + +func attemptFailure(err error, nonRetryable bool) *failurepb.Failure { + return &failurepb.Failure{ + Message: err.Error(), + FailureInfo: &failurepb.Failure_ApplicationFailureInfo{ + ApplicationFailureInfo: &failurepb.ApplicationFailureInfo{NonRetryable: nonRetryable}, + }, + } +} + +// BackoffDone reports whether a backoff task still has an attempt to resume. +func (c *Channel) BackoffDone(ctx chasm.Context, task *channelpb.ChannelCallbackBackoffTask) bool { + field, ok := c.CallbackListeners[task.GetListenerId()] + if !ok { + return false + } + listener := field.Get(readOnly(ctx)) + return listener.GetInFlight() != nil && listener.GetTaskSequence() == task.GetSequence() && + listener.GetNextAttemptTime() != nil +} + +// ResumeCallback schedules the next attempt once a backoff has run out. +func (c *Channel) ResumeCallback( + mctx chasm.MutableContext, + task *channelpb.ChannelCallbackBackoffTask, +) error { + if !c.BackoffDone(mctx, task) { + return nil + } + listener := c.CallbackListeners[task.GetListenerId()].Get(mctx) + listener.NextAttemptTime = nil + return c.scheduleCallback(mctx, listener) +} + +// PollRequest asks for the retained notifications above a counter. +type PollRequest struct { + AfterCounter int64 + Max int +} + +// Poll returns the retained notifications with a counter above the request's, +// oldest first, at most Max of them. +func (c *Channel) Poll(ctx chasm.Context, req PollRequest) ([]*channelpb.Notification, error) { + limit := req.Max + if limit <= 0 || limit > DefaultRetainedNotifications { + limit = DefaultRetainedNotifications + } + view := readOnly(ctx) + var out []*channelpb.Notification + for seq := c.State.GetRetainedFirst(); seq < c.State.GetRetainedNext(); seq++ { + field, ok := c.Retained[seq] + if !ok { + continue + } + n := field.Get(view) + if n.GetCounter() <= req.AfterCounter { + continue + } + out = append(out, common.CloneProto(n)) + if len(out) >= limit { + break + } + } + return out, nil +} + +// LatestCounter is the highest counter the channel has accepted. It only +// grows, which is what makes it a condition a long poll can wait on. +func (c *Channel) LatestCounter() int64 { + return c.State.GetLatest().GetCounter() +} + +// Snapshot is a read of the channel for describe and for the poll path. +type Snapshot struct { + Listeners []*channelpb.ChannelListenerInfo + Latest *channelpb.Notification + RetainedCount int64 + LastActivityTime time.Time +} + +// Describe lists the listeners, workflow listeners first, each kind in id +// order, and reports the latest notification and how many are retained. +func (c *Channel) Describe(ctx chasm.Context, _ struct{}) (Snapshot, error) { + view := readOnly(ctx) + out := Snapshot{ + Latest: common.CloneProto(c.State.GetLatest()), + RetainedCount: c.RetainedCount(), + LastActivityTime: c.State.GetLastActivityTime().AsTime(), + } + for _, id := range slices.Sorted(maps.Keys(c.WorkflowListeners)) { + listener := c.WorkflowListeners[id].Get(view) + out.Listeners = append(out.Listeners, &channelpb.ChannelListenerInfo{ + ListenerId: id, + Variant: &channelpb.ChannelListenerInfo_Workflow_{ + Workflow: &channelpb.ChannelListenerInfo_Workflow{ + WorkflowId: listener.GetWorkflowId(), + RunId: listener.GetRunId(), + }, + }, + RegisteredTime: common.CloneProto(listener.GetRegisteredTime()), + }) + } + for _, id := range slices.Sorted(maps.Keys(c.CallbackListeners)) { + listener := c.CallbackListeners[id].Get(view) + out.Listeners = append(out.Listeners, &channelpb.ChannelListenerInfo{ + ListenerId: id, + Variant: &channelpb.ChannelListenerInfo_Callback{ + Callback: common.CloneProto(listener.GetCallback()), + }, + RegisteredTime: common.CloneProto(listener.GetRegisteredTime()), + }) + } + return out, nil +} + +// TouchForPoll records a poll as activity, so a channel only pollers use is +// not deleted under them. Skipped while the last activity is recent, since a +// poll is otherwise a read and this keeps it one on all but a few calls. +func (c *Channel) TouchForPoll(mctx chasm.MutableContext, limits Limits) (struct{}, error) { + limits = limits.withDefaults() + if mctx.Now(c).Sub(c.State.GetLastActivityTime().AsTime()) < limits.Retention/2 { + return struct{}{}, nil + } + c.touch(mctx) + return struct{}{}, nil +} + +// PollNeedsTouch reports whether a poll should record itself as activity. +func (c *Channel) PollNeedsTouch(now time.Time, limits Limits) bool { + limits = limits.withDefaults() + return now.Sub(c.State.GetLastActivityTime().AsTime()) >= limits.Retention/2 +} + +// scheduleIdleCheck arms the deletion check for a channel with no listeners. +// One is outstanding at a time; it re-arms itself while the channel is in use. +func (c *Channel) scheduleIdleCheck(mctx chasm.MutableContext, limits Limits) { + if c.State.IdleCheckPending || c.ListenerCount() > 0 { + return + } + c.State.IdleCheckPending = true + at := c.State.GetLastActivityTime().AsTime().Add(limits.Retention) + mctx.AddTask(c, chasm.TaskAttributes{ScheduledTime: at}, &channelpb.ChannelIdleTask{}) +} + +// RunIdleCheck is the idle task's transition. It reports whether the channel +// has gone unused for a full retention with no listeners, which is when the +// task deletes it. Otherwise it re-arms for when that could next be true, or +// stands down while there are listeners, whose departure arms it again. +func (c *Channel) RunIdleCheck(mctx chasm.MutableContext, limits Limits) (bool, error) { + limits = limits.withDefaults() + c.State.IdleCheckPending = false + if c.ListenerCount() > 0 { + return false, nil + } + deadline := c.State.GetLastActivityTime().AsTime().Add(limits.Retention) + if !mctx.Now(c).Before(deadline) { + c.State.Closed = true + return true, nil + } + c.scheduleIdleCheck(mctx, limits) + return false, nil +} + +// CheckChannelName refuses a channel name that cannot key an execution. +func CheckChannelName(name string, maxLength int) error { + if name == "" { + return serviceerror.NewInvalidArgument("channel is required") + } + if maxLength > 0 && len(name) > maxLength { + return serviceerror.NewInvalidArgumentf( + "channel name is %d bytes, more than the limit of %d", len(name), maxLength) + } + return nil +} diff --git a/chasm/lib/channel/channel_test.go b/chasm/lib/channel/channel_test.go new file mode 100644 index 00000000000..e4ee5cc25ad --- /dev/null +++ b/chasm/lib/channel/channel_test.go @@ -0,0 +1,351 @@ +package channel + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + commonpb "go.temporal.io/api/common/v1" + "go.temporal.io/api/serviceerror" + "go.temporal.io/server/chasm" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + "go.temporal.io/server/common/backoff" +) + +func newTestChannel(t *testing.T) (*Channel, *chasm.MockMutableContext, *time.Time) { + t.Helper() + now := time.Unix(1_700_000_000, 0).UTC() + mctx := &chasm.MockMutableContext{MockContext: chasm.MockContext{ + HandleNow: func(chasm.Component) time.Time { return now }, + }} + return NewChannel(mctx), mctx, &now +} + +func note(counter int64) *channelpb.Notification { + return &channelpb.Notification{Channel: "orders", Counter: counter, Position: []byte{byte(counter)}} +} + +func tasksOf[T any](mctx *chasm.MockMutableContext) []chasm.MockTask { + var out []chasm.MockTask + for _, task := range mctx.Tasks { + if _, ok := task.Payload.(T); ok { + out = append(out, task) + } + } + return out +} + +func counters(ns []*channelpb.Notification) []int64 { + out := make([]int64, len(ns)) + for i, n := range ns { + out[i] = n.GetCounter() + } + return out +} + +func testCallback() *commonpb.Callback { + return &commonpb.Callback{Variant: &commonpb.Callback_Nexus_{ + Nexus: &commonpb.Callback_Nexus{Url: "http://localhost:7243/notify"}, + }} +} + +// A channel nobody listens to still keeps what it is told, for a poller that +// arrives later, and reports that nobody was there. +func TestNotifyWithoutListenersRetains(t *testing.T) { + c, mctx, _ := newTestChannel(t) + result, err := c.Notify(mctx, note(1), Limits{}) + require.NoError(t, err) + require.Zero(t, result.ListenerCount) + require.Empty(t, tasksOf[*channelpb.ChannelFanOutTask](mctx), "nobody to fan out to") + + polled, err := c.Poll(mctx, PollRequest{}) + require.NoError(t, err) + require.Equal(t, []int64{1}, counters(polled)) + require.Equal(t, int64(1), c.LatestCounter()) +} + +// The ring keeps the newest notifications up to its bound, and a poll returns +// those above the caller's counter, oldest first, up to its page size. +func TestRingDropsOldestAndPollFilters(t *testing.T) { + c, mctx, _ := newTestChannel(t) + limits := Limits{RetainedNotifications: 3} + for counter := int64(1); counter <= 5; counter++ { + _, err := c.Notify(mctx, note(counter), limits) + require.NoError(t, err) + } + require.Equal(t, int64(3), c.RetainedCount()) + require.Len(t, c.Retained, 3) + + polled, err := c.Poll(mctx, PollRequest{}) + require.NoError(t, err) + require.Equal(t, []int64{3, 4, 5}, counters(polled)) + + polled, err = c.Poll(mctx, PollRequest{AfterCounter: 3, Max: 1}) + require.NoError(t, err) + require.Equal(t, []int64{4}, counters(polled)) +} + +func TestNotifyRefusesWhatItCannotAccept(t *testing.T) { + c, mctx, _ := newTestChannel(t) + cases := map[string]*channelpb.Notification{ + "no counter": {Channel: "orders"}, + "no channel": {Counter: 1}, + "long position": {Channel: "orders", Counter: 1, Position: make([]byte, MaxPositionBytes+1)}, + "large metadata": {Channel: "orders", Counter: 1, Metadata: map[string]*commonpb.Payload{"k": {Data: make([]byte, 64)}}}, + } + for name, n := range cases { + _, err := c.Notify(mctx, n, Limits{MaxMetadataBytes: 32}) + var invalid *serviceerror.InvalidArgument + require.ErrorAs(t, err, &invalid, name) + } + require.Zero(t, c.RetainedCount()) +} + +// A burst schedules one fan-out, and the fan-out hands each listener only the +// highest counter. +func TestBurstCoalescesIntoOneFanOut(t *testing.T) { + c, mctx, _ := newTestChannel(t) + require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + WorkflowID: "wf", RunID: "run-1", + })) + for _, counter := range []int64{1, 3, 2} { + result, err := c.Notify(mctx, note(counter), Limits{}) + require.NoError(t, err) + require.Equal(t, 1, result.ListenerCount) + } + require.Len(t, tasksOf[*channelpb.ChannelFanOutTask](mctx), 1) + require.Equal(t, int64(3), c.LatestCounter(), "a lower counter does not replace the latest") + + fanOut, err := c.TakeFanOut(mctx, struct{}{}) + require.NoError(t, err) + require.Equal(t, int64(3), fanOut.Latest.GetCounter()) + require.Len(t, fanOut.WorkflowListeners, 1) + require.False(t, c.State.GetFanOutPending()) + + _, err = c.Notify(mctx, note(4), Limits{}) + require.NoError(t, err) + require.Len(t, tasksOf[*channelpb.ChannelFanOutTask](mctx), 2, "the flag came down") +} + +// A callback listener has one delivery in flight. What arrives meanwhile folds +// into a single pending notification, which goes out when the in-flight one +// is done. +func TestCallbackFoldsWhileBusy(t *testing.T) { + c, mctx, _ := newTestChannel(t) + id, err := c.RegisterCallbackListener(mctx, CallbackRegistration{ + RequestID: "req-1", Callback: testCallback(), + }) + require.NoError(t, err) + + notifyAndFanOut := func(counter int64) FanOut { + _, err := c.Notify(mctx, note(counter), Limits{}) + require.NoError(t, err) + fanOut, err := c.TakeFanOut(mctx, struct{}{}) + require.NoError(t, err) + return fanOut + } + + require.Equal(t, 1, notifyAndFanOut(1).CallbackStarted) + deliveries := tasksOf[*channelpb.ChannelCallbackTask](mctx) + require.Len(t, deliveries, 1) + require.Equal(t, "http://localhost:7243", deliveries[0].Attributes.Destination) + + require.Zero(t, notifyAndFanOut(2).CallbackFolded, "first one waiting is not a fold") + require.Equal(t, 1, notifyAndFanOut(3).CallbackFolded) + require.Len(t, tasksOf[*channelpb.ChannelCallbackTask](mctx), 1, "still one in flight") + + task := deliveries[0].Payload.(*channelpb.ChannelCallbackTask) + delivery, ok := c.CallbackDeliveryFor(mctx, task) + require.True(t, ok) + require.Equal(t, int64(1), delivery.Notification.GetCounter()) + + delivered, err := c.CompleteCallbackDelivery(mctx, CallbackOutcome{ + ListenerID: id, Sequence: task.GetSequence(), + }) + require.NoError(t, err) + require.True(t, delivered) + deliveries = tasksOf[*channelpb.ChannelCallbackTask](mctx) + require.Len(t, deliveries, 2, "the pending one goes out next") + next, ok := c.CallbackDeliveryFor(mctx, deliveries[1].Payload.(*channelpb.ChannelCallbackTask)) + require.True(t, ok) + require.Equal(t, int64(3), next.Notification.GetCounter()) + _, ok = c.CallbackDeliveryFor(mctx, task) + require.False(t, ok, "the first delivery's task is stale") + + stale, err := c.CompleteCallbackDelivery(mctx, CallbackOutcome{ + ListenerID: id, Sequence: task.GetSequence(), + }) + require.NoError(t, err) + require.False(t, stale, "a stale outcome changes nothing") + delivered, err = c.CompleteCallbackDelivery(mctx, CallbackOutcome{ + ListenerID: id, Sequence: deliveries[1].Payload.(*channelpb.ChannelCallbackTask).GetSequence(), + }) + require.NoError(t, err) + require.True(t, delivered) + require.Nil(t, c.CallbackListeners[id].Get(mctx).GetInFlight()) +} + +// A retryable failure backs off and tries the same notification again; a +// failure that will not succeed on retry drops it. +func TestCallbackRetryBacksOff(t *testing.T) { + c, mctx, now := newTestChannel(t) + id, err := c.RegisterCallbackListener(mctx, CallbackRegistration{ + RequestID: "req-1", Callback: testCallback(), + }) + require.NoError(t, err) + _, err = c.Notify(mctx, note(1), Limits{}) + require.NoError(t, err) + _, err = c.TakeFanOut(mctx, struct{}{}) + require.NoError(t, err) + + policy := backoff.NewExponentialRetryPolicy(time.Second).WithExpirationInterval(backoff.NoInterval) + delivered, err := c.CompleteCallbackDelivery(mctx, CallbackOutcome{ + ListenerID: id, Sequence: 1, Err: errors.New("503"), Retryable: true, RetryPolicy: policy, + }) + require.NoError(t, err) + require.False(t, delivered) + backoffs := tasksOf[*channelpb.ChannelCallbackBackoffTask](mctx) + require.Len(t, backoffs, 1) + require.True(t, backoffs[0].Attributes.ScheduledTime.After(*now)) + backoffTask := backoffs[0].Payload.(*channelpb.ChannelCallbackBackoffTask) + require.Equal(t, int64(1), backoffTask.GetSequence()) + _, ok := c.CallbackDeliveryFor(mctx, &channelpb.ChannelCallbackTask{ListenerId: id, Sequence: 1}) + require.False(t, ok, "nothing to post while backing off") + + require.True(t, c.BackoffDone(mctx, backoffTask)) + require.NoError(t, c.ResumeCallback(mctx, backoffTask)) + retry, ok := c.CallbackDeliveryFor(mctx, &channelpb.ChannelCallbackTask{ListenerId: id, Sequence: 2}) + require.True(t, ok) + require.Equal(t, int64(1), retry.Notification.GetCounter()) + require.Equal(t, int32(1), retry.Attempt) + + delivered, err = c.CompleteCallbackDelivery(mctx, CallbackOutcome{ + ListenerID: id, Sequence: 2, Err: errors.New("400"), + }) + require.NoError(t, err) + require.False(t, delivered) + listener := c.CallbackListeners[id].Get(mctx) + require.Nil(t, listener.GetInFlight()) + require.True(t, listener.GetLastAttemptFailure().GetApplicationFailureInfo().GetNonRetryable()) +} + +// A retried registration returns the listener it made; a new listener is not +// handed what was accepted before it registered; the table is bounded. +func TestListenerTable(t *testing.T) { + c, mctx, _ := newTestChannel(t) + _, err := c.Notify(mctx, note(5), Limits{}) + require.NoError(t, err) + + limits := Limits{MaxListeners: 2} + first, err := c.RegisterCallbackListener(mctx, CallbackRegistration{ + RequestID: "req-1", Callback: testCallback(), Limits: limits, + }) + require.NoError(t, err) + again, err := c.RegisterCallbackListener(mctx, CallbackRegistration{ + RequestID: "req-1", Callback: testCallback(), Limits: limits, + }) + require.NoError(t, err) + require.Equal(t, first, again) + require.Equal(t, int64(5), c.CallbackListeners[first].Get(mctx).GetHandedCounter()) + + require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + WorkflowID: "wf", RunID: "run-1", Limits: limits, + })) + _, err = c.RegisterCallbackListener(mctx, CallbackRegistration{ + RequestID: "req-2", Callback: testCallback(), Limits: limits, + }) + var exhausted *serviceerror.ResourceExhausted + require.ErrorAs(t, err, &exhausted) + require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + WorkflowID: "wf", RunID: "run-2", Limits: limits, + }), "a later run of a known workflow takes its entry, not a new one") + require.Equal(t, "run-2", c.WorkflowListeners["wf"].Get(mctx).GetRunId()) + + _, err = c.RegisterCallbackListener(mctx, CallbackRegistration{ + RequestID: "req-3", Callback: &commonpb.Callback{}, + }) + require.Error(t, err, "a callback with no URL cannot be reached") + + require.Error(t, c.UnregisterListener(mctx, "wf", Limits{}), "workflows leave by ending") + require.NoError(t, c.UnregisterListener(mctx, first, Limits{})) + require.NoError(t, c.UnregisterListener(mctx, first, Limits{}), "a retry succeeds") + require.Equal(t, 1, c.ListenerCount()) + + snapshot, err := c.Describe(mctx, struct{}{}) + require.NoError(t, err) + require.Len(t, snapshot.Listeners, 1) + require.Equal(t, "run-2", snapshot.Listeners[0].GetWorkflow().GetRunId()) + require.Equal(t, int64(5), snapshot.Latest.GetCounter()) +} + +// The chain rule: a listener moves to the run that continued the one on +// record, and goes when no run listens. Neither touches an entry a newer run +// has already taken. +func TestWorkflowListenerRekeyAndForget(t *testing.T) { + c, mctx, _ := newTestChannel(t) + require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + WorkflowID: "wf", RunID: "run-1", + })) + + c.RekeyWorkflowListener(mctx, "wf", "run-1", "run-2") + require.Equal(t, "run-2", c.WorkflowListeners["wf"].Get(mctx).GetRunId()) + c.RekeyWorkflowListener(mctx, "wf", "run-1", "run-3") + require.Equal(t, "run-2", c.WorkflowListeners["wf"].Get(mctx).GetRunId(), "stale re-key") + + c.ForgetWorkflowListener(mctx, "wf", "run-1", Limits{}) + require.Equal(t, 1, c.ListenerCount(), "stale forget") + c.ForgetWorkflowListener(mctx, "wf", "run-2", Limits{}) + require.Zero(t, c.ListenerCount()) + require.Len(t, tasksOf[*channelpb.ChannelIdleTask](mctx), 1, "the last listener leaving arms the idle check") +} + +// A channel with no listeners is closed for deletion a full retention after +// its last activity, and not before. +func TestIdleCheck(t *testing.T) { + c, mctx, now := newTestChannel(t) + limits := Limits{Retention: time.Hour} + _, err := c.Notify(mctx, note(1), limits) + require.NoError(t, err) + idle := tasksOf[*channelpb.ChannelIdleTask](mctx) + require.Len(t, idle, 1) + require.Equal(t, now.Add(time.Hour), idle[0].Attributes.ScheduledTime) + + *now = now.Add(30 * time.Minute) + _, err = c.Notify(mctx, note(2), limits) + require.NoError(t, err) + require.Len(t, tasksOf[*channelpb.ChannelIdleTask](mctx), 1, "one check outstanding") + + *now = now.Add(30 * time.Minute) + expired, err := c.RunIdleCheck(mctx, limits) + require.NoError(t, err) + require.False(t, expired, "the second notify moved the deadline") + idle = tasksOf[*channelpb.ChannelIdleTask](mctx) + require.Len(t, idle, 2) + require.Equal(t, now.Add(30*time.Minute), idle[1].Attributes.ScheduledTime) + + *now = now.Add(30 * time.Minute) + require.True(t, c.PollNeedsTouch(*now, limits)) + expired, err = c.RunIdleCheck(mctx, limits) + require.NoError(t, err) + require.True(t, expired) + require.Equal(t, chasm.LifecycleStateCompleted, c.LifecycleState(mctx)) +} + +// A listener holds the channel: the idle check stands down while one is +// registered. +func TestIdleCheckStandsDownWithListeners(t *testing.T) { + c, mctx, now := newTestChannel(t) + limits := Limits{Retention: time.Minute} + _, err := c.Notify(mctx, note(1), limits) + require.NoError(t, err) + require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + WorkflowID: "wf", RunID: "run-1", + })) + *now = now.Add(time.Hour) + expired, err := c.RunIdleCheck(mctx, limits) + require.NoError(t, err) + require.False(t, expired) + require.False(t, c.State.GetIdleCheckPending()) + require.Len(t, tasksOf[*channelpb.ChannelIdleTask](mctx), 1) +} diff --git a/chasm/lib/channel/config.go b/chasm/lib/channel/config.go new file mode 100644 index 00000000000..7efff2424c0 --- /dev/null +++ b/chasm/lib/channel/config.go @@ -0,0 +1,138 @@ +package channel + +import ( + "time" + + "go.temporal.io/server/common/dynamicconfig" +) + +// MaxPositionBytes bounds the position a notification carries. It is a cursor +// for the listener to read from rather than data, and the channel retains it. +const MaxPositionBytes = 1024 + +// LongPollTimeout matches the other history long polls: on expiry the caller +// gets an empty response and polls again. +const LongPollTimeout = 20 * time.Second + +// LongPollBuffer leaves room to answer before the caller's own deadline. +const LongPollBuffer = 3 * time.Second + +// RoutedCallTimeout bounds one call to another shard, so a slow listener does +// not take the whole budget of the task that is telling the others. +const RoutedCallTimeout = 5 * time.Second + +// The defaults below are also what component code falls back on when it is +// driven without a config, as the unit tests do. +const ( + DefaultMaxListeners = 1000 + DefaultRetainedNotifications = 1000 + DefaultMaxMetadataBytes = 2048 + DefaultRetention = time.Hour + DefaultNotifyPerSecond = 10000 + DefaultMaxSubscriptionsPerWorkflow = 32 +) + +var ( + MaxListenersSetting = dynamicconfig.NewNamespaceIntSetting( + "channel.maxListeners", + DefaultMaxListeners, + `Most listeners, workflow and callback together, one notification channel holds. A +registration past it is refused.`, + ) + RetainedNotificationsSetting = dynamicconfig.NewNamespaceIntSetting( + "channel.retainedNotifications", + DefaultRetainedNotifications, + `Most notifications a channel keeps for pollers. The oldest goes when a new one +arrives past it.`, + ) + MaxMetadataBytesSetting = dynamicconfig.NewNamespaceIntSetting( + "channel.maxMetadataBytes", + DefaultMaxMetadataBytes, + `Largest notification metadata accepted, summed over its payloads.`, + ) + RetentionSetting = dynamicconfig.NewNamespaceDurationSetting( + "channel.retention", + DefaultRetention, + `How long a channel with no listeners stays after its last notify, registration or +poll before it is deleted with what it retained.`, + ) + NotifyPerSecondSetting = dynamicconfig.NewNamespaceIntSetting( + "channel.notifyPerSecond", + DefaultNotifyPerSecond, + `Most NotifyChannel calls a namespace may make per second on one history host. Zero +means no limit.`, + ) + MaxSubscriptionsPerWorkflowSetting = dynamicconfig.NewNamespaceIntSetting( + "channel.maxSubscriptionsPerWorkflow", + DefaultMaxSubscriptionsPerWorkflow, + `Most notification channels one workflow run subscribes to. A subscribe command past +it fails the workflow task.`, + ) +) + +// Limits are the per-namespace bounds a channel transition applies, resolved +// by the caller so the component needs no config of its own. +type Limits struct { + MaxListeners int + RetainedNotifications int + MaxMetadataBytes int + Retention time.Duration +} + +// DefaultLimits is what applies when nothing is configured. +func DefaultLimits() Limits { + return Limits{ + MaxListeners: DefaultMaxListeners, + RetainedNotifications: DefaultRetainedNotifications, + MaxMetadataBytes: DefaultMaxMetadataBytes, + Retention: DefaultRetention, + } +} + +// withDefaults fills what a caller left at zero. +func (l Limits) withDefaults() Limits { + d := DefaultLimits() + if l.MaxListeners <= 0 { + l.MaxListeners = d.MaxListeners + } + if l.RetainedNotifications <= 0 { + l.RetainedNotifications = d.RetainedNotifications + } + if l.MaxMetadataBytes <= 0 { + l.MaxMetadataBytes = d.MaxMetadataBytes + } + if l.Retention <= 0 { + l.Retention = d.Retention + } + return l +} + +type Config struct { + MaxListeners dynamicconfig.IntPropertyFnWithNamespaceFilter + RetainedNotifications dynamicconfig.IntPropertyFnWithNamespaceFilter + MaxMetadataBytes dynamicconfig.IntPropertyFnWithNamespaceFilter + Retention dynamicconfig.DurationPropertyFnWithNamespaceFilter + NotifyPerSecond dynamicconfig.IntPropertyFnWithNamespaceFilter + MaxSubscriptionsPerWorkflow dynamicconfig.IntPropertyFnWithNamespaceFilter +} + +func NewConfig(dc *dynamicconfig.Collection) *Config { + return &Config{ + MaxListeners: MaxListenersSetting.Get(dc), + RetainedNotifications: RetainedNotificationsSetting.Get(dc), + MaxMetadataBytes: MaxMetadataBytesSetting.Get(dc), + Retention: RetentionSetting.Get(dc), + NotifyPerSecond: NotifyPerSecondSetting.Get(dc), + MaxSubscriptionsPerWorkflow: MaxSubscriptionsPerWorkflowSetting.Get(dc), + } +} + +// LimitsFor resolves the namespace's limits. +func (c *Config) LimitsFor(namespaceName string) Limits { + return Limits{ + MaxListeners: c.MaxListeners(namespaceName), + RetainedNotifications: c.RetainedNotifications(namespaceName), + MaxMetadataBytes: c.MaxMetadataBytes(namespaceName), + Retention: c.Retention(namespaceName), + } +} diff --git a/chasm/lib/channel/gen/channelpb/v1/channel_state.go-helpers.pb.go b/chasm/lib/channel/gen/channelpb/v1/channel_state.go-helpers.pb.go new file mode 100644 index 00000000000..19e0772f892 --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/channel_state.go-helpers.pb.go @@ -0,0 +1,43 @@ +// Code generated by protoc-gen-go-helpers. DO NOT EDIT. +package channelpb + +import ( + "google.golang.org/protobuf/proto" +) + +// Marshal an object of type ChannelState to the protobuf v3 wire format +func (val *ChannelState) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type ChannelState from the protobuf v3 wire format +func (val *ChannelState) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *ChannelState) Size() int { + return proto.Size(val) +} + +// Equal returns whether two ChannelState values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *ChannelState) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *ChannelState + switch t := that.(type) { + case *ChannelState: + that1 = t + case ChannelState: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} diff --git a/chasm/lib/channel/gen/channelpb/v1/channel_state.pb.go b/chasm/lib/channel/gen/channelpb/v1/channel_state.pb.go new file mode 100644 index 00000000000..2886f33b0ee --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/channel_state.pb.go @@ -0,0 +1,206 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// plugins: +// protoc-gen-go +// protoc +// source: temporal/server/chasm/lib/channel/proto/v1/channel_state.proto + +package channelpb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ChannelState is the part of a channel written on every notify. The retained +// notifications and the listeners are nodes of their own, so a notify writes +// this, one retained notification, and nothing per listener. +type ChannelState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The notification with the highest counter accepted so far. + Latest *Notification `protobuf:"bytes,1,opt,name=latest,proto3" json:"latest,omitempty"` + // Sequence of the oldest retained notification and the next one to be + // written. The retained notifications are keyed by sequence. + RetainedFirst int64 `protobuf:"varint,2,opt,name=retained_first,json=retainedFirst,proto3" json:"retained_first,omitempty"` + RetainedNext int64 `protobuf:"varint,3,opt,name=retained_next,json=retainedNext,proto3" json:"retained_next,omitempty"` + // A fan-out task is outstanding. Notifies that land while it is up schedule + // none of their own; the task reads the latest when it runs. + FanOutPending bool `protobuf:"varint,4,opt,name=fan_out_pending,json=fanOutPending,proto3" json:"fan_out_pending,omitempty"` + // Last notify, registration or poll. The idle task deletes a channel with no + // listeners once this is older than the retention. + LastActivityTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_activity_time,json=lastActivityTime,proto3" json:"last_activity_time,omitempty"` + IdleCheckPending bool `protobuf:"varint,6,opt,name=idle_check_pending,json=idleCheckPending,proto3" json:"idle_check_pending,omitempty"` + // Total notifications accepted, for describe. + AcceptedCount int64 `protobuf:"varint,7,opt,name=accepted_count,json=acceptedCount,proto3" json:"accepted_count,omitempty"` + // Set by the idle task just before it deletes the channel, so a notify or a + // registration that arrives in between starts a new channel rather than + // landing on the one being deleted. + Closed bool `protobuf:"varint,8,opt,name=closed,proto3" json:"closed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelState) Reset() { + *x = ChannelState{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelState) ProtoMessage() {} + +func (x *ChannelState) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelState.ProtoReflect.Descriptor instead. +func (*ChannelState) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDescGZIP(), []int{0} +} + +func (x *ChannelState) GetLatest() *Notification { + if x != nil { + return x.Latest + } + return nil +} + +func (x *ChannelState) GetRetainedFirst() int64 { + if x != nil { + return x.RetainedFirst + } + return 0 +} + +func (x *ChannelState) GetRetainedNext() int64 { + if x != nil { + return x.RetainedNext + } + return 0 +} + +func (x *ChannelState) GetFanOutPending() bool { + if x != nil { + return x.FanOutPending + } + return false +} + +func (x *ChannelState) GetLastActivityTime() *timestamppb.Timestamp { + if x != nil { + return x.LastActivityTime + } + return nil +} + +func (x *ChannelState) GetIdleCheckPending() bool { + if x != nil { + return x.IdleCheckPending + } + return false +} + +func (x *ChannelState) GetAcceptedCount() int64 { + if x != nil { + return x.AcceptedCount + } + return 0 +} + +func (x *ChannelState) GetClosed() bool { + if x != nil { + return x.Closed + } + return false +} + +var File_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto protoreflect.FileDescriptor + +const file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDesc = "" + + "\n" + + ">temporal/server/chasm/lib/channel/proto/v1/channel_state.proto\x12*temporal.server.chasm.lib.channel.proto.v1\x1a8temporal/server/chasm/lib/channel/proto/v1/message.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8b\x03\n" + + "\fChannelState\x12P\n" + + "\x06latest\x18\x01 \x01(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\x06latest\x12%\n" + + "\x0eretained_first\x18\x02 \x01(\x03R\rretainedFirst\x12#\n" + + "\rretained_next\x18\x03 \x01(\x03R\fretainedNext\x12&\n" + + "\x0ffan_out_pending\x18\x04 \x01(\bR\rfanOutPending\x12H\n" + + "\x12last_activity_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x10lastActivityTime\x12,\n" + + "\x12idle_check_pending\x18\x06 \x01(\bR\x10idleCheckPending\x12%\n" + + "\x0eaccepted_count\x18\a \x01(\x03R\racceptedCount\x12\x16\n" + + "\x06closed\x18\b \x01(\bR\x06closedBAZ?go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpbb\x06proto3" + +var ( + file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDescOnce sync.Once + file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDescData []byte +) + +func file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDescGZIP() []byte { + file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDescOnce.Do(func() { + file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDesc))) + }) + return file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDescData +} + +var file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_goTypes = []any{ + (*ChannelState)(nil), // 0: temporal.server.chasm.lib.channel.proto.v1.ChannelState + (*Notification)(nil), // 1: temporal.server.chasm.lib.channel.proto.v1.Notification + (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp +} +var file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_depIdxs = []int32{ + 1, // 0: temporal.server.chasm.lib.channel.proto.v1.ChannelState.latest:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 2, // 1: temporal.server.chasm.lib.channel.proto.v1.ChannelState.last_activity_time:type_name -> google.protobuf.Timestamp + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_init() } +func file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_init() { + if File_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto != nil { + return + } + file_temporal_server_chasm_lib_channel_proto_v1_message_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_goTypes, + DependencyIndexes: file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_depIdxs, + MessageInfos: file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_msgTypes, + }.Build() + File_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto = out.File + file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_goTypes = nil + file_temporal_server_chasm_lib_channel_proto_v1_channel_state_proto_depIdxs = nil +} diff --git a/chasm/lib/channel/gen/channelpb/v1/message.go-helpers.pb.go b/chasm/lib/channel/gen/channelpb/v1/message.go-helpers.pb.go new file mode 100644 index 00000000000..f5089f2e084 --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/message.go-helpers.pb.go @@ -0,0 +1,117 @@ +// Code generated by protoc-gen-go-helpers. DO NOT EDIT. +package channelpb + +import ( + "google.golang.org/protobuf/proto" +) + +// Marshal an object of type Notification to the protobuf v3 wire format +func (val *Notification) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type Notification from the protobuf v3 wire format +func (val *Notification) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *Notification) Size() int { + return proto.Size(val) +} + +// Equal returns whether two Notification values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *Notification) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *Notification + switch t := that.(type) { + case *Notification: + that1 = t + case Notification: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type WorkflowListener to the protobuf v3 wire format +func (val *WorkflowListener) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type WorkflowListener from the protobuf v3 wire format +func (val *WorkflowListener) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *WorkflowListener) Size() int { + return proto.Size(val) +} + +// Equal returns whether two WorkflowListener values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *WorkflowListener) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *WorkflowListener + switch t := that.(type) { + case *WorkflowListener: + that1 = t + case WorkflowListener: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type CallbackListener to the protobuf v3 wire format +func (val *CallbackListener) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type CallbackListener from the protobuf v3 wire format +func (val *CallbackListener) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *CallbackListener) Size() int { + return proto.Size(val) +} + +// Equal returns whether two CallbackListener values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *CallbackListener) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *CallbackListener + switch t := that.(type) { + case *CallbackListener: + that1 = t + case CallbackListener: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} diff --git a/chasm/lib/channel/gen/channelpb/v1/message.pb.go b/chasm/lib/channel/gen/channelpb/v1/message.pb.go new file mode 100644 index 00000000000..dd8e8b7bff7 --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/message.pb.go @@ -0,0 +1,404 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// plugins: +// protoc-gen-go +// protoc +// source: temporal/server/chasm/lib/channel/proto/v1/message.proto + +package channelpb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + v1 "go.temporal.io/api/common/v1" + v11 "go.temporal.io/api/failure/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Notification has the fields of temporal.api.notification.v1.Notification +// under the same names, so the stored form and the JSON a callback listener +// receives read the same as the public message. +type Notification struct { + state protoimpl.MessageState `protogen:"open.v1"` + Channel string `protobuf:"bytes,1,opt,name=channel,proto3" json:"channel,omitempty"` + // Opaque to the server: where the writer stood when it notified. + Position []byte `protobuf:"bytes,2,opt,name=position,proto3" json:"position,omitempty"` + // Set by the writer and expected to grow. Folding keeps the highest. + Counter int64 `protobuf:"varint,3,opt,name=counter,proto3" json:"counter,omitempty"` + Metadata map[string]*v1.Payload `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Notification) Reset() { + *x = Notification{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_message_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Notification) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Notification) ProtoMessage() {} + +func (x *Notification) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_message_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Notification.ProtoReflect.Descriptor instead. +func (*Notification) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescGZIP(), []int{0} +} + +func (x *Notification) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +func (x *Notification) GetPosition() []byte { + if x != nil { + return x.Position + } + return nil +} + +func (x *Notification) GetCounter() int64 { + if x != nil { + return x.Counter + } + return 0 +} + +func (x *Notification) GetMetadata() map[string]*v1.Payload { + if x != nil { + return x.Metadata + } + return nil +} + +// WorkflowListener is a workflow run that subscribed to the channel. +type WorkflowListener struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkflowId string `protobuf:"bytes,1,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + // The run that subscribed, or the one the subscription was re-keyed to. + RunId string `protobuf:"bytes,2,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + FirstExecutionRunId string `protobuf:"bytes,3,opt,name=first_execution_run_id,json=firstExecutionRunId,proto3" json:"first_execution_run_id,omitempty"` + RegisteredTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=registered_time,json=registeredTime,proto3" json:"registered_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkflowListener) Reset() { + *x = WorkflowListener{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_message_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkflowListener) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkflowListener) ProtoMessage() {} + +func (x *WorkflowListener) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_message_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkflowListener.ProtoReflect.Descriptor instead. +func (*WorkflowListener) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescGZIP(), []int{1} +} + +func (x *WorkflowListener) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +func (x *WorkflowListener) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *WorkflowListener) GetFirstExecutionRunId() string { + if x != nil { + return x.FirstExecutionRunId + } + return "" +} + +func (x *WorkflowListener) GetRegisteredTime() *timestamppb.Timestamp { + if x != nil { + return x.RegisteredTime + } + return nil +} + +// CallbackListener is an HTTP endpoint the channel posts its notifications to. +type CallbackListener struct { + state protoimpl.MessageState `protogen:"open.v1"` + ListenerId string `protobuf:"bytes,1,opt,name=listener_id,json=listenerId,proto3" json:"listener_id,omitempty"` + Callback *v1.Callback `protobuf:"bytes,2,opt,name=callback,proto3" json:"callback,omitempty"` + // Request id of the registration, so a retried registration finds this + // listener rather than adding another. + RequestId string `protobuf:"bytes,3,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + RegisteredTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=registered_time,json=registeredTime,proto3" json:"registered_time,omitempty"` + // The notification being delivered, if any. One delivery is in flight at a + // time per listener. + InFlight *Notification `protobuf:"bytes,5,opt,name=in_flight,json=inFlight,proto3" json:"in_flight,omitempty"` + // The latest notification that arrived while one was in flight. A newer one + // replaces it, and it is sent when the in-flight delivery is done. + Pending *Notification `protobuf:"bytes,6,opt,name=pending,proto3" json:"pending,omitempty"` + // Highest counter handed to this listener, in flight or pending, so the + // fan-out hands each notification once. + HandedCounter int64 `protobuf:"varint,7,opt,name=handed_counter,json=handedCounter,proto3" json:"handed_counter,omitempty"` + // Attempts made at the in-flight delivery. + Attempt int32 `protobuf:"varint,8,opt,name=attempt,proto3" json:"attempt,omitempty"` + NextAttemptTime *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=next_attempt_time,json=nextAttemptTime,proto3" json:"next_attempt_time,omitempty"` + LastAttemptFailure *v11.Failure `protobuf:"bytes,10,opt,name=last_attempt_failure,json=lastAttemptFailure,proto3" json:"last_attempt_failure,omitempty"` + // Bumped for every delivery task scheduled, so a task names one attempt of + // one delivery and a stale one finds nothing to do. + TaskSequence int64 `protobuf:"varint,11,opt,name=task_sequence,json=taskSequence,proto3" json:"task_sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CallbackListener) Reset() { + *x = CallbackListener{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_message_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CallbackListener) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CallbackListener) ProtoMessage() {} + +func (x *CallbackListener) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_message_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CallbackListener.ProtoReflect.Descriptor instead. +func (*CallbackListener) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescGZIP(), []int{2} +} + +func (x *CallbackListener) GetListenerId() string { + if x != nil { + return x.ListenerId + } + return "" +} + +func (x *CallbackListener) GetCallback() *v1.Callback { + if x != nil { + return x.Callback + } + return nil +} + +func (x *CallbackListener) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *CallbackListener) GetRegisteredTime() *timestamppb.Timestamp { + if x != nil { + return x.RegisteredTime + } + return nil +} + +func (x *CallbackListener) GetInFlight() *Notification { + if x != nil { + return x.InFlight + } + return nil +} + +func (x *CallbackListener) GetPending() *Notification { + if x != nil { + return x.Pending + } + return nil +} + +func (x *CallbackListener) GetHandedCounter() int64 { + if x != nil { + return x.HandedCounter + } + return 0 +} + +func (x *CallbackListener) GetAttempt() int32 { + if x != nil { + return x.Attempt + } + return 0 +} + +func (x *CallbackListener) GetNextAttemptTime() *timestamppb.Timestamp { + if x != nil { + return x.NextAttemptTime + } + return nil +} + +func (x *CallbackListener) GetLastAttemptFailure() *v11.Failure { + if x != nil { + return x.LastAttemptFailure + } + return nil +} + +func (x *CallbackListener) GetTaskSequence() int64 { + if x != nil { + return x.TaskSequence + } + return 0 +} + +var File_temporal_server_chasm_lib_channel_proto_v1_message_proto protoreflect.FileDescriptor + +const file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDesc = "" + + "\n" + + "8temporal/server/chasm/lib/channel/proto/v1/message.proto\x12*temporal.server.chasm.lib.channel.proto.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\x1a%temporal/api/failure/v1/message.proto\"\xa0\x02\n" + + "\fNotification\x12\x18\n" + + "\achannel\x18\x01 \x01(\tR\achannel\x12\x1a\n" + + "\bposition\x18\x02 \x01(\fR\bposition\x12\x18\n" + + "\acounter\x18\x03 \x01(\x03R\acounter\x12b\n" + + "\bmetadata\x18\x04 \x03(\v2F.temporal.server.chasm.lib.channel.proto.v1.Notification.MetadataEntryR\bmetadata\x1a\\\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x125\n" + + "\x05value\x18\x02 \x01(\v2\x1f.temporal.api.common.v1.PayloadR\x05value:\x028\x01\"\xc4\x01\n" + + "\x10WorkflowListener\x12\x1f\n" + + "\vworkflow_id\x18\x01 \x01(\tR\n" + + "workflowId\x12\x15\n" + + "\x06run_id\x18\x02 \x01(\tR\x05runId\x123\n" + + "\x16first_execution_run_id\x18\x03 \x01(\tR\x13firstExecutionRunId\x12C\n" + + "\x0fregistered_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x0eregisteredTime\"\x82\x05\n" + + "\x10CallbackListener\x12\x1f\n" + + "\vlistener_id\x18\x01 \x01(\tR\n" + + "listenerId\x12<\n" + + "\bcallback\x18\x02 \x01(\v2 .temporal.api.common.v1.CallbackR\bcallback\x12\x1d\n" + + "\n" + + "request_id\x18\x03 \x01(\tR\trequestId\x12C\n" + + "\x0fregistered_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x0eregisteredTime\x12U\n" + + "\tin_flight\x18\x05 \x01(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\binFlight\x12R\n" + + "\apending\x18\x06 \x01(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\apending\x12%\n" + + "\x0ehanded_counter\x18\a \x01(\x03R\rhandedCounter\x12\x18\n" + + "\aattempt\x18\b \x01(\x05R\aattempt\x12F\n" + + "\x11next_attempt_time\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\x0fnextAttemptTime\x12R\n" + + "\x14last_attempt_failure\x18\n" + + " \x01(\v2 .temporal.api.failure.v1.FailureR\x12lastAttemptFailure\x12#\n" + + "\rtask_sequence\x18\v \x01(\x03R\ftaskSequenceBAZ?go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpbb\x06proto3" + +var ( + file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescOnce sync.Once + file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescData []byte +) + +func file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescGZIP() []byte { + file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescOnce.Do(func() { + file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDesc))) + }) + return file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDescData +} + +var file_temporal_server_chasm_lib_channel_proto_v1_message_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_temporal_server_chasm_lib_channel_proto_v1_message_proto_goTypes = []any{ + (*Notification)(nil), // 0: temporal.server.chasm.lib.channel.proto.v1.Notification + (*WorkflowListener)(nil), // 1: temporal.server.chasm.lib.channel.proto.v1.WorkflowListener + (*CallbackListener)(nil), // 2: temporal.server.chasm.lib.channel.proto.v1.CallbackListener + nil, // 3: temporal.server.chasm.lib.channel.proto.v1.Notification.MetadataEntry + (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp + (*v1.Callback)(nil), // 5: temporal.api.common.v1.Callback + (*v11.Failure)(nil), // 6: temporal.api.failure.v1.Failure + (*v1.Payload)(nil), // 7: temporal.api.common.v1.Payload +} +var file_temporal_server_chasm_lib_channel_proto_v1_message_proto_depIdxs = []int32{ + 3, // 0: temporal.server.chasm.lib.channel.proto.v1.Notification.metadata:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification.MetadataEntry + 4, // 1: temporal.server.chasm.lib.channel.proto.v1.WorkflowListener.registered_time:type_name -> google.protobuf.Timestamp + 5, // 2: temporal.server.chasm.lib.channel.proto.v1.CallbackListener.callback:type_name -> temporal.api.common.v1.Callback + 4, // 3: temporal.server.chasm.lib.channel.proto.v1.CallbackListener.registered_time:type_name -> google.protobuf.Timestamp + 0, // 4: temporal.server.chasm.lib.channel.proto.v1.CallbackListener.in_flight:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 0, // 5: temporal.server.chasm.lib.channel.proto.v1.CallbackListener.pending:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 4, // 6: temporal.server.chasm.lib.channel.proto.v1.CallbackListener.next_attempt_time:type_name -> google.protobuf.Timestamp + 6, // 7: temporal.server.chasm.lib.channel.proto.v1.CallbackListener.last_attempt_failure:type_name -> temporal.api.failure.v1.Failure + 7, // 8: temporal.server.chasm.lib.channel.proto.v1.Notification.MetadataEntry.value:type_name -> temporal.api.common.v1.Payload + 9, // [9:9] is the sub-list for method output_type + 9, // [9:9] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name +} + +func init() { file_temporal_server_chasm_lib_channel_proto_v1_message_proto_init() } +func file_temporal_server_chasm_lib_channel_proto_v1_message_proto_init() { + if File_temporal_server_chasm_lib_channel_proto_v1_message_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_message_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_temporal_server_chasm_lib_channel_proto_v1_message_proto_goTypes, + DependencyIndexes: file_temporal_server_chasm_lib_channel_proto_v1_message_proto_depIdxs, + MessageInfos: file_temporal_server_chasm_lib_channel_proto_v1_message_proto_msgTypes, + }.Build() + File_temporal_server_chasm_lib_channel_proto_v1_message_proto = out.File + file_temporal_server_chasm_lib_channel_proto_v1_message_proto_goTypes = nil + file_temporal_server_chasm_lib_channel_proto_v1_message_proto_depIdxs = nil +} diff --git a/chasm/lib/channel/gen/channelpb/v1/namespace.go b/chasm/lib/channel/gen/channelpb/v1/namespace.go new file mode 100644 index 00000000000..7609409c75f --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/namespace.go @@ -0,0 +1,35 @@ +package channelpb + +// Every RPC here carries its namespace inside `frontend_request`, because the +// top-level field is the namespace id the frontend resolves before routing. +// The interceptors find a request's namespace through `GetNamespace() string` +// on the request itself, and the generator cannot point that at a nested +// field, so the getters are written here. + +func (x *NotifyChannelRequest) GetNamespace() string { + return x.GetFrontendRequest().GetNamespace() +} + +func (x *RegisterChannelListenerRequest) GetNamespace() string { + return x.GetFrontendRequest().GetNamespace() +} + +func (x *UnregisterChannelListenerRequest) GetNamespace() string { + return x.GetFrontendRequest().GetNamespace() +} + +func (x *PollChannelRequest) GetNamespace() string { + return x.GetFrontendRequest().GetNamespace() +} + +func (x *DescribeChannelRequest) GetNamespace() string { + return x.GetFrontendRequest().GetNamespace() +} + +func (x *RegisterWorkflowListenerRequest) GetNamespace() string { + return x.GetFrontendRequest().GetNamespace() +} + +func (x *DeliverChannelNotificationRequest) GetNamespace() string { + return x.GetFrontendRequest().GetNamespace() +} diff --git a/chasm/lib/channel/gen/channelpb/v1/namespace_test.go b/chasm/lib/channel/gen/channelpb/v1/namespace_test.go new file mode 100644 index 00000000000..3b4af3fd53d --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/namespace_test.go @@ -0,0 +1,38 @@ +package channelpb + +import ( + "testing" + + "go.temporal.io/server/common/rpc/interceptor" + "google.golang.org/protobuf/reflect/protoregistry" +) + +// Every request carrying a frontend_request must expose its namespace to the +// interceptors. Driven off the descriptor rather than a hand-written list so a +// new RPC fails here instead of silently opting out of namespace rate limits, +// validation, authorization and redirection. +func TestEveryRoutedRequestExposesNamespace(t *testing.T) { + fd := File_temporal_server_chasm_lib_channel_proto_v1_request_response_proto + messages := fd.Messages() + + checked := 0 + for i := 0; i < messages.Len(); i++ { + md := messages.Get(i) + if md.Fields().ByName("frontend_request") == nil { + continue + } + mt, err := protoregistry.GlobalTypes.FindMessageByName(md.FullName()) + if err != nil { + t.Fatalf("%s is not registered: %v", md.FullName(), err) + } + msg := mt.New().Interface() + if _, ok := msg.(interceptor.NamespaceNameGetter); !ok { + t.Errorf("%s has a frontend_request but no GetNamespace; add it in namespace.go", md.FullName()) + } + checked++ + } + + if checked == 0 { + t.Fatal("found no routed requests, so this test is not checking anything") + } +} diff --git a/chasm/lib/channel/gen/channelpb/v1/request_response.go-helpers.pb.go b/chasm/lib/channel/gen/channelpb/v1/request_response.go-helpers.pb.go new file mode 100644 index 00000000000..da334dad83c --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/request_response.go-helpers.pb.go @@ -0,0 +1,1079 @@ +// Code generated by protoc-gen-go-helpers. DO NOT EDIT. +package channelpb + +import ( + "google.golang.org/protobuf/proto" +) + +// Marshal an object of type NotifyChannelInput to the protobuf v3 wire format +func (val *NotifyChannelInput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type NotifyChannelInput from the protobuf v3 wire format +func (val *NotifyChannelInput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *NotifyChannelInput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two NotifyChannelInput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *NotifyChannelInput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *NotifyChannelInput + switch t := that.(type) { + case *NotifyChannelInput: + that1 = t + case NotifyChannelInput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type NotifyChannelOutput to the protobuf v3 wire format +func (val *NotifyChannelOutput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type NotifyChannelOutput from the protobuf v3 wire format +func (val *NotifyChannelOutput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *NotifyChannelOutput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two NotifyChannelOutput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *NotifyChannelOutput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *NotifyChannelOutput + switch t := that.(type) { + case *NotifyChannelOutput: + that1 = t + case NotifyChannelOutput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type NotifyChannelRequest to the protobuf v3 wire format +func (val *NotifyChannelRequest) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type NotifyChannelRequest from the protobuf v3 wire format +func (val *NotifyChannelRequest) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *NotifyChannelRequest) Size() int { + return proto.Size(val) +} + +// Equal returns whether two NotifyChannelRequest values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *NotifyChannelRequest) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *NotifyChannelRequest + switch t := that.(type) { + case *NotifyChannelRequest: + that1 = t + case NotifyChannelRequest: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type NotifyChannelResponse to the protobuf v3 wire format +func (val *NotifyChannelResponse) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type NotifyChannelResponse from the protobuf v3 wire format +func (val *NotifyChannelResponse) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *NotifyChannelResponse) Size() int { + return proto.Size(val) +} + +// Equal returns whether two NotifyChannelResponse values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *NotifyChannelResponse) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *NotifyChannelResponse + switch t := that.(type) { + case *NotifyChannelResponse: + that1 = t + case NotifyChannelResponse: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type RegisterChannelListenerInput to the protobuf v3 wire format +func (val *RegisterChannelListenerInput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type RegisterChannelListenerInput from the protobuf v3 wire format +func (val *RegisterChannelListenerInput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *RegisterChannelListenerInput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two RegisterChannelListenerInput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *RegisterChannelListenerInput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *RegisterChannelListenerInput + switch t := that.(type) { + case *RegisterChannelListenerInput: + that1 = t + case RegisterChannelListenerInput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type RegisterChannelListenerOutput to the protobuf v3 wire format +func (val *RegisterChannelListenerOutput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type RegisterChannelListenerOutput from the protobuf v3 wire format +func (val *RegisterChannelListenerOutput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *RegisterChannelListenerOutput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two RegisterChannelListenerOutput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *RegisterChannelListenerOutput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *RegisterChannelListenerOutput + switch t := that.(type) { + case *RegisterChannelListenerOutput: + that1 = t + case RegisterChannelListenerOutput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type RegisterChannelListenerRequest to the protobuf v3 wire format +func (val *RegisterChannelListenerRequest) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type RegisterChannelListenerRequest from the protobuf v3 wire format +func (val *RegisterChannelListenerRequest) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *RegisterChannelListenerRequest) Size() int { + return proto.Size(val) +} + +// Equal returns whether two RegisterChannelListenerRequest values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *RegisterChannelListenerRequest) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *RegisterChannelListenerRequest + switch t := that.(type) { + case *RegisterChannelListenerRequest: + that1 = t + case RegisterChannelListenerRequest: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type RegisterChannelListenerResponse to the protobuf v3 wire format +func (val *RegisterChannelListenerResponse) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type RegisterChannelListenerResponse from the protobuf v3 wire format +func (val *RegisterChannelListenerResponse) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *RegisterChannelListenerResponse) Size() int { + return proto.Size(val) +} + +// Equal returns whether two RegisterChannelListenerResponse values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *RegisterChannelListenerResponse) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *RegisterChannelListenerResponse + switch t := that.(type) { + case *RegisterChannelListenerResponse: + that1 = t + case RegisterChannelListenerResponse: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type UnregisterChannelListenerInput to the protobuf v3 wire format +func (val *UnregisterChannelListenerInput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type UnregisterChannelListenerInput from the protobuf v3 wire format +func (val *UnregisterChannelListenerInput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *UnregisterChannelListenerInput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two UnregisterChannelListenerInput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *UnregisterChannelListenerInput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *UnregisterChannelListenerInput + switch t := that.(type) { + case *UnregisterChannelListenerInput: + that1 = t + case UnregisterChannelListenerInput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type UnregisterChannelListenerOutput to the protobuf v3 wire format +func (val *UnregisterChannelListenerOutput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type UnregisterChannelListenerOutput from the protobuf v3 wire format +func (val *UnregisterChannelListenerOutput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *UnregisterChannelListenerOutput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two UnregisterChannelListenerOutput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *UnregisterChannelListenerOutput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *UnregisterChannelListenerOutput + switch t := that.(type) { + case *UnregisterChannelListenerOutput: + that1 = t + case UnregisterChannelListenerOutput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type UnregisterChannelListenerRequest to the protobuf v3 wire format +func (val *UnregisterChannelListenerRequest) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type UnregisterChannelListenerRequest from the protobuf v3 wire format +func (val *UnregisterChannelListenerRequest) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *UnregisterChannelListenerRequest) Size() int { + return proto.Size(val) +} + +// Equal returns whether two UnregisterChannelListenerRequest values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *UnregisterChannelListenerRequest) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *UnregisterChannelListenerRequest + switch t := that.(type) { + case *UnregisterChannelListenerRequest: + that1 = t + case UnregisterChannelListenerRequest: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type UnregisterChannelListenerResponse to the protobuf v3 wire format +func (val *UnregisterChannelListenerResponse) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type UnregisterChannelListenerResponse from the protobuf v3 wire format +func (val *UnregisterChannelListenerResponse) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *UnregisterChannelListenerResponse) Size() int { + return proto.Size(val) +} + +// Equal returns whether two UnregisterChannelListenerResponse values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *UnregisterChannelListenerResponse) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *UnregisterChannelListenerResponse + switch t := that.(type) { + case *UnregisterChannelListenerResponse: + that1 = t + case UnregisterChannelListenerResponse: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type PollChannelInput to the protobuf v3 wire format +func (val *PollChannelInput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type PollChannelInput from the protobuf v3 wire format +func (val *PollChannelInput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *PollChannelInput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two PollChannelInput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *PollChannelInput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *PollChannelInput + switch t := that.(type) { + case *PollChannelInput: + that1 = t + case PollChannelInput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type PollChannelOutput to the protobuf v3 wire format +func (val *PollChannelOutput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type PollChannelOutput from the protobuf v3 wire format +func (val *PollChannelOutput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *PollChannelOutput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two PollChannelOutput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *PollChannelOutput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *PollChannelOutput + switch t := that.(type) { + case *PollChannelOutput: + that1 = t + case PollChannelOutput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type PollChannelRequest to the protobuf v3 wire format +func (val *PollChannelRequest) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type PollChannelRequest from the protobuf v3 wire format +func (val *PollChannelRequest) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *PollChannelRequest) Size() int { + return proto.Size(val) +} + +// Equal returns whether two PollChannelRequest values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *PollChannelRequest) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *PollChannelRequest + switch t := that.(type) { + case *PollChannelRequest: + that1 = t + case PollChannelRequest: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type PollChannelResponse to the protobuf v3 wire format +func (val *PollChannelResponse) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type PollChannelResponse from the protobuf v3 wire format +func (val *PollChannelResponse) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *PollChannelResponse) Size() int { + return proto.Size(val) +} + +// Equal returns whether two PollChannelResponse values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *PollChannelResponse) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *PollChannelResponse + switch t := that.(type) { + case *PollChannelResponse: + that1 = t + case PollChannelResponse: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type DescribeChannelInput to the protobuf v3 wire format +func (val *DescribeChannelInput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type DescribeChannelInput from the protobuf v3 wire format +func (val *DescribeChannelInput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *DescribeChannelInput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two DescribeChannelInput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *DescribeChannelInput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *DescribeChannelInput + switch t := that.(type) { + case *DescribeChannelInput: + that1 = t + case DescribeChannelInput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type ChannelListenerInfo to the protobuf v3 wire format +func (val *ChannelListenerInfo) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type ChannelListenerInfo from the protobuf v3 wire format +func (val *ChannelListenerInfo) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *ChannelListenerInfo) Size() int { + return proto.Size(val) +} + +// Equal returns whether two ChannelListenerInfo values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *ChannelListenerInfo) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *ChannelListenerInfo + switch t := that.(type) { + case *ChannelListenerInfo: + that1 = t + case ChannelListenerInfo: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type DescribeChannelOutput to the protobuf v3 wire format +func (val *DescribeChannelOutput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type DescribeChannelOutput from the protobuf v3 wire format +func (val *DescribeChannelOutput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *DescribeChannelOutput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two DescribeChannelOutput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *DescribeChannelOutput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *DescribeChannelOutput + switch t := that.(type) { + case *DescribeChannelOutput: + that1 = t + case DescribeChannelOutput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type DescribeChannelRequest to the protobuf v3 wire format +func (val *DescribeChannelRequest) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type DescribeChannelRequest from the protobuf v3 wire format +func (val *DescribeChannelRequest) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *DescribeChannelRequest) Size() int { + return proto.Size(val) +} + +// Equal returns whether two DescribeChannelRequest values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *DescribeChannelRequest) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *DescribeChannelRequest + switch t := that.(type) { + case *DescribeChannelRequest: + that1 = t + case DescribeChannelRequest: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type DescribeChannelResponse to the protobuf v3 wire format +func (val *DescribeChannelResponse) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type DescribeChannelResponse from the protobuf v3 wire format +func (val *DescribeChannelResponse) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *DescribeChannelResponse) Size() int { + return proto.Size(val) +} + +// Equal returns whether two DescribeChannelResponse values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *DescribeChannelResponse) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *DescribeChannelResponse + switch t := that.(type) { + case *DescribeChannelResponse: + that1 = t + case DescribeChannelResponse: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type RegisterWorkflowListenerInput to the protobuf v3 wire format +func (val *RegisterWorkflowListenerInput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type RegisterWorkflowListenerInput from the protobuf v3 wire format +func (val *RegisterWorkflowListenerInput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *RegisterWorkflowListenerInput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two RegisterWorkflowListenerInput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *RegisterWorkflowListenerInput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *RegisterWorkflowListenerInput + switch t := that.(type) { + case *RegisterWorkflowListenerInput: + that1 = t + case RegisterWorkflowListenerInput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type RegisterWorkflowListenerOutput to the protobuf v3 wire format +func (val *RegisterWorkflowListenerOutput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type RegisterWorkflowListenerOutput from the protobuf v3 wire format +func (val *RegisterWorkflowListenerOutput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *RegisterWorkflowListenerOutput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two RegisterWorkflowListenerOutput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *RegisterWorkflowListenerOutput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *RegisterWorkflowListenerOutput + switch t := that.(type) { + case *RegisterWorkflowListenerOutput: + that1 = t + case RegisterWorkflowListenerOutput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type RegisterWorkflowListenerRequest to the protobuf v3 wire format +func (val *RegisterWorkflowListenerRequest) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type RegisterWorkflowListenerRequest from the protobuf v3 wire format +func (val *RegisterWorkflowListenerRequest) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *RegisterWorkflowListenerRequest) Size() int { + return proto.Size(val) +} + +// Equal returns whether two RegisterWorkflowListenerRequest values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *RegisterWorkflowListenerRequest) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *RegisterWorkflowListenerRequest + switch t := that.(type) { + case *RegisterWorkflowListenerRequest: + that1 = t + case RegisterWorkflowListenerRequest: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type RegisterWorkflowListenerResponse to the protobuf v3 wire format +func (val *RegisterWorkflowListenerResponse) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type RegisterWorkflowListenerResponse from the protobuf v3 wire format +func (val *RegisterWorkflowListenerResponse) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *RegisterWorkflowListenerResponse) Size() int { + return proto.Size(val) +} + +// Equal returns whether two RegisterWorkflowListenerResponse values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *RegisterWorkflowListenerResponse) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *RegisterWorkflowListenerResponse + switch t := that.(type) { + case *RegisterWorkflowListenerResponse: + that1 = t + case RegisterWorkflowListenerResponse: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type DeliverChannelNotificationInput to the protobuf v3 wire format +func (val *DeliverChannelNotificationInput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type DeliverChannelNotificationInput from the protobuf v3 wire format +func (val *DeliverChannelNotificationInput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *DeliverChannelNotificationInput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two DeliverChannelNotificationInput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *DeliverChannelNotificationInput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *DeliverChannelNotificationInput + switch t := that.(type) { + case *DeliverChannelNotificationInput: + that1 = t + case DeliverChannelNotificationInput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type DeliverChannelNotificationOutput to the protobuf v3 wire format +func (val *DeliverChannelNotificationOutput) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type DeliverChannelNotificationOutput from the protobuf v3 wire format +func (val *DeliverChannelNotificationOutput) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *DeliverChannelNotificationOutput) Size() int { + return proto.Size(val) +} + +// Equal returns whether two DeliverChannelNotificationOutput values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *DeliverChannelNotificationOutput) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *DeliverChannelNotificationOutput + switch t := that.(type) { + case *DeliverChannelNotificationOutput: + that1 = t + case DeliverChannelNotificationOutput: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type DeliverChannelNotificationRequest to the protobuf v3 wire format +func (val *DeliverChannelNotificationRequest) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type DeliverChannelNotificationRequest from the protobuf v3 wire format +func (val *DeliverChannelNotificationRequest) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *DeliverChannelNotificationRequest) Size() int { + return proto.Size(val) +} + +// Equal returns whether two DeliverChannelNotificationRequest values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *DeliverChannelNotificationRequest) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *DeliverChannelNotificationRequest + switch t := that.(type) { + case *DeliverChannelNotificationRequest: + that1 = t + case DeliverChannelNotificationRequest: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type DeliverChannelNotificationResponse to the protobuf v3 wire format +func (val *DeliverChannelNotificationResponse) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type DeliverChannelNotificationResponse from the protobuf v3 wire format +func (val *DeliverChannelNotificationResponse) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *DeliverChannelNotificationResponse) Size() int { + return proto.Size(val) +} + +// Equal returns whether two DeliverChannelNotificationResponse values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *DeliverChannelNotificationResponse) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *DeliverChannelNotificationResponse + switch t := that.(type) { + case *DeliverChannelNotificationResponse: + that1 = t + case DeliverChannelNotificationResponse: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} diff --git a/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go b/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go new file mode 100644 index 00000000000..bb6a9cc8140 --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go @@ -0,0 +1,1895 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// plugins: +// protoc-gen-go +// protoc +// source: temporal/server/chasm/lib/channel/proto/v1/request_response.proto + +package channelpb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + v1 "go.temporal.io/api/common/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type NotifyChannelInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + Notification *Notification `protobuf:"bytes,2,opt,name=notification,proto3" json:"notification,omitempty"` + Identity string `protobuf:"bytes,3,opt,name=identity,proto3" json:"identity,omitempty"` + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotifyChannelInput) Reset() { + *x = NotifyChannelInput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotifyChannelInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotifyChannelInput) ProtoMessage() {} + +func (x *NotifyChannelInput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotifyChannelInput.ProtoReflect.Descriptor instead. +func (*NotifyChannelInput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{0} +} + +func (x *NotifyChannelInput) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *NotifyChannelInput) GetNotification() *Notification { + if x != nil { + return x.Notification + } + return nil +} + +func (x *NotifyChannelInput) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +func (x *NotifyChannelInput) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +type NotifyChannelOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Listeners registered when the notification was accepted. + ListenerCount int64 `protobuf:"varint,1,opt,name=listener_count,json=listenerCount,proto3" json:"listener_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotifyChannelOutput) Reset() { + *x = NotifyChannelOutput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotifyChannelOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotifyChannelOutput) ProtoMessage() {} + +func (x *NotifyChannelOutput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotifyChannelOutput.ProtoReflect.Descriptor instead. +func (*NotifyChannelOutput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{1} +} + +func (x *NotifyChannelOutput) GetListenerCount() int64 { + if x != nil { + return x.ListenerCount + } + return 0 +} + +type NotifyChannelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NamespaceId string `protobuf:"bytes,1,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"` + FrontendRequest *NotifyChannelInput `protobuf:"bytes,2,opt,name=frontend_request,json=frontendRequest,proto3" json:"frontend_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotifyChannelRequest) Reset() { + *x = NotifyChannelRequest{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotifyChannelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotifyChannelRequest) ProtoMessage() {} + +func (x *NotifyChannelRequest) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotifyChannelRequest.ProtoReflect.Descriptor instead. +func (*NotifyChannelRequest) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{2} +} + +func (x *NotifyChannelRequest) GetNamespaceId() string { + if x != nil { + return x.NamespaceId + } + return "" +} + +func (x *NotifyChannelRequest) GetFrontendRequest() *NotifyChannelInput { + if x != nil { + return x.FrontendRequest + } + return nil +} + +type NotifyChannelResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FrontendResponse *NotifyChannelOutput `protobuf:"bytes,1,opt,name=frontend_response,json=frontendResponse,proto3" json:"frontend_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotifyChannelResponse) Reset() { + *x = NotifyChannelResponse{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotifyChannelResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotifyChannelResponse) ProtoMessage() {} + +func (x *NotifyChannelResponse) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotifyChannelResponse.ProtoReflect.Descriptor instead. +func (*NotifyChannelResponse) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{3} +} + +func (x *NotifyChannelResponse) GetFrontendResponse() *NotifyChannelOutput { + if x != nil { + return x.FrontendResponse + } + return nil +} + +type RegisterChannelListenerInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` + Callback *v1.Callback `protobuf:"bytes,3,opt,name=callback,proto3" json:"callback,omitempty"` + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + Identity string `protobuf:"bytes,5,opt,name=identity,proto3" json:"identity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterChannelListenerInput) Reset() { + *x = RegisterChannelListenerInput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterChannelListenerInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterChannelListenerInput) ProtoMessage() {} + +func (x *RegisterChannelListenerInput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterChannelListenerInput.ProtoReflect.Descriptor instead. +func (*RegisterChannelListenerInput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{4} +} + +func (x *RegisterChannelListenerInput) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *RegisterChannelListenerInput) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +func (x *RegisterChannelListenerInput) GetCallback() *v1.Callback { + if x != nil { + return x.Callback + } + return nil +} + +func (x *RegisterChannelListenerInput) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *RegisterChannelListenerInput) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +type RegisterChannelListenerOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + ListenerId string `protobuf:"bytes,1,opt,name=listener_id,json=listenerId,proto3" json:"listener_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterChannelListenerOutput) Reset() { + *x = RegisterChannelListenerOutput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterChannelListenerOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterChannelListenerOutput) ProtoMessage() {} + +func (x *RegisterChannelListenerOutput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterChannelListenerOutput.ProtoReflect.Descriptor instead. +func (*RegisterChannelListenerOutput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{5} +} + +func (x *RegisterChannelListenerOutput) GetListenerId() string { + if x != nil { + return x.ListenerId + } + return "" +} + +type RegisterChannelListenerRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NamespaceId string `protobuf:"bytes,1,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"` + FrontendRequest *RegisterChannelListenerInput `protobuf:"bytes,2,opt,name=frontend_request,json=frontendRequest,proto3" json:"frontend_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterChannelListenerRequest) Reset() { + *x = RegisterChannelListenerRequest{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterChannelListenerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterChannelListenerRequest) ProtoMessage() {} + +func (x *RegisterChannelListenerRequest) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterChannelListenerRequest.ProtoReflect.Descriptor instead. +func (*RegisterChannelListenerRequest) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{6} +} + +func (x *RegisterChannelListenerRequest) GetNamespaceId() string { + if x != nil { + return x.NamespaceId + } + return "" +} + +func (x *RegisterChannelListenerRequest) GetFrontendRequest() *RegisterChannelListenerInput { + if x != nil { + return x.FrontendRequest + } + return nil +} + +type RegisterChannelListenerResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FrontendResponse *RegisterChannelListenerOutput `protobuf:"bytes,1,opt,name=frontend_response,json=frontendResponse,proto3" json:"frontend_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterChannelListenerResponse) Reset() { + *x = RegisterChannelListenerResponse{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterChannelListenerResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterChannelListenerResponse) ProtoMessage() {} + +func (x *RegisterChannelListenerResponse) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterChannelListenerResponse.ProtoReflect.Descriptor instead. +func (*RegisterChannelListenerResponse) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{7} +} + +func (x *RegisterChannelListenerResponse) GetFrontendResponse() *RegisterChannelListenerOutput { + if x != nil { + return x.FrontendResponse + } + return nil +} + +type UnregisterChannelListenerInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` + ListenerId string `protobuf:"bytes,3,opt,name=listener_id,json=listenerId,proto3" json:"listener_id,omitempty"` + Identity string `protobuf:"bytes,4,opt,name=identity,proto3" json:"identity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnregisterChannelListenerInput) Reset() { + *x = UnregisterChannelListenerInput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnregisterChannelListenerInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnregisterChannelListenerInput) ProtoMessage() {} + +func (x *UnregisterChannelListenerInput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnregisterChannelListenerInput.ProtoReflect.Descriptor instead. +func (*UnregisterChannelListenerInput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{8} +} + +func (x *UnregisterChannelListenerInput) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *UnregisterChannelListenerInput) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +func (x *UnregisterChannelListenerInput) GetListenerId() string { + if x != nil { + return x.ListenerId + } + return "" +} + +func (x *UnregisterChannelListenerInput) GetIdentity() string { + if x != nil { + return x.Identity + } + return "" +} + +type UnregisterChannelListenerOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnregisterChannelListenerOutput) Reset() { + *x = UnregisterChannelListenerOutput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnregisterChannelListenerOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnregisterChannelListenerOutput) ProtoMessage() {} + +func (x *UnregisterChannelListenerOutput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnregisterChannelListenerOutput.ProtoReflect.Descriptor instead. +func (*UnregisterChannelListenerOutput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{9} +} + +type UnregisterChannelListenerRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NamespaceId string `protobuf:"bytes,1,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"` + FrontendRequest *UnregisterChannelListenerInput `protobuf:"bytes,2,opt,name=frontend_request,json=frontendRequest,proto3" json:"frontend_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnregisterChannelListenerRequest) Reset() { + *x = UnregisterChannelListenerRequest{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnregisterChannelListenerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnregisterChannelListenerRequest) ProtoMessage() {} + +func (x *UnregisterChannelListenerRequest) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnregisterChannelListenerRequest.ProtoReflect.Descriptor instead. +func (*UnregisterChannelListenerRequest) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{10} +} + +func (x *UnregisterChannelListenerRequest) GetNamespaceId() string { + if x != nil { + return x.NamespaceId + } + return "" +} + +func (x *UnregisterChannelListenerRequest) GetFrontendRequest() *UnregisterChannelListenerInput { + if x != nil { + return x.FrontendRequest + } + return nil +} + +type UnregisterChannelListenerResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FrontendResponse *UnregisterChannelListenerOutput `protobuf:"bytes,1,opt,name=frontend_response,json=frontendResponse,proto3" json:"frontend_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnregisterChannelListenerResponse) Reset() { + *x = UnregisterChannelListenerResponse{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnregisterChannelListenerResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnregisterChannelListenerResponse) ProtoMessage() {} + +func (x *UnregisterChannelListenerResponse) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnregisterChannelListenerResponse.ProtoReflect.Descriptor instead. +func (*UnregisterChannelListenerResponse) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{11} +} + +func (x *UnregisterChannelListenerResponse) GetFrontendResponse() *UnregisterChannelListenerOutput { + if x != nil { + return x.FrontendResponse + } + return nil +} + +type PollChannelInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` + // Return retained notifications with a counter above this one. + AfterCounter int64 `protobuf:"varint,3,opt,name=after_counter,json=afterCounter,proto3" json:"after_counter,omitempty"` + // Block until one arrives when none is retained above after_counter. + Wait bool `protobuf:"varint,4,opt,name=wait,proto3" json:"wait,omitempty"` + // Zero means the server's page size. + MaxNotifications int32 `protobuf:"varint,5,opt,name=max_notifications,json=maxNotifications,proto3" json:"max_notifications,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PollChannelInput) Reset() { + *x = PollChannelInput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PollChannelInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PollChannelInput) ProtoMessage() {} + +func (x *PollChannelInput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PollChannelInput.ProtoReflect.Descriptor instead. +func (*PollChannelInput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{12} +} + +func (x *PollChannelInput) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *PollChannelInput) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +func (x *PollChannelInput) GetAfterCounter() int64 { + if x != nil { + return x.AfterCounter + } + return 0 +} + +func (x *PollChannelInput) GetWait() bool { + if x != nil { + return x.Wait + } + return false +} + +func (x *PollChannelInput) GetMaxNotifications() int32 { + if x != nil { + return x.MaxNotifications + } + return 0 +} + +type PollChannelOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Notifications []*Notification `protobuf:"bytes,1,rep,name=notifications,proto3" json:"notifications,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PollChannelOutput) Reset() { + *x = PollChannelOutput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PollChannelOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PollChannelOutput) ProtoMessage() {} + +func (x *PollChannelOutput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PollChannelOutput.ProtoReflect.Descriptor instead. +func (*PollChannelOutput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{13} +} + +func (x *PollChannelOutput) GetNotifications() []*Notification { + if x != nil { + return x.Notifications + } + return nil +} + +type PollChannelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NamespaceId string `protobuf:"bytes,1,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"` + FrontendRequest *PollChannelInput `protobuf:"bytes,2,opt,name=frontend_request,json=frontendRequest,proto3" json:"frontend_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PollChannelRequest) Reset() { + *x = PollChannelRequest{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PollChannelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PollChannelRequest) ProtoMessage() {} + +func (x *PollChannelRequest) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PollChannelRequest.ProtoReflect.Descriptor instead. +func (*PollChannelRequest) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{14} +} + +func (x *PollChannelRequest) GetNamespaceId() string { + if x != nil { + return x.NamespaceId + } + return "" +} + +func (x *PollChannelRequest) GetFrontendRequest() *PollChannelInput { + if x != nil { + return x.FrontendRequest + } + return nil +} + +type PollChannelResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FrontendResponse *PollChannelOutput `protobuf:"bytes,1,opt,name=frontend_response,json=frontendResponse,proto3" json:"frontend_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PollChannelResponse) Reset() { + *x = PollChannelResponse{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PollChannelResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PollChannelResponse) ProtoMessage() {} + +func (x *PollChannelResponse) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PollChannelResponse.ProtoReflect.Descriptor instead. +func (*PollChannelResponse) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{15} +} + +func (x *PollChannelResponse) GetFrontendResponse() *PollChannelOutput { + if x != nil { + return x.FrontendResponse + } + return nil +} + +type DescribeChannelInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeChannelInput) Reset() { + *x = DescribeChannelInput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeChannelInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeChannelInput) ProtoMessage() {} + +func (x *DescribeChannelInput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DescribeChannelInput.ProtoReflect.Descriptor instead. +func (*DescribeChannelInput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{16} +} + +func (x *DescribeChannelInput) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *DescribeChannelInput) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +// ChannelListenerInfo has the fields of temporal.api.notification.v1.ChannelListener. +type ChannelListenerInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + ListenerId string `protobuf:"bytes,1,opt,name=listener_id,json=listenerId,proto3" json:"listener_id,omitempty"` + // Types that are valid to be assigned to Variant: + // + // *ChannelListenerInfo_Workflow_ + // *ChannelListenerInfo_Callback + Variant isChannelListenerInfo_Variant `protobuf_oneof:"variant"` + RegisteredTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=registered_time,json=registeredTime,proto3" json:"registered_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelListenerInfo) Reset() { + *x = ChannelListenerInfo{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelListenerInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelListenerInfo) ProtoMessage() {} + +func (x *ChannelListenerInfo) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelListenerInfo.ProtoReflect.Descriptor instead. +func (*ChannelListenerInfo) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{17} +} + +func (x *ChannelListenerInfo) GetListenerId() string { + if x != nil { + return x.ListenerId + } + return "" +} + +func (x *ChannelListenerInfo) GetVariant() isChannelListenerInfo_Variant { + if x != nil { + return x.Variant + } + return nil +} + +func (x *ChannelListenerInfo) GetWorkflow() *ChannelListenerInfo_Workflow { + if x != nil { + if x, ok := x.Variant.(*ChannelListenerInfo_Workflow_); ok { + return x.Workflow + } + } + return nil +} + +func (x *ChannelListenerInfo) GetCallback() *v1.Callback { + if x != nil { + if x, ok := x.Variant.(*ChannelListenerInfo_Callback); ok { + return x.Callback + } + } + return nil +} + +func (x *ChannelListenerInfo) GetRegisteredTime() *timestamppb.Timestamp { + if x != nil { + return x.RegisteredTime + } + return nil +} + +type isChannelListenerInfo_Variant interface { + isChannelListenerInfo_Variant() +} + +type ChannelListenerInfo_Workflow_ struct { + Workflow *ChannelListenerInfo_Workflow `protobuf:"bytes,2,opt,name=workflow,proto3,oneof"` +} + +type ChannelListenerInfo_Callback struct { + Callback *v1.Callback `protobuf:"bytes,3,opt,name=callback,proto3,oneof"` +} + +func (*ChannelListenerInfo_Workflow_) isChannelListenerInfo_Variant() {} + +func (*ChannelListenerInfo_Callback) isChannelListenerInfo_Variant() {} + +type DescribeChannelOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Listeners []*ChannelListenerInfo `protobuf:"bytes,1,rep,name=listeners,proto3" json:"listeners,omitempty"` + Latest *Notification `protobuf:"bytes,2,opt,name=latest,proto3" json:"latest,omitempty"` + RetainedCount int64 `protobuf:"varint,3,opt,name=retained_count,json=retainedCount,proto3" json:"retained_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeChannelOutput) Reset() { + *x = DescribeChannelOutput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeChannelOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeChannelOutput) ProtoMessage() {} + +func (x *DescribeChannelOutput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DescribeChannelOutput.ProtoReflect.Descriptor instead. +func (*DescribeChannelOutput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{18} +} + +func (x *DescribeChannelOutput) GetListeners() []*ChannelListenerInfo { + if x != nil { + return x.Listeners + } + return nil +} + +func (x *DescribeChannelOutput) GetLatest() *Notification { + if x != nil { + return x.Latest + } + return nil +} + +func (x *DescribeChannelOutput) GetRetainedCount() int64 { + if x != nil { + return x.RetainedCount + } + return 0 +} + +type DescribeChannelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NamespaceId string `protobuf:"bytes,1,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"` + FrontendRequest *DescribeChannelInput `protobuf:"bytes,2,opt,name=frontend_request,json=frontendRequest,proto3" json:"frontend_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeChannelRequest) Reset() { + *x = DescribeChannelRequest{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeChannelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeChannelRequest) ProtoMessage() {} + +func (x *DescribeChannelRequest) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DescribeChannelRequest.ProtoReflect.Descriptor instead. +func (*DescribeChannelRequest) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{19} +} + +func (x *DescribeChannelRequest) GetNamespaceId() string { + if x != nil { + return x.NamespaceId + } + return "" +} + +func (x *DescribeChannelRequest) GetFrontendRequest() *DescribeChannelInput { + if x != nil { + return x.FrontendRequest + } + return nil +} + +type DescribeChannelResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FrontendResponse *DescribeChannelOutput `protobuf:"bytes,1,opt,name=frontend_response,json=frontendResponse,proto3" json:"frontend_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DescribeChannelResponse) Reset() { + *x = DescribeChannelResponse{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DescribeChannelResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DescribeChannelResponse) ProtoMessage() {} + +func (x *DescribeChannelResponse) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DescribeChannelResponse.ProtoReflect.Descriptor instead. +func (*DescribeChannelResponse) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{20} +} + +func (x *DescribeChannelResponse) GetFrontendResponse() *DescribeChannelOutput { + if x != nil { + return x.FrontendResponse + } + return nil +} + +type RegisterWorkflowListenerInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` + WorkflowId string `protobuf:"bytes,3,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + RunId string `protobuf:"bytes,4,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + FirstExecutionRunId string `protobuf:"bytes,5,opt,name=first_execution_run_id,json=firstExecutionRunId,proto3" json:"first_execution_run_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterWorkflowListenerInput) Reset() { + *x = RegisterWorkflowListenerInput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterWorkflowListenerInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterWorkflowListenerInput) ProtoMessage() {} + +func (x *RegisterWorkflowListenerInput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterWorkflowListenerInput.ProtoReflect.Descriptor instead. +func (*RegisterWorkflowListenerInput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{21} +} + +func (x *RegisterWorkflowListenerInput) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *RegisterWorkflowListenerInput) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +func (x *RegisterWorkflowListenerInput) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +func (x *RegisterWorkflowListenerInput) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *RegisterWorkflowListenerInput) GetFirstExecutionRunId() string { + if x != nil { + return x.FirstExecutionRunId + } + return "" +} + +type RegisterWorkflowListenerOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterWorkflowListenerOutput) Reset() { + *x = RegisterWorkflowListenerOutput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterWorkflowListenerOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterWorkflowListenerOutput) ProtoMessage() {} + +func (x *RegisterWorkflowListenerOutput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterWorkflowListenerOutput.ProtoReflect.Descriptor instead. +func (*RegisterWorkflowListenerOutput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{22} +} + +type RegisterWorkflowListenerRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NamespaceId string `protobuf:"bytes,1,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"` + FrontendRequest *RegisterWorkflowListenerInput `protobuf:"bytes,2,opt,name=frontend_request,json=frontendRequest,proto3" json:"frontend_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterWorkflowListenerRequest) Reset() { + *x = RegisterWorkflowListenerRequest{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterWorkflowListenerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterWorkflowListenerRequest) ProtoMessage() {} + +func (x *RegisterWorkflowListenerRequest) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterWorkflowListenerRequest.ProtoReflect.Descriptor instead. +func (*RegisterWorkflowListenerRequest) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{23} +} + +func (x *RegisterWorkflowListenerRequest) GetNamespaceId() string { + if x != nil { + return x.NamespaceId + } + return "" +} + +func (x *RegisterWorkflowListenerRequest) GetFrontendRequest() *RegisterWorkflowListenerInput { + if x != nil { + return x.FrontendRequest + } + return nil +} + +type RegisterWorkflowListenerResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FrontendResponse *RegisterWorkflowListenerOutput `protobuf:"bytes,1,opt,name=frontend_response,json=frontendResponse,proto3" json:"frontend_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterWorkflowListenerResponse) Reset() { + *x = RegisterWorkflowListenerResponse{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterWorkflowListenerResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterWorkflowListenerResponse) ProtoMessage() {} + +func (x *RegisterWorkflowListenerResponse) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterWorkflowListenerResponse.ProtoReflect.Descriptor instead. +func (*RegisterWorkflowListenerResponse) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{24} +} + +func (x *RegisterWorkflowListenerResponse) GetFrontendResponse() *RegisterWorkflowListenerOutput { + if x != nil { + return x.FrontendResponse + } + return nil +} + +type DeliverChannelNotificationInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + WorkflowId string `protobuf:"bytes,2,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + // The run the channel has on record. If it is closed, the current run is + // asked instead. + RunId string `protobuf:"bytes,3,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + Notification *Notification `protobuf:"bytes,4,opt,name=notification,proto3" json:"notification,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeliverChannelNotificationInput) Reset() { + *x = DeliverChannelNotificationInput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeliverChannelNotificationInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeliverChannelNotificationInput) ProtoMessage() {} + +func (x *DeliverChannelNotificationInput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeliverChannelNotificationInput.ProtoReflect.Descriptor instead. +func (*DeliverChannelNotificationInput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{25} +} + +func (x *DeliverChannelNotificationInput) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *DeliverChannelNotificationInput) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +func (x *DeliverChannelNotificationInput) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *DeliverChannelNotificationInput) GetNotification() *Notification { + if x != nil { + return x.Notification + } + return nil +} + +type DeliverChannelNotificationOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // No run of the workflow listens to the channel any more, so the channel + // drops the listener. + ListenerClosed bool `protobuf:"varint,1,opt,name=listener_closed,json=listenerClosed,proto3" json:"listener_closed,omitempty"` + // The run on record is closed and the current run listens, so the channel + // re-keys the listener to it. + SuccessorRunId string `protobuf:"bytes,2,opt,name=successor_run_id,json=successorRunId,proto3" json:"successor_run_id,omitempty"` + // The notification joined one the run had not seen yet. + Folded bool `protobuf:"varint,3,opt,name=folded,proto3" json:"folded,omitempty"` + // The run had already seen this counter, so nothing changed. + Duplicate bool `protobuf:"varint,4,opt,name=duplicate,proto3" json:"duplicate,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeliverChannelNotificationOutput) Reset() { + *x = DeliverChannelNotificationOutput{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeliverChannelNotificationOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeliverChannelNotificationOutput) ProtoMessage() {} + +func (x *DeliverChannelNotificationOutput) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeliverChannelNotificationOutput.ProtoReflect.Descriptor instead. +func (*DeliverChannelNotificationOutput) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{26} +} + +func (x *DeliverChannelNotificationOutput) GetListenerClosed() bool { + if x != nil { + return x.ListenerClosed + } + return false +} + +func (x *DeliverChannelNotificationOutput) GetSuccessorRunId() string { + if x != nil { + return x.SuccessorRunId + } + return "" +} + +func (x *DeliverChannelNotificationOutput) GetFolded() bool { + if x != nil { + return x.Folded + } + return false +} + +func (x *DeliverChannelNotificationOutput) GetDuplicate() bool { + if x != nil { + return x.Duplicate + } + return false +} + +type DeliverChannelNotificationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NamespaceId string `protobuf:"bytes,1,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"` + FrontendRequest *DeliverChannelNotificationInput `protobuf:"bytes,2,opt,name=frontend_request,json=frontendRequest,proto3" json:"frontend_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeliverChannelNotificationRequest) Reset() { + *x = DeliverChannelNotificationRequest{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeliverChannelNotificationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeliverChannelNotificationRequest) ProtoMessage() {} + +func (x *DeliverChannelNotificationRequest) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeliverChannelNotificationRequest.ProtoReflect.Descriptor instead. +func (*DeliverChannelNotificationRequest) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{27} +} + +func (x *DeliverChannelNotificationRequest) GetNamespaceId() string { + if x != nil { + return x.NamespaceId + } + return "" +} + +func (x *DeliverChannelNotificationRequest) GetFrontendRequest() *DeliverChannelNotificationInput { + if x != nil { + return x.FrontendRequest + } + return nil +} + +type DeliverChannelNotificationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FrontendResponse *DeliverChannelNotificationOutput `protobuf:"bytes,1,opt,name=frontend_response,json=frontendResponse,proto3" json:"frontend_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeliverChannelNotificationResponse) Reset() { + *x = DeliverChannelNotificationResponse{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeliverChannelNotificationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeliverChannelNotificationResponse) ProtoMessage() {} + +func (x *DeliverChannelNotificationResponse) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeliverChannelNotificationResponse.ProtoReflect.Descriptor instead. +func (*DeliverChannelNotificationResponse) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{28} +} + +func (x *DeliverChannelNotificationResponse) GetFrontendResponse() *DeliverChannelNotificationOutput { + if x != nil { + return x.FrontendResponse + } + return nil +} + +type ChannelListenerInfo_Workflow struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkflowId string `protobuf:"bytes,1,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + RunId string `protobuf:"bytes,2,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelListenerInfo_Workflow) Reset() { + *x = ChannelListenerInfo_Workflow{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelListenerInfo_Workflow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelListenerInfo_Workflow) ProtoMessage() {} + +func (x *ChannelListenerInfo_Workflow) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelListenerInfo_Workflow.ProtoReflect.Descriptor instead. +func (*ChannelListenerInfo_Workflow) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{17, 0} +} + +func (x *ChannelListenerInfo_Workflow) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +func (x *ChannelListenerInfo_Workflow) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +var File_temporal_server_chasm_lib_channel_proto_v1_request_response_proto protoreflect.FileDescriptor + +const file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDesc = "" + + "\n" + + "Atemporal/server/chasm/lib/channel/proto/v1/request_response.proto\x12*temporal.server.chasm.lib.channel.proto.v1\x1a8temporal/server/chasm/lib/channel/proto/v1/message.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\"\xcb\x01\n" + + "\x12NotifyChannelInput\x12\x1c\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\\\n" + + "\fnotification\x18\x02 \x01(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\fnotification\x12\x1a\n" + + "\bidentity\x18\x03 \x01(\tR\bidentity\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestId\"<\n" + + "\x13NotifyChannelOutput\x12%\n" + + "\x0elistener_count\x18\x01 \x01(\x03R\rlistenerCount\"\xa4\x01\n" + + "\x14NotifyChannelRequest\x12!\n" + + "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12i\n" + + "\x10frontend_request\x18\x02 \x01(\v2>.temporal.server.chasm.lib.channel.proto.v1.NotifyChannelInputR\x0ffrontendRequest\"\x85\x01\n" + + "\x15NotifyChannelResponse\x12l\n" + + "\x11frontend_response\x18\x01 \x01(\v2?.temporal.server.chasm.lib.channel.proto.v1.NotifyChannelOutputR\x10frontendResponse\"\xcf\x01\n" + + "\x1cRegisterChannelListenerInput\x12\x1c\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x18\n" + + "\achannel\x18\x02 \x01(\tR\achannel\x12<\n" + + "\bcallback\x18\x03 \x01(\v2 .temporal.api.common.v1.CallbackR\bcallback\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestId\x12\x1a\n" + + "\bidentity\x18\x05 \x01(\tR\bidentity\"@\n" + + "\x1dRegisterChannelListenerOutput\x12\x1f\n" + + "\vlistener_id\x18\x01 \x01(\tR\n" + + "listenerId\"\xb8\x01\n" + + "\x1eRegisterChannelListenerRequest\x12!\n" + + "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12s\n" + + "\x10frontend_request\x18\x02 \x01(\v2H.temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerInputR\x0ffrontendRequest\"\x99\x01\n" + + "\x1fRegisterChannelListenerResponse\x12v\n" + + "\x11frontend_response\x18\x01 \x01(\v2I.temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerOutputR\x10frontendResponse\"\x95\x01\n" + + "\x1eUnregisterChannelListenerInput\x12\x1c\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x18\n" + + "\achannel\x18\x02 \x01(\tR\achannel\x12\x1f\n" + + "\vlistener_id\x18\x03 \x01(\tR\n" + + "listenerId\x12\x1a\n" + + "\bidentity\x18\x04 \x01(\tR\bidentity\"!\n" + + "\x1fUnregisterChannelListenerOutput\"\xbc\x01\n" + + " UnregisterChannelListenerRequest\x12!\n" + + "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12u\n" + + "\x10frontend_request\x18\x02 \x01(\v2J.temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerInputR\x0ffrontendRequest\"\x9d\x01\n" + + "!UnregisterChannelListenerResponse\x12x\n" + + "\x11frontend_response\x18\x01 \x01(\v2K.temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerOutputR\x10frontendResponse\"\xb0\x01\n" + + "\x10PollChannelInput\x12\x1c\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x18\n" + + "\achannel\x18\x02 \x01(\tR\achannel\x12#\n" + + "\rafter_counter\x18\x03 \x01(\x03R\fafterCounter\x12\x12\n" + + "\x04wait\x18\x04 \x01(\bR\x04wait\x12+\n" + + "\x11max_notifications\x18\x05 \x01(\x05R\x10maxNotifications\"s\n" + + "\x11PollChannelOutput\x12^\n" + + "\rnotifications\x18\x01 \x03(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\rnotifications\"\xa0\x01\n" + + "\x12PollChannelRequest\x12!\n" + + "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12g\n" + + "\x10frontend_request\x18\x02 \x01(\v2<.temporal.server.chasm.lib.channel.proto.v1.PollChannelInputR\x0ffrontendRequest\"\x81\x01\n" + + "\x13PollChannelResponse\x12j\n" + + "\x11frontend_response\x18\x01 \x01(\v2=.temporal.server.chasm.lib.channel.proto.v1.PollChannelOutputR\x10frontendResponse\"N\n" + + "\x14DescribeChannelInput\x12\x1c\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x18\n" + + "\achannel\x18\x02 \x01(\tR\achannel\"\xf2\x02\n" + + "\x13ChannelListenerInfo\x12\x1f\n" + + "\vlistener_id\x18\x01 \x01(\tR\n" + + "listenerId\x12f\n" + + "\bworkflow\x18\x02 \x01(\v2H.temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.WorkflowH\x00R\bworkflow\x12>\n" + + "\bcallback\x18\x03 \x01(\v2 .temporal.api.common.v1.CallbackH\x00R\bcallback\x12C\n" + + "\x0fregistered_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x0eregisteredTime\x1aB\n" + + "\bWorkflow\x12\x1f\n" + + "\vworkflow_id\x18\x01 \x01(\tR\n" + + "workflowId\x12\x15\n" + + "\x06run_id\x18\x02 \x01(\tR\x05runIdB\t\n" + + "\avariant\"\xef\x01\n" + + "\x15DescribeChannelOutput\x12]\n" + + "\tlisteners\x18\x01 \x03(\v2?.temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfoR\tlisteners\x12P\n" + + "\x06latest\x18\x02 \x01(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\x06latest\x12%\n" + + "\x0eretained_count\x18\x03 \x01(\x03R\rretainedCount\"\xa8\x01\n" + + "\x16DescribeChannelRequest\x12!\n" + + "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12k\n" + + "\x10frontend_request\x18\x02 \x01(\v2@.temporal.server.chasm.lib.channel.proto.v1.DescribeChannelInputR\x0ffrontendRequest\"\x89\x01\n" + + "\x17DescribeChannelResponse\x12n\n" + + "\x11frontend_response\x18\x01 \x01(\v2A.temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutputR\x10frontendResponse\"\xc4\x01\n" + + "\x1dRegisterWorkflowListenerInput\x12\x1c\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x18\n" + + "\achannel\x18\x02 \x01(\tR\achannel\x12\x1f\n" + + "\vworkflow_id\x18\x03 \x01(\tR\n" + + "workflowId\x12\x15\n" + + "\x06run_id\x18\x04 \x01(\tR\x05runId\x123\n" + + "\x16first_execution_run_id\x18\x05 \x01(\tR\x13firstExecutionRunId\" \n" + + "\x1eRegisterWorkflowListenerOutput\"\xba\x01\n" + + "\x1fRegisterWorkflowListenerRequest\x12!\n" + + "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12t\n" + + "\x10frontend_request\x18\x02 \x01(\v2I.temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerInputR\x0ffrontendRequest\"\x9b\x01\n" + + " RegisterWorkflowListenerResponse\x12w\n" + + "\x11frontend_response\x18\x01 \x01(\v2J.temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerOutputR\x10frontendResponse\"\xd5\x01\n" + + "\x1fDeliverChannelNotificationInput\x12\x1c\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x1f\n" + + "\vworkflow_id\x18\x02 \x01(\tR\n" + + "workflowId\x12\x15\n" + + "\x06run_id\x18\x03 \x01(\tR\x05runId\x12\\\n" + + "\fnotification\x18\x04 \x01(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\fnotification\"\xab\x01\n" + + " DeliverChannelNotificationOutput\x12'\n" + + "\x0flistener_closed\x18\x01 \x01(\bR\x0elistenerClosed\x12(\n" + + "\x10successor_run_id\x18\x02 \x01(\tR\x0esuccessorRunId\x12\x16\n" + + "\x06folded\x18\x03 \x01(\bR\x06folded\x12\x1c\n" + + "\tduplicate\x18\x04 \x01(\bR\tduplicate\"\xbe\x01\n" + + "!DeliverChannelNotificationRequest\x12!\n" + + "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12v\n" + + "\x10frontend_request\x18\x02 \x01(\v2K.temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInputR\x0ffrontendRequest\"\x9f\x01\n" + + "\"DeliverChannelNotificationResponse\x12y\n" + + "\x11frontend_response\x18\x01 \x01(\v2L.temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationOutputR\x10frontendResponseBAZ?go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpbb\x06proto3" + +var ( + file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescOnce sync.Once + file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescData []byte +) + +func file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP() []byte { + file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescOnce.Do(func() { + file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDesc))) + }) + return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescData +} + +var file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes = make([]protoimpl.MessageInfo, 30) +var file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_goTypes = []any{ + (*NotifyChannelInput)(nil), // 0: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelInput + (*NotifyChannelOutput)(nil), // 1: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelOutput + (*NotifyChannelRequest)(nil), // 2: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelRequest + (*NotifyChannelResponse)(nil), // 3: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelResponse + (*RegisterChannelListenerInput)(nil), // 4: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerInput + (*RegisterChannelListenerOutput)(nil), // 5: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerOutput + (*RegisterChannelListenerRequest)(nil), // 6: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerRequest + (*RegisterChannelListenerResponse)(nil), // 7: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerResponse + (*UnregisterChannelListenerInput)(nil), // 8: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerInput + (*UnregisterChannelListenerOutput)(nil), // 9: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerOutput + (*UnregisterChannelListenerRequest)(nil), // 10: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerRequest + (*UnregisterChannelListenerResponse)(nil), // 11: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerResponse + (*PollChannelInput)(nil), // 12: temporal.server.chasm.lib.channel.proto.v1.PollChannelInput + (*PollChannelOutput)(nil), // 13: temporal.server.chasm.lib.channel.proto.v1.PollChannelOutput + (*PollChannelRequest)(nil), // 14: temporal.server.chasm.lib.channel.proto.v1.PollChannelRequest + (*PollChannelResponse)(nil), // 15: temporal.server.chasm.lib.channel.proto.v1.PollChannelResponse + (*DescribeChannelInput)(nil), // 16: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelInput + (*ChannelListenerInfo)(nil), // 17: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo + (*DescribeChannelOutput)(nil), // 18: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput + (*DescribeChannelRequest)(nil), // 19: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelRequest + (*DescribeChannelResponse)(nil), // 20: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelResponse + (*RegisterWorkflowListenerInput)(nil), // 21: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerInput + (*RegisterWorkflowListenerOutput)(nil), // 22: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerOutput + (*RegisterWorkflowListenerRequest)(nil), // 23: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest + (*RegisterWorkflowListenerResponse)(nil), // 24: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse + (*DeliverChannelNotificationInput)(nil), // 25: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput + (*DeliverChannelNotificationOutput)(nil), // 26: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationOutput + (*DeliverChannelNotificationRequest)(nil), // 27: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest + (*DeliverChannelNotificationResponse)(nil), // 28: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse + (*ChannelListenerInfo_Workflow)(nil), // 29: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.Workflow + (*Notification)(nil), // 30: temporal.server.chasm.lib.channel.proto.v1.Notification + (*v1.Callback)(nil), // 31: temporal.api.common.v1.Callback + (*timestamppb.Timestamp)(nil), // 32: google.protobuf.Timestamp +} +var file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_depIdxs = []int32{ + 30, // 0: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelInput.notification:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 0, // 1: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.NotifyChannelInput + 1, // 2: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.NotifyChannelOutput + 31, // 3: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerInput.callback:type_name -> temporal.api.common.v1.Callback + 4, // 4: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerInput + 5, // 5: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerOutput + 8, // 6: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerInput + 9, // 7: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerOutput + 30, // 8: temporal.server.chasm.lib.channel.proto.v1.PollChannelOutput.notifications:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 12, // 9: temporal.server.chasm.lib.channel.proto.v1.PollChannelRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.PollChannelInput + 13, // 10: temporal.server.chasm.lib.channel.proto.v1.PollChannelResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.PollChannelOutput + 29, // 11: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.workflow:type_name -> temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.Workflow + 31, // 12: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.callback:type_name -> temporal.api.common.v1.Callback + 32, // 13: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.registered_time:type_name -> google.protobuf.Timestamp + 17, // 14: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput.listeners:type_name -> temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo + 30, // 15: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput.latest:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 16, // 16: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelInput + 18, // 17: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput + 21, // 18: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerInput + 22, // 19: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerOutput + 30, // 20: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput.notification:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 25, // 21: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput + 26, // 22: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationOutput + 23, // [23:23] is the sub-list for method output_type + 23, // [23:23] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name +} + +func init() { file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_init() } +func file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_init() { + if File_temporal_server_chasm_lib_channel_proto_v1_request_response_proto != nil { + return + } + file_temporal_server_chasm_lib_channel_proto_v1_message_proto_init() + file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes[17].OneofWrappers = []any{ + (*ChannelListenerInfo_Workflow_)(nil), + (*ChannelListenerInfo_Callback)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 30, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_goTypes, + DependencyIndexes: file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_depIdxs, + MessageInfos: file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_msgTypes, + }.Build() + File_temporal_server_chasm_lib_channel_proto_v1_request_response_proto = out.File + file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_goTypes = nil + file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_depIdxs = nil +} diff --git a/chasm/lib/channel/gen/channelpb/v1/service.pb.go b/chasm/lib/channel/gen/channelpb/v1/service.pb.go new file mode 100644 index 00000000000..db157c165da --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/service.pb.go @@ -0,0 +1,100 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// plugins: +// protoc-gen-go +// protoc +// source: temporal/server/chasm/lib/channel/proto/v1/service.proto + +package channelpb + +import ( + reflect "reflect" + unsafe "unsafe" + + _ "go.temporal.io/server/api/common/v1" + _ "go.temporal.io/server/api/routing/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_temporal_server_chasm_lib_channel_proto_v1_service_proto protoreflect.FileDescriptor + +const file_temporal_server_chasm_lib_channel_proto_v1_service_proto_rawDesc = "" + + "\n" + + "8temporal/server/chasm/lib/channel/proto/v1/service.proto\x12*temporal.server.chasm.lib.channel.proto.v1\x1aAtemporal/server/chasm/lib/channel/proto/v1/request_response.proto\x1a0temporal/server/api/common/v1/api_category.proto\x1a.temporal/server/api/routing/v1/extension.proto2\xd6\v\n" + + "\x0eChannelService\x12\xc7\x01\n" + + "\rNotifyChannel\x12@.temporal.server.chasm.lib.channel.proto.v1.NotifyChannelRequest\x1aA.temporal.server.chasm.lib.channel.proto.v1.NotifyChannelResponse\"1\x8a\xb5\x18\x02\b\x01\xd2\xc3\x18'\x1a%frontend_request.notification.channel\x12\xd8\x01\n" + + "\x17RegisterChannelListener\x12J.temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerRequest\x1aK.temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerResponse\"$\x8a\xb5\x18\x02\b\x01\xd2\xc3\x18\x1a\x1a\x18frontend_request.channel\x12\xde\x01\n" + + "\x19UnregisterChannelListener\x12L.temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerRequest\x1aM.temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerResponse\"$\x8a\xb5\x18\x02\b\x01\xd2\xc3\x18\x1a\x1a\x18frontend_request.channel\x12\xb4\x01\n" + + "\vPollChannel\x12>.temporal.server.chasm.lib.channel.proto.v1.PollChannelRequest\x1a?.temporal.server.chasm.lib.channel.proto.v1.PollChannelResponse\"$\x8a\xb5\x18\x02\b\x02\xd2\xc3\x18\x1a\x1a\x18frontend_request.channel\x12\xc0\x01\n" + + "\x0fDescribeChannel\x12B.temporal.server.chasm.lib.channel.proto.v1.DescribeChannelRequest\x1aC.temporal.server.chasm.lib.channel.proto.v1.DescribeChannelResponse\"$\x8a\xb5\x18\x02\b\x01\xd2\xc3\x18\x1a\x1a\x18frontend_request.channel\x12\xdb\x01\n" + + "\x18RegisterWorkflowListener\x12K.temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest\x1aL.temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse\"$\x8a\xb5\x18\x02\b\x01\xd2\xc3\x18\x1a\x1a\x18frontend_request.channel\x12\xe5\x01\n" + + "\x1aDeliverChannelNotification\x12M.temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest\x1aN.temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse\"(\x8a\xb5\x18\x02\b\x01\xd2\xc3\x18\x1e\x1a\x1cfrontend_request.workflow_idBAZ?go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpbb\x06proto3" + +var file_temporal_server_chasm_lib_channel_proto_v1_service_proto_goTypes = []any{ + (*NotifyChannelRequest)(nil), // 0: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelRequest + (*RegisterChannelListenerRequest)(nil), // 1: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerRequest + (*UnregisterChannelListenerRequest)(nil), // 2: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerRequest + (*PollChannelRequest)(nil), // 3: temporal.server.chasm.lib.channel.proto.v1.PollChannelRequest + (*DescribeChannelRequest)(nil), // 4: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelRequest + (*RegisterWorkflowListenerRequest)(nil), // 5: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest + (*DeliverChannelNotificationRequest)(nil), // 6: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest + (*NotifyChannelResponse)(nil), // 7: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelResponse + (*RegisterChannelListenerResponse)(nil), // 8: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerResponse + (*UnregisterChannelListenerResponse)(nil), // 9: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerResponse + (*PollChannelResponse)(nil), // 10: temporal.server.chasm.lib.channel.proto.v1.PollChannelResponse + (*DescribeChannelResponse)(nil), // 11: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelResponse + (*RegisterWorkflowListenerResponse)(nil), // 12: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse + (*DeliverChannelNotificationResponse)(nil), // 13: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse +} +var file_temporal_server_chasm_lib_channel_proto_v1_service_proto_depIdxs = []int32{ + 0, // 0: temporal.server.chasm.lib.channel.proto.v1.ChannelService.NotifyChannel:input_type -> temporal.server.chasm.lib.channel.proto.v1.NotifyChannelRequest + 1, // 1: temporal.server.chasm.lib.channel.proto.v1.ChannelService.RegisterChannelListener:input_type -> temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerRequest + 2, // 2: temporal.server.chasm.lib.channel.proto.v1.ChannelService.UnregisterChannelListener:input_type -> temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerRequest + 3, // 3: temporal.server.chasm.lib.channel.proto.v1.ChannelService.PollChannel:input_type -> temporal.server.chasm.lib.channel.proto.v1.PollChannelRequest + 4, // 4: temporal.server.chasm.lib.channel.proto.v1.ChannelService.DescribeChannel:input_type -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelRequest + 5, // 5: temporal.server.chasm.lib.channel.proto.v1.ChannelService.RegisterWorkflowListener:input_type -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest + 6, // 6: temporal.server.chasm.lib.channel.proto.v1.ChannelService.DeliverChannelNotification:input_type -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest + 7, // 7: temporal.server.chasm.lib.channel.proto.v1.ChannelService.NotifyChannel:output_type -> temporal.server.chasm.lib.channel.proto.v1.NotifyChannelResponse + 8, // 8: temporal.server.chasm.lib.channel.proto.v1.ChannelService.RegisterChannelListener:output_type -> temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerResponse + 9, // 9: temporal.server.chasm.lib.channel.proto.v1.ChannelService.UnregisterChannelListener:output_type -> temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerResponse + 10, // 10: temporal.server.chasm.lib.channel.proto.v1.ChannelService.PollChannel:output_type -> temporal.server.chasm.lib.channel.proto.v1.PollChannelResponse + 11, // 11: temporal.server.chasm.lib.channel.proto.v1.ChannelService.DescribeChannel:output_type -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelResponse + 12, // 12: temporal.server.chasm.lib.channel.proto.v1.ChannelService.RegisterWorkflowListener:output_type -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse + 13, // 13: temporal.server.chasm.lib.channel.proto.v1.ChannelService.DeliverChannelNotification:output_type -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse + 7, // [7:14] is the sub-list for method output_type + 0, // [0:7] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_temporal_server_chasm_lib_channel_proto_v1_service_proto_init() } +func file_temporal_server_chasm_lib_channel_proto_v1_service_proto_init() { + if File_temporal_server_chasm_lib_channel_proto_v1_service_proto != nil { + return + } + file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_service_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_temporal_server_chasm_lib_channel_proto_v1_service_proto_goTypes, + DependencyIndexes: file_temporal_server_chasm_lib_channel_proto_v1_service_proto_depIdxs, + }.Build() + File_temporal_server_chasm_lib_channel_proto_v1_service_proto = out.File + file_temporal_server_chasm_lib_channel_proto_v1_service_proto_goTypes = nil + file_temporal_server_chasm_lib_channel_proto_v1_service_proto_depIdxs = nil +} diff --git a/chasm/lib/channel/gen/channelpb/v1/service_client.pb.go b/chasm/lib/channel/gen/channelpb/v1/service_client.pb.go new file mode 100644 index 00000000000..35982975c8d --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/service_client.pb.go @@ -0,0 +1,368 @@ +// Code generated by protoc-gen-go-chasm. DO NOT EDIT. +package channelpb + +import ( + "context" + "time" + + "go.temporal.io/server/client/history" + "go.temporal.io/server/common" + "go.temporal.io/server/common/backoff" + "go.temporal.io/server/common/config" + "go.temporal.io/server/common/dynamicconfig" + "go.temporal.io/server/common/headers" + "go.temporal.io/server/common/log" + "go.temporal.io/server/common/membership" + "go.temporal.io/server/common/metrics" + "go.temporal.io/server/common/primitives" + "go.uber.org/fx" + "google.golang.org/grpc" +) + +// ChannelServiceLayeredClient is a client for ChannelService. +type ChannelServiceLayeredClient struct { + metricsHandler metrics.Handler + numShards int32 + redirector history.Redirector[ChannelServiceClient] + retryPolicy backoff.RetryPolicy +} + +// NewChannelServiceLayeredClient initializes a new ChannelServiceLayeredClient. +func NewChannelServiceLayeredClient( + lc fx.Lifecycle, + dc *dynamicconfig.Collection, + rpcFactory common.RPCFactory, + monitor membership.Monitor, + config *config.Persistence, + logger log.Logger, + metricsHandler metrics.Handler, +) (ChannelServiceClient, error) { + resolver, err := monitor.GetResolver(primitives.HistoryService) + if err != nil { + return nil, err + } + connections := history.NewConnectionPool(resolver, rpcFactory, NewChannelServiceClient, logger, dynamicconfig.HistoryConnectionCloseDelay.Get(dc)) + var redirector history.Redirector[ChannelServiceClient] + if dynamicconfig.HistoryClientOwnershipCachingEnabled.Get(dc)() { + redirector = history.NewCachingRedirector( + connections, + resolver, + logger, + dynamicconfig.HistoryClientOwnershipCachingStaleTTL.Get(dc), + ) + } else { + redirector = history.NewBasicRedirector(connections, resolver) + } + client := &ChannelServiceLayeredClient{ + metricsHandler: metricsHandler, + redirector: redirector, + numShards: config.NumHistoryShards, + retryPolicy: common.CreateHistoryClientRetryPolicy(dynamicconfig.RetryUnboundedOnSystemResourceExhausted.Get(dc)), + } + lc.Append(fx.StopHook(client.Stop)) + return client, nil +} +func (c *ChannelServiceLayeredClient) Stop() { + c.redirector.Close() +} +func (c *ChannelServiceLayeredClient) callNotifyChannelNoRetry( + ctx context.Context, + request *NotifyChannelRequest, + opts ...grpc.CallOption, +) (*NotifyChannelResponse, error) { + var response *NotifyChannelResponse + var err error + startTime := time.Now().UTC() + // the caller is a namespace, hence the tag below. + caller := headers.GetCallerInfo(ctx).CallerName + metricsHandler := c.metricsHandler.WithTags( + metrics.OperationTag("ChannelService.NotifyChannel"), + metrics.NamespaceTag(caller), + metrics.ServiceRoleTag(metrics.HistoryRoleTagValue), + ) + metrics.ClientRequests.With(metricsHandler).Record(1) + defer func() { + if err != nil { + metrics.ClientFailures.With(metricsHandler).Record(1, metrics.ServiceErrorTypeTag(err)) + } + metrics.ClientLatency.With(metricsHandler).Record(time.Since(startTime)) + }() + shardID := common.WorkflowIDToHistoryShard(request.GetNamespaceId(), request.GetFrontendRequest().GetNotification().GetChannel(), c.numShards) + op := func(ctx context.Context, client ChannelServiceClient) error { + var err error + ctx, cancel := context.WithTimeout(ctx, history.DefaultTimeout) + defer cancel() + response, err = client.NotifyChannel(ctx, request, opts...) + return err + } + err = c.redirector.Execute(ctx, shardID, op) + return response, err +} +func (c *ChannelServiceLayeredClient) NotifyChannel( + ctx context.Context, + request *NotifyChannelRequest, + opts ...grpc.CallOption, +) (*NotifyChannelResponse, error) { + call := func(ctx context.Context) (*NotifyChannelResponse, error) { + return c.callNotifyChannelNoRetry(ctx, request, opts...) + } + return backoff.ThrottleRetryContextWithReturn(ctx, call, c.retryPolicy, common.IsServiceClientTransientError) +} +func (c *ChannelServiceLayeredClient) callRegisterChannelListenerNoRetry( + ctx context.Context, + request *RegisterChannelListenerRequest, + opts ...grpc.CallOption, +) (*RegisterChannelListenerResponse, error) { + var response *RegisterChannelListenerResponse + var err error + startTime := time.Now().UTC() + // the caller is a namespace, hence the tag below. + caller := headers.GetCallerInfo(ctx).CallerName + metricsHandler := c.metricsHandler.WithTags( + metrics.OperationTag("ChannelService.RegisterChannelListener"), + metrics.NamespaceTag(caller), + metrics.ServiceRoleTag(metrics.HistoryRoleTagValue), + ) + metrics.ClientRequests.With(metricsHandler).Record(1) + defer func() { + if err != nil { + metrics.ClientFailures.With(metricsHandler).Record(1, metrics.ServiceErrorTypeTag(err)) + } + metrics.ClientLatency.With(metricsHandler).Record(time.Since(startTime)) + }() + shardID := common.WorkflowIDToHistoryShard(request.GetNamespaceId(), request.GetFrontendRequest().GetChannel(), c.numShards) + op := func(ctx context.Context, client ChannelServiceClient) error { + var err error + ctx, cancel := context.WithTimeout(ctx, history.DefaultTimeout) + defer cancel() + response, err = client.RegisterChannelListener(ctx, request, opts...) + return err + } + err = c.redirector.Execute(ctx, shardID, op) + return response, err +} +func (c *ChannelServiceLayeredClient) RegisterChannelListener( + ctx context.Context, + request *RegisterChannelListenerRequest, + opts ...grpc.CallOption, +) (*RegisterChannelListenerResponse, error) { + call := func(ctx context.Context) (*RegisterChannelListenerResponse, error) { + return c.callRegisterChannelListenerNoRetry(ctx, request, opts...) + } + return backoff.ThrottleRetryContextWithReturn(ctx, call, c.retryPolicy, common.IsServiceClientTransientError) +} +func (c *ChannelServiceLayeredClient) callUnregisterChannelListenerNoRetry( + ctx context.Context, + request *UnregisterChannelListenerRequest, + opts ...grpc.CallOption, +) (*UnregisterChannelListenerResponse, error) { + var response *UnregisterChannelListenerResponse + var err error + startTime := time.Now().UTC() + // the caller is a namespace, hence the tag below. + caller := headers.GetCallerInfo(ctx).CallerName + metricsHandler := c.metricsHandler.WithTags( + metrics.OperationTag("ChannelService.UnregisterChannelListener"), + metrics.NamespaceTag(caller), + metrics.ServiceRoleTag(metrics.HistoryRoleTagValue), + ) + metrics.ClientRequests.With(metricsHandler).Record(1) + defer func() { + if err != nil { + metrics.ClientFailures.With(metricsHandler).Record(1, metrics.ServiceErrorTypeTag(err)) + } + metrics.ClientLatency.With(metricsHandler).Record(time.Since(startTime)) + }() + shardID := common.WorkflowIDToHistoryShard(request.GetNamespaceId(), request.GetFrontendRequest().GetChannel(), c.numShards) + op := func(ctx context.Context, client ChannelServiceClient) error { + var err error + ctx, cancel := context.WithTimeout(ctx, history.DefaultTimeout) + defer cancel() + response, err = client.UnregisterChannelListener(ctx, request, opts...) + return err + } + err = c.redirector.Execute(ctx, shardID, op) + return response, err +} +func (c *ChannelServiceLayeredClient) UnregisterChannelListener( + ctx context.Context, + request *UnregisterChannelListenerRequest, + opts ...grpc.CallOption, +) (*UnregisterChannelListenerResponse, error) { + call := func(ctx context.Context) (*UnregisterChannelListenerResponse, error) { + return c.callUnregisterChannelListenerNoRetry(ctx, request, opts...) + } + return backoff.ThrottleRetryContextWithReturn(ctx, call, c.retryPolicy, common.IsServiceClientTransientError) +} +func (c *ChannelServiceLayeredClient) callPollChannelNoRetry( + ctx context.Context, + request *PollChannelRequest, + opts ...grpc.CallOption, +) (*PollChannelResponse, error) { + var response *PollChannelResponse + var err error + startTime := time.Now().UTC() + // the caller is a namespace, hence the tag below. + caller := headers.GetCallerInfo(ctx).CallerName + metricsHandler := c.metricsHandler.WithTags( + metrics.OperationTag("ChannelService.PollChannel"), + metrics.NamespaceTag(caller), + metrics.ServiceRoleTag(metrics.HistoryRoleTagValue), + ) + metrics.ClientRequests.With(metricsHandler).Record(1) + defer func() { + if err != nil { + metrics.ClientFailures.With(metricsHandler).Record(1, metrics.ServiceErrorTypeTag(err)) + } + metrics.ClientLatency.With(metricsHandler).Record(time.Since(startTime)) + }() + shardID := common.WorkflowIDToHistoryShard(request.GetNamespaceId(), request.GetFrontendRequest().GetChannel(), c.numShards) + op := func(ctx context.Context, client ChannelServiceClient) error { + var err error + ctx, cancel := context.WithTimeout(ctx, history.DefaultTimeout) + defer cancel() + response, err = client.PollChannel(ctx, request, opts...) + return err + } + err = c.redirector.Execute(ctx, shardID, op) + return response, err +} +func (c *ChannelServiceLayeredClient) PollChannel( + ctx context.Context, + request *PollChannelRequest, + opts ...grpc.CallOption, +) (*PollChannelResponse, error) { + call := func(ctx context.Context) (*PollChannelResponse, error) { + return c.callPollChannelNoRetry(ctx, request, opts...) + } + return backoff.ThrottleRetryContextWithReturn(ctx, call, c.retryPolicy, common.IsServiceClientTransientError) +} +func (c *ChannelServiceLayeredClient) callDescribeChannelNoRetry( + ctx context.Context, + request *DescribeChannelRequest, + opts ...grpc.CallOption, +) (*DescribeChannelResponse, error) { + var response *DescribeChannelResponse + var err error + startTime := time.Now().UTC() + // the caller is a namespace, hence the tag below. + caller := headers.GetCallerInfo(ctx).CallerName + metricsHandler := c.metricsHandler.WithTags( + metrics.OperationTag("ChannelService.DescribeChannel"), + metrics.NamespaceTag(caller), + metrics.ServiceRoleTag(metrics.HistoryRoleTagValue), + ) + metrics.ClientRequests.With(metricsHandler).Record(1) + defer func() { + if err != nil { + metrics.ClientFailures.With(metricsHandler).Record(1, metrics.ServiceErrorTypeTag(err)) + } + metrics.ClientLatency.With(metricsHandler).Record(time.Since(startTime)) + }() + shardID := common.WorkflowIDToHistoryShard(request.GetNamespaceId(), request.GetFrontendRequest().GetChannel(), c.numShards) + op := func(ctx context.Context, client ChannelServiceClient) error { + var err error + ctx, cancel := context.WithTimeout(ctx, history.DefaultTimeout) + defer cancel() + response, err = client.DescribeChannel(ctx, request, opts...) + return err + } + err = c.redirector.Execute(ctx, shardID, op) + return response, err +} +func (c *ChannelServiceLayeredClient) DescribeChannel( + ctx context.Context, + request *DescribeChannelRequest, + opts ...grpc.CallOption, +) (*DescribeChannelResponse, error) { + call := func(ctx context.Context) (*DescribeChannelResponse, error) { + return c.callDescribeChannelNoRetry(ctx, request, opts...) + } + return backoff.ThrottleRetryContextWithReturn(ctx, call, c.retryPolicy, common.IsServiceClientTransientError) +} +func (c *ChannelServiceLayeredClient) callRegisterWorkflowListenerNoRetry( + ctx context.Context, + request *RegisterWorkflowListenerRequest, + opts ...grpc.CallOption, +) (*RegisterWorkflowListenerResponse, error) { + var response *RegisterWorkflowListenerResponse + var err error + startTime := time.Now().UTC() + // the caller is a namespace, hence the tag below. + caller := headers.GetCallerInfo(ctx).CallerName + metricsHandler := c.metricsHandler.WithTags( + metrics.OperationTag("ChannelService.RegisterWorkflowListener"), + metrics.NamespaceTag(caller), + metrics.ServiceRoleTag(metrics.HistoryRoleTagValue), + ) + metrics.ClientRequests.With(metricsHandler).Record(1) + defer func() { + if err != nil { + metrics.ClientFailures.With(metricsHandler).Record(1, metrics.ServiceErrorTypeTag(err)) + } + metrics.ClientLatency.With(metricsHandler).Record(time.Since(startTime)) + }() + shardID := common.WorkflowIDToHistoryShard(request.GetNamespaceId(), request.GetFrontendRequest().GetChannel(), c.numShards) + op := func(ctx context.Context, client ChannelServiceClient) error { + var err error + ctx, cancel := context.WithTimeout(ctx, history.DefaultTimeout) + defer cancel() + response, err = client.RegisterWorkflowListener(ctx, request, opts...) + return err + } + err = c.redirector.Execute(ctx, shardID, op) + return response, err +} +func (c *ChannelServiceLayeredClient) RegisterWorkflowListener( + ctx context.Context, + request *RegisterWorkflowListenerRequest, + opts ...grpc.CallOption, +) (*RegisterWorkflowListenerResponse, error) { + call := func(ctx context.Context) (*RegisterWorkflowListenerResponse, error) { + return c.callRegisterWorkflowListenerNoRetry(ctx, request, opts...) + } + return backoff.ThrottleRetryContextWithReturn(ctx, call, c.retryPolicy, common.IsServiceClientTransientError) +} +func (c *ChannelServiceLayeredClient) callDeliverChannelNotificationNoRetry( + ctx context.Context, + request *DeliverChannelNotificationRequest, + opts ...grpc.CallOption, +) (*DeliverChannelNotificationResponse, error) { + var response *DeliverChannelNotificationResponse + var err error + startTime := time.Now().UTC() + // the caller is a namespace, hence the tag below. + caller := headers.GetCallerInfo(ctx).CallerName + metricsHandler := c.metricsHandler.WithTags( + metrics.OperationTag("ChannelService.DeliverChannelNotification"), + metrics.NamespaceTag(caller), + metrics.ServiceRoleTag(metrics.HistoryRoleTagValue), + ) + metrics.ClientRequests.With(metricsHandler).Record(1) + defer func() { + if err != nil { + metrics.ClientFailures.With(metricsHandler).Record(1, metrics.ServiceErrorTypeTag(err)) + } + metrics.ClientLatency.With(metricsHandler).Record(time.Since(startTime)) + }() + shardID := common.WorkflowIDToHistoryShard(request.GetNamespaceId(), request.GetFrontendRequest().GetWorkflowId(), c.numShards) + op := func(ctx context.Context, client ChannelServiceClient) error { + var err error + ctx, cancel := context.WithTimeout(ctx, history.DefaultTimeout) + defer cancel() + response, err = client.DeliverChannelNotification(ctx, request, opts...) + return err + } + err = c.redirector.Execute(ctx, shardID, op) + return response, err +} +func (c *ChannelServiceLayeredClient) DeliverChannelNotification( + ctx context.Context, + request *DeliverChannelNotificationRequest, + opts ...grpc.CallOption, +) (*DeliverChannelNotificationResponse, error) { + call := func(ctx context.Context) (*DeliverChannelNotificationResponse, error) { + return c.callDeliverChannelNotificationNoRetry(ctx, request, opts...) + } + return backoff.ThrottleRetryContextWithReturn(ctx, call, c.retryPolicy, common.IsServiceClientTransientError) +} diff --git a/chasm/lib/channel/gen/channelpb/v1/service_grpc.pb.go b/chasm/lib/channel/gen/channelpb/v1/service_grpc.pb.go new file mode 100644 index 00000000000..e37f6203c59 --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/service_grpc.pb.go @@ -0,0 +1,338 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// plugins: +// - protoc-gen-go-grpc +// - protoc +// source: temporal/server/chasm/lib/channel/proto/v1/service.proto + +package channelpb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + ChannelService_NotifyChannel_FullMethodName = "/temporal.server.chasm.lib.channel.proto.v1.ChannelService/NotifyChannel" + ChannelService_RegisterChannelListener_FullMethodName = "/temporal.server.chasm.lib.channel.proto.v1.ChannelService/RegisterChannelListener" + ChannelService_UnregisterChannelListener_FullMethodName = "/temporal.server.chasm.lib.channel.proto.v1.ChannelService/UnregisterChannelListener" + ChannelService_PollChannel_FullMethodName = "/temporal.server.chasm.lib.channel.proto.v1.ChannelService/PollChannel" + ChannelService_DescribeChannel_FullMethodName = "/temporal.server.chasm.lib.channel.proto.v1.ChannelService/DescribeChannel" + ChannelService_RegisterWorkflowListener_FullMethodName = "/temporal.server.chasm.lib.channel.proto.v1.ChannelService/RegisterWorkflowListener" + ChannelService_DeliverChannelNotification_FullMethodName = "/temporal.server.chasm.lib.channel.proto.v1.ChannelService/DeliverChannelNotification" +) + +// ChannelServiceClient is the client API for ChannelService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ChannelServiceClient interface { + NotifyChannel(ctx context.Context, in *NotifyChannelRequest, opts ...grpc.CallOption) (*NotifyChannelResponse, error) + RegisterChannelListener(ctx context.Context, in *RegisterChannelListenerRequest, opts ...grpc.CallOption) (*RegisterChannelListenerResponse, error) + UnregisterChannelListener(ctx context.Context, in *UnregisterChannelListenerRequest, opts ...grpc.CallOption) (*UnregisterChannelListenerResponse, error) + PollChannel(ctx context.Context, in *PollChannelRequest, opts ...grpc.CallOption) (*PollChannelResponse, error) + DescribeChannel(ctx context.Context, in *DescribeChannelRequest, opts ...grpc.CallOption) (*DescribeChannelResponse, error) + // Internal. The completion of a Workflow Task that subscribed calls this on + // the channel's shard, creating the channel if it is absent. + RegisterWorkflowListener(ctx context.Context, in *RegisterWorkflowListenerRequest, opts ...grpc.CallOption) (*RegisterWorkflowListenerResponse, error) + // Internal. The channel's fan-out calls this on the listener's shard. + DeliverChannelNotification(ctx context.Context, in *DeliverChannelNotificationRequest, opts ...grpc.CallOption) (*DeliverChannelNotificationResponse, error) +} + +type channelServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewChannelServiceClient(cc grpc.ClientConnInterface) ChannelServiceClient { + return &channelServiceClient{cc} +} + +func (c *channelServiceClient) NotifyChannel(ctx context.Context, in *NotifyChannelRequest, opts ...grpc.CallOption) (*NotifyChannelResponse, error) { + out := new(NotifyChannelResponse) + err := c.cc.Invoke(ctx, ChannelService_NotifyChannel_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelServiceClient) RegisterChannelListener(ctx context.Context, in *RegisterChannelListenerRequest, opts ...grpc.CallOption) (*RegisterChannelListenerResponse, error) { + out := new(RegisterChannelListenerResponse) + err := c.cc.Invoke(ctx, ChannelService_RegisterChannelListener_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelServiceClient) UnregisterChannelListener(ctx context.Context, in *UnregisterChannelListenerRequest, opts ...grpc.CallOption) (*UnregisterChannelListenerResponse, error) { + out := new(UnregisterChannelListenerResponse) + err := c.cc.Invoke(ctx, ChannelService_UnregisterChannelListener_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelServiceClient) PollChannel(ctx context.Context, in *PollChannelRequest, opts ...grpc.CallOption) (*PollChannelResponse, error) { + out := new(PollChannelResponse) + err := c.cc.Invoke(ctx, ChannelService_PollChannel_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelServiceClient) DescribeChannel(ctx context.Context, in *DescribeChannelRequest, opts ...grpc.CallOption) (*DescribeChannelResponse, error) { + out := new(DescribeChannelResponse) + err := c.cc.Invoke(ctx, ChannelService_DescribeChannel_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelServiceClient) RegisterWorkflowListener(ctx context.Context, in *RegisterWorkflowListenerRequest, opts ...grpc.CallOption) (*RegisterWorkflowListenerResponse, error) { + out := new(RegisterWorkflowListenerResponse) + err := c.cc.Invoke(ctx, ChannelService_RegisterWorkflowListener_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelServiceClient) DeliverChannelNotification(ctx context.Context, in *DeliverChannelNotificationRequest, opts ...grpc.CallOption) (*DeliverChannelNotificationResponse, error) { + out := new(DeliverChannelNotificationResponse) + err := c.cc.Invoke(ctx, ChannelService_DeliverChannelNotification_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ChannelServiceServer is the server API for ChannelService service. +// All implementations must embed UnimplementedChannelServiceServer +// for forward compatibility +type ChannelServiceServer interface { + NotifyChannel(context.Context, *NotifyChannelRequest) (*NotifyChannelResponse, error) + RegisterChannelListener(context.Context, *RegisterChannelListenerRequest) (*RegisterChannelListenerResponse, error) + UnregisterChannelListener(context.Context, *UnregisterChannelListenerRequest) (*UnregisterChannelListenerResponse, error) + PollChannel(context.Context, *PollChannelRequest) (*PollChannelResponse, error) + DescribeChannel(context.Context, *DescribeChannelRequest) (*DescribeChannelResponse, error) + // Internal. The completion of a Workflow Task that subscribed calls this on + // the channel's shard, creating the channel if it is absent. + RegisterWorkflowListener(context.Context, *RegisterWorkflowListenerRequest) (*RegisterWorkflowListenerResponse, error) + // Internal. The channel's fan-out calls this on the listener's shard. + DeliverChannelNotification(context.Context, *DeliverChannelNotificationRequest) (*DeliverChannelNotificationResponse, error) + mustEmbedUnimplementedChannelServiceServer() +} + +// UnimplementedChannelServiceServer must be embedded to have forward compatible implementations. +type UnimplementedChannelServiceServer struct { +} + +func (UnimplementedChannelServiceServer) NotifyChannel(context.Context, *NotifyChannelRequest) (*NotifyChannelResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method NotifyChannel not implemented") +} +func (UnimplementedChannelServiceServer) RegisterChannelListener(context.Context, *RegisterChannelListenerRequest) (*RegisterChannelListenerResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RegisterChannelListener not implemented") +} +func (UnimplementedChannelServiceServer) UnregisterChannelListener(context.Context, *UnregisterChannelListenerRequest) (*UnregisterChannelListenerResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnregisterChannelListener not implemented") +} +func (UnimplementedChannelServiceServer) PollChannel(context.Context, *PollChannelRequest) (*PollChannelResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method PollChannel not implemented") +} +func (UnimplementedChannelServiceServer) DescribeChannel(context.Context, *DescribeChannelRequest) (*DescribeChannelResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DescribeChannel not implemented") +} +func (UnimplementedChannelServiceServer) RegisterWorkflowListener(context.Context, *RegisterWorkflowListenerRequest) (*RegisterWorkflowListenerResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RegisterWorkflowListener not implemented") +} +func (UnimplementedChannelServiceServer) DeliverChannelNotification(context.Context, *DeliverChannelNotificationRequest) (*DeliverChannelNotificationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeliverChannelNotification not implemented") +} +func (UnimplementedChannelServiceServer) mustEmbedUnimplementedChannelServiceServer() {} + +// UnsafeChannelServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ChannelServiceServer will +// result in compilation errors. +type UnsafeChannelServiceServer interface { + mustEmbedUnimplementedChannelServiceServer() +} + +func RegisterChannelServiceServer(s grpc.ServiceRegistrar, srv ChannelServiceServer) { + s.RegisterService(&ChannelService_ServiceDesc, srv) +} + +func _ChannelService_NotifyChannel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(NotifyChannelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelServiceServer).NotifyChannel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChannelService_NotifyChannel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelServiceServer).NotifyChannel(ctx, req.(*NotifyChannelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelService_RegisterChannelListener_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterChannelListenerRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelServiceServer).RegisterChannelListener(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChannelService_RegisterChannelListener_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelServiceServer).RegisterChannelListener(ctx, req.(*RegisterChannelListenerRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelService_UnregisterChannelListener_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnregisterChannelListenerRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelServiceServer).UnregisterChannelListener(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChannelService_UnregisterChannelListener_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelServiceServer).UnregisterChannelListener(ctx, req.(*UnregisterChannelListenerRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelService_PollChannel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PollChannelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelServiceServer).PollChannel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChannelService_PollChannel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelServiceServer).PollChannel(ctx, req.(*PollChannelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelService_DescribeChannel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DescribeChannelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelServiceServer).DescribeChannel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChannelService_DescribeChannel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelServiceServer).DescribeChannel(ctx, req.(*DescribeChannelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelService_RegisterWorkflowListener_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterWorkflowListenerRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelServiceServer).RegisterWorkflowListener(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChannelService_RegisterWorkflowListener_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelServiceServer).RegisterWorkflowListener(ctx, req.(*RegisterWorkflowListenerRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelService_DeliverChannelNotification_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeliverChannelNotificationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelServiceServer).DeliverChannelNotification(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChannelService_DeliverChannelNotification_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelServiceServer).DeliverChannelNotification(ctx, req.(*DeliverChannelNotificationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ChannelService_ServiceDesc is the grpc.ServiceDesc for ChannelService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ChannelService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "temporal.server.chasm.lib.channel.proto.v1.ChannelService", + HandlerType: (*ChannelServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "NotifyChannel", + Handler: _ChannelService_NotifyChannel_Handler, + }, + { + MethodName: "RegisterChannelListener", + Handler: _ChannelService_RegisterChannelListener_Handler, + }, + { + MethodName: "UnregisterChannelListener", + Handler: _ChannelService_UnregisterChannelListener_Handler, + }, + { + MethodName: "PollChannel", + Handler: _ChannelService_PollChannel_Handler, + }, + { + MethodName: "DescribeChannel", + Handler: _ChannelService_DescribeChannel_Handler, + }, + { + MethodName: "RegisterWorkflowListener", + Handler: _ChannelService_RegisterWorkflowListener_Handler, + }, + { + MethodName: "DeliverChannelNotification", + Handler: _ChannelService_DeliverChannelNotification_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "temporal/server/chasm/lib/channel/proto/v1/service.proto", +} diff --git a/chasm/lib/channel/gen/channelpb/v1/tasks.go-helpers.pb.go b/chasm/lib/channel/gen/channelpb/v1/tasks.go-helpers.pb.go new file mode 100644 index 00000000000..44fd230c300 --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/tasks.go-helpers.pb.go @@ -0,0 +1,154 @@ +// Code generated by protoc-gen-go-helpers. DO NOT EDIT. +package channelpb + +import ( + "google.golang.org/protobuf/proto" +) + +// Marshal an object of type ChannelFanOutTask to the protobuf v3 wire format +func (val *ChannelFanOutTask) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type ChannelFanOutTask from the protobuf v3 wire format +func (val *ChannelFanOutTask) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *ChannelFanOutTask) Size() int { + return proto.Size(val) +} + +// Equal returns whether two ChannelFanOutTask values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *ChannelFanOutTask) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *ChannelFanOutTask + switch t := that.(type) { + case *ChannelFanOutTask: + that1 = t + case ChannelFanOutTask: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type ChannelCallbackTask to the protobuf v3 wire format +func (val *ChannelCallbackTask) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type ChannelCallbackTask from the protobuf v3 wire format +func (val *ChannelCallbackTask) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *ChannelCallbackTask) Size() int { + return proto.Size(val) +} + +// Equal returns whether two ChannelCallbackTask values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *ChannelCallbackTask) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *ChannelCallbackTask + switch t := that.(type) { + case *ChannelCallbackTask: + that1 = t + case ChannelCallbackTask: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type ChannelCallbackBackoffTask to the protobuf v3 wire format +func (val *ChannelCallbackBackoffTask) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type ChannelCallbackBackoffTask from the protobuf v3 wire format +func (val *ChannelCallbackBackoffTask) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *ChannelCallbackBackoffTask) Size() int { + return proto.Size(val) +} + +// Equal returns whether two ChannelCallbackBackoffTask values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *ChannelCallbackBackoffTask) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *ChannelCallbackBackoffTask + switch t := that.(type) { + case *ChannelCallbackBackoffTask: + that1 = t + case ChannelCallbackBackoffTask: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type ChannelIdleTask to the protobuf v3 wire format +func (val *ChannelIdleTask) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type ChannelIdleTask from the protobuf v3 wire format +func (val *ChannelIdleTask) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *ChannelIdleTask) Size() int { + return proto.Size(val) +} + +// Equal returns whether two ChannelIdleTask values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *ChannelIdleTask) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *ChannelIdleTask + switch t := that.(type) { + case *ChannelIdleTask: + that1 = t + case ChannelIdleTask: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} diff --git a/chasm/lib/channel/gen/channelpb/v1/tasks.pb.go b/chasm/lib/channel/gen/channelpb/v1/tasks.pb.go new file mode 100644 index 00000000000..db2a9105d9a --- /dev/null +++ b/chasm/lib/channel/gen/channelpb/v1/tasks.pb.go @@ -0,0 +1,277 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// plugins: +// protoc-gen-go +// protoc +// source: temporal/server/chasm/lib/channel/proto/v1/tasks.proto + +package channelpb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Fires after a notify on a channel with listeners. One is outstanding at a +// time, and it delivers the latest notification to every listener that has +// not been handed it yet. +type ChannelFanOutTask struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelFanOutTask) Reset() { + *x = ChannelFanOutTask{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelFanOutTask) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelFanOutTask) ProtoMessage() {} + +func (x *ChannelFanOutTask) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelFanOutTask.ProtoReflect.Descriptor instead. +func (*ChannelFanOutTask) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescGZIP(), []int{0} +} + +// Posts a callback listener's in-flight notification. Runs on the outbound +// queue, keyed by the callback's destination. +type ChannelCallbackTask struct { + state protoimpl.MessageState `protogen:"open.v1"` + ListenerId string `protobuf:"bytes,1,opt,name=listener_id,json=listenerId,proto3" json:"listener_id,omitempty"` + // The listener's task sequence when this was scheduled. + Sequence int64 `protobuf:"varint,2,opt,name=sequence,proto3" json:"sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelCallbackTask) Reset() { + *x = ChannelCallbackTask{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelCallbackTask) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelCallbackTask) ProtoMessage() {} + +func (x *ChannelCallbackTask) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelCallbackTask.ProtoReflect.Descriptor instead. +func (*ChannelCallbackTask) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescGZIP(), []int{1} +} + +func (x *ChannelCallbackTask) GetListenerId() string { + if x != nil { + return x.ListenerId + } + return "" +} + +func (x *ChannelCallbackTask) GetSequence() int64 { + if x != nil { + return x.Sequence + } + return 0 +} + +// Fires when a failed callback delivery has backed off long enough to try +// again. +type ChannelCallbackBackoffTask struct { + state protoimpl.MessageState `protogen:"open.v1"` + ListenerId string `protobuf:"bytes,1,opt,name=listener_id,json=listenerId,proto3" json:"listener_id,omitempty"` + // The listener's task sequence when the failed attempt ran. + Sequence int64 `protobuf:"varint,2,opt,name=sequence,proto3" json:"sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelCallbackBackoffTask) Reset() { + *x = ChannelCallbackBackoffTask{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelCallbackBackoffTask) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelCallbackBackoffTask) ProtoMessage() {} + +func (x *ChannelCallbackBackoffTask) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelCallbackBackoffTask.ProtoReflect.Descriptor instead. +func (*ChannelCallbackBackoffTask) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescGZIP(), []int{2} +} + +func (x *ChannelCallbackBackoffTask) GetListenerId() string { + if x != nil { + return x.ListenerId + } + return "" +} + +func (x *ChannelCallbackBackoffTask) GetSequence() int64 { + if x != nil { + return x.Sequence + } + return 0 +} + +// Fires a retention after the channel's last activity. A channel with no +// listeners that nobody touched in that time is deleted. +type ChannelIdleTask struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelIdleTask) Reset() { + *x = ChannelIdleTask{} + mi := &file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelIdleTask) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelIdleTask) ProtoMessage() {} + +func (x *ChannelIdleTask) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelIdleTask.ProtoReflect.Descriptor instead. +func (*ChannelIdleTask) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescGZIP(), []int{3} +} + +var File_temporal_server_chasm_lib_channel_proto_v1_tasks_proto protoreflect.FileDescriptor + +const file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDesc = "" + + "\n" + + "6temporal/server/chasm/lib/channel/proto/v1/tasks.proto\x12*temporal.server.chasm.lib.channel.proto.v1\"\x13\n" + + "\x11ChannelFanOutTask\"R\n" + + "\x13ChannelCallbackTask\x12\x1f\n" + + "\vlistener_id\x18\x01 \x01(\tR\n" + + "listenerId\x12\x1a\n" + + "\bsequence\x18\x02 \x01(\x03R\bsequence\"Y\n" + + "\x1aChannelCallbackBackoffTask\x12\x1f\n" + + "\vlistener_id\x18\x01 \x01(\tR\n" + + "listenerId\x12\x1a\n" + + "\bsequence\x18\x02 \x01(\x03R\bsequence\"\x11\n" + + "\x0fChannelIdleTaskBAZ?go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpbb\x06proto3" + +var ( + file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescOnce sync.Once + file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescData []byte +) + +func file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescGZIP() []byte { + file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescOnce.Do(func() { + file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDesc))) + }) + return file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDescData +} + +var file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_goTypes = []any{ + (*ChannelFanOutTask)(nil), // 0: temporal.server.chasm.lib.channel.proto.v1.ChannelFanOutTask + (*ChannelCallbackTask)(nil), // 1: temporal.server.chasm.lib.channel.proto.v1.ChannelCallbackTask + (*ChannelCallbackBackoffTask)(nil), // 2: temporal.server.chasm.lib.channel.proto.v1.ChannelCallbackBackoffTask + (*ChannelIdleTask)(nil), // 3: temporal.server.chasm.lib.channel.proto.v1.ChannelIdleTask +} +var file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_init() } +func file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_init() { + if File_temporal_server_chasm_lib_channel_proto_v1_tasks_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDesc), len(file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_goTypes, + DependencyIndexes: file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_depIdxs, + MessageInfos: file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_msgTypes, + }.Build() + File_temporal_server_chasm_lib_channel_proto_v1_tasks_proto = out.File + file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_goTypes = nil + file_temporal_server_chasm_lib_channel_proto_v1_tasks_proto_depIdxs = nil +} diff --git a/chasm/lib/channel/proto/v1/channel_state.proto b/chasm/lib/channel/proto/v1/channel_state.proto new file mode 100644 index 00000000000..273205f52b5 --- /dev/null +++ b/chasm/lib/channel/proto/v1/channel_state.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package temporal.server.chasm.lib.channel.proto.v1; + +import "chasm/lib/channel/proto/v1/message.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpb"; + +// ChannelState is the part of a channel written on every notify. The retained +// notifications and the listeners are nodes of their own, so a notify writes +// this, one retained notification, and nothing per listener. +message ChannelState { + // The notification with the highest counter accepted so far. + Notification latest = 1; + // Sequence of the oldest retained notification and the next one to be + // written. The retained notifications are keyed by sequence. + int64 retained_first = 2; + int64 retained_next = 3; + + // A fan-out task is outstanding. Notifies that land while it is up schedule + // none of their own; the task reads the latest when it runs. + bool fan_out_pending = 4; + + // Last notify, registration or poll. The idle task deletes a channel with no + // listeners once this is older than the retention. + google.protobuf.Timestamp last_activity_time = 5; + bool idle_check_pending = 6; + + // Total notifications accepted, for describe. + int64 accepted_count = 7; + + // Set by the idle task just before it deletes the channel, so a notify or a + // registration that arrives in between starts a new channel rather than + // landing on the one being deleted. + bool closed = 8; +} diff --git a/chasm/lib/channel/proto/v1/message.proto b/chasm/lib/channel/proto/v1/message.proto new file mode 100644 index 00000000000..8b95dc1b8e3 --- /dev/null +++ b/chasm/lib/channel/proto/v1/message.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package temporal.server.chasm.lib.channel.proto.v1; + +import "google/protobuf/timestamp.proto"; +import "temporal/api/common/v1/message.proto"; +import "temporal/api/failure/v1/message.proto"; + +option go_package = "go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpb"; + +// Notification has the fields of temporal.api.notification.v1.Notification +// under the same names, so the stored form and the JSON a callback listener +// receives read the same as the public message. +message Notification { + string channel = 1; + // Opaque to the server: where the writer stood when it notified. + bytes position = 2; + // Set by the writer and expected to grow. Folding keeps the highest. + int64 counter = 3; + map metadata = 4; +} + +// WorkflowListener is a workflow run that subscribed to the channel. +message WorkflowListener { + string workflow_id = 1; + // The run that subscribed, or the one the subscription was re-keyed to. + string run_id = 2; + string first_execution_run_id = 3; + google.protobuf.Timestamp registered_time = 4; +} + +// CallbackListener is an HTTP endpoint the channel posts its notifications to. +message CallbackListener { + string listener_id = 1; + temporal.api.common.v1.Callback callback = 2; + // Request id of the registration, so a retried registration finds this + // listener rather than adding another. + string request_id = 3; + google.protobuf.Timestamp registered_time = 4; + + // The notification being delivered, if any. One delivery is in flight at a + // time per listener. + Notification in_flight = 5; + // The latest notification that arrived while one was in flight. A newer one + // replaces it, and it is sent when the in-flight delivery is done. + Notification pending = 6; + // Highest counter handed to this listener, in flight or pending, so the + // fan-out hands each notification once. + int64 handed_counter = 7; + // Attempts made at the in-flight delivery. + int32 attempt = 8; + google.protobuf.Timestamp next_attempt_time = 9; + temporal.api.failure.v1.Failure last_attempt_failure = 10; + // Bumped for every delivery task scheduled, so a task names one attempt of + // one delivery and a stale one finds nothing to do. + int64 task_sequence = 11; +} diff --git a/chasm/lib/channel/proto/v1/request_response.proto b/chasm/lib/channel/proto/v1/request_response.proto new file mode 100644 index 00000000000..4de68dbfc46 --- /dev/null +++ b/chasm/lib/channel/proto/v1/request_response.proto @@ -0,0 +1,182 @@ +syntax = "proto3"; + +package temporal.server.chasm.lib.channel.proto.v1; + +import "chasm/lib/channel/proto/v1/message.proto"; +import "google/protobuf/timestamp.proto"; +import "temporal/api/common/v1/message.proto"; + +option go_package = "go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpb"; + +// The frontend serves the public channel calls on WorkflowService and forwards +// them here, to the shard that owns the channel. Each request keeps the +// caller's fields in frontend_request, next to the namespace id the frontend +// resolved, as the other CHASM libraries do. + +message NotifyChannelInput { + string namespace = 1; + Notification notification = 2; + string identity = 3; + string request_id = 4; +} + +message NotifyChannelOutput { + // Listeners registered when the notification was accepted. + int64 listener_count = 1; +} + +message NotifyChannelRequest { + string namespace_id = 1; + NotifyChannelInput frontend_request = 2; +} + +message NotifyChannelResponse { + NotifyChannelOutput frontend_response = 1; +} + +message RegisterChannelListenerInput { + string namespace = 1; + string channel = 2; + temporal.api.common.v1.Callback callback = 3; + string request_id = 4; + string identity = 5; +} + +message RegisterChannelListenerOutput { + string listener_id = 1; +} + +message RegisterChannelListenerRequest { + string namespace_id = 1; + RegisterChannelListenerInput frontend_request = 2; +} + +message RegisterChannelListenerResponse { + RegisterChannelListenerOutput frontend_response = 1; +} + +message UnregisterChannelListenerInput { + string namespace = 1; + string channel = 2; + string listener_id = 3; + string identity = 4; +} + +message UnregisterChannelListenerOutput {} + +message UnregisterChannelListenerRequest { + string namespace_id = 1; + UnregisterChannelListenerInput frontend_request = 2; +} + +message UnregisterChannelListenerResponse { + UnregisterChannelListenerOutput frontend_response = 1; +} + +message PollChannelInput { + string namespace = 1; + string channel = 2; + // Return retained notifications with a counter above this one. + int64 after_counter = 3; + // Block until one arrives when none is retained above after_counter. + bool wait = 4; + // Zero means the server's page size. + int32 max_notifications = 5; +} + +message PollChannelOutput { + repeated Notification notifications = 1; +} + +message PollChannelRequest { + string namespace_id = 1; + PollChannelInput frontend_request = 2; +} + +message PollChannelResponse { + PollChannelOutput frontend_response = 1; +} + +message DescribeChannelInput { + string namespace = 1; + string channel = 2; +} + +// ChannelListenerInfo has the fields of temporal.api.notification.v1.ChannelListener. +message ChannelListenerInfo { + message Workflow { + string workflow_id = 1; + string run_id = 2; + } + string listener_id = 1; + oneof variant { + Workflow workflow = 2; + temporal.api.common.v1.Callback callback = 3; + } + google.protobuf.Timestamp registered_time = 4; +} + +message DescribeChannelOutput { + repeated ChannelListenerInfo listeners = 1; + Notification latest = 2; + int64 retained_count = 3; +} + +message DescribeChannelRequest { + string namespace_id = 1; + DescribeChannelInput frontend_request = 2; +} + +message DescribeChannelResponse { + DescribeChannelOutput frontend_response = 1; +} + +message RegisterWorkflowListenerInput { + string namespace = 1; + string channel = 2; + string workflow_id = 3; + string run_id = 4; + string first_execution_run_id = 5; +} + +message RegisterWorkflowListenerOutput {} + +message RegisterWorkflowListenerRequest { + string namespace_id = 1; + RegisterWorkflowListenerInput frontend_request = 2; +} + +message RegisterWorkflowListenerResponse { + RegisterWorkflowListenerOutput frontend_response = 1; +} + +message DeliverChannelNotificationInput { + string namespace = 1; + string workflow_id = 2; + // The run the channel has on record. If it is closed, the current run is + // asked instead. + string run_id = 3; + Notification notification = 4; +} + +message DeliverChannelNotificationOutput { + // No run of the workflow listens to the channel any more, so the channel + // drops the listener. + bool listener_closed = 1; + // The run on record is closed and the current run listens, so the channel + // re-keys the listener to it. + string successor_run_id = 2; + // The notification joined one the run had not seen yet. + bool folded = 3; + // The run had already seen this counter, so nothing changed. + bool duplicate = 4; +} + +message DeliverChannelNotificationRequest { + string namespace_id = 1; + DeliverChannelNotificationInput frontend_request = 2; +} + +message DeliverChannelNotificationResponse { + DeliverChannelNotificationOutput frontend_response = 1; +} diff --git a/chasm/lib/channel/proto/v1/service.proto b/chasm/lib/channel/proto/v1/service.proto new file mode 100644 index 00000000000..22c1a4d21bc --- /dev/null +++ b/chasm/lib/channel/proto/v1/service.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package temporal.server.chasm.lib.channel.proto.v1; + +import "chasm/lib/channel/proto/v1/request_response.proto"; +import "temporal/server/api/common/v1/api_category.proto"; +import "temporal/server/api/routing/v1/extension.proto"; + +option go_package = "go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpb"; + +// ChannelService is served by History. The frontend reaches it for the public +// channel calls, and History reaches it to cross from a channel's shard to a +// listener's shard and back. +service ChannelService { + rpc NotifyChannel(NotifyChannelRequest) returns (NotifyChannelResponse) { + option (temporal.server.api.routing.v1.routing).business_id = "frontend_request.notification.channel"; + option (temporal.server.api.common.v1.api_category).category = API_CATEGORY_STANDARD; + } + + rpc RegisterChannelListener(RegisterChannelListenerRequest) returns (RegisterChannelListenerResponse) { + option (temporal.server.api.routing.v1.routing).business_id = "frontend_request.channel"; + option (temporal.server.api.common.v1.api_category).category = API_CATEGORY_STANDARD; + } + + rpc UnregisterChannelListener(UnregisterChannelListenerRequest) returns (UnregisterChannelListenerResponse) { + option (temporal.server.api.routing.v1.routing).business_id = "frontend_request.channel"; + option (temporal.server.api.common.v1.api_category).category = API_CATEGORY_STANDARD; + } + + rpc PollChannel(PollChannelRequest) returns (PollChannelResponse) { + option (temporal.server.api.routing.v1.routing).business_id = "frontend_request.channel"; + option (temporal.server.api.common.v1.api_category).category = API_CATEGORY_LONG_POLL; + } + + rpc DescribeChannel(DescribeChannelRequest) returns (DescribeChannelResponse) { + option (temporal.server.api.routing.v1.routing).business_id = "frontend_request.channel"; + option (temporal.server.api.common.v1.api_category).category = API_CATEGORY_STANDARD; + } + + // Internal. The completion of a Workflow Task that subscribed calls this on + // the channel's shard, creating the channel if it is absent. + rpc RegisterWorkflowListener(RegisterWorkflowListenerRequest) returns (RegisterWorkflowListenerResponse) { + option (temporal.server.api.routing.v1.routing).business_id = "frontend_request.channel"; + option (temporal.server.api.common.v1.api_category).category = API_CATEGORY_STANDARD; + } + + // Internal. The channel's fan-out calls this on the listener's shard. + rpc DeliverChannelNotification(DeliverChannelNotificationRequest) returns (DeliverChannelNotificationResponse) { + option (temporal.server.api.routing.v1.routing).business_id = "frontend_request.workflow_id"; + option (temporal.server.api.common.v1.api_category).category = API_CATEGORY_STANDARD; + } +} diff --git a/chasm/lib/channel/proto/v1/tasks.proto b/chasm/lib/channel/proto/v1/tasks.proto new file mode 100644 index 00000000000..ff2064c2877 --- /dev/null +++ b/chasm/lib/channel/proto/v1/tasks.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package temporal.server.chasm.lib.channel.proto.v1; + +option go_package = "go.temporal.io/server/chasm/lib/channel/gen/channelpb;channelpb"; + +// Fires after a notify on a channel with listeners. One is outstanding at a +// time, and it delivers the latest notification to every listener that has +// not been handed it yet. +message ChannelFanOutTask {} + +// Posts a callback listener's in-flight notification. Runs on the outbound +// queue, keyed by the callback's destination. +message ChannelCallbackTask { + string listener_id = 1; + // The listener's task sequence when this was scheduled. + int64 sequence = 2; +} + +// Fires when a failed callback delivery has backed off long enough to try +// again. +message ChannelCallbackBackoffTask { + string listener_id = 1; + // The listener's task sequence when the failed attempt ran. + int64 sequence = 2; +} + +// Fires a retention after the channel's last activity. A channel with no +// listeners that nobody touched in that time is deleted. +message ChannelIdleTask {} diff --git a/chasm/lib/channel/routing.go b/chasm/lib/channel/routing.go new file mode 100644 index 00000000000..f5a373944ff --- /dev/null +++ b/chasm/lib/channel/routing.go @@ -0,0 +1,24 @@ +package channel + +import ( + "context" + + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" +) + +type clientContextKey struct{} + +// WithClient supplies shard routing for a call that reaches a channel from +// inside a workflow task's transaction, which runs on the workflow's shard. +func WithClient(ctx context.Context, client channelpb.ChannelServiceClient) context.Context { + if client == nil { + return ctx + } + return context.WithValue(ctx, clientContextKey{}, client) +} + +// ClientFromContext returns the routed client installed by the engine. +func ClientFromContext(ctx context.Context) (channelpb.ChannelServiceClient, bool) { + client, ok := ctx.Value(clientContextKey{}).(channelpb.ChannelServiceClient) + return client, ok +} diff --git a/chasm/lib/channel/service/fx.go b/chasm/lib/channel/service/fx.go new file mode 100644 index 00000000000..1a0ff8fee87 --- /dev/null +++ b/chasm/lib/channel/service/fx.go @@ -0,0 +1,38 @@ +package service + +import ( + "go.temporal.io/server/chasm" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + "go.uber.org/fx" +) + +// HistoryModule serves ChannelService on History. The routed client is here +// too: the fan-out runs on the channel's shard and has to reach each +// listener's, which this host may not own. The channel config comes from the +// workflow library module, which every service running this one has. +var HistoryModule = fx.Module( + "channel-history", + fx.Provide( + channelpb.NewChannelServiceLayeredClient, + newHandler, + newFanOutTaskHandler, + newCallbackTaskHandler, + newCallbackBackoffTaskHandler, + newIdleTaskHandler, + newLibrary, + ), + fx.Invoke(func(l *library, registry *chasm.Registry) error { + return registry.Register(l) + }), +) + +// FrontendModule gives the frontend the routed client its channel handlers +// forward to, and registers the component type. +var FrontendModule = fx.Module( + "channel-frontend", + fx.Provide(channelpb.NewChannelServiceLayeredClient), + fx.Provide(newComponentOnlyLibrary), + fx.Invoke(func(l *componentOnlyLibrary, registry *chasm.Registry) error { + return registry.Register(l) + }), +) diff --git a/chasm/lib/channel/service/handler.go b/chasm/lib/channel/service/handler.go new file mode 100644 index 00000000000..a9dfdc16052 --- /dev/null +++ b/chasm/lib/channel/service/handler.go @@ -0,0 +1,496 @@ +package service + +import ( + "context" + "errors" + "time" + + "go.temporal.io/api/serviceerror" + "go.temporal.io/server/chasm" + "go.temporal.io/server/chasm/lib/channel" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + chasmworkflow "go.temporal.io/server/chasm/lib/workflow" + "go.temporal.io/server/common/clock" + "go.temporal.io/server/common/contextutil" + "go.temporal.io/server/common/headers" + "go.temporal.io/server/common/log" + "go.temporal.io/server/common/metrics" + "go.temporal.io/server/common/namespace" +) + +type handler struct { + channelpb.UnimplementedChannelServiceServer + + namespaceRegistry namespace.Registry + logger log.Logger + metricsHandler metrics.Handler + timeSource clock.TimeSource + config *channel.Config + limiters *notifyLimiters +} + +func newHandler( + namespaceRegistry namespace.Registry, + logger log.Logger, + metricsHandler metrics.Handler, + timeSource clock.TimeSource, + config *channel.Config, +) *handler { + return &handler{ + namespaceRegistry: namespaceRegistry, + logger: logger, + metricsHandler: metricsHandler, + timeSource: timeSource, + config: config, + limiters: newNotifyLimiters(config), + } +} + +// namespaceName resolves an id for the limits and the metrics tag. The +// interceptors have already refused a namespace that does not exist, so an id +// the registry cannot name resolves to the empty string. +func (h *handler) namespaceName(namespaceID string) string { + name, err := h.namespaceRegistry.GetNamespaceName(namespace.ID(namespaceID)) + if err != nil { + return "" + } + return name.String() +} + +func (h *handler) limitsFor(namespaceName string) channel.Limits { + if namespaceName == "" { + return channel.DefaultLimits() + } + return h.config.LimitsFor(namespaceName) +} + +// withCallerInfo attributes the channel's persistence calls to the namespace +// that caused them, so they are rate limited, prioritized and metered as that +// namespace's. +func withCallerInfo(ctx context.Context, namespaceName string) context.Context { + if namespaceName == "" { + return ctx + } + return headers.SetCallerInfo(ctx, headers.NewCallerInfo(namespaceName, headers.CallerTypeAPI, "")) +} + +func channelKey(namespaceID, name string) chasm.ExecutionKey { + return chasm.ExecutionKey{NamespaceID: namespaceID, BusinessID: name} +} + +func channelRef(namespaceID, name string) chasm.ComponentRef { + return chasm.NewComponentRef[*channel.Channel](channelKey(namespaceID, name)) +} + +func workflowRef(namespaceID, workflowID, runID string) chasm.ComponentRef { + return chasm.NewComponentRef[*chasmworkflow.Workflow](chasm.ExecutionKey{ + NamespaceID: namespaceID, + BusinessID: workflowID, + RunID: runID, + }) +} + +func startChannel[I any](mctx chasm.MutableContext, _ I) (*channel.Channel, error) { + return channel.NewChannel(mctx), nil +} + +func executionAbsent(err error) bool { + var notFound *serviceerror.NotFound + return errors.As(err, ¬Found) +} + +// NotifyChannel accepts a notification, creating the channel if this is the +// first anyone has heard of it. A channel with no listeners still retains the +// notification, so a poller that arrives later can catch up. +func (h *handler) NotifyChannel( + ctx context.Context, + req *channelpb.NotifyChannelRequest, +) (*channelpb.NotifyChannelResponse, error) { + in := req.GetFrontendRequest() + ns := h.namespaceName(req.GetNamespaceId()) + ctx = withCallerInfo(ctx, ns) + limits := h.limitsFor(ns) + if err := channel.CheckNotification(in.GetNotification(), limits.MaxMetadataBytes); err != nil { + return nil, err + } + if err := h.limiters.allowNotify(ns, h.timeSource.Now()); err != nil { + return nil, err + } + + result, err := chasm.UpdateWithStartExecution( + ctx, + channelKey(req.GetNamespaceId(), in.GetNotification().GetChannel()), + startChannel[*channelpb.Notification], + func(c *channel.Channel, mctx chasm.MutableContext, n *channelpb.Notification) (channel.NotifyResult, error) { + return c.Notify(mctx, n, limits) + }, + in.GetNotification(), + ) + if err != nil { + return nil, err + } + metrics.ChannelNotificationsAccepted.With(h.metricsHandler).Record(1, metrics.NamespaceTag(ns)) + return &channelpb.NotifyChannelResponse{ + FrontendResponse: &channelpb.NotifyChannelOutput{ + ListenerCount: int64(result.UpdateOutput.ListenerCount), + }, + }, nil +} + +// RegisterChannelListener adds a callback listener, creating the channel if it +// is absent. A retry with the same request id gets the same listener back. +func (h *handler) RegisterChannelListener( + ctx context.Context, + req *channelpb.RegisterChannelListenerRequest, +) (*channelpb.RegisterChannelListenerResponse, error) { + in := req.GetFrontendRequest() + ns := h.namespaceName(req.GetNamespaceId()) + ctx = withCallerInfo(ctx, ns) + if err := channel.CheckChannelName(in.GetChannel(), 0); err != nil { + return nil, err + } + reg := channel.CallbackRegistration{ + RequestID: in.GetRequestId(), + Callback: in.GetCallback(), + Limits: h.limitsFor(ns), + } + result, err := chasm.UpdateWithStartExecution( + ctx, + channelKey(req.GetNamespaceId(), in.GetChannel()), + startChannel[channel.CallbackRegistration], + (*channel.Channel).RegisterCallbackListener, + reg, + ) + if err != nil { + return nil, err + } + return &channelpb.RegisterChannelListenerResponse{ + FrontendResponse: &channelpb.RegisterChannelListenerOutput{ListenerId: result.UpdateOutput}, + }, nil +} + +// UnregisterChannelListener drops a callback listener. A channel or listener +// that is already gone is not an error, so a retry succeeds. +func (h *handler) UnregisterChannelListener( + ctx context.Context, + req *channelpb.UnregisterChannelListenerRequest, +) (*channelpb.UnregisterChannelListenerResponse, error) { + in := req.GetFrontendRequest() + ns := h.namespaceName(req.GetNamespaceId()) + ctx = withCallerInfo(ctx, ns) + limits := h.limitsFor(ns) + _, _, err := chasm.UpdateComponent(ctx, channelRef(req.GetNamespaceId(), in.GetChannel()), + func(c *channel.Channel, mctx chasm.MutableContext, id string) (struct{}, error) { + return struct{}{}, c.UnregisterListener(mctx, id, limits) + }, in.GetListenerId()) + if err != nil && !executionAbsent(err) { + return nil, err + } + return &channelpb.UnregisterChannelListenerResponse{ + FrontendResponse: &channelpb.UnregisterChannelListenerOutput{}, + }, nil +} + +// pollBudget is how long one blocking poll may hold its caller. It ends before +// the caller's own deadline, so expiry is answered with an empty response. +func pollBudget(ctx context.Context) (context.Context, context.CancelFunc) { + return contextutil.WithDeadlineBuffer(ctx, channel.LongPollTimeout, channel.LongPollBuffer) +} + +// pollRecheckInterval is how often a blocking poll on a channel nobody has +// created yet asks again whether it exists. +const pollRecheckInterval = time.Second + +// PollChannel returns the retained notifications above the caller's counter. +// With wait set and none to return, it blocks until a notify brings one or the +// long-poll budget runs out, and then answers with what there is. +func (h *handler) PollChannel( + ctx context.Context, + req *channelpb.PollChannelRequest, +) (*channelpb.PollChannelResponse, error) { + in := req.GetFrontendRequest() + ns := h.namespaceName(req.GetNamespaceId()) + ctx = withCallerInfo(ctx, ns) + metrics.ChannelPollers.With(h.metricsHandler).Record(1, metrics.NamespaceTag(ns)) + ref := channelRef(req.GetNamespaceId(), in.GetChannel()) + limits := h.limitsFor(ns) + + latest, lastActivity, err := h.readLatest(ctx, ref) + if executionAbsent(err) { + if !in.GetWait() { + return &channelpb.PollChannelResponse{FrontendResponse: &channelpb.PollChannelOutput{}}, nil + } + pollCtx, cancel := pollBudget(ctx) + defer cancel() + latest, lastActivity, err = h.waitForChannel(pollCtx, ctx, ref) + if err != nil || pollCtx.Err() != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return &channelpb.PollChannelResponse{FrontendResponse: &channelpb.PollChannelOutput{}}, nil + } + } else if err != nil { + return nil, err + } + + if in.GetWait() && latest <= in.GetAfterCounter() { + pollCtx, cancel := pollBudget(ctx) + defer cancel() + _, _, err := chasm.PollComponent(pollCtx, ref, + func(c *channel.Channel, _ chasm.Context, after int64) (struct{}, bool, error) { + // Monotonic, as PollComponent requires: the latest counter + // only grows. + return struct{}{}, c.LatestCounter() > after, nil + }, in.GetAfterCounter()) + if err != nil && (pollCtx.Err() == nil || ctx.Err() != nil) { + return nil, err + } + } + + notifications, err := chasm.ReadComponent(ctx, ref, (*channel.Channel).Poll, channel.PollRequest{ + AfterCounter: in.GetAfterCounter(), + Max: int(in.GetMaxNotifications()), + }) + if err != nil { + return nil, err + } + h.touchForPoll(ctx, ref, lastActivity, limits) + return &channelpb.PollChannelResponse{ + FrontendResponse: &channelpb.PollChannelOutput{Notifications: notifications}, + }, nil +} + +func (h *handler) readLatest(ctx context.Context, ref chasm.ComponentRef) (int64, time.Time, error) { + snapshot, err := chasm.ReadComponent(ctx, ref, + func(c *channel.Channel, _ chasm.Context, _ struct{}) (channel.Snapshot, error) { + return channel.Snapshot{ + Latest: c.State.GetLatest(), + LastActivityTime: c.State.GetLastActivityTime().AsTime(), + }, nil + }, struct{}{}) + if err != nil { + return 0, time.Time{}, err + } + return snapshot.Latest.GetCounter(), snapshot.LastActivityTime, nil +} + +// waitForChannel parks a blocking poll on a channel nobody has created yet, +// asking again on an interval, so a client can start polling before the first +// notify. +func (h *handler) waitForChannel( + pollCtx, callerCtx context.Context, + ref chasm.ComponentRef, +) (int64, time.Time, error) { + ticker := time.NewTicker(pollRecheckInterval) + defer ticker.Stop() + for { + select { + case <-pollCtx.Done(): + if callerCtx.Err() != nil { + return 0, time.Time{}, callerCtx.Err() + } + return 0, time.Time{}, pollCtx.Err() + case <-ticker.C: + } + latest, lastActivity, err := h.readLatest(pollCtx, ref) + switch { + case err == nil: + return latest, lastActivity, nil + case executionAbsent(err), pollCtx.Err() != nil: + continue + default: + return 0, time.Time{}, err + } + } +} + +// touchForPoll records the poll as activity when the last one is old enough to +// matter to the idle check. A failure here costs nothing the caller needs, so +// it is logged rather than returned. +func (h *handler) touchForPoll( + ctx context.Context, + ref chasm.ComponentRef, + lastActivity time.Time, + limits channel.Limits, +) { + retention := limits.Retention + if retention <= 0 { + retention = channel.DefaultRetention + } + if h.timeSource.Now().Sub(lastActivity) < retention/2 { + return + } + _, _, err := chasm.UpdateComponent(ctx, ref, (*channel.Channel).TouchForPoll, limits) + if err != nil && !executionAbsent(err) { + h.logger.Warn("failed to record a channel poll as activity") + } +} + +// DescribeChannel lists the listeners and reports the latest notification and +// how many are retained. +func (h *handler) DescribeChannel( + ctx context.Context, + req *channelpb.DescribeChannelRequest, +) (*channelpb.DescribeChannelResponse, error) { + in := req.GetFrontendRequest() + ctx = withCallerInfo(ctx, h.namespaceName(req.GetNamespaceId())) + snapshot, err := chasm.ReadComponent(ctx, channelRef(req.GetNamespaceId(), in.GetChannel()), + (*channel.Channel).Describe, struct{}{}) + if err != nil { + return nil, err + } + return &channelpb.DescribeChannelResponse{ + FrontendResponse: &channelpb.DescribeChannelOutput{ + Listeners: snapshot.Listeners, + Latest: snapshot.Latest, + RetainedCount: snapshot.RetainedCount, + }, + }, nil +} + +// RegisterWorkflowListener records a workflow run as a listener on the +// channel's shard, creating the channel if it is absent. +// +// Internal. Called by the completion of the Workflow Task that subscribed, +// which runs on the workflow's shard and cannot reach the channel itself. +func (h *handler) RegisterWorkflowListener( + ctx context.Context, + req *channelpb.RegisterWorkflowListenerRequest, +) (*channelpb.RegisterWorkflowListenerResponse, error) { + in := req.GetFrontendRequest() + ns := h.namespaceName(req.GetNamespaceId()) + ctx = withCallerInfo(ctx, ns) + if err := channel.CheckChannelName(in.GetChannel(), 0); err != nil { + return nil, err + } + reg := channel.WorkflowRegistration{ + WorkflowID: in.GetWorkflowId(), + RunID: in.GetRunId(), + FirstExecutionRunID: in.GetFirstExecutionRunId(), + Limits: h.limitsFor(ns), + } + _, err := chasm.UpdateWithStartExecution( + ctx, + channelKey(req.GetNamespaceId(), in.GetChannel()), + startChannel[channel.WorkflowRegistration], + func(c *channel.Channel, mctx chasm.MutableContext, r channel.WorkflowRegistration) (struct{}, error) { + return struct{}{}, c.RegisterWorkflowListener(mctx, r) + }, + reg, + ) + if err != nil { + return nil, err + } + return &channelpb.RegisterWorkflowListenerResponse{ + FrontendResponse: &channelpb.RegisterWorkflowListenerOutput{}, + }, nil +} + +// listenerProbe is what one run says about a channel: whether it is still +// open, whether it subscribed, and whether it already has this counter. +type listenerProbe struct { + runID string + closed bool + subscribed bool + duplicate bool +} + +// probeListener reads a run without touching it. A run that is gone, or that +// never had a Workflow component and so cannot have subscribed, reads as +// closed: the fan-out only needs to know whether delivering can achieve +// anything. +func (h *handler) probeListener( + ctx context.Context, + namespaceID, workflowID, runID string, + n *channelpb.Notification, +) (listenerProbe, error) { + probe, err := chasm.ReadComponent(ctx, workflowRef(namespaceID, workflowID, runID), + func(wf *chasmworkflow.Workflow, cctx chasm.Context, _ struct{}) (listenerProbe, error) { + p := listenerProbe{ + runID: cctx.ExecutionKey().RunID, + closed: !cctx.ExecutionInfo().CloseTime.IsZero(), + subscribed: wf.SubscribedToChannel(n.GetChannel()), + } + if p.subscribed { + p.duplicate = wf.ChannelNotificationIsDuplicate(cctx, n.GetChannel(), n.GetCounter()) + } + return p, nil + }, struct{}{}) + if executionAbsent(err) { + return listenerProbe{runID: runID, closed: true}, nil + } + return probe, err +} + +func (h *handler) accept( + ctx context.Context, + namespaceID, workflowID, runID string, + n *channelpb.Notification, +) (bool, error) { + folded, _, err := chasm.UpdateComponent(ctx, workflowRef(namespaceID, workflowID, runID), + (*chasmworkflow.Workflow).AcceptChannelNotification, n) + return folded, err +} + +// DeliverChannelNotification hands a notification to one workflow listener, +// on the shard that owns it. +// +// Internal. Called by the channel's fan-out, which runs on the channel's shard. +// The channel has a run on record, and a run ends: if it has, the current run +// is asked instead, and the answer tells the channel to re-key the listener to +// it or, when no run listens, to drop it. +func (h *handler) DeliverChannelNotification( + ctx context.Context, + req *channelpb.DeliverChannelNotificationRequest, +) (*channelpb.DeliverChannelNotificationResponse, error) { + in := req.GetFrontendRequest() + ns := h.namespaceName(req.GetNamespaceId()) + ctx = withCallerInfo(ctx, ns) + namespaceID, workflowID, n := req.GetNamespaceId(), in.GetWorkflowId(), in.GetNotification() + if n.GetChannel() == "" || n.GetCounter() <= 0 { + return nil, serviceerror.NewInvalidArgument("notification needs a channel and a counter") + } + + out := &channelpb.DeliverChannelNotificationOutput{} + target, err := h.probeListener(ctx, namespaceID, workflowID, in.GetRunId(), n) + if err != nil { + return nil, err + } + if target.closed { + pinned := target.runID + if target, err = h.probeListener(ctx, namespaceID, workflowID, "", n); err != nil { + return nil, err + } + if target.closed || target.runID == pinned || !target.subscribed { + out.ListenerClosed = true + return &channelpb.DeliverChannelNotificationResponse{FrontendResponse: out}, nil + } + out.SuccessorRunId = target.runID + } else if !target.subscribed { + out.ListenerClosed = true + return &channelpb.DeliverChannelNotificationResponse{FrontendResponse: out}, nil + } + + // A duplicate leaves the run exactly as it was: reaching the component + // mutably is already a write. + if target.duplicate { + out.Duplicate = true + return &channelpb.DeliverChannelNotificationResponse{FrontendResponse: out}, nil + } + folded, err := h.accept(ctx, namespaceID, workflowID, target.runID, n) + if err != nil { + return nil, err + } + out.Folded = folded + tag := metrics.NamespaceTag(ns) + metrics.ChannelNotificationsDelivered.With(h.metricsHandler).Record(1, tag, workflowKindTag) + if folded { + metrics.ChannelNotificationsFolded.With(h.metricsHandler).Record(1, tag, workflowKindTag) + } + return &channelpb.DeliverChannelNotificationResponse{FrontendResponse: out}, nil +} + +var ( + workflowKindTag = metrics.StringTag("listener_kind", "workflow") + callbackKindTag = metrics.StringTag("listener_kind", "callback") +) diff --git a/chasm/lib/channel/service/library.go b/chasm/lib/channel/service/library.go new file mode 100644 index 00000000000..ca6efadb69d --- /dev/null +++ b/chasm/lib/channel/service/library.go @@ -0,0 +1,86 @@ +package service + +import ( + "go.temporal.io/server/chasm" + "go.temporal.io/server/chasm/lib/channel" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + "google.golang.org/grpc" +) + +const ( + libraryName = "channel" + componentName = "channel" +) + +var ( + Archetype = chasm.FullyQualifiedName(libraryName, componentName) + ArchetypeID = chasm.GenerateTypeID(Archetype) +) + +type library struct { + chasm.UnimplementedLibrary + + handler *handler + fanOut *fanOutTaskHandler + callback *callbackTaskHandler + backoff *callbackBackoffTaskHandler + idle *idleTaskHandler +} + +func newLibrary( + h *handler, + fanOut *fanOutTaskHandler, + callback *callbackTaskHandler, + backoff *callbackBackoffTaskHandler, + idle *idleTaskHandler, +) *library { + return &library{handler: h, fanOut: fanOut, callback: callback, backoff: backoff, idle: idle} +} + +// componentOnlyLibrary registers the component without the service, for a +// process that has to know the type but serves nothing. +type componentOnlyLibrary struct { + chasm.UnimplementedLibrary +} + +func newComponentOnlyLibrary() *componentOnlyLibrary { + return &componentOnlyLibrary{} +} + +func (l *componentOnlyLibrary) Name() string { + return libraryName +} + +func (l *componentOnlyLibrary) Components() []*chasm.RegistrableComponent { + return components() +} + +func components() []*chasm.RegistrableComponent { + return []*chasm.RegistrableComponent{ + chasm.NewRegistrableComponent[*channel.Channel]( + componentName, + chasm.WithBusinessIDAlias("Channel"), + ), + } +} + +func (l *library) Name() string { + return libraryName +} + +func (l *library) Components() []*chasm.RegistrableComponent { + return components() +} + +func (l *library) Tasks() []*chasm.RegistrableTask { + return []*chasm.RegistrableTask{ + chasm.NewRegistrableSideEffectTask("fanOut", l.fanOut), + chasm.NewRegistrableSideEffectTask("callback", l.callback), + chasm.NewRegistrablePureTask("callbackBackoff", l.backoff), + chasm.NewRegistrableSideEffectTask("idle", l.idle), + } +} + +func (l *library) RegisterServices(server *grpc.Server) { + channelpb.RegisterChannelServiceServer(server, l.handler) +} diff --git a/chasm/lib/channel/service/ratelimit.go b/chasm/lib/channel/service/ratelimit.go new file mode 100644 index 00000000000..a868d7194f4 --- /dev/null +++ b/chasm/lib/channel/service/ratelimit.go @@ -0,0 +1,58 @@ +package service + +import ( + "fmt" + "sync" + "time" + + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/api/serviceerror" + "go.temporal.io/server/chasm/lib/channel" + "go.temporal.io/server/common/quotas" +) + +// limiterRefreshInterval is how long a changed rate takes to reach a namespace +// that already has a limiter. +const limiterRefreshInterval = 10 * time.Second + +// notifyLimiters enforces the per-namespace notify rate on this host. One +// limiter per namespace, made on first use and refreshed from config, so a +// change lands without a restart. A rate of zero is not enforced. +type notifyLimiters struct { + config *channel.Config + + mu sync.Mutex + byNamespace map[string]quotas.RateLimiter +} + +func newNotifyLimiters(config *channel.Config) *notifyLimiters { + return ¬ifyLimiters{config: config, byNamespace: make(map[string]quotas.RateLimiter)} +} + +func (l *notifyLimiters) forNamespace(ns string) quotas.RateLimiter { + l.mu.Lock() + defer l.mu.Unlock() + if limiter, ok := l.byNamespace[ns]; ok { + return limiter + } + rate := func() int { return max(l.config.NotifyPerSecond(ns), 0) } + limiter := quotas.NewDynamicRateLimiter(quotas.NewRateBurst( + func() float64 { return float64(rate()) }, + func() int { return max(rate(), 1) }, + ), limiterRefreshInterval) + l.byNamespace[ns] = limiter + return limiter +} + +func (l *notifyLimiters) allowNotify(ns string, now time.Time) error { + rate := l.config.NotifyPerSecond(ns) + if rate <= 0 || l.forNamespace(ns).AllowN(now, 1) { + return nil + } + return &serviceerror.ResourceExhausted{ + Cause: enumspb.RESOURCE_EXHAUSTED_CAUSE_RPS_LIMIT, + Scope: enumspb.RESOURCE_EXHAUSTED_SCOPE_NAMESPACE, + Message: fmt.Sprintf( + "namespace %q is over its limit of %d channel notifications per second", ns, rate), + } +} diff --git a/chasm/lib/channel/service/tasks.go b/chasm/lib/channel/service/tasks.go new file mode 100644 index 00000000000..243628155b7 --- /dev/null +++ b/chasm/lib/channel/service/tasks.go @@ -0,0 +1,451 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + + "go.temporal.io/api/serviceerror" + "go.temporal.io/server/chasm" + "go.temporal.io/server/chasm/lib/callback" + "go.temporal.io/server/chasm/lib/channel" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + "go.temporal.io/server/common/headers" + "go.temporal.io/server/common/log" + "go.temporal.io/server/common/log/tag" + "go.temporal.io/server/common/metrics" + "go.temporal.io/server/common/namespace" + queuescommon "go.temporal.io/server/service/history/queues/common" + queueserrors "go.temporal.io/server/service/history/queues/errors" + "golang.org/x/sync/errgroup" + "google.golang.org/protobuf/encoding/protojson" +) + +// ChannelHeader names the channel on a callback post, so an endpoint serving +// several channels can route without parsing the body. +const ChannelHeader = "Temporal-Notification-Channel" + +// fanOutConcurrency bounds how many workflow listeners one fan-out tells at +// once. A channel holds up to a thousand, and telling them one at a time makes +// the task's runtime grow with that count. +const fanOutConcurrency = 16 + +// backgroundCallerContext tags a task's context with the namespace, since the +// task runs outside any request. The name comes back too: a routed call the +// task makes carries it on the request, which is what the interceptors resolve +// the call against. +func backgroundCallerContext( + ctx context.Context, + registry namespace.Registry, + namespaceID string, +) (context.Context, string) { + name, err := registry.GetNamespaceName(namespace.ID(namespaceID)) + if err != nil { + return ctx, "" + } + return headers.SetCallerInfo(ctx, headers.NewBackgroundLowCallerInfo(name.String())), name.String() +} + +// fanOutTaskHandler hands the channel's latest notification to its listeners. +// Callback listeners are handed it inside the channel's own transition; each +// workflow listener is reached on its own shard through the routed service. +type fanOutTaskHandler struct { + chasm.SideEffectTaskHandlerBase[*channelpb.ChannelFanOutTask] + + namespaceRegistry namespace.Registry + logger log.Logger + metricsHandler metrics.Handler + config *channel.Config + routed channelpb.ChannelServiceClient +} + +func newFanOutTaskHandler( + namespaceRegistry namespace.Registry, + logger log.Logger, + metricsHandler metrics.Handler, + config *channel.Config, + routed channelpb.ChannelServiceClient, +) *fanOutTaskHandler { + return &fanOutTaskHandler{ + namespaceRegistry: namespaceRegistry, + logger: logger, + metricsHandler: metricsHandler, + config: config, + routed: routed, + } +} + +// Validate accepts the task unconditionally. The notify that scheduled it +// raised a flag so later notifies schedule none of their own, and turning the +// task down would leave the flag up with nothing to lower it. +func (h *fanOutTaskHandler) Validate( + _ chasm.Context, + _ *channel.Channel, + _ chasm.TaskInvocation, + _ *channelpb.ChannelFanOutTask, +) (bool, error) { + return true, nil +} + +// Execute delivers the latest notification. The first error comes back so the +// task retries and tells the whole set again; a run that already has the +// counter takes the retry as a duplicate and changes nothing. +func (h *fanOutTaskHandler) Execute( + ctx context.Context, + ref chasm.ComponentRef, + _ chasm.TaskAttributes, + _ *channelpb.ChannelFanOutTask, +) error { + ctx, ns := backgroundCallerContext(ctx, h.namespaceRegistry, ref.NamespaceID) + fanOut, _, err := chasm.UpdateComponent(ctx, ref, (*channel.Channel).TakeFanOut, struct{}{}) + if err != nil { + return err + } + if fanOut.CallbackFolded > 0 { + metrics.ChannelNotificationsFolded.With(h.metricsHandler).Record( + int64(fanOut.CallbackFolded), metrics.NamespaceTag(ns), callbackKindTag) + } + if fanOut.Latest == nil { + return nil + } + + // No cancelling context on purpose: one unreachable listener must not stop + // the others being told. + var group errgroup.Group + group.SetLimit(fanOutConcurrency) + for _, listener := range fanOut.WorkflowListeners { + group.Go(func() error { + return h.deliverOne(ctx, ref, ns, listener, fanOut.Latest) + }) + } + return group.Wait() +} + +// deliverOne tells one workflow listener and acts on its answer. The call +// carries its own deadline, so a listener on a slow host does not take the +// whole task's. +func (h *fanOutTaskHandler) deliverOne( + ctx context.Context, + ref chasm.ComponentRef, + ns string, + listener *channelpb.WorkflowListener, + n *channelpb.Notification, +) error { + callCtx, cancel := context.WithTimeout(ctx, channel.RoutedCallTimeout) + defer cancel() + response, err := h.routed.DeliverChannelNotification(callCtx, &channelpb.DeliverChannelNotificationRequest{ + NamespaceId: ref.NamespaceID, + FrontendRequest: &channelpb.DeliverChannelNotificationInput{ + Namespace: ns, + WorkflowId: listener.GetWorkflowId(), + RunId: listener.GetRunId(), + Notification: n, + }, + }) + if err != nil { + // Not taken as proof the listener is gone. The handler already reads a + // missing run as closed and says so in its answer, so an error here is + // the transport's, and dropping a listener on that would be a guess. + h.logger.Warn("failed to deliver a channel notification to a workflow", + tag.NewStringTag("channel", ref.BusinessID), + tag.WorkflowID(listener.GetWorkflowId()), + tag.Error(err)) + return err + } + out := response.GetFrontendResponse() + limits := h.config.LimitsFor(ns) + switch { + case out.GetListenerClosed(): + _, _, err = chasm.UpdateComponent(ctx, ref, + func(c *channel.Channel, mctx chasm.MutableContext, l *channelpb.WorkflowListener) (struct{}, error) { + c.ForgetWorkflowListener(mctx, l.GetWorkflowId(), l.GetRunId(), limits) + return struct{}{}, nil + }, listener) + case out.GetSuccessorRunId() != "": + _, _, err = chasm.UpdateComponent(ctx, ref, + func(c *channel.Channel, mctx chasm.MutableContext, l *channelpb.WorkflowListener) (struct{}, error) { + c.RekeyWorkflowListener(mctx, l.GetWorkflowId(), l.GetRunId(), out.GetSuccessorRunId()) + return struct{}{}, nil + }, listener) + } + return err +} + +// Discard lowers the coalescing flag the scheduling notify raised. Left up, no +// notify would ever schedule another fan-out. A channel that is gone has no +// flag to lower. +func (h *fanOutTaskHandler) Discard( + ctx context.Context, + ref chasm.ComponentRef, + _ chasm.TaskAttributes, + _ *channelpb.ChannelFanOutTask, +) error { + ctx, _ = backgroundCallerContext(ctx, h.namespaceRegistry, ref.NamespaceID) + _, _, err := chasm.UpdateComponent(ctx, ref, + func(c *channel.Channel, _ chasm.MutableContext, _ struct{}) (struct{}, error) { + c.State.FanOutPending = false + return struct{}{}, nil + }, struct{}{}) + if executionAbsent(err) { + return nil + } + return err +} + +// callbackTaskHandler posts one callback listener's in-flight notification. +// It runs on the outbound queue, so a destination that keeps failing trips the +// same per-destination circuit breaker completion callbacks use, and it goes +// out through the callback library's HTTP caller. +type callbackTaskHandler struct { + chasm.SideEffectTaskHandlerBase[*channelpb.ChannelCallbackTask] + + namespaceRegistry namespace.Registry + logger log.Logger + metricsHandler metrics.Handler + callbackConfig *callback.Config + httpCallerProvider callback.HTTPCallerProvider +} + +func newCallbackTaskHandler( + namespaceRegistry namespace.Registry, + logger log.Logger, + metricsHandler metrics.Handler, + callbackConfig *callback.Config, + httpCallerProvider callback.HTTPCallerProvider, +) *callbackTaskHandler { + return &callbackTaskHandler{ + namespaceRegistry: namespaceRegistry, + logger: logger, + metricsHandler: metricsHandler, + callbackConfig: callbackConfig, + httpCallerProvider: httpCallerProvider, + } +} + +func (h *callbackTaskHandler) Validate( + ctx chasm.Context, + c *channel.Channel, + _ chasm.TaskInvocation, + task *channelpb.ChannelCallbackTask, +) (bool, error) { + _, ok := c.CallbackDeliveryFor(ctx, task) + return ok, nil +} + +func (h *callbackTaskHandler) Execute( + ctx context.Context, + ref chasm.ComponentRef, + attrs chasm.TaskAttributes, + task *channelpb.ChannelCallbackTask, +) error { + ctx, ns := backgroundCallerContext(ctx, h.namespaceRegistry, ref.NamespaceID) + delivery, err := chasm.ReadComponent(ctx, ref, + func(c *channel.Channel, cctx chasm.Context, t *channelpb.ChannelCallbackTask) (channel.CallbackDelivery, error) { + d, ok := c.CallbackDeliveryFor(cctx, t) + if !ok { + return channel.CallbackDelivery{}, errStaleDelivery + } + return d, nil + }, task) + if errors.Is(err, errStaleDelivery) { + return nil + } + if err != nil { + return err + } + + callCtx, cancel := context.WithTimeout(ctx, h.callbackConfig.RequestTimeout(ns, attrs.Destination)) + defer cancel() + retryable, postErr := h.post(callCtx, ref, attrs.Destination, delivery) + if postErr != nil { + h.logger.Warn("channel callback delivery failed", + tag.NewStringTag("channel", ref.BusinessID), + tag.NewStringTag("listener-id", task.GetListenerId()), + tag.Attempt(delivery.Attempt), + tag.Bool("retryable", retryable), + tag.Error(postErr)) + } + + delivered, _, saveErr := chasm.UpdateComponent(ctx, ref, (*channel.Channel).CompleteCallbackDelivery, + channel.CallbackOutcome{ + ListenerID: task.GetListenerId(), + Sequence: task.GetSequence(), + Err: postErr, + Retryable: retryable, + RetryPolicy: h.callbackConfig.RetryPolicy(), + }) + if saveErr == nil && delivered { + metrics.ChannelNotificationsDelivered.With(h.metricsHandler).Record( + 1, metrics.NamespaceTag(ns), callbackKindTag) + } + if postErr != nil && retryable { + // Reported to the queue as the destination being down, which feeds its + // circuit breaker. The retry itself is the backoff task the outcome + // scheduled, so a rerun of this task finds its attempt over. + return queueserrors.NewDestinationDownError(postErr.Error(), saveErr) + } + return saveErr +} + +var errStaleDelivery = errors.New("channel callback delivery is no longer in flight") + +// post sends the notification as JSON, with the callback's own headers and the +// channel named in a header of its own. A 2xx is a delivery. A 4xx other than +// a timeout or a throttle will not succeed on retry, so it is not retried. +func (h *callbackTaskHandler) post( + ctx context.Context, + ref chasm.ComponentRef, + destination string, + delivery channel.CallbackDelivery, +) (bool, error) { + body, err := protojson.Marshal(delivery.Notification) + if err != nil { + return false, err + } + nexus := delivery.Callback.GetNexus() + request, err := http.NewRequestWithContext(ctx, http.MethodPost, nexus.GetUrl(), bytes.NewReader(body)) + if err != nil { + return false, err + } + for key, value := range nexus.GetHeader() { + request.Header.Set(key, value) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set(ChannelHeader, delivery.Notification.GetChannel()) + + caller := h.httpCallerProvider(queuescommon.NamespaceIDAndDestination{ + NamespaceID: ref.NamespaceID, + Destination: destination, + }) + response, err := caller(request) + if err != nil { + return true, err + } + defer func() { + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + }() + if response.StatusCode >= 200 && response.StatusCode < 300 { + return false, nil + } + statusErr := fmt.Errorf("callback endpoint answered %s", response.Status) + switch { + case response.StatusCode == http.StatusRequestTimeout, + response.StatusCode == http.StatusTooManyRequests, + response.StatusCode >= 500: + return true, statusErr + default: + return false, statusErr + } +} + +func (h *callbackTaskHandler) Discard( + _ context.Context, + _ chasm.ComponentRef, + _ chasm.TaskAttributes, + _ *channelpb.ChannelCallbackTask, +) error { + // Nothing to undo: the task has no side effect until it executes. + return nil +} + +// callbackBackoffTaskHandler resumes a callback delivery once its backoff has +// run out. +type callbackBackoffTaskHandler struct { + chasm.PureTaskHandlerBase +} + +func newCallbackBackoffTaskHandler() *callbackBackoffTaskHandler { + return &callbackBackoffTaskHandler{} +} + +func (h *callbackBackoffTaskHandler) Validate( + ctx chasm.Context, + c *channel.Channel, + _ chasm.TaskInvocation, + task *channelpb.ChannelCallbackBackoffTask, +) (bool, error) { + return c.BackoffDone(ctx, task), nil +} + +func (h *callbackBackoffTaskHandler) Execute( + mctx chasm.MutableContext, + c *channel.Channel, + _ chasm.TaskAttributes, + task *channelpb.ChannelCallbackBackoffTask, +) error { + return c.ResumeCallback(mctx, task) +} + +// idleTaskHandler deletes a channel that has had no listeners and no activity +// for a full retention. +type idleTaskHandler struct { + chasm.SideEffectTaskHandlerBase[*channelpb.ChannelIdleTask] + + namespaceRegistry namespace.Registry + config *channel.Config +} + +func newIdleTaskHandler(namespaceRegistry namespace.Registry, config *channel.Config) *idleTaskHandler { + return &idleTaskHandler{namespaceRegistry: namespaceRegistry, config: config} +} + +func (h *idleTaskHandler) Validate( + _ chasm.Context, + c *channel.Channel, + _ chasm.TaskInvocation, + _ *channelpb.ChannelIdleTask, +) (bool, error) { + return c.State.GetIdleCheckPending() && !c.State.GetClosed(), nil +} + +// Execute closes the channel in one transition and deletes it in the next. +// Closing first means a notify or registration that lands in between starts a +// new channel instead of joining the one about to be deleted, and the delete +// names this run so it cannot take that new one. +func (h *idleTaskHandler) Execute( + ctx context.Context, + ref chasm.ComponentRef, + _ chasm.TaskAttributes, + _ *channelpb.ChannelIdleTask, +) error { + ctx, ns := backgroundCallerContext(ctx, h.namespaceRegistry, ref.NamespaceID) + limits := channel.DefaultLimits() + if ns != "" { + limits = h.config.LimitsFor(ns) + } + expired, _, err := chasm.UpdateComponent(ctx, ref, (*channel.Channel).RunIdleCheck, limits) + if err != nil || !expired { + return err + } + key := ref.ExecutionKey + if key.RunID == "" { + return serviceerror.NewInternal("idle channel task has no run id to delete") + } + err = chasm.DeleteExecution[*channel.Channel](ctx, key, chasm.DeleteExecutionRequest{}) + if executionAbsent(err) { + return nil + } + return err +} + +func (h *idleTaskHandler) Discard( + ctx context.Context, + ref chasm.ComponentRef, + _ chasm.TaskAttributes, + _ *channelpb.ChannelIdleTask, +) error { + ctx, _ = backgroundCallerContext(ctx, h.namespaceRegistry, ref.NamespaceID) + _, _, err := chasm.UpdateComponent(ctx, ref, + func(c *channel.Channel, _ chasm.MutableContext, _ struct{}) (struct{}, error) { + c.State.IdleCheckPending = false + return struct{}{}, nil + }, struct{}{}) + if executionAbsent(err) { + return nil + } + return err +} diff --git a/common/metrics/metric_defs.go b/common/metrics/metric_defs.go index 8507de46f23..6bc3c2d13df 100644 --- a/common/metrics/metric_defs.go +++ b/common/metrics/metric_defs.go @@ -797,6 +797,25 @@ var ( "workflow_wake_delivered", WithDescription("The number of wakes handed to a started Workflow Task."), ) + // Notification channel meters, per namespace. + ChannelNotificationsAccepted = NewCounterDef( + "channel_notifications_accepted", + WithDescription("The number of notifications a notification channel accepted."), + ) + ChannelNotificationsDelivered = NewCounterDef( + "channel_notifications_delivered", + WithDescription("The number of notifications handed to a channel listener, tagged by the "+ + "listener kind."), + ) + ChannelNotificationsFolded = NewCounterDef( + "channel_notifications_folded", + WithDescription("The number of notifications that replaced one a listener had not "+ + "been handed yet."), + ) + ChannelPollers = NewCounterDef( + "channel_pollers", + WithDescription("The number of PollChannel calls admitted."), + ) NexusRequests = NewCounterDef( "nexus_requests", WithDescription("The number of Nexus requests received by the service."), diff --git a/service/history/fx.go b/service/history/fx.go index 595bce956c2..4a3271ac068 100644 --- a/service/history/fx.go +++ b/service/history/fx.go @@ -10,6 +10,7 @@ import ( "go.temporal.io/server/chasm" "go.temporal.io/server/chasm/lib/activity" "go.temporal.io/server/chasm/lib/callback" + chasmchannel "go.temporal.io/server/chasm/lib/channel/service" chasmnexus "go.temporal.io/server/chasm/lib/nexusoperation" "go.temporal.io/server/chasm/lib/scheduler" chasmstream "go.temporal.io/server/chasm/lib/stream/service" @@ -129,6 +130,7 @@ var Module = fx.Options( fx.Invoke(hsmnexusworkflow.RegisterCommandHandlers), activity.HistoryModule, chasmstream.HistoryModule, + chasmchannel.HistoryModule, scheduler.Module, callback.Module, chasmnexus.Module, diff --git a/service/history/history_engine.go b/service/history/history_engine.go index d242dbbeb67..5ff00cddd48 100644 --- a/service/history/history_engine.go +++ b/service/history/history_engine.go @@ -14,6 +14,8 @@ import ( replicationspb "go.temporal.io/server/api/replication/v1" workflowspb "go.temporal.io/server/api/workflow/v1" "go.temporal.io/server/chasm" + "go.temporal.io/server/chasm/lib/channel" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" streampb "go.temporal.io/server/chasm/lib/stream/gen/streampb/v1" chasmworkflow "go.temporal.io/server/chasm/lib/workflow" "go.temporal.io/server/client" @@ -110,6 +112,7 @@ type ( engineOptions struct { workflowResendScheduler workflowresend.Scheduler streamClient streampb.StreamServiceClient + channelClient channelpb.ChannelServiceClient } // EngineOption adds optional history engine dependencies without adding required parameters. @@ -152,6 +155,7 @@ type ( workflowResendScheduler workflowresend.Scheduler chasmEngine chasm.Engine streamClient streampb.StreamServiceClient + channelClient channelpb.ChannelServiceClient versionChecker headers.VersionChecker versionCache worker_versioning.VersionMembershipAndReactivationStatusCache workerDeploymentClient workerdeployment.Client @@ -182,6 +186,14 @@ func WithStreamClient(streamClient streampb.StreamServiceClient) EngineOption { } } +// WithChannelClient routes notification channel calls to the shard owning the +// channel. +func WithChannelClient(channelClient channelpb.ChannelServiceClient) EngineOption { + return func(options *engineOptions) { + options.channelClient = channelClient + } +} + func applyEngineOptions(options []EngineOption) engineOptions { var result engineOptions for _, option := range options { @@ -277,6 +289,7 @@ func NewEngineWithShardContext( testHooks: testHooks, chasmEngine: chasmEngine, streamClient: engineOptions.streamClient, + channelClient: engineOptions.channelClient, versionCache: versionCache, workerDeploymentClient: workerDeploymentClient, routingInfoCache: routingInfoCache, @@ -679,6 +692,7 @@ func (e *historyEngineImpl) RespondWorkflowTaskCompleted( e.matchingClient, e.versionCache, ) + ctx = channel.WithClient(ctx, e.channelClient) return h.Invoke(recordworkflowtaskstarted.WithStreamClient(ctx, e.streamClient), req) } diff --git a/service/history/history_engine_factory.go b/service/history/history_engine_factory.go index fb75ae56779..f2abbf9e137 100644 --- a/service/history/history_engine_factory.go +++ b/service/history/history_engine_factory.go @@ -3,6 +3,7 @@ package history import ( "go.opentelemetry.io/otel/trace" "go.temporal.io/server/chasm" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" streampb "go.temporal.io/server/chasm/lib/stream/gen/streampb/v1" chasmworkflow "go.temporal.io/server/chasm/lib/workflow" "go.temporal.io/server/client" @@ -55,6 +56,7 @@ type ( TestHooks testhooks.TestHooks ChasmEngine chasm.Engine StreamClient streampb.StreamServiceClient + ChannelClient channelpb.ChannelServiceClient VersionMembershipCache worker_versioning.VersionMembershipAndReactivationStatusCache WorkerDeploymentClient workerdeployment.Client RoutingInfoCache worker_versioning.RoutingInfoCache @@ -100,5 +102,6 @@ func (f *historyEngineFactory) CreateEngine( f.ChasmEngine, WithWorkflowResendScheduler(f.WorkflowResendScheduler), WithStreamClient(f.StreamClient), + WithChannelClient(f.ChannelClient), ) } From e03d4ad257b5e02991f6e694cae069509f774074 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 17:43:36 -0700 Subject: [PATCH 02/12] Recorded channel subscriptions and pending notifications on the workflow. A run keeps the channels it subscribed to and the latest notification per channel that no scheduled event has carried, and a pending one schedules a Workflow Task through the transaction close. --- chasm/lib/workflow/fx.go | 2 + .../gen/workflowpb/v1/state.go-helpers.pb.go | 74 +++++++++ .../workflow/gen/workflowpb/v1/state.pb.go | 154 +++++++++++++++++- chasm/lib/workflow/notification.go | 153 +++++++++++++++++ chasm/lib/workflow/notification_test.go | 103 ++++++++++++ chasm/lib/workflow/proto/v1/state.proto | 20 +++ chasm/lib/workflow/workflow.go | 12 ++ .../channel_subscriptions.go | 66 ++++++++ .../history/workflow/mutable_state_impl.go | 45 ++++- 9 files changed, 614 insertions(+), 15 deletions(-) create mode 100644 chasm/lib/workflow/notification.go create mode 100644 chasm/lib/workflow/notification_test.go create mode 100644 service/history/api/respondworkflowtaskcompleted/channel_subscriptions.go diff --git a/chasm/lib/workflow/fx.go b/chasm/lib/workflow/fx.go index 8e69ef2f8b8..f80640acb81 100644 --- a/chasm/lib/workflow/fx.go +++ b/chasm/lib/workflow/fx.go @@ -3,6 +3,7 @@ package workflow import ( "go.temporal.io/server/api/historyservice/v1" "go.temporal.io/server/chasm" + "go.temporal.io/server/chasm/lib/channel" "go.temporal.io/server/chasm/lib/nexusoperation" "go.temporal.io/server/chasm/lib/stream" "go.uber.org/fx" @@ -16,6 +17,7 @@ var Module = fx.Module( // Provided here rather than by the stream service module, because the // command handlers need it in every service that runs this library. fx.Provide(stream.NewConfig), + fx.Provide(channel.NewConfig), fx.Invoke(func( chasmRegistry *chasm.Registry, library *library, diff --git a/chasm/lib/workflow/gen/workflowpb/v1/state.go-helpers.pb.go b/chasm/lib/workflow/gen/workflowpb/v1/state.go-helpers.pb.go index f430b87c52f..76c8ce82397 100644 --- a/chasm/lib/workflow/gen/workflowpb/v1/state.go-helpers.pb.go +++ b/chasm/lib/workflow/gen/workflowpb/v1/state.go-helpers.pb.go @@ -152,3 +152,77 @@ func (this *WakeEntry) Equal(that interface{}) bool { return proto.Equal(this, that1) } + +// Marshal an object of type ChannelSubscription to the protobuf v3 wire format +func (val *ChannelSubscription) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type ChannelSubscription from the protobuf v3 wire format +func (val *ChannelSubscription) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *ChannelSubscription) Size() int { + return proto.Size(val) +} + +// Equal returns whether two ChannelSubscription values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *ChannelSubscription) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *ChannelSubscription + switch t := that.(type) { + case *ChannelSubscription: + that1 = t + case ChannelSubscription: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} + +// Marshal an object of type ChannelNotificationEntry to the protobuf v3 wire format +func (val *ChannelNotificationEntry) Marshal() ([]byte, error) { + return proto.Marshal(val) +} + +// Unmarshal an object of type ChannelNotificationEntry from the protobuf v3 wire format +func (val *ChannelNotificationEntry) Unmarshal(buf []byte) error { + return proto.Unmarshal(buf, val) +} + +// Size returns the size of the object, in bytes, once serialized +func (val *ChannelNotificationEntry) Size() int { + return proto.Size(val) +} + +// Equal returns whether two ChannelNotificationEntry values are equivalent by recursively +// comparing the message's fields. +// For more information see the documentation for +// https://pkg.go.dev/google.golang.org/protobuf/proto#Equal +func (this *ChannelNotificationEntry) Equal(that interface{}) bool { + if that == nil { + return this == nil + } + + var that1 *ChannelNotificationEntry + switch t := that.(type) { + case *ChannelNotificationEntry: + that1 = t + case ChannelNotificationEntry: + that1 = &t + default: + return false + } + + return proto.Equal(this, that1) +} diff --git a/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go b/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go index 1680d5b7de2..f5d1dabaf33 100644 --- a/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go +++ b/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go @@ -11,6 +11,7 @@ import ( sync "sync" unsafe "unsafe" + v1 "go.temporal.io/api/common/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" ) @@ -246,11 +247,130 @@ func (x *WakeEntry) GetDeliveredGeneration() int64 { return 0 } +// ChannelSubscription is a notification channel this run subscribed to with a +// command. It lives for the run. +type ChannelSubscription struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The WorkflowNotificationChannelSubscribed event that recorded it. + EventId int64 `protobuf:"varint,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + // Highest counter this run accepted from the channel. A redelivery at or + // below it changes nothing, since the run may already have it in History. + LastCounter int64 `protobuf:"varint,2,opt,name=last_counter,json=lastCounter,proto3" json:"last_counter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelSubscription) Reset() { + *x = ChannelSubscription{} + mi := &file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelSubscription) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelSubscription) ProtoMessage() {} + +func (x *ChannelSubscription) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelSubscription.ProtoReflect.Descriptor instead. +func (*ChannelSubscription) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDescGZIP(), []int{4} +} + +func (x *ChannelSubscription) GetEventId() int64 { + if x != nil { + return x.EventId + } + return 0 +} + +func (x *ChannelSubscription) GetLastCounter() int64 { + if x != nil { + return x.LastCounter + } + return 0 +} + +// ChannelNotificationEntry is the latest notification from one channel that +// no WorkflowTaskScheduled event has carried yet. Deleted when one does. +type ChannelNotificationEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Position []byte `protobuf:"bytes,1,opt,name=position,proto3" json:"position,omitempty"` + Counter int64 `protobuf:"varint,2,opt,name=counter,proto3" json:"counter,omitempty"` + Metadata map[string]*v1.Payload `protobuf:"bytes,3,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelNotificationEntry) Reset() { + *x = ChannelNotificationEntry{} + mi := &file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelNotificationEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelNotificationEntry) ProtoMessage() {} + +func (x *ChannelNotificationEntry) ProtoReflect() protoreflect.Message { + mi := &file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelNotificationEntry.ProtoReflect.Descriptor instead. +func (*ChannelNotificationEntry) Descriptor() ([]byte, []int) { + return file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDescGZIP(), []int{5} +} + +func (x *ChannelNotificationEntry) GetPosition() []byte { + if x != nil { + return x.Position + } + return nil +} + +func (x *ChannelNotificationEntry) GetCounter() int64 { + if x != nil { + return x.Counter + } + return 0 +} + +func (x *ChannelNotificationEntry) GetMetadata() map[string]*v1.Payload { + if x != nil { + return x.Metadata + } + return nil +} + var File_temporal_server_chasm_lib_workflow_proto_v1_state_proto protoreflect.FileDescriptor const file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDesc = "" + "\n" + - "7temporal/server/chasm/lib/workflow/proto/v1/state.proto\x12+temporal.server.chasm.lib.workflow.proto.v1\"|\n" + + "7temporal/server/chasm/lib/workflow/proto/v1/state.proto\x12+temporal.server.chasm.lib.workflow.proto.v1\x1a$temporal/api/common/v1/message.proto\"|\n" + "\x18NexusOperationParentData\x12,\n" + "\x12scheduled_event_id\x18\x01 \x01(\x03R\x10scheduledEventId\x122\n" + "\x15scheduled_event_token\x18\x02 \x01(\fR\x13scheduledEventToken\"K\n" + @@ -264,7 +384,17 @@ const file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDesc = "" "\n" + "generation\x18\x03 \x01(\x03R\n" + "generation\x121\n" + - "\x14delivered_generation\x18\x04 \x01(\x03R\x13deliveredGenerationBDZBgo.temporal.io/server/chasm/lib/workflow/gen/workflowpb;workflowpbb\x06proto3" + "\x14delivered_generation\x18\x04 \x01(\x03R\x13deliveredGeneration\"S\n" + + "\x13ChannelSubscription\x12\x19\n" + + "\bevent_id\x18\x01 \x01(\x03R\aeventId\x12!\n" + + "\flast_counter\x18\x02 \x01(\x03R\vlastCounter\"\x9f\x02\n" + + "\x18ChannelNotificationEntry\x12\x1a\n" + + "\bposition\x18\x01 \x01(\fR\bposition\x12\x18\n" + + "\acounter\x18\x02 \x01(\x03R\acounter\x12o\n" + + "\bmetadata\x18\x03 \x03(\v2S.temporal.server.chasm.lib.workflow.proto.v1.ChannelNotificationEntry.MetadataEntryR\bmetadata\x1a\\\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x125\n" + + "\x05value\x18\x02 \x01(\v2\x1f.temporal.api.common.v1.PayloadR\x05value:\x028\x01BDZBgo.temporal.io/server/chasm/lib/workflow/gen/workflowpb;workflowpbb\x06proto3" var ( file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDescOnce sync.Once @@ -278,19 +408,25 @@ func file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDescGZIP() return file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDescData } -var file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_goTypes = []any{ (*NexusOperationParentData)(nil), // 0: temporal.server.chasm.lib.workflow.proto.v1.NexusOperationParentData (*NexusCancellationParentData)(nil), // 1: temporal.server.chasm.lib.workflow.proto.v1.NexusCancellationParentData (*IncomingSignalData)(nil), // 2: temporal.server.chasm.lib.workflow.proto.v1.IncomingSignalData (*WakeEntry)(nil), // 3: temporal.server.chasm.lib.workflow.proto.v1.WakeEntry + (*ChannelSubscription)(nil), // 4: temporal.server.chasm.lib.workflow.proto.v1.ChannelSubscription + (*ChannelNotificationEntry)(nil), // 5: temporal.server.chasm.lib.workflow.proto.v1.ChannelNotificationEntry + nil, // 6: temporal.server.chasm.lib.workflow.proto.v1.ChannelNotificationEntry.MetadataEntry + (*v1.Payload)(nil), // 7: temporal.api.common.v1.Payload } var file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 6, // 0: temporal.server.chasm.lib.workflow.proto.v1.ChannelNotificationEntry.metadata:type_name -> temporal.server.chasm.lib.workflow.proto.v1.ChannelNotificationEntry.MetadataEntry + 7, // 1: temporal.server.chasm.lib.workflow.proto.v1.ChannelNotificationEntry.MetadataEntry.value:type_name -> temporal.api.common.v1.Payload + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_init() } @@ -304,7 +440,7 @@ func file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDesc), len(file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/chasm/lib/workflow/notification.go b/chasm/lib/workflow/notification.go new file mode 100644 index 00000000000..4fe3b48f969 --- /dev/null +++ b/chasm/lib/workflow/notification.go @@ -0,0 +1,153 @@ +package workflow + +import ( + "maps" + "slices" + + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/api/serviceerror" + "go.temporal.io/server/chasm" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + chasmworkflowpb "go.temporal.io/server/chasm/lib/workflow/gen/workflowpb/v1" + "go.temporal.io/server/common" +) + +// SubscribedToChannel reports whether this run subscribed to the channel. +// The channel's fan-out asks, through a probe of the run, before it delivers, +// and a run that does not answer yes is dropped from the channel. +func (w *Workflow) SubscribedToChannel(channel string) bool { + _, ok := w.ChannelSubscriptions[channel] + return ok +} + +// ChannelSubscriptionCount is how many channels this run subscribed to, +// counting those its open Workflow Task is about to register. +func (w *Workflow) ChannelSubscriptionCount() int { + return len(w.ChannelSubscriptions) +} + +// RecordChannelSubscription records that this run subscribed to a channel, +// at the event that says so. A second subscription to the same channel keeps +// the first record. The limit bounds what one run may hold; zero means none. +// +// It reports whether the subscription is new, which is when the channel has +// to be told. +func (w *Workflow) RecordChannelSubscription( + mctx chasm.MutableContext, + channel string, + eventID int64, + limit int, +) (bool, error) { + if _, ok := w.ChannelSubscriptions[channel]; ok { + return false, nil + } + if limit > 0 && len(w.ChannelSubscriptions) >= limit { + return false, serviceerror.NewResourceExhaustedf( + enumspb.RESOURCE_EXHAUSTED_CAUSE_CONCURRENT_LIMIT, + "workflow already subscribes to %d notification channels, which is the limit", + len(w.ChannelSubscriptions)) + } + if w.ChannelSubscriptions == nil { + w.ChannelSubscriptions = make(chasm.Map[string, *chasmworkflowpb.ChannelSubscription]) + } + w.ChannelSubscriptions[channel] = chasm.NewDataField(mctx, + &chasmworkflowpb.ChannelSubscription{EventId: eventID}) + return true, nil +} + +// StageChannelRegistration queues a channel for the completion path to +// register this run on. A command handler holds the state lock and cannot +// reach the channel's shard, so the registration happens after the commands +// and before the commit. +func (w *Workflow) StageChannelRegistration(channel string) { + w.pendingChannelRegistrations = append(w.pendingChannelRegistrations, channel) +} + +// DrainChannelRegistrations returns and clears the staged registrations. +func (w *Workflow) DrainChannelRegistrations() []string { + out := w.pendingChannelRegistrations + w.pendingChannelRegistrations = nil + return out +} + +// ChannelNotificationIsDuplicate reports whether accepting the notification +// would change nothing: the run already accepted this counter or a higher one +// from the channel, or does not listen to it at all. +func (w *Workflow) ChannelNotificationIsDuplicate( + ctx chasm.Context, + channel string, + counter int64, +) bool { + field, ok := w.ChannelSubscriptions[channel] + if !ok { + return true + } + return counter <= field.Get(readOnly(ctx)).GetLastCounter() +} + +// AcceptChannelNotification records a notification from a channel this run +// listens to, for the next WorkflowTaskScheduled event to carry. Nothing is +// written to History here; the transaction close schedules the Workflow Task +// whose scheduled event carries it. +// +// One entry per channel. A newer notification replaces one no scheduled event +// has carried yet, and the report says it folded. A counter at or below the +// highest this run accepted is dropped: a run may already have that one in +// History, and the channel redelivers after a fan-out it has to retry. +func (w *Workflow) AcceptChannelNotification( + mctx chasm.MutableContext, + n *channelpb.Notification, +) (bool, error) { + channel := n.GetChannel() + field, ok := w.ChannelSubscriptions[channel] + if !ok { + return false, serviceerror.NewFailedPreconditionf( + "workflow does not listen to notification channel %q", channel) + } + if n.GetCounter() <= field.Get(readOnly(mctx)).GetLastCounter() { + return false, nil + } + field.Get(mctx).LastCounter = n.GetCounter() + + entry := &chasmworkflowpb.ChannelNotificationEntry{ + Position: n.GetPosition(), + Counter: n.GetCounter(), + Metadata: common.CloneProto(n).GetMetadata(), + } + if w.ChannelNotifications == nil { + w.ChannelNotifications = make(chasm.Map[string, *chasmworkflowpb.ChannelNotificationEntry]) + } + _, folded := w.ChannelNotifications[channel] + w.ChannelNotifications[channel] = chasm.NewDataField(mctx, entry) + return folded, nil +} + +// HasPendingChannelNotifications reports whether a notification is waiting +// for a scheduled event. Only the keys are read, so nothing is loaded. +func (w *Workflow) HasPendingChannelNotifications() bool { + return len(w.ChannelNotifications) > 0 +} + +// TakeChannelNotifications returns the pending notifications, one per +// channel and sorted by channel, and clears them. Called for a +// WorkflowTaskScheduled event that is being written to History, which is +// what acknowledges them: nothing is redelivered once an event carries it. +func (w *Workflow) TakeChannelNotifications(mctx chasm.MutableContext) []*channelpb.Notification { + if len(w.ChannelNotifications) == 0 { + return nil + } + view := readOnly(mctx) + channels := slices.Sorted(maps.Keys(w.ChannelNotifications)) + out := make([]*channelpb.Notification, 0, len(channels)) + for _, channel := range channels { + entry := w.ChannelNotifications[channel].Get(view) + out = append(out, &channelpb.Notification{ + Channel: channel, + Position: entry.GetPosition(), + Counter: entry.GetCounter(), + Metadata: entry.GetMetadata(), + }) + delete(w.ChannelNotifications, channel) + } + return out +} diff --git a/chasm/lib/workflow/notification_test.go b/chasm/lib/workflow/notification_test.go new file mode 100644 index 00000000000..affa5b01abd --- /dev/null +++ b/chasm/lib/workflow/notification_test.go @@ -0,0 +1,103 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/require" + commonpb "go.temporal.io/api/common/v1" + "go.temporal.io/api/serviceerror" + "go.temporal.io/server/chasm" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" +) + +func channelNote(channel string, counter int64) *channelpb.Notification { + return &channelpb.Notification{ + Channel: channel, + Counter: counter, + Position: []byte{byte(counter)}, + Metadata: map[string]*commonpb.Payload{"k": {Data: []byte{byte(counter)}}}, + } +} + +func subscribe(t *testing.T, w *Workflow, ctx chasm.MutableContext, channel string) { + t.Helper() + added, err := w.RecordChannelSubscription(ctx, channel, 5, 0) + require.NoError(t, err) + require.True(t, added) +} + +// Notifications fold to the highest counter per channel, and a scheduled +// event takes one per channel, sorted, and clears them. +func TestChannelNotificationsFoldAndSnapshot(t *testing.T) { + ctx := &chasm.MockMutableContext{} + w := &Workflow{} + subscribe(t, w, ctx, "orders") + subscribe(t, w, ctx, "billing") + + folded, err := w.AcceptChannelNotification(ctx, channelNote("orders", 1)) + require.NoError(t, err) + require.False(t, folded) + folded, err = w.AcceptChannelNotification(ctx, channelNote("orders", 3)) + require.NoError(t, err) + require.True(t, folded) + folded, err = w.AcceptChannelNotification(ctx, channelNote("orders", 2)) + require.NoError(t, err) + require.False(t, folded, "a lower counter is dropped, not folded") + _, err = w.AcceptChannelNotification(ctx, channelNote("billing", 7)) + require.NoError(t, err) + require.True(t, w.HasPendingChannelNotifications()) + + taken := w.TakeChannelNotifications(ctx) + require.Len(t, taken, 2) + require.Equal(t, "billing", taken[0].GetChannel()) + require.Equal(t, "orders", taken[1].GetChannel()) + require.Equal(t, int64(3), taken[1].GetCounter()) + require.Equal(t, []byte{3}, taken[1].GetPosition()) + require.Equal(t, []byte{3}, taken[1].GetMetadata()["k"].GetData()) + require.False(t, w.HasPendingChannelNotifications()) + require.Empty(t, w.TakeChannelNotifications(ctx)) +} + +// What a scheduled event carried is in History, so a redelivery of the same +// counter after the snapshot changes nothing. +func TestChannelNotificationRedeliveryIsDuplicate(t *testing.T) { + ctx := &chasm.MockMutableContext{} + w := &Workflow{} + subscribe(t, w, ctx, "orders") + _, err := w.AcceptChannelNotification(ctx, channelNote("orders", 4)) + require.NoError(t, err) + w.TakeChannelNotifications(ctx) + + require.True(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 4)) + require.False(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 5)) + _, err = w.AcceptChannelNotification(ctx, channelNote("orders", 4)) + require.NoError(t, err) + require.False(t, w.HasPendingChannelNotifications()) +} + +func TestChannelSubscriptionRules(t *testing.T) { + ctx := &chasm.MockMutableContext{} + w := &Workflow{} + + _, err := w.AcceptChannelNotification(ctx, channelNote("orders", 1)) + var precondition *serviceerror.FailedPrecondition + require.ErrorAs(t, err, &precondition, "a run that did not subscribe takes nothing") + require.True(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 1)) + + added, err := w.RecordChannelSubscription(ctx, "orders", 5, 1) + require.NoError(t, err) + require.True(t, added) + added, err = w.RecordChannelSubscription(ctx, "orders", 9, 1) + require.NoError(t, err) + require.False(t, added, "a repeat keeps the first record") + require.Equal(t, int64(5), w.ChannelSubscriptions["orders"].Get(ctx).GetEventId()) + _, err = w.RecordChannelSubscription(ctx, "billing", 10, 1) + var exhausted *serviceerror.ResourceExhausted + require.ErrorAs(t, err, &exhausted) + require.True(t, w.SubscribedToChannel("orders")) + require.False(t, w.SubscribedToChannel("billing")) + + w.StageChannelRegistration("orders") + require.Equal(t, []string{"orders"}, w.DrainChannelRegistrations()) + require.Empty(t, w.DrainChannelRegistrations()) +} diff --git a/chasm/lib/workflow/proto/v1/state.proto b/chasm/lib/workflow/proto/v1/state.proto index 519c390a1c6..6c9937e1dc6 100644 --- a/chasm/lib/workflow/proto/v1/state.proto +++ b/chasm/lib/workflow/proto/v1/state.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package temporal.server.chasm.lib.workflow.proto.v1; +import "temporal/api/common/v1/message.proto"; + option go_package = "go.temporal.io/server/chasm/lib/workflow/gen/workflowpb;workflowpb"; // NexusOperationParentData contains workflow-specific data stored in a nexus operation's @@ -39,3 +41,21 @@ message WakeEntry { // Generation handed to the last Workflow Task that started with this entry. int64 delivered_generation = 4; } + +// ChannelSubscription is a notification channel this run subscribed to with a +// command. It lives for the run. +message ChannelSubscription { + // The WorkflowNotificationChannelSubscribed event that recorded it. + int64 event_id = 1; + // Highest counter this run accepted from the channel. A redelivery at or + // below it changes nothing, since the run may already have it in History. + int64 last_counter = 2; +} + +// ChannelNotificationEntry is the latest notification from one channel that +// no WorkflowTaskScheduled event has carried yet. Deleted when one does. +message ChannelNotificationEntry { + bytes position = 1; + int64 counter = 2; + map metadata = 3; +} diff --git a/chasm/lib/workflow/workflow.go b/chasm/lib/workflow/workflow.go index a7398c25d99..77527496fdf 100644 --- a/chasm/lib/workflow/workflow.go +++ b/chasm/lib/workflow/workflow.go @@ -63,6 +63,18 @@ type Workflow struct { // writes nothing to History, so this table is the only record of it. Wakes chasm.Map[string, *chasmworkflowpb.WakeEntry] + // Notification channels this run subscribed to, keyed by channel name. + // Not carried to a successor run, which subscribes again if it listens. + ChannelSubscriptions chasm.Map[string, *chasmworkflowpb.ChannelSubscription] + + // The latest notification per channel that no WorkflowTaskScheduled event + // has carried. The event that carries one is its acknowledgment. + ChannelNotifications chasm.Map[string, *chasmworkflowpb.ChannelNotificationEntry] + + // Channels a subscribe command in the open Workflow Task has to register + // this run on. In memory only, drained before commit. + pendingChannelRegistrations []string + // Subscribe commands whose stream is in another execution, so the addressing // has to be looked up before a cursor can be made. In memory only, drained // by the flush before commit. diff --git a/service/history/api/respondworkflowtaskcompleted/channel_subscriptions.go b/service/history/api/respondworkflowtaskcompleted/channel_subscriptions.go new file mode 100644 index 00000000000..b1fcfe4f072 --- /dev/null +++ b/service/history/api/respondworkflowtaskcompleted/channel_subscriptions.go @@ -0,0 +1,66 @@ +package respondworkflowtaskcompleted + +import ( + "context" + + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/api/serviceerror" + "go.temporal.io/server/chasm/lib/channel" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + chasmworkflow "go.temporal.io/server/chasm/lib/workflow" + "go.temporal.io/server/service/history/api/recordworkflowtaskstarted" + historyi "go.temporal.io/server/service/history/interfaces" +) + +// registerStagedChannelListeners registers this run on every channel a +// subscribe command in the task named, on each channel's own shard. +// +// The command handler recorded the subscription and wrote its event, but it +// holds the state lock with nowhere to do I/O from, so the registration +// happens here, between the commands and the commit. A refusal fails the task +// with the given cause, which rolls the subscription and its event back with +// it. A registration that succeeded before a later one was refused stays on +// its channel; the channel's next fan-out finds the run does not listen and +// drops it. +func registerStagedChannelListeners( + ctx context.Context, + ms historyi.MutableState, + cause enumspb.WorkflowTaskFailedCause, + staged []string, +) error { + if len(staged) == 0 { + return nil + } + client, ok := channel.ClientFromContext(ctx) + if !ok { + return serviceerror.NewInternal( + "channel service client is not available on the workflow task completion path") + } + + // One budget over the whole set, since the execution's lock is held for + // all of them. + ctx, cancelBudget := recordworkflowtaskstarted.WithRoutedBudget(ctx) + defer cancelBudget() + + key := ms.GetWorkflowKey() + namespaceName := ms.GetNamespaceEntry().Name().String() + firstRunID := ms.GetExecutionInfo().GetFirstExecutionRunId() + for _, name := range staged { + callCtx, cancel := context.WithTimeout(ctx, channel.RoutedCallTimeout) + _, err := client.RegisterWorkflowListener(callCtx, &channelpb.RegisterWorkflowListenerRequest{ + NamespaceId: key.NamespaceID, + FrontendRequest: &channelpb.RegisterWorkflowListenerInput{ + Namespace: namespaceName, + Channel: name, + WorkflowId: key.WorkflowID, + RunId: key.RunID, + FirstExecutionRunId: firstRunID, + }, + }) + cancel() + if err != nil { + return chasmworkflow.StreamAdmissionFailure(cause, err) + } + } + return nil +} diff --git a/service/history/workflow/mutable_state_impl.go b/service/history/workflow/mutable_state_impl.go index f9922064879..e12949f80e9 100644 --- a/service/history/workflow/mutable_state_impl.go +++ b/service/history/workflow/mutable_state_impl.go @@ -37,6 +37,7 @@ import ( tokenspb "go.temporal.io/server/api/token/v1" workflowspb "go.temporal.io/server/api/workflow/v1" "go.temporal.io/server/chasm" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" chasmworkflow "go.temporal.io/server/chasm/lib/workflow" "go.temporal.io/server/common" "go.temporal.io/server/common/backoff" @@ -731,12 +732,13 @@ func (ms *MutableStateImpl) carryStreamSubscriptionsTo(newMutableState *MutableS } // HasPendingStreamData reports whether a subscription of this workflow has -// offsets left to deliver, or a wake has arrived that no started workflow task -// carried. These are the conditions under which stream traffic and wakes -// schedule a workflow task; neither writes an event that would. +// offsets left to deliver, a wake has arrived that no started workflow task +// carried, or a channel notification is waiting for a scheduled event. These +// are the conditions under which stream traffic, wakes and notifications +// schedule a workflow task; none of them writes an event that would. // -// A wake does not cut short a first workflow task backoff: the task that ends -// the backoff carries it. +// A wake or a notification does not cut short a first workflow task backoff: +// the task that ends the backoff carries it. // // Called on every transaction close for every workflow, almost none of which // have a subscription or a wake, so it resolves the root component once rather @@ -758,7 +760,38 @@ func (ms *MutableStateImpl) HasPendingStreamData() bool { if wf.StreamCursorsBehind(chasmCtx) { return true } - return wf.HasUndeliveredWakes(chasmCtx) && !ms.IsWorkflowPendingOnWorkflowTaskBackoff() + if !wf.HasUndeliveredWakes(chasmCtx) && !wf.HasPendingChannelNotifications() { + return false + } + return !ms.IsWorkflowPendingOnWorkflowTaskBackoff() +} + +// takeChannelNotifications hands the pending channel notifications to a +// WorkflowTaskScheduled event that is being written, and clears them. The +// event is the acknowledgment: once History carries a notification nothing +// redelivers it. +// +// Called for every scheduled event, almost none of which have anything to +// carry, so the keys are checked through a read-only view first and the +// component is only taken for writing when there is something to take. +func (ms *MutableStateImpl) takeChannelNotifications() []*channelpb.Notification { + node, ok := ms.chasmTree.(*chasm.Node) + if !ok { + return nil + } + view := chasm.NewContext(context.Background(), node) + rootComponent, err := node.ComponentByPath(view, nil) + if err != nil { + return nil + } + if wf, ok := rootComponent.(*chasmworkflow.Workflow); !ok || !wf.HasPendingChannelNotifications() { + return nil + } + wf, chasmCtx, err := ms.ChasmWorkflowComponent(context.Background()) + if err != nil { + return nil + } + return wf.TakeChannelNotifications(chasmCtx) } // ConsumesStreams reports whether this workflow holds a cursor into any From 1e95a6664385667f9d5b817b05e0d05cc703ebd1 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 18:16:52 -0700 Subject: [PATCH 03/12] Created a missing channel in its own transition before updating it. The update half of an update-with-start runs after the new execution's structure is synced, so a listener added there was dropped. The poll wait is a duration, as in the public request. --- .../gen/channelpb/v1/request_response.pb.go | 62 +++++---- .../channel/proto/v1/request_response.proto | 6 +- chasm/lib/channel/service/handler.go | 130 +++++++++++++----- chasm/lib/channel/service/tasks.go | 2 + 4 files changed, 133 insertions(+), 67 deletions(-) diff --git a/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go b/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go index bb6a9cc8140..03b9b93f21d 100644 --- a/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go +++ b/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go @@ -14,6 +14,7 @@ import ( v1 "go.temporal.io/api/common/v1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" ) @@ -655,8 +656,9 @@ type PollChannelInput struct { Channel string `protobuf:"bytes,2,opt,name=channel,proto3" json:"channel,omitempty"` // Return retained notifications with a counter above this one. AfterCounter int64 `protobuf:"varint,3,opt,name=after_counter,json=afterCounter,proto3" json:"after_counter,omitempty"` - // Block until one arrives when none is retained above after_counter. - Wait bool `protobuf:"varint,4,opt,name=wait,proto3" json:"wait,omitempty"` + // How long to wait for one when none is retained above after_counter. + // Bounded by the server's long-poll timeout. Zero answers at once. + Wait *durationpb.Duration `protobuf:"bytes,4,opt,name=wait,proto3" json:"wait,omitempty"` // Zero means the server's page size. MaxNotifications int32 `protobuf:"varint,5,opt,name=max_notifications,json=maxNotifications,proto3" json:"max_notifications,omitempty"` unknownFields protoimpl.UnknownFields @@ -714,11 +716,11 @@ func (x *PollChannelInput) GetAfterCounter() int64 { return 0 } -func (x *PollChannelInput) GetWait() bool { +func (x *PollChannelInput) GetWait() *durationpb.Duration { if x != nil { return x.Wait } - return false + return nil } func (x *PollChannelInput) GetMaxNotifications() int32 { @@ -1679,7 +1681,7 @@ var File_temporal_server_chasm_lib_channel_proto_v1_request_response_proto proto const file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDesc = "" + "\n" + - "Atemporal/server/chasm/lib/channel/proto/v1/request_response.proto\x12*temporal.server.chasm.lib.channel.proto.v1\x1a8temporal/server/chasm/lib/channel/proto/v1/message.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\"\xcb\x01\n" + + "Atemporal/server/chasm/lib/channel/proto/v1/request_response.proto\x12*temporal.server.chasm.lib.channel.proto.v1\x1a8temporal/server/chasm/lib/channel/proto/v1/message.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a$temporal/api/common/v1/message.proto\"\xcb\x01\n" + "\x12NotifyChannelInput\x12\x1c\n" + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\\\n" + "\fnotification\x18\x02 \x01(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\fnotification\x12\x1a\n" + @@ -1719,12 +1721,12 @@ const file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_raw "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12u\n" + "\x10frontend_request\x18\x02 \x01(\v2J.temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerInputR\x0ffrontendRequest\"\x9d\x01\n" + "!UnregisterChannelListenerResponse\x12x\n" + - "\x11frontend_response\x18\x01 \x01(\v2K.temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerOutputR\x10frontendResponse\"\xb0\x01\n" + + "\x11frontend_response\x18\x01 \x01(\v2K.temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerOutputR\x10frontendResponse\"\xcb\x01\n" + "\x10PollChannelInput\x12\x1c\n" + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x18\n" + "\achannel\x18\x02 \x01(\tR\achannel\x12#\n" + - "\rafter_counter\x18\x03 \x01(\x03R\fafterCounter\x12\x12\n" + - "\x04wait\x18\x04 \x01(\bR\x04wait\x12+\n" + + "\rafter_counter\x18\x03 \x01(\x03R\fafterCounter\x12-\n" + + "\x04wait\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\x04wait\x12+\n" + "\x11max_notifications\x18\x05 \x01(\x05R\x10maxNotifications\"s\n" + "\x11PollChannelOutput\x12^\n" + "\rnotifications\x18\x01 \x03(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\rnotifications\"\xa0\x01\n" + @@ -1832,7 +1834,8 @@ var file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_goTyp (*ChannelListenerInfo_Workflow)(nil), // 29: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.Workflow (*Notification)(nil), // 30: temporal.server.chasm.lib.channel.proto.v1.Notification (*v1.Callback)(nil), // 31: temporal.api.common.v1.Callback - (*timestamppb.Timestamp)(nil), // 32: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 32: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 33: google.protobuf.Timestamp } var file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_depIdxs = []int32{ 30, // 0: temporal.server.chasm.lib.channel.proto.v1.NotifyChannelInput.notification:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification @@ -1843,26 +1846,27 @@ var file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_depId 5, // 5: temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterChannelListenerOutput 8, // 6: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerInput 9, // 7: temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.UnregisterChannelListenerOutput - 30, // 8: temporal.server.chasm.lib.channel.proto.v1.PollChannelOutput.notifications:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification - 12, // 9: temporal.server.chasm.lib.channel.proto.v1.PollChannelRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.PollChannelInput - 13, // 10: temporal.server.chasm.lib.channel.proto.v1.PollChannelResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.PollChannelOutput - 29, // 11: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.workflow:type_name -> temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.Workflow - 31, // 12: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.callback:type_name -> temporal.api.common.v1.Callback - 32, // 13: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.registered_time:type_name -> google.protobuf.Timestamp - 17, // 14: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput.listeners:type_name -> temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo - 30, // 15: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput.latest:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification - 16, // 16: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelInput - 18, // 17: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput - 21, // 18: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerInput - 22, // 19: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerOutput - 30, // 20: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput.notification:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification - 25, // 21: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput - 26, // 22: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationOutput - 23, // [23:23] is the sub-list for method output_type - 23, // [23:23] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name + 32, // 8: temporal.server.chasm.lib.channel.proto.v1.PollChannelInput.wait:type_name -> google.protobuf.Duration + 30, // 9: temporal.server.chasm.lib.channel.proto.v1.PollChannelOutput.notifications:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 12, // 10: temporal.server.chasm.lib.channel.proto.v1.PollChannelRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.PollChannelInput + 13, // 11: temporal.server.chasm.lib.channel.proto.v1.PollChannelResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.PollChannelOutput + 29, // 12: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.workflow:type_name -> temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.Workflow + 31, // 13: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.callback:type_name -> temporal.api.common.v1.Callback + 33, // 14: temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo.registered_time:type_name -> google.protobuf.Timestamp + 17, // 15: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput.listeners:type_name -> temporal.server.chasm.lib.channel.proto.v1.ChannelListenerInfo + 30, // 16: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput.latest:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 16, // 17: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelInput + 18, // 18: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput + 21, // 19: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerInput + 22, // 20: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerOutput + 30, // 21: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput.notification:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 25, // 22: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput + 26, // 23: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationOutput + 24, // [24:24] is the sub-list for method output_type + 24, // [24:24] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name } func init() { file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_init() } diff --git a/chasm/lib/channel/proto/v1/request_response.proto b/chasm/lib/channel/proto/v1/request_response.proto index 4de68dbfc46..1fc9896f076 100644 --- a/chasm/lib/channel/proto/v1/request_response.proto +++ b/chasm/lib/channel/proto/v1/request_response.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package temporal.server.chasm.lib.channel.proto.v1; import "chasm/lib/channel/proto/v1/message.proto"; +import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; import "temporal/api/common/v1/message.proto"; @@ -78,8 +79,9 @@ message PollChannelInput { string channel = 2; // Return retained notifications with a counter above this one. int64 after_counter = 3; - // Block until one arrives when none is retained above after_counter. - bool wait = 4; + // How long to wait for one when none is retained above after_counter. + // Bounded by the server's long-poll timeout. Zero answers at once. + google.protobuf.Duration wait = 4; // Zero means the server's page size. int32 max_notifications = 5; } diff --git a/chasm/lib/channel/service/handler.go b/chasm/lib/channel/service/handler.go index a9dfdc16052..691a09236f0 100644 --- a/chasm/lib/channel/service/handler.go +++ b/chasm/lib/channel/service/handler.go @@ -90,10 +90,48 @@ func workflowRef(namespaceID, workflowID, runID string) chasm.ComponentRef { }) } -func startChannel[I any](mctx chasm.MutableContext, _ I) (*channel.Channel, error) { +func startChannel(mctx chasm.MutableContext, _ struct{}) (*channel.Channel, error) { return channel.NewChannel(mctx), nil } +// errChannelClosed refuses an update to a channel the idle task has closed on +// its way to deleting it, so the caller starts a new one instead. +var errChannelClosed = serviceerror.NewNotFound("channel is closed") + +// upsert applies an update to the channel, creating the channel first when +// there is none or the one there is closed. +// +// Creation is a transition of its own rather than an update-with-start: the +// engine applies the update of an update-with-start after it has synced the +// new execution's structure, so listeners and retained notifications added by +// that update would not be persisted. +func upsert[O any]( + ctx context.Context, + namespaceID, name string, + update func(*channel.Channel, chasm.MutableContext) (O, error), + opts ...chasm.TransitionOption, +) (O, error) { + ref := channelRef(namespaceID, name) + apply := func(c *channel.Channel, mctx chasm.MutableContext, _ struct{}) (O, error) { + if c.State.GetClosed() { + var zero O + return zero, errChannelClosed + } + return update(c, mctx) + } + out, _, err := chasm.UpdateComponent(ctx, ref, apply, struct{}{}, opts...) + if !executionAbsent(err) { + return out, err + } + _, err = chasm.StartExecution(ctx, channelKey(namespaceID, name), startChannel, struct{}{}) + if _, started := errors.AsType[*chasm.ExecutionAlreadyStartedError](err); err != nil && !started { + var zero O + return zero, err + } + out, _, err = chasm.UpdateComponent(ctx, ref, apply, struct{}{}, opts...) + return out, err +} + func executionAbsent(err error) bool { var notFound *serviceerror.NotFound return errors.As(err, ¬Found) @@ -117,26 +155,52 @@ func (h *handler) NotifyChannel( return nil, err } - result, err := chasm.UpdateWithStartExecution( - ctx, - channelKey(req.GetNamespaceId(), in.GetNotification().GetChannel()), - startChannel[*channelpb.Notification], - func(c *channel.Channel, mctx chasm.MutableContext, n *channelpb.Notification) (channel.NotifyResult, error) { - return c.Notify(mctx, n, limits) - }, - in.GetNotification(), - ) + count, err := h.notify(ctx, req.GetNamespaceId(), in, limits) + if errors.Is(err, chasm.ErrRequestIDAlreadyUsed) { + // A retry of a notify that was accepted. Answered with the listeners + // the channel holds now, without accepting it a second time. + snapshot, readErr := chasm.ReadComponent(ctx, + channelRef(req.GetNamespaceId(), in.GetNotification().GetChannel()), + (*channel.Channel).Describe, struct{}{}) + if readErr != nil { + return nil, readErr + } + return &channelpb.NotifyChannelResponse{ + FrontendResponse: &channelpb.NotifyChannelOutput{ListenerCount: int64(len(snapshot.Listeners))}, + }, nil + } if err != nil { return nil, err } metrics.ChannelNotificationsAccepted.With(h.metricsHandler).Record(1, metrics.NamespaceTag(ns)) return &channelpb.NotifyChannelResponse{ FrontendResponse: &channelpb.NotifyChannelOutput{ - ListenerCount: int64(result.UpdateOutput.ListenerCount), + ListenerCount: int64(count), }, }, nil } +// notify accepts the notification on the channel, creating it if absent. A +// request id makes a retry of an accepted notify a no-op: the engine records +// it with the update and refuses its reuse. +func (h *handler) notify( + ctx context.Context, + namespaceID string, + in *channelpb.NotifyChannelInput, + limits channel.Limits, +) (int, error) { + n := in.GetNotification() + var opts []chasm.TransitionOption + if in.GetRequestId() != "" { + opts = append(opts, chasm.WithRequestID(in.GetRequestId())) + } + result, err := upsert(ctx, namespaceID, n.GetChannel(), + func(c *channel.Channel, mctx chasm.MutableContext) (channel.NotifyResult, error) { + return c.Notify(mctx, n, limits) + }, opts...) + return result.ListenerCount, err +} + // RegisterChannelListener adds a callback listener, creating the channel if it // is absent. A retry with the same request id gets the same listener back. func (h *handler) RegisterChannelListener( @@ -154,18 +218,15 @@ func (h *handler) RegisterChannelListener( Callback: in.GetCallback(), Limits: h.limitsFor(ns), } - result, err := chasm.UpdateWithStartExecution( - ctx, - channelKey(req.GetNamespaceId(), in.GetChannel()), - startChannel[channel.CallbackRegistration], - (*channel.Channel).RegisterCallbackListener, - reg, - ) + listenerID, err := upsert(ctx, req.GetNamespaceId(), in.GetChannel(), + func(c *channel.Channel, mctx chasm.MutableContext) (string, error) { + return c.RegisterCallbackListener(mctx, reg) + }) if err != nil { return nil, err } return &channelpb.RegisterChannelListenerResponse{ - FrontendResponse: &channelpb.RegisterChannelListenerOutput{ListenerId: result.UpdateOutput}, + FrontendResponse: &channelpb.RegisterChannelListenerOutput{ListenerId: listenerID}, }, nil } @@ -191,10 +252,11 @@ func (h *handler) UnregisterChannelListener( }, nil } -// pollBudget is how long one blocking poll may hold its caller. It ends before -// the caller's own deadline, so expiry is answered with an empty response. -func pollBudget(ctx context.Context) (context.Context, context.CancelFunc) { - return contextutil.WithDeadlineBuffer(ctx, channel.LongPollTimeout, channel.LongPollBuffer) +// pollBudget is how long one blocking poll may hold its caller: what it asked +// for, capped at the server's long-poll timeout. It ends before the caller's +// own deadline, so expiry is answered with an empty response. +func pollBudget(ctx context.Context, wait time.Duration) (context.Context, context.CancelFunc) { + return contextutil.WithDeadlineBuffer(ctx, min(wait, channel.LongPollTimeout), channel.LongPollBuffer) } // pollRecheckInterval is how often a blocking poll on a channel nobody has @@ -215,12 +277,13 @@ func (h *handler) PollChannel( ref := channelRef(req.GetNamespaceId(), in.GetChannel()) limits := h.limitsFor(ns) + wait := in.GetWait().AsDuration() latest, lastActivity, err := h.readLatest(ctx, ref) if executionAbsent(err) { - if !in.GetWait() { + if wait <= 0 { return &channelpb.PollChannelResponse{FrontendResponse: &channelpb.PollChannelOutput{}}, nil } - pollCtx, cancel := pollBudget(ctx) + pollCtx, cancel := pollBudget(ctx, wait) defer cancel() latest, lastActivity, err = h.waitForChannel(pollCtx, ctx, ref) if err != nil || pollCtx.Err() != nil { @@ -233,8 +296,8 @@ func (h *handler) PollChannel( return nil, err } - if in.GetWait() && latest <= in.GetAfterCounter() { - pollCtx, cancel := pollBudget(ctx) + if wait > 0 && latest <= in.GetAfterCounter() { + pollCtx, cancel := pollBudget(ctx, wait) defer cancel() _, _, err := chasm.PollComponent(pollCtx, ref, func(c *channel.Channel, _ chasm.Context, after int64) (struct{}, bool, error) { @@ -369,15 +432,10 @@ func (h *handler) RegisterWorkflowListener( FirstExecutionRunID: in.GetFirstExecutionRunId(), Limits: h.limitsFor(ns), } - _, err := chasm.UpdateWithStartExecution( - ctx, - channelKey(req.GetNamespaceId(), in.GetChannel()), - startChannel[channel.WorkflowRegistration], - func(c *channel.Channel, mctx chasm.MutableContext, r channel.WorkflowRegistration) (struct{}, error) { - return struct{}{}, c.RegisterWorkflowListener(mctx, r) - }, - reg, - ) + _, err := upsert(ctx, req.GetNamespaceId(), in.GetChannel(), + func(c *channel.Channel, mctx chasm.MutableContext) (struct{}, error) { + return struct{}{}, c.RegisterWorkflowListener(mctx, reg) + }) if err != nil { return nil, err } diff --git a/chasm/lib/channel/service/tasks.go b/chasm/lib/channel/service/tasks.go index 243628155b7..de4655c649b 100644 --- a/chasm/lib/channel/service/tasks.go +++ b/chasm/lib/channel/service/tasks.go @@ -170,6 +170,8 @@ func (h *fanOutTaskHandler) deliverOne( c.RekeyWorkflowListener(mctx, l.GetWorkflowId(), l.GetRunId(), out.GetSuccessorRunId()) return struct{}{}, nil }, listener) + default: + // Delivered, or a duplicate. The listener stays as it is. } return err } From 5c63db03147d66c9341f87e2a7db56be6f342787 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 18:43:52 -0700 Subject: [PATCH 04/12] Handed a new channel listener the latest notification. A reader that found its source empty and subscribed would otherwise miss a write that landed in between, since the notify that followed found nobody to tell. --- chasm/lib/channel/channel.go | 35 +++++++++++----- chasm/lib/channel/channel_test.go | 42 ++++++++++++++++--- .../gen/channelpb/v1/request_response.pb.go | 39 +++++++++++------ .../channel/proto/v1/request_response.proto | 7 +++- chasm/lib/channel/service/handler.go | 8 ++-- 5 files changed, 96 insertions(+), 35 deletions(-) diff --git a/chasm/lib/channel/channel.go b/chasm/lib/channel/channel.go index 47e8acf8ea4..56737feddfa 100644 --- a/chasm/lib/channel/channel.go +++ b/chasm/lib/channel/channel.go @@ -296,27 +296,33 @@ type WorkflowRegistration struct { // RegisterWorkflowListener records a run as a listener. A run of the same // workflow already on record is replaced: one workflow id has one open run, so // the entry belongs to a run that has closed. +// +// A run new to the channel gets the latest notification back, for it to take +// as pending. A reader that checked its source, found nothing and subscribed +// can otherwise miss a write that landed in between, since the notify that +// followed found nobody to tell. func (c *Channel) RegisterWorkflowListener( mctx chasm.MutableContext, reg WorkflowRegistration, -) error { +) (*channelpb.Notification, error) { if reg.WorkflowID == "" || reg.RunID == "" { - return serviceerror.NewInvalidArgument("workflow id and run id are required") + return nil, serviceerror.NewInvalidArgument("workflow id and run id are required") } limits := reg.Limits.withDefaults() c.touch(mctx) + latest := common.CloneProto(c.State.GetLatest()) if field, ok := c.WorkflowListeners[reg.WorkflowID]; ok { if field.Get(readOnly(mctx)).GetRunId() == reg.RunID { - return nil + return nil, nil } listener := field.Get(mctx) listener.RunId = reg.RunID listener.FirstExecutionRunId = reg.FirstExecutionRunID listener.RegisteredTime = timestamppb.New(mctx.Now(c)) - return nil + return latest, nil } if err := c.checkListenerRoom(limits.MaxListeners); err != nil { - return err + return nil, err } if c.WorkflowListeners == nil { c.WorkflowListeners = make(chasm.Map[string, *channelpb.WorkflowListener]) @@ -327,7 +333,7 @@ func (c *Channel) RegisterWorkflowListener( FirstExecutionRunId: reg.FirstExecutionRunID, RegisteredTime: timestamppb.New(mctx.Now(c)), }) - return nil + return latest, nil } func (c *Channel) checkListenerRoom(maxListeners int) error { @@ -379,8 +385,8 @@ type CallbackRegistration struct { // registration whose request id the channel has seen returns the listener // that request made, so a retry adds nothing. // -// A new listener is told about notifications that arrive after it, not about -// the latest one already accepted. +// A new listener is posted the latest notification the channel holds, once, +// for the same reason a new workflow listener is handed it. func (c *Channel) RegisterCallbackListener( mctx chasm.MutableContext, reg CallbackRegistration, @@ -407,13 +413,20 @@ func (c *Channel) RegisterCallbackListener( c.CallbackListeners = make(chasm.Map[string, *channelpb.CallbackListener]) } id := reg.RequestID - c.CallbackListeners[id] = chasm.NewDataField(mctx, &channelpb.CallbackListener{ + listener := &channelpb.CallbackListener{ ListenerId: id, Callback: common.CloneProto(reg.Callback), RequestId: reg.RequestID, RegisteredTime: timestamppb.New(mctx.Now(c)), - HandedCounter: c.State.GetLatest().GetCounter(), - }) + } + if latest := c.State.GetLatest(); latest != nil { + listener.InFlight = common.CloneProto(latest) + listener.HandedCounter = latest.GetCounter() + if err := c.scheduleCallback(mctx, listener); err != nil { + return "", err + } + } + c.CallbackListeners[id] = chasm.NewDataField(mctx, listener) return id, nil } diff --git a/chasm/lib/channel/channel_test.go b/chasm/lib/channel/channel_test.go index e4ee5cc25ad..c646d459d4a 100644 --- a/chasm/lib/channel/channel_test.go +++ b/chasm/lib/channel/channel_test.go @@ -106,7 +106,7 @@ func TestNotifyRefusesWhatItCannotAccept(t *testing.T) { // highest counter. func TestBurstCoalescesIntoOneFanOut(t *testing.T) { c, mctx, _ := newTestChannel(t) - require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + require.NoError(t, registerWorkflow(c, mctx, WorkflowRegistration{ WorkflowID: "wf", RunID: "run-1", })) for _, counter := range []int64{1, 3, 2} { @@ -247,9 +247,12 @@ func TestListenerTable(t *testing.T) { }) require.NoError(t, err) require.Equal(t, first, again) - require.Equal(t, int64(5), c.CallbackListeners[first].Get(mctx).GetHandedCounter()) + listener := c.CallbackListeners[first].Get(mctx) + require.Equal(t, int64(5), listener.GetInFlight().GetCounter(), "a new listener is posted the latest") + require.Equal(t, int64(5), listener.GetHandedCounter()) + require.Len(t, tasksOf[*channelpb.ChannelCallbackTask](mctx), 1, "once, not per retry") - require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + require.NoError(t, registerWorkflow(c, mctx, WorkflowRegistration{ WorkflowID: "wf", RunID: "run-1", Limits: limits, })) _, err = c.RegisterCallbackListener(mctx, CallbackRegistration{ @@ -257,7 +260,7 @@ func TestListenerTable(t *testing.T) { }) var exhausted *serviceerror.ResourceExhausted require.ErrorAs(t, err, &exhausted) - require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + require.NoError(t, registerWorkflow(c, mctx, WorkflowRegistration{ WorkflowID: "wf", RunID: "run-2", Limits: limits, }), "a later run of a known workflow takes its entry, not a new one") require.Equal(t, "run-2", c.WorkflowListeners["wf"].Get(mctx).GetRunId()) @@ -284,7 +287,7 @@ func TestListenerTable(t *testing.T) { // has already taken. func TestWorkflowListenerRekeyAndForget(t *testing.T) { c, mctx, _ := newTestChannel(t) - require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + require.NoError(t, registerWorkflow(c, mctx, WorkflowRegistration{ WorkflowID: "wf", RunID: "run-1", })) @@ -339,7 +342,7 @@ func TestIdleCheckStandsDownWithListeners(t *testing.T) { limits := Limits{Retention: time.Minute} _, err := c.Notify(mctx, note(1), limits) require.NoError(t, err) - require.NoError(t, c.RegisterWorkflowListener(mctx, WorkflowRegistration{ + require.NoError(t, registerWorkflow(c, mctx, WorkflowRegistration{ WorkflowID: "wf", RunID: "run-1", })) *now = now.Add(time.Hour) @@ -349,3 +352,30 @@ func TestIdleCheckStandsDownWithListeners(t *testing.T) { require.False(t, c.State.GetIdleCheckPending()) require.Len(t, tasksOf[*channelpb.ChannelIdleTask](mctx), 1) } + +func registerWorkflow(c *Channel, mctx chasm.MutableContext, reg WorkflowRegistration) error { + _, err := c.RegisterWorkflowListener(mctx, reg) + return err +} + +// A run new to the channel is handed the latest notification, so a write that +// landed before it subscribed still wakes it; a repeat registration and an +// empty channel hand nothing. +func TestNewWorkflowListenerGetsLatest(t *testing.T) { + c, mctx, _ := newTestChannel(t) + latest, err := c.RegisterWorkflowListener(mctx, WorkflowRegistration{WorkflowID: "a", RunID: "r1"}) + require.NoError(t, err) + require.Nil(t, latest, "nothing to hand on an empty channel") + + _, err = c.Notify(mctx, note(3), Limits{}) + require.NoError(t, err) + latest, err = c.RegisterWorkflowListener(mctx, WorkflowRegistration{WorkflowID: "b", RunID: "r1"}) + require.NoError(t, err) + require.Equal(t, int64(3), latest.GetCounter()) + latest, err = c.RegisterWorkflowListener(mctx, WorkflowRegistration{WorkflowID: "b", RunID: "r1"}) + require.NoError(t, err) + require.Nil(t, latest, "a repeat is not new") + latest, err = c.RegisterWorkflowListener(mctx, WorkflowRegistration{WorkflowID: "b", RunID: "r2"}) + require.NoError(t, err) + require.Equal(t, int64(3), latest.GetCounter(), "a successor run is new") +} diff --git a/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go b/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go index 03b9b93f21d..78a782c30e1 100644 --- a/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go +++ b/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go @@ -1254,7 +1254,11 @@ func (x *RegisterWorkflowListenerInput) GetFirstExecutionRunId() string { } type RegisterWorkflowListenerOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // The channel's latest notification, when the run is new to the channel. + // The run takes it as pending, so a write that landed before the run was + // registered still wakes it. + Latest *Notification `protobuf:"bytes,1,opt,name=latest,proto3" json:"latest,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1289,6 +1293,13 @@ func (*RegisterWorkflowListenerOutput) Descriptor() ([]byte, []int) { return file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_rawDescGZIP(), []int{22} } +func (x *RegisterWorkflowListenerOutput) GetLatest() *Notification { + if x != nil { + return x.Latest + } + return nil +} + type RegisterWorkflowListenerRequest struct { state protoimpl.MessageState `protogen:"open.v1"` NamespaceId string `protobuf:"bytes,1,opt,name=namespace_id,json=namespaceId,proto3" json:"namespace_id,omitempty"` @@ -1764,8 +1775,9 @@ const file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_raw "\vworkflow_id\x18\x03 \x01(\tR\n" + "workflowId\x12\x15\n" + "\x06run_id\x18\x04 \x01(\tR\x05runId\x123\n" + - "\x16first_execution_run_id\x18\x05 \x01(\tR\x13firstExecutionRunId\" \n" + - "\x1eRegisterWorkflowListenerOutput\"\xba\x01\n" + + "\x16first_execution_run_id\x18\x05 \x01(\tR\x13firstExecutionRunId\"r\n" + + "\x1eRegisterWorkflowListenerOutput\x12P\n" + + "\x06latest\x18\x01 \x01(\v28.temporal.server.chasm.lib.channel.proto.v1.NotificationR\x06latest\"\xba\x01\n" + "\x1fRegisterWorkflowListenerRequest\x12!\n" + "\fnamespace_id\x18\x01 \x01(\tR\vnamespaceId\x12t\n" + "\x10frontend_request\x18\x02 \x01(\v2I.temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerInputR\x0ffrontendRequest\"\x9b\x01\n" + @@ -1857,16 +1869,17 @@ var file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_depId 30, // 16: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput.latest:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification 16, // 17: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelInput 18, // 18: temporal.server.chasm.lib.channel.proto.v1.DescribeChannelResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DescribeChannelOutput - 21, // 19: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerInput - 22, // 20: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerOutput - 30, // 21: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput.notification:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification - 25, // 22: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput - 26, // 23: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationOutput - 24, // [24:24] is the sub-list for method output_type - 24, // [24:24] is the sub-list for method input_type - 24, // [24:24] is the sub-list for extension type_name - 24, // [24:24] is the sub-list for extension extendee - 0, // [0:24] is the sub-list for field type_name + 30, // 19: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerOutput.latest:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 21, // 20: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerInput + 22, // 21: temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.RegisterWorkflowListenerOutput + 30, // 22: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput.notification:type_name -> temporal.server.chasm.lib.channel.proto.v1.Notification + 25, // 23: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationRequest.frontend_request:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationInput + 26, // 24: temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationResponse.frontend_response:type_name -> temporal.server.chasm.lib.channel.proto.v1.DeliverChannelNotificationOutput + 25, // [25:25] is the sub-list for method output_type + 25, // [25:25] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name } func init() { file_temporal_server_chasm_lib_channel_proto_v1_request_response_proto_init() } diff --git a/chasm/lib/channel/proto/v1/request_response.proto b/chasm/lib/channel/proto/v1/request_response.proto index 1fc9896f076..b2b29d9efe1 100644 --- a/chasm/lib/channel/proto/v1/request_response.proto +++ b/chasm/lib/channel/proto/v1/request_response.proto @@ -141,7 +141,12 @@ message RegisterWorkflowListenerInput { string first_execution_run_id = 5; } -message RegisterWorkflowListenerOutput {} +message RegisterWorkflowListenerOutput { + // The channel's latest notification, when the run is new to the channel. + // The run takes it as pending, so a write that landed before the run was + // registered still wakes it. + Notification latest = 1; +} message RegisterWorkflowListenerRequest { string namespace_id = 1; diff --git a/chasm/lib/channel/service/handler.go b/chasm/lib/channel/service/handler.go index 691a09236f0..dbdec8f147b 100644 --- a/chasm/lib/channel/service/handler.go +++ b/chasm/lib/channel/service/handler.go @@ -432,15 +432,15 @@ func (h *handler) RegisterWorkflowListener( FirstExecutionRunID: in.GetFirstExecutionRunId(), Limits: h.limitsFor(ns), } - _, err := upsert(ctx, req.GetNamespaceId(), in.GetChannel(), - func(c *channel.Channel, mctx chasm.MutableContext) (struct{}, error) { - return struct{}{}, c.RegisterWorkflowListener(mctx, reg) + latest, err := upsert(ctx, req.GetNamespaceId(), in.GetChannel(), + func(c *channel.Channel, mctx chasm.MutableContext) (*channelpb.Notification, error) { + return c.RegisterWorkflowListener(mctx, reg) }) if err != nil { return nil, err } return &channelpb.RegisterWorkflowListenerResponse{ - FrontendResponse: &channelpb.RegisterWorkflowListenerOutput{}, + FrontendResponse: &channelpb.RegisterWorkflowListenerOutput{Latest: latest}, }, nil } From 4af5d32bf68a403ee635a4c080da462fcefb71c2 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 18:43:52 -0700 Subject: [PATCH 05/12] Added the subscribe command and carried notifications on scheduled events. The scheduled event of a task no worker has seen takes the pending notifications and clears them, so History is the acknowledgment and replay sees what the workflow saw. --- chasm/lib/channel/convert.go | 71 +++++++++ chasm/lib/channel/service/events.go | 39 +++++ chasm/lib/channel/service/fx.go | 1 + chasm/lib/workflow/fx.go | 7 + chasm/lib/workflow/notification_commands.go | 141 ++++++++++++++++++ service/history/api/command_attr_validator.go | 5 +- .../api/respondworkflowtaskcompleted/api.go | 18 +++ .../channel_subscriptions.go | 15 +- .../workflow_task_completed_handler.go | 5 + .../history/workflow/mutable_state_impl.go | 15 ++ .../workflow/workflow_task_state_machine.go | 11 ++ 11 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 chasm/lib/channel/convert.go create mode 100644 chasm/lib/channel/service/events.go create mode 100644 chasm/lib/workflow/notification_commands.go diff --git a/chasm/lib/channel/convert.go b/chasm/lib/channel/convert.go new file mode 100644 index 00000000000..2a25f6f2cd5 --- /dev/null +++ b/chasm/lib/channel/convert.go @@ -0,0 +1,71 @@ +package channel + +import ( + notificationpb "go.temporal.io/api/notification/v1" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + "go.temporal.io/server/common" +) + +// The stored notification has the public message's fields under the same +// names, so converting is a field copy in either direction. + +func FromAPINotification(n *notificationpb.Notification) *channelpb.Notification { + if n == nil { + return nil + } + n = common.CloneProto(n) + return &channelpb.Notification{ + Channel: n.GetChannel(), + Position: n.GetPosition(), + Counter: n.GetCounter(), + Metadata: n.GetMetadata(), + } +} + +func ToAPINotification(n *channelpb.Notification) *notificationpb.Notification { + if n == nil { + return nil + } + n = common.CloneProto(n) + return ¬ificationpb.Notification{ + Channel: n.GetChannel(), + Position: n.GetPosition(), + Counter: n.GetCounter(), + Metadata: n.GetMetadata(), + } +} + +func ToAPINotifications(ns []*channelpb.Notification) []*notificationpb.Notification { + if len(ns) == 0 { + return nil + } + out := make([]*notificationpb.Notification, len(ns)) + for i, n := range ns { + out[i] = ToAPINotification(n) + } + return out +} + +// ToAPIListener converts a listener from describe. +func ToAPIListener(l *channelpb.ChannelListenerInfo) *notificationpb.ChannelListener { + out := ¬ificationpb.ChannelListener{ + ListenerId: l.GetListenerId(), + RegisteredTime: common.CloneProto(l.GetRegisteredTime()), + } + switch variant := l.GetVariant().(type) { + case *channelpb.ChannelListenerInfo_Workflow_: + out.Listener = ¬ificationpb.ChannelListener_Workflow{ + Workflow: ¬ificationpb.WorkflowListener{ + WorkflowId: variant.Workflow.GetWorkflowId(), + RunId: variant.Workflow.GetRunId(), + }, + } + case *channelpb.ChannelListenerInfo_Callback: + out.Listener = ¬ificationpb.ChannelListener_Callback{ + Callback: common.CloneProto(variant.Callback), + } + default: + // A listener with no variant has nothing more to say. + } + return out +} diff --git a/chasm/lib/channel/service/events.go b/chasm/lib/channel/service/events.go new file mode 100644 index 00000000000..b2f860255f4 --- /dev/null +++ b/chasm/lib/channel/service/events.go @@ -0,0 +1,39 @@ +package service + +import ( + enumspb "go.temporal.io/api/enums/v1" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/server/service/history/hsm" +) + +// channelSubscribedEventDefinition tells the history service how to treat the +// event a subscribe command writes. The subscription itself is CHASM state on +// the Workflow component, so there is nothing here to apply. +type channelSubscribedEventDefinition struct{} + +func (channelSubscribedEventDefinition) Type() enumspb.EventType { + return enumspb.EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED +} + +// Subscribing gives the workflow nothing to decide on. A notification does, +// and it schedules its own Workflow Task. +func (channelSubscribedEventDefinition) IsWorkflowTaskTrigger() bool { return false } + +func (channelSubscribedEventDefinition) Apply(*hsm.Node, *historypb.HistoryEvent) error { + return nil +} + +// A command event, so never reapplied onto another branch. +func (channelSubscribedEventDefinition) CherryPick( + *hsm.Node, + *historypb.HistoryEvent, + map[enumspb.ResetReapplyExcludeType]struct{}, +) error { + return hsm.ErrNotCherryPickable +} + +// RegisterEventDefinitions makes the channel event known to the history +// service. +func RegisterEventDefinitions(reg *hsm.Registry) error { + return reg.RegisterEventDefinition(channelSubscribedEventDefinition{}) +} diff --git a/chasm/lib/channel/service/fx.go b/chasm/lib/channel/service/fx.go index 1a0ff8fee87..f041c74991e 100644 --- a/chasm/lib/channel/service/fx.go +++ b/chasm/lib/channel/service/fx.go @@ -24,6 +24,7 @@ var HistoryModule = fx.Module( fx.Invoke(func(l *library, registry *chasm.Registry) error { return registry.Register(l) }), + fx.Invoke(RegisterEventDefinitions), ) // FrontendModule gives the frontend the routed client its channel handlers diff --git a/chasm/lib/workflow/fx.go b/chasm/lib/workflow/fx.go index f80640acb81..8f6111a5d1b 100644 --- a/chasm/lib/workflow/fx.go +++ b/chasm/lib/workflow/fx.go @@ -23,6 +23,8 @@ var Module = fx.Module( library *library, config *nexusoperation.Config, streamConfig *stream.Config, + workflowConfig Config, + channelConfig *channel.Config, ) error { if err := library.registry.Register( newNexusLibrary(config, chasmRegistry.NexusEndpointProcessor), @@ -32,6 +34,11 @@ var Module = fx.Module( if err := library.registry.Register(newStreamLibrary(streamConfig)); err != nil { return err } + if err := library.registry.Register( + newNotificationLibrary(workflowConfig, channelConfig), + ); err != nil { + return err + } return chasmRegistry.Register(library) }), ) diff --git a/chasm/lib/workflow/notification_commands.go b/chasm/lib/workflow/notification_commands.go new file mode 100644 index 00000000000..73555e12d40 --- /dev/null +++ b/chasm/lib/workflow/notification_commands.go @@ -0,0 +1,141 @@ +package workflow + +import ( + "fmt" + + commandpb "go.temporal.io/api/command/v1" + enumspb "go.temporal.io/api/enums/v1" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/server/chasm" + "go.temporal.io/server/chasm/lib/channel" +) + +// handleSubscribeNotificationChannelCommand makes this run a listener of a +// notification channel. +// +// The event is written and the subscription recorded here, with the command, +// so the event sits in command order. The channel lives on its own shard, +// which a command handler cannot reach while it holds the state lock, so the +// registration there is staged for the completion path, which makes it before +// the commit and fails the task if the channel refuses. +func handleSubscribeNotificationChannelCommand( + chasmCtx chasm.MutableContext, + wf *Workflow, + command *commandpb.Command, + opts CommandHandlerOptions, + maxIDLength int, + maxSubscriptions int, +) error { + badAttributes := enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_NOTIFICATION_CHANNEL_ATTRIBUTES + attrs := command.GetSubscribeNotificationChannelCommandAttributes() + if attrs == nil { + return FailWorkflowTaskError{ + Cause: badAttributes, + Message: "SubscribeNotificationChannelCommandAttributes is not set", + } + } + name := attrs.GetChannel() + if err := channel.CheckChannelName(name, maxIDLength); err != nil { + return FailWorkflowTaskError{Cause: badAttributes, Message: err.Error()} + } + + // A run already subscribed changes nothing and writes no event, so an SDK + // that re-emits the command after a crash is harmless. + if wf.SubscribedToChannel(name) { + return nil + } + + // Refused before the event is written, so a refusal leaves nothing behind + // even before the failed task is rolled back. + if maxSubscriptions > 0 && wf.ChannelSubscriptionCount() >= maxSubscriptions { + return FailWorkflowTaskError{ + Cause: badAttributes, + Message: fmt.Sprintf( + "workflow already subscribes to %d notification channels, the limit", maxSubscriptions), + } + } + + event := wf.AddHistoryEvent(enumspb.EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED, + func(e *historypb.HistoryEvent) { + e.Attributes = &historypb.HistoryEvent_WorkflowNotificationChannelSubscribedEventAttributes{ + WorkflowNotificationChannelSubscribedEventAttributes: &historypb. + WorkflowNotificationChannelSubscribedEventAttributes{ + WorkflowTaskCompletedEventId: opts.WorkflowTaskCompletedEventID, + Channel: name, + }, + } + }) + if _, err := wf.RecordChannelSubscription(chasmCtx, name, event.GetEventId(), 0); err != nil { + return err + } + wf.StageChannelRegistration(name) + return nil +} + +// channelSubscribedEvent is the event a subscribe command writes. It is also +// what puts the subscription back on a run rebuilt from History, which is how +// a reset run and a run replicated by events come to listen. +type channelSubscribedEvent struct{} + +func (channelSubscribedEvent) Type() enumspb.EventType { + return enumspb.EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED +} + +func (channelSubscribedEvent) IsWorkflowTaskTrigger() bool { return false } + +// Apply records the subscription. A second event for the same channel keeps +// the first record, so the run's first subscription is the one on record. +func (channelSubscribedEvent) Apply( + mctx chasm.MutableContext, + wf *Workflow, + event *historypb.HistoryEvent, +) error { + attrs := event.GetWorkflowNotificationChannelSubscribedEventAttributes() + _, err := wf.RecordChannelSubscription(mctx, attrs.GetChannel(), event.GetEventId(), 0) + return err +} + +// A command event, so it is never cherry-picked: the workflow reissues the +// subscribe command on the new branch if it still wants one. +func (channelSubscribedEvent) CherryPick( + chasm.MutableContext, + *Workflow, + *historypb.HistoryEvent, + map[enumspb.ResetReapplyExcludeType]struct{}, +) error { + return ErrEventNotCherryPickable +} + +// notificationLibrary registers the subscribe command with the workflow +// registry. +type notificationLibrary struct { + workflowConfig Config + channelConfig *channel.Config +} + +func newNotificationLibrary(workflowConfig Config, channelConfig *channel.Config) *notificationLibrary { + return ¬ificationLibrary{workflowConfig: workflowConfig, channelConfig: channelConfig} +} + +func (l *notificationLibrary) CommandHandlers() map[enumspb.CommandType]CommandHandler { + return map[enumspb.CommandType]CommandHandler{ + enumspb.COMMAND_TYPE_SUBSCRIBE_NOTIFICATION_CHANNEL: func( + chasmCtx chasm.MutableContext, + wf *Workflow, + _ Validator, + command *commandpb.Command, + opts CommandHandlerOptions, + ) error { + namespaceName := chasmCtx.NamespaceEntry().Name().String() + return handleSubscribeNotificationChannelCommand( + chasmCtx, wf, command, opts, + l.workflowConfig.maxIDLengthLimit(), + l.channelConfig.MaxSubscriptionsPerWorkflow(namespaceName), + ) + }, + } +} + +func (l *notificationLibrary) EventDefinitions() []EventDefinition { + return []EventDefinition{channelSubscribedEvent{}} +} diff --git a/service/history/api/command_attr_validator.go b/service/history/api/command_attr_validator.go index 257135405d9..b8dabbe323f 100644 --- a/service/history/api/command_attr_validator.go +++ b/service/history/api/command_attr_validator.go @@ -667,7 +667,10 @@ func (v *CommandAttrValidator) ValidateCommandSequence( enumspb.COMMAND_TYPE_APPEND_STREAM_RECORDS, // Subscribing to a stream. Also not closing: it records a cursor // and the workflow carries on. - enumspb.COMMAND_TYPE_SUBSCRIBE_STREAM: + enumspb.COMMAND_TYPE_SUBSCRIBE_STREAM, + // Listening on a notification channel. Not closing either: it + // records a subscription for the run. + enumspb.COMMAND_TYPE_SUBSCRIBE_NOTIFICATION_CHANNEL: // noop case enumspb.COMMAND_TYPE_CONTINUE_AS_NEW_WORKFLOW_EXECUTION, enumspb.COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION, diff --git a/service/history/api/respondworkflowtaskcompleted/api.go b/service/history/api/respondworkflowtaskcompleted/api.go index 318b79d358e..6f08ed088c9 100644 --- a/service/history/api/respondworkflowtaskcompleted/api.go +++ b/service/history/api/respondworkflowtaskcompleted/api.go @@ -482,6 +482,24 @@ func (handler *WorkflowTaskCompletedHandler) Invoke( } } + // Notification channel subscriptions, registered on each channel's + // shard before the commit. Skipped once the task has failed, since the + // subscription and its event are about to be rolled back. + if workflowTaskHandler.workflowTaskFailedCause == nil && !workflowTaskHandler.stopProcessing { + err = registerStagedChannelListeners( + ctx, + ms, + enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_NOTIFICATION_CHANNEL_ATTRIBUTES, + workflowTaskHandler.stagedChannelRegistrations, + ) + if failWFTErr, ok := errors.AsType[chasmworkflow.FailWorkflowTaskError](err); ok { + err = workflowTaskHandler.failWorkflowTask(failWFTErr.Cause, failWFTErr) + } + if err != nil { + return nil, err + } + } + // Worker must respond with Update Accepted or Update Rejected message on every Update Requested // message that were delivered on specific WT, when completing this WT. // If worker ignored the update request (old SDK or SDK bug), then server rejects this update. diff --git a/service/history/api/respondworkflowtaskcompleted/channel_subscriptions.go b/service/history/api/respondworkflowtaskcompleted/channel_subscriptions.go index b1fcfe4f072..6010a6860e1 100644 --- a/service/history/api/respondworkflowtaskcompleted/channel_subscriptions.go +++ b/service/history/api/respondworkflowtaskcompleted/channel_subscriptions.go @@ -17,7 +17,9 @@ import ( // // The command handler recorded the subscription and wrote its event, but it // holds the state lock with nowhere to do I/O from, so the registration -// happens here, between the commands and the commit. A refusal fails the task +// happens here, between the commands and the commit. The channel hands back +// its latest notification, which the run takes as pending so the task this +// completion schedules carries it. A refusal fails the task // with the given cause, which rolls the subscription and its event back with // it. A registration that succeeded before a later one was refused stays on // its channel; the channel's next fan-out finds the run does not listen and @@ -42,12 +44,16 @@ func registerStagedChannelListeners( ctx, cancelBudget := recordworkflowtaskstarted.WithRoutedBudget(ctx) defer cancelBudget() + wf, chasmCtx, err := ms.ChasmWorkflowComponent(ctx) + if err != nil { + return err + } key := ms.GetWorkflowKey() namespaceName := ms.GetNamespaceEntry().Name().String() firstRunID := ms.GetExecutionInfo().GetFirstExecutionRunId() for _, name := range staged { callCtx, cancel := context.WithTimeout(ctx, channel.RoutedCallTimeout) - _, err := client.RegisterWorkflowListener(callCtx, &channelpb.RegisterWorkflowListenerRequest{ + resp, err := client.RegisterWorkflowListener(callCtx, &channelpb.RegisterWorkflowListenerRequest{ NamespaceId: key.NamespaceID, FrontendRequest: &channelpb.RegisterWorkflowListenerInput{ Namespace: namespaceName, @@ -61,6 +67,11 @@ func registerStagedChannelListeners( if err != nil { return chasmworkflow.StreamAdmissionFailure(cause, err) } + if latest := resp.GetFrontendResponse().GetLatest(); latest != nil { + if _, err := wf.AcceptChannelNotification(chasmCtx, latest); err != nil { + return err + } + } } return nil } diff --git a/service/history/api/respondworkflowtaskcompleted/workflow_task_completed_handler.go b/service/history/api/respondworkflowtaskcompleted/workflow_task_completed_handler.go index 8baf1728e6d..b4609967d48 100644 --- a/service/history/api/respondworkflowtaskcompleted/workflow_task_completed_handler.go +++ b/service/history/api/respondworkflowtaskcompleted/workflow_task_completed_handler.go @@ -64,6 +64,7 @@ type ( // Subscribe commands for streams in other executions, resolved before // this workflow task commits. stagedStreamSubscriptions []chasmworkflow.PendingStreamSubscription + stagedChannelRegistrations []string hasBufferedEventsOrMessages bool workflowTaskFailedCause *workflowTaskFailedCause activityNotStartedCancelled bool @@ -363,6 +364,10 @@ func (handler *workflowTaskCompletedHandler) handleCommand( // resolution that has to precede this task's commit. handler.stagedStreamSubscriptions = append( handler.stagedStreamSubscriptions, chasmWorkflow.DrainStreamSubscriptions()...) + // A notification channel subscription stages its registration + // for the same reason. + handler.stagedChannelRegistrations = append( + handler.stagedChannelRegistrations, chasmWorkflow.DrainChannelRegistrations()...) // Fall back to the HSM handler either when the command type is not supported by CHASM (disabled // feature flag) or when the targeted entity is not owned by the CHASM tree (e.g. an operation // scheduled in HSM before the flag was flipped on). diff --git a/service/history/workflow/mutable_state_impl.go b/service/history/workflow/mutable_state_impl.go index e12949f80e9..6a91a0da603 100644 --- a/service/history/workflow/mutable_state_impl.go +++ b/service/history/workflow/mutable_state_impl.go @@ -37,6 +37,7 @@ import ( tokenspb "go.temporal.io/server/api/token/v1" workflowspb "go.temporal.io/server/api/workflow/v1" "go.temporal.io/server/chasm" + "go.temporal.io/server/chasm/lib/channel" channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" chasmworkflow "go.temporal.io/server/chasm/lib/workflow" "go.temporal.io/server/common" @@ -766,6 +767,20 @@ func (ms *MutableStateImpl) HasPendingStreamData() bool { return !ms.IsWorkflowPendingOnWorkflowTaskBackoff() } +// attachChannelNotifications puts the pending channel notifications on a +// WorkflowTaskScheduled event being written for a task no worker has seen. +// Scheduled events written after the fact, for a transient or speculative +// task a worker already ran, carry none: History has to match what that +// worker saw, and the notifications wait for the next scheduled event. +func (ms *MutableStateImpl) attachChannelNotifications(event *historypb.HistoryEvent) { + notifications := ms.takeChannelNotifications() + if len(notifications) == 0 { + return + } + event.GetWorkflowTaskScheduledEventAttributes().Notifications = + channel.ToAPINotifications(notifications) +} + // takeChannelNotifications hands the pending channel notifications to a // WorkflowTaskScheduled event that is being written, and clears them. The // event is the acknowledgment: once History carries a notification nothing diff --git a/service/history/workflow/workflow_task_state_machine.go b/service/history/workflow/workflow_task_state_machine.go index 9ecc63b75ae..3e4fa05c4bd 100644 --- a/service/history/workflow/workflow_task_state_machine.go +++ b/service/history/workflow/workflow_task_state_machine.go @@ -367,6 +367,7 @@ func (m *workflowTaskStateMachine) AddWorkflowTaskScheduledEventAsHeartbeat( attempt, scheduleTime, ) + m.ms.attachChannelNotifications(scheduledEvent) scheduledEventID = scheduledEvent.GetEventId() } else { // WorkflowTaskScheduledEvent will be created later. @@ -584,6 +585,9 @@ func (m *workflowTaskStateMachine) AddWorkflowTaskStartedEvent( workflowTask.Attempt, startTime, ) + // The worker has not seen this task yet, so its scheduled event can + // carry what is pending. + m.ms.attachChannelNotifications(scheduledEvent) scheduledEventID = scheduledEvent.GetEventId() } @@ -672,6 +676,7 @@ func (m *workflowTaskStateMachine) processBuildIdRedirectInfo( workflowTask.Attempt, workflowTask.ScheduledTime, ) + m.ms.attachChannelNotifications(scheduledEvent) newWorkflowTask = m.getWorkflowTaskInfo() newWorkflowTask.ScheduledEventID = scheduledEvent.GetEventId() // Using 1 as the attempt in MS. it's needed so that the new WFT is not considered transient. @@ -1567,6 +1572,12 @@ func (m *workflowTaskStateMachine) convertSpeculativeWorkflowTaskToNormal() erro if scheduledEvent.EventId != wt.ScheduledEventID { return serviceerror.NewInternalf("it could be a bug, scheduled event Id: %d for normal workflow task doesn't match the one from speculative workflow task: %d", scheduledEvent.EventId, wt.ScheduledEventID) } + // A started speculative task already went out without notifications, and + // its History has to say what the worker saw. One not started yet carries + // what is pending. + if wt.StartedEventID == common.EmptyEventID { + m.ms.attachChannelNotifications(scheduledEvent) + } if wtAlreadyStarted := wt.StartedEventID != common.EmptyEventID; wtAlreadyStarted { // If WT was already started then started event is written to the history and From 7e7efe1287498aa276118299742b78c037a6ac73 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 18:43:52 -0700 Subject: [PATCH 06/12] Served the five notification channel RPCs on the frontend. --- service/frontend/errors.go | 10 + service/frontend/fx.go | 5 + service/frontend/workflow_handler.go | 288 +++++++++++++++++++--- service/frontend/workflow_handler_test.go | 1 + 4 files changed, 274 insertions(+), 30 deletions(-) diff --git a/service/frontend/errors.go b/service/frontend/errors.go index 9939cb92a5f..89bf967e0dc 100644 --- a/service/frontend/errors.go +++ b/service/frontend/errors.go @@ -33,6 +33,16 @@ var ( errWakeSourceTooLong = serviceerror.NewInvalidArgument("Wake.Source length exceeds limit.") errWakePositionTooLarge = serviceerror.NewInvalidArgument("Wake.Position exceeds 1024 bytes.") errWakeCounterNotPositive = serviceerror.NewInvalidArgument("Wake.Counter must be greater than zero.") + errNotificationNotSet = serviceerror.NewInvalidArgument("Notification is not set on request.") + errChannelNotSet = serviceerror.NewInvalidArgument("Channel is not set on request.") + errChannelTooLong = serviceerror.NewInvalidArgument("Channel length exceeds limit.") + errNotificationPositionTooLarge = serviceerror.NewInvalidArgument("Notification.Position exceeds 1024 bytes.") + errNotificationCounterNotPositive = serviceerror.NewInvalidArgument("Notification.Counter must be greater than zero.") + errChannelCallbackNotSet = serviceerror.NewInvalidArgument("Callback is not set on request.") + errListenerIDNotSet = serviceerror.NewInvalidArgument("ListenerId is not set on request.") + errListenerIDTooLong = serviceerror.NewInvalidArgument("ListenerId length exceeds limit.") + errMaxNotificationsNegative = serviceerror.NewInvalidArgument("MaxNotifications must not be negative.") + errPollWaitNegative = serviceerror.NewInvalidArgument("Wait must not be negative.") errRequestIDTooLong = serviceerror.NewInvalidArgument("RequestId length exceeds limit.") errIdentityTooLong = serviceerror.NewInvalidArgument("Identity length exceeds limit.") errNotesTooLong = serviceerror.NewInvalidArgument("Schedule notes exceeds limit.") diff --git a/service/frontend/fx.go b/service/frontend/fx.go index 6dbf6b651c5..b51a66c51e5 100644 --- a/service/frontend/fx.go +++ b/service/frontend/fx.go @@ -10,6 +10,8 @@ import ( "go.temporal.io/server/chasm" "go.temporal.io/server/chasm/lib/activity" chasmcallback "go.temporal.io/server/chasm/lib/callback" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + chasmchannel "go.temporal.io/server/chasm/lib/channel/service" chasmnexus "go.temporal.io/server/chasm/lib/nexusoperation" nexusoperationpb "go.temporal.io/server/chasm/lib/nexusoperation/gen/nexusoperationpb/v1" chasmscheduler "go.temporal.io/server/chasm/lib/scheduler" @@ -159,6 +161,7 @@ var Module = fx.Options( chasmcallback.Module, activity.FrontendModule, chasmstream.FrontendModule, + chasmchannel.FrontendModule, fx.Provide(visibility.ChasmVisibilityManagerProvider), fx.Provide(chasm.ChasmVisibilityInterceptorProvider), ) @@ -994,6 +997,7 @@ func HandlerProvider( matchingClient resource.MatchingClient, workerDeploymentStoreClient workerdeployment.Client, schedulerClient schedulerpb.SchedulerServiceClient, + channelClient channelpb.ChannelServiceClient, archiverProvider provider.ArchiverProvider, metricsHandler metrics.Handler, payloadSerializer serialization.Serializer, @@ -1036,6 +1040,7 @@ func HandlerProvider( matchingClient, workerDeploymentStoreClient, schedulerClient, + channelClient, archiverProvider, payloadSerializer, namespaceRegistry, diff --git a/service/frontend/workflow_handler.go b/service/frontend/workflow_handler.go index 2eabbfc26f8..cd10697daec 100644 --- a/service/frontend/workflow_handler.go +++ b/service/frontend/workflow_handler.go @@ -22,6 +22,7 @@ import ( failurepb "go.temporal.io/api/failure/v1" filterpb "go.temporal.io/api/filter/v1" historypb "go.temporal.io/api/history/v1" + notificationpb "go.temporal.io/api/notification/v1" querypb "go.temporal.io/api/query/v1" schedulepb "go.temporal.io/api/schedule/v1" "go.temporal.io/api/serviceerror" @@ -38,6 +39,8 @@ import ( taskqueuespb "go.temporal.io/server/api/taskqueue/v1" "go.temporal.io/server/chasm" "go.temporal.io/server/chasm/lib/activity" + "go.temporal.io/server/chasm/lib/channel" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" chasmnexus "go.temporal.io/server/chasm/lib/nexusoperation" chasmscheduler "go.temporal.io/server/chasm/lib/scheduler" schedulerpb "go.temporal.io/server/chasm/lib/scheduler/gen/schedulerpb/v1" @@ -147,6 +150,7 @@ type ( matchingClient matchingservice.MatchingServiceClient workerDeploymentClient workerdeployment.Client schedulerClient schedulerpb.SchedulerServiceClient + channelClient channelpb.ChannelServiceClient archiverProvider provider.ArchiverProvider payloadSerializer serialization.Serializer namespaceRegistry namespace.Registry @@ -324,6 +328,7 @@ func NewWorkflowHandler( matchingClient matchingservice.MatchingServiceClient, workerDeploymentClient workerdeployment.Client, schedulerClient schedulerpb.SchedulerServiceClient, + channelClient channelpb.ChannelServiceClient, archiverProvider provider.ArchiverProvider, payloadSerializer serialization.Serializer, namespaceRegistry namespace.Registry, @@ -381,6 +386,7 @@ func NewWorkflowHandler( matchingClient: matchingClient, workerDeploymentClient: workerDeploymentClient, schedulerClient: schedulerClient, + channelClient: channelClient, archiverProvider: archiverProvider, payloadSerializer: payloadSerializer, namespaceRegistry: namespaceRegistry, @@ -2421,49 +2427,271 @@ func (wh *WorkflowHandler) WakeWorkflowExecution( }, nil } -// NotifyChannel is declared by the pinned API and served by a later layer of -// this series. +// channelNamespaceID resolves the namespace of a channel call. +func (wh *WorkflowHandler) channelNamespaceID(name string) (string, error) { + if name == "" { + return "", errNamespaceNotSet + } + id, err := wh.namespaceRegistry.GetNamespaceID(namespace.Name(name)) + if err != nil { + return "", err + } + return id.String(), nil +} + +// validateChannelName refuses a channel name that cannot key an execution. +func (wh *WorkflowHandler) validateChannelName(name string) error { + if name == "" { + return errChannelNotSet + } + if len(name) > wh.config.MaxIDLengthLimit() { + return errChannelTooLong + } + return nil +} + +// validateCallerFields checks the identity and request id a channel call +// carries, the same way Signal checks them. +func (wh *WorkflowHandler) validateCallerFields(identity, requestID string) error { + if len(identity) > wh.config.MaxIDLengthLimit() { + return errIdentityTooLong + } + if len(requestID) > wh.config.MaxIDLengthLimit() { + return errRequestIDTooLong + } + return nil +} + +// NotifyChannel tells every listener of a channel that a source they consume +// moved. The writer never learns who listens; the channel's shard wakes each +// one. A channel with no listeners retains the notification for pollers. func (wh *WorkflowHandler) NotifyChannel( - _ context.Context, - _ *workflowservice.NotifyChannelRequest, -) (*workflowservice.NotifyChannelResponse, error) { - return nil, serviceerror.NewUnimplemented("NotifyChannel is not implemented") + ctx context.Context, + request *workflowservice.NotifyChannelRequest, +) (_ *workflowservice.NotifyChannelResponse, retError error) { + defer log.CapturePanic(wh.logger, &retError) + + if request == nil { + return nil, errRequestNotSet + } + n := request.GetNotification() + if n == nil { + return nil, errNotificationNotSet + } + if err := wh.validateChannelName(n.GetChannel()); err != nil { + return nil, err + } + if len(n.GetPosition()) > channel.MaxPositionBytes { + return nil, errNotificationPositionTooLarge + } + if n.GetCounter() <= 0 { + return nil, errNotificationCounterNotPositive + } + if err := wh.validateCallerFields(request.GetIdentity(), request.GetRequestId()); err != nil { + return nil, err + } + namespaceID, err := wh.channelNamespaceID(request.GetNamespace()) + if err != nil { + return nil, err + } + + resp, err := wh.channelClient.NotifyChannel(ctx, &channelpb.NotifyChannelRequest{ + NamespaceId: namespaceID, + FrontendRequest: &channelpb.NotifyChannelInput{ + Namespace: request.GetNamespace(), + Notification: channel.FromAPINotification(n), + Identity: request.GetIdentity(), + RequestId: request.GetRequestId(), + }, + }) + if err != nil { + return nil, err + } + return &workflowservice.NotifyChannelResponse{ + ListenerCount: int32(resp.GetFrontendResponse().GetListenerCount()), + }, nil } -// RegisterChannelListener is declared by the pinned API and served by a later -// layer of this series. +// RegisterChannelListener registers a callback as a listener of a channel. +// The server posts each notification to it. A retry with the same request id +// returns the listener the first call made. func (wh *WorkflowHandler) RegisterChannelListener( - _ context.Context, - _ *workflowservice.RegisterChannelListenerRequest, -) (*workflowservice.RegisterChannelListenerResponse, error) { - return nil, serviceerror.NewUnimplemented("RegisterChannelListener is not implemented") + ctx context.Context, + request *workflowservice.RegisterChannelListenerRequest, +) (_ *workflowservice.RegisterChannelListenerResponse, retError error) { + defer log.CapturePanic(wh.logger, &retError) + + if request == nil { + return nil, errRequestNotSet + } + if err := wh.validateChannelName(request.GetChannel()); err != nil { + return nil, err + } + if err := wh.validateCallerFields(request.GetIdentity(), request.GetRequestId()); err != nil { + return nil, err + } + if request.GetCallback() == nil { + return nil, errChannelCallbackNotSet + } + // Only a URL callback is accepted: the server posts the notification to + // it, which a Nexus handler callback has no way to receive. + opts := callbacks.ValidatorOptions{EnabledKinds: []callbacks.Kind{callbacks.KindNexus}} + cbs := []*commonpb.Callback{request.GetCallback()} + if err := wh.callbackValidator.Validate(ctx, request.GetNamespace(), cbs, opts); err != nil { + return nil, err + } + namespaceID, err := wh.channelNamespaceID(request.GetNamespace()) + if err != nil { + return nil, err + } + requestID := request.GetRequestId() + if requestID == "" { + requestID = uuid.NewString() + } + + resp, err := wh.channelClient.RegisterChannelListener(ctx, &channelpb.RegisterChannelListenerRequest{ + NamespaceId: namespaceID, + FrontendRequest: &channelpb.RegisterChannelListenerInput{ + Namespace: request.GetNamespace(), + Channel: request.GetChannel(), + Callback: request.GetCallback(), + RequestId: requestID, + Identity: request.GetIdentity(), + }, + }) + if err != nil { + return nil, err + } + return &workflowservice.RegisterChannelListenerResponse{ + ListenerId: resp.GetFrontendResponse().GetListenerId(), + }, nil } -// UnregisterChannelListener is declared by the pinned API and served by a -// later layer of this series. +// UnregisterChannelListener removes a callback listener. A listener that is +// already gone is not an error, so a retry succeeds. func (wh *WorkflowHandler) UnregisterChannelListener( - _ context.Context, - _ *workflowservice.UnregisterChannelListenerRequest, -) (*workflowservice.UnregisterChannelListenerResponse, error) { - return nil, serviceerror.NewUnimplemented("UnregisterChannelListener is not implemented") + ctx context.Context, + request *workflowservice.UnregisterChannelListenerRequest, +) (_ *workflowservice.UnregisterChannelListenerResponse, retError error) { + defer log.CapturePanic(wh.logger, &retError) + + if request == nil { + return nil, errRequestNotSet + } + if err := wh.validateChannelName(request.GetChannel()); err != nil { + return nil, err + } + if request.GetListenerId() == "" { + return nil, errListenerIDNotSet + } + if len(request.GetListenerId()) > wh.config.MaxIDLengthLimit() { + return nil, errListenerIDTooLong + } + if err := wh.validateCallerFields(request.GetIdentity(), ""); err != nil { + return nil, err + } + namespaceID, err := wh.channelNamespaceID(request.GetNamespace()) + if err != nil { + return nil, err + } + + _, err = wh.channelClient.UnregisterChannelListener(ctx, &channelpb.UnregisterChannelListenerRequest{ + NamespaceId: namespaceID, + FrontendRequest: &channelpb.UnregisterChannelListenerInput{ + Namespace: request.GetNamespace(), + Channel: request.GetChannel(), + ListenerId: request.GetListenerId(), + Identity: request.GetIdentity(), + }, + }) + if err != nil { + return nil, err + } + return &workflowservice.UnregisterChannelListenerResponse{}, nil } -// PollChannel is declared by the pinned API and served by a later layer of -// this series. +// PollChannel returns the retained notifications above the caller's counter, +// waiting up to the requested time for one when none is retained yet. func (wh *WorkflowHandler) PollChannel( - _ context.Context, - _ *workflowservice.PollChannelRequest, -) (*workflowservice.PollChannelResponse, error) { - return nil, serviceerror.NewUnimplemented("PollChannel is not implemented") + ctx context.Context, + request *workflowservice.PollChannelRequest, +) (_ *workflowservice.PollChannelResponse, retError error) { + defer log.CapturePanic(wh.logger, &retError) + + if request == nil { + return nil, errRequestNotSet + } + if err := wh.validateChannelName(request.GetChannel()); err != nil { + return nil, err + } + if request.GetMaxNotifications() < 0 { + return nil, errMaxNotificationsNegative + } + if request.GetWait().AsDuration() < 0 { + return nil, errPollWaitNegative + } + namespaceID, err := wh.channelNamespaceID(request.GetNamespace()) + if err != nil { + return nil, err + } + + resp, err := wh.channelClient.PollChannel(ctx, &channelpb.PollChannelRequest{ + NamespaceId: namespaceID, + FrontendRequest: &channelpb.PollChannelInput{ + Namespace: request.GetNamespace(), + Channel: request.GetChannel(), + AfterCounter: request.GetAfterCounter(), + Wait: request.GetWait(), + MaxNotifications: request.GetMaxNotifications(), + }, + }) + if err != nil { + return nil, err + } + return &workflowservice.PollChannelResponse{ + Notifications: channel.ToAPINotifications(resp.GetFrontendResponse().GetNotifications()), + }, nil } -// DescribeChannel is declared by the pinned API and served by a later layer of -// this series. +// DescribeChannel returns a channel's listeners, its latest notification and +// how many notifications it retains. func (wh *WorkflowHandler) DescribeChannel( - _ context.Context, - _ *workflowservice.DescribeChannelRequest, -) (*workflowservice.DescribeChannelResponse, error) { - return nil, serviceerror.NewUnimplemented("DescribeChannel is not implemented") + ctx context.Context, + request *workflowservice.DescribeChannelRequest, +) (_ *workflowservice.DescribeChannelResponse, retError error) { + defer log.CapturePanic(wh.logger, &retError) + + if request == nil { + return nil, errRequestNotSet + } + if err := wh.validateChannelName(request.GetChannel()); err != nil { + return nil, err + } + namespaceID, err := wh.channelNamespaceID(request.GetNamespace()) + if err != nil { + return nil, err + } + + resp, err := wh.channelClient.DescribeChannel(ctx, &channelpb.DescribeChannelRequest{ + NamespaceId: namespaceID, + FrontendRequest: &channelpb.DescribeChannelInput{ + Namespace: request.GetNamespace(), + Channel: request.GetChannel(), + }, + }) + if err != nil { + return nil, err + } + out := resp.GetFrontendResponse() + listeners := make([]*notificationpb.ChannelListener, 0, len(out.GetListeners())) + for _, l := range out.GetListeners() { + listeners = append(listeners, channel.ToAPIListener(l)) + } + return &workflowservice.DescribeChannelResponse{ + Listeners: listeners, + Latest: channel.ToAPINotification(out.GetLatest()), + RetainedCount: int32(out.GetRetainedCount()), + }, nil } // SignalWithStartWorkflowExecution is used to ensure sending signal to a workflow. diff --git a/service/frontend/workflow_handler_test.go b/service/frontend/workflow_handler_test.go index 613676e9117..4064ff7bef8 100644 --- a/service/frontend/workflow_handler_test.go +++ b/service/frontend/workflow_handler_test.go @@ -211,6 +211,7 @@ func (s *WorkflowHandlerSuite) getWorkflowHandler(config *Config) *WorkflowHandl s.mockResource.GetMatchingClient(), nil, nil, // Not initializing the scheduler client here. + nil, // Nor the channel client. s.mockResource.GetArchiverProvider(), s.mockResource.GetPayloadSerializer(), s.mockResource.GetNamespaceRegistry(), From b77a9e8348b004331e18272035581f1354678b1a Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 18:43:52 -0700 Subject: [PATCH 07/12] Added functional tests for the notification channel. --- tests/notification_channel_test.go | 758 +++++++++++++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 tests/notification_channel_test.go diff --git a/tests/notification_channel_test.go b/tests/notification_channel_test.go new file mode 100644 index 00000000000..0a73c43c879 --- /dev/null +++ b/tests/notification_channel_test.go @@ -0,0 +1,758 @@ +package tests + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + commandpb "go.temporal.io/api/command/v1" + commonpb "go.temporal.io/api/common/v1" + enumspb "go.temporal.io/api/enums/v1" + historypb "go.temporal.io/api/history/v1" + notificationpb "go.temporal.io/api/notification/v1" + "go.temporal.io/api/serviceerror" + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/server/api/adminservice/v1" + "go.temporal.io/server/chasm/lib/callback" + "go.temporal.io/server/chasm/lib/channel" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + channelservice "go.temporal.io/server/chasm/lib/channel/service" + chasmworkflowpb "go.temporal.io/server/chasm/lib/workflow/gen/workflowpb/v1" + "go.temporal.io/server/common/testing/await" + "go.temporal.io/server/tests/testcore" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" +) + +// channelTestEnv drives the notification channel with the raw client, playing +// the worker by hand so each scheduled event can be inspected. +type channelTestEnv struct { + *wakeTestEnv +} + +func newChannelTestEnv(t *testing.T, opts ...testcore.TestOption) *channelTestEnv { + opts = append([]testcore.TestOption{ + testcore.WithDynamicConfig(callback.AllowedAddresses, + []any{map[string]any{"Pattern": "*", "AllowInsecure": true}}), + testcore.WithDynamicConfig(callback.RetryPolicyInitialInterval, 10*time.Millisecond), + testcore.WithDynamicConfig(callback.RetryPolicyMaximumInterval, 50*time.Millisecond), + }, opts...) + return &channelTestEnv{wakeTestEnv: newWakeTestEnv(t, opts...)} +} + +func subscribeChannelCommand(name string) *commandpb.Command { + return &commandpb.Command{ + CommandType: enumspb.COMMAND_TYPE_SUBSCRIBE_NOTIFICATION_CHANNEL, + Attributes: &commandpb.Command_SubscribeNotificationChannelCommandAttributes{ + SubscribeNotificationChannelCommandAttributes: &commandpb.SubscribeNotificationChannelCommandAttributes{ + Channel: name, + }, + }, + } +} + +func channelNotification(name string, counter int64) *notificationpb.Notification { + return ¬ificationpb.Notification{ + Channel: name, + Position: []byte(name + "@" + strconv.FormatInt(counter, 10)), + Counter: counter, + Metadata: map[string]*commonpb.Payload{ + "topic": {Data: []byte("t" + strconv.FormatInt(counter, 10))}, + }, + } +} + +func (c *channelTestEnv) notify(name string, counter int64) (*workflowservice.NotifyChannelResponse, error) { + return c.env.FrontendClient().NotifyChannel(c.ctx(), &workflowservice.NotifyChannelRequest{ + Namespace: c.ns, + Notification: channelNotification(name, counter), + Identity: "tester", + RequestId: uuid.NewString(), + }) +} + +func (c *channelTestEnv) mustNotify(name string, counter int64) int32 { + c.t.Helper() + resp, err := c.notify(name, counter) + require.NoError(c.t, err) + return resp.GetListenerCount() +} + +func (c *channelTestEnv) describe(name string) *workflowservice.DescribeChannelResponse { + c.t.Helper() + resp, err := c.env.FrontendClient().DescribeChannel(c.ctx(), &workflowservice.DescribeChannelRequest{ + Namespace: c.ns, + Channel: name, + }) + require.NoError(c.t, err) + return resp +} + +// workflowListenerRun is the run the channel has on record for a workflow id, +// or the empty string when it has none. +func (c *channelTestEnv) workflowListenerRun(name, workflowID string) string { + c.t.Helper() + for _, l := range c.describe(name).GetListeners() { + if l.GetWorkflow().GetWorkflowId() == workflowID { + return l.GetWorkflow().GetRunId() + } + } + return "" +} + +// subscribe starts a workflow and completes its first task with a subscribe +// command, returning the run id. +func (c *channelTestEnv) subscribe(id string, channels ...string) string { + c.t.Helper() + runID := c.start(id) + var commands []*commandpb.Command + for _, name := range channels { + commands = append(commands, subscribeChannelCommand(name)) + } + c.complete(c.poll(id), false, commands...) + for _, name := range channels { + require.Equal(c.t, runID, c.workflowListenerRun(name, id)) + } + return runID +} + +// scheduledNotifications reads the notifications on the scheduled event of +// the task, from History as a replaying worker would. +func (c *channelTestEnv) scheduledNotifications( + id string, + task *workflowservice.PollWorkflowTaskQueueResponse, +) []*notificationpb.Notification { + c.t.Helper() + events := c.env.GetHistory(c.ns, task.GetWorkflowExecution()) + var scheduled *historypb.HistoryEvent + for _, e := range events { + if e.GetEventType() == enumspb.EVENT_TYPE_WORKFLOW_TASK_SCHEDULED && + e.GetEventId() <= task.GetStartedEventId() { + scheduled = e + } + } + require.NotNil(c.t, scheduled, "no scheduled event for the task of %s", id) + return scheduled.GetWorkflowTaskScheduledEventAttributes().GetNotifications() +} + +// awaitPending waits until the run holds a pending notification for the +// channel at the counter, which is how a test knows a fan-out reached it +// while it had no way to schedule a task. +func (c *channelTestEnv) awaitPending(id, name string, counter int64) { + c.t.Helper() + await.Require(c.ctx(), c.t, func(t *await.T) { + nodes := c.persisted(id).GetDatabaseMutableState().GetChasmNodes() + node, ok := nodes["ChannelNotifications#"+name] + require.True(t, ok, "no pending notification for %s yet", name) + var entry chasmworkflowpb.ChannelNotificationEntry + require.NoError(t, proto.Unmarshal(node.GetData().GetData(), &entry)) + require.Equal(t, counter, entry.GetCounter()) + }, 20*time.Second, 50*time.Millisecond) +} + +// awaitCallbackPending waits until the callback listener holds a pending +// notification at the counter behind the one in flight. +func (c *channelTestEnv) awaitCallbackPending(name, listenerID string, counter int64) { + c.t.Helper() + await.Require(c.ctx(), c.t, func(t *await.T) { + resp, err := c.env.AdminClient().DescribeMutableState(c.ctx(), + &adminservice.DescribeMutableStateRequest{ + Namespace: c.ns, + Execution: &commonpb.WorkflowExecution{WorkflowId: name}, + Archetype: channelservice.Archetype, + }) + require.NoError(t, err) + node, ok := resp.GetDatabaseMutableState().GetChasmNodes()["CallbackListeners#"+listenerID] + require.True(t, ok) + var listener channelpb.CallbackListener + require.NoError(t, proto.Unmarshal(node.GetData().GetData(), &listener)) + require.Equal(t, counter, listener.GetPending().GetCounter()) + }, 20*time.Second, 50*time.Millisecond) +} + +func requireNotifications(t *testing.T, got []*notificationpb.Notification, want map[string]int64) { + t.Helper() + require.Len(t, got, len(want), "notifications: %v", got) + for _, n := range got { + counter, ok := want[n.GetChannel()] + require.True(t, ok, "unexpected notification from %q", n.GetChannel()) + require.Equal(t, counter, n.GetCounter(), "counter of %q", n.GetChannel()) + require.Equal(t, []byte(n.GetChannel()+"@"+strconv.FormatInt(counter, 10)), n.GetPosition()) + require.Equal(t, []byte("t"+strconv.FormatInt(counter, 10)), n.GetMetadata()["topic"].GetData()) + } +} + +func requireInvalidArgument(t *testing.T, err error) { + t.Helper() + var invalid *serviceerror.InvalidArgument + require.ErrorAs(t, err, &invalid) +} + +// A workflow subscribes by completing a task with the command, the event says +// so, and a notify then schedules one task whose scheduled event carries the +// notification. History is where it lives: the poll response carries nothing +// of its own, and a replayed history shows the same event. +func TestNotificationChannelSubscribeAndNotify(t *testing.T) { + c := newChannelTestEnv(t) + id := "channel-basic-" + uuid.NewString() + name := "orders-" + uuid.NewString() + runID := c.subscribe(id, name) + + events := c.env.GetHistory(c.ns, &commonpb.WorkflowExecution{WorkflowId: id, RunId: runID}) + var subscribed *historypb.WorkflowNotificationChannelSubscribedEventAttributes + for _, e := range events { + if e.GetEventType() == enumspb.EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED { + subscribed = e.GetWorkflowNotificationChannelSubscribedEventAttributes() + } + } + require.NotNil(t, subscribed) + require.Equal(t, name, subscribed.GetChannel()) + require.Positive(t, subscribed.GetWorkflowTaskCompletedEventId()) + require.False(t, c.hasPendingTask(id)) + + require.Equal(t, int32(1), c.mustNotify(name, 1)) + task := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 1}) + require.Empty(t, task.GetWakes()) + c.complete(task, false) + require.False(t, c.hasPendingTask(id), "History acknowledged it, so nothing is redelivered") + + // A replayed history reads the same scheduled event. + reread, err := c.env.FrontendClient().GetWorkflowExecutionHistory(c.ctx(), + &workflowservice.GetWorkflowExecutionHistoryRequest{ + Namespace: c.ns, + Execution: &commonpb.WorkflowExecution{WorkflowId: id, RunId: runID}, + }) + require.NoError(t, err) + var carried int + for _, e := range reread.GetHistory().GetEvents() { + if n := e.GetWorkflowTaskScheduledEventAttributes().GetNotifications(); len(n) > 0 { + requireNotifications(t, n, map[string]int64{name: 1}) + carried++ + } + } + require.Equal(t, 1, carried) + + desc := c.describe(name) + require.Len(t, desc.GetListeners(), 1) + require.Equal(t, id, desc.GetListeners()[0].GetWorkflow().GetWorkflowId()) + require.Equal(t, int64(1), desc.GetLatest().GetCounter()) + require.Equal(t, int32(1), desc.GetRetainedCount()) +} + +// Notifications that reach a workflow while it has a task open wait for the +// next scheduled event and fold there into the highest counter. +func TestNotificationChannelFoldsWhileTaskOpen(t *testing.T) { + c := newChannelTestEnv(t) + id := "channel-fold-" + uuid.NewString() + name := "orders-" + uuid.NewString() + c.subscribe(id, name) + + c.mustNotify(name, 1) + open := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, open), map[string]int64{name: 1}) + + for counter := int64(2); counter <= 4; counter++ { + c.mustNotify(name, counter) + } + c.awaitPending(id, name, 4) + + c.complete(open, false) + next := c.poll(id) + got := c.scheduledNotifications(id, next) + require.Len(t, got, 1, "one per channel") + require.Equal(t, int64(4), got[0].GetCounter()) + c.complete(next, false) + require.False(t, c.hasPendingTask(id)) +} + +// Every listener is woken, and the writer learns how many there were. +func TestNotificationChannelWakesEveryListener(t *testing.T) { + c := newChannelTestEnv(t) + name := "orders-" + uuid.NewString() + first := "channel-many-a-" + uuid.NewString() + second := "channel-many-b-" + uuid.NewString() + c.subscribe(first, name) + c.subscribe(second, name) + + require.Equal(t, int32(2), c.mustNotify(name, 7)) + for _, id := range []string{first, second} { + task := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 7}) + c.complete(task, false) + } +} + +// A subscription ends with its run. After a continue-as-new the predecessor's +// listener is dropped on the next notify unless the successor subscribes, +// and a successor that subscribes takes the entry and gets the next one. +func TestNotificationChannelContinueAsNew(t *testing.T) { + c := newChannelTestEnv(t) + name := "orders-" + uuid.NewString() + + id := "channel-can-" + uuid.NewString() + predecessor := c.subscribe(id, name) + c.mustNotify(name, 1) + task := c.poll(id) + c.complete(task, false, c.continueAsNewCommand(id)) + successorTask := c.poll(id) + require.NotEqual(t, predecessor, successorTask.GetWorkflowExecution().GetRunId()) + c.complete(successorTask, false) + + c.mustNotify(name, 2) + await.Require(c.ctx(), t, func(t *await.T) { + require.Empty(t, c.describe(name).GetListeners(), "the closed run's listener goes") + }, 20*time.Second, 50*time.Millisecond) + require.False(t, c.hasPendingTask(id), "a successor that did not subscribe is not woken") + + // A new subscriber is handed the latest the channel holds, and a + // successor that subscribes again is handed it too, then the next one. + id2 := "channel-can2-" + uuid.NewString() + c.subscribe(id2, name) + task = c.poll(id2) + requireNotifications(t, c.scheduledNotifications(id2, task), map[string]int64{name: 2}) + c.complete(task, false) + c.mustNotify(name, 3) + task = c.poll(id2) + requireNotifications(t, c.scheduledNotifications(id2, task), map[string]int64{name: 3}) + c.complete(task, false, c.continueAsNewCommand(id2)) + successorTask = c.poll(id2) + successor := successorTask.GetWorkflowExecution().GetRunId() + c.complete(successorTask, false, subscribeChannelCommand(name)) + require.Equal(t, successor, c.workflowListenerRun(name, id2), "the successor takes the entry") + c.mustNotify(name, 4) + task = c.poll(id2) + require.Equal(t, successor, task.GetWorkflowExecution().GetRunId()) + requireNotifications(t, c.scheduledNotifications(id2, task), map[string]int64{name: 3}) + c.complete(task, false) + task = c.poll(id2) + requireNotifications(t, c.scheduledNotifications(id2, task), map[string]int64{name: 4}) +} + +// A reset run is rebuilt from History, subscription event included, so it +// listens. The channel still has the reset-away run on record, and the next +// notify follows the chain to the reset run and re-keys the listener. +func TestNotificationChannelReKeysToResetRun(t *testing.T) { + c := newChannelTestEnv(t) + id := "channel-reset-" + uuid.NewString() + name := "orders-" + uuid.NewString() + base := c.subscribe(id, name) + + c.mustNotify(name, 1) + task := c.poll(id) + c.complete(task, false) + + resp, err := c.env.FrontendClient().ResetWorkflowExecution(c.ctx(), + &workflowservice.ResetWorkflowExecutionRequest{ + Namespace: c.ns, + WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: id, RunId: base}, + Reason: "test", + WorkflowTaskFinishEventId: task.GetStartedEventId(), + RequestId: uuid.NewString(), + }) + require.NoError(t, err) + resetRun := resp.GetRunId() + c.complete(c.poll(id), false) + require.Equal(t, base, c.workflowListenerRun(name, id)) + + c.mustNotify(name, 2) + task = c.poll(id) + require.Equal(t, resetRun, task.GetWorkflowExecution().GetRunId()) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 2}) + await.Require(c.ctx(), t, func(t *await.T) { + require.Equal(t, resetRun, c.workflowListenerRun(name, id)) + }, 20*time.Second, 50*time.Millisecond) +} + +// callbackRecorder is an HTTP endpoint that records what the channel posts, +// and can hold a request open to keep a delivery in flight. +type callbackRecorder struct { + mu sync.Mutex + bodies []map[string]any + channels []string + hold chan struct{} + arrived chan struct{} +} + +func newCallbackRecorder(t *testing.T) (*callbackRecorder, string) { + r := &callbackRecorder{arrived: make(chan struct{}, 100)} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + data, _ := io.ReadAll(req.Body) + var body map[string]any + _ = json.Unmarshal(data, &body) + r.mu.Lock() + r.bodies = append(r.bodies, body) + r.channels = append(r.channels, req.Header.Get(channelservice.ChannelHeader)) + hold := r.hold + r.mu.Unlock() + r.arrived <- struct{}{} + if hold != nil { + <-hold + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + return r, server.URL + "/notify" +} + +func (r *callbackRecorder) counters() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.bodies)) + for i, b := range r.bodies { + out[i], _ = b["counter"].(string) + } + return out +} + +// A callback listener is posted each notification as JSON with the channel +// in a header, and while one post is in flight what arrives folds into one, +// sent when the post completes. +func TestNotificationChannelCallbackListener(t *testing.T) { + c := newChannelTestEnv(t) + name := "orders-" + uuid.NewString() + recorder, url := newCallbackRecorder(t) + + register := func(requestID string) string { + resp, err := c.env.FrontendClient().RegisterChannelListener(c.ctx(), + &workflowservice.RegisterChannelListenerRequest{ + Namespace: c.ns, + Channel: name, + Callback: &commonpb.Callback{Variant: &commonpb.Callback_Nexus_{ + Nexus: &commonpb.Callback_Nexus{Url: url}, + }}, + RequestId: requestID, + Identity: "tester", + }) + require.NoError(t, err) + return resp.GetListenerId() + } + requestID := uuid.NewString() + listenerID := register(requestID) + require.Equal(t, listenerID, register(requestID), "a retried registration finds its listener") + + require.Equal(t, int32(1), c.mustNotify(name, 1)) + select { + case <-recorder.arrived: + case <-time.After(20 * time.Second): + t.Fatal("the callback was not posted") + } + await.Require(c.ctx(), t, func(t *await.T) { + require.Equal(t, []string{"1"}, recorder.counters()) + }, 5*time.Second, 20*time.Millisecond) + recorder.mu.Lock() + require.Equal(t, name, recorder.channels[0]) + require.Equal(t, name, recorder.bodies[0]["channel"]) + recorder.hold = make(chan struct{}) + hold := recorder.hold + recorder.mu.Unlock() + + c.mustNotify(name, 2) + select { + case <-recorder.arrived: + case <-time.After(20 * time.Second): + t.Fatal("the second notification was not posted") + } + c.mustNotify(name, 3) + c.mustNotify(name, 4) + // The fan-outs for 3 and 4 hand their notifications over while 2 is still + // in flight, and 4 replaces 3. + c.awaitCallbackPending(name, listenerID, 4) + recorder.mu.Lock() + recorder.hold = nil + recorder.mu.Unlock() + close(hold) + + await.Require(c.ctx(), t, func(t *await.T) { + require.Equal(t, []string{"1", "2", "4"}, recorder.counters()) + }, 20*time.Second, 50*time.Millisecond) + + _, err := c.env.FrontendClient().UnregisterChannelListener(c.ctx(), + &workflowservice.UnregisterChannelListenerRequest{ + Namespace: c.ns, Channel: name, ListenerId: listenerID, Identity: "tester", + }) + require.NoError(t, err) + require.Empty(t, c.describe(name).GetListeners()) + require.Equal(t, int32(0), c.mustNotify(name, 5)) +} + +// A long poll returns a notification published while it waits. +func TestNotificationChannelPollWaits(t *testing.T) { + c := newChannelTestEnv(t) + name := "orders-" + uuid.NewString() + require.Equal(t, int32(0), c.mustNotify(name, 1), "nobody listens yet") + + done := make(chan *workflowservice.PollChannelResponse, 1) + errs := make(chan error, 1) + go func() { + resp, err := c.env.FrontendClient().PollChannel(c.ctx(), &workflowservice.PollChannelRequest{ + Namespace: c.ns, + Channel: name, + AfterCounter: 1, + Wait: durationpb.New(15 * time.Second), + }) + if err != nil { + errs <- err + return + } + done <- resp + }() + // Nothing says when the poll has parked, so it is given a moment. Were the + // notify to land first, the poll would return it all the same. + time.Sleep(time.Second) //nolint:forbidigo // no signal says the poll is waiting + c.mustNotify(name, 2) + select { + case resp := <-done: + require.Len(t, resp.GetNotifications(), 1) + require.Equal(t, int64(2), resp.GetNotifications()[0].GetCounter()) + case err := <-errs: + t.Fatal(err) + case <-time.After(20 * time.Second): + t.Fatal("the poll did not return") + } +} + +// A notify with no listeners is retained, so a poll that arrives later +// catches up on it; describe reports what the channel holds. +func TestNotificationChannelRetainsWithoutListeners(t *testing.T) { + c := newChannelTestEnv(t) + name := "orders-" + uuid.NewString() + require.Equal(t, int32(0), c.mustNotify(name, 1)) + require.Equal(t, int32(0), c.mustNotify(name, 2)) + + resp, err := c.env.FrontendClient().PollChannel(c.ctx(), &workflowservice.PollChannelRequest{ + Namespace: c.ns, + Channel: name, + }) + require.NoError(t, err) + requireNotifications(t, resp.GetNotifications()[:1], map[string]int64{name: 1}) + require.Len(t, resp.GetNotifications(), 2) + + resp, err = c.env.FrontendClient().PollChannel(c.ctx(), &workflowservice.PollChannelRequest{ + Namespace: c.ns, Channel: name, AfterCounter: 1, MaxNotifications: 1, + }) + require.NoError(t, err) + require.Len(t, resp.GetNotifications(), 1) + require.Equal(t, int64(2), resp.GetNotifications()[0].GetCounter()) + + desc := c.describe(name) + require.Empty(t, desc.GetListeners()) + require.Equal(t, int64(2), desc.GetLatest().GetCounter()) + require.Equal(t, int32(2), desc.GetRetainedCount()) + + _, err = c.env.FrontendClient().DescribeChannel(c.ctx(), &workflowservice.DescribeChannelRequest{ + Namespace: c.ns, Channel: "absent-" + uuid.NewString(), + }) + requireNotFound(t, err) +} + +// The frontend refuses what the channel could not accept, and the limits hold. +func TestNotificationChannelValidationAndLimits(t *testing.T) { + c := newChannelTestEnv(t, + testcore.WithDynamicConfig(channel.MaxListenersSetting, 1), + testcore.WithDynamicConfig(channel.MaxMetadataBytesSetting, 16), + testcore.WithDynamicConfig(channel.MaxSubscriptionsPerWorkflowSetting, 1), + testcore.WithDynamicConfig(channel.RetainedNotificationsSetting, 2), + ) + name := "orders-" + uuid.NewString() + notify := func(n *notificationpb.Notification) error { + _, err := c.env.FrontendClient().NotifyChannel(c.ctx(), &workflowservice.NotifyChannelRequest{ + Namespace: c.ns, Notification: n, + }) + return err + } + requireInvalidArgument(t, notify(nil)) + requireInvalidArgument(t, notify(¬ificationpb.Notification{Counter: 1})) + requireInvalidArgument(t, notify(¬ificationpb.Notification{Channel: name})) + requireInvalidArgument(t, notify(¬ificationpb.Notification{ + Channel: name, Counter: 1, Position: make([]byte, channel.MaxPositionBytes+1), + })) + requireInvalidArgument(t, notify(¬ificationpb.Notification{ + Channel: name, Counter: 1, + Metadata: map[string]*commonpb.Payload{"k": {Data: make([]byte, 64)}}, + })) + requireInvalidArgument(t, notify(¬ificationpb.Notification{ + Channel: string(make([]byte, 2000)), Counter: 1, + })) + + // The ring keeps the newest two. + for counter := int64(1); counter <= 3; counter++ { + c.mustNotify(name, counter) + } + require.Equal(t, int32(2), c.describe(name).GetRetainedCount()) + + // One listener is the limit. + id := "channel-limit-" + uuid.NewString() + c.subscribe(id, name) + _, err := c.env.FrontendClient().RegisterChannelListener(c.ctx(), + &workflowservice.RegisterChannelListenerRequest{ + Namespace: c.ns, + Channel: name, + Callback: &commonpb.Callback{Variant: &commonpb.Callback_Nexus_{ + Nexus: &commonpb.Callback_Nexus{Url: "http://localhost:1/notify"}, + }}, + RequestId: uuid.NewString(), + }) + var exhausted *serviceerror.ResourceExhausted + require.ErrorAs(t, err, &exhausted) + + // One subscription per run is the limit, and the refusal fails the task + // with the channel's cause. + c.mustNotify(name, 4) + task := c.poll(id) + _, err = c.env.FrontendClient().RespondWorkflowTaskCompleted(c.ctx(), + &workflowservice.RespondWorkflowTaskCompletedRequest{ + Namespace: c.ns, + TaskToken: task.GetTaskToken(), + Commands: []*commandpb.Command{subscribeChannelCommand("other-" + uuid.NewString())}, + Identity: "tester", + }) + requireInvalidArgument(t, err) + var failed *historypb.WorkflowTaskFailedEventAttributes + for _, e := range c.env.GetHistory(c.ns, task.GetWorkflowExecution()) { + if e.GetEventType() == enumspb.EVENT_TYPE_WORKFLOW_TASK_FAILED { + failed = e.GetWorkflowTaskFailedEventAttributes() + } + } + require.NotNil(t, failed) + require.Equal(t, + enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_NOTIFICATION_CHANNEL_ATTRIBUTES, + failed.GetCause()) +} + +// The notify rate is per namespace; past it a notify is refused with the rate +// limit cause. +func TestNotificationChannelNotifyRate(t *testing.T) { + c := newChannelTestEnv(t, testcore.WithDynamicConfig(channel.NotifyPerSecondSetting, 1)) + name := "orders-" + uuid.NewString() + var refused error + for counter := int64(1); counter <= 20 && refused == nil; counter++ { + _, refused = c.notify(name, counter) + } + var exhausted *serviceerror.ResourceExhausted + require.ErrorAs(t, refused, &exhausted) + require.Equal(t, enumspb.RESOURCE_EXHAUSTED_CAUSE_RPS_LIMIT, exhausted.Cause) +} + +// eventsOfType returns the run's events of one type, in order. +func (c *channelTestEnv) eventsOfType( + execution *commonpb.WorkflowExecution, + eventType enumspb.EventType, +) []*historypb.HistoryEvent { + var out []*historypb.HistoryEvent + for _, e := range c.env.GetHistory(c.ns, execution) { + if e.GetEventType() == eventType { + out = append(out, e) + } + } + return out +} + +// retryScheduled is the scheduled event History records for a retried task, +// written when the retry completes: the last one at attempt two. +func (c *channelTestEnv) retryScheduled( + retry *workflowservice.PollWorkflowTaskQueueResponse, +) *historypb.WorkflowTaskScheduledEventAttributes { + c.t.Helper() + var out *historypb.WorkflowTaskScheduledEventAttributes + for _, e := range c.eventsOfType(retry.GetWorkflowExecution(), enumspb.EVENT_TYPE_WORKFLOW_TASK_SCHEDULED) { + if attrs := e.GetWorkflowTaskScheduledEventAttributes(); attrs.GetAttempt() == 2 { + out = attrs + } + } + require.NotNil(c.t, out, "no scheduled event for the retry") + return out +} + +// A retry after a failed task does not carry again what the failed attempt's +// scheduled event carried: an SDK reads every scheduled event since its last +// completed task, so carrying it twice would deliver it twice. A notification +// that arrives after the failure rides the scheduled event after the retry. +func TestNotificationChannelRetryDoesNotRecarry(t *testing.T) { + c := newChannelTestEnv(t) + id := "channel-retry-" + uuid.NewString() + name := "orders-" + uuid.NewString() + c.subscribe(id, name) + + c.mustNotify(name, 1) + task := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 1}) + _, err := c.env.FrontendClient().RespondWorkflowTaskFailed(c.ctx(), + &workflowservice.RespondWorkflowTaskFailedRequest{ + Namespace: c.ns, + TaskToken: task.GetTaskToken(), + Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_WORKER_UNHANDLED_FAILURE, + Identity: "tester", + }) + require.NoError(t, err) + + retry := c.poll(id) + require.Equal(t, int32(2), retry.GetAttempt()) + c.complete(retry, false) + require.Empty(t, c.retryScheduled(retry).GetNotifications(), "the retry does not carry it again") + require.False(t, c.hasPendingTask(id)) + + // A notification arriving between a failure and the retry is not carried + // by the retry, whose scheduled event the worker never saw with it; it + // rides the next scheduled event, alone. + c.mustNotify(name, 2) + task = c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 2}) + _, err = c.env.FrontendClient().RespondWorkflowTaskFailed(c.ctx(), + &workflowservice.RespondWorkflowTaskFailedRequest{ + Namespace: c.ns, + TaskToken: task.GetTaskToken(), + Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_WORKER_UNHANDLED_FAILURE, + Identity: "tester", + }) + require.NoError(t, err) + c.mustNotify(name, 3) + c.awaitPending(id, name, 3) + retry = c.poll(id) + c.complete(retry, false) + require.Empty(t, c.retryScheduled(retry).GetNotifications()) + next := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, next), map[string]int64{name: 3}) +} + +// A notify that found no listeners is handed to a workflow that subscribes +// afterwards, so a reader that checked its source, found nothing and +// subscribed is not left waiting on a write that landed in between. +func TestNotificationChannelSubscribeGetsLatest(t *testing.T) { + c := newChannelTestEnv(t) + id := "channel-late-" + uuid.NewString() + name := "orders-" + uuid.NewString() + require.Equal(t, int32(0), c.mustNotify(name, 1)) + require.Equal(t, int32(0), c.mustNotify(name, 2)) + + c.subscribe(id, name) + task := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 2}) + c.complete(task, false) + require.False(t, c.hasPendingTask(id)) +} + +// Subscribing to a channel that holds nothing schedules nothing, and a second +// subscribe to the same channel is a no-op that writes no event. +func TestNotificationChannelSubscribeOnEmptyChannel(t *testing.T) { + c := newChannelTestEnv(t) + id := "channel-empty-" + uuid.NewString() + name := "orders-" + uuid.NewString() + runID := c.subscribe(id, name) + require.False(t, c.hasPendingTask(id)) + + c.mustNotify(name, 1) + task := c.poll(id) + c.complete(task, false, subscribeChannelCommand(name)) + execution := &commonpb.WorkflowExecution{WorkflowId: id, RunId: runID} + require.Len(t, c.eventsOfType(execution, + enumspb.EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED), 1) + require.False(t, c.hasPendingTask(id)) +} From caf94c1fc7d1226daff5f7c3503728b5376bbafd Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 18:49:10 -0700 Subject: [PATCH 08/12] Recorded the event for a repeated channel subscribe. Every command needs its event, or the SDK's command matching desyncs on replay. The repeat changes nothing else. --- chasm/lib/workflow/notification_commands.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/chasm/lib/workflow/notification_commands.go b/chasm/lib/workflow/notification_commands.go index 73555e12d40..d9e7306f135 100644 --- a/chasm/lib/workflow/notification_commands.go +++ b/chasm/lib/workflow/notification_commands.go @@ -39,15 +39,14 @@ func handleSubscribeNotificationChannelCommand( return FailWorkflowTaskError{Cause: badAttributes, Message: err.Error()} } - // A run already subscribed changes nothing and writes no event, so an SDK - // that re-emits the command after a crash is harmless. - if wf.SubscribedToChannel(name) { - return nil - } + // A run already subscribed still gets the event, since every SDK matches + // the commands it issued against the events they produced, in order. It + // changes nothing else: no new listener and no latest notification. + already := wf.SubscribedToChannel(name) // Refused before the event is written, so a refusal leaves nothing behind // even before the failed task is rolled back. - if maxSubscriptions > 0 && wf.ChannelSubscriptionCount() >= maxSubscriptions { + if !already && maxSubscriptions > 0 && wf.ChannelSubscriptionCount() >= maxSubscriptions { return FailWorkflowTaskError{ Cause: badAttributes, Message: fmt.Sprintf( @@ -65,6 +64,9 @@ func handleSubscribeNotificationChannelCommand( }, } }) + if already { + return nil + } if _, err := wf.RecordChannelSubscription(chasmCtx, name, event.GetEventId(), 0); err != nil { return err } From f4daaddf38665559292930bb2625ea48521149ad Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 18:49:10 -0700 Subject: [PATCH 09/12] Answered a notify that changes nothing without a write. A notify whose counter is not above the channel's latest is read and answered, and a redelivery a run already has is folded without touching the run. --- chasm/lib/channel/channel.go | 29 +++++++++++++-------- chasm/lib/channel/channel_test.go | 3 +++ chasm/lib/channel/service/handler.go | 39 +++++++++++++++++++++++++++- tests/notification_channel_test.go | 18 ++++++++++--- 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/chasm/lib/channel/channel.go b/chasm/lib/channel/channel.go index 56737feddfa..bb3a5ce173d 100644 --- a/chasm/lib/channel/channel.go +++ b/chasm/lib/channel/channel.go @@ -131,13 +131,15 @@ func MetadataSize(metadata map[string]*commonpb.Payload) int { // NotifyResult is what a notify reports back to the writer. type NotifyResult struct { ListenerCount int - // The notification raised the channel's latest counter, so listeners are - // being told about it. + // The notification raised the channel's latest counter and was accepted. + // One that did not changed nothing. Advanced bool } -// Notify accepts a notification: it joins the retained ring for pollers and, -// when it is the newest the channel has seen, is fanned out to the listeners. +// Notify accepts a notification: it joins the retained ring for pollers and +// is fanned out to the listeners. One whose counter is not above the latest +// changes nothing, since every listener and poller already has a newer one; +// the caller checks that first so such a notify writes nothing at all. // // The fan-out is one task at a time. A notify that lands while one is // outstanding schedules nothing: the task reads the latest when it runs, so a @@ -152,19 +154,18 @@ func (c *Channel) Notify( if err := CheckNotification(n, limits.MaxMetadataBytes); err != nil { return NotifyResult{}, err } + if !c.Advances(n.GetCounter()) { + return NotifyResult{ListenerCount: c.ListenerCount()}, nil + } n = common.CloneProto(n) c.retain(mctx, n, limits.RetainedNotifications) c.State.AcceptedCount++ c.touch(mctx) - - advanced := n.GetCounter() > c.State.GetLatest().GetCounter() - if advanced { - c.State.Latest = n - c.scheduleFanOut(mctx) - } + c.State.Latest = n + c.scheduleFanOut(mctx) c.scheduleIdleCheck(mctx, limits) - return NotifyResult{ListenerCount: c.ListenerCount(), Advanced: advanced}, nil + return NotifyResult{ListenerCount: c.ListenerCount(), Advanced: true}, nil } // retain appends to the ring and drops the oldest past the bound. @@ -181,6 +182,12 @@ func (c *Channel) retain(mctx chasm.MutableContext, n *channelpb.Notification, b } } +// Advances reports whether a notification at this counter would be news to +// the channel. +func (c *Channel) Advances(counter int64) bool { + return counter > c.State.GetLatest().GetCounter() +} + // RetainedCount is how many notifications the ring holds. func (c *Channel) RetainedCount() int64 { return c.State.GetRetainedNext() - c.State.GetRetainedFirst() diff --git a/chasm/lib/channel/channel_test.go b/chasm/lib/channel/channel_test.go index c646d459d4a..c52ff9c166a 100644 --- a/chasm/lib/channel/channel_test.go +++ b/chasm/lib/channel/channel_test.go @@ -116,6 +116,9 @@ func TestBurstCoalescesIntoOneFanOut(t *testing.T) { } require.Len(t, tasksOf[*channelpb.ChannelFanOutTask](mctx), 1) require.Equal(t, int64(3), c.LatestCounter(), "a lower counter does not replace the latest") + require.Equal(t, int64(2), c.RetainedCount(), "nor is it retained: it changes nothing") + require.False(t, c.Advances(3)) + require.True(t, c.Advances(4)) fanOut, err := c.TakeFanOut(mctx, struct{}{}) require.NoError(t, err) diff --git a/chasm/lib/channel/service/handler.go b/chasm/lib/channel/service/handler.go index dbdec8f147b..b186e23d6e4 100644 --- a/chasm/lib/channel/service/handler.go +++ b/chasm/lib/channel/service/handler.go @@ -155,6 +155,16 @@ func (h *handler) NotifyChannel( return nil, err } + // Read first: a notify that would not raise the channel's latest counter + // changes nothing, and answering it from a read keeps it from writing. + if stale, count, err := h.staleNotify(ctx, req.GetNamespaceId(), in.GetNotification()); err != nil { + return nil, err + } else if stale { + metrics.ChannelNotificationsFolded.With(h.metricsHandler).Record(1, metrics.NamespaceTag(ns)) + return &channelpb.NotifyChannelResponse{ + FrontendResponse: &channelpb.NotifyChannelOutput{ListenerCount: int64(count)}, + }, nil + } count, err := h.notify(ctx, req.GetNamespaceId(), in, limits) if errors.Is(err, chasm.ErrRequestIDAlreadyUsed) { // A retry of a notify that was accepted. Answered with the listeners @@ -180,6 +190,31 @@ func (h *handler) NotifyChannel( }, nil } +// staleNotify reports whether the channel already holds a notification at +// this counter or above, and how many listeners it has. A channel that does +// not exist is not stale: the notify creates it. +func (h *handler) staleNotify( + ctx context.Context, + namespaceID string, + n *channelpb.Notification, +) (bool, int, error) { + type answer struct { + stale bool + listeners int + } + got, err := chasm.ReadComponent(ctx, channelRef(namespaceID, n.GetChannel()), + func(c *channel.Channel, _ chasm.Context, counter int64) (answer, error) { + if c.State.GetClosed() { + return answer{}, nil + } + return answer{stale: !c.Advances(counter), listeners: c.ListenerCount()}, nil + }, n.GetCounter()) + if executionAbsent(err) { + return false, 0, nil + } + return got.stale, got.listeners, err +} + // notify accepts the notification on the channel, creating it if absent. A // request id makes a retry of an accepted notify a no-op: the engine records // it with the update and refuses its reuse. @@ -530,9 +565,11 @@ func (h *handler) DeliverChannelNotification( } // A duplicate leaves the run exactly as it was: reaching the component - // mutably is already a write. + // mutably is already a write. It is a fold that changed nothing. if target.duplicate { out.Duplicate = true + metrics.ChannelNotificationsFolded.With(h.metricsHandler).Record( + 1, metrics.NamespaceTag(ns), workflowKindTag) return &channelpb.DeliverChannelNotificationResponse{FrontendResponse: out}, nil } folded, err := h.accept(ctx, namespaceID, workflowID, target.runID, n) diff --git a/tests/notification_channel_test.go b/tests/notification_channel_test.go index 0a73c43c879..ca0f60cf641 100644 --- a/tests/notification_channel_test.go +++ b/tests/notification_channel_test.go @@ -739,8 +739,9 @@ func TestNotificationChannelSubscribeGetsLatest(t *testing.T) { require.False(t, c.hasPendingTask(id)) } -// Subscribing to a channel that holds nothing schedules nothing, and a second -// subscribe to the same channel is a no-op that writes no event. +// Subscribing to a channel that holds nothing schedules nothing. A second +// subscribe to the same channel records its own event, since every command +// needs one, and changes nothing else: no new listener and no delivery. func TestNotificationChannelSubscribeOnEmptyChannel(t *testing.T) { c := newChannelTestEnv(t) id := "channel-empty-" + uuid.NewString() @@ -752,7 +753,16 @@ func TestNotificationChannelSubscribeOnEmptyChannel(t *testing.T) { task := c.poll(id) c.complete(task, false, subscribeChannelCommand(name)) execution := &commonpb.WorkflowExecution{WorkflowId: id, RunId: runID} - require.Len(t, c.eventsOfType(execution, - enumspb.EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED), 1) + subscribed := c.eventsOfType(execution, enumspb.EVENT_TYPE_WORKFLOW_NOTIFICATION_CHANNEL_SUBSCRIBED) + require.Len(t, subscribed, 2) + require.Equal(t, name, + subscribed[1].GetWorkflowNotificationChannelSubscribedEventAttributes().GetChannel()) + require.Len(t, c.describe(name).GetListeners(), 1) + require.False(t, c.hasPendingTask(id), "the latest is not handed out again") + + // A notify that does not raise the latest counter changes nothing and + // wakes nobody. + require.Equal(t, int32(1), c.mustNotify(name, 1)) + require.Equal(t, int32(1), c.describe(name).GetRetainedCount()) require.False(t, c.hasPendingTask(id)) } From 06c42d4f1693c357447fb9fabe38999ee60246f7 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 19:01:01 -0700 Subject: [PATCH 10/12] Folded a repeated notification per listener, not per channel. A watcher that finds a record after its reader's task ran sends the same counter again, and folding that against the channel's latest left the reader waiting. Only a listener that holds it pending folds it. --- chasm/lib/channel/channel.go | 75 ++++++++++--- chasm/lib/channel/channel_test.go | 31 ++++++ .../gen/channelpb/v1/request_response.pb.go | 3 +- .../channel/proto/v1/request_response.proto | 3 +- chasm/lib/channel/service/deliver.go | 104 ++++++++++++++++++ chasm/lib/channel/service/fx.go | 1 + chasm/lib/channel/service/handler.go | 95 ++++++++++++---- chasm/lib/channel/service/tasks.go | 76 +------------ .../workflow/gen/workflowpb/v1/state.pb.go | 4 +- chasm/lib/workflow/notification.go | 34 +++--- chasm/lib/workflow/notification_test.go | 21 ++-- chasm/lib/workflow/proto/v1/state.proto | 4 +- tests/notification_channel_test.go | 49 ++++++++- 13 files changed, 365 insertions(+), 135 deletions(-) create mode 100644 chasm/lib/channel/service/deliver.go diff --git a/chasm/lib/channel/channel.go b/chasm/lib/channel/channel.go index bb3a5ce173d..f974a894809 100644 --- a/chasm/lib/channel/channel.go +++ b/chasm/lib/channel/channel.go @@ -209,8 +209,8 @@ func (c *Channel) touch(mctx chasm.MutableContext) { type FanOut struct { Latest *channelpb.Notification WorkflowListeners []*channelpb.WorkflowListener - // Callback listeners whose newest notification replaced one still waiting - // behind an in-flight delivery. + // Callback listeners that already held the notification, in flight or + // pending, or whose pending one it replaced. CallbackFolded int // Callback listeners a delivery was started for. CallbackStarted int @@ -233,33 +233,82 @@ func (c *Channel) TakeFanOut(mctx chasm.MutableContext, _ struct{}) (FanOut, err return out, nil } + started, folded, err := c.HandToCallbacks(mctx, latest) + if err != nil { + return FanOut{}, err + } + out.CallbackStarted, out.CallbackFolded = started, folded + + view := readOnly(mctx) + for _, id := range slices.Sorted(maps.Keys(c.WorkflowListeners)) { + out.WorkflowListeners = append(out.WorkflowListeners, + common.CloneProto(c.WorkflowListeners[id].Get(view))) + } + return out, nil +} + +// callbackHolds reports whether a callback listener already holds a +// notification at this counter or above, in flight or pending. Only what it +// holds counts: once a post is done, the same counter is news again. +func callbackHolds(listener *channelpb.CallbackListener, counter int64) bool { + return (listener.GetInFlight() != nil && listener.GetInFlight().GetCounter() >= counter) || + (listener.GetPending() != nil && listener.GetPending().GetCounter() >= counter) +} + +// CallbacksNeed reports whether any callback listener does not hold the +// notification, which is when handing it over writes the channel. +func (c *Channel) CallbacksNeed(ctx chasm.Context, counter int64) bool { + view := readOnly(ctx) + for _, field := range c.CallbackListeners { + if !callbackHolds(field.Get(view), counter) { + return true + } + } + return false +} + +// HandToCallbacks gives the notification to every callback listener that +// does not hold it: one with nothing in flight starts a post, and one that is +// busy keeps it as its pending notification, replacing an older one. A +// listener that holds it already is left untouched. It reports how many posts +// it started and how many listeners folded it. +func (c *Channel) HandToCallbacks( + mctx chasm.MutableContext, + n *channelpb.Notification, +) (started int, folded int, err error) { view := readOnly(mctx) for _, id := range slices.Sorted(maps.Keys(c.CallbackListeners)) { - if c.CallbackListeners[id].Get(view).GetHandedCounter() >= latest.GetCounter() { + if callbackHolds(c.CallbackListeners[id].Get(view), n.GetCounter()) { + folded++ continue } listener := c.CallbackListeners[id].Get(mctx) - listener.HandedCounter = latest.GetCounter() + listener.HandedCounter = max(listener.GetHandedCounter(), n.GetCounter()) if listener.GetInFlight() != nil { if listener.GetPending() != nil { - out.CallbackFolded++ + folded++ } - listener.Pending = common.CloneProto(latest) + listener.Pending = common.CloneProto(n) continue } - listener.InFlight = common.CloneProto(latest) + listener.InFlight = common.CloneProto(n) listener.Attempt = 0 - if err := c.scheduleCallback(mctx, listener); err != nil { - return FanOut{}, err + if err = c.scheduleCallback(mctx, listener); err != nil { + return 0, 0, err } - out.CallbackStarted++ + started++ } + return started, folded, nil +} +// WorkflowListenerList returns the workflow listeners in workflow id order. +func (c *Channel) WorkflowListenerList(ctx chasm.Context) []*channelpb.WorkflowListener { + view := readOnly(ctx) + out := make([]*channelpb.WorkflowListener, 0, len(c.WorkflowListeners)) for _, id := range slices.Sorted(maps.Keys(c.WorkflowListeners)) { - out.WorkflowListeners = append(out.WorkflowListeners, - common.CloneProto(c.WorkflowListeners[id].Get(view))) + out = append(out, common.CloneProto(c.WorkflowListeners[id].Get(view))) } - return out, nil + return out } // CallbackDestination keys a callback's deliveries on the outbound queue, diff --git a/chasm/lib/channel/channel_test.go b/chasm/lib/channel/channel_test.go index c52ff9c166a..4dd2b0b3905 100644 --- a/chasm/lib/channel/channel_test.go +++ b/chasm/lib/channel/channel_test.go @@ -382,3 +382,34 @@ func TestNewWorkflowListenerGetsLatest(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(3), latest.GetCounter(), "a successor run is new") } + +// A notification that does not advance the channel still reaches a callback +// listener that does not hold it, and changes nothing for one that does. +func TestRepeatHandsOnlyToCallbacksThatLackIt(t *testing.T) { + c, mctx, _ := newTestChannel(t) + id, err := c.RegisterCallbackListener(mctx, CallbackRegistration{ + RequestID: "req-1", Callback: testCallback(), + }) + require.NoError(t, err) + _, err = c.Notify(mctx, note(5), Limits{}) + require.NoError(t, err) + _, err = c.TakeFanOut(mctx, struct{}{}) + require.NoError(t, err) + require.Len(t, tasksOf[*channelpb.ChannelCallbackTask](mctx), 1) + + require.False(t, c.CallbacksNeed(mctx, 5), "in flight, so held") + started, folded, err := c.HandToCallbacks(mctx, note(5)) + require.NoError(t, err) + require.Zero(t, started) + require.Equal(t, 1, folded) + + task := tasksOf[*channelpb.ChannelCallbackTask](mctx)[0].Payload.(*channelpb.ChannelCallbackTask) + _, err = c.CompleteCallbackDelivery(mctx, CallbackOutcome{ListenerID: id, Sequence: task.GetSequence()}) + require.NoError(t, err) + require.True(t, c.CallbacksNeed(mctx, 5), "posted, so the same counter is news again") + started, _, err = c.HandToCallbacks(mctx, note(5)) + require.NoError(t, err) + require.Equal(t, 1, started) + require.Len(t, tasksOf[*channelpb.ChannelCallbackTask](mctx), 2) + require.Empty(t, c.WorkflowListenerList(mctx)) +} diff --git a/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go b/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go index 78a782c30e1..d907286eb39 100644 --- a/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go +++ b/chasm/lib/channel/gen/channelpb/v1/request_response.pb.go @@ -1476,7 +1476,8 @@ type DeliverChannelNotificationOutput struct { SuccessorRunId string `protobuf:"bytes,2,opt,name=successor_run_id,json=successorRunId,proto3" json:"successor_run_id,omitempty"` // The notification joined one the run had not seen yet. Folded bool `protobuf:"varint,3,opt,name=folded,proto3" json:"folded,omitempty"` - // The run had already seen this counter, so nothing changed. + // The run holds a pending notification at this counter or above, so + // nothing changed. Duplicate bool `protobuf:"varint,4,opt,name=duplicate,proto3" json:"duplicate,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/chasm/lib/channel/proto/v1/request_response.proto b/chasm/lib/channel/proto/v1/request_response.proto index b2b29d9efe1..445446283ed 100644 --- a/chasm/lib/channel/proto/v1/request_response.proto +++ b/chasm/lib/channel/proto/v1/request_response.proto @@ -175,7 +175,8 @@ message DeliverChannelNotificationOutput { string successor_run_id = 2; // The notification joined one the run had not seen yet. bool folded = 3; - // The run had already seen this counter, so nothing changed. + // The run holds a pending notification at this counter or above, so + // nothing changed. bool duplicate = 4; } diff --git a/chasm/lib/channel/service/deliver.go b/chasm/lib/channel/service/deliver.go new file mode 100644 index 00000000000..bff2e3b2868 --- /dev/null +++ b/chasm/lib/channel/service/deliver.go @@ -0,0 +1,104 @@ +package service + +import ( + "context" + + "go.temporal.io/server/chasm" + "go.temporal.io/server/chasm/lib/channel" + channelpb "go.temporal.io/server/chasm/lib/channel/gen/channelpb/v1" + "go.temporal.io/server/common/log" + "go.temporal.io/server/common/log/tag" + "golang.org/x/sync/errgroup" +) + +// workflowDeliverer hands a notification to workflow listeners, each on its +// own shard through the routed service, and keeps the listener table honest +// about runs that have ended. The fan-out task uses it for an advancing +// notify, and NotifyChannel uses it directly for one that does not advance +// the channel, which writes nothing on the channel itself. +type workflowDeliverer struct { + logger log.Logger + config *channel.Config + routed channelpb.ChannelServiceClient +} + +func newWorkflowDeliverer( + logger log.Logger, + config *channel.Config, + routed channelpb.ChannelServiceClient, +) *workflowDeliverer { + return &workflowDeliverer{logger: logger, config: config, routed: routed} +} + +// deliverAll tells every listener. No cancelling context on purpose: one +// unreachable listener must not stop the others being told. The first error +// comes back, so the caller retries the set; a run that already holds the +// notification pending takes the retry as a fold and changes nothing. +func (h *workflowDeliverer) deliverAll( + ctx context.Context, + ref chasm.ComponentRef, + ns string, + listeners []*channelpb.WorkflowListener, + n *channelpb.Notification, +) error { + var group errgroup.Group + group.SetLimit(fanOutConcurrency) + for _, listener := range listeners { + group.Go(func() error { + return h.deliverOne(ctx, ref, ns, listener, n) + }) + } + return group.Wait() +} + +// deliverOne tells one workflow listener and acts on its answer. The call +// carries its own deadline, so a listener on a slow host does not take the +// whole task's. +func (h *workflowDeliverer) deliverOne( + ctx context.Context, + ref chasm.ComponentRef, + ns string, + listener *channelpb.WorkflowListener, + n *channelpb.Notification, +) error { + callCtx, cancel := context.WithTimeout(ctx, channel.RoutedCallTimeout) + defer cancel() + response, err := h.routed.DeliverChannelNotification(callCtx, &channelpb.DeliverChannelNotificationRequest{ + NamespaceId: ref.NamespaceID, + FrontendRequest: &channelpb.DeliverChannelNotificationInput{ + Namespace: ns, + WorkflowId: listener.GetWorkflowId(), + RunId: listener.GetRunId(), + Notification: n, + }, + }) + if err != nil { + // Not taken as proof the listener is gone. The handler already reads a + // missing run as closed and says so in its answer, so an error here is + // the transport's, and dropping a listener on that would be a guess. + h.logger.Warn("failed to deliver a channel notification to a workflow", + tag.NewStringTag("channel", ref.BusinessID), + tag.WorkflowID(listener.GetWorkflowId()), + tag.Error(err)) + return err + } + out := response.GetFrontendResponse() + limits := h.config.LimitsFor(ns) + switch { + case out.GetListenerClosed(): + _, _, err = chasm.UpdateComponent(ctx, ref, + func(c *channel.Channel, mctx chasm.MutableContext, l *channelpb.WorkflowListener) (struct{}, error) { + c.ForgetWorkflowListener(mctx, l.GetWorkflowId(), l.GetRunId(), limits) + return struct{}{}, nil + }, listener) + case out.GetSuccessorRunId() != "": + _, _, err = chasm.UpdateComponent(ctx, ref, + func(c *channel.Channel, mctx chasm.MutableContext, l *channelpb.WorkflowListener) (struct{}, error) { + c.RekeyWorkflowListener(mctx, l.GetWorkflowId(), l.GetRunId(), out.GetSuccessorRunId()) + return struct{}{}, nil + }, listener) + default: + // Delivered, or a duplicate. The listener stays as it is. + } + return err +} diff --git a/chasm/lib/channel/service/fx.go b/chasm/lib/channel/service/fx.go index f041c74991e..628809d2431 100644 --- a/chasm/lib/channel/service/fx.go +++ b/chasm/lib/channel/service/fx.go @@ -14,6 +14,7 @@ var HistoryModule = fx.Module( "channel-history", fx.Provide( channelpb.NewChannelServiceLayeredClient, + newWorkflowDeliverer, newHandler, newFanOutTaskHandler, newCallbackTaskHandler, diff --git a/chasm/lib/channel/service/handler.go b/chasm/lib/channel/service/handler.go index b186e23d6e4..cf6f7c0e634 100644 --- a/chasm/lib/channel/service/handler.go +++ b/chasm/lib/channel/service/handler.go @@ -27,6 +27,7 @@ type handler struct { timeSource clock.TimeSource config *channel.Config limiters *notifyLimiters + deliverer *workflowDeliverer } func newHandler( @@ -35,6 +36,7 @@ func newHandler( metricsHandler metrics.Handler, timeSource clock.TimeSource, config *channel.Config, + deliverer *workflowDeliverer, ) *handler { return &handler{ namespaceRegistry: namespaceRegistry, @@ -43,6 +45,7 @@ func newHandler( timeSource: timeSource, config: config, limiters: newNotifyLimiters(config), + deliverer: deliverer, } } @@ -155,14 +158,20 @@ func (h *handler) NotifyChannel( return nil, err } - // Read first: a notify that would not raise the channel's latest counter - // changes nothing, and answering it from a read keeps it from writing. - if stale, count, err := h.staleNotify(ctx, req.GetNamespaceId(), in.GetNotification()); err != nil { + // Read first. A notify that would not raise the channel's latest counter + // leaves the ring alone, but it still reaches every listener: one that + // holds it pending folds it, and one that does not, because its last + // task already ran, is woken again. + repeat, err := h.readRepeat(ctx, req.GetNamespaceId(), in.GetNotification()) + if err != nil { return nil, err - } else if stale { - metrics.ChannelNotificationsFolded.With(h.metricsHandler).Record(1, metrics.NamespaceTag(ns)) + } + if repeat.repeat { + if err := h.redeliver(ctx, req.GetNamespaceId(), ns, in.GetNotification(), repeat); err != nil { + return nil, err + } return &channelpb.NotifyChannelResponse{ - FrontendResponse: &channelpb.NotifyChannelOutput{ListenerCount: int64(count)}, + FrontendResponse: &channelpb.NotifyChannelOutput{ListenerCount: int64(repeat.listeners)}, }, nil } count, err := h.notify(ctx, req.GetNamespaceId(), in, limits) @@ -190,29 +199,73 @@ func (h *handler) NotifyChannel( }, nil } -// staleNotify reports whether the channel already holds a notification at -// this counter or above, and how many listeners it has. A channel that does -// not exist is not stale: the notify creates it. -func (h *handler) staleNotify( +// repeatRead is what a notify learns from reading the channel first. +type repeatRead struct { + // The counter is not above the channel's latest, so the ring is left as + // it is. + repeat bool + listeners int + workflows []*channelpb.WorkflowListener + // Some callback listener does not hold the notification. + callbacksNeed bool +} + +// readRepeat reads whether the notification would advance the channel. A +// channel that does not exist, or is closed, is not a repeat: the notify +// creates a channel. +func (h *handler) readRepeat( ctx context.Context, namespaceID string, n *channelpb.Notification, -) (bool, int, error) { - type answer struct { - stale bool - listeners int - } +) (repeatRead, error) { got, err := chasm.ReadComponent(ctx, channelRef(namespaceID, n.GetChannel()), - func(c *channel.Channel, _ chasm.Context, counter int64) (answer, error) { - if c.State.GetClosed() { - return answer{}, nil + func(c *channel.Channel, cctx chasm.Context, counter int64) (repeatRead, error) { + if c.State.GetClosed() || c.Advances(counter) { + return repeatRead{}, nil } - return answer{stale: !c.Advances(counter), listeners: c.ListenerCount()}, nil + return repeatRead{ + repeat: true, + listeners: c.ListenerCount(), + workflows: c.WorkflowListenerList(cctx), + callbacksNeed: c.CallbacksNeed(cctx, counter), + }, nil }, n.GetCounter()) if executionAbsent(err) { - return false, 0, nil + return repeatRead{}, nil + } + return got, err +} + +// redeliver hands a notification that does not advance the channel to every +// listener. The channel is written only when a callback listener needs it; +// each workflow listener decides on its own shard, and one that holds it +// pending writes nothing. +func (h *handler) redeliver( + ctx context.Context, + namespaceID, ns string, + n *channelpb.Notification, + read repeatRead, +) error { + ref := channelRef(namespaceID, n.GetChannel()) + if read.callbacksNeed { + folded, _, err := chasm.UpdateComponent(ctx, ref, + func(c *channel.Channel, mctx chasm.MutableContext, n *channelpb.Notification) (int, error) { + _, folded, err := c.HandToCallbacks(mctx, n) + return folded, err + }, n) + if err != nil && !executionAbsent(err) { + return err + } + if folded > 0 { + metrics.ChannelNotificationsFolded.With(h.metricsHandler).Record( + int64(folded), metrics.NamespaceTag(ns), callbackKindTag) + } + } + if holders := read.listeners - len(read.workflows); !read.callbacksNeed && holders > 0 { + metrics.ChannelNotificationsFolded.With(h.metricsHandler).Record( + int64(holders), metrics.NamespaceTag(ns), callbackKindTag) } - return got.stale, got.listeners, err + return h.deliverer.deliverAll(ctx, ref, ns, read.workflows, n) } // notify accepts the notification on the channel, creating it if absent. A diff --git a/chasm/lib/channel/service/tasks.go b/chasm/lib/channel/service/tasks.go index de4655c649b..828fa430ede 100644 --- a/chasm/lib/channel/service/tasks.go +++ b/chasm/lib/channel/service/tasks.go @@ -20,7 +20,6 @@ import ( "go.temporal.io/server/common/namespace" queuescommon "go.temporal.io/server/service/history/queues/common" queueserrors "go.temporal.io/server/service/history/queues/errors" - "golang.org/x/sync/errgroup" "google.golang.org/protobuf/encoding/protojson" ) @@ -56,25 +55,19 @@ type fanOutTaskHandler struct { chasm.SideEffectTaskHandlerBase[*channelpb.ChannelFanOutTask] namespaceRegistry namespace.Registry - logger log.Logger metricsHandler metrics.Handler - config *channel.Config - routed channelpb.ChannelServiceClient + deliverer *workflowDeliverer } func newFanOutTaskHandler( namespaceRegistry namespace.Registry, - logger log.Logger, metricsHandler metrics.Handler, - config *channel.Config, - routed channelpb.ChannelServiceClient, + deliverer *workflowDeliverer, ) *fanOutTaskHandler { return &fanOutTaskHandler{ namespaceRegistry: namespaceRegistry, - logger: logger, metricsHandler: metricsHandler, - config: config, - routed: routed, + deliverer: deliverer, } } @@ -112,68 +105,7 @@ func (h *fanOutTaskHandler) Execute( return nil } - // No cancelling context on purpose: one unreachable listener must not stop - // the others being told. - var group errgroup.Group - group.SetLimit(fanOutConcurrency) - for _, listener := range fanOut.WorkflowListeners { - group.Go(func() error { - return h.deliverOne(ctx, ref, ns, listener, fanOut.Latest) - }) - } - return group.Wait() -} - -// deliverOne tells one workflow listener and acts on its answer. The call -// carries its own deadline, so a listener on a slow host does not take the -// whole task's. -func (h *fanOutTaskHandler) deliverOne( - ctx context.Context, - ref chasm.ComponentRef, - ns string, - listener *channelpb.WorkflowListener, - n *channelpb.Notification, -) error { - callCtx, cancel := context.WithTimeout(ctx, channel.RoutedCallTimeout) - defer cancel() - response, err := h.routed.DeliverChannelNotification(callCtx, &channelpb.DeliverChannelNotificationRequest{ - NamespaceId: ref.NamespaceID, - FrontendRequest: &channelpb.DeliverChannelNotificationInput{ - Namespace: ns, - WorkflowId: listener.GetWorkflowId(), - RunId: listener.GetRunId(), - Notification: n, - }, - }) - if err != nil { - // Not taken as proof the listener is gone. The handler already reads a - // missing run as closed and says so in its answer, so an error here is - // the transport's, and dropping a listener on that would be a guess. - h.logger.Warn("failed to deliver a channel notification to a workflow", - tag.NewStringTag("channel", ref.BusinessID), - tag.WorkflowID(listener.GetWorkflowId()), - tag.Error(err)) - return err - } - out := response.GetFrontendResponse() - limits := h.config.LimitsFor(ns) - switch { - case out.GetListenerClosed(): - _, _, err = chasm.UpdateComponent(ctx, ref, - func(c *channel.Channel, mctx chasm.MutableContext, l *channelpb.WorkflowListener) (struct{}, error) { - c.ForgetWorkflowListener(mctx, l.GetWorkflowId(), l.GetRunId(), limits) - return struct{}{}, nil - }, listener) - case out.GetSuccessorRunId() != "": - _, _, err = chasm.UpdateComponent(ctx, ref, - func(c *channel.Channel, mctx chasm.MutableContext, l *channelpb.WorkflowListener) (struct{}, error) { - c.RekeyWorkflowListener(mctx, l.GetWorkflowId(), l.GetRunId(), out.GetSuccessorRunId()) - return struct{}{}, nil - }, listener) - default: - // Delivered, or a duplicate. The listener stays as it is. - } - return err + return h.deliverer.deliverAll(ctx, ref, ns, fanOut.WorkflowListeners, fanOut.Latest) } // Discard lowers the coalescing flag the scheduling notify raised. Left up, no diff --git a/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go b/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go index f5d1dabaf33..006d3c1fdd1 100644 --- a/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go +++ b/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go @@ -253,8 +253,8 @@ type ChannelSubscription struct { state protoimpl.MessageState `protogen:"open.v1"` // The WorkflowNotificationChannelSubscribed event that recorded it. EventId int64 `protobuf:"varint,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - // Highest counter this run accepted from the channel. A redelivery at or - // below it changes nothing, since the run may already have it in History. + // Highest counter this run accepted from the channel. Kept for describing + // the subscription; folding compares against the pending entry instead. LastCounter int64 `protobuf:"varint,2,opt,name=last_counter,json=lastCounter,proto3" json:"last_counter,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/chasm/lib/workflow/notification.go b/chasm/lib/workflow/notification.go index 4fe3b48f969..aefe4ea1f38 100644 --- a/chasm/lib/workflow/notification.go +++ b/chasm/lib/workflow/notification.go @@ -71,18 +71,24 @@ func (w *Workflow) DrainChannelRegistrations() []string { } // ChannelNotificationIsDuplicate reports whether accepting the notification -// would change nothing: the run already accepted this counter or a higher one -// from the channel, or does not listen to it at all. +// would change nothing: the run holds a pending notification from the channel +// at this counter or a higher one, or does not listen to it at all. +// +// Only a pending entry counts. Once a scheduled event has carried a +// notification the entry is gone, and the same counter arriving again is a +// new reason to run: a watcher that finds a record with no task open sends +// the counter it already sent, and folding that away would leave its reader +// waiting. func (w *Workflow) ChannelNotificationIsDuplicate( ctx chasm.Context, channel string, counter int64, ) bool { - field, ok := w.ChannelSubscriptions[channel] - if !ok { + if _, ok := w.ChannelSubscriptions[channel]; !ok { return true } - return counter <= field.Get(readOnly(ctx)).GetLastCounter() + field, ok := w.ChannelNotifications[channel] + return ok && counter <= field.Get(readOnly(ctx)).GetCounter() } // AcceptChannelNotification records a notification from a channel this run @@ -90,24 +96,26 @@ func (w *Workflow) ChannelNotificationIsDuplicate( // written to History here; the transaction close schedules the Workflow Task // whose scheduled event carries it. // -// One entry per channel. A newer notification replaces one no scheduled event -// has carried yet, and the report says it folded. A counter at or below the -// highest this run accepted is dropped: a run may already have that one in -// History, and the channel redelivers after a fan-out it has to retry. +// One entry per channel. A notification at or below the pending entry's +// counter is folded into it and changes nothing; a higher one replaces it; +// with no pending entry it starts one, whatever its counter. It reports +// whether the notification folded into a pending entry. func (w *Workflow) AcceptChannelNotification( mctx chasm.MutableContext, n *channelpb.Notification, ) (bool, error) { channel := n.GetChannel() - field, ok := w.ChannelSubscriptions[channel] + subscription, ok := w.ChannelSubscriptions[channel] if !ok { return false, serviceerror.NewFailedPreconditionf( "workflow does not listen to notification channel %q", channel) } - if n.GetCounter() <= field.Get(readOnly(mctx)).GetLastCounter() { - return false, nil + if w.ChannelNotificationIsDuplicate(mctx, channel, n.GetCounter()) { + return true, nil + } + if n.GetCounter() > subscription.Get(readOnly(mctx)).GetLastCounter() { + subscription.Get(mctx).LastCounter = n.GetCounter() } - field.Get(mctx).LastCounter = n.GetCounter() entry := &chasmworkflowpb.ChannelNotificationEntry{ Position: n.GetPosition(), diff --git a/chasm/lib/workflow/notification_test.go b/chasm/lib/workflow/notification_test.go index affa5b01abd..81d9d0f13a3 100644 --- a/chasm/lib/workflow/notification_test.go +++ b/chasm/lib/workflow/notification_test.go @@ -42,7 +42,7 @@ func TestChannelNotificationsFoldAndSnapshot(t *testing.T) { require.True(t, folded) folded, err = w.AcceptChannelNotification(ctx, channelNote("orders", 2)) require.NoError(t, err) - require.False(t, folded, "a lower counter is dropped, not folded") + require.True(t, folded, "a lower counter folds into the pending one and changes nothing") _, err = w.AcceptChannelNotification(ctx, channelNote("billing", 7)) require.NoError(t, err) require.True(t, w.HasPendingChannelNotifications()) @@ -58,21 +58,28 @@ func TestChannelNotificationsFoldAndSnapshot(t *testing.T) { require.Empty(t, w.TakeChannelNotifications(ctx)) } -// What a scheduled event carried is in History, so a redelivery of the same -// counter after the snapshot changes nothing. -func TestChannelNotificationRedeliveryIsDuplicate(t *testing.T) { +// Folding is against the pending entry only. While one is pending, the same +// or a lower counter changes nothing; once a scheduled event has carried it, +// the same counter is a new reason to run, since a watcher that finds a +// record with no task open sends the counter it already sent. +func TestChannelNotificationFoldsOnlyWhilePending(t *testing.T) { ctx := &chasm.MockMutableContext{} w := &Workflow{} subscribe(t, w, ctx, "orders") _, err := w.AcceptChannelNotification(ctx, channelNote("orders", 4)) require.NoError(t, err) - w.TakeChannelNotifications(ctx) require.True(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 4)) + require.True(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 3)) require.False(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 5)) - _, err = w.AcceptChannelNotification(ctx, channelNote("orders", 4)) + + w.TakeChannelNotifications(ctx) + require.False(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 4), "carried, so news again") + folded, err := w.AcceptChannelNotification(ctx, channelNote("orders", 4)) require.NoError(t, err) - require.False(t, w.HasPendingChannelNotifications()) + require.False(t, folded) + require.True(t, w.HasPendingChannelNotifications()) + require.Equal(t, int64(4), w.ChannelSubscriptions["orders"].Get(ctx).GetLastCounter()) } func TestChannelSubscriptionRules(t *testing.T) { diff --git a/chasm/lib/workflow/proto/v1/state.proto b/chasm/lib/workflow/proto/v1/state.proto index 6c9937e1dc6..64a4777f1c7 100644 --- a/chasm/lib/workflow/proto/v1/state.proto +++ b/chasm/lib/workflow/proto/v1/state.proto @@ -47,8 +47,8 @@ message WakeEntry { message ChannelSubscription { // The WorkflowNotificationChannelSubscribed event that recorded it. int64 event_id = 1; - // Highest counter this run accepted from the channel. A redelivery at or - // below it changes nothing, since the run may already have it in History. + // Highest counter this run accepted from the channel. Kept for describing + // the subscription; folding compares against the pending entry instead. int64 last_counter = 2; } diff --git a/tests/notification_channel_test.go b/tests/notification_channel_test.go index ca0f60cf641..6195239bc39 100644 --- a/tests/notification_channel_test.go +++ b/tests/notification_channel_test.go @@ -760,9 +760,52 @@ func TestNotificationChannelSubscribeOnEmptyChannel(t *testing.T) { require.Len(t, c.describe(name).GetListeners(), 1) require.False(t, c.hasPendingTask(id), "the latest is not handed out again") - // A notify that does not raise the latest counter changes nothing and - // wakes nobody. +} + +// channelTransitions reads the channel execution's state transition count, +// which moves on every write. +func (c *channelTestEnv) channelTransitions(name string) int64 { + c.t.Helper() + resp, err := c.env.AdminClient().DescribeMutableState(c.ctx(), + &adminservice.DescribeMutableStateRequest{ + Namespace: c.ns, + Execution: &commonpb.WorkflowExecution{WorkflowId: name}, + Archetype: channelservice.Archetype, + }) + require.NoError(c.t, err) + return resp.GetDatabaseMutableState().GetExecutionInfo().GetStateTransitionCount() +} + +// The stall the wake round fixed: a listener's task runs to completion, and a +// watcher that later finds the same record with no task open notifies the +// same counter again. That is a new reason to run, so it schedules a task +// whose scheduled event carries the counter. It does not join the ring again. +func TestNotificationChannelRepeatAfterTaskWakesAgain(t *testing.T) { + c := newChannelTestEnv(t) + id := "channel-repeat-" + uuid.NewString() + name := "orders-" + uuid.NewString() + c.subscribe(id, name) + + c.mustNotify(name, 1) + task := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 1}) + c.complete(task, false) + require.False(t, c.hasPendingTask(id)) + require.Equal(t, int32(1), c.mustNotify(name, 1)) + task = c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 1}) require.Equal(t, int32(1), c.describe(name).GetRetainedCount()) - require.False(t, c.hasPendingTask(id)) + + // While the open task keeps the repeat pending, the same counter again + // writes neither the run nor the channel. + c.mustNotify(name, 2) + c.awaitPending(id, name, 2) + runWrites, channelWrites := c.stateTransitions(id), c.channelTransitions(name) + require.Equal(t, int32(1), c.mustNotify(name, 2)) + require.Equal(t, runWrites, c.stateTransitions(id), "folded into the pending entry") + require.Equal(t, channelWrites, c.channelTransitions(name), "the ring and the table are unchanged") + c.complete(task, false) + next := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, next), map[string]int64{name: 2}) } From cede30ebf26d1810694efe12b0b1de0ec2292ffa Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 19:10:21 -0700 Subject: [PATCH 11/12] Folded a repeated notification into a task that has not started. A task that has not read anything yet will see the write a repeat stands for, so the producer and its watcher notifying the same counter cost one task. Once the task starts, a repeat goes to the next one. --- .../workflow/gen/workflowpb/v1/state.pb.go | 22 +++++++-- chasm/lib/workflow/notification.go | 49 ++++++++++++++++--- chasm/lib/workflow/notification_test.go | 37 ++++++++++++-- chasm/lib/workflow/proto/v1/state.proto | 4 ++ .../history/workflow/mutable_state_impl.go | 23 +++++++++ .../workflow/workflow_task_state_machine.go | 7 +++ tests/notification_channel_test.go | 30 ++++++++++++ 7 files changed, 155 insertions(+), 17 deletions(-) diff --git a/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go b/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go index 006d3c1fdd1..57f7be9acb1 100644 --- a/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go +++ b/chasm/lib/workflow/gen/workflowpb/v1/state.pb.go @@ -255,9 +255,13 @@ type ChannelSubscription struct { EventId int64 `protobuf:"varint,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` // Highest counter this run accepted from the channel. Kept for describing // the subscription; folding compares against the pending entry instead. - LastCounter int64 `protobuf:"varint,2,opt,name=last_counter,json=lastCounter,proto3" json:"last_counter,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + LastCounter int64 `protobuf:"varint,2,opt,name=last_counter,json=lastCounter,proto3" json:"last_counter,omitempty"` + // Counter the scheduled event of the run's current task carried, while that + // task has not started. A repeat at or below it folds into that task, which + // has not read anything yet. Zero once the task starts, fails or times out. + ScheduledCounter int64 `protobuf:"varint,3,opt,name=scheduled_counter,json=scheduledCounter,proto3" json:"scheduled_counter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChannelSubscription) Reset() { @@ -304,6 +308,13 @@ func (x *ChannelSubscription) GetLastCounter() int64 { return 0 } +func (x *ChannelSubscription) GetScheduledCounter() int64 { + if x != nil { + return x.ScheduledCounter + } + return 0 +} + // ChannelNotificationEntry is the latest notification from one channel that // no WorkflowTaskScheduled event has carried yet. Deleted when one does. type ChannelNotificationEntry struct { @@ -384,10 +395,11 @@ const file_temporal_server_chasm_lib_workflow_proto_v1_state_proto_rawDesc = "" "\n" + "generation\x18\x03 \x01(\x03R\n" + "generation\x121\n" + - "\x14delivered_generation\x18\x04 \x01(\x03R\x13deliveredGeneration\"S\n" + + "\x14delivered_generation\x18\x04 \x01(\x03R\x13deliveredGeneration\"\x80\x01\n" + "\x13ChannelSubscription\x12\x19\n" + "\bevent_id\x18\x01 \x01(\x03R\aeventId\x12!\n" + - "\flast_counter\x18\x02 \x01(\x03R\vlastCounter\"\x9f\x02\n" + + "\flast_counter\x18\x02 \x01(\x03R\vlastCounter\x12+\n" + + "\x11scheduled_counter\x18\x03 \x01(\x03R\x10scheduledCounter\"\x9f\x02\n" + "\x18ChannelNotificationEntry\x12\x1a\n" + "\bposition\x18\x01 \x01(\fR\bposition\x12\x18\n" + "\acounter\x18\x02 \x01(\x03R\acounter\x12o\n" + diff --git a/chasm/lib/workflow/notification.go b/chasm/lib/workflow/notification.go index aefe4ea1f38..7b27af5b867 100644 --- a/chasm/lib/workflow/notification.go +++ b/chasm/lib/workflow/notification.go @@ -72,23 +72,29 @@ func (w *Workflow) DrainChannelRegistrations() []string { // ChannelNotificationIsDuplicate reports whether accepting the notification // would change nothing: the run holds a pending notification from the channel -// at this counter or a higher one, or does not listen to it at all. +// at this counter or a higher one, its scheduled and not yet started task +// carries one, or it does not listen to the channel at all. // -// Only a pending entry counts. Once a scheduled event has carried a -// notification the entry is gone, and the same counter arriving again is a -// new reason to run: a watcher that finds a record with no task open sends -// the counter it already sent, and folding that away would leave its reader +// A started task does not count. It may have read its source before the +// write the repeat stands for, so the same counter arriving then is a new +// reason to run: a watcher that finds a record with no task open sends the +// counter it already sent, and folding that away would leave its reader // waiting. func (w *Workflow) ChannelNotificationIsDuplicate( ctx chasm.Context, channel string, counter int64, ) bool { - if _, ok := w.ChannelSubscriptions[channel]; !ok { + subscription, ok := w.ChannelSubscriptions[channel] + if !ok { + return true + } + view := readOnly(ctx) + if counter <= subscription.Get(view).GetScheduledCounter() { return true } field, ok := w.ChannelNotifications[channel] - return ok && counter <= field.Get(readOnly(ctx)).GetCounter() + return ok && counter <= field.Get(view).GetCounter() } // AcceptChannelNotification records a notification from a channel this run @@ -140,6 +146,8 @@ func (w *Workflow) HasPendingChannelNotifications() bool { // channel and sorted by channel, and clears them. Called for a // WorkflowTaskScheduled event that is being written to History, which is // what acknowledges them: nothing is redelivered once an event carries it. +// Each channel's subscription remembers the counter the event carries until +// the task starts, so a repeat in that window folds into the task. func (w *Workflow) TakeChannelNotifications(mctx chasm.MutableContext) []*channelpb.Notification { if len(w.ChannelNotifications) == 0 { return nil @@ -156,6 +164,33 @@ func (w *Workflow) TakeChannelNotifications(mctx chasm.MutableContext) []*channe Metadata: entry.GetMetadata(), }) delete(w.ChannelNotifications, channel) + if sub, ok := w.ChannelSubscriptions[channel]; ok { + sub.Get(mctx).ScheduledCounter = entry.GetCounter() + } } return out } + +// HasScheduledChannelCounters reports whether any subscription remembers what +// a scheduled task carries. +func (w *Workflow) HasScheduledChannelCounters(ctx chasm.Context) bool { + view := readOnly(ctx) + for _, field := range w.ChannelSubscriptions { + if field.Get(view).GetScheduledCounter() > 0 { + return true + } + } + return false +} + +// ClearScheduledChannelCounters forgets what the scheduled task carries. +// Called when the task starts, fails or times out: from then on a repeat +// cannot be assumed to reach a task that has not read yet. +func (w *Workflow) ClearScheduledChannelCounters(mctx chasm.MutableContext) { + view := readOnly(mctx) + for _, field := range w.ChannelSubscriptions { + if field.Get(view).GetScheduledCounter() > 0 { + field.Get(mctx).ScheduledCounter = 0 + } + } +} diff --git a/chasm/lib/workflow/notification_test.go b/chasm/lib/workflow/notification_test.go index 81d9d0f13a3..30133bbf4bd 100644 --- a/chasm/lib/workflow/notification_test.go +++ b/chasm/lib/workflow/notification_test.go @@ -58,10 +58,10 @@ func TestChannelNotificationsFoldAndSnapshot(t *testing.T) { require.Empty(t, w.TakeChannelNotifications(ctx)) } -// Folding is against the pending entry only. While one is pending, the same -// or a lower counter changes nothing; once a scheduled event has carried it, -// the same counter is a new reason to run, since a watcher that finds a -// record with no task open sends the counter it already sent. +// Folding is against the pending entry and the unstarted task. While one is +// pending, the same or a lower counter changes nothing; once a task carrying +// it has started, the same counter is a new reason to run, since a watcher +// that finds a record with no task open sends the counter it already sent. func TestChannelNotificationFoldsOnlyWhilePending(t *testing.T) { ctx := &chasm.MockMutableContext{} w := &Workflow{} @@ -74,7 +74,8 @@ func TestChannelNotificationFoldsOnlyWhilePending(t *testing.T) { require.False(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 5)) w.TakeChannelNotifications(ctx) - require.False(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 4), "carried, so news again") + w.ClearScheduledChannelCounters(ctx) + require.False(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 4), "carried and started, so news again") folded, err := w.AcceptChannelNotification(ctx, channelNote("orders", 4)) require.NoError(t, err) require.False(t, folded) @@ -108,3 +109,29 @@ func TestChannelSubscriptionRules(t *testing.T) { require.Equal(t, []string{"orders"}, w.DrainChannelRegistrations()) require.Empty(t, w.DrainChannelRegistrations()) } + +// The scheduled event of a task that has not started remembers what it +// carries, so a repeat in that window folds into the task; once the task +// starts, fails or times out, the memory is cleared and a repeat is news. +func TestChannelNotificationFoldsIntoUnstartedTask(t *testing.T) { + ctx := &chasm.MockMutableContext{} + w := &Workflow{} + subscribe(t, w, ctx, "orders") + _, err := w.AcceptChannelNotification(ctx, channelNote("orders", 4)) + require.NoError(t, err) + w.TakeChannelNotifications(ctx) + require.True(t, w.HasScheduledChannelCounters(ctx)) + + folded, err := w.AcceptChannelNotification(ctx, channelNote("orders", 4)) + require.NoError(t, err) + require.True(t, folded, "the unstarted task carries it") + require.False(t, w.HasPendingChannelNotifications()) + require.False(t, w.ChannelNotificationIsDuplicate(ctx, "orders", 5)) + + w.ClearScheduledChannelCounters(ctx) + require.False(t, w.HasScheduledChannelCounters(ctx)) + folded, err = w.AcceptChannelNotification(ctx, channelNote("orders", 4)) + require.NoError(t, err) + require.False(t, folded, "the task started, so the repeat goes to the next one") + require.True(t, w.HasPendingChannelNotifications()) +} diff --git a/chasm/lib/workflow/proto/v1/state.proto b/chasm/lib/workflow/proto/v1/state.proto index 64a4777f1c7..6789886dc23 100644 --- a/chasm/lib/workflow/proto/v1/state.proto +++ b/chasm/lib/workflow/proto/v1/state.proto @@ -50,6 +50,10 @@ message ChannelSubscription { // Highest counter this run accepted from the channel. Kept for describing // the subscription; folding compares against the pending entry instead. int64 last_counter = 2; + // Counter the scheduled event of the run's current task carried, while that + // task has not started. A repeat at or below it folds into that task, which + // has not read anything yet. Zero once the task starts, fails or times out. + int64 scheduled_counter = 3; } // ChannelNotificationEntry is the latest notification from one channel that diff --git a/service/history/workflow/mutable_state_impl.go b/service/history/workflow/mutable_state_impl.go index 6a91a0da603..22664743fe9 100644 --- a/service/history/workflow/mutable_state_impl.go +++ b/service/history/workflow/mutable_state_impl.go @@ -781,6 +781,29 @@ func (ms *MutableStateImpl) attachChannelNotifications(event *historypb.HistoryE channel.ToAPINotifications(notifications) } +// clearScheduledChannelCounters forgets the counters the scheduled task +// carries, once that task starts, fails or times out. Checked through a +// read-only view first, since almost no workflow has any. +func (ms *MutableStateImpl) clearScheduledChannelCounters() { + node, ok := ms.chasmTree.(*chasm.Node) + if !ok { + return + } + view := chasm.NewContext(context.Background(), node) + rootComponent, err := node.ComponentByPath(view, nil) + if err != nil { + return + } + if wf, ok := rootComponent.(*chasmworkflow.Workflow); !ok || !wf.HasScheduledChannelCounters(view) { + return + } + wf, chasmCtx, err := ms.ChasmWorkflowComponent(context.Background()) + if err != nil { + return + } + wf.ClearScheduledChannelCounters(chasmCtx) +} + // takeChannelNotifications hands the pending channel notifications to a // WorkflowTaskScheduled event that is being written, and clears them. The // event is the acknowledgment: once History carries a notification nothing diff --git a/service/history/workflow/workflow_task_state_machine.go b/service/history/workflow/workflow_task_state_machine.go index 3e4fa05c4bd..08b5e36fe94 100644 --- a/service/history/workflow/workflow_task_state_machine.go +++ b/service/history/workflow/workflow_task_state_machine.go @@ -267,6 +267,7 @@ func (m *workflowTaskStateMachine) ApplyWorkflowTaskTimedOutEvent(timeoutType en func (m *workflowTaskStateMachine) AddWorkflowTaskScheduleToStartTimeoutEvent( workflowTask *historyi.WorkflowTaskInfo, ) (*historypb.HistoryEvent, error) { + m.ms.clearScheduledChannelCounters() opTag := tag.WorkflowActionWorkflowTaskTimedOut if m.ms.executionInfo.WorkflowTaskScheduledEventId != workflowTask.ScheduledEventID || m.ms.executionInfo.WorkflowTaskStartedEventId > 0 { m.ms.logger.Warn(mutableStateInvalidHistoryActionMsg, opTag, @@ -630,6 +631,10 @@ func (m *workflowTaskStateMachine) AddWorkflowTaskStartedEvent( m.emitWorkflowTaskAttemptStats(workflowTask.Attempt) + // The task is reading from here on, so a repeated notification can no + // longer be assumed to reach it and goes to the next task instead. + m.ms.clearScheduledChannelCounters() + // TODO merge active & passive task generation if err = m.ms.taskGenerator.GenerateStartWorkflowTaskTasks( scheduledEventID, @@ -917,6 +922,7 @@ func (m *workflowTaskStateMachine) AddWorkflowTaskFailedEvent( newRunID string, forkEventVersion int64, ) (*historypb.HistoryEvent, error) { + m.ms.clearScheduledChannelCounters() // IMPORTANT: returned event can be nil under some circumstances. Specifically, if WT is transient. @@ -992,6 +998,7 @@ func (m *workflowTaskStateMachine) AddWorkflowTaskFailedEvent( func (m *workflowTaskStateMachine) AddWorkflowTaskTimedOutEvent( workflowTask *historyi.WorkflowTaskInfo, ) (*historypb.HistoryEvent, error) { + m.ms.clearScheduledChannelCounters() if workflowTask.Type == enumsspb.WORKFLOW_TASK_TYPE_SPECULATIVE { m.ms.RemoveSpeculativeWorkflowTaskTimeoutTask() diff --git a/tests/notification_channel_test.go b/tests/notification_channel_test.go index 6195239bc39..42ecb3cb1ae 100644 --- a/tests/notification_channel_test.go +++ b/tests/notification_channel_test.go @@ -797,6 +797,11 @@ func TestNotificationChannelRepeatAfterTaskWakesAgain(t *testing.T) { requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 1}) require.Equal(t, int32(1), c.describe(name).GetRetainedCount()) + // The task has started, so the same counter again may stand for a write + // its read missed: it goes pending for the next task. + c.mustNotify(name, 1) + c.awaitPending(id, name, 1) + // While the open task keeps the repeat pending, the same counter again // writes neither the run nor the channel. c.mustNotify(name, 2) @@ -809,3 +814,28 @@ func TestNotificationChannelRepeatAfterTaskWakesAgain(t *testing.T) { next := c.poll(id) requireNotifications(t, c.scheduledNotifications(id, next), map[string]int64{name: 2}) } + +// A repeat that arrives while the task carrying the counter is scheduled and +// not yet started folds into that task: it has not read anything, so it will +// see the write the repeat stands for. One task, and neither the run nor the +// channel is written. +func TestNotificationChannelFoldsIntoUnstartedTask(t *testing.T) { + c := newChannelTestEnv(t) + id := "channel-unstarted-" + uuid.NewString() + name := "orders-" + uuid.NewString() + c.subscribe(id, name) + + c.mustNotify(name, 1) + await.Require(c.ctx(), t, func(t *await.T) { + require.True(t, c.hasPendingTask(id)) + }, 20*time.Second, 50*time.Millisecond) + runWrites, channelWrites := c.stateTransitions(id), c.channelTransitions(name) + require.Equal(t, int32(1), c.mustNotify(name, 1)) + require.Equal(t, runWrites, c.stateTransitions(id), "folded into the unstarted task") + require.Equal(t, channelWrites, c.channelTransitions(name)) + + task := c.poll(id) + requireNotifications(t, c.scheduledNotifications(id, task), map[string]int64{name: 1}) + c.complete(task, false) + require.False(t, c.hasPendingTask(id), "one task") +} From 93bc38076cb68cea8cfa612cb214812e6e7b415a Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 1 Oct 2026 22:50:49 -0700 Subject: [PATCH 12/12] Gave each regenerated test timer a distinct deadline. Timers with equal deadlines are grouped into one state machine timer, and the wall clock on macOS does not always tick between two nodes of one refresh walk, so the task refresher test counted two timer groups where it expected three. --- service/history/hsm/hsmtest/sm.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/service/history/hsm/hsmtest/sm.go b/service/history/hsm/hsmtest/sm.go index d01aec19a01..0a5c72308cc 100644 --- a/service/history/hsm/hsmtest/sm.go +++ b/service/history/hsm/hsmtest/sm.go @@ -3,6 +3,7 @@ package hsmtest import ( "fmt" "strings" + "sync/atomic" "time" "go.temporal.io/server/service/history/hsm" @@ -41,10 +42,17 @@ func (d *Data) SetState(s State) { d.state = s } +// regenerated counts the timer tasks handed out. Timers with equal deadlines +// are grouped into one state machine timer, and the wall clock does not tick +// between two nodes of one walk on every platform, so each deadline is pushed +// out by one more nanosecond than the last. +var regenerated atomic.Int64 + func (d *Data) RegenerateTasks(node *hsm.Node) ([]hsm.Task, error) { + deadline := time.Now().Add(time.Hour + time.Duration(regenerated.Add(1))) return []hsm.Task{ NewTask( - hsm.TaskAttributes{Deadline: time.Now().Add(time.Hour)}, + hsm.TaskAttributes{Deadline: deadline}, false, ), NewTask(