bmitc

bmitc

How to take the Erlang/Elixir way of doing things to Python?

I have a new role in a domain that is completely dominated by Python. This is in the scientific R&D domain, where Python is used for everything from distributed control systems to data analysis pipelines. Of course, it’s the latter that makes some sense but not the former. However, that is the ultimate reality of the situation, in the short term at least.

I have never built big systems in Python before, so I am feeling a little bit like a fish out of water, especially since most of my systems have been built using Elixir, F#, and LabVIEW, all using actor/process-oriented architectures.

What I am looking for is advice from people who have built large systems in both Python and Elixir or Erlang and how they went about doing Erlang/Elixir-y type of things in Python without straying too far away from so-called Pythonic code. Obviously, a distributed control system calls for exactly the things provided by the BEAM, Erlang/Elixir, and OTP, but in the early days here, I must build in Python. So what I need are BEAM and OTP-ish type of things in Python without being too exotic. I.e., I need this to be robust as possible in Python while having a reasonable architecture for handling asynchronous and distributed things.

What architectures did you use? What libraries? Etc. Think of large amounts of hardware being controlled with large amounts of telemetry coming in from distributed network devices, all collected into a single control computer that will also handle user interfaces. I know of Pykka, and I’ve done some experiments with it, and it looks reasonable. But I am wanting some advice of robust frameworks, and I don’t know how robust and well-used Pykka is. There is also Ray. The user interface(s) will be using PyQt6 or PySide6, although there will also likely be some remote viewing (over the browser) for telemetry only.

Most Liked

RomanKotov

RomanKotov

Hi,

I have both Python and Elixir experience. Other languages greatly influenced me with their ideas. Beam VM and OTP inspires me with architecture decisions. Its documentation is very proactive and helps to see the problems from other perspective.

I have never worked with Pykka directly, only when debugged code in Python music player. This experience is not enough, so will not speculate on it.

As I understand, you have a server, that collects telemetry data. This server also needs to display interface for the user. It would be helpful, if you could give more context about the task. For example:

  • Do you need to aggregate data before showing it? You can discard raw data after aggregation.
  • Do you need a raw data? You can run other aggregations on it later.
  • How fast the data comes in?
  • How do you plan to store this data?
  • What will be if server crashes? Is it OK, if it stops responding?
  • Do you really have to implement both desktop and browser UI?
  • Which web framework do you plan to use?
  • Does a server fetch the data from these devices, or the devices send telemetry to the server?
  • How much history do you plan to store? Do you have enough disk space for it (if everything will be on the single server)?
  • Do you need to have separate permissions for people? I mean, do you need to hide some data from specific users?
  • Do you need to show interface remotely? I mean, if it will be deployed to a public server? If yes, then it is better to protect it.
  • How do you plan to scale the application, if you will have 2 servers instead of one?

I would split the task into 3 main parts. Receiving the data, processing it and presentation.

Receive (devices send data to a server)

If I expect large amounts of data, then I possibly need to store it raw before processing.
There are some ways to do it. For example, the data can be saved directly to a database (many options here), added to a queue (RabbitMQ, Redis, Kafka, etc) or possibly some other solution.

It is better to have these endpoints as fast as possible, so they can handle much data. Also I would load-test different solutions here. Possibly web servers with async features will work fine.

We can compare it to process mailbox in Elixir. A process receives many messages, but can handle them as soon as gets some processor time. So, we try to emulate a mailbox here.

Receive (server polls devices for data)

If server fetches data from many devices over the network, then this part should be as concurrent, as it can be. Running multiple Python processes will introduce overhead here, so possibly you will want to go with threads, or even asyncio-related libraries. Idea is the same - you need to fetch as many data as possible with as little overhead, as possible.

You can compare this to launching multiple processes in Elixir to get the data, but we try to emulate the same behaviour with features from Python.

Process the data

If you need to show a raw data, then you can omit this step at all.

If you need to process it somehow, then you can look at something like Celery. I have also worked with Apache Airflow, but it can be overkill here. So, the basic idea is to get a chunk (batch) to process and store the results somewhere. The details greatly depend on the use-case.

Present data

It depends on the type of data, its amounts etc. Possibly you can just generate pages with charts via Flask or Django templates. Besides, you can just refresh a page every N seconds instead of using websockets. It can be just refreshing the whole page, only a part of it, or fetching data from API. Example with Stimulus, example with unpoly.js, example with HTMX. I would like to have only one UI. If you do really need a desktop application - possibly there is a way to show the same UI in a web-view.

Also there is Plotly Dash - have used it to render dashboards. It also supports open-source version.

General advices

I would recommend Designing Data-Intensive Applications book. It gives nice overview of tradeoffs, architecture details and approaches to work with data. It does not dive too deep into details, but gives many ideas (at least for me). Enjoyed reading it (and watching nice pictures there).

Another book with useful tips for overloaded system is Erlang in anger. It describes some typical issues with overloaded system in Beam and how to fight them. But you can apply ideas in it in other languages. Actually you can use actor model to design the system, but it will consist in Operating System processes instead of BEAM VM ones.

I would also recommend not to make the system too complicated - you will be grateful for it later. Possibly the simplest solution will work fine for you. The more moving parts you have - the more of them you need to support.

Hope it will help you, or at least give some ideas.

RomanKotov

RomanKotov

Hi thanks for a reply! I am glad that it helped a little bit :slight_smile:

So, as I understand you have something like this:

  • Multiple remote servers. They connect via TCP/IP to a central one.
  • Central server gathers information, converts it to the required format and stores it to the database.
  • You need to send alerts if the data is out of bounds.
  • You need to display data.

According to your description, it is not an ordinary HTTP server. I will be able to provide only general advice in this case. Possibly it will give you some insights. You know the requirements better and will be able to decide how to structure the system.

Regarding multiprocessing.

Here is a nice article about different ways to speedup Python code.

Types of tasks:

  • CPU-bound tasks - working with numbers, compressing images, transcoding video, etc. In simple words - the faster PC you have, the faster you get results. You can speed up processing by running multiple parallel tasks.
  • IO-bound tasks - writing/reading files, network requests, etc. You need to wait the response. Faster PC does not mean that you will get results faster. You can speed up this by multiple concurrent tasks.

Difference between processes and threads in Python:

  • Process - OS process. Has one thread by default (you can launch more later). Each Python process has a single GIL (at least now). GIL allows to execute Python code in only one thread at a time.
  • Thread - part of a process. All threads share the same GIL. One thread does some CPU work - other threads wait. If one thread does too much CPU-bound work it blocks other threads in the process, and it “freezes”.
  • Asyncio loop - Runs inside a single thread. “Event-based”. If you run too much CPU-bound tasks in a coroutine - it may block the whole loop (and thread).

Types of IO:

  • blocking - You opened a TCP socket. When you read from socket, the thread waits until you receive a message.
  • non-blocking - You opened a TCP socket. You can periodically poll a socket if it has some data. Or you can set timeout on a read/write operation. The code will throw error if received no data before timeout.
  • async - You opened a TCP socket. You want to read some data and “subscribe” (or “register a callback”) to “data available event”. Event loop does other jobs. When it sees a “data available event” from your socket, it runs your callback.

Major part of Python code and libraries use blocking IO (at least now).

So, here is a difference between between multithreading, multiple processes and asyncio:

  • Multiple processes - Each process is a separate OS process with a single thread (you can launch more threads later). It loads full Python (takes more RAM). Sharing memory is limited. Best for running multiple parallel CPU-bound tasks (Python GIL will not interfere here).
  • Multiple threads - They share the same process. Sharing memory is easier, but may lead to race conditions (use it with caution). You can use it to run multiple concurrent IO-bound tasks (many Python libraries support only this kind of operation).
  • Asyncio - coroutines share the same process. Has same memory and GIL issues as multiple threads. Best for async concurrent IO-bound tasks. Not all Python libraries support async features, use them with caution (they may block the whole event loop).

I gave a very high-level overview of how Python system works. If you have only one process, and some part takes too much CPU time - the whole system may freeze and stop responding. I would not recommend to run many background tasks. Also each process has a single GC - it can pause the whole system if it consumed too much memory. Sometimes Python leaks a memory, so GC is possible for active long-running processes.

Regarding Celery

Celery runs background tasks or . It starts a couple of “worker processes”. Each worker process subscribes to a “broker” - message queue (example brokers are Redis or RabbitMQ). Worker reads a task from the queue and processes it. You can put the task to the queue at any part of your application (even from another task). “Celery beat” may run scheduled tasks (like cronjob).

Regarding message passing between processes

I am not sure that I understand the reason to pass some datatypes between processes. Possibly there is a simpler approach.

Regarding Plotly Dash

I think it will be better to use Grafana here, if it fits you. Plotly Dash allows you to write dashboards in Python code. Used it for small-medium datasets, so have no experience if it scales well and fits your needs. Suggested it as an option (not as a strong recommendation), so you can try it.

My thoughts about possible architecture

Warning - this is not a call to action, but only a point of view.

As I understand, the task is to:

  • connect to the remote servers
  • periodically get some data from them
  • process a data and put it to the database
  • periodically check for outliers and send alerts

I think the system may consist of this parts:

  • “Connectors” - Each connector connects to a remote server. It gets a data, processes it and puts it into the database.
  • Periodic or background tasks - If you don’t need fast alerts, then possibly, you can periodically query the most recent from the database for outliers. This may be once-a-minute periodic job. If you do some heavy processing (it seems not in your case), then you may offload this to background jobs. General recommendation about background jobs - it is better to put the data into the database (possibly into other table) and launch specific job with record ID, instead of serialising the whole structure.
  • Alert system - Optional, if you need to report alerts as soon as possible.
  • GUI - You know the requirements better and can pick the one that fits your needs better. You can get the data directly from DB, instead of serialising objects between processes.

If you need a realtime updates, then you may create a message queue (possibly consider external one). If a connector sees the outlier, it publishes the id of the outlier into the “outliers” topic. Alert system reads this topic and takes does relevant actions.

So the algorithm like this:

  • Connector fetches the data.
  • Connector processes data and stores it in the database.
  • If the data is wrong, connector raises alert (possibly it can just put the data to another database table).
  • GUI takes the data from the database and displays it.

As I understand, you have a very similar solution:

  • You have asyncio connectors, that connect to the remote servers, fetch the data and put it into the database.
  • You can send commands to these connectors via asyncio.Queue.
  • Your future GUI shows the data from the database.

Again, you know your system, requirements and constraints better than me, so please, do not take any action unless it makes sense to you. My message is just an opinion, not an order or call to action. I can not read minds or debug systems/architecture without seeing a code.

I wish you to succeed with your system. Possibly this message was helpful for you.

bmitc

bmitc

Hi Roman,

I apologize for my delayed response. Thank you very much for such a detailed response and helpful pointers! I read it at the time you replied and kept doing some research. To provide a little more detail, here is a kind of architecture I’ve landed upon:

However, even that is a little bit of an old design, because as of yesterday, we no longer have a need to separate the GUI over a network, so it’s likely that the WebSocket server is no longer needed and can become some sort of internal worker process.

If server fetches data from many devices over the network, then this part should be as concurrent, as it can be. Running multiple Python processes will introduce overhead here, so possibly you will want to go with threads, or even asyncio-related libraries.

There are indeed a lot of external devices, primarily TCP/IP servers. So I have been landing on the idea of using asyncio for all (or at least most of) the TCP/IP requests with asyncio.Queues for message passing, treating the various asyncio streams clients as little workers. I think you are right that creating many Python processes introduces a lot of overhead (because of the additional network calls between the processes) and orchestration (because of having to rely on the OS to manage these) needed. And it also requires the need for serialization/deserialization between the processes, instead of using native Python datatypes and of course custom ones using dataclasses and typing.NamedTuple.

Between threading, asyncio, multiprocessing, and separate Python processes, I mainly landed on asyncio because of the very nice APIs it has (such as asyncio streams). For a threading solution, I probably would lean on Pykka since it is the actor model on top of Python’s threading, but I worry about the context switching performance hit. For multiprocessing, I considered a queue-based solution there, but I am unfamiliar with multiprocessing for IO-bound applications, and asyncio seemed to be the recommendation there. And as mentioned, the multiple, separate Python processes feels unwieldy, slower, and requires serialization/deserialization for datatypes.

Does that sound accurate to you? Or a valid approach and decision criteria?

If you need to show a raw data, then you can omit this step at all.

There is some processing, but it is mainly parsing the text-based responses into datatypes and then passing them around to a timeseries database and the GUI. There may be one or two procedures that we need with heavier calculations, but even there it shouldn’t be too much to handle. If so, then perhaps multiprocessing is the way to offload the calculation. The aggregation is primarily simply collating the data and then displaying and saving it. There will be some watchers that look for values out of bounds that then provide alerts. But such calculations are like “if outside of a given numeric range, then alert”.

I think in general, I have ruled out the need for and complexity of things like RabbitMQ, Celery, etc.

Also there is Plotly Dash - have used it to render dashboards. It also supports open-source version.

Thank you for mentioning that. I am aware of Plotly, having used it in F#, but I haven’t ever used Dash or looked into it much. Right now, the plan was to write data to a timeseries database (such as InfluxDB, QuestDB, or TimescaleDB), and then view that data with Grafana. How does Dash compare to that approach?

Thank you again, Roman! I greatly appreciate the time you put into your original post.

Last Post!

RomanKotov

RomanKotov

Regarding multiprocessing.

Elixir/Erlang can utilize all CPU cores and resources from a server. Standard Python is single-threaded. This means, that even if you have a beefy server with lots of CPU cores, it will use only one of them by default. I do not try to push you to use multiprocessing or other things. Just looking at it from resource utilization perspective. It is fine, if your server runs other applications except Python server - they will use some CPU.

I remember I load-tested a poorly performing Node.js application. We bought larger server for it, but it did not fixed the issue. It turned out, that the application used only a single core, and was limited by RAM. We tweaked it and the application started to use more resources.

My goal is not to force you to use multiprocessing, but to point to possible limitations. I think there are ways to use multiple processes (to use more resources and scale better) and not to use queues from multiprocessing at all. I would think if it is possible to partition all clients between multiple OS processes (not Python ones) without sharing any memory between them. I like thinking about the possible ways to scale application and creating architecture with this in mind (even if I will not use this option in future).

It is up to you whether to do something about it. Plan not to continue digging into this topic.

Regarding Thousand Islands vs Ranch

Both libraries heavily use standard :gen_tcp or :ssl modules from BEAM. Thousand Islands is heavily inspired by Ranch and use similar ideas. It is rewritten in Elixir and has less code (easier to understand).

I believe you can do the benchmark using any of these libraries, and it should work fine. But it may be slightly easier to configure and reason about the code from Thousand Island. This library is newer, so it is not so I believe it is not so heavily used as Ranch right now. More recent Phoenix versions migrated to it by default.

I had to tweak :gen_smtp once, and it relies on Ranch. It was quite difficult to dig into the Ranch codebase and understand what is going on.

If you already have Ranch-backed test service, then it is better to continue using it. If you want to create a small throwaway script to load-test your implementation - why not to try Thousand Island?

Personally I don’t have strong preference from one library over another. I believe both ones have edge cases.

Where Next?

Popular in Questions Top

rms.mrcs
Hi, I need to transform a list of numbers into a map where the keys are the indexes and the values are the original values of the list. ...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod – where is this set? Thanks.
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New

Other popular topics Top

JeremM34
Hello, how can I check the Phoenix version ? Thanks !
New
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 records...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
gshaw
What is the idiomatic way of matching for not nil in Elixir? E.g., First way: defp halt_if_not_signed_in(conn, signed_in_account) when...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New

We're in Beta

About us Mission Statement