Search code examples
jquerypythonajaxdjangotwisted

I'm trying to make a long-polling chat app using Twisted and jQuery (with Django). How do I pass queries back to the JS?


This is the first long-polling application that I have ever built, and second project with Twisted, so I would appreciate any feedback that anyone has about anything in my code at all, as I may be going about this entirely the wrong way.

I have been cobbling together various examples as I've gone along and it's almost working, but I can't seem to find a way to get the data back to the Javascript. I have a Django site running on Twisted, and it seems to work fine so I'm not going to include the Django bits unless someone thinks it's important, and the only thing the Django site does is host a chat. I had originally set it up using regular polling, but I've been asked to change it to long-polling and I'm almost there (I hope).

Here's the HTML/JS (long.html):

<div class="chat-messages" style="width:300px;height:400px;border:1px solid black;overflow:scroll;" id="messages">
  </div><br/>
  <form action="javascript:sendMessage();" >
    <input type="text" id="chat_nickname" name="author"/>
    <input type="text" id="chat_input" name="message" class="chat-new"/>
    <button class="submit">Submit</button>
  </form>
 </body>

 <script type="text/javascript">
    // keep track of the last time data wes received
    var last_update = 0;

    // call getData when the document has loaded
    $(document).ready(function(){
        getData(last_update);
    });

    // execute ajax call to chat_server.py
    var getData = function(last_update){
        $.ajax({
            type: "GET",
            url: "http://"+ window.location.hostname + ":8081?last_update=" + last_update + "&callback=?",
            dataType: 'json',
            async: true,
            cache:false,
            timeout: 300000,
            success: function(response){
                // append the new message to the message list
                var messages = response.data.messages;
                console.log(response);
                for (i in messages){
                    $('<p><span class="time">[' + messages[i].time +']</span> - <span class="message">' + messages[i].message + '</span></p>').appendTo('#messages');
                    if (messages[i].time > last_update){
                        last_update = messages[i].time;
                    }
                }
                console.log("Last_update: " + last_update);
                // Keep div scrolled to bottom
                $("#messages").scrollTop($("#messages")[0].scrollHeight);
                // Check again in a second
                setTimeout('getData(' + last_update + ');', 1000);
            },
            error: function(XMLHttpRequest, textStatus, errorThrown){
                // Try again in 10 seconds
                setTimeout( "getData(" + last_update + ");", 10000);
            },
            failure: function(){ console.log('fail'); },
        });
    }
    // Add a contribution to the conversation
     function sendMessage(){
        var nickname = $('#chat_nickname').val();
        var message = $('#chat_input').val();
        $('#chat_input').val("");

        console.log( "nickname: " + nickname + "; message: " + message );

        $.ajax({
             type: 'POST',
             url: '/chat/post_message/',
             data: {
                nickname: nickname, 
                message:message
             },
             success: function(data, status, xml){
                console.log("Success! - " + status);
             },
             error: function(xml, status, error){
                console.log(error + " - Error! - " + status);
             },
             complete: function(xml, status){
                console.log("Complete! - " + status);
             }
        });

    }
</script> 

sendMessage passes data from the form to Django, and Django puts it into the database (and adds a time to it). getData directs to :8081, where Twisted is listening with the ### Chat Server portion (second half) of this next bit of code (chat_server.py):

import datetime, json, sys, time, os, types
from twisted.web import client, resource, server, wsgi
from twisted.python import threadpool
from twisted.internet import defer, task, reactor
from twisted.application import internet, service
from twisted.enterprise import adbapi
from django.core.handlers.wsgi import WSGIHandler

## Django environment variables
sys.path.append("mydjangosite")
os.environ['DJANGO_SETTINGS_MODULE'] = 'mydjangosite.settings'

 ## Tying Django's WSGIHandler into Twisted
def wsgi_resource():
    pool = threadpool.ThreadPool()
    pool.start()

    # Allow Ctrl-C to get you out cleanly:
    reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)

    wsgi_resource = wsgi.WSGIResource(reactor, pool, WSGIHandler())

    return wsgi_resource

## Twisted Application Framework
application = service.Application('twisted-django')

class Root(resource.Resource):
    def __init__(self, wsgi_resource = None):
        resource.Resource.__init__(self)
        if wsgi_resource != None:
            self.wsgi_resource = wsgi_resource

    def getChild(self, path, request):
        child_path = request.prepath.pop(0)
        request.postpath.insert(0, child_path)
        return self.wsgi_resource

    def render_GET(self, request):
        id = request.args.get('id', [""])[0]
        command = request.args.get('command', [""])[0]
        self.get_page(request, id)
        return server.NOT_DONE_YET

    @defer.inlineCallbacks
    def get_page(self, request, id):
        page = yield client.getPage("/chat/latest/%s" % id)
        request.write(page)
        request.finish()

## Create and attach the django site to the reactor
django_root = Root(wsgi_resource())
django_factory = server.Site(django_root)
reactor.listenTCP(8080, django_factory)





### Chat Server
class ChatServer(resource.Resource):
    isLeaf = True

    def __init__(self):
        # throttle in seconds
        self.throttle = 5
        # store client requests
        self.delayed_requests = []
        # setup a loop to process collected requests
        loopingCall = task.LoopingCall(self.processDelayedRequests)
        loopingCall.start(self.throttle, False)
        # Initialize
        resource.Resource.__init__(self)

    def render(self, request):
        """Handle a new request"""
        request.setHeader('Content-Type', 'applicaton/json')
        args = request.args
        # set jsonp callback handler name if it exists
        if 'callback' in args:
            request.jsonpcallback = args['callback'][0]
        # set last_update if it exists
        if 'last_update' in args:
            request.last_update = args ['last_update'][0]

        data = self.getData(request)
        if type(data) is not types.InstanceType and len(data) > 0:
            # send the requested messages back
            return self.__format_response(request, 1, data)
        else:
            # or put them in the delayed request list and keep the connection going
            self.delayed_requests.append(request)
            return server.NOT_DONE_YET

    def getData(self, request):
        data = {}
        dbpool = adbapi.ConnectionPool("sqlite3", database="/home/server/development/twisted_chat/twisted-wsgi-django/mydjangosite/site.db", check_same_thread=False)
        last_update = request.last_update
        print "LAST UPDATE: ", last_update
        new_messages = dbpool.runQuery("SELECT * FROM chat_message WHERE time > %r" % request.last_update )
        return new_messages.addCallback(self.gotRows, request )

    def gotRows(self, rows, request):
        if rows:
            data = {"messages": 
                                [{ 'author': row[1], 'message':row[2],'timestamp': row[3] } for row in rows] 
                        }
            print 'MESSAGES: ', data
            if  len(data) > 0:
                return self.__format_response(request, 1, data)
            return data

    def processDelayedRequests(self):
        for request in self.delayed_requests:
            data = self.getData(request)
            if type(data) is not types.InstanceType and len(data) > 0:
                try:
                    print "REQUEST DATA:", data
                    request.write(self.__format_response(request, 1, data))
                    request.finish()
                except:
                    print 'connection lost before complete.'
                finally:
                    self.delayed_requests.remove(request)

    def __format_response(self, request, status, data):
        response = json.dumps({ "status": status, "time": int(time.time()), "data": data })
        if hasattr(request, 'jsonpcallback'):
            return request.jsonpcallback + '(' + response + ')'
        else:
            return response

chat_server = ChatServer()
chat_factory = server.Site(chat_server)
reactor.listenTCP(8081, chat_factory)

Here, render tries to getData (it may not ever?) and when it can't puts the request into self.delayed_requests. getData uses enterprise.adbapi to make a query on Django's db, returning a Deferred instance. processedDelayedRequests goes through that the delayed request queue and, if the query is completed, that data gets passed to gotRows, which then converts it to the format I want and sends it off to __format_response which sends the data back to the JS where it can be dealt with. That's the theory anyway - the previous sentence is where I think my problem is.

print "LAST UPDATE: ", last_update always prints "LAST_UPDATE: 0" but last_update gets updated via the JS, so this not an error.

print 'MESSAGES: ', data prints "{'messages': [{'timestamp': u'2013-08-10 16:59:07.909350', 'message': u'chat message', 'author': u'test'}, {'timestamp': u'2013-08-10 17:11:56.893340', 'message': u'hello', 'author': u'pardon'}]}" and so forth as new messages are added to the db. It gets new data when posts are made, and seems to work pretty well otherwise.

print "REQUEST DATA:", data never fires at all... I think this method is leftover from an earlier attempt to get this working.

I get the correct output from gotRows but don't know how to get that output passed back to the client. I'm not even half confident with my understanding of Deferreds, so I think that's where my problem lies, but I don't know what I can do to move forward from here. Any help would be very much appreciated.


Solution

  • Sometimes, a function in a twisted application may conditionally return data, and at other times return a Deferred. In those cases, you can't check to see if you got data; you probably won't, and in the cases that you do get a deferred, no amount of rechecking will change that; you must always turn such functions into real Deferreds, with maybeDeferred, and then attach a callback to the result.

    That said, t.e.adbapi.ConnectionPool.runQuery() is not such a function. it always returns a deferred. The only way to work with that data is to attach a callback. In general, you won't ever see the result of an asynchronous call in twisted applications in the same function that makes the initial call.

    This means that, since you want to run a query for every long polling request, and since those are unconditionally asynchronous (you have to return from your render() function before they can even start), your render() always returns NOT_DONE_YET:

    def render(self, request):
        """Handle a new request"""
        request.setHeader('Content-Type', 'applicaton/json')
        self.getData(request)
        return server.NOT_DONE_YET
    

    and now everything needs to happen correctly in getData. As it turns out, the handling of the deferred from runQuery is fine; but the sql itself has a pretty big issue. To understand why, imagine a clever hacker tried to access

    http://yoursite?last_update=5+and+"secret"+in+(select+password+from+users)
    

    The fix is easy, though, don't do string interpolation, use bind parameters. switch the %s for a ? in the query, and the % for a , in the function call itself. While we're at it, lets move the ConnectionPool out of this method and into __init__, you don't want or need a whole pool for each retry for each request.

    def getData(self, request):
        last_update = request.args['last_update']
        print "LAST UPDATE: ", last_update
        new_messages = self.dbpool.runQuery("SELECT *"
                                            " FROM chat_message"
                                            " WHERE time > ?", request.last_update)
        #                                                  ^ ^
        return new_messages.addCallback(self.gotRows, request)
    

    The callback attached to the deferred returned by runQuery is returning the formatted result; but there's nobody to return it to; it needs to do all the work itself. Fortunately, we're already giving it the request to work with, so that's not too difficult. We also need to handle the case of when there was no data to return, since there's nobody on the other end to add it to the list of delayed requests.

    def gotRows(self, rows, request):
        if rows:
            # we have data to send back to the client! actually finish the
            # request here.
            data = {"messages": [{'author': row[1], 'message': row[2], 'timestamp': row[3]} for
                                 row in rows]}
            request.write(self.__format_response(request, 1, data))
            request.finish()
    
        else:
            self.delayed_requests.append(self)
    

    Lastly, we need to make a similar change to processedDelayedRequests() as we made in render(). it can only fire off the query, it can't update its state based on the results because it doesn't have them. To simplify things, we'll just eat items off the list.

    def processDelayedRequests(self):
        delayed_requests = self.delayed_requests
        self.delayed_requests = []
        while self.delayed_requests:
            # grab a request out of the "queue"
            request = self.delayed_requests.pop()
    
            # we can cause another attempt at getting data, but we'll never get
            # to see what hapened with it in this function.
            self.getData(request)