Incrementally moving to Rust

We at Pratilipi Comics are slowly adopting Rust in our work

Kaustubh Patange
Team Pratilipi
13 min readAug 10, 2022

--

Photo by Ocean Ng on Unsplash

As developers, the one thing we don’t have is time. When working on a feature, usually we ignore the code-style, best practices & rush to develop these features as fast as we can to complete the task in the given time. This results in buggy, untested code that gets shipped onto the user’s device who faces them (at most a crash) which in turn creates a bad experience for them. The only way we get to know about these crashes is by using crash monitoring tools like Crashlytics, Bugsnag, Sentry, etc. where we try to identify what part of code has caused this crash, run a git blame & blame that person! Just kidding, we then try to reproduce it on our device, come up with a patch & try to fix it. Usually, it gets fixed but as said maintaining software is hard so you might have to go with trial & error methods to fix a particular annoying crash that woke you up at 4 in the morning.

Can you identify what could’ve been done to prevent these bugs from ever getting shipped in the production? Some people would suggest removing this time-constraint barrier so that they can work without pressure & focus more on code & testing it. However, this cannot be always possible. I’ll tell you why, when you work in an organization or a startup with a small team like 10–15 engineers it is not always feasible to work slowly, what you want is a rapid growth of product so yes things will break & you can’t do anything unless hire a QA person/team to test your code changes but even doing so it won’t prevent you from writing buggy code.

From the dawn of time when programming languages were evolving to improve developer productivity with things like better type-system, IDEs, and compile-time safety someone thought hey why not just develop a language that is very simple to learn & write but has no types (except primitive), no runtime errors & unexpected behaviors when doing basic comparison using == , yes I’m talking about Javascript.

Consider the above example, where we write a function to add 1 to a number. Sure this will arise a lot of questions in your mind. Will “n” always be the number? What if n is null, how are we handling that? What is the return type of the function? All of these assumptions we’ve to keep in mind even though we are just writing a function to increment a number by 1.

Even introducing TypeScript in the project & relying on TypeScript’s type system is not a 100% bullet-proof solution. In my opinion, the greatest disadvantage of TypeScript is that it can bring you a false sense of security. Yes, it’s a huge benefit that the language can check types for us and warn us when there’s something wrong with our code. However, relying on this too heavily comes with significant risk. TypeScript performs type checks only during compilation. Afterward, we’re dealing with pure JavaScript that doesn’t do that. This means we may still encounter some bugs that the compiler didn’t find, though admittedly there are going to be far fewer of them than if we hadn’t used TypeScript.

Rust will make you feel like a Genius

One of the things I like about Rust is its compile-time safety. The errors that the compiler spit out are one of the nicest things I’ve ever seen.

When developing in rust it almost feels like there is a tour guide who is always keeping a watch on you & corrects you if you did something wrong unknowingly. This system of communication between a developer & compiler is one of the things I like about Rust.

Rust is both statically-typed and strongly-typed which means the compiler knows about the type during compile time rather than dynamically inferring types at runtime. By default, variables in Rust are immutable (cannot be modified once assigned), you can make them mutable by adding a keyword mut . This default immutability can help you save tons of debugging later when you try to identify which thread had modified your object. There are no classes/interfaces in Rust but something called as struct & trait .

But wait, hold on if there are no classes how can we do object-oriented programming in Rust? The thing about Rust is that it nearly changes your programming paradigm i.e you’ve to unlearn a lot of things not because they are wrong but languages like Java, and Python hides all these implementations from you. We are so used to encapsulating our logic behind a class that we forgot about the primitives we came from. Now, to answer the question take a look at the following code,

All we need is a series id to calculate no. of likes & comments count. As you can see the code is pretty straightforward there is no unnecessary allocations & each implementation is separated by a trait thus maintaining separation of concern. For someone, it may look like we’ve declared an extension function on an object which is a correct assumption that’s why we don’t see :: (path separator) because those methods have become part of the structure. When coming from languages like Java this may seem a little weird (don’t worry I felt the same) but once you hop on a journey to learn Rust trust me it will never be the same :)

In Rust, you don’t necessarily create structures based on your logic instead you act on it. This helps to incorporate the single responsibility principle into your codebase without you knowing it. Things like this make Rust a perfect language for almost any stack. Yes, it has a learning curve & some people might find it difficult as we are so used to OOP that we forget about the fundamentals. One such example is managing memory ourselves, meaning allocating & deallocated objects. How many of you know about malloc ? I’m sure most of us have. The main catch with malloc is that you need to manually free the memory when you don’t need it. What if I told you in Rust, this is done automatically not at the runtime but at the compile time.

You must’ve heard that Rust does not have a garbage collector but what does this mean? When you run a program in languages like Java, Python, etc. a small program will run alongside to free up your object allocation. This is done in two phases, Mark & Sweep. Garbage collector (GC) will mark objects which aren’t used i.e there is no pathway to reach the given object, then it Sweeps (remove) the object that is identified earlier. This approach has many advantages but also comes with certain disadvantages that should not be overlooked,

  • Since the runtime has to keep track of object reference creation/deletion, this activity requires more CPU power than the original application. It may affect the performance of requests which require large memory.
  • Programmers have no control over the scheduling of CPU time dedicated to freeing objects that are no longer needed.
  • Using some GC implementations might result in the application stopping unpredictably.
  • Automatized memory management won’t be as efficient as the proper manual memory allocation/deallocation.

In Rust automatized memory management is solved through a concept called borrow-checker. The rules are very simple,

  1. Data has one owner.
  2. Data may have multiple readers or one writer.

Let’s see this in an example, Joey & Amanda are two people who wants to eat a single pizza,

Joey loves pizza but he doesn’t want to share it with Amanda

What happened why can’t Amanda eat the same pizza? Well for starters we should stay away from Joey because “He doesn’t share his food” :)

In Rust, we have primitives like pass-by-reference (&) called borrowing & pass-by-value called transferring the ownership. In main method, the lifetime of the variable pizza, joey, amanda is bounded by the scope defined by the curly braces { } which means once this method completes Rust will automatically free up the memory allocated by pizza, joey, amanda. In the same method, we are also adding toppings & eating pizza. Both take the first argument as Pizza but one takes it by reference & the other by value. When passing the value by reference we are guaranteed that despite any modification happening to pizza the pointer to the memory address where pizza lives will not change. Hence, Rust allows multiple borrowing. This is not the case with passing pizza by value, one can modify the original reference to the pizza & could do anything with it (you can see that in eat method where we are assigning a new value). Hence, the main method is not sure whether the reference it has is valid or not & that’s why Rust throws a compile-time error.

Not only does this ensures code correctness but it also provides some runtime benefits like performance. Yes, Rust is faster in fact in many cases it can be faster than C. Take an example from this article, where Eugene Retunsky implemented TCP proxy servers in various languages & benchmarked them.

https://medium.com/star-gazers/benchmarking-low-level-i-o-c-c-rust-golang-java-python-9a0d505f85f7
  • The blue line is tail latency (Y-axis on the left) — the lower, the better.
  • The grey bars are throughput (Y-axis on the right) — the higher, the better.

Rust can definitely be used in a backend service & you can yourself see the performance gain it brings. Even by comparing web frameworks on techempower.com/benchmarks, Rust’s xitca-web, salvo, may-minihttp, actix frameworks are in the top 10.

For anyone coming from node.js background I’ve got good news, Rust’s toolchain is very similar to that of node.js.

  • cargo — packaging, building (similar to npm)
  • cargo fmt — standard formatting (similar to tsfmt)
  • cargo test — doc & unit tests
  • cargo bench — benchmarking
  • cargo clippy — code listing (similar to eslint or tslint)
  • rustup — rust version switching (similar to nvm)

For the past 16 years, the Rust community has evolved so much that the language has become the most popular language on stackoverflow.com.

https://insights.stackoverflow.com/survey/2021#most-loved-dreaded-and-wanted-language-love-dread

Rust at work

Instead of rewriting all your microservices in Rust, we are going to slowly adopt it. At Pratilipi Comics, we experiment a lot & one of such is when we wrote a cron-script in Rust. Yes, the title of this article says we are doing it in a production environment but that is not far, we will surely migrate to Rust for our backend services if that seems feasible in the near future.

As for now, we are using it for writing CronJobs that are deployed on Kubernetes. A cron job is a job that is scheduled to run at a specific time defined by the cron itself. One of the scripts that we deployed is the Category/Genre automation script, we have an optimized algorithm that calculates the top series for a genre based on likes, subscriptions & reviews. The problem here is that executing this algorithm takes a good amount of time, so instead of running it every time whenever a user sends a request we store it in a Redis cache & update the cache by this cron job. So, any incoming request will fetch the response from this cache & process accordingly.

Now since you understood the whole process let’s see how this script is written. The algorithm I talked about is basically a long SQL query acting upon a MySQL database. So we need a library to connect & execute a query on our MySQL database. In Rust, a library is called a crate. There are two types of crates, binary crate & library crate. Binary crates are programs that are compiled to an executable so that they can run on any platform. Library crates don’t have a main function, and they don’t compile to an executable. They define functionality intended to be shared with multiple projects for eg: mysql is a crate that provides MySQL database driver in pure Rust. We are also going to use reqwest which is a batteries included HTTP client for Rust, mini-redis for Redis & yes finally we will use an asynchronous runtime called tokio. Note that I said a runtime, not a framework because async/await primitives are built in Rust & tokio is an abstraction over it with a runtime that provides the building blocks needed for writing asynchronous applications + a lot of features for writing multi-threaded applications.

A Rust project has Cargo.toml file (a manifest for a package) where you will specify all your configuration (like package.json in node.js). In the dependencies section, you can add any library crates you want.

cargo.toml

Once done, similar to npm install you run cargo update which will pull up the dependencies in the cargo registry. cargo run to run the executable & cargo build to build an executable for your platform.

package-structure

The package structure for our cron job script looks like the above, there is no specific package structure we’ve followed because it is just a script & not a full fledge application. There is main.rs which has the main function that makes use of modules defined in internals package. In Rust, there is a module system, basically here each file is a module which has a trait that executes specific logic for eg: cache.rs abstracts Redis cache implementation & expose APIs to be consumed, in the same way comics_db.rs abstracts the database implementation & fevicol.rswhich is the name of one of our microservices that deals with many things including likes, reviews & comments. All of them expose APIs to be consumed by our main.rs.

For this application we followed a pattern inspired by UDF (Uni-directional Data Flow), in our case, data i.e object references are passed to the modules & errors are propagated up (only if a module fails to mitigate the issue). Such errors which cannot be recovered are thrown i.e panic ed. This is useful in cases where suppose if the program fails to initialize mysql or redis then there is no need to proceed with the current execution as everything depends on it. There are many programming patterns but I find this to make the most sense.

By following this pattern each module has its own responsibility to do something with the data or return the processed data, this encapsulates logic from external modification thus ensuring the “single responsibility principle”. Let’s take an example of fevicol.rs where we fetch the list of genre names.

From here you can see we are making an API call, just look at the code even if you don’t know how to code in Rust you can easily decode the logic. Notice the return type Result<GenreData, String> . io::std::Result<T, E> is an enum with Ok(T) and Err(E) values where you can return whether the return response is successful or not (similar to Kotlin’s Result<T> class). In our case the error type is String , as I said before when the error is unrecoverable we stop the process (exit code 1).

We are also using a JSON serialization/deserialization crate called serde. GenreData & GenreItem are our types into which the response JSON will be deserialized. You can also omit any unwanted properties, that’s why here we’re only interested in id & name. #derive[...] is a macro. Just like we’ve annotations & annotation processors in JVM, Rust’s macro system is their approach for Meta-programming.

Finally, we glue all this together in our main function,

Concurrency in Rust is so clean & easy to use. Each await is a Future<T> (similar to Promise in Javascript or Job in Kotlin coroutines), so we can chain & execute them in parallel; future::join_all does that for us (similar to Promise.all or awaitAll in Kotlin coroutines).

Edit: mini-redis does not support selecting database numbers, as you know Redis has 0–15 database instances & we use 11 to store this cache. Also, mini-redis does not support executing custom commands where after the connection we could execute SELECT db command by ourselves. Due to such issues with mini-redis, it has been replaced with redis.

Conclusion

With great power, there also comes great responsibility; the same holds true for Rust’s memory management. Rust can only perform these memory optimizations within Rust code so any second you pull any 3rd party C/C++ library you are on your own to write an unsafe code that could potentially break. The good news is, that there are many libraries that are rewritten in Rust by the community so you’ll hardly have to ever write unsafe code unless you are touching OS-level bindings.

Even though the compile-time safety is so much great in Rust it takes a significant amount of time to compile the code, this could slow down your developer productivity.

One of the benefits of Rust is that it outputs a very small single executable (in release mode with strip enabled) but not so for the intermediate build files. During build time the size of these temporary build files can go up to 2 GB in size. This is one of the problems we faced because our cron machine has a max 8 GB space where spitting out this 2 GB build is not great every time you run cargo build . The good news is, that with --release flag enabled this shrinks down to 300 MB but is still much larger. What we’ve done to mitigate this issue is we wrote a shell script that builds & stores the executable upon the first run so that any subsequent run will use the same executable. This shell script additionally does one more job which is to rebuild the executable if there is a change in the source code (since we store all the sources in the cron machine for now), this is done by comparing md5 hashes of files with the previous one which was stored when the build was run for the first time. You can take a look at the code of the script here.

Lastly, I want to say that Rust has so many benefits & to me personally, it feels like a perfect language designed for developers not only just to write CLI tools but also for backend & other stacks. If you are still unsure whether to start learning Rust, just take a look at the projects on Github that are built with Rust.

--

--