graphql-ruby icon indicating copy to clipboard operation
graphql-ruby copied to clipboard

Unsubscribed callback method on Subscription

Open gastonmorixe opened this issue 4 years ago • 12 comments

Hi,

I'd like to know if there's a way to have a callback method like def subscribe for subscriptions but for unsubscribed.

We have a lot of context information in each subscription that we need to use when the subscription is unsubscribed and we only have it in the subscription not when the channel has been unsubscribed.

Is it possible to have a workaround this right now or possibly add this functionality?

Thank you

gastonmorixe avatar Nov 19 '21 06:11 gastonmorixe

Yes, it's definitely possible. There are currently delete_subscription(sub_id) hooks which are used for cleaning up whatever persistence backend the subscription system uses:

https://github.com/rmosolgo/graphql-ruby/blob/a242ffabf395369700b9a26c4b79f10a4234b4f4/lib/graphql/subscriptions/action_cable_subscriptions.rb#L215

We could leverage that same hook to call some kind of hook for the subscription. I'll take a look and follow up here when I have a PR ready.

rmosolgo avatar Nov 19 '21 17:11 rmosolgo

@rmosolgo cool, I tried yesterday and failed. We are using graphql-anycable, this is the data I was able to get from it but I couldn't figured out how to get the right variables (not arguments) and scope to trigger something before unsubscribing. Maybe it's not even this way.

aaa = RocketReferralsSchema.subscriptions.instance_eval {s::SUBSCRIPTION_PREFIX}#{subscription_id}",
  redis.smembers(GraphQL::Subscriptions::AnyCableSubscriptions::CHANNEL_PREFIX + channel_id).map do |subscription_id|
    redis.mapped_hmget(
      "#{GraphQL::Subscriptions::AnyCableSubscriptions::SUBSCRIPTION_PREFIX}#{subscription_id}",
      :query_string, :variables, :context, :operation_name
    )
  end
}
=> [
    [0] {
          :query_string => "subscription ConversationTyping($conversationId: ID!) {\n  conversationTyping(conversationId: $conversationId) {\n  ...
=> [
    [0] {
          :query_string => "subscription Conversation...",
             :variables => "{\"conversationId\":\"MBLNyshSXDUkAV0/zRnXB8o3wzipTrBeDsPn6Q5K1/gyag\"}",
               :context => "{\"current_account_id\":53,\"current_user_id\":16896,\"__sym_keys__\":[\"current_account_id\",\"current_user_id\"]}",
        :operation_name => "ConversationTyping"
    }
]

gastonmorixe avatar Nov 19 '21 18:11 gastonmorixe

trigger something

Just curious, do you mean that you want to send more data to the client from this point? (It could be tricky, but it might be possible...)

rmosolgo avatar Nov 19 '21 22:11 rmosolgo

no, my bad, I was just trying to find a way to add an unsubscribed method to the subscription.

gastonmorixe avatar Nov 20 '21 15:11 gastonmorixe

Hi @rmosolgo, did you have any more thoughts on implementing a callback within delete_subscription? I'd like to do some cleanup when someone closes a subscription.

lostboy avatar Mar 07 '22 14:03 lostboy

Hey, sorry for the silence on this. I did look into it a while ago, but I found that there's no easy way to work it into the existing implementation. Instead, it really makes most sense to add it on a per-implementation basis. (That is, ActionCableSubscriptions, PusherSubscriptions, etc, should each call .delete_subscription in their own code.) For example, these could be extended to read subscription data, call delete_subscription, then clean up their storage:

https://github.com/rmosolgo/graphql-ruby/blob/a242ffabf395369700b9a26c4b79f10a4234b4f4/lib/graphql/subscriptions/action_cable_subscriptions.rb#L214-L216

And probably here, if I had to guess: https://github.com/anycable/graphql-anycable/blob/495e5505830fdf1ef5566a270ab9ee1759be21ab/lib/graphql/subscriptions/anycable_subscriptions.rb#L190

rmosolgo avatar Mar 07 '22 19:03 rmosolgo

(For the record I also received an email requesting a feature like this for Ably subscriptions.)

rmosolgo avatar Dec 29 '22 15:12 rmosolgo

It's really important for us to find a way. We need to clean up data after the subscription is unsubscribed. We are relying in background jobs, pings, and workarounds just because we don't have access to an unsubscribe method / callback. It's really a blocker for a perfect UX and use of good practice on the ruby side. We use this in conjunction with anycable/graphql-anycable

gastonmorixe avatar Dec 29 '22 18:12 gastonmorixe

We're running into this issue as well with actioncable and would appreciate some help with this.

We have a similar delete_subscription(sid) call

  def unsubscribed
    @subscription_ids.each { |sid|
      MySchema.subscriptions.delete_subscription(sid)
    }
  end

but I'm unclear how to get back to the instantiated GraphQL::Schema::Subscription class that subscribe() gets called on when subscribing. read_subscription(sid) seems to be a different object and I'm not seeing a reference to the other class anywhere from there.

pzcfg avatar Apr 10 '23 19:04 pzcfg

Hey y'all!

Had a similar issue when setting up subscriptions in an application using the gem over ActionCable. Ended up solving it with an in-house implementation to register and execute the callbacks

In my case, the solution involves changing the GraphQL channel (from ActionCable) to include the following:

def receive(data)
  # ...
   
  # Note we need to send the `channel` key here
  Schema.execute(query, variables:, context: { channel: self })
     
  # ...
end

# Accessor for the registered callbacks
def on_unsubscribe
  @on_unsubscribe ||= []
end

# Setter for the registered callbacks
def on_unsubscribe=(callback)
  on_unsubscribe << callback
end

def unsubscribed
  # Delete all of the consumer's subscriptions from the GraphQL Schema
  @subscription_ids.each do |sid|
    Schema.subscriptions.delete_subscription(sid)
  end

  # GraphQL::Ruby does not support a callback per subscription, so we have to
  # manually call the callback for each subscription the consumer was listening for,
  # if they are defined
  on_unsubscribe.each(&:call)
end

And then, on the actual subscription file, we can append a callback to be executed when the subscription is closed:

def subscribe(id:)
  channel.on_unsubscribe = -> { unsubscribe(id:) }
end

def unsubscribe(id:)
  # Add the unsubscribe logic
end

Is probably not the most efficient or even simple solution to maintain, but should do the trick for most cases. Hope yet helps!

lucaspbordignon avatar Sep 18 '23 19:09 lucaspbordignon