-
Notifications
You must be signed in to change notification settings - Fork 7
Websockets
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 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.
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.
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 is a sequence diagram of the
basic event stream flow on the server.
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 in the Appendix
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.
-
EventStream::Notifiable - Mixed in to any
ActiveRecord::Base
model we need to event stream to the client. It ties into theActiveRecord::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 toPaper
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, wrapsActiveSupport::Notifications.subscribe
. - EventStreamSubscriber - Subclassed for different channels and payload types.
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 is a sequence diagram of the basic event stream flow on the client.
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.
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.
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.
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, ...
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()