Inactivity timeout in Channels

I’m using Channels for the purpose of detecting access to certain kinds of shared resources in my app, in order to have a “locking” system against concurrent edits.

When a user navigates to the editor page for a certain resource, its frontend app connects to a consumer and subscribes to that resource. The consumer then updates a field called locked_by in the resource which indicates that user is currently (the only one) editing it. If an edit request comes in and the user isn’t the one indicated by the value of that lock, the request is rejected.

When the consumer detects the connection has been closed, the field is reset in order to allow other users to access the resource.

The problem with this approach is I have noticed that sometimes (for reasons I haven’t been able to fully identify), even if a client closes the connection, the consumer doesn’t seem to register the event. Or, more commonly, a user might simply leave the page open and forget about it.

For these reasons, I’d like to implement a timeout mechanism: the frontend would periodically send a “heartbeat” to the WS consumer upon detecting user activity on the page. If the consumer doesn’t receive any heartbeats for long enough, it triggers a reset in the locked_by field, giving a chance to other users to access the resource.

How would you go about implementing such a timeout in channels? I’m thinking something that would have to do with asyncio event loop, but I’m not 100% sure. Any input is appreciated!

I use the django-channels-presence package to create and manage “inspectable” groups.

As part of that, I have a task that runs Room.objects.prune_presences every 30 seconds in the server.

On the client, I run the following function to send a “heartbeat” signal every 10 seconds.

setInterval(function() {
    ws = $("#websocket-div")[0]["htmx-internal-data"].webSocket;
    if (ws.readyState == 1) {
        ws.send(JSON.stringify({"signal": "heartbeat"}));
    }
}, 10000);

(Note: Yes, this is done using the htmx websocket extension. My entire client-side websocket code is handled by htmx.)

See setInterval() - Web APIs | MDN

1 Like

Thank you, the package you mentioned is interesting. I see it relies on celerybeat though, which I’m not very familiar with. I could learn it for sure, but if I was trying to go with a solution that requires channels only, do you think this would work? It’s not real code I’ve tested so it might be incorrect, but it’s just to get the idea across:

class MyConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # establish connection and lock resource
        # ...

        self.task = asyncio.get_event_loop().create_task(self.countdown_task)


   async def countdown_task(self):
       await asyncio.sleep(30) # max idle time allowed
       unlock_resource_and_kick_user_out()
  
   async def receive(self, data):
      self.task.cancel()
      self.task = asyncio.get_event_loop().create_task(self.countdown_task)

      # process data normally 

Would there be any downsides to this? Do I have a guarantee that the consumer will stay alive for long enough after the user has dropped connection for this to happen?

Actually, it doesn’t rely upon Celery beats, it simply mentions that you can call the prune functions as a celery task using beats.

I don’t use beats for this, I’ve implemented the prune process myself in a manner somewhat similar to what you describe.

I’d have concerns about tieing anything like this to a consumer instance. I want the clean-up to occur outside that context, so if the consumer just “goes away” and the browser reconnects for whatever reason, I don’t lose track of that.

Maybe it’s just an abundance of caution on my part, but when I started working with websockets, they weren’t exactly what I would call stable or reliable. They would drop for whatever reason. The browser would reconnect, and create a new instance of the consumer. It’s a new consumer, on a new channel name, but still the same “user”. In those situations, I don’t want to lose sight of the fact that it’s still the same person and browser connected.

That’s why I went with an “external to the consumer” approach for groups.

Hey @KenWhitesell — Is that package Channels 3 compatible already do you know? Since it doesn’t provide ASGI callables I can’t see why not but… :thinking: Thanks

@carltongibson - I use a “self-patched” forked-version of it with Channels 3. (I get the impression that it’s an abandoned project, and from the issues list I don’t believe I’m the only one with that impression.)

Now I’m curious - I must be not-seeing some option or possibility here - why would / should this package provide an ASGI callable?

It shouldn’t :slightly_smiling_face: — I’m just wondering if it’s compatible.

Would you be prepared to share a compare view of your fork?

Well, that kinda disqualifies it as an option for me, since I use channels 3 haha. I’ll probably give the solution I described above a shot and see.

This is the issue being resolved: Incompatible with Django 4.0 due to use of provides_args argument for Signal · Issue #23 · mitmedialab/django-channels-presence · GitHub

(I mis-remembered - it’s not a Channels 3 issue, it’s a Django 4 issue.)

The patch I implemented is to delete this line: django-channels-presence/signals.py at bcc9f71ca2162d8d8539466ad9787715d43c0faf · mitmedialab/django-channels-presence · GitHub

Beyond that, it works as I need it to work. YMMV.

1 Like

No - that’s what I tried to point out in my reply to you above. From my experience - especially if you have people using this on mobile or “stability-challenged wifi”, the websocket connection may at times just go away. You may run into situations where they lose the lock when they shouldn’t, or be unable to reacquire their lock when they reconnect.

1 Like

Super. Thanks for confirming!

Yeah, you’re right. So if I understand the issue correctly, the issue here would be solved by employing some sort of durable storage to store usage/activity data, which is what the project you linked it appeared to me was doing.

Then, some sort of periodic task/job would monitor that data and take the appropriate actions.

If I wanted to build a solution myself based on this, I imagine I could either use my relational db or redis and (let’s assume the first one) create a model with a DateTimeField that gets periodically updated as the user takes action inside the page.

Did I get the idea right?

You appear to understand it perfectly.

Yes, that project creates two tables, Room and Presence.

Correct. You can use beats, or schedule your own periodic task using a background worker to perform the “maintenance and cleanup”.

Correct. In django-channels-presence, it’s the “last_seen” field in the Presence model. Code you add to your consumer keeps that field up-to-date. The prune_presences method in the Room model performs the clean-up - that’s the method to be called periodically.

If I ever get to the point where the database load is such that these updates create a problem, I could see moving it to Redis. But I’m a long way away from that being an issue for me.

Spot on!

I will add, that even if you choose to do it entirely yourself, you might get some good ideas by reviewing the code in that project. (The beauty of Open Source!)

1 Like