graphql-subscriptions: iterable.return() is not sufficient to clean up subscriptions
Hey there đź‘‹ This is perhaps a bug report and a bit of a question about how ya;'ll have implemented this.
We’ve been working on our own semi-internal implementation of subscriptions for graphql and come across an issue handling the pub-sub case, one that seems like ya’ll suffer from as well (unless i’m missing something)
The crux of the issue is that iterable.return()
doesn’t get called when an iterable hasn’t started. which means once you’ve used language level constructs to map or filter an iterable it’ll never clean up unless it’s already had a value pulled
Following will run in Chrome if you paste it into a console:
let f = (()=> {
let i = 0
return {
next() {
return Promise.resolve({ value: i++, done: i >= 4 })
},
return() {
console.log('return!')
return Promise.resolve({ value: undefined, done: true });
},
throw(error) {
return Promise.reject(error);
},
[Symbol.asyncIterator]() {
return this
}
}
})()
async function* addOne() {
for await (const val of f) yield val + 1;
}
await addOne().return();
The practical problem is that if you disconnect or unsubscribe from a subscription before it pushes a value, the underlying pup-sub will never clean itself up.
About this issue
- Original URL
- State: open
- Created 6 years ago
- Reactions: 3
- Comments: 39 (21 by maintainers)
@jquense Okay let me clarify, cause yeah this stuff is not obvious.
In
for await (let val of foo)
,foo
must be anAsyncIterable
, but not necessarilyAsyncIterator
. When the loop starts it callsfoo[Symbol.asyncIterator]
to create the actualAsyncIterator
, which can be a completely different object thanfoo
(and ought to be a different object in most use cases). The loop then repeatedly awaitsnext()
on the iterator until it is done, in which case it calls the iterator’sreturn()
method, or an error is thrown, in which case it calls the iterator’sthrow()
method.Here’s the consequence: if the
for await
loop never runs (like in your example) it never calls the[Symbol.asyncIterator]
method. Which means if you only perform listener registration when[Symbol.asyncIterator]()
runs, you won’t register listeners unless the loop is actually going to run (in which case you’re guaranteed to get areturn()
orthrow()
call in which you can unregister the listeners).So here’s a safe way to rewrite your original example:
Now if I call the generator but make its iterator
return()
before I ever callnext()
, it never registers the “listener”:However, the first time I call
next()
, it actually runs thefor await
statement, and the first thing that does is calliterable[Symbol.asyncIterator]()
, which is what actually adds the listener. And when I callreturn()
in the middle of thefor await
, it does pass through the return call, removing the listener:@TimSusa This is how we ended up handling things, BTW: https://github.com/4Catalyzer/graphql-subscription-server/pull/20. We still expose an async iterable, because they’re nice to work with for various operations, but we also thread through a separate “close” method that we use to clean things up.
@taion I understand you are frustrated and would like to see a sea change here, and I agree that we need to figure out a better solution for this, but please be technically honest:
iterator.return()
will work as long as people don’t use async generators. “Just not going to work” will mislead people into thinking it will not work in any case.I think it’s important to be clear about this because solving this issue by switching to observables will be a far-reaching change requiring coordination between many different parts of the ecosystem and change in everyone’s application code, not necessarily something I’m opposed to, but if it even happens it will take a lot of time. So until that day, it’s important that we spread the word that people should avoid using async generators for GraphQL subscriptions and try to clearly explain why.
From the other thread, this is much worse than previously understood given https://github.com/tc39/proposal-async-iteration/issues/126#issuecomment-403454433.
At the current point,
iterator.return()
is just not going to work for tearing down and cleaning up resources.It’s worth keeping in mind FWIW that the issue isn’t generator functions per se. It’s about adapting external event sources (something like an observable) to an async iterable.
Right now these options all seem pretty mediocre.
I don’t believe this is a problem for subscriptions Apollo because I don’t think it uses
for await
anywhere under the hood.Looking at
subscriptions-transport-ws
, I believe it calls anAsyncIterator
’sreturn
method here: https://github.com/apollographql/subscriptions-transport-ws/blob/master/src/server.ts#L211But keep in mind: no
AsyncIterable
s anyone is using for GraphQL subscriptions should do anything that necessitates cleanup until theirSymbol.asyncIterator
method is called. As long as they follow that contract then they will work safely infor await
statements without leaving any dangling resources.