The Problem

These days all the cool kids are using Ember.JS or Angular or Meteor or some other single page web application. If you deploy often, like we do at Discourse, you have a problem.

How can you get everyone to run the latest version of your JavaScript and CSS bundle? Since people do not reload full pages and just navigate around accumulating small json payload there is a strong possibility people can be on old versions and experience weird and wonderful odd bugs.

The message bus

One BIG criticism I have heard of Rails and Django lately is the lack of "realtime" support. This is an issue we foresaw over a year ago at Discourse.

Traditionally, people add more component to a Rails system to support "realtime" based notifications. Be it Ruby built systems like faye, non Ruby systems like Node.JS with socket.io or outsourced systems like Pusher. For Discourse none of these were an option. We could not afford to complicate the setup process or outsource this stuff to a third party.

I built the message_bus gem to provide us with an engine for realtime updates:

https://github.com/SamSaffron/message_bus

At the core of it message_bus allows you a very simple API to publish and subscribe to messages on the client:

# in ruby
MessageBus.publish('/my_channel', 'hello')

<!-- client side -->
<script src="message-bus.js" type="text/javascript"></script>
<script>
MessageBus.subscribe('/my_channel', function(data){
  alert(data);
});
</script>

Behind this trivial API hides a fairly huge amount of feature goodness:

  1. This thing scales really well, clients "pull" information from a reliable pub sub channel, minimal per-client house keeping.
  2. Built in security (send messages to user or groups only)
  3. Built on rack hijack and thin async, so we support passenger, thin, unicorn and puma.
  4. Uses long polling, with an event machine event loop, can easily service thousands of clients from a single web server.
  5. Built in multi-site support (for hosting sub domains)

We use this system at Discourse to notify you interesting things, update the topic pages live and so on.

On to the implementation

Given a message_bus, implementing a system for updating assets is fairly straight forward.

On the Rails side we calculate a digest that represents our application version.

def assets_digest
  @assets_digest ||= Digest::MD5.hexdigest(
         ActionView::Base.assets_manifest.assets.values.sort.join
   )
end

def notify_clients_if_needed
   # global channel is special, it goes to all sites 
   channel = "/global/asset-version"
   message = MessageBus.last_message(channel)

   unless message && message.data == digest
      MessageBus.publish channel, digest
   end
end

With every full page we deliver to the clients we include this magic digest:

Discourse.set('assetVersion','<%= Discourse.assets_digest %>');

Then on the client side we listen for version changes:

Discourse.MessageBus.subscribe("/global/asset-version", function(version){
  Discourse.set("assetVersion",version);

  if(Discourse.get("requiresRefresh")) {
    // since we can do this transparently for people browsing the forum
    //  hold back the message a couple of hours
    setTimeout(function() {
      bootbox.confirm(I18n.lookup("assets_changed_confirm"), function(){
        document.location.reload();
      });
    }, 1000 * 60 * 120);
  }
});

Finally, we hook into the transition to new routes to force a refresh if we detected assets have changed:

routeTo: function(path) {

  if(Discourse.get("requiresRefresh")){
    document.location.href = path;
    return;
   }
 //...
}

Since in the majority of spots we render "full links" and pass them through this magic method this works well. Recently Robin added a second mechanism that allows us to trap every transition, however it would require a double load which I wanted to avoid:

Eg, the following would also work

Discourse.PageTracker.current().on('change', function() {
   if(Discourse.get("requiresRefresh")){
    document.location.reload();
    return;
   }
});

Summary

I agree that every single page application needs some sort of messaging bus, you can have one today, on Rails, if you start using the message_bus.

Real-time is not holding us back with Rails, it is production ready.

Comments

George Armhold 8 months ago
George Armhold

Sam, thanks for publishing MessageBus.

Any clue how I might access Rails-level session data for the user_id_lookup?

My app saves the user_id to the Rails session as in this Railscast (perhaps a bad idea security-wise, but let's assume a toy application for the moment).

However I can't seem to get access to the session from Rack. I'm using an initializer like the following:

MessageBus.user_id_lookup do |env|

  ad_request = ActionDispatch::Request.new(env)
  puts "ad_session: #{ad_request.session}"

  ad_request.session[:user_id]
end

But the session is always an empty hash. I can see that ad_request.cookies has my _appname_session string, and ad_request.cookie_jar.signed is there, but has no :user_id.

I checked to see how Discourse is using it, and they seem to be generating a (secure, random) permanent "remember me" token, if I've understood the source correctly.

I'm able to to access permanent cookies from Rack in my app (ad_request.cookie_jar.permanent['foo']), but before I go down that route I'd like to know why the regular Rails session seems to not work. Must it necessarily be a permanent cookie for Rack to see it?

Many Thanks!

Sam Saffron 8 months ago
Sam Saffron

This is a combination of middleware ordering and plugin design.

Part of rake middleware

use ActiveRecord::ConnectionAdapters::ConnectionManagement
use ActiveRecord::QueryCache
use MessageBus::Rack::Middleware
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ParamsParser

If you move the middleware down after Session, you will have access to it via env['rack.session'] which should solve your problem. I decided to keep the middleware as high in the stack as I could to minimise cost.

Reordering should be easy in an initializer, though you are probably going to have to "require: false" and inject manually. If you get all of this working it may be worth sending a documentation PR through.

George Armhold 8 months ago
George Armhold

Hmm, I know nothing about Rack but I was able to get this working as an initializer (again, the Discourse source is really helpful for learning this kind of stuff):

require 'message_bus'
Rails.configuration.middleware.use MessageBus::Rack::Middleware

However this seems to prevent message-bus.js from being added to the available assets. If I copy that file in manually to my project then it works, and indeed env['rack.session'] has what I am looking for as you suggested.

I will be very happy to submit a documentation PR once I can resolve the assets issue.

Thanks, I really appreciate the hand-holding on this.

Sam Saffron 8 months ago
Sam Saffron

On second thought, let's just move it after session https://github.com/SamSaffron/message_bus/blob/master/lib/message_bus/rails/railtie.rb#L9

that would make for a more robust default, perf heads like me can yank it forward, will have to read through railstie source to figure out how to do this.

Iulian Onofrei 6 months ago
Iulian Onofrei

So, are you using Ember.js?

Sam Saffron 6 months ago
Sam Saffron

Yeah Discourse is an Ember.JS project!

Slava Kim 5 months ago
Slava Kim

You mentioned Meteor but similar behavior (updating client preserving the state) was baked into framework from early days and is called "hot code push".

Henrik Nyh 71 days ago
Henrik Nyh

Nice.

When is notify_clients_if_needed called?

By the stale clients themselves, when they make some request to the server?

Or by new clients that visit the site and so hit the server?

I did something similar for a Heroku app by hooking into their webhook system – after deploy, they POST to a URL on the site, which then notifies clients of an update.

Sam Saffron 71 days ago
Sam Saffron

We actually call it on boot, so its ensured to happen. The operation is so short that it really does not impact boot in any noticeable way.

Henrik Nyh 70 days ago
Henrik Nyh

Aha, thank you. I considered triggering it on boot instead of via a deploy webhook. I can't remember why I didn't pursue that further.

I wanted to look into where in the Rails boot process you hooked it in, but it seems maybe it doesn't happen that way anymore? Or I misunderstood you.

Discourse.assets_digest is defined here: https://github.com/discourse/discourse/blob/master/lib/discourse.rb#L81-L93

And it seems to be called only from a partial, which is rendered from the application layout: https://github.com/discourse/discourse/blob/master/app/views/common/_discourse_javascript.html.erb#L42

So I guess the asset-version message is in fact triggered by the next visitor who loads a full page? Meaning that stale clients can theoretically remain stale forever if no one else does a "full" visit to the site?

Sam Saffron 70 days ago
Sam Saffron

Yeah, but the odds of that are so low it does not even matter smile

Looks like I mis-remembered what I did blush

Henrik Nyh 70 days ago
Henrik Nyh

smile Yeah, it seems improbable, and also I guess if every client is equally stale, it might not matter as much.

Thanks so much for the replies! It's been great to hear how you've approached this problem.


comments powered by Discourse