Skip to content
Closed
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
38f8826
feat: add subscriptions hook
alepane21 Jul 18, 2025
824362b
chore: add tests of OnSubscriptionStartFn
alepane21 Jul 19, 2025
cdfa9bf
chore: remove duplicated test
alepane21 Jul 19, 2025
f8de362
chore: add test of error with Start method
alepane21 Jul 19, 2025
e500d1c
chore: document OnSubscriptionStartFn
alepane21 Jul 19, 2025
c970c07
Merge branch 'master' into ale/eng-7600-add-subscriptiononstarthandler
alepane21 Jul 19, 2025
2a6b5f0
chore: refactores the OnSubscriptionStart hook to be called from the …
alepane21 Jul 22, 2025
1e6ed5f
fix: behaviour when errors happend have been changed by mistake
alepane21 Jul 22, 2025
d776943
chore: change OnSubscriptionStart hook contract
alepane21 Jul 22, 2025
2f34327
chore: hooks now can decide if the subscription has to end
alepane21 Jul 22, 2025
dc89310
chore: events are written directly on the resolveCtx
alepane21 Jul 23, 2025
3cd5690
Merge branch 'master' into ale/eng-7600-add-subscriptiononstarthandler
alepane21 Jul 23, 2025
bfad223
chore: improve test
alepane21 Jul 23, 2025
91dbadd
chore: improve names to align with the ones in the ADR of the router
alepane21 Jul 23, 2025
207adf7
chore: execute hooks inside worker, so that we don't block the main l…
alepane21 Jul 23, 2025
3257c52
fix: if only one event was to be skipped, every event was skipped! re…
alepane21 Jul 23, 2025
ec79b06
chore: remove now useless handleTriggerUpdateSubscription
alepane21 Jul 23, 2025
e74d079
chore: send updates on subscription on time (there is still a data ra…
alepane21 Jul 24, 2025
41b92a0
chore: avoid workChan races
alepane21 Jul 24, 2025
7bb7431
chore: lint issue
alepane21 Jul 24, 2025
e0845be
Merge branch 'master' into ale/eng-7600-add-subscriptiononstarthandler
alepane21 Jul 29, 2025
3b61ef2
chore: rename SubscriptionOnStartFns to StartupHooks for clarity
alepane21 Jul 29, 2025
ae55c92
Merge remote-tracking branch 'origin/master' into ale/eng-7600-add-su…
alepane21 Jul 29, 2025
87cb3a8
Merge remote-tracking branch 'origin/ale/eng-7600-add-subscriptionons…
alepane21 Jul 29, 2025
ea8ab75
chore: wait for the initial hook execution before starting the dataso…
alepane21 Aug 5, 2025
2ed6134
fix: avoid locking worker goroutine if the trigger was already set up
alepane21 Aug 6, 2025
5fa5a76
chore: add test to verify Resolve beahviour with more than one subscr…
alepane21 Aug 6, 2025
8731ff2
chore: remove return close option
alepane21 Aug 19, 2025
6793f09
chore: HookableSubscriptionDataSource should also be without close
alepane21 Aug 19, 2025
fdf829a
chore: fix initialHooksClose value
alepane21 Aug 19, 2025
7b569b6
chore: simplify hooks implementation
alepane21 Aug 20, 2025
4a94540
chore: remove pinned subscription
alepane21 Aug 20, 2025
a673838
chore: remove unused code, fix naming
alepane21 Aug 20, 2025
46d97b2
chore: fix test
alepane21 Aug 20, 2025
447c103
chore: avoid data source start if the subscription should close
alepane21 Aug 20, 2025
7bb99a5
chore: improve resolve structure
alepane21 Aug 21, 2025
f8c9d06
chore: type
alepane21 Aug 21, 2025
baec1da
chore: readd pinned updater to sub
alepane21 Aug 21, 2025
2100f14
chore: simplify events flow
alepane21 Aug 22, 2025
311b021
chore: fix compile issues
StarpTech Aug 23, 2025
e0af0a0
chore: fix tests
alepane21 Aug 25, 2025
f4eba63
chore: remove pubsub datasource implementations and related tests, it…
alepane21 Aug 25, 2025
0efce3c
test: add new test for handling multiple subscriptions with the same …
alepane21 Aug 25, 2025
6234791
chore: execute startup hooks inside worker go routine
alepane21 Sep 3, 2025
8df1b67
chore: remove parameter not used anymore
alepane21 Sep 3, 2025
a0024f0
chore: add subscription to registry after startup hooks execution
alepane21 Sep 9, 2025
ddc652f
Merge branch 'topic/streams-v1' into ale/eng-7600-add-subscriptionons…
dkorittki Sep 30, 2025
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
9 changes: 9 additions & 0 deletions v2/pkg/engine/datasource/graphql_datasource/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/wundergraph/graphql-go-tools/v2/pkg/astparser"
"github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform"
grpcdatasource "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/grpc_datasource"
"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve"
"github.com/wundergraph/graphql-go-tools/v2/pkg/federation"
"github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport"
)
Expand Down Expand Up @@ -103,6 +104,13 @@ type SingleTypeField struct {
FieldName string
}

// SubscriptionOnStartFn defines a hook function that is called when a subscription starts.
// It receives the resolve context and the input of the subscription.
// The function can return a boolean indicating if the subscription should be closed, and an error.
// The error is propagated to the client.
// If close is true, the subscription is closed.
type SubscriptionOnStartFn func(ctx *resolve.Context, input []byte) (close bool, err error)

type SubscriptionConfiguration struct {
URL string
Header http.Header
Expand All @@ -119,6 +127,7 @@ type SubscriptionConfiguration struct {
// these headers by itself.
ForwardedClientHeaderRegularExpressions []RegularExpression
WsSubProtocol string
SubscriptionOnStartFns []SubscriptionOnStartFn
Comment thread
alepane21 marked this conversation as resolved.
Outdated
}

type FetchConfiguration struct {
Expand Down
16 changes: 14 additions & 2 deletions v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,8 @@ func (p *Planner[T]) ConfigureSubscription() plan.SubscriptionConfiguration {
return plan.SubscriptionConfiguration{
Input: string(input),
DataSource: &SubscriptionSource{
client: p.subscriptionClient,
client: p.subscriptionClient,
subscriptionOnStartFns: p.config.subscription.SubscriptionOnStartFns,
},
Variables: p.variables,
PostProcessing: DefaultPostProcessingConfiguration,
Expand Down Expand Up @@ -1939,7 +1940,8 @@ type RegularExpression struct {
}

type SubscriptionSource struct {
client GraphQLSubscriptionClient
client GraphQLSubscriptionClient
subscriptionOnStartFns []SubscriptionOnStartFn
}

func (s *SubscriptionSource) AsyncStart(ctx *resolve.Context, id uint64, input []byte, updater resolve.SubscriptionUpdater) error {
Expand Down Expand Up @@ -1989,3 +1991,13 @@ func (s *SubscriptionSource) UniqueRequestID(ctx *resolve.Context, input []byte,
}
return s.client.UniqueRequestID(ctx, options, xxh)
}

func (s *SubscriptionSource) SubscriptionOnStart(ctx *resolve.Context, input []byte) (close bool, err error) {
Comment thread
alepane21 marked this conversation as resolved.
Outdated
for _, fn := range s.subscriptionOnStartFns {
close, err = fn(ctx, input)
Comment thread
alepane21 marked this conversation as resolved.
Outdated
if err != nil || close {
return close, err
}
}
return
}
Original file line number Diff line number Diff line change
Expand Up @@ -4019,7 +4019,7 @@ func TestGraphQLDataSource(t *testing.T) {
Trigger: resolve.GraphQLSubscriptionTrigger{
Input: []byte(`{"url":"wss://swapi.com/graphql","body":{"query":"subscription{remainingJedis}"}}`),
Source: &SubscriptionSource{
NewGraphQLSubscriptionClient(http.DefaultClient, http.DefaultClient, ctx),
client: NewGraphQLSubscriptionClient(http.DefaultClient, http.DefaultClient, ctx),
},
PostProcessing: DefaultPostProcessingConfiguration,
},
Expand Down Expand Up @@ -8902,6 +8902,59 @@ func TestSanitizeKey(t *testing.T) {
}
}

func TestSubscriptionSource_SubscriptionOnStart(t *testing.T) {

t.Run("SubscriptionOnStart calls subscriptionOnStartFns", func(t *testing.T) {
ctx := resolve.NewContext(context.Background())
defer ctx.Context().Done()

type fnData struct {
ctx *resolve.Context
input []byte
}

startFnCalled := make(chan fnData, 1)
subscriptionSource := SubscriptionSource{
subscriptionOnStartFns: []SubscriptionOnStartFn{
func(ctx *resolve.Context, input []byte) (bool, error) {
startFnCalled <- fnData{ctx, input}
return false, nil
},
},
}

close, err := subscriptionSource.SubscriptionOnStart(ctx, []byte(`{"variables": {}, "extensions": {}, "operationName": "LiveMessages", "query": "subscription LiveMessages { messageAdded(roomName: \"#test\") { text createdBy } }"}`))
require.NoError(t, err)
assert.False(t, close)
var called fnData
select {
case called = <-startFnCalled:
case <-time.After(1 * time.Second):
t.Fatal("SubscriptionOnStartFn was not called")
}
assert.Equal(t, ctx, called.ctx)
assert.Equal(t, []byte(`{"variables": {}, "extensions": {}, "operationName": "LiveMessages", "query": "subscription LiveMessages { messageAdded(roomName: \"#test\") { text createdBy } }"}`), called.input)
})

t.Run("SubscriptionOnStart calls subscriptionOnStartFns and returns error if one of the functions returns an error", func(t *testing.T) {
ctx := resolve.NewContext(context.Background())
defer ctx.Context().Done()

subscriptionSource := SubscriptionSource{
subscriptionOnStartFns: []SubscriptionOnStartFn{
func(ctx *resolve.Context, input []byte) (bool, error) {
return false, errors.New("test error")
},
},
}

close, err := subscriptionSource.SubscriptionOnStart(ctx, []byte(`{"variables": {}, "extensions": {}, "operationName": "LiveMessages", "query": "subscription LiveMessages { messageAdded(roomName: \"#test\") { text createdBy } }"}`))
assert.False(t, close)
require.Error(t, err)
assert.ErrorContains(t, err, "test error")
})
}

const interfaceSelectionSchema = `

scalar String
Expand Down
22 changes: 22 additions & 0 deletions v2/pkg/engine/resolve/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"io"
"net/http"
"sync"
"time"

"github.com/wundergraph/astjson"
Expand All @@ -32,6 +33,9 @@ type Context struct {
fieldRenderer FieldValueRenderer

subgraphErrors error

emitEventRWMutex *sync.RWMutex
emitEventFn func(data []byte) bool
}

type ExecutionOptions struct {
Expand Down Expand Up @@ -104,6 +108,22 @@ func (c *Context) SetEngineLoaderHooks(hooks LoaderHooks) {
c.LoaderHooks = hooks
}

// TryEmitSubscriptionUpdate emits a subscription update to the client
// Returns true if the update was emitted.
func (c *Context) TryEmitSubscriptionUpdate(data []byte) bool {
if c.emitEventRWMutex == nil {
return false
}
c.emitEventRWMutex.RLock()
emitEventFn := c.emitEventFn
c.emitEventRWMutex.RUnlock()
if emitEventFn == nil {
return false
}

return emitEventFn(data)
}

type RateLimitOptions struct {
// Enable switches rate limiting on or off
Enable bool
Expand Down Expand Up @@ -205,6 +225,8 @@ func (c *Context) Free() {
c.subgraphErrors = nil
c.authorizer = nil
c.LoaderHooks = nil
c.emitEventRWMutex = nil
c.emitEventFn = nil
}

type traceStartKey struct{}
Expand Down
13 changes: 13 additions & 0 deletions v2/pkg/engine/resolve/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,16 @@ type AsyncSubscriptionDataSource interface {
AsyncStop(id uint64)
UniqueRequestID(ctx *Context, input []byte, xxh *xxhash.Digest) (err error)
}

// HookableSubscriptionDataSource is a hookable interface for subscription data sources.
// It is used to call a function when a subscription is started.
// This is useful for data sources that need to do some work when a subscription is started,
// e.g. to establish a connection to the data source or to emit updates to the client.
// The function is called with the context and the input of the subscription.
// The function is called before the subscription is started and can be used to emit updates to the client.
type HookableSubscriptionDataSource interface {
// SubscriptionOnStart is called when a new subscription is created
// If close is true, the subscription is closed.
// If an error is returned, the error is propagated to the client.
SubscriptionOnStart(ctx *Context, input []byte) (close bool, err error)
}
149 changes: 115 additions & 34 deletions v2/pkg/engine/resolve/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"sync"
"time"

"github.com/buger/jsonparser"
Expand Down Expand Up @@ -488,7 +489,7 @@ func (r *Resolver) handleEvent(event subscriptionEvent) {
case subscriptionEventKindRemoveClient:
r.handleRemoveClient(event.id.ConnectionID)
case subscriptionEventKindTriggerUpdate:
r.handleTriggerUpdate(event.triggerID, event.data)
r.handleTriggerUpdate(event.triggerID, event.data, event.pinnedSubscription)
case subscriptionEventKindTriggerComplete:
r.handleTriggerComplete(event.triggerID)
case subscriptionEventKindTriggerInitialized:
Expand Down Expand Up @@ -569,6 +570,56 @@ func (r *Resolver) handleTriggerComplete(triggerID uint64) {
r.completeTrigger(triggerID)
}

func (r *Resolver) handleAddSubscriptionInitialHook(triggerID uint64, add *addSubscription, initialHookCtx *Context, s *sub) {
// Set the emitEventFn to synchronously emit the event to the subscription
if initialHookCtx.emitEventRWMutex == nil {
initialHookCtx.emitEventRWMutex = &sync.RWMutex{}
}
initialHookCtx.emitEventRWMutex.Lock()
initialHookCtx.emitEventFn = func(data []byte) bool {
if r.shouldSkipEvent(initialHookCtx, s, data) {
return true
}
r.executeSubscriptionUpdate(initialHookCtx, s, data)
return true
}
initialHookCtx.emitEventRWMutex.Unlock()

// After the initial hook is executed, there could be other work items in the workChan that need to be processed.
// But if the hook starts a go routine, it could emit events while other work items are being processed.
// Also the workChan could be closed while the go routine is running.
// We have to avoid concurrent executeSubscriptionUpdate to the same subscription.
// To avoid this, after the initial hook is executed, we add the work item to the events channel with a pinned subscription.
defer func() {
initialHookCtx.emitEventRWMutex.Lock()
initialHookCtx.emitEventFn = func(data []byte) bool {
r.events <- subscriptionEvent{
triggerID: triggerID,
kind: subscriptionEventKindTriggerUpdate,
data: data,
pinnedSubscription: s,
}
return true
}
initialHookCtx.emitEventRWMutex.Unlock()
}()

hook, ok := add.resolve.Trigger.Source.(HookableSubscriptionDataSource)
if !ok {
return
}

close, errStartHook := hook.SubscriptionOnStart(initialHookCtx, add.input)
if errStartHook != nil {
r.asyncErrorWriter.WriteError(initialHookCtx, errStartHook, add.resolve.Response, add.writer)
}
if close {
r.closeTrigger(triggerID, SubscriptionCloseKindNormal)
return
Comment thread
alepane21 marked this conversation as resolved.
Outdated
}

}

func (r *Resolver) handleAddSubscription(triggerID uint64, add *addSubscription) {
var (
err error
Expand All @@ -590,6 +641,20 @@ func (r *Resolver) handleAddSubscription(triggerID uint64, add *addSubscription)
s.heartbeat = true
}

// Clone the context to avoid modifying the original context.
// This is important because the original context is changed in the parent thread
// and could cause data races.
initialHookCtx := add.ctx.clone(add.ctx.ctx)

// Send handleAddSubscriptionInitialHook as the first work sent to the worker to process the hooks
Comment thread
alepane21 marked this conversation as resolved.
Outdated
// before getting updates from the subscription. This operation is never blocking because workChan
// is buffered and this is the first work item, before anyone is allowed to send other work items.
// This is important because the hooks need to be executed before the other updates are processed.
s.workChan <- workItem{
fn: func() { r.handleAddSubscriptionInitialHook(triggerID, add, initialHookCtx, s) },
final: false,
}

// Start the dedicated worker goroutine where the subscription updates are processed
// and writes are written to the client in a single threaded manner
go s.startWorker()
Expand Down Expand Up @@ -757,7 +822,42 @@ func (r *Resolver) handleRemoveClient(id int64) {
}
}

func (r *Resolver) handleTriggerUpdate(id uint64, data []byte) {
func (r *Resolver) shouldSkipEvent(ctx *Context, sub *sub, data []byte) bool {
if err := ctx.ctx.Err(); err != nil {
return true // no need to schedule an event update when the client already disconnected
}
skip, err := sub.resolve.Filter.SkipEvent(ctx, data, r.triggerUpdateBuf)
if err != nil {
r.asyncErrorWriter.WriteError(ctx, err, sub.resolve.Response, sub.writer)
return true
}
if skip {
return true
}
return false
Comment thread
alepane21 marked this conversation as resolved.
Outdated
}

func (r *Resolver) handleTriggerUpdateQueueWorkItem(c *Context, s *sub, data []byte) {
if r.shouldSkipEvent(c, s, data) {
return
}

fn := func() {
r.executeSubscriptionUpdate(c, s, data)
}

select {
case <-r.ctx.Done():
// Skip sending all events if the resolver is shutting down
return
case <-c.ctx.Done():
// Skip sending the event if the client disconnected
case s.workChan <- workItem{fn, false}:
// Send the event to the subscription worker
}
}
Comment thread
alepane21 marked this conversation as resolved.
Outdated

func (r *Resolver) handleTriggerUpdate(id uint64, data []byte, pinnedSubscription *sub) {
trig, ok := r.triggers[id]
if !ok {
return
Expand All @@ -766,33 +866,13 @@ func (r *Resolver) handleTriggerUpdate(id uint64, data []byte) {
fmt.Printf("resolver:trigger:update:%d\n", id)
}

for c, s := range trig.subscriptions {
Comment thread
alepane21 marked this conversation as resolved.
c, s := c, s
if err := c.ctx.Err(); err != nil {
continue // no need to schedule an event update when the client already disconnected
}
skip, err := s.resolve.Filter.SkipEvent(c, data, r.triggerUpdateBuf)
if err != nil {
r.asyncErrorWriter.WriteError(c, err, s.resolve.Response, s.writer)
continue
}
if skip {
continue
}

fn := func() {
r.executeSubscriptionUpdate(c, s, data)
}
if pinnedSubscription != nil {
r.handleTriggerUpdateQueueWorkItem(pinnedSubscription.ctx, pinnedSubscription, data)
return
}

select {
case <-r.ctx.Done():
// Skip sending all events if the resolver is shutting down
return
case <-c.ctx.Done():
// Skip sending the event if the client disconnected
case s.workChan <- workItem{fn, false}:
// Send the event to the subscription worker
}
for c, s := range trig.subscriptions {
r.handleTriggerUpdateQueueWorkItem(c, s, data)
}
}

Expand Down Expand Up @@ -1231,12 +1311,13 @@ func (s *subscriptionUpdater) Close(kind SubscriptionCloseKind) {
}

type subscriptionEvent struct {
triggerID uint64
id SubscriptionIdentifier
kind subscriptionEventKind
data []byte
addSubscription *addSubscription
closeKind SubscriptionCloseKind
triggerID uint64
id SubscriptionIdentifier
kind subscriptionEventKind
data []byte
addSubscription *addSubscription
pinnedSubscription *sub
closeKind SubscriptionCloseKind
}

type addSubscription struct {
Expand Down
Loading
Loading