Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions router-tests/events/trigger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
package events_test

import (
"sync"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/wundergraph/cosmo/router-tests/testenv"
)

// TestEDFSTriggerDeduplication verifies trigger ID generation for Cosmo Streams subscriptions.
// It tests that the trigger hash is based on the NATS subject and provider ID, not on
// the resolver input — meaning subscriptions to the same subject share one trigger
// even when they select different fields, have different headers or arguments.
func TestEDFSTriggerDeduplication(t *testing.T) {
t.Parallel()

// Two subscriptions with the same employeeID but different selected fields should share
// a single NATS trigger because both resolve to the same subject ("employeeUpdated.3").
t.Run("same subject different selected fields shares one trigger", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithEdfsNatsJSONTemplate,
EnableNats: true,
}, func(t *testing.T, xEnv *testenv.Environment) {
var done sync.WaitGroup
done.Add(2)

go func() {
xEnv.WaitForSubscriptionCount(2, time.Second*10)
// Both subscriptions target the same NATS subject ("employeeUpdated.3") and
// provider ID, so they must resolve to the same trigger ID — wait for exactly
// one trigger to be initialized before asserting.
xEnv.WaitForTriggerCount(1, time.Second*10)
xEnv.RequireTriggerCount(1)
xEnv.NATSPublishUntilReceived(xEnv.NatsConnectionDefault, xEnv.GetPubSubName("employeeUpdated.3"), []byte(`{"id":3,"__typename":"Employee"}`), 2, time.Second*10)
}()

// Subscription 1: selects only id.
go func() {
defer done.Done()
conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, nil)
defer conn.Close()

err := testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{
ID: "1",
Type: "subscribe",
Payload: []byte(`{"query":"subscription { employeeUpdated(employeeID: 3) { id } }"}`),
})
require.NoError(t, err)

var msg testenv.WebSocketMessage
err = testenv.WSReadJSON(t, conn, &msg)
require.NoError(t, err)
require.Equal(t, "next", msg.Type)
require.Equal(t, "1", msg.ID)

err = testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{ID: "1", Type: "complete"})
require.NoError(t, err)

var complete testenv.WebSocketMessage
err = conn.SetReadDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
err = testenv.WSReadJSON(t, conn, &complete)
require.NoError(t, err)
require.Equal(t, "complete", complete.Type)
require.Equal(t, "1", complete.ID)
}()

// Subscription 2: selects id and details — a different query shape over the same subject.
go func() {
defer done.Done()
conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, nil)
defer conn.Close()

err := testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{
ID: "1",
Type: "subscribe",
Payload: []byte(`{"query":"subscription { employeeUpdated(employeeID: 3) { id details { forename surname } } }"}`),
})
require.NoError(t, err)

var msg testenv.WebSocketMessage
err = testenv.WSReadJSON(t, conn, &msg)
require.NoError(t, err)
require.Equal(t, "next", msg.Type)
require.Equal(t, "1", msg.ID)

err = testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{ID: "1", Type: "complete"})
require.NoError(t, err)

var complete testenv.WebSocketMessage
err = conn.SetReadDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
err = testenv.WSReadJSON(t, conn, &complete)
require.NoError(t, err)
require.Equal(t, "complete", complete.Type)
require.Equal(t, "1", complete.ID)
}()

done.Wait()
xEnv.WaitForSubscriptionCount(0, time.Second*5)
})
})

// Two subscriptions with the same query but different initial_payload (headers) should
// still share a single NATS trigger because the trigger ID is based on the NATS subject,
// not on connection-level metadata like headers.
t.Run("same subject different initial payload shares one trigger", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithEdfsNatsJSONTemplate,
EnableNats: true,
}, func(t *testing.T, xEnv *testenv.Environment) {
var done sync.WaitGroup
done.Add(2)

go func() {
xEnv.WaitForSubscriptionCount(2, time.Second*10)
xEnv.WaitForTriggerCount(1, time.Second*10)
xEnv.RequireTriggerCount(1)
xEnv.NATSPublishUntilReceived(xEnv.NatsConnectionDefault, xEnv.GetPubSubName("employeeUpdated.3"), []byte(`{"id":3,"__typename":"Employee"}`), 2, time.Second*10)
}()

// Subscription 1: sends Authorization header A in connection_init.
go func() {
defer done.Done()
conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, []byte(`{"headers":{"Authorization":"Bearer token-a"}}`))
defer conn.Close()

err := testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{
ID: "1",
Type: "subscribe",
Payload: []byte(`{"query":"subscription { employeeUpdated(employeeID: 3) { id } }"}`),
})
require.NoError(t, err)

var msg testenv.WebSocketMessage
err = testenv.WSReadJSON(t, conn, &msg)
require.NoError(t, err)
require.Equal(t, "next", msg.Type)
require.Equal(t, "1", msg.ID)

err = testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{ID: "1", Type: "complete"})
require.NoError(t, err)

var complete testenv.WebSocketMessage
err = conn.SetReadDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
err = testenv.WSReadJSON(t, conn, &complete)
require.NoError(t, err)
require.Equal(t, "complete", complete.Type)
require.Equal(t, "1", complete.ID)
}()

// Subscription 2: sends a different Authorization header in connection_init.
go func() {
defer done.Done()
conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, []byte(`{"headers":{"Authorization":"Bearer token-b"}}`))
defer conn.Close()

err := testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{
ID: "1",
Type: "subscribe",
Payload: []byte(`{"query":"subscription { employeeUpdated(employeeID: 3) { id } }"}`),
})
require.NoError(t, err)

var msg testenv.WebSocketMessage
err = testenv.WSReadJSON(t, conn, &msg)
require.NoError(t, err)
require.Equal(t, "next", msg.Type)
require.Equal(t, "1", msg.ID)

err = testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{ID: "1", Type: "complete"})
require.NoError(t, err)

var complete testenv.WebSocketMessage
err = conn.SetReadDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
err = testenv.WSReadJSON(t, conn, &complete)
require.NoError(t, err)
require.Equal(t, "complete", complete.Type)
require.Equal(t, "1", complete.ID)
}()

done.Wait()
xEnv.WaitForSubscriptionCount(0, time.Second*5)
})
})

// Two subscriptions with different employeeIDs resolve to different NATS subjects and
// must therefore receive independent triggers.
t.Run("different subjects use separate triggers", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithEdfsNatsJSONTemplate,
EnableNats: true,
}, func(t *testing.T, xEnv *testenv.Environment) {
var done sync.WaitGroup
done.Add(2)

go func() {
xEnv.WaitForSubscriptionCount(2, time.Second*10)
xEnv.WaitForTriggerCount(2, time.Second*10)
xEnv.NATSPublishUntilReceived(xEnv.NatsConnectionDefault, xEnv.GetPubSubName("employeeUpdated.3"), []byte(`{"id":3,"__typename":"Employee"}`), 1, time.Second*10)
xEnv.NATSPublishUntilReceived(xEnv.NatsConnectionDefault, xEnv.GetPubSubName("employeeUpdated.4"), []byte(`{"id":4,"__typename":"Employee"}`), 1, time.Second*10)
}()

// Subscription 1: employeeID 3 → subject "employeeUpdated.3"
go func() {
defer done.Done()
conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, nil)
defer conn.Close()

err := testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{
ID: "1",
Type: "subscribe",
Payload: []byte(`{"query":"subscription { employeeUpdated(employeeID: 3) { id } }"}`),
})
require.NoError(t, err)

var msg testenv.WebSocketMessage
err = testenv.WSReadJSON(t, conn, &msg)
require.NoError(t, err)
require.Equal(t, "next", msg.Type)
require.Equal(t, "1", msg.ID)

err = testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{ID: "1", Type: "complete"})
require.NoError(t, err)

var complete testenv.WebSocketMessage
err = conn.SetReadDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
err = testenv.WSReadJSON(t, conn, &complete)
require.NoError(t, err)
require.Equal(t, "complete", complete.Type)
require.Equal(t, "1", complete.ID)
}()

// Subscription 2: employeeID 4 → subject "employeeUpdated.4"
go func() {
defer done.Done()
conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, nil)
defer conn.Close()

err := testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{
ID: "1",
Type: "subscribe",
Payload: []byte(`{"query":"subscription { employeeUpdated(employeeID: 4) { id } }"}`),
})
require.NoError(t, err)

var msg testenv.WebSocketMessage
err = testenv.WSReadJSON(t, conn, &msg)
require.NoError(t, err)
require.Equal(t, "next", msg.Type)
require.Equal(t, "1", msg.ID)

err = testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{ID: "1", Type: "complete"})
require.NoError(t, err)

var complete testenv.WebSocketMessage
err = conn.SetReadDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
err = testenv.WSReadJSON(t, conn, &complete)
require.NoError(t, err)
require.Equal(t, "complete", complete.Type)
require.Equal(t, "1", complete.ID)
}()

done.Wait()
xEnv.WaitForSubscriptionCount(0, time.Second*5)
})
})
}
4 changes: 2 additions & 2 deletions router-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ require (
github.com/wundergraph/cosmo/router v0.0.0-20260330183556-dc4388d100a4
github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e
github.com/wundergraph/cosmo/speedtrap v0.0.0-00010101000000-000000000000
github.com/wundergraph/graphql-go-tools/v2 v2.4.5
github.com/wundergraph/graphql-go-tools/v2 v2.4.6-0.20260611153020-46b36e826347
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
Expand Down Expand Up @@ -207,5 +207,5 @@ replace (
github.com/wundergraph/cosmo/router => ../router
github.com/wundergraph/cosmo/router-plugin => ../router-plugin
github.com/wundergraph/cosmo/speedtrap => ../speedtrap
// github.com/wundergraph/graphql-go-tools/v2 => ../../graphql-go-tools/v2
// github.com/wundergraph/graphql-go-tools/v2 => ../../../graphql-go-tools.worktrees/dominik-eng-9714-fix-inefficient-trigger-id-generation-in-engine/v2
)
4 changes: 2 additions & 2 deletions router-tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8=
github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw=
github.com/wundergraph/graphql-go-tools/v2 v2.4.5 h1:hJsNYupt3i2zWD9X00ytcEkgU6Cr+//21oIKMDbydmE=
github.com/wundergraph/graphql-go-tools/v2 v2.4.5/go.mod h1:rGG9m74sUyucfvSZ83Mjuq/6qRJetl1CVP872f/dCok=
github.com/wundergraph/graphql-go-tools/v2 v2.4.6-0.20260611153020-46b36e826347 h1:a5vWKl6UKblMS3r+7ut0QMKh77HzIdMG/q//qVcXy+s=
github.com/wundergraph/graphql-go-tools/v2 v2.4.6-0.20260611153020-46b36e826347/go.mod h1:rGG9m74sUyucfvSZ83Mjuq/6qRJetl1CVP872f/dCok=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand Down
9 changes: 9 additions & 0 deletions router-tests/testenv/testenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -2915,6 +2915,15 @@ func (e *Environment) WaitForTriggerCount(desiredCount uint64, timeout time.Dura
}
}

// RequireTriggerCount asserts that the current trigger count equals desiredCount exactly.
// Call this after WaitForSubscriptionCount has confirmed all subscriptions are active;
// trigger creation is synchronous with subscription registration so no additional waiting is needed.
func (e *Environment) RequireTriggerCount(desiredCount uint64) {
e.t.Helper()
report := e.syncReporter().GetReport()
require.Equal(e.t, desiredCount, report.Triggers, "expected exactly %d triggers, got %d", desiredCount, report.Triggers)
}

// NATSPublishUntilMinMessagesSent publishes a NATS message repeatedly until the
// total MessagesSent count reaches minCount. This handles fan-out scenarios where
// multiple subscriptions must all receive the message: if some consumers aren't
Expand Down
4 changes: 2 additions & 2 deletions router/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/twmb/franz-go v1.16.1
github.com/wundergraph/graphql-go-tools/v2 v2.4.5
github.com/wundergraph/graphql-go-tools/v2 v2.4.6-0.20260611153020-46b36e826347
// Do not upgrade, it renames attributes we rely on
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/contrib/propagators/b3 v1.43.0
Expand Down Expand Up @@ -181,4 +181,4 @@ require (
// Remember you can use Go workspaces to avoid using replace directives in multiple go.mod files
// Use what is best for your personal workflow. See CONTRIBUTING.md for more information

// replace github.com/wundergraph/graphql-go-tools/v2 => ../../graphql-go-tools/v2
// replace github.com/wundergraph/graphql-go-tools/v2 => ../../../graphql-go-tools.worktrees/dominik-eng-9714-fix-inefficient-trigger-id-generation-in-engine/v2
4 changes: 2 additions & 2 deletions router/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8=
github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw=
github.com/wundergraph/graphql-go-tools/v2 v2.4.5 h1:hJsNYupt3i2zWD9X00ytcEkgU6Cr+//21oIKMDbydmE=
github.com/wundergraph/graphql-go-tools/v2 v2.4.5/go.mod h1:rGG9m74sUyucfvSZ83Mjuq/6qRJetl1CVP872f/dCok=
github.com/wundergraph/graphql-go-tools/v2 v2.4.6-0.20260611153020-46b36e826347 h1:a5vWKl6UKblMS3r+7ut0QMKh77HzIdMG/q//qVcXy+s=
github.com/wundergraph/graphql-go-tools/v2 v2.4.6-0.20260611153020-46b36e826347/go.mod h1:rGG9m74sUyucfvSZ83Mjuq/6qRJetl1CVP872f/dCok=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
Expand Down
27 changes: 16 additions & 11 deletions router/pkg/pubsub/datasource/subscription_datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ import (
"go.uber.org/zap/zapcore"
)

type uniqueRequestIdFn func(ctx *resolve.Context, input []byte, xxh *xxhash.Digest) error
type triggerHashInputFn func(ctx *resolve.Context, input []byte, xxh *xxhash.Digest) error

type EventBuilderFn func(data []byte) MutableStreamEvent

// PubSubSubscriptionDataSource is a data source for handling subscriptions using a Pub/Sub mechanism.
// It implements the SubscriptionDataSource interface and HookableSubscriptionDataSource
type PubSubSubscriptionDataSource[C SubscriptionEventConfiguration] struct {
pubSub Adapter
uniqueRequestID uniqueRequestIdFn
hooks Hooks
logger *zap.Logger
eventBuilder EventBuilderFn
pubSub Adapter
triggerHashInput triggerHashInputFn
hooks Hooks
logger *zap.Logger
eventBuilder EventBuilderFn
}

func (s *PubSubSubscriptionDataSource[C]) SubscriptionEventConfiguration(input []byte) (SubscriptionEventConfiguration, error) {
Expand Down Expand Up @@ -88,17 +88,22 @@ func (s *PubSubSubscriptionDataSource[C]) SetHooks(hooks Hooks) {
s.hooks = hooks
}

func (s *PubSubSubscriptionDataSource[C]) ProvideTriggerHashInput(ctx *resolve.Context, input []byte, xxh *xxhash.Digest) error {
return s.triggerHashInput(ctx, input, xxh)
Comment thread
endigma marked this conversation as resolved.
Outdated
}

var _ SubscriptionDataSource = (*PubSubSubscriptionDataSource[SubscriptionEventConfiguration])(nil)
var _ resolve.HookableSubscriptionDataSource = (*PubSubSubscriptionDataSource[SubscriptionEventConfiguration])(nil)
var _ resolve.SubscriptionTriggerHasher = (*PubSubSubscriptionDataSource[SubscriptionEventConfiguration])(nil)

func NewPubSubSubscriptionDataSource[C SubscriptionEventConfiguration](pubSub Adapter, uniqueRequestIdFn uniqueRequestIdFn, logger *zap.Logger, eventBuilder EventBuilderFn) *PubSubSubscriptionDataSource[C] {
func NewPubSubSubscriptionDataSource[C SubscriptionEventConfiguration](pubSub Adapter, triggerHashInputFn triggerHashInputFn, logger *zap.Logger, eventBuilder EventBuilderFn) *PubSubSubscriptionDataSource[C] {
if logger == nil {
logger = zap.NewNop()
}
return &PubSubSubscriptionDataSource[C]{
pubSub: pubSub,
uniqueRequestID: uniqueRequestIdFn,
logger: logger,
eventBuilder: eventBuilder,
pubSub: pubSub,
triggerHashInput: triggerHashInputFn,
logger: logger,
eventBuilder: eventBuilder,
}
}
Loading
Loading