Skip to content

fix(resolve): resolve subscription updates concurrently in order#1602

Open
jensneuse wants to merge 3 commits into
masterfrom
fix/ordered-concurrent-subscription-updates
Open

fix(resolve): resolve subscription updates concurrently in order#1602
jensneuse wants to merge 3 commits into
masterfrom
fix/ordered-concurrent-subscription-updates

Conversation

@jensneuse

Copy link
Copy Markdown
Member

@coderabbitai summary

Summary

  • resolve targeted subscription updates concurrently across distinct subscription identifiers
  • preserve FIFO admission order for updates targeting the same subscription
  • coordinate completion, terminal errors, cleanup, and per-subscription close with admitted updates
  • prevent data, heartbeat, and formatted-error writes after a terminal frame
  • add focused concurrency, lifecycle, panic, cancellation, and single-flight regression coverage

Motivation

UpdateSubscription held a trigger-wide mutex across filtering, resolution, subgraph fetches, writes, and flushes. Fan-out updates for different subscribers therefore ran serially, causing head-of-line blocking and preventing identical in-flight subgraph fetches from sharing a response.

This change replaces that trigger-wide resolution serialization with keyed per-subscription admission lanes. Different subscribers can resolve concurrently, while each subscriber retains deterministic update order and lifecycle operations cannot overtake admitted work.

Verification

  • go test ./... -count=1
  • go test -race ./pkg/engine/resolve -count=1
  • go vet ./...
  • targeted concurrency and lifecycle tests repeated under the race detector

Checklist

  • I have discussed my proposed changes in an issue and have received approval to proceed.
  • I have followed the coding standards of the project.
  • Tests or benchmarks have been added or updated.

Open Source AI Manifesto

This contribution follows the principles of the Open Source AI Manifesto.

@jensneuse
jensneuse requested a review from a team as a code owner July 17, 2026 22:37
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Subscription resolution now admits updates concurrently across subscribers while preserving FIFO ordering per subscriber. Lifecycle operations wait for admitted updates, terminal frames suppress later writes, terminating triggers reject new subscribers, and tests cover concurrency, cleanup, cancellation, and failure paths.

Changes

Ordered subscription updates

Layer / File(s) Summary
Concurrency and lifecycle design
docs/superpowers/plans/..., docs/superpowers/specs/...
Documents define keyed update lanes, lifecycle barriers, terminal suppression, cancellation and panic handling, and verification steps.
Terminal state and write protection
v2/pkg/engine/resolve/resolve.go, v2/pkg/engine/resolve/subscription_updater_test.go
Subscription writes and heartbeats suppress output after terminal or removal states; arena cleanup, flush failures, ticker handling, and registration admission are updated.
Per-subscriber update admission
v2/pkg/engine/resolve/resolve.go, v2/pkg/engine/resolve/subscription_updater_test.go
Updates use keyed predecessor tails, allowing distinct subscribers to resolve concurrently while preserving same-subscriber FIFO ordering and cleaning up tails.
Lifecycle barriers and terminal admission
v2/pkg/engine/resolve/resolve.go, v2/pkg/engine/resolve/subscription_updater_test.go
Update, Heartbeat, Complete, Error, Done, and CloseSubscription coordinate with admitted updates; terminating triggers reject late subscribers and terminal delivery follows first-terminal-wins behavior.
Concurrency and failure validation
v2/pkg/engine/resolve/subscription_updater_test.go
Tests cover cancellation, panic and flush cleanup, filter-error ordering, tail cleanup, concurrent resolution, and single-flight fetches.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Trigger
  participant subscriptionUpdater
  participant Resolver
  participant subscriptionState
  participant Writer
  Trigger->>subscriptionUpdater: UpdateSubscription
  subscriptionUpdater->>Resolver: resolve admitted subscriber update
  Resolver->>subscriptionState: ordered write or terminal check
  subscriptionState->>Writer: emit data or control frame
  Trigger->>subscriptionUpdater: Complete or Error
  subscriptionUpdater->>subscriptionUpdater: wait for admitted updates
  subscriptionUpdater->>Resolver: deliver terminal lifecycle event
  Resolver->>Writer: emit terminal frame
Loading
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: ordered concurrent subscription updates in resolve.
Description check ✅ Passed The description matches the changeset and explains the concurrency, ordering, lifecycle, and testing updates.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ordered-concurrent-subscription-updates

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@v2/pkg/engine/resolve/resolve.go`:
- Around line 1074-1076: Synchronize the removal state with the downstream write
phase in resolve.go around the subscription write path guarded by sub.writeMu,
so Resolve or Flush cannot continue after unsubscribe returns; alternatively
stage output and atomically discard it if removal wins. Add a
subscription_updater_test.go case covering unsubscribe after the final write
section begins, and update the design spec and implementation plan at the cited
ranges to document this synchronization requirement and post-check race
coverage.
- Around line 980-987: Update subscription error-writing flows around
subscriptionState.writeError and the referenced send paths to return whether an
error frame was actually written, returning false when removed or terminal and
true after WriteError succeeds. Gate every SubscriptionUpdateSent() call on that
sent result, including the additional paths noted in the review, while
preserving existing locking and output behavior.

In `@v2/pkg/engine/resolve/subscription_updater_test.go`:
- Line 14: Fix the lint issues in subscription_updater_test.go by running the
repository’s gci formatter, inserting blank lines between embedded fields in the
affected declarations, and applying the rangeint-required loop fixes at the
referenced locations. Preserve the existing test behavior while updating all
indicated occurrences, including the additional reported ranges.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3008777d-c8e4-43e0-874c-6f4688024c7d

📥 Commits

Reviewing files that changed from the base of the PR and between dd73327 and 7ef3a87.

📒 Files selected for processing (4)
  • docs/superpowers/plans/2026-07-17-ordered-concurrent-subscription-updates.md
  • docs/superpowers/specs/2026-07-17-ordered-concurrent-subscription-updates-design.md
  • v2/pkg/engine/resolve/resolve.go
  • v2/pkg/engine/resolve/subscription_updater_test.go

Comment on lines 980 to 987
func (s *subscriptionState) writeError(w AsyncErrorWriter, ctx *Context, err error, response *GraphQLResponse) {
s.writeMu.Lock()
defer s.writeMu.Unlock()
if s.removed.Load() {
if s.removed.Load() || s.terminal {
return
}
w.WriteError(ctx, err, response, s.writer)
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Count formatted errors only when a frame was written.

writeError now suppresses output after removal or termination, but these three paths still increment SubscriptionUpdateSent. Return a sent flag, as sendHeartbeat does, and gate the reporter increment.

Proposed fix
-func (s *subscriptionState) writeError(w AsyncErrorWriter, ctx *Context, err error, response *GraphQLResponse) {
+func (s *subscriptionState) writeError(w AsyncErrorWriter, ctx *Context, err error, response *GraphQLResponse) bool {
 	s.writeMu.Lock()
 	defer s.writeMu.Unlock()
 	if s.removed.Load() || s.terminal {
-		return
+		return false
 	}
 	w.WriteError(ctx, err, response, s.writer)
+	return true
 }

Use the returned value to guard each SubscriptionUpdateSent() call.

Also applies to: 1031-1039, 1042-1050, 1059-1067

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/resolve/resolve.go` around lines 980 - 987, Update subscription
error-writing flows around subscriptionState.writeError and the referenced send
paths to return whether an error frame was actually written, returning false
when removed or terminal and true after WriteError succeeds. Gate every
SubscriptionUpdateSent() call on that sent result, including the additional
paths noted in the review, while preserving existing locking and output
behavior.

Comment on lines +1074 to +1076
sub.writeMu.Lock()
defer sub.writeMu.Unlock()
if sub.removed.Load() || sub.terminal {

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Linearize unsubscribe with the downstream write phase.

removed can change after the one-time check at Line 1076, allowing Resolve or Flush to continue after unsubscribe returns.

  • v2/pkg/engine/resolve/resolve.go#L1074-L1076: synchronize removal with writeMu, or stage output and atomically discard it when removal wins.
  • v2/pkg/engine/resolve/subscription_updater_test.go#L1021-L1079: add a case that unsubscribes after the final write section has begun.
  • docs/superpowers/specs/2026-07-17-ordered-concurrent-subscription-updates-design.md#L78-L78: document the required write/removal synchronization.
  • docs/superpowers/plans/2026-07-17-ordered-concurrent-subscription-updates.md#L286-L286: require coverage of this post-check race window.
📍 Affects 4 files
  • v2/pkg/engine/resolve/resolve.go#L1074-L1076 (this comment)
  • v2/pkg/engine/resolve/subscription_updater_test.go#L1021-L1079
  • docs/superpowers/specs/2026-07-17-ordered-concurrent-subscription-updates-design.md#L78-L78
  • docs/superpowers/plans/2026-07-17-ordered-concurrent-subscription-updates.md#L286-L286
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/resolve/resolve.go` around lines 1074 - 1076, Synchronize the
removal state with the downstream write phase in resolve.go around the
subscription write path guarded by sub.writeMu, so Resolve or Flush cannot
continue after unsubscribe returns; alternatively stage output and atomically
discard it if removal wins. Add a subscription_updater_test.go case covering
unsubscribe after the final write section begins, and update the design spec and
implementation plan at the cited ranges to document this synchronization
requirement and post-check race coverage.

"testing"
"time"

"github.com/stretchr/testify/require"

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the reported lint failures.

Run the repository’s gci formatter, separate embedded fields with blank lines, and apply the rangeint fixes at Lines 350 and 698.

 type panickingSubscriptionWriter struct {
 	chronologicalSubscriptionWriter
+
 	writeErr error
 }

 type subscriptionSingleFlightWaitContext struct {
 	context.Context
+
 	waitObserved chan struct{}
 }

-for i := 0; i < count; i++ {
+for i := range count {

-for i := 0; i < 32; i++ {
+for i := range 32 {

Also applies to: 127-134, 170-174, 350-350, 698-698

🧰 Tools
🪛 GitHub Check: Linters (1.25)

[failure] 14-14:
File is not properly formatted (gci)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/resolve/subscription_updater_test.go` at line 14, Fix the lint
issues in subscription_updater_test.go by running the repository’s gci
formatter, inserting blank lines between embedded fields in the affected
declarations, and applying the rangeint-required loop fixes at the referenced
locations. Preserve the existing test behavior while updating all indicated
occurrences, including the additional reported ranges.

Source: Linters/SAST tools

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant