Skip to content

Do not open a spurious empty WorkflowRun span on no-work wakeups in the streaming run loop - #739

Open
PratikDhanave wants to merge 2 commits into
microsoft:mainfrom
PratikDhanaveFork:no-spurious-workflowrun-span-no-work-wakeup
Open

Do not open a spurious empty WorkflowRun span on no-work wakeups in the streaming run loop#739
PratikDhanave wants to merge 2 commits into
microsoft:mainfrom
PratikDhanaveFork:no-spurious-workflowrun-span-no-work-wakeup

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

What

In workflow/internal/execution/eventstream.go, streamingRunEventStream.runLoop() opened a WorkflowRun span on every wakeup: it called telemetry.StartWorkflowRun + runActivity.AddEvent(EventWorkflowStarted) unconditionally, and AddEvent(EventWorkflowCompleted) + End() unconditionally. Only the actual superstep work and the StartedEvent were guarded by if s.stepRunner.HasUnprocessedMessages(), with the existing comment noting that events should be emitted "only when there's actual work to process, to avoid spurious events on no-work loop iterations." The telemetry span was left outside that guard.

No-work wakeups are reachable in normal operation:

  • the loop is signaled when only HasUnservicedRequests() is true (no messages queued), and
  • checkpoint restore always signals the run loop even when no messages are queued.

Each such wakeup therefore produced a zero-superstep workflow_invoke span with no executor.process children.

This change moves StartWorkflowRun + EventWorkflowStarted inside the HasUnprocessedMessages() branch, co-located with the StartedEvent emission, and finalizes the span (EventWorkflowCompleted + End()) only when a run activity was actually started, guarding on a nil runActivity. No-work iterations now create no span.

Why

This mirrors the sibling lockstep implementation (lockstepRunEventStream) in the same file, which already opens the run span lazily via startRunActivity() and guards finalization with if runActivity != nil. Aligning the streaming path removes an inconsistency between the two execution modes and keeps WorkflowRun span semantics matching the .NET/Python behavior, where a run/invoke activity corresponds to an actual unit of work rather than an idle wakeup. It also brings the telemetry in line with the StartedEvent guard that already lived in this loop.

Tests

Added TestStreamingRunEventStream_NoWorkWakeupDoesNotOpenWorkflowRunSpan to the package's canonical eventstream_test.go. It builds a telemetry-enabled workflow with an in-memory recording tracer, starts the streaming run loop, and triggers a no-work wakeup (SignalInput while HasUnprocessedMessages() reports false, as in a checkpoint restore whose only state is an unserviced request). It synchronizes on the loop's internal halt signal, then asserts zero workflow_invoke spans. The test fails before the fix (count = 1) and passes after.

go build ./..., go vet ./workflow/internal/execution/..., and go test ./workflow/internal/execution/... -race all pass.

Copilot AI review requested due to automatic review settings July 24, 2026 03:49
@PratikDhanave
PratikDhanave requested a review from a team as a code owner July 24, 2026 03:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts streaming run-loop telemetry so a workflow_invoke (WorkflowRun) span is only created when there is actual work (unprocessed messages) to execute, eliminating zero-superstep spans caused by idle/no-work wakeups and aligning behavior with the existing lockstep implementation.

Changes:

  • Move telemetry.StartWorkflowRun and EventWorkflowStarted inside the HasUnprocessedMessages() branch in streamingRunEventStream.runLoop().
  • Guard run-span finalization (EventWorkflowCompleted + End()) behind a runActivity != nil check.
  • Add a regression test ensuring a no-work wakeup does not emit any workflow_invoke spans.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
workflow/internal/execution/eventstream.go Avoids creating WorkflowRun spans on no-work wakeups by starting/ending spans only when work is present.
workflow/internal/execution/eventstream_test.go Adds a telemetry-focused regression test to assert no workflow_invoke spans are created on a no-work wakeup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread workflow/internal/execution/eventstream_test.go Outdated
@github-actions

This comment has been minimized.

@github-actions github-actions Bot added the parity-approved Go API consistency review found no parity issues label Jul 24, 2026
The streaming run loop opened a WorkflowRun span (StartWorkflowRun +
EventWorkflowStarted) unconditionally on every wakeup, and emitted
EventWorkflowCompleted + End() unconditionally, even when the iteration
had no unprocessed messages to run.

No-work wakeups are reachable: the loop is signaled when only
HasUnservicedRequests() is true, and checkpoint restore always signals
the run loop regardless of queued messages. Each such wakeup produced a
zero-superstep workflow_invoke span.

Move span creation inside the HasUnprocessedMessages branch, co-located
with the StartedEvent emission that was already guarded there, and only
finalize the span when one was actually started (guard on nil
runActivity). This mirrors the lockstep implementation, which already
uses a nil runActivity guard, keeping the two paths consistent.
@PratikDhanave
PratikDhanave force-pushed the no-spurious-workflowrun-span-no-work-wakeup branch from 9b95de4 to 8e0a576 Compare July 24, 2026 09:30
@github-actions

Copy link
Copy Markdown
Contributor

Parity Review — PR #739

Scope: Internal observability fix — removes a spurious empty WorkflowRun span that was incorrectly opened on no-work wakeups in the streaming run loop.

Verdict: ✅ No parity issues.

  • No exported Go API is added, changed, or removed.
  • No user-visible behavior changes (span emission is transparent to callers).
  • The upstream .NET and Python implementations do not expose an equivalent span-emission contract that this change could diverge from.
  • The parity-approved label remains correct; public-api-change is not warranted.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

Generated by Go API Consistency Review Agent · 31.2 AIC · ⌖ 5.52 AIC · ⊞ 5.9K ·

Comment on lines +179 to +180
cycleCtx := ctx
var runActivity *observability.Activity

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These declarations are not necessary if the runActivity.AddEvent and runActivity.End calls are moved inside the s.stepRunner.HasUnprocessedMessages block.

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

Labels

parity-approved Go API consistency review found no parity issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants