Skip to content

Websockets

Erik Hetzner edited this page Jan 24, 2018 · 5 revisions

Websockets

Overview

Aperta uses server sent events for two general types of use cases.

  • Broadcasting changes to other users - This was the original use case for server sent events in Aperta. Broadcasting updates to a Paper, a Task, or a Card improves the feel of the application, and keeps users from having to reload the page as frequently. These are places where Aperta would continue to function relatively normally if server sent events are unavailable.
  • A replacement for polling - In the example case of uploading a new Attachment, the client POSTs a new record to the server, and then the server will update the client via a server sent event when that attachment has finished processing or errored. If server sent events were unavailable then the user will have to reload the page in order to see the updated attachment.

When a resource is updated on the server (either from a client request or from a background job), the server sends a notification to other clients with the id and type of that resource. If another client is interested, it makes a normal HTTP request to get the updated resource from the server. Figure event_stream_overview{.unresolved} is a sequence diagram of the system as a whole.

Technically, websockets are the protocol that the server and client use to communicate, but the client only sends messages to the server via HTTP.

Notably, Rails does not push any further details of a resource to the client other than the resource id, resource type, and the type of event that has occurred. The client loads the updated resource via the existing REST API. This approach affords better security, as the clients still use the existing Roles and Permissions system. It also minimizes the payload the server needs to generate for its broadcast.

{height="250"}

Architecture

The event streaming system in Aperta has pieces on both the client and server that work in concert. Both the client and server implementations are a thin layer on top of existing solutions that already exist in the community.

Server

On the server, Aperta uses ActiveSupport::Notifications (AS::N) for its underlying implementation. AS::N.subscribe and AS::N.instrument listen for and create custom events, respectively. These methods are a part of Rails, and documented in the Rails Guides. Events are broadcast to the client in background jobs managed by Sidekiq. The system uses the Pusher gem to do the actual streaming to the client. Figure event_stream_server{.unresolved} is a sequence diagram of the basic event stream flow on the server.

{height="250"}

Subscriptions

The list of events the system can send to the client is declared in event_stream_subscriptions.rb.

Note that we also use ActiveSupport::Notifications to power a more general subscription system. Those general subscriptions are set up in the subscriptions.rb initializer.

The entire list of registered subscribers can be printed out using the subscriptions rake task. See Example rake_subscriptions{.unresolved} in the Appendix

Channels

The system uses different channels to send updates to the appropriate users. Some examples include:

  • A system channel broadcasts events to all clients. For example, when a resource is deleted all clients receive a notification on the system channel.
  • Each paper has a specific channel to broadcast updates relevant to that specific paper. Clients without access to that paper will not receive updates.
  • Private channels for individual users broadcast updates that are only relevant to them in specific circumstances. For instance, when an user uploads a manuscript that with the same file name as the current one, they will see flash message telling them that their current manuscript is being replaced. This message is created from an event sent on their private user channel.

Notable Classes

  • EventStream::Notifiable - Mixed in to any ActiveRecord::Base model we need to event stream to the client. It ties into the ActiveRecord::Base#after_commit hook to event stream. It was important to bubble the actual model references as part of the internal Rails payload. This allowed us to still reference model relationships for models that were deleted (example: when a Task is deleted, we bubble the actual Task object as part of the payload and due to that, we can ask what the Task's Paper is even though the record had been deleted).

By default, when a client updates a resource on the server that client does not receive the corresponding notification. This behavior is configurable per resource.

There are also instances where the server can temporarily disable sending notifications, for instance when updating a batch of records in a rake task. In this case the EventStream::Notifiable class has a notifications_enabled flag that can temporarily be set to avoid swamping clients with messages.

  • Notifier - Solely exists to wrap the call to ActiveSupport::Notifications.instrument with further information.
  • Subscriptions - Defines the DSL used to configure the list of subscribers, which is intended to be run in an initializer. Subscribers can be triggered off of any of the CRUD actions of a model that has included EventStream::Notifiable. We also manually trigger events in response to Paper AASM transitions.

A valid subscriber only has to implement a call method that takes the event name and event payload as arguments. The EventStreamSubscriber is one example.

  • Subscriber - The underlying class used by the Subscriptions DSL when registering subscriptions, wraps ActiveSupport::Notifications.subscribe.
  • EventStreamSubscriber - Subclassed for different channels and payload types.

Client

The client uses the ember-pusherEmber addon for its underlying

implementation. The Application Route listens to the system and user channels. When a user navigates to a given paper, the route for that paper will start listening to the Pusher channel for that paper until the user navigates away. Figure event_stream_client{.unresolved} is a sequence diagram of the basic event stream flow on the client.

{height="250"}

Excluding Clients from Events

The Pusher gem has provisions for excluding clients from events. In Aperta's case we mostly use this to keep a client from getting a notification about an action they have just performed. For instance, if a client makes a POST to the server to create a new Task, if that client received the 'created' event then a user would erroneously see a duplicate Task instance on the screen, without having additional logic to process the event.

When a client makes any request to the Rails server, it sends along a Pusher-Socket-ID header that the server stores via code in the TahiPusher::SocketTracker module. Later on in the lifecycle of the request, the server will pass that socket id to Pusher to exclude the requester from any notifications that are created as a result of that particular request.

EventStream::Notifiable has a notify_requester flag that can be toggled situationally to override this behavior for some requests.

Alternatives

In the cases where we've essentially used server sent events to replace long polling, switching to long polling would be a reasonable alternative. Two ember addons that look like they'd provide a more fully baked implementation out of the box would be ember-poll and ember-pollboy For a lower-level approach, ember-lifeline (which Aperta currently uses) allows for a lightweight, idiomatic polling implementation but would require more custom code. All three would be worth evaluating.

Existing examples of the collaborative use case may be able to simply be removed without much negative impact on UX. It would be good to reevaluate the specific instances where event streaming features prominently in the UX of Aperta and determine what impact removing it would have.

If the decision were made to replace the more general use case with polling, a polling-based replacement would come with its own technical challenges. The naive approach of reloading resources from the server after a fixed time period may cause a high load on the server at scale. Some further work would need to be done to investigate the implementation.

Appendix

The Appendix includes an abbreviated example of the output from the subscriptions rake task. It also includes listings of the source code for the sequence diagrams that are displayed throughout this report.

Subscription rake command and output

The subscriptions rake task prints a list of all the registered events and subscribers that have been set up via the Subscriptions.configure DSL.

$> bundle exec rake subscriptions 
Event Name                      Subscribers
.*                              EventLogger
assignment:created              Assignment::NotifyAssignee
assignment:destroyed            Assignment::NotifyAssignee
assignment:updated              Assignment::NotifyAssignee
attachment:created              EventStream::StreamToPaperChannel
attachment:destroyed            EventStream::StreamToEveryone
attachment:updated              EventStream::StreamToPaperChannel
paper:accepted                  Paper::DecisionMade::UnassignReviewers, ...
paper:data_extracted            Paper::DataExtracted::NotifyUser
paper:destroyed                 EventStream::StreamToEveryone
paper:in_revision               Paper::DecisionMade::UnassignReviewers
paper:initially_submitted       Paper::Submitted::EmailCreator, ...
paper:rejected                  Paper::DecisionMade::UnassignReviewers, ...
paper:submitted                 Paper::Submitted::CreateReviewerReports, ...
paper:updated                   EventStream::StreamToPaperChannel
paper:withdrawn                 Paper::DecisionMade::UnassignReviewers, ...

Sequence diagram code

The sequence diagrams in this report were generated using Plantuml. The source code for each diagram is included below

Error rendering macro 'code': Invalid value specified for parameter 'lang'

participant "Client 1"
participant Rails #orange
participant "Client 2"
"Client 1" -> Rails : POST api/tasks/
Rails -> "Client 2" : (server sent event) Task 1 created
"Client 2" -> Rails : fetch Task 1 (GET api/tasks/1)
"Client 1" -> Rails : PUT api/tasks/1
Rails -> "Client 2" : Task 1 updated
"Client 2" -> Rails : reload Task 1 (GET api/tasks/1)
"Client 1" -> Rails : DELETE api/tasks/1
Rails -> "Client 2" : Task 1 destroyed
"Client 2" -> "Client 2" : unload Task 1

Error rendering macro 'code': Invalid value specified for parameter 'lang'

header
AS::N === ActiveSupport::Notifications
endheader
== Rails Server ==
"Paper (EventStream::Notifiable)" -> "Paper (EventStream::Notifiable)" : after_commit
"Paper (EventStream::Notifiable)" -> Notifier : notify
Notifier -> EventStreamSubscriber : call
note left
 'call' happens indirectly via
 AS::N.instrument and
 AS::N.subscribe
end note

== Sidekiq Job ==
EventStreamSubscriber -> "TahiPusher::Channel" : push
"TahiPusher::Channel" -> Pusher : trigger

Error rendering macro 'code': Invalid value specified for parameter 'lang'

EmberPusher -> PaperRoute : updated {type: 'paper', id: 1}
PaperRoute -> ApplicationRoute : updated {type: 'paper', id: 1}
note left: action bubbles up
ApplicationRoute -> Store : peekRecord('paper', 1)
Store -> ApplicationRoute : <paper:1>
ApplicationRoute -> Rails : <paper:1>.reload()

Attachments:

{width="8" height="8"} overview_sequence.png (image/png)
{width="8" height="8"} diagram2.png (image/png)
{width="8" height="8"} diagram1.png (image/png)

Clone this wiki locally