From Erlang to Lunatic

Erlang

I've been working in the BEAM/ERTS ecosystem full time for around 4 years now. Spoiler alert, I enjoy Erlang. The production debugging experience compared to Java is amazing, the productivity feels like no other, and one can feel extremely safe while programming. 'Safe' in this sense means fearless pattern matching. 'Safe' means I can pull two elements out of a list without having to write two accesses and a bounds check and a try/catch. Safe like if I want to know if a list has two elements and the first element is equal to a I can just write:

validate_list([a, _]) -> true;
validate_list(_) -> false.


true = validate_list([a, b]),
false = validate_list([b, c, d]).

It feels safe, until you have to reason about un-typed data structures. Sometimes you'll look at a random proplists passed between 4 gen_fsm/gen_statems and wonder 'where did this go wrong'? Some of you non-Erlang folks may be wondering what I am talking about. Here is an example:

MyPets = [
    {dog, "Spot"},
    {cat, "Boo"},
    {bird, "Larry"}
].

% I rescued a Florida friend.
MyPetsNew = [{gator, "Belle"} | MyPets].

(Don't worry, there isn't any more code in this post.)

What is and isn't a valid pet? What if I end up owning a Zoo and keep adding / removing pets. What if this carried more semantic meaning than just pet types and their names. What if the list evolved based on logic.

In an application with an arbitrarily large proplist I'm sure original authors no-doubt started with a proplist that had 3-4 items which are easy enough to track. Then, it starts growing. It becomes the main data structure to pass critical information. There are multitude of 'excuses' that one can make (that I've made) that causes this list to really grow.

  • 'It is just one more field to add, the list is currently pretty small.'
  • 'This list is passed between a few processes, seems like the easiest way instead of changing a bunch of function definitions.'
  • 'This is a high priority P1 stop the world issue, I'll make this a record/struct/type later.'
  • 'Well there are 20 items here already, this can be refactored with a TODO comment and I'll add just one more.'

Add as many items to this list as you like.

I'm not claiming that these excuses are any different to, say, a well touched piece of Java / C++ / Python that has a function with 20 arguments that continually evolves over time. While the excuses for this organic growth are the same there is one key difference: a person reading the Java / C++ code an at a glance atleast see the beast that they're dealing with (maybe not Python, looking at you kwargs...). In Erlang it is oh so simple to build a proplists over 10 functions, each adding 2-3 elements, and in the end have a list of unknown length and values that eventually gets passed between 3 processes. By the time someone needs to debug this issue, they just happen to need to start in process 3 where the badarg or undef error appears in the logs, scratch their head at what in God's name they're looking at, trace back to process 2 that doesn't modify the list, trace back to process 1 and realize that the debugging endeavor has just started. (Then I start wading through the commit history more out of morbid curiosity than to actually help debug and play bingo with the excuse list above).

I hold no bitterness. What works, works. What doesn't gets refactored into a record or a map. These ever-growing proplists are just difficult to debug at times, but extremely convenient to write. It is very much a trade off, you burn up-front productivity for long term maintenance. I wouldn't go so far as to say a prototype first mentality, but often micro-YAGNI comes into play. Not every proplists needs to be a record, and making them all records to start with becomes cumbersome fast.

All of that being said I started searching for answers to half-formed questions. I like types. Know what fixes the above? Types and records. Tools like Gradualizer are being introduced and evolving, which is great! Know what isn't great? Running Gradualizer for the first time and seeing all the errors. Errors stemming from using the Process Dictionary and not handling the undefined return results everywhere (which you know won't happen, right. Right? Right.) There are answers to these problems. They just take time, effort, refactoring, iteration. But things just happen to work even without records and types. It is hard to justify spending the time making things 'better' when things are most certainly not broken. When you can only come up with...4? 5? cases where this slightly expensive refactoring actually helped, does it justify spending a few moons better typing a host of type issues on a significant code-base? Inevitably no.

Rust

Anyways, I wanted to expand my horizons. I tried out Gleam and realized 'wow I should really try Rust'. Not knocking Gleam but I used it very early on, back when it didn't really have it's own gen_server equivalent and quite a bit of it was typed-shims (it could still be that, I don't really know). So I decided to try Rust.

By the time I actually got around to trying Rust out I read about Lunatic, an Erlang style runtime. Rust (types, safety, happiness) with Lunatic (the Erlang mindset) on WebAssembly (the F U T U R E) sounded like a good learning experience at the least and at the most something I could try to hone in the background for the future.

What has turned into a simple test has blossomed into an even deeper love/hate relationship to what I have with Erlang. I'm going to detail my hate and then detail my love. I do greatly enjoy this combo don't get me wrong, and don't take offense to the hatred first. A spoon full of medicine is going to make the sugar go down easier. I've been lurking on lobste.rs for quite a while so these Rust opinons might just be subconsciously regurgitated and reinforced during my learning. I'm damn sure I'm not the first person to feel this way before. I've also only been using Rust on the weekends for ~4 months? So these are really just a chronicle of my first thoughts after writing more Rust than I ever set out to.

The Hate

Rust Is Hard

Rust is hard. Rust is especially hard if you haven't had to work with seriously statically typed code in a loooong time. It takes forever to get anything to compile. I'm working on a project with ~45 files right now, and I'm still in the phases of laying the groundwork so often sweeping changes need to be made. This involves wading through iterations of pleasing the compiler. I don't want to think ahead about what my types should be, which causes me to make shitty choices. I've repeated a dirty pattern of using an Enum of Structs and having 30 match contexts. It was easy to do that when there were 4 structs. Now there are 4 'parent' Enums with 10-15 'child' structs each and it shows that I've made a foolish decision. In hindsight I should have considered Traits earlier on. However, as I started I thought to myself "It doesn't make a ton of sense to lock myself into an interface this early, lets see how this evolves with more uses across more modules." I was wrong. I'm now reaping the bitter barley that I have sown. These aren't Rust problems, they're me problems and I'm fine with that.

Blah blah blah Borrow Checker. Know what solves that? Writing .to_string() on every String and String::from on every &str. Writing .clone() on every Vector and leaving a comment that says // Shame. Sometimes I'll get it right on the first pass and it'll just work. I have found that there is a very concious decision I need to make: do I want to do something correctly or do I want to feel like I'm making progress on my project. Ultimately I want to make progress. Sometimes I'm writing &**my_thing, at a fault of my own because of having to Box all my types.

Speaking of Boxing All My Types.

Boxing All My Types

I get why. I get the heap, I get the stack, I get that everything needs to be calculated at runtime. I'm using a recursive tree-like data structure. I want to rip my fucking hair out every time I write another Box<AstNode>.

abstract_process

And now I get to something I hate about Lunatic. I miss my gen_server. I miss my gen_statem. This is more of a complaint about an underdeveloped lunatic-rs ecosystem compared to an actual gripe about Lunatic its self. That being said modeling state transitions with Lunatic (at the time of writing) is not easy. One could ask 'are you using the right tool for the job', and that one person can jump off a cliff. To model a valid network protocol yes, the abstract_process macro is a great tool given you can match on an Enum and push all of the logic into a helper function. I have no problem with that, but it is the raw Erlang pattern matching in a function head that I am missing. The extra step of writing a match is painful. As I write this I hear myself speaking in Violet Beauregarde's voice, but it is how I feel.

The Love

Rust Is Hard

I had an epiphany after I got my first semi-complex piece of code to compile. I was dreading running it. It took so long to get it compiling. I was mentally prepping to debug it. Aaaand it just worked. Every unit test passed. I was actually at a loss for words.

In Erlang, I feel more and more that I write the code then spend all my time testing it. Testing for failure in different ways, testing for the 'what-ifs' of the input. With Rust, it seems to 'just work'. I'm talking before logic based mistakes come into play.

It is painful, but I feel like I have a deep trust for my Rust code that I just can't feel for my Erlang in many cases. I didn't have this feeling before, the distrust came after the epiphany. It is like finding my milk is slightly spoiled.

It does feel like this is a great learning experience for newer language features and design.

Bubbling Up Errors

?. I love ?. Just pass the Result<> up. It is beautiful, especially if you define a project specific error type. Huge fan.

I didn't love .unwrap()ing everything at first. I was almost debating adding unwrap to the hates, but I just get so tickled writing ? everywhere and knowing I don't need to write some kind of if-statement that I ripped it out of the Hate section all together.

Match Contexts

I'm not sure if you could tell but I stan pattern matching. Going into Rust I had no idea that match existed and I nearly wept. I had written a bit of Python recently and the entire time I was thinking 'how nifty would this be with pattern matching'. Until I realized, Python has pattern matching and I just didn't know about it?. To be fair, I feel like that came out just as I started the nba-sql project.

Regardless, match is awesome and was an unexpected present.

Let It Crash

I love writing unreachable!(). I love writing todo!(). And I love that Lunatic will tell me exactly where I have yet to implement something in a way that also satisfies the compiler. Especially as a fallback in the match spec. It Lunatic's philosophy of killing the current process when necessary is amazing to abuse.

This is a bit contradictory to the 'Bubbling Up Errors' section, because sure many of those unreachables could be a catch all error, but the fact that I don't need to care if a user session panics gives peace of mind.

This really embodies the 'best of both worlds'. The up-front work of Rust for type safety coupled with the Erlang runtime philosophy feels like when Volvo released the 3 point seatbelt. This is a level of confidence I didn't realized I could find outside of maybe Java.

abstract_process

The abstract_process process macro is actually amazing, completely ignore the Hate section on it above. This is a well designed macro to generate the behavior without having to write all the boilerplate. It feels very productive to use this as opposed to most of the other Rust I've been writing. It is a wonderful high level wrapper once the lower level process APIs are understood and how mailboxes and the like come into play. I don't have a ton more to say about it, I've created several named processes in the supervision tree and it really does feel extremely Erlang-esque. Again the 'best of both worlds' keeps coming up for me.

The Lunatic Community

The Lunatic discord is amazing. It is a friendly and helpful bunch and the core team is always willing to answer questions and point to the documentation. Many interesting conversations happen in there, too.

The ecosystem is also progressing with practical libraries. submillisecond is a great backend webframework. It is the perfect way to launch an isolated user session process and has all the batteries included. Much like the abstract_process the router macro is well thought out and intuitive to use. I'm excited to see what else is being developed for the future.

Conclusion

I was looking to learn about type safety, and how to avoid pitfalls that make runtime debugging difficult. I instead found even more pitfalls in typing and frustration with a compiler to a degree that I never knew could exist. But I'm happy I found these. I really enjoy Lunatic and I feel that one day it will be a viable alternative to the ERTS. The added safety of Rust, the Rust std library, and existing repository of libraries make it feel much more mature. I guess that is the benefit to creating just the runtime instead of a new language + runtime together. The Lunatic community is amazing, the documentation is great, there are are usually examples for nearly everything. My project will continue to rely on it as a runtime for the foreseeable future.


2492 Words

2023-02-10