boazsegev / plezi

Plezi - the Ruby framework for realtime web-apps, websockets and RESTful HTTP
www.plezi.io
MIT License
246 stars 9 forks source link

Architectural clarification #37

Open khataev opened 3 years ago

khataev commented 3 years ago

Hi! I'm not an expert in websockets field and still have a few practical experience with them, only theoretical researches, started some time ago from Faye, Rack bottleneck (partly solved as socket hijacking mechanism) and search for better alternatives that led to invention of Iodine/Plezi. So I just want to clarify some points.

1) Starting from simple example - chat application, as in your example. You say that Plezi is the wrapper around Iodine web server. How tightly is it coupled to it? If I start app with rackup config.ru -p 9292 - it works. But If I replace Iodine with puma - it does not work:

→ bundle exec puma                 
Puma starting in single mode...
* Version 5.0.2 (ruby 2.7.1-p83), codename: Spoony Bard
* Min threads: 0, max threads: 5
* Environment: development
Running Plezi version: 0.16.4
* Listening on http://0.0.0.0:9292

App is opening, but websockets do not work:

(index):190 WebSocket connection to 'ws://0.0.0.0:9292/' failed: Error during WebSocket handshake: Unexpected response code: 200
init_websocket @ (index):190
(index):203 

So is it possible to interchange Iodine web server with others - puma, falcon? What features have Iodine in comparison with Puma (nio4r) related to sockets?

2) More practical example. I have an Rails app (on Puma) with separate background (Sneakers/RabbitMQ) workers. Workers change state of models and Frontend should be notified about these changes. I want to replace long polling with Websockets. What architecture should I consider, if I choose Plezi framework? First, good choice is to separate Websocket server and HTTP server, which could remain Puma. Second, we need some "Interconnector" block to communicate between my app or background workers to be able to send messages to websocket channel (i.e. that model has been updated). What is this "Interconnector" in case of Plezi? Some redis-baked logic? Do you have some docs of how to construct such solution? We could find drawing of similar architecture from AnyCable docs. How it transforms with use of Plezi/Iodine?

Looking forward for your answer and thanks in advance! )

boazsegev commented 3 years ago

Hi @khataev ,

Thank you for your interest in my work.

Pleas is basically a wrapper around iodine. Please consider using iodine directly unless you need the controller router features Plezi offers.

Total Coupling with Iodine

The iodine server has the same HTTP features as Puma in addition to its WebSockets/SSE and pub/sub features.

Plezi can be used with Puma only for the HTTP features. All other features - WebSockets, SSE, pub/sub - require iodine.

Iodine handles pub/sub, Redis backend connectivity, IPC (inter process communication), WebSocket upgrading and callbacks and more. I wouldn't write this logic twice just to support other servers.

FYI: iodine is a Ruby wrapper around the facil.io C framework. it's not only fast, but also helps to fight memory fragmentation and aims to be both secure (protecting against attacks) and permissive (allowing for custom HTTP methods, etc').

On my machine it's also significantly faster than Puma. Why not benchmark it and see how it goes?

Architecture... next...

I have to go, I'll post another reply to answer your question about the architecture.

The short story is that if you're using ActionCable, you'd be better off incorporating AnyCable into your stack. Iodine (and Plezi) work in the "raw", closer to the metal, and don't auto-wrap messages in the ActionCable format.

Iodine pub/sub can be easily connected to Redis (using two connections per cluster, which is more efficient than two connection per worker, as you get less load on Redis and use less bandwidth). From there, connecting to sidekiq or an external process is fairly simple (just subscribe to the messages).

boazsegev commented 3 years ago

Architecture... Continued

An example

I committed an example async-task Rack application to the iodine repo that has two tasks (echo and add). These tasks can be performed locally on the same process or exported to a Redis server and pushed back to the client using a named channel.

To run it locally, start the app using iodine. To run the app with Redis, start a web server with:

iodine -r redis://localhost async_task.ru # or whatever url your Redis is on

Then start the async task worker with:

iodine -r redis://localhost async_task.ru worker

You will need to connect to the app using WebSockets (to get notified of task results):

ws = new WebSocket("ws://localhost:3000/userID");
ws.onopen = function(e) {ws.send(JSON.stringify({'task': 'echo', 'data': 'Hello!'}));};
ws.onmessage = function(e) {console.log(e.data);};
ws.onclose = function(e) {console.log("closed")};

Then try performing the addition task using:

ws.send(JSON.stringify({"task":"add", "data":[1,2,3]}));

Separating the HTTP server from the...?

First, good choice is to separate Websocket server and HTTP server, which could remain Puma.

Actually, this can't be done with Puma. Puma only serves HTTP. If you use ActionCable, then the WebSocket server is part of the Rails framework and it is NOT part of Puma.

AnyCable solves the performance problem by replacing the Rails WebSocket server with its own WebSocket server, while using Redis to broker messages between the two.

Iodine and goo are the only Ruby servers I know of that actually offer both HTTP and WebSockets. However, Rails ignores this fact and uses its own server for WebSockets unless you forget about ActiveCable and implement your own WebSocket layer.

Using a message broker

Second, we need some "Interconnector" block to communicate between my app or background workers to be able to send messages to websocket channel (i.e. that model has been updated).

This is called a "message broker" and iodine (and Plezi) come with support for Redis. It's also possible to add your own message broker "engine" and connect to PostgreSQL / RabbitMQ or whatever message broker you desire.

I recommend Redis practically for everything. It had a good balance between features and complexity.

i.e., for connecting iodine to Redis, simply use the -r option from the command line (yes, it really is that simple).

In general, I would recommend connecting the WebSocket to a message broker (pub/sub service) and allowing the tasks to publish to that service whenever a task is done.

Workers change state of models and Frontend should be notified about these changes.

If you're using PostgreSQL, then you can implement "hooks" that PG will call whenever a database table is updated. This could leverage the existing database connections and allow you to use PG as your message broker.


Things to consider

Things really depend on your specific application needs.

For example, if your tasks are connection bound, you might be better off running the tasks on the same machine, so they could be canceled if a user disconnects.

What happens if the workers / server are shut down? Do your tasks need to persist if the server restarts?


Performance & Other Costs

On my machine, a simple "Hello World" app with 200 concurrent connections runs at 16,940 req/sec with a single Puma thread and 65,898 req/sec with a single iodine thread.

With only a single concurrent connection, that same simple "Hello World" app speeds up to 18,019 req/sec with a single Puma thread and slows down to 28,746 req/sec with a single iodine thread - the iodine server was designed for higher loads and concurrency in order to accommodate WebSockets.

However, most applications are usually very happy with 1000 req/sec, which only goes to show that the server is rarely the bottleneck and architecture is everything.

Every time you go on the net (to send data to Redis, to wait for a response, etc'), you're running into new and interesting bottlenecks. This is why your architecture dictates a lot.