<?xml version="1.0" encoding="UTF-8" ?>
<?xml-stylesheet type="text/xsl" href="/rss.xsl" media="all"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Roastidio.us Tagged with elixir</title>
<link>https://roastidio.us/tag/2771</link>
<atom:link href="https://roastidio.us/tagged_with/elixir" rel="self" type="application/rss+xml"></atom:link>
<description>Roastidio.us Tagged with elixir</description>
<item>
<title>Exploring Programming Languages</title>
<link>https://rocket-science.ru/hacking/2026/06/17/programming-languages</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">I-hd2LBCOKMOJ49cN7a-yP_hgbeZMl7Dr-EX3A==</guid>
<pubDate>Fri, 19 Jun 2026 18:28:26 +0000</pubDate>
<description>True story of the developer exploring different programming languages</description>
<content:encoded>&lt;div&gt;&lt;a href=&quot;https://soundcloud.com/nott-lovland&quot;&gt;Nott Løvland&lt;/a&gt; · &lt;a href=&quot;https://soundcloud.com/nott-lovland/exploring-languages&quot;&gt;Exploring Languages&lt;/a&gt;&lt;/div&gt;&lt;p&gt;In the beginning, I sought to be wise,&lt;br/&gt;
with a language that opened my digital eyes.&lt;br/&gt;
I started with LISP, for the pure of the mind,&lt;br/&gt;
But spent three whole weeks trying pairs to unwind.&lt;/p&gt;&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(((((((Are (we) (sure) (this) (is) (right?))))))))&lt;/code&gt;&lt;br/&gt;
Parentheses blinded my aching eyesight.&lt;/p&gt;&lt;p&gt;So I fled to the past, where the money was made,&lt;br/&gt;
and woke up in COBOL, deeply afraid.&lt;br/&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IDENTIFICATION DIVISION&lt;/code&gt; roared in my head,&lt;br/&gt;
with columns and margins, I wished I were dead.&lt;br/&gt;
It’s great for a bank in the year seventy-nine,&lt;br/&gt;
But a thousand lines later, I still couldn’t sign.&lt;/p&gt;&lt;p&gt;“Let’s try something hip!” I exclaimed with a twirl,&lt;br/&gt;
and drowned in a bucket of regex and Perl.&lt;br/&gt;
A script that looked exactly like line-noise and spit,&lt;br/&gt;
It ran, but God help me, I can’t read a bit.&lt;br/&gt;
Is that a variable, or did my cat walk&lt;br/&gt;
across the keyboard while trying to talk?&lt;/p&gt;&lt;p&gt;Then came Python, the savior, the clean, and the bright!&lt;br/&gt;
Until a stray space ruined my day and my night.&lt;br/&gt;
“Indentation Error,” the compiler did shriek,&lt;br/&gt;
because of one tab in the middle of the week.&lt;br/&gt;
Don’t get me even started on packaging hell,&lt;br/&gt;
where pip and venv cast a curse and a spell.&lt;/p&gt;&lt;p&gt;I jumped into Ruby, for joy and for love,&lt;br/&gt;
with blocks and with gems sent from heaven above.&lt;br/&gt;
But &lt;em&gt;Monkey Patching&lt;/em&gt; turned the code to a zoo,&lt;br/&gt;
When a library changed what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2 + 2&lt;/code&gt; do.&lt;br/&gt;
It was beautiful, sure, but it ran like a snail,&lt;br/&gt;
Chugging along on a rusted old rail.&lt;/p&gt;&lt;p&gt;So I went corporate. Enter Java, the grand.&lt;br/&gt;
The boilerplate king of the enterprise land!&lt;br/&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AbstractMethodFactoryProviderBean&lt;/code&gt;&lt;br/&gt;
—was the shortest class name that I ever had seen.&lt;br/&gt;
I typed until my fingers were bleeding and numb,&lt;br/&gt;
just to print out “Hello” to a world that was glum.&lt;/p&gt;&lt;p&gt;“To the web!” I declared, and embraced JavaScript,&lt;br/&gt;
where logic is warped and reality’s flipped.&lt;br/&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[] == ![]&lt;/code&gt; evaluated to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;true&lt;/code&gt;,&lt;br/&gt;
and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NaN&lt;/code&gt; is a number? I’m sorry, that’s rude!&lt;br/&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;undefined&lt;/code&gt; is not a function, it cried in my face,&lt;br/&gt;
as npm bloated and swallowed my disk space.&lt;/p&gt;&lt;p&gt;So I looked for speed, and Golang caught my eye,&lt;br/&gt;
“It’s simple!” they promised, “Just give it a try!”&lt;br/&gt;
But &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if err != nil&lt;/code&gt; was on every damn line,&lt;br/&gt;
an endless repetition of error design.&lt;br/&gt;
No generics (at first), just copy and paste,&lt;br/&gt;
a minimalist’s dream, and a developer’s waste.&lt;/p&gt;&lt;p&gt;Then Rust was the answer, the savior of tech!&lt;br/&gt;
If the Borrow Checker didn’t snap my poor neck.&lt;br/&gt;
“You can’t use lifetimes! This memory’s owned!”&lt;br/&gt;
I sat at my desk, thoroughly powned.&lt;br/&gt;
I fought with the compiler for hours on end,&lt;br/&gt;
until I forgot why I coded, my friend.&lt;/p&gt;&lt;p&gt;Then out of the ashes, a phoenix arose,&lt;br/&gt;
embraced by Erlang, in functional prose.&lt;br/&gt;
Elixir! Oh, sweet elixir of life,&lt;br/&gt;
you banished my sorrow, you ended my strife.&lt;br/&gt;
With pattern matching so clean and so neat,&lt;br/&gt;
and pipe operators (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;|&amp;gt;&lt;/code&gt;) that make life a treat.&lt;br/&gt;
The BEAM handles millions of actors with grace,&lt;br/&gt;
while I sit with a massive, smug smile on my face.&lt;/p&gt;&lt;p&gt;Let the servers all crash! Let the supervisors play!&lt;br/&gt;
I’m finally happy. Go away, anyway.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Elixir for a Bluesky DataPlane: the choice we didn&#39;t expect</title>
<link>https://bitcrowd.dev/why-elixir-bluesky-dataplane</link>
<guid isPermaLink="false">PPIi53N8Uk9a7v2fua6aPKkXFhgOr__Zl3jHfg==</guid>
<pubDate>Thu, 18 Jun 2026 20:30:01 +0000</pubDate>
<description>Why we chose Elixir over Go, Rust and Node for a high-performance Bluesky DataPlane - and how a small Rust NIF and in-process fan-out made it the right fit.</description>
<content:encoded>&lt;p&gt;Bluesky&amp;#39;s source code is widely open source, so you can run your own social
network with it. - Provided you stay with a comparably small user base. What&amp;#39;s
missing? A performant DataPlane implementation. Closing this gap would be an
important step towards building digital independence.&lt;/p&gt;&lt;p&gt;We wanted to contribute our share and decided to work on a performant DataPlane
for Bluesky. When we started the project, we expected to work in Go, Rust or
even Node. After all, these were the predominant languages in the community.
Instead, we landed with Elixir. Here is why and how we came to that decision.&lt;/p&gt;&lt;h2&gt;The component nobody knows about&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-component-nobody-knows-about&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Everyone who has looked into the Bluesky infrastructure knows about the Personal
Data Stores (PDS), the Relay or the AppView. When you set up your own little
Bluesky from the
&lt;a href=&quot;https://github.com/bluesky-social/atproto&quot;&gt;ATProto repository&lt;/a&gt;, you run the
same components as Bluesky, the commercial service. However, there&amp;#39;s one notable
exception: the &lt;em&gt;DataPlane&lt;/em&gt;, a part of the AppView. It exists publicly only as a
Node-and-Postgres reference implementation, while the production Bluesky network
runs on a dedicated, &lt;a href=&quot;https://www.scylladb.com/&quot;&gt;ScyllaDB&lt;/a&gt;-backed, closed-source
DataPlane (as documented in
&lt;a href=&quot;https://newsletter.pragmaticengineer.com/i/114113498/5-scaling-the-database-layer&quot;&gt;Pragmatic Engineer&amp;#39;s deep-dive on Bluesky&amp;#39;s architecture&lt;/a&gt;).&lt;/p&gt;&lt;p&gt;That gap is exactly where things get interesting. The reference implementation
tells you what the DataPlane &lt;em&gt;does&lt;/em&gt;; it doesn&amp;#39;t tell you how to make it survive
contact with real traffic.&lt;/p&gt;&lt;p&gt;If you want to run your own, you have to answer the scaling question yourself -
and the first step is understanding the workload well enough to stop treating it
as one thing.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/bluesky-5fcedfef78dd330f8c008f30b0457a02.png&quot; alt=&quot;Bluesky architecture overview&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The DataPlane is a central component of the AppView in Bluesky&amp;#39;s architecture. Read the&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;previous post&lt;/a&gt;to learn more.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;ScyllaDB is Bluesky&amp;#39;s operational choice, not part of the interface. The
DataPlane&amp;#39;s contract is a gRPC service that answers high-volume, low-complexity
queries and returns &lt;em&gt;skeletons&lt;/em&gt; - lists of IDs, counts, booleans - which a
higher layer later hydrates into full views. What sits behind that contract is
entirely up to you: the language, and the datastore. So before picking either,
we spent our time on the only thing that actually constrains the choice: the
shape of the load.&lt;/p&gt;&lt;h2&gt;A tale of two workloads&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#a-tale-of-two-workloads&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The Bluesky&amp;#39;s historical data is enormous — terabytes. But when a user opens the
app, they read a few dozen recent posts, get distracted, and wander off to a
profile or a thread. They almost never scroll back further than a day or two.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;A note on specifics:
&lt;a href=&quot;https://jazco.dev/2025/02/19/imperfection/&quot;&gt;it&amp;#39;s been observed&lt;/a&gt; that
Bluesky&amp;#39;s timeline doesn&amp;#39;t serve much beyond the last day or two of content,
and that deeper cursor positions tend to fill with very recent posts rather
than true history. Treat the exact window as illustrative unless you&amp;#39;ve
measured it on your own deployment - the architectural point holds regardless
of the precise number.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;This leads to an interesting disparity: Data that is more than a few days old is
almost never accessed in the timeline. When it is accessed, it is usually
through a profile or a thread. Yet the reference implementation compiles every
timeline by joining those tables, which are terabyte-scale. This process is slow
and becomes slower as the tables grow, and timeline requests account for most of
what the DataPlane is asked to do.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/inversion-7f5340cbaf0f4d018152cd5285cf78ca.png&quot; alt=&quot;Inversion of data volume and access frequency&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;Data volume and read frequency are inversely related: the vast bulk of data is old and almost never read, while the tiny sliver of recent posts drives nearly all timeline traffic.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;So timeline generation deserves a different treatment from record and thread
retrieval. The two workloads don&amp;#39;t just differ in degree. They have opposite
resource profiles, and they want different things from the runtime underneath.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;strong&gt;Hot path&lt;/strong&gt; - timelines&lt;/th&gt;&lt;th&gt;&lt;strong&gt;Cold path&lt;/strong&gt; - records, threads, profiles&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Data age&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Recent (last day or two)&lt;/td&gt;&lt;td&gt;Historic (anything older)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Data volume&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;A tiny sliver&lt;/td&gt;&lt;td&gt;Terabytes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Request share&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;The dominant workload&lt;/td&gt;&lt;td&gt;Comparatively rare&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Bound by&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Memory + compute&lt;/td&gt;&lt;td&gt;I/O&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Lives in&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Memory&lt;/td&gt;&lt;td&gt;Database, fetched on demand&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Strategy&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Fan-out + bounded timeline length&lt;/td&gt;&lt;td&gt;Swappable datastore behind an interface&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h2&gt;The hot path: timelines served from memory&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-hot-path-timelines-served-from-memory&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Most social platforms use a hybrid strategy to handle timeline fan-out. A post
from an account with a few thousand followers is pushed into its followers&amp;#39;
timelines as soon as it is published. This is known as &lt;strong&gt;fan-out on write&lt;/strong&gt; in
the lingo.&lt;/p&gt;&lt;p&gt;However, a post from an account with millions of followers (&lt;em&gt;&amp;quot;the celebrities&amp;quot;&lt;/em&gt;)
is not immediately pushed to all of its followers&amp;#39; mailboxes; instead, it is
merged in when a follower actually requests their timeline: This is called
&lt;strong&gt;fan-in on read&lt;/strong&gt;. The former keeps write amplification bounded for ordinary
accounts. The second prevents the write storm that a celebrity post would
otherwise cause.&lt;/p&gt;&lt;p&gt;Another obvious simplification becomes apparent when you stop thinking of a
social feed as an immutable archive. Take into account that users will jump
around with their attention and you will realise that timelines are finite.
Therefore, a user who follows ten thousand accounts will never see all of their
posts anyway. There&amp;#39;s reasonable to limit how much content is distributed to any
single timeline. The obligation is to provide a good, recent, bounded timeline,
not a complete one.&lt;/p&gt;&lt;p&gt;What this boils down to:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Recent posts - the overwhelming majority of what timelines are made of - can
live and be served &lt;strong&gt;from memory&lt;/strong&gt;.&lt;/li&gt;&lt;li&gt;Fan-out can be &lt;strong&gt;deferred&lt;/strong&gt;: as long as posts land in followers&amp;#39; timelines
within minutes, and the backlog of fan-out jobs doesn&amp;#39;t outgrow available
resources, nobody notices the delay.&lt;/li&gt;&lt;li&gt;Older content, when it&amp;#39;s genuinely needed, is safe to fetch from the database
on demand.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The dominant request type, the timeline read, stops being I/O-bound (wait on a
giant join) and becomes memory-and-compute-bound (manipulate in-memory
structures quickly). That shift changes what &amp;quot;fast&amp;quot; means, and it&amp;#39;s what
reopened the language question for us.&lt;/p&gt;&lt;p&gt;It also argues for putting the concrete database behind an interface. The hot
path barely touches it; the cold path is the only part that leans on it. Keep
that boundary clean and you can swap the backing store later without touching
the rest of the system.&lt;/p&gt;&lt;h3&gt;The follower graph: in memory, but not naively&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-follower-graph-in-memory-but-not-naively&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;There&amp;#39;s a catch. Fan-out and timeline assembly both need answers about the
follow graph: who follows whom, and the intersections and unions of those sets.
Holding hundreds of millions of follow relationships in memory as hash maps
would be ruinously wasteful.&lt;/p&gt;&lt;p&gt;Jaz&amp;#39;s &lt;a href=&quot;https://jazco.dev/2024/04/15/in-memory-graphs/&quot;&gt;&amp;quot;GraphD&amp;quot; series&lt;/a&gt;
documents this exact problem: an in-memory graph store that originally used hash
maps and hash sets to track each user&amp;#39;s followers and follows. The fix was
switching to &lt;a href=&quot;https://roaringbitmap.org/&quot;&gt;Roaring Bitmaps&lt;/a&gt;, a
&lt;a href=&quot;https://vikramoberoi.com/posts/a-primer-on-roaring-bitmaps-what-they-are-and-how-they-work/&quot;&gt;compressed bitmap structure&lt;/a&gt;
built for large set operations. The numbers are striking: the entire Bluesky
follow graph fits in roughly 6.5 GB of RAM, takes about 1.6 GB on disk, and
loads in around 20 seconds.&lt;/p&gt;&lt;p&gt;Jaz
&lt;a href=&quot;https://jazco.dev/2024/04/20/roaring-bitmaps/&quot;&gt;also describes two cost modes&lt;/a&gt;
that map onto our hot/cold split. Paging over &lt;em&gt;all&lt;/em&gt; of a user&amp;#39;s follows is
expensive and belongs in paginated or async fan-out jobs. On-demand set
intersection — &amp;quot;which people I follow also follow this person&amp;quot; — has to run at
interactive speed. Our split isn&amp;#39;t an invention; the access patterns already
worked this way in Bluesky&amp;#39;s own tooling.&lt;/p&gt;&lt;h2&gt;So what do we actually need from a language?&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#so-what-do-we-actually-need-from-a-language&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;With the workload pinned down, the requirements are:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;Fast, compute-bound set operations over a large, long-lived, in-memory graph:
bitmap intersections and unions. Per-core compute, byte-level work, cache
sensitivity.&lt;/li&gt;&lt;li&gt;High-concurrency serving of memory-resident timeline reads, with bounded
response sizes and tight tail-latency expectations.&lt;/li&gt;&lt;li&gt;A deferrable fan-out queue that absorbs bursts, applies backpressure,
delivers within minutes, and degrades gracefully instead of falling over.&lt;/li&gt;&lt;li&gt;A clean datastore boundary for the cold path: records, threads, profiles.
Ordinary I/O-bound request handling.&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;The first three are the hot path; the fourth is the cold path. No language
obviously wins all four. This is the scorecard we judged each candidate against.&lt;/p&gt;&lt;h2&gt;The four candidates&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-four-candidates&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;At &lt;a href=&quot;https://bitcrowd.net/en&quot;&gt;bitcrowd&lt;/a&gt; we work with Elixir, Go and Rust day to
day, with the occasional Node project on the side. So this wasn&amp;#39;t a contest
between a favourite and a lineup of strangers - we had hands-on experience to
weigh on every side of the comparison.&lt;/p&gt;&lt;h3&gt;Go&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#go&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Go is the incumbent. It&amp;#39;s what Bluesky&amp;#39;s own production DataPlane is written in,
and the fit is strong. Goroutines and channels map cleanly onto &amp;quot;fan out work,
gather results, respond.&amp;quot; The bitmap work is comfortable in Go (GraphD itself is
Go). gRPC support is first-class, deployment is a single static binary, and the
tooling is mature. In-process fan-out is doable with worker pools draining
buffered channels.&lt;/p&gt;&lt;p&gt;The costs sit at the extremes. Under heavy allocation Go&amp;#39;s garbage collector
starts eating CPU, and at very high socket counts the network backend can
bottleneck on syscalls. Both respond to runtime tuning, but the tuning is
ongoing work, not a one-time fix. And the in-process fan-out you build yourself
comes with no supervision or isolation layer: a panicking worker takes the
process down, and backpressure and lifecycle management would be our&amp;#39;s to write.&lt;/p&gt;&lt;h3&gt;Rust&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#rust&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Rust gives the highest ceiling and the tightest control. No garbage collector
means no GC pauses in the tail latency. Tokio handles enormous concurrency with
low per-task overhead, tonic covers gRPC, and the roaring-bitmap and datastore
libraries are excellent. For the compute-bound half of our workload, nothing
beats it.&lt;/p&gt;&lt;p&gt;The cost is velocity. As this service is thin on business logic, we would pay
the price of the borrow checker and the sharp edges of async Rust on every line
while protecting very little. Fan-out with Tokio tasks and channels works very
well. However, as with Go, we would need to build lifecycle, backpressure and
supervision manually.&lt;/p&gt;&lt;h3&gt;Node / TypeScript&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#node--typescript&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Node deserves real consideration because it&amp;#39;s the language of the reference
DataPlane and the rest of the atproto stack: PDS, AppView frontend, lexicons.
One language across the codebase, shared types from lexicon definitions, the
largest hiring pool, the fastest iteration. For the cold path&amp;#39;s I/O-bound record
fetches it&amp;#39;s fine, and for modest-scale self-hosting it&amp;#39;s a sensible default.&lt;/p&gt;&lt;p&gt;The hot path is where it breaks down. One event loop per process means in-memory
queues and request serving compete for the same loop, and any CPU-bound work
blocks both. Using multiple cores means multiple processes with no shared
memory, which brings back the cross-process coordination the in-process design
was meant to remove, and makes a large shared in-memory graph awkward. It&amp;#39;s the
right tool for the reference implementation and a strained one for a
throughput-oriented production server.&lt;/p&gt;&lt;h3&gt;Elixir&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#elixir&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Elixir runs on the BEAM, a runtime built for massive numbers of cheap, isolated,
preemptively scheduled processes. Per-process garbage collection means no global
stop-the-world pauses, so tail latency stays flat under load. Supervision trees
give fault isolation and recovery essentially for free. For high-concurrency
request serving and a backpressured in-process queue, it&amp;#39;s the most naturally
suited runtime of the four.&lt;/p&gt;&lt;p&gt;It has one well-known weakness: raw per-core compute. The BEAM optimises for
concurrency and consistent latency, not single-threaded number-crunching.
Byte-level work like set operations over a large follower graph is exactly where
it&amp;#39;s slowest. Taken at face value, that rules it out for a workload with heavy
bitmap operations at its core.&lt;/p&gt;&lt;h2&gt;Every candidate has a flaw — which ones can you fix?&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#every-candidate-has-a-flaw--which-ones-can-you-fix&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Four reasonable options, each with one thing standing between it and a clean
fit. Instead of arguing it on paper, we prototyped toward each remedy, far
enough to tell whether the flaw could be engineered away or was structural.&lt;/p&gt;&lt;p&gt;Go&amp;#39;s GC and syscall overheads respond to tuning, and Bluesky&amp;#39;s production
DataPlane proves you can push Go a long way. But the tuning never ends. It&amp;#39;s a
knob you keep turning for the life of the service.&lt;/p&gt;&lt;p&gt;Node&amp;#39;s single event loop has exactly one fix, running more processes, and that
fix recreates the cross-process coordination and shared-state problems we were
designing out. There&amp;#39;s no way around it within the language.&lt;/p&gt;&lt;p&gt;Rust has no performance flaw. Its cost showed up as soon as we started building:
hand-rolled concurrency, lifecycle and backpressure machinery on every path, for
a service with little logic to protect. We realised that that cost would not
shrink over time, but stay with us with every change we would need to make.&lt;/p&gt;&lt;p&gt;Elixir&amp;#39;s flaw, per-core compute on tight loops, is real, and we hit it where
you&amp;#39;d expect: the follower-graph set operations. But it stayed in one place. It
didn&amp;#39;t smear across the service. It sat in a single, well-defined component we
could draw a boundary around, and a component with a sharp boundary can be
replaced. That&amp;#39;s what sent us back to Elixir for a second look.&lt;/p&gt;&lt;h2&gt;Resolving the Elixir paradox&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#resolving-the-elixir-paradox&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Can that component actually be lifted out? Yes, and the reason has less to do
with Elixir than with how our system is laid out.&lt;/p&gt;&lt;p&gt;Start with the claim itself, because it&amp;#39;s often stated too broadly. &amp;quot;The BEAM is
slow at CPU work&amp;quot; is true for tight numeric loops on a single core. It is not a
claim about how much hardware a real service needs. Most of what a production
server spends its time on isn&amp;#39;t a tight loop; it&amp;#39;s keeping thousands of
concurrent requests moving, and the BEAM is efficient at exactly that.&lt;/p&gt;&lt;p&gt;Per-process garbage collection means no global pauses, and tail latency stays
flat enough that you can run a node closer to its limit and still hit your
latency target.&lt;/p&gt;&lt;p&gt;This is why Elixir services often need fewer machines than the Go or Node
version of the same thing, occasionally approaching Rust, even though they lose
every microbenchmark. Hardware cost under concurrency and single-core throughput
are two different measurements, and only one of them shows up on your bill.&lt;/p&gt;&lt;p&gt;That said, our workload really does contain the thing the BEAM is bad at: the
roaring-bitmap intersections and unions over the follow graph. We don&amp;#39;t want to
talk our way around that, so we move it off the BEAM entirely.&lt;/p&gt;&lt;p&gt;The follower graph lives in a Rust implementation of Roaring Bitmaps, called
from Elixir as a NIF (Native Implemented Function). What makes this more than a
generic &amp;quot;wrap the slow part in Rust&amp;quot; patch is the shape of the data flow:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;The input crossing the boundary is tiny: a user ID, or a small set of IDs.&lt;/li&gt;&lt;li&gt;The graph itself, millions of edges, stays on the Rust side in native memory.
The BEAM never holds it, never copies it, never garbage-collects it.&lt;/li&gt;&lt;li&gt;The expensive compute, the intersections and unions, runs entirely inside Rust
at native speed.&lt;/li&gt;&lt;li&gt;Only the result crosses back, and because timelines are length-limited, that
result is small by construction.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The usual objection to NIFs is the copy cost at the boundary. But in our case,
only small data crosses in both directions while the big structure and the heavy
computation stay native, which is the ideal case. That isn&amp;#39;t luck. The
timeline-length limit we&amp;#39;d already committed to is what bounds the return
values.&lt;/p&gt;&lt;p&gt;This subtracts the BEAM&amp;#39;s weakness from the hot path and keeps its strengths.
The compute-bound half runs in Rust regardless of host language. What&amp;#39;s left for
Elixir is orchestration: high-concurrency request serving and the fan-out queue,
the regime where the BEAM is at its best and where our own production experience
says it&amp;#39;s most efficient.&lt;/p&gt;&lt;p&gt;Of course, the NIF has its own price. It runs inside the BEAM&amp;#39;s memory space, so
a crash in the Rust code can take down the VM, and a long-running call can stall
a scheduler. So we give up some of the &amp;quot;let it crash&amp;quot; isolation exactly at the
native boundary. The mitigations fit this case well: the calls are short (small
in, bounded compute, small out), and the Rust surface is small, stable, and the
kind of code that rarely changes once written. Two languages is a real
maintenance cost, but &lt;a href=&quot;https://hexdocs.pm/rustler&quot;&gt;Rustler&lt;/a&gt; keeps the boundary
ergonomic, and a small, bounded Rust core seemed the cheapest of the available
evils.&lt;/p&gt;&lt;h2&gt;The deciding factor: fan-out as code, not infrastructure&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#the-deciding-factor-fan-out-as-code-not-infrastructure&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The NIF made Elixir competitive. The fan-out requirement made it the choice.&lt;/p&gt;&lt;p&gt;Recall the spec: a deferrable queue that absorbs bursts, applies backpressure,
delivers within minutes, and degrades gracefully when the backlog grows. In Go,
Rust or Node, that almost always becomes an external system — Redis, NATS,
RabbitMQ, Kafka — because the language doesn&amp;#39;t give you primitives to do it
safely in-process.&lt;/p&gt;&lt;p&gt;On the BEAM, those primitives are the language. Processes are the workers,
mailboxes are the queues, supervisors handle recovery, and
&lt;a href=&quot;https://hexdocs.pm/gen_stage&quot;&gt;GenStage&lt;/a&gt; and
&lt;a href=&quot;https://hexdocs.pm/broadway&quot;&gt;Broadway&lt;/a&gt; add explicit backpressure, all inside
one VM with no network hop and nothing extra to deploy. Several costs disappear
outright:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;No serialisation across a queue boundary. An external queue serialises every
fan-out job on push and deserialises it on pop, putting per-event encoding
cost right back on the hot path we&amp;#39;d worked to keep it off. In-process, a job
is a message between processes.&lt;/li&gt;&lt;li&gt;No second system to operate. No separate scaling story, no &amp;quot;is Redis the
bottleneck now,&amp;quot; no disagreement between the service&amp;#39;s view of the backlog and
the queue&amp;#39;s.&lt;/li&gt;&lt;li&gt;One failure model. Supervision covers fan-out workers the same way it covers
everything else. There&amp;#39;s no seam between &amp;quot;the service crashed&amp;quot; and &amp;quot;the queue
is in a weird state.&amp;quot;&lt;/li&gt;&lt;li&gt;Backpressure in-band. Producer and consumer share a runtime, so the producer
notices the consumer falling behind directly instead of inferring it from
queue-depth metrics after the fact.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;The trade is durability. In-process means in-memory, and an in-memory backlog
dies with the node. For us that&amp;#39;s acceptable: fan-out is best-effort timeline
population, timelines are bounded and ageing, and the fan-in path on read covers
whatever goes missing for a window. We&amp;#39;re choosing fast, simple and
lossy-on-crash over durable, external and heavier, and the choice only works
because the rest of the architecture makes the loss cheap. If you need
durability here, much of the in-process advantage narrows. Check this assumption
against your own tolerances.&lt;/p&gt;&lt;h2&gt;Why Elixir&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#why-elixir&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;We love working with Elixir, but we did not assume it to be the tool of choice
for this project. The ecosystem just did not seem to be waiting for it. Choosing
it means making the case for it, repeatedly, starting with this post.&lt;/p&gt;&lt;p&gt;And Elixir doesn&amp;#39;t win every category. We came to the decision to use it
because, for this specific workload, the one axis it loses on — per-core compute
for graph operations — is the one we can cleanly offload to a small Rust NIF,
with a data flow that makes the offload nearly free.&lt;/p&gt;&lt;p&gt;Everything that remains is concurrency, coordination, predictable tail latency
under load, and a burst-absorbing fan-out queue we can build in the program
itself instead of bolting on as infrastructure. Go would have been the
pragmatic, proven middle.&lt;/p&gt;&lt;p&gt;Rust would have given us the highest ceiling at the cost of velocity and a lot
of hand-rolled concurrency machinery. Node was right for the reference
implementation and wrong for a memory-bound production server. Elixir plus a
thin Rust core covers both halves of a workload that genuinely has two halves.&lt;/p&gt;&lt;h2&gt;What next?&lt;a href=&quot;https://bitcrowd.dev/why-elixir-bluesky-dataplane#what-next&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Most of this is architecture-level reasoning informed by the workload&amp;#39;s shape
and each runtime&amp;#39;s known characteristics. We backed this up with head-to-head
benchmarks between the Node reference implementation and our Elixir POC.&lt;/p&gt;&lt;p&gt;For that, we needed to build a performance evaluation toolkit for our studies.
If you want to find out the limits of your setup,
&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;check it out&lt;/a&gt;&lt;/p&gt;&lt;p&gt;The pieces we lean on hardest are well-sourced: Jaz&amp;#39;s GraphD work for the
in-memory graph and Roaring Bitmaps, and the documented existence of a
fan-in/fan-out split between expensive paging and interactive-speed set
intersection. The pieces we&amp;#39;re least certain about - the exact timeline window,
the precise per-request CPU breakdown - we&amp;#39;ve flagged as such.&lt;/p&gt;&lt;p&gt;We have build and tested a
&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir&quot;&gt;proof of concept&lt;/a&gt; that
serves the skeleton amazingly fast. What is missing now is the boring part of
writing the &amp;gt; 90 endpoints of the DataPlane API and the indexer plugins.
Currently, it&amp;#39;s a side project.
&lt;a href=&quot;https://cal.eu/bitcrowd/discuss-a-project&quot;&gt;Give us a shout&lt;/a&gt; if you want to help
us take it further, faster.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Your Orchestrator Is a Finite Automaton in Denial</title>
<link>https://rocket-science.ru/hacking/2026/06/14/status-driven-orchestrator-in-denial</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">5ghBBH0F5npiisbndTG8m73jcj-6rvTi17EXwg==</guid>
<pubDate>Tue, 16 Jun 2026 03:03:39 +0000</pubDate>
<description>On the lonely `status` column that quietly metastasised into a state machine, the boolean flags breeding in its shadow, and why a real finite automaton is not academic finery but the cheapest insurance you will ever decline to buy</description>
<content:encoded>&lt;p&gt;Somewhere in your codebase there is a table with a column called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt;. It started life as the most innocent thing imaginable—a single string, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;quot;pending&amp;quot;&lt;/code&gt;, set once at insertion and forgotten. Then somebody needed to know whether the thing had been paid for, so a boolean joined the schema. Then somebody needed to know whether it had shipped, and another boolean arrived. By the time the quarter closed, your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; column had accreted flags the way a ship’s hull accretes barnacles: quietly, asymmetrically, and always below the waterline where nobody looks until the thing stops steering.&lt;/p&gt;&lt;p&gt;This is &lt;em&gt;status-driven orchestration&lt;/em&gt;, and it is the most popular way in the industry to build a finite-state machine while loudly insisting you are doing nothing of the sort. The denial is the interesting part. Everyone agrees that explicit state machines are good in the abstract—the way everyone agrees that flossing is good—and then goes home and writes another &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cond/1&lt;/code&gt; that branches on a string.&lt;/p&gt;&lt;p&gt;Let me try to talk you out of it.&lt;/p&gt;&lt;h2&gt;The Anatomy of the Thing You Built&lt;/h2&gt;&lt;p&gt;Here is a perfectly representative specimen. An order goes through a fulfilment pipeline: it gets paid for, packed, shipped, delivered. It can be cancelled. It can be refunded. Nothing exotic. Let us model it the way it actually gets modelled in the wild, in an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ecto&lt;/code&gt; schema that has clearly survived three product managers:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;schema &amp;quot;orders&amp;quot; do
  field :status,     :string,  default: &amp;quot;pending&amp;quot;
  field :paid?,      :boolean, default: false
  field :packed?,    :boolean, default: false
  field :shipped?,   :boolean, default: false
  field :delivered?, :boolean, default: false
  field :cancelled?, :boolean, default: false
  field :refunded?,  :boolean, default: false
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;And here is the orchestrator that drives it—the beating heart of the system, the thing that gets paged at three in the morning:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;def advance(%Order{} = order) do
  cond do
    order.status == &amp;quot;pending&amp;quot; and order.paid? and not order.cancelled? -&amp;gt;
      update(order, status: &amp;quot;paid&amp;quot;)

    order.status == &amp;quot;paid&amp;quot; and order.packed? and not order.refunded? -&amp;gt;
      update(order, status: &amp;quot;packed&amp;quot;)

    order.status == &amp;quot;packed&amp;quot; and order.shipped? -&amp;gt;
      update(order, status: &amp;quot;shipped&amp;quot;)

    order.status == &amp;quot;shipped&amp;quot; and order.delivered? -&amp;gt;
      update(order, status: &amp;quot;delivered&amp;quot;)

    order.cancelled? -&amp;gt;
      update(order, status: &amp;quot;cancelled&amp;quot;)

    true -&amp;gt;
      {:ok, order}
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;It looks reasonable. It compiles. It passes the one test somebody wrote for the happy path. And it is, I regret to inform you, a catastrophe wearing the high-visibility vest of a solution.&lt;/p&gt;&lt;h2&gt;What Is Actually Wrong Here&lt;/h2&gt;&lt;h3&gt;Illegal states are not merely possible, they are the majority&lt;/h3&gt;&lt;p&gt;Count the representable states. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; string takes seven values; each of the six booleans doubles the space. That is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;7 × 2⁶ = 448&lt;/code&gt; distinct rows your database will cheerfully accept. Of those four hundred and forty-eight, perhaps seven correspond to an order that could exist in physical reality.&lt;/p&gt;&lt;p&gt;The other four hundred and forty-one are nonsense, and your schema treats them with exactly the same hospitality as the legal ones. Nothing—not a constraint, not a type, not a stern comment—prevents a row with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status: &amp;quot;delivered&amp;quot;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cancelled?: true&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refunded?: true&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paid?: false&lt;/code&gt;. That is an order that was never paid for, was cancelled, was refunded money it never received, and was nonetheless delivered. It sits in your database like a passenger holding a ticket for a train that was cancelled: still on the platform, still expecting to be taken somewhere, and now also somehow already at the destination.&lt;/p&gt;&lt;p&gt;The flags breed in the schema like adapters in a junk drawer—each one solved a real problem exactly once, and now you own six and can confidently explain three. Every new boolean does not &lt;em&gt;add&lt;/em&gt; a state; it &lt;em&gt;multiplies&lt;/em&gt; the space of states you have promised to reason about and silently declined to.&lt;/p&gt;&lt;h3&gt;The state machine exists; you simply refused to draw it&lt;/h3&gt;&lt;p&gt;There is a finite-state machine in that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;advance/1&lt;/code&gt; function. It is real, it has transitions, it has rules about what may follow what. The only problem is that it has no single, inspectable existence. It is smeared across the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cond&lt;/code&gt;, the changeset validations, three controller actions, a background job, and the part of the senior engineer’s memory that he is planning to take with him when he leaves. The transition table lives nowhere and everywhere at once, like a signature forged by committee.&lt;/p&gt;&lt;p&gt;Ask the system a simple question—&lt;em&gt;what states can follow &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paid&lt;/code&gt;?&lt;/em&gt;—and there is no honest way to answer it except to read the entire codebase and hope you found every site that writes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt;. Spoiler: you did not. There is one in a Rake-equivalent task from 2023 that sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status = &amp;quot;shipped&amp;quot;&lt;/code&gt; directly, bypassing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;advance/1&lt;/code&gt; entirely, because someone needed to fix a stuck order once and never removed the scaffolding.&lt;/p&gt;&lt;h3&gt;Nothing stops a transition that should be unthinkable&lt;/h3&gt;&lt;p&gt;Because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; is just a field, &lt;em&gt;any&lt;/em&gt; code that can reach the struct can write &lt;em&gt;any&lt;/em&gt; value to it. There is no notion of a transition being illegal—there is only the notion of you having remembered to write an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if&lt;/code&gt; that forbids it, everywhere, forever, without exception. Going from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delivered&lt;/code&gt; back to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pending&lt;/code&gt; is not prevented by the design; it is prevented by your vigilance, which is a renewable resource right up until the on-call rotation hits someone new.&lt;/p&gt;&lt;h3&gt;Concurrency turns the whole thing into a knife fight in a lift&lt;/h3&gt;&lt;p&gt;Two requests arrive at once. Both read the order in state &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pending&lt;/code&gt;. Both evaluate the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cond&lt;/code&gt;. Both decide the next state. Both write. What follows is less a race condition than a knife fight in a lift: cramped, badly lit, and with exactly one party walking out. Last writer wins, the first update evaporates, and the customer is charged twice because the “already paid?” check read a value that was true a microsecond ago and is now a lie.&lt;/p&gt;&lt;p&gt;You can paper over this with row locks and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT ... FOR UPDATE&lt;/code&gt; and optimistic-concurrency version columns, and now you are hand-rolling the serialisation guarantees that a process-based state machine hands you for nothing.&lt;/p&gt;&lt;h3&gt;Failure is an afterthought wearing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rescue&lt;/code&gt;&lt;/h3&gt;&lt;p&gt;Where, in the specimen above, does failure live? It does not. When the payment processor times out, the code raises, something up the stack catches it, sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status: &amp;quot;failed&amp;quot;&lt;/code&gt;—an eighth string value nobody added to the schema’s mental model—and the order joins the population of rows that no longer match any branch of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cond&lt;/code&gt; and will sit there, inert, until a human notices the revenue gap.&lt;/p&gt;&lt;h3&gt;You overwrote your own audit trail&lt;/h3&gt;&lt;p&gt;Every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;update(order, status: &amp;quot;paid&amp;quot;)&lt;/code&gt; destroys the evidence of where the order was a moment ago. The history of the process—the single most valuable thing you have when debugging why an order is stuck—is overwritten in place. Reconstructing it later is archaeology conducted with a teaspoon and a head-torch, cross-referencing log lines against &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;updated_at&lt;/code&gt; timestamps and praying nobody ran a backfill.&lt;/p&gt;&lt;h2&gt;Now Do It With an Actual Finite Automaton&lt;/h2&gt;&lt;p&gt;Here is the same pipeline as a real FSM, using my &lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata&lt;/code&gt;&lt;/a&gt; library. The entire state machine—every state, every legal transition, every event that triggers it—is declared once, in plain text, in a format that is simultaneously the code, the documentation, and a diagram your product manager can read:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;defmodule Order.FSM do
  @fsm &amp;quot;&amp;quot;&amp;quot;
  pending --&amp;gt; |pay| paid
  pending --&amp;gt; |cancel| cancelled
  pending --&amp;gt; |expire| cancelled
  paid --&amp;gt; |pack| packed
  paid --&amp;gt; |refund?| refunded
  packed --&amp;gt; |ship| shipped
  shipped --&amp;gt; |deliver| delivered
  &amp;quot;&amp;quot;&amp;quot;

  use Finitomata, fsm: @fsm, auto_terminate: true, timer: 15 * 60_000
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;That is not pseudocode and it is not a comment. That &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@fsm&lt;/code&gt; string is parsed, validated, and compiled into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenServer&lt;/code&gt; with all the transition machinery generated for you. The diagram &lt;em&gt;is&lt;/em&gt; the source of truth, because there is no other source for it to disagree with.&lt;/p&gt;&lt;p&gt;Then you implement the business logic, and &lt;em&gt;only&lt;/em&gt; the business logic, in callbacks that pattern-match on exactly the state-and-event pairs you care about:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;@impl Finitomata
def on_transition(:pending, :pay, %{amount: amount}, payload) do
  case Payments.charge(payload.customer, amount) do
    {:ok, receipt} -&amp;gt; {:ok, :paid, Map.put(payload, :receipt, receipt)}
    {:error, _reason} -&amp;gt; {:error, :payment_declined}
  end
end

def on_transition(:paid, :pack, _event_payload, payload),
  do: {:ok, :packed, payload}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A successful charge moves the machine to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:paid&lt;/code&gt; and stashes the receipt in the payload. A declined charge returns an error, the machine &lt;em&gt;stays exactly where it was&lt;/em&gt;, and control flows to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_failure/3&lt;/code&gt;—a first-class, named place for things going wrong, rather than a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rescue&lt;/code&gt; clause hoping for the best:&lt;/p&gt;&lt;p&gt;Time itself becomes a transition rather than a shadow orchestrator. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer: 15 * 60_000&lt;/code&gt; option calls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_timer/2&lt;/code&gt; on a schedule, so an unpaid order expires on its own, without a cron job somewhere running &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WHERE status = &amp;#39;pending&amp;#39; AND inserted_at &amp;lt; ...&lt;/code&gt; and mutating rows behind the FSM’s back:&lt;/p&gt;&lt;p&gt;And driving it from the outside is unceremonious:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;{:ok, _pid} = Finitomata.start_link()

Finitomata.start_fsm(Order.FSM, &amp;quot;order:42&amp;quot;, %{customer: cust, amount: 9_900})
Finitomata.transition(&amp;quot;order:42&amp;quot;, {:pay, %{amount: 9_900}})

Finitomata.state(&amp;quot;order:42&amp;quot;)
#⇒ %Finitomata.State{current: :paid, history: [:pending], payload: %{…}}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Notice the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;history&lt;/code&gt; field. The machine remembers where it has been, for free, without you overwriting anything.&lt;/p&gt;&lt;h2&gt;Point by Point, Why This One Wins&lt;/h2&gt;&lt;h3&gt;Illegal states stop being representable&lt;/h3&gt;&lt;p&gt;The current state is a single atom drawn from a closed set the compiler knows about. There is no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; string &lt;em&gt;and&lt;/em&gt; a constellation of booleans to fall out of sync; there is the state, and there is the payload, and they are different things with different jobs. The four hundred and forty-one nonsense rows simply have nowhere to live. You cannot be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delivered&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cancelled&lt;/code&gt; simultaneously for the same brutally simple reason you cannot be in two rooms at once: the model does not have a word for it.&lt;/p&gt;&lt;h3&gt;The transition table is validated before your code ever runs&lt;/h3&gt;&lt;p&gt;This is the part that ought to close the argument by itself. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:finitomata&lt;/code&gt; compiler reads your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@fsm&lt;/code&gt; declaration and refuses to proceed unless it is a &lt;em&gt;consistent&lt;/em&gt; machine: exactly one initial state, at least one final state, and no orphans—no state you can enter and never leave, no state you declared and can never reach. It refuses to compile an incoherent machine the way a good editor refuses a sentence that parses but lies.&lt;/p&gt;&lt;p&gt;Better still, if you add a transition to the diagram and forget to handle an ambiguous case in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_transition/4&lt;/code&gt;, the compiler &lt;em&gt;tells you&lt;/em&gt;, at compile time, with a warning that names the gap. Compare this to the status-driven approach, where a forgotten branch is discovered by a customer, on a Saturday, via Twitter.&lt;/p&gt;&lt;h3&gt;Transitions are guarded by construction, not by your memory&lt;/h3&gt;&lt;p&gt;A transition that is not in the diagram does not happen. Sending &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:deliver, …}&lt;/code&gt; to an order in state &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pending&lt;/code&gt; is not a bug you must remember to prevent with an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if&lt;/code&gt;; it is structurally impossible, ignored by the machine the way a vending machine ignores a button for a slot that does not exist. The set of legal moves is data, declared once, enforced everywhere, rather than folklore re-implemented at each call site.&lt;/p&gt;&lt;h3&gt;Concurrency is solved because each machine is a process&lt;/h3&gt;&lt;p&gt;Every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata&lt;/code&gt; instance is its own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GenServer&lt;/code&gt;, which means transition requests for a single order are serialised through a single mailbox and processed one at a time, in order. The knife-fight-in-a-lift disappears, not because you were careful, but because the architecture made the fight impossible to start. No row locks, no version columns, no optimistic-concurrency retries—just the actor model doing the one thing it has always been excellent at.&lt;/p&gt;&lt;h3&gt;Failure, timeouts, and retries are vocabulary, not accidents&lt;/h3&gt;&lt;p&gt;The library has named, first-class places for the things status-driven code handles by flailing: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_failure/3&lt;/code&gt; for transitions that did not complete, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_timer/2&lt;/code&gt; for the passage of time, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ensure_entry:&lt;/code&gt; option to retry a transition until it sticks, and the last error preserved in the state for when you need to know &lt;em&gt;why&lt;/em&gt;. A transition that ends with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt;—like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paid --&amp;gt; |refund?| refunded&lt;/code&gt;—is declared as one that is &lt;em&gt;expected&lt;/em&gt; to sometimes fail, so it does so quietly, without crying wolf in your logs. A transition that ends with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;!&lt;/code&gt; is &lt;em&gt;determined&lt;/em&gt; and fires the instant it becomes the only way forward. These are not features bolted on; they are the grammar of the thing.&lt;/p&gt;&lt;h3&gt;History and observability come included&lt;/h3&gt;&lt;p&gt;The state carries its own history. When an order is stuck, you ask the machine where it has been and it tells you, instead of you reconstructing the past from the sediment of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;updated_at&lt;/code&gt;. Pair it with the &lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;telemetria&lt;/code&gt;&lt;/a&gt; integration and every transition is an event you can measure, rather than a mutation you have to infer.&lt;/p&gt;&lt;h3&gt;Testing stops being string-equality theatre&lt;/h3&gt;&lt;p&gt;Because the machine is explicit, you can test the machine. &lt;a href=&quot;https://hexdocs.pm/finitomata/Finitomata.ExUnit.html&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata.ExUnit&lt;/code&gt;&lt;/a&gt; lets you walk a path through the states and assert on each transition and the resulting payload, with a syntax that reads like the thing it verifies:&lt;/p&gt;&lt;p&gt;The status-driven equivalent is mocking the database and asserting that a string equals another string, which tests your typing accuracy and very little else.&lt;/p&gt;&lt;h3&gt;Distribution is a one-line upgrade&lt;/h3&gt;&lt;p&gt;When one node is no longer enough, swap &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata&lt;/code&gt; for &lt;a href=&quot;https://hexdocs.pm/finitomata/Infinitomata.html&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Infinitomata&lt;/code&gt;&lt;/a&gt; and your machines run transparently across the cluster on top of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:pg&lt;/code&gt;, with no change to the business logic. Scaling becomes a matter of adding nodes, not of rewriting your orchestrator around a distributed lock you will get subtly wrong.&lt;/p&gt;&lt;h2&gt;The Objections, and Why They Fold&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;“This is over-engineering. It’s just a status field.”&lt;/strong&gt; It is not just a status field, and the proof is the six booleans standing next to it. You did not avoid building a state machine; you built one and declined to name it, which is the single option strictly worse than both alternatives—you pay the full cost of the complexity and receive none of the guarantees. Naming the tumour does not create it. It lets you operate.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;“FSMs are academic, textbook stuff.”&lt;/strong&gt; So is binary search, which you use without flinching, and the hash table underneath every dictionary you have ever instantiated. “Academic” is what we call the ideas that turned out to be so correct they became invisible. The finite automaton is one of the oldest, most thoroughly understood objects in computer science. Refusing to use it because it has a formal name is like refusing to use a bridge because an engineer was involved.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;“We already use a workflow engine for this.”&lt;/strong&gt; A workflow engine is, in the cases that matter, a finite-state machine that hired a sales team and learned to bill by the seat. If you want the semantics, you can have them in a hundred and fifty lines of your own language, in your own repository, without the YAML, the vendor console, and the per-execution pricing. Sometimes the heavy engine is the right call. Usually it is a sledgehammer rented monthly to drive a thumbtack.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;“Our orchestrator works fine.”&lt;/strong&gt; So does a car with insulating tape over the check-engine light. “Works fine” is a statement about the inputs you have happened to receive so far, not about the four hundred and forty-one illegal states patiently waiting for the input that produces them.&lt;/p&gt;&lt;h2&gt;The Point&lt;/h2&gt;&lt;p&gt;You are going to build a state machine. That decision was made the moment your process had more than one step. The only choice left to you is whether it will be an &lt;em&gt;explicit&lt;/em&gt; state machine—declared in one place, validated by the compiler, guarded by construction, serialised by the runtime, and testable as a unit—or an &lt;em&gt;implicit&lt;/em&gt; one, scattered across your codebase like cutlery after an earthquake, held together by a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; string, a fistful of booleans, and the fervent hope that nobody writes to the field from somewhere you forgot about.&lt;/p&gt;&lt;p&gt;The implicit machine is not simpler. It is the same machine with the guarantees filed off and the documentation set on fire. Name it. Draw it. Let the compiler check it. Your three-in-the-morning self, squinting at a row that claims to be delivered and refunded and never paid for, will thank you with a sincerity your waking self is not capable of.&lt;/p&gt;&lt;p&gt;Happy—and finite—automating.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;Previously, on the subject of refusing to lose your mind over state:&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://rocket-science.ru/hacking/2024/03/03/finitomata-for-the-win&quot;&gt;Finitomata FTW&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://rocket-science.ru/hacking/2024/05/24/finitomata-exunit&quot;&gt;Make Your Library Test-Friendly&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded>
</item>
<item>
<title>Serving timelines from Elixir</title>
<link>https://bitcrowd.dev/timelines-from-elixir</link>
<guid isPermaLink="false">olUyjOHUSPkBNpoVkLx3lMKVxZn6x4b-wcqAdw==</guid>
<pubDate>Tue, 02 Jun 2026 04:24:30 +0000</pubDate>
<description>Recently, we presented our performance toolkit for atproto including a small Proof-of-Concept for a dataplane written in Elixir.</description>
<content:encoded>&lt;p&gt;Recently, we presented our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;performance toolkit for atproto&lt;/a&gt; including a small Proof-of-Concept for a dataplane written in Elixir.
We didn&amp;#39;t go too much into the details though.
We will make up for that in this post.&lt;/p&gt;&lt;p&gt;Let&amp;#39;s start by recapping the problem.&lt;/p&gt;&lt;p&gt;The &lt;a href=&quot;https://atproto.com/&quot;&gt;AT Protocol&lt;/a&gt; enables us to build an open social ecosystem of interoperable applications.
By far the most popular application in this ecosystem today is &lt;a href=&quot;https://bsky.app/&quot;&gt;Bluesky&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;Bluesky has open sourced almost all of their software.
You can find their &lt;a href=&quot;https://github.com/bluesky-social/social-app&quot;&gt;app&lt;/a&gt;, their &lt;a href=&quot;https://github.com/bluesky-social/indigo&quot;&gt;Go services&lt;/a&gt; and their &lt;a href=&quot;https://github.com/bluesky-social/atproto&quot;&gt;TypeScript code&lt;/a&gt; (backend services and protocol reference implementation).&lt;/p&gt;&lt;p&gt;This is their production code, with one exception: they don&amp;#39;t run the open source dataplane.
They&amp;#39;ve replaced it with a closed source implementation that is more performant.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/bluesky-5fcedfef78dd330f8c008f30b0457a02.png&quot; alt=&quot;Bluesky architecture overview&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The dataplane is a central component of the AppView in Bluesky&amp;#39;s architecture. Read the&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto&quot;&gt;previous post&lt;/a&gt;to learn more.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;We talked about some of the issues of the open source dataplane in our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#the-problems-of-the-dataplane&quot;&gt;previous blog post&lt;/a&gt;.&lt;br/&gt;&lt;strong&gt;The bottom line is&lt;/strong&gt;: it won&amp;#39;t scale if you&amp;#39;re trying to serve hundreds of thousands of users.&lt;/p&gt;&lt;h2&gt;Why you should care&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#why-you-should-care&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Even the Bluesky people think that &lt;a href=&quot;https://atproto.com/blog/protocol-check-in-fall-2025#hard-decentralization&quot;&gt;hard decentralization&lt;/a&gt; is necessary.
What is the point of an open ecosystem when everything is tied to the existence of a single company?&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Bluesky can be bought and start to enshittify&lt;/li&gt;&lt;li&gt;Bluesky operates under US law, why should we want our online social life to be ruled by mad people inside a white house&lt;/li&gt;&lt;li&gt;Bluesky has &lt;a href=&quot;https://pckt.blog/b/jcalabro/april-2026-outage-post-mortem-219ebg2&quot;&gt;outages&lt;/a&gt;, alternatives could continue to operate&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;So, we should want alternative providers of Bluesky to exist.&lt;/p&gt;&lt;p&gt;Luckily for us, there is already a number of organizations picking up that work.&lt;br/&gt;Luckily for them, they can build on the open source components that Bluesky provides.&lt;br/&gt;Unluckily for them, they are left with a dataplane implementation that won&amp;#39;t work for them when their userbase grows.&lt;/p&gt;&lt;p&gt;As a result, the Blacksky people have to spend their time rewriting &lt;a href=&quot;https://github.com/blacksky-algorithms/rsky/tree/main/rsky-wintermute&quot;&gt;components in Rust&lt;/a&gt; and our friends from Eurosky started exploring &lt;a href=&quot;https://github.com/yawn/crimeline&quot;&gt;alternative ways to build timelines&lt;/a&gt;.&lt;/p&gt;&lt;h2&gt;Serving timelines from Elixir&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#serving-timelines-from-elixir&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Elixir provides a number of benefits for a backend service like the dataplane.&lt;br/&gt;You work on a high level of abstraction, getting more done in the same amount of time and end up with very readable code (yes, this is still important, even for your agent!).&lt;br/&gt;At the same time you get parallelism for free and performance that scales with the cores of the system.&lt;br/&gt;Errors are isolated, so they don&amp;#39;t bring down the whole service.
As a bonus, the VM provides you with good tooling for introspection and observability, all out of the box.&lt;/p&gt;&lt;p&gt;So, when we built our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#a-performant-open-source-dataplane&quot;&gt;Proof-of-Concept&lt;/a&gt;, we naturally chose Elixir.
We wanted to investigate the performance issues that will come up in the open source dataplane implementation.
For this reason, we focused on implementing the critical endpoint which is responsible for serving timelines to users.&lt;/p&gt;&lt;p&gt;From our exploration in the last blog post, we already know that the most important decision to solve the issues of Bluesky&amp;#39;s open source database implementation is to change the approach from fan-in to fan-out.&lt;/p&gt;&lt;p&gt;When a user requests their timeline from Bluesky&amp;#39;s open source database implementation it will query the posts that should be in the timeline on demand.
This is known as fan-in principle and is a major reason for the performance issues of this implementation.&lt;/p&gt;&lt;p&gt;Consequently, the implementation used by Bluesky in production turns this process on the head, known as the fan-out principle.
It constructs the timeline for each user whenever a new post arrives in the system.
When the user requests their timeline, the data is already waiting for them to be served.&lt;/p&gt;&lt;h3&gt;ETS tables&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#ets-tables&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;With this insight, it was clear to us that we should build our dataplane on the fan-out principle.
It was less clear how we should store the data we need.
The Erlang VM has multiple built-in options for storing data, so choosing one of them, the &lt;a href=&quot;https://www.erlang.org/doc/apps/stdlib/ets.html&quot;&gt;Erlang Term Storage&lt;/a&gt; (ETS), was a good starting point.&lt;/p&gt;&lt;p&gt;Citing the documentation:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Data is organized as a set of dynamic tables, which can store tuples.&lt;br/&gt;...&lt;br/&gt;Tables are divided into four different types, set, ordered_set, bag, and duplicate_bag. A set or ordered_set table can only have one object associated with each key. A bag or duplicate_bag table can have many objects associated with each key.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;ETS stores data as tuples in tables in memory but which tables do we need to store all the data we need to serve timelines?
And of which type should the tables be?&lt;/p&gt;&lt;p&gt;To store follows in a way that we can retrieve all the information we need efficiently, we actually need two tables: &lt;code&gt;followers_table&lt;/code&gt; and &lt;code&gt;following_table&lt;/code&gt;.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;:ets.new(@followers_table,[
:duplicate_bag,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true
])

:ets.new(@following_table,[
:duplicate_bag,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true
])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;These store the follows information in both directions, holding pairs of user IDs.&lt;br/&gt;We can find all the followers of a user by looking up the entries with their user ID as key in the &lt;code&gt;followers_table&lt;/code&gt;.
And we can find all the users this user follows by looking up the entries with their user ID as key in the &lt;code&gt;following_table&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Both tables are of type &lt;code&gt;:duplicate_bag&lt;/code&gt;.&lt;br/&gt;We need multiple entries per key, so we must use either &lt;code&gt;:bag&lt;/code&gt; or &lt;code&gt;:duplicate_bag&lt;/code&gt; as type of the table.
&lt;code&gt;:bag&lt;/code&gt; would have the benefit of preventing duplicates, however we chose &lt;code&gt;:duplicate_bag&lt;/code&gt; for its performance benefits.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;posts_table&lt;/code&gt; represents the source of truth for all the posts in the system.
It&amp;#39;s a &lt;code&gt;:set&lt;/code&gt; storing post IDs as we don&amp;#39;t want duplicates here.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;:ets.new(@posts_table,[
:set,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true,
decentralized_counters:true
])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;In the &lt;code&gt;feeds_table&lt;/code&gt; we store the feeds of all users.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;:ets.new(@feeds_table,[
:duplicate_bag,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true,
decentralized_counters:true
])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Whenever a new post arrives in our system, we look up all the followers of the author of the post using the &lt;code&gt;followers_table&lt;/code&gt;.
Then, for each follower we store a pair of the follower&amp;#39;s user ID and the post ID in the &lt;code&gt;feeds_table&lt;/code&gt;.
We chose the type &lt;code&gt;:duplicate_bag&lt;/code&gt; here for the same reasons as we did for the &lt;code&gt;followers_table&lt;/code&gt;.
We need multiple entries per key (user ID) and we want the performance benefits of &lt;code&gt;:duplicate_bag&lt;/code&gt; over &lt;code&gt;:bag&lt;/code&gt;.&lt;/p&gt;&lt;h4&gt;Celebrities&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#celebrities&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;With these tables we would have a functioning system.
However, there is still the &lt;code&gt;celebrity_posts_table&lt;/code&gt; to talk about.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;:ets.new(@celebrity_posts_table,[
:duplicate_bag,
:named_table,
:public,
read_concurrency:true,
write_concurrency:true,
decentralized_counters:true
])&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This table is actually a first optimization of the system.
You might have wondered what would happen if a post arrives in the system authored by a user who has many many followers.
If we would stick to our &lt;code&gt;feeds_table&lt;/code&gt;, we would have to loop through all of them to insert the corresponding data.&lt;/p&gt;&lt;p&gt;For this reason, we introduced the &lt;code&gt;celebrity_posts_table&lt;/code&gt;.
The key idea here is that if a user has too many followers (is a celebrity), we give their posts a special treatment to avoid looping through all the followers and the associated performance issues.
Instead we will store pairs of the author and post IDs in &lt;code&gt;celebrity_posts_table&lt;/code&gt;, which is again of type &lt;code&gt;:duplicate_bag&lt;/code&gt;.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;definsert_post(post_id, author_id)do
:ets.insert(@posts_table,{post_id, author_id})

  follower_entries =:ets.lookup(@followers_table, author_id)
  limit =fan_out_limit()

if limit ==:infinityorlength(follower_entries)&amp;lt;= limit do
Enum.each(follower_entries,fn{_subject, follower_id}-&amp;gt;
:ets.insert(@feeds_table,{follower_id, post_id})
end)
else
:ets.insert(@celebrity_posts_table,{author_id, post_id})
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;When a user request their timeline, we will first look for their feed in the &lt;code&gt;feeds_table&lt;/code&gt;, then do another lookup in the &lt;code&gt;celebrity_posts_table&lt;/code&gt; to gather posts from celebrities they follow.
The user will then see a combination of those posts.
So, in fact this dataplane combines fan-out (for regular posts in &lt;code&gt;feeds_table&lt;/code&gt;) with fan-in (for celebrity posts in &lt;code&gt;celebrity_posts_table&lt;/code&gt;) for performance reasons.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defget_timeline(user_id)do
  fan_out_posts =
@feeds_table
|&amp;gt;:ets.lookup(user_id)
|&amp;gt;Enum.map(&amp;amp;elem(&amp;amp;1,1))

  celebrity_posts =
@following_table
|&amp;gt;:ets.lookup(user_id)
|&amp;gt;Enum.flat_map(fn{_actor, followed_id}-&amp;gt;
:ets.lookup(@celebrity_posts_table, followed_id)
end)
|&amp;gt;Enum.map(&amp;amp;elem(&amp;amp;1,1))

  fan_out_posts ++ celebrity_posts
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;h3&gt;Evaluating our dataplane&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#evaluating-our-dataplane&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;To get an idea about how much performance we gain with our dataplane, we evaluated it using our &lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#the-outcomes&quot;&gt;simulator&lt;/a&gt;.
The dashboard below shows the results of this comparison.&lt;/p&gt;&lt;p&gt;We can see two blocks of data:&lt;/p&gt;&lt;p&gt;The first run was recorded with Bluesky&amp;#39;s open source dataplane.
We can see a maximum p99 latency of more than 60 ms. Furthermore, we see the throughput of requests per second fluctuating around 600.&lt;/p&gt;&lt;p&gt;In comparison, the block on the right shows recordings of the same simulation with our dataplane. The latencies are tiny, the throughput sits smoothly at 800 requests per second.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/results_simulator-c843918ee3774947ed1e5375d2f9f131.png&quot; alt=&quot;Grafana dashboard showing data for two simulation runs&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;h3&gt;Crimeline comparison&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#crimeline-comparison&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;a href=&quot;https://github.com/yawn/crimeline&quot;&gt;Crimeline&lt;/a&gt; is an experimental timeline builder which caught our attention when doing research for this project.
Like our proof of concept, Crimeline is not a complete dataplane implementation.
Unlike our proof of concept, Crimeline focuses on creating efficient datastructures and conveniently comes with synthetic benchmarks for them.&lt;br/&gt;After having seen the end-to-end evaluation of our dataplane, we were curious how ETS tables would perform in those benchmarks.&lt;/p&gt;&lt;p&gt;The &lt;a href=&quot;https://github.com/yawn/crimeline/blob/main/README.md&quot;&gt;README&lt;/a&gt; describes one of Crimeline&amp;#39;s datastructures, the &lt;a href=&quot;https://github.com/yawn/crimeline/blob/main/src/users/map.rs&quot;&gt;UserMap&lt;/a&gt;, as follows:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Sharded adjacency map. Each uid is split via bitmask into shard index (low bits) and backbone index (high bits). Each shard holds a &lt;code&gt;Vec&amp;lt;Vec&amp;lt;Uid&amp;gt;&amp;gt;&lt;/code&gt; — a dense backbone of sorted adjacency lists.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;It&amp;#39;s a struct that holds information whether a user ID is included in it (think your followers on Bluesky) with efficient methods to add and remove user IDs as well as performing the lookup of an ID.&lt;/p&gt;&lt;p&gt;An ETS-based equivalent looks like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmoduleUserMapEtsdo
@user0

def new do
:ets.new(:forward,[:duplicate_bag,:public,{:read_concurrency,true}])
end

defpopulated(n)do
    table =new()
    tuples =for t &amp;lt;-0..(n -1),do:{@user, t *3}
:ets.insert(table, tuples)
    table
end

defadd(table, target)do
:ets.insert(table,{@user, target})
end

defadd_bulk_list(table, targets)do
    tuples =for t &amp;lt;- targets,do:{@user, t}
:ets.insert(table, tuples)
end

defadd_bulk_each(table, targets)do
Enum.each(targets,fn t -&amp;gt;:ets.insert(table,{@user, t})end)
end

defcontains(table, target)do
:ets.match_object(table,{@user, target})!=[]
end

defremove(table, target)do
:ets.delete_object(table,{@user, target})
end

defdestroy(table)do
:ets.delete(table)
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;We can expect this to be a bit slower than the Rust implementation of Crimeline due to the overhead of the Erlang VM.&lt;br/&gt;The table below shows the average time for each operation in &lt;strong&gt;nanoseconds&lt;/strong&gt; when performed with 100 users.&lt;br/&gt;From our results, we can see that ETS tables nevertheless perform relatively well.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation&lt;/th&gt;&lt;th&gt;Rust UserMap&lt;/th&gt;&lt;th&gt;ETS UserMap&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;add&lt;/td&gt;&lt;td&gt;18 ns&lt;/td&gt;&lt;td&gt;186 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_hit&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1360 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1350 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;remove&lt;/td&gt;&lt;td&gt;26 ns&lt;/td&gt;&lt;td&gt;599 ns&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;So overall we were happy but two benchmarks stood out with growing numbers of users: &lt;code&gt;contains_hit&lt;/code&gt; and &lt;code&gt;contains_miss&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Note the units in the following table.
While the Rust UserMap goes from &lt;code&gt;8 ns&lt;/code&gt; to &lt;code&gt;14 ns&lt;/code&gt; (not even 2x) when increasing the number of users from &lt;code&gt;100&lt;/code&gt; to &lt;code&gt;100_000&lt;/code&gt;, ETS goes from &lt;code&gt;1.36 μs&lt;/code&gt; to &lt;code&gt;1.11 ms&lt;/code&gt;, almost 1000x.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation (number of users)&lt;/th&gt;&lt;th&gt;Rust UserMap&lt;/th&gt;&lt;th&gt;ETS UserMap&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;contains_hit (100)&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1.36 μs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_hit (100_000)&lt;/td&gt;&lt;td&gt;14 ns&lt;/td&gt;&lt;td&gt;1.11 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss (100)&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1.35 μs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss (100_000)&lt;/td&gt;&lt;td&gt;14 ns&lt;/td&gt;&lt;td&gt;1.11 ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;Why is this so slow?
From the &lt;a href=&quot;https://www.erlang.org/doc/apps/stdlib/ets.html&quot;&gt;ETS docs&lt;/a&gt;:&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Insert and lookup times in tables of type set are constant, regardless of the table size. For table types bag and duplicate_bag time is proportional to the number of objects with the same key.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;We chose a &lt;code&gt;:duplicate_bag&lt;/code&gt; for the table to improve insert performance.
Now we are paying for that with slow lookups at scale.&lt;/p&gt;&lt;h4&gt;Roaring Bitmaps&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#roaring-bitmaps&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;We didn&amp;#39;t give up here, of course.
So let&amp;#39;s see what we can improve.&lt;/p&gt;&lt;p&gt;During our research, we also came across &lt;a href=&quot;https://jazco.dev/&quot;&gt;Jaz&amp;#39;s&lt;/a&gt; blog and learned how to &lt;a href=&quot;https://jazco.dev/2024/04/15/in-memory-graphs/&quot;&gt;shrink the size of your datastructures&lt;/a&gt; and go even further with &lt;a href=&quot;https://jazco.dev/2024/04/20/roaring-bitmaps/&quot;&gt;Roaring Bitmaps&lt;/a&gt;.
The latter seems to be a good fit for optimizing our user map datastructure.&lt;/p&gt;&lt;p&gt;Luckily, &lt;a href=&quot;https://hex.pm/packages/rustler&quot;&gt;rustler&lt;/a&gt; lowers the barrier to use Rust crates from Elixir a lot these days.
Conveniently for us, Aaron Gunderson already did the work to wrap the &lt;a href=&quot;https://crates.io/crates/roaring&quot;&gt;roaring crate&lt;/a&gt; in an &lt;a href=&quot;https://hex.pm/packages/roaring&quot;&gt;Elixir package&lt;/a&gt; and &lt;a href=&quot;https://agundy.com/blog/using-roaring-bitmaps-in-elixir/&quot;&gt;blogged about it&lt;/a&gt;.
So, it&amp;#39;s ready for us to use.&lt;/p&gt;&lt;p&gt;As a very brief summary, bitmaps are a suitable datastructure to compress the follower graph in our dataplane.
Instead of storing pairs of &lt;code&gt;{user_id, follower_id}&lt;/code&gt;, we store a single array for each user where each bit represents whether the user with the id at the index follows the user.&lt;/p&gt;&lt;p&gt;Say we have 5 users, and our ids start at 0. If the first four users all follow user with id 4, we would store the following pairs in our ETS table:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;# user_id, follower_id
{4, 0}
{4, 1}
{4, 2}
{4, 3}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;With a bitmap, we would store a single array of bits for the user with id 4.
If the bit is &lt;code&gt;1&lt;/code&gt;, the corresponding user follows the user with id 4.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;# user_id/index:
# 0, 1, 2, 3, 4
 [1, 1, 1, 1, 0]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;You can see how that compresses the required space in memory in this simplified example.&lt;br/&gt;Assuming 1 byte per id, we would have 4 * 2 = 8 bytes of memory in our ETS table variant.
The bitmap however is complete with 5 bits which fit in a single byte.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://roaringbitmap.org/&quot;&gt;Roaring bitmaps&lt;/a&gt; apply further optimizations on top of this basic idea.&lt;/p&gt;&lt;p&gt;With the &lt;code&gt;roaring&lt;/code&gt; package we can plug the Roaring Bitmap into our user map module.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmoduleUserMapRoaringdo
def new do
{:ok, roaring}=RoaringBitmap64.new()
    roaring
end

defpopulated(n)do
    list =for t &amp;lt;-0..(n -1),do: t
{:ok, roaring}=RoaringBitmap64.from_list(list)
    roaring
end

defadd(roaring, target)do
RoaringBitmap64.insert(roaring, target)
end

defadd_bulk_list(roaring, targets)do
for t &amp;lt;- targets,do:RoaringBitmap64.insert(roaring, t)
end

defadd_bulk_each(roaring, targets)do
Enum.each(targets,fn t -&amp;gt;RoaringBitmap64.insert(roaring, t)end)
end

defcontains(roaring, target)do
RoaringBitmap64.contains?(roaring, target)
end

defremove(roaring, target)do
RoaringBitmap64.remove(roaring, target)
end

defdestroy(_roaring)do
:ok
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;With that we get the following results.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation (number of users)&lt;/th&gt;&lt;th&gt;Rust UserMap&lt;/th&gt;&lt;th&gt;ETS UserMap&lt;/th&gt;&lt;th&gt;Roaring Bitmaps UserMap&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;contains_hit (100)&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1.36 μs&lt;/td&gt;&lt;td&gt;104 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_hit (100_000)&lt;/td&gt;&lt;td&gt;14 ns&lt;/td&gt;&lt;td&gt;1.11 ms&lt;/td&gt;&lt;td&gt;100 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss (100)&lt;/td&gt;&lt;td&gt;8 ns&lt;/td&gt;&lt;td&gt;1.35 μs&lt;/td&gt;&lt;td&gt;98 ns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;contains_miss (100_000)&lt;/td&gt;&lt;td&gt;14 ns&lt;/td&gt;&lt;td&gt;1.11 ms&lt;/td&gt;&lt;td&gt;100 ns&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;We&amp;#39;re still slower than Crimeline&amp;#39;s implementation but it&amp;#39;s acceptable and more importantly does not degrade with the number of users.&lt;/p&gt;&lt;h4&gt;Comparison in a more realistic scenario&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#comparison-in-a-more-realistic-scenario&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;After satisfying our curiosity about Crimeline&amp;#39;s synthetic benchmarks, we were wondering how the comparison would look like in a more realistic scenario.
Therefore, we added another benchmark &lt;code&gt;feeds&lt;/code&gt; that is somewhere in between Crimeline&amp;#39;s benchmarks and the work we&amp;#39;ve previously done with our simulator.
Interestingly, in this more realistic scenario the performance difference between the implementations diminishes.&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation (number of follows)&lt;/th&gt;&lt;th&gt;Rust feed&lt;/th&gt;&lt;th&gt;ETS feed&lt;/th&gt;&lt;th&gt;Roaring Bitmaps feed&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;get_feed (100)&lt;/td&gt;&lt;td&gt;41 µs&lt;/td&gt;&lt;td&gt;9 μs&lt;/td&gt;&lt;td&gt;8 μs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;get_feed (100_000)&lt;/td&gt;&lt;td&gt;57 µs&lt;/td&gt;&lt;td&gt;117 μs&lt;/td&gt;&lt;td&gt;108 μs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;get_feed (1_000_000)&lt;/td&gt;&lt;td&gt;58 µs&lt;/td&gt;&lt;td&gt;118 μs&lt;/td&gt;&lt;td&gt;109 μs&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h2&gt;Where to go from here?&lt;a href=&quot;https://bitcrowd.dev/timelines-from-elixir#where-to-go-from-here&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;We showed how to build a basic dataplane implementation in Elixir, then added optimizations to squeeze out the issues we encountered.&lt;/p&gt;&lt;p&gt;One essential feature that is missing from our implementation is persistence.
So far we store everything in memory, so our data wouldn&amp;#39;t survive a restart of the server.&lt;/p&gt;&lt;p&gt;Our goal is to provide a simple to use dataplane implementation.
So, the first idea would be to add persistence with Postgres and treat our in-memory data as an index to avoid costly queries and retrieve the data directly by primary key.
Another idea is to provide an adapter interface so that everyone can use their preferred database.&lt;/p&gt;&lt;p&gt;We&amp;#39;ve seen how to combine Rust and Elixir to get the strengths of both technologies while mitigating their weaknesses, Discord has another &lt;a href=&quot;https://discord.com/blog/using-rust-to-scale-elixir-for-11-million-concurrent-users&quot;&gt;interesting article&lt;/a&gt; on that topic.&lt;br/&gt;In essence, we can get the high level of abstraction from Elixir which leads to readable, maintainable code and a high velocity.
We can also get the parallelism and robustness of the Erlang VM on which Elixir runs.
In combination with Rust, we can improve our performance both in speed and memory usage while we are confident that the native code won&amp;#39;t crash the VM as we build on Rust&amp;#39;s memory-safety features.
This way, we can optimize the parts of the codebase where we benefit from it.
Whether it&amp;#39;s Roaring Bitmaps or even wrapping the Crimeline datastructures, a whole ecosystem of crates is available to us.&lt;/p&gt;&lt;p&gt;With that, we have all the tools to build the remaining features a complete dataplane implementation needs and we are ready to add more advanced features like the &lt;a href=&quot;https://jazco.dev/2024/04/15/in-memory-graphs/&quot;&gt;social proof&lt;/a&gt; that is available in Bluesky.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Erlang Ecosystem Foundation - Supporting the BEAM community</title>
<link>https://erlef.org/blog/security/atom-exhaustion</link>
<guid isPermaLink="false">V7vRAJQXDdCFAH-UG1JD5BOx_PmQde2p1b5OHQ==</guid>
<pubDate>Thu, 28 May 2026 00:36:05 +0000</pubDate>
<description>Atom Exhaustion Is Not a Footgun. It&#39;s One Third of Our CVEs.</description>
<content:encoded>&lt;h2&gt;Atom Exhaustion Is Not a Footgun. It&amp;#39;s One Third of Our CVEs.&lt;/h2&gt;&lt;p&gt;
&lt;strong&gt;35.8% of CVEs published by the Erlang Ecosystem Foundation CNA fall into the
category of uncontrolled resource consumption.&lt;/strong&gt; In the BEAM ecosystem, a large
share of those are caused by one recurring issue: atom exhaustion. You can find
the current distribution on the EEF CNA’s &lt;a href=&quot;https://cna.erlef.org/common-weaknesses&quot;&gt;Common Weaknesses&lt;/a&gt;
page.&lt;/p&gt;&lt;p&gt;
Atom exhaustion is a denial-of-service vulnerability. Atoms are not garbage
collected and are stored in a global atom table, and once it fills up, the VM
crashes. Creating atoms from non-finite values, especially user-supplied input,
is therefore a latent DoS waiting to happen.&lt;/p&gt;&lt;p&gt;
This is not limited to obvious calls such as &lt;code class=&quot;inline&quot;&gt;binary_to_atom/1&lt;/code&gt;,
&lt;code class=&quot;inline&quot;&gt;list_to_atom/1&lt;/code&gt;, &lt;code class=&quot;inline&quot;&gt;String.to_atom/1&lt;/code&gt;, or &lt;code class=&quot;inline&quot;&gt;List.to_atom/1&lt;/code&gt;. Some dangerous
patterns are less obvious:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;erlang&quot;&gt;% Erlang: dynamic atom creation through interpolation
list_to_atom(&amp;quot;field_&amp;quot; ++ UserInput)&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;elixir&quot;&gt;# Elixir: decoding JSON with atom keys
Jason.decode(json, keys: :atoms)&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;elixir&quot;&gt;# Elixir: dynamic atom creation through interpolation
:&amp;quot;field_#{user_input}&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
What makes this class of vulnerability persistent is not carelessness. It often
appears in code where the input was assumed to be controlled or finite. URI
schemes are a good example: it may feel like there are only a few schemes to
handle, but if the value comes from external input, the set is no longer
guaranteed to be finite.&lt;/p&gt;&lt;blockquote&gt;
  &lt;p&gt;
Creating atoms from input is unsafe unless the set of possible values is
finite, known, and enforced.  &lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;
The safest approach is to avoid creating new atoms at runtime entirely. Prefer
explicit lookup tables when the accepted values are known:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;erlang&quot;&gt;% Erlang
case Scheme of
  &amp;lt;&amp;lt;&amp;quot;http&amp;quot;&amp;gt;&amp;gt;  -&amp;gt; http;
  &amp;lt;&amp;lt;&amp;quot;https&amp;quot;&amp;gt;&amp;gt; -&amp;gt; https;
  _           -&amp;gt; error
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
When a lookup table is not practical, use the safer existing-atom variants,
which will raise an error instead of creating a new atom:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;erlang&quot;&gt;% Erlang
binary_to_existing_atom(Value)
list_to_existing_atom(Value)&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;elixir&quot;&gt;# Elixir
String.to_existing_atom(value)
List.to_existing_atom(value)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
Linters can help catch these patterns before they become vulnerabilities. For
Elixir projects, consider enabling Credo’s
&lt;a href=&quot;https://hexdocs.pm/credo/Credo.Check.Warning.UnsafeToAtom.html&quot;&gt;&lt;code class=&quot;inline&quot;&gt;Credo.Check.Warning.UnsafeToAtom&lt;/code&gt;&lt;/a&gt;, which flags unsafe
calls to &lt;code class=&quot;inline&quot;&gt;String.to_atom/1&lt;/code&gt;, &lt;code class=&quot;inline&quot;&gt;List.to_atom/1&lt;/code&gt;, &lt;code class=&quot;inline&quot;&gt;Module.concat/1,2&lt;/code&gt;, and
&lt;code class=&quot;inline&quot;&gt;Jason.decode/2&lt;/code&gt; with &lt;code class=&quot;inline&quot;&gt;keys: :atoms&lt;/code&gt;. The check is disabled by default.&lt;/p&gt;&lt;p&gt;
If you maintain an Erlang or Elixir project, search your codebase for atom
creation from binaries, strings, JSON keys, URI components, headers, and
configuration values. This is one of the easiest vulnerability classes to fix
before it becomes a CVE.&lt;/p&gt;&lt;p&gt;
For more detailed guidance, see the EEF Security Working Group’s
&lt;a href=&quot;https://security.erlef.org/secure_coding_and_deployment_hardening/atom_exhaustion&quot;&gt;guide on preventing atom exhaustion&lt;/a&gt;.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Highest Random Weight in Elixir | jola.dev</title>
<link>https://jola.dev/posts/highest-random-weight-in-elixir</link>
<enclosure type="image/jpeg" length="0" url="https://jola.dev/images/og/posts/highest-random-weight-in-elixir.png"></enclosure>
<guid isPermaLink="false">8eKqqXRGCUwMI--e2BA3tsLTjE5cK-8DdK6CCg==</guid>
<pubDate>Sat, 23 May 2026 15:49:11 +0000</pubDate>
<description>A description of HRW/rendezvous hashing and the HRW elixir library.</description>
<content:encoded>&lt;p&gt;
Consistent hashing is a common building block for distributed Elixir and enables fairly low complexity and high value design patterns, like the distributed rate limiter or cache. I’ve written about it before.&lt;/p&gt;&lt;p&gt;
The most common way of assigning keys to nodes, ensuring that any node participating in the cluster can figure out which node owns the given key, is Discord’s &lt;a href=&quot;https://github.com/discord/ex_hash_ring&quot;&gt;ExHashRing&lt;/a&gt;. This is an incredibly battle-tested and reliable library with excellent performance characteristics, and I’ve only had good experiences with it.&lt;/p&gt;&lt;p&gt;
That said, it does have a downside. You have to start and manage the ring processes. It’s not a huge downside, you can give them global names and it’s trivial to look them up, but you still want them set up under your supervision tree and they are stateful persistent things that hang around. That state has to be managed. It’s not a big deal at all, but when I found a stateless alternative it did immediately catch my attention.&lt;/p&gt;&lt;h2&gt;
Rendezvous hashing&lt;/h2&gt;&lt;p&gt;
As described by the &lt;a href=&quot;https://en.wikipedia.org/wiki/Rendezvous_hashing&quot;&gt;Wikipedia page&lt;/a&gt;: &lt;em&gt;Rendezvous hashing is both much simpler and more general than consistent hashing.&lt;/em&gt; Also called HRW or Highest Random Weight&lt;em&gt;.&lt;/em&gt; In practice, you can use it very much like you would ExHashRing.&lt;/p&gt;&lt;p&gt;
&lt;code class=&quot;makeup ok&quot;&gt;ExHashRing&lt;/code&gt; example.&lt;/p&gt;&lt;p&gt;
HRW example.&lt;/p&gt;&lt;p&gt;
That’s it. No stateful process, no setup. Just pure functional programming with inputs and outputs. Consistent across multiple machines. Avoids unnecessary drift when changing the list of nodes. You can see why it caught my eye!&lt;/p&gt;&lt;p&gt;
There’s a downside of course. The big O notation for &lt;code class=&quot;makeup ok&quot;&gt;HRW.owner&lt;/code&gt; is linear (O(n)), or in other words, it doesn’t do well with larger lists of nodes. That’s definitely something to take into account when considering using it. But to be honest, looking back at the times I’ve used &lt;code class=&quot;makeup ok&quot;&gt;ExHashRing&lt;/code&gt; I’ve never had more than ~14 nodes to worry about. Here’s a comparison of how each algorithm does on my machine for 14 nodes.&lt;/p&gt;&lt;p&gt;
&lt;code class=&quot;makeup ok&quot;&gt;ExHashRing&lt;/code&gt; is extremely fast, and stays fast as the number of nodes grow. But at a smaller number of nodes, even on a fairly hot path, there’s not much difference here. You’re free to pick whichever one you think reads better.&lt;/p&gt;&lt;h2&gt;
Basic HRW algorithm&lt;/h2&gt;&lt;p&gt;
Let’s dig a bit deeper into rendezvous hashing. The basic implementation is actually incredibly small. What you want to do is apply a scoring function on the key together with each of the nodes separately and then return the highest value. Highest Random Weight. For a scoring function you can use any fast hashing function really. &lt;code class=&quot;makeup ok&quot;&gt;:erlang.phash2&lt;/code&gt; is an obvious candidate in the BEAM ecosystem.&lt;/p&gt;&lt;p&gt;
Here’s what that looks like.&lt;/p&gt;&lt;p&gt;
It’s pretty ingenious!&lt;/p&gt;&lt;h2&gt;
Linear growth&lt;/h2&gt;&lt;p&gt;
Just to demonstrate how that affects performance as &lt;code class=&quot;makeup ok&quot;&gt;nodes&lt;/code&gt; grows, here’s a benchmark run with 10K nodes. 4200x times slower than &lt;code class=&quot;makeup ok&quot;&gt;ExHashRing&lt;/code&gt;. Although to put things into perspective, it’s still just taking ~2 ms on my machine. Depending on your use case, that might actually be just fine. It’s hard to beat the convenience of a pure function.&lt;/p&gt;&lt;p&gt;
But let’s see if we can do better.&lt;/p&gt;&lt;h2&gt;
HRW skeleton&lt;/h2&gt;&lt;p&gt;
Our basic HRW implementation, although actually quite fast, doesn’t behave well as the number of nodes grows. This is because it, for every lookup, has to hash the key against every node. That same Wikipedia page &lt;a href=&quot;https://en.wikipedia.org/wiki/Rendezvous_hashing#O(log_n)_running_time_via_skeleton-based_hierarchical_rendezvous_hashing&quot;&gt;describes&lt;/a&gt; a way around that by arranging the nodes into an efficient data structure and bringing the big O notation of &lt;code class=&quot;makeup ok&quot;&gt;owner&lt;/code&gt; to O(log n).&lt;/p&gt;&lt;p&gt;
At a very (very) high level what we want to do is sort the list of nodes and then chunk them into clusters. Each cluster gets an address and instead of hashing the key against every node, we now just need to calculate the address of the cluster, and then we can hash the key against the nodes inside that cluster to find the correct one. This means significantly less effort, bringing us to a much nicer logarithmic complexity.&lt;/p&gt;&lt;p&gt;
Using it looks something like this.&lt;/p&gt;&lt;p&gt;
Running the same benchmark as above, but with the skeleton created in advance, just like we do for &lt;code class=&quot;makeup ok&quot;&gt;ExHashRing&lt;/code&gt;, this is what we get.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup ok&quot;&gt;##### With input D: 10_000 #####
Name                                ips        average  deviation         median         99th %
ExHashRing.Ring.find_node        2.17 M     0.00046 ms  ±1791.93%     0.00042 ms     0.00058 ms
HRW.owner (skeleton)             0.71 M     0.00141 ms   ±634.18%     0.00138 ms     0.00183 ms
HRW.owner                     0.00047 M        2.13 ms     ±5.03%        2.10 ms        2.53 ms

Comparison:
ExHashRing.Ring.find_node        2.17 M
HRW.owner (skeleton)             0.71 M - 3.06x slower +0.00095 ms
HRW.owner                     0.00047 M - 4615.43x slower +2.13 ms&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
We’ve gone from 2 ms per lookup to 141 µs, only ~3x slower than &lt;code class=&quot;makeup ok&quot;&gt;ExHashRing&lt;/code&gt;, with no NIFs and no stateful processes to start up. We do have a struct we have to pass around now, and adding and removing nodes is no longer a stable operation. Adding a node pushes everything that comes after in the sorted list one slot over. I guess nothing in life is free. Still, this is an interesting tradeoff for a lot of use cases.&lt;/p&gt;&lt;h2&gt;
Distribution&lt;/h2&gt;&lt;p&gt;
The other thing you probably want to know about a mechanism for distributing work/keys/load across a set of nodes, is how well it distributes. It wouldn’t be very useful if every key maps to the same node. Here’s a little sample script that demonstrates the distribution.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup ok&quot;&gt;defmodule Distribution do
  def run do
    keys = Enum.map(1..100_000, fn i -&amp;gt; &amp;quot;key-#{i}&amp;quot; end)

    for n &amp;lt;- [10, 100, 1000] do
      nodes = Enum.map(1..n, &amp;amp;&amp;quot;node#{&amp;amp;1}&amp;quot;)
      ideal = div(length(keys), n)

      counts =
        keys
        |&amp;gt; Enum.map(&amp;amp;HRW.owner(&amp;amp;1, nodes))
        |&amp;gt; Enum.frequencies()
        |&amp;gt; Map.values()

      min_c = Enum.min(counts)
      max_c = Enum.max(counts)
      avg = Enum.sum(counts) / length(counts)
      stddev = :math.sqrt(Enum.sum(Enum.map(counts, fn c -&amp;gt; (c - avg) ** 2 end)) / length(counts))

      IO.puts(&amp;quot;#{n} nodes, #{length(keys)} keys (ideal #{ideal} per node):&amp;quot;)
      IO.puts(&amp;quot;  min: #{min_c}  max: #{max_c}  stddev: #{Float.round(stddev, 1)}  (#{Float.round(stddev/avg*100, 2)}% of mean)&amp;quot;)
    end
  end
end

Distribution.run()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
I extended that to add HRW with MurmurHash3, HRW with skeleton, and ExHashRing, for comparison.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup ok&quot;&gt;10 nodes, 100000 keys (ideal 10000 per node):
    phash2 (HRW)           min: 9691  max: 10639  stddev: 249.9  (2.5% of mean)
    murmur3 x86_32 (HRW)   min: 9859  max: 10192  stddev: 112.2  (1.12% of mean)
    murmur3 x64_128 (HRW)  min: 9864  max: 10170  stddev: 98.1   (0.98% of mean)
    HRW.Skeleton           min: 9691  max: 10639  stddev: 249.9  (2.5% of mean)
    ExHashRing             min: 9526  max: 10513  stddev: 338.5  (3.38% of mean)

  100 nodes, 100000 keys (ideal 1000 per node):
    phash2 (HRW)           min: 920   max: 1075   stddev: 29.7   (2.97% of mean)
    murmur3 x86_32 (HRW)   min: 934   max: 1059   stddev: 27.0   (2.7% of mean)
    murmur3 x64_128 (HRW)  min: 902   max: 1072   stddev: 29.2   (2.92% of mean)
    HRW.Skeleton           min: 877   max: 1124   stddev: 46.6   (4.66% of mean)
    ExHashRing             min: 105   max: 1229   stddev: 279.7  (27.97% of mean)

  1000 nodes, 100000 keys (ideal 100 per node):
    phash2 (HRW)           min: 69    max: 132    stddev: 9.9    (9.91% of mean)
    murmur3 x86_32 (HRW)   min: 72    max: 132    stddev: 9.6    (9.65% of mean)
    murmur3 x64_128 (HRW)  min: 67    max: 144    stddev: 9.8    (9.79% of mean)
    HRW.Skeleton           min: 72    max: 141    stddev: 9.9    (9.85% of mean)
    ExHashRing             min: 0     max: 147    stddev: 31.4   (31.42% of mean)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
As you can see, we’re doing just fine with &lt;code class=&quot;makeup ok&quot;&gt;:erlang.phash2.&lt;/code&gt; Murmur3 is maybe slightly better at smaller node counts, but that’s not the big takeaway from here. It’s that &lt;code class=&quot;makeup ok&quot;&gt;ExHashRing&lt;/code&gt; is really struggling at larger node counts on the default settings. The solution is to add more vnodes, but that was unexpected to me!&lt;/p&gt;&lt;h2&gt;
Announcing HRW, the library&lt;/h2&gt;&lt;p&gt;
You’re very welcome to try out the &lt;code class=&quot;makeup ok&quot;&gt;hrw&lt;/code&gt; library on &lt;a href=&quot;http://hex.pm&quot;&gt;hex.pm&lt;/a&gt;, or why not take a look at the Github repository at &lt;a href=&quot;https://github.com/joladev/hrw&quot;&gt;https://github.com/joladev/hrw&lt;/a&gt;. For very large number of nodes, you’ll want to use &lt;code class=&quot;makeup ok&quot;&gt;ExHashRing&lt;/code&gt; or &lt;code class=&quot;makeup ok&quot;&gt;HRW.Skeleton&lt;/code&gt;, for anything else, why not stick with plain &lt;code class=&quot;makeup ok&quot;&gt;HRW.owner&lt;/code&gt;?&lt;/p&gt;&lt;p&gt;
The library comes with additional strategies not described here, like &lt;code class=&quot;makeup ok&quot;&gt;HRW.Weighted&lt;/code&gt; which lets you assign more key space to specific nodes, useful for heterogenous clusters where some machines are bigger, and &lt;code class=&quot;makeup ok&quot;&gt;HRW.Bounded&lt;/code&gt;, which gives you greater precision in how keys are distributed when you know the keys up front.&lt;/p&gt;&lt;p&gt;
Let me know how you find it.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Make Friends With Your AI Assistant</title>
<link>https://rocket-science.ru/hacking/2026/05/18/make-claude-robust</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">JMW_giP8FEk1my0-Dp3QtGo1x6rwIsTFl4NlcQ==</guid>
<pubDate>Mon, 18 May 2026 10:10:08 +0000</pubDate>
<description>Several rules from my experience which would make your cooperation with AI assistants a charm</description>
<content:encoded>&lt;h2&gt;Typography&lt;/h2&gt;&lt;p&gt;AI assistants do apparently love clean markers. I use ⓪ –⑳  markers for numbering, ▸/▹ markers for itemizing, typographically correct quotes (“”/‘’,) and m-dashes. Compose key makes entering that stuff easier, alternative layout makes it a charm. No tips for poor MacOS/Win users, sorry.&lt;/p&gt;&lt;h2&gt;Workflows&lt;/h2&gt;&lt;p&gt;I have system-wide workflows for my assistant. They all look like “START_WORD → WORKFLOW_RULES → STOP_WORD.”&lt;/p&gt;&lt;p&gt;Here is an example:&lt;/p&gt;&lt;blockquote&gt;
  &lt;p&gt;Let’s define the workflow you must follow every time we work on a new task, named NEW_TASK. The rules are following:&lt;/p&gt;

  &lt;p&gt;① The statement “New task &lt;task_name&gt;!&amp;quot; typed by me starts new NEW_TASK workflow&lt;br/&gt;
② Upon starting new workflow you must pull the main branch from github and create a new branch named &amp;quot;&lt;task_name&gt;&amp;quot;&lt;br/&gt;
③ At this point you have to wait for me to enter the problem statement&lt;br/&gt;
④ You have to analyze the problem statement and immediately ask all the questions regarding it where you might have had any uncertancy and/or lack of clarity&lt;br/&gt;
⑤ After everythig is clarified, you must come up with the plan of implementation and ask for its amendment and/or approval&lt;br/&gt;
⑥ Upon approval, you should start implementing it, asking all the questions you might have appeared&lt;br/&gt;
⑦ Once done, you must create a comprehensive tests for the new functionality, documentation in both the source code as standalone (markdown), changelog entry, and a brief what-has-been-done note in markdown&lt;br/&gt;
⑧ After I accept everything by saying &amp;quot;Task done!&amp;quot;, you should commit everything with a descriptive comprehensive commit message, push it to remote, and create a pull request in github.&lt;/task_name&gt;&lt;/task_name&gt;&lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;The key insight here is the &lt;strong&gt;stop-words&lt;/strong&gt;. Without them the assistant will cheerfully barrel through all eight steps in one breath, commit untested code to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;, and open a pull request titled “feat: everything.” Stop-words are the leash. Love the leash (and the muzzle, your assistant is ill-mannered and disobedient.)&lt;/p&gt;&lt;h2&gt;Context Is King (And Your Assistant Has Amnesia)&lt;/h2&gt;&lt;p&gt;Every new session your assistant is a freshly hatched duckling. It will imprint on whatever you show it first. Feed it your codebase conventions, your style preferences, your testing philosophy—and do it &lt;em&gt;persistently&lt;/em&gt;, through rules and system prompts, not through hopeful repetition in every chat.&lt;/p&gt;&lt;p&gt;The difference between a rule that says “always use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:json&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:jason&lt;/code&gt;” and manually correcting the same dependency in every generated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mix.exs&lt;/code&gt; is the difference between engineering and penance.&lt;/p&gt;&lt;p&gt;Persistent rules are not optional. They are the civilizational layer between you and the primordial chaos of a context window that has never heard of your project. Each workflow (see above) might have different persistent rules attached.&lt;/p&gt;&lt;h2&gt;Atomic Tasks, or: Stop Writing Novels&lt;/h2&gt;&lt;p&gt;The temptation to say “implement the entire authentication system” is roughly as productive as asking an intern to “make the app work.” Break it down. Then break it down again. If your task description exceeds one screenful, you have written a novel, not a task. If within the task there are forks in the road possible, the assistant will inevitable choose the wrong path.&lt;/p&gt;&lt;p&gt;The assistant will cheerfully &lt;em&gt;attempt&lt;/em&gt; your novel and &lt;em&gt;wild guess&lt;/em&gt; the direction on each fork. It will produce something that compiles, passes zero tests, and makes you question your career choices. Whereas “add the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verify_token/2&lt;/code&gt; function that pattern-matches on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:ok, claims}&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:error, reason}&lt;/code&gt;”—that it can do in its sleep. If models slept.&lt;/p&gt;&lt;p&gt;The rule of thumb: &lt;strong&gt;if there is more than one correct architectural path, the task is too large.&lt;/strong&gt; Decide the architecture yourself; delegate the typing.&lt;/p&gt;&lt;h2&gt;Feed It Good Code, Get Good Code&lt;/h2&gt;&lt;p&gt;This is not mysticism, it is pattern matching. If you feed your assistant examples of clean, idiomatic code, it will produce clean, idiomatic code. If you feed it your legacy spaghetti written at 3 AM during a production outage, you will get more spaghetti—but with AI-generated parmesan on top.&lt;/p&gt;&lt;p&gt;I keep a curated set of “golden” modules that I attached as context ages ago. The assistant does not need to be told “you are a senior Elixir architect with 300 years of experience.” It needs to &lt;em&gt;see&lt;/em&gt; what good Elixir looks like. Show, don’t pep-talk.&lt;/p&gt;&lt;h2&gt;Trust, But Verify (Especially the Tests)&lt;/h2&gt;&lt;p&gt;The cruelest irony of AI-assisted development: the assistant generates tests that pass. “Green across the board!” you exclaim, and deploy. Three hours later you discover the tests were testing that the function returns &lt;em&gt;something&lt;/em&gt;, not that it returns the &lt;em&gt;right&lt;/em&gt; something.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Read the generated tests more carefully than the implementation.&lt;/strong&gt; The implementation at least looks suspicious when it is wrong. A bad test looks &lt;em&gt;exactly&lt;/em&gt; like a good test, except it asserts nothing useful. Pattern-match on the actual values; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert result&lt;/code&gt; is not a test, it is a prayer.&lt;/p&gt;&lt;h2&gt;The Art of Saying “No, Try Again”&lt;/h2&gt;&lt;p&gt;Your assistant is not your therapist. It does not need encouragement. “This is wrong” followed by silence teaches it nothing. “This is wrong because the recursive clause does not handle the empty-list base case, see &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;List.foldl/3&lt;/code&gt; for the pattern I expect”—now you are getting somewhere. Keep your paw right above &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl&lt;/code&gt;+&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;C&lt;/code&gt; combination and press it every time you see the assistant’s thinking process drives bonkers.&lt;/p&gt;&lt;p&gt;You always liked interrupting people in person, feel free to interrupt your deputy.&lt;/p&gt;&lt;h2&gt;Know When to Kill the Session&lt;/h2&gt;&lt;p&gt;If you have been going back and forth for more than three iterations on the same function, close the session. Start fresh. The context window is polluted with failed attempts, corrections, and mutual disappointment—a couples therapy transcript, essentially.&lt;/p&gt;&lt;p&gt;A new session with a clean description of what you &lt;em&gt;actually&lt;/em&gt; want will outperform the seventh round of “no, I meant the &lt;em&gt;other&lt;/em&gt; thing” every single time. Sunk cost fallacy applies to token budgets too.&lt;/p&gt;&lt;p&gt;Please aware, that the necessity to close a session vividly implies your original prompt sucked in the first place. Rewrite it from scratch. Make sure it has no freedom for the assistant to choose paths. They are good at walking the straight roads.&lt;/p&gt;&lt;h2&gt;The Checklist&lt;/h2&gt;&lt;p&gt;Distilled to a grocery list for the impatient:&lt;/p&gt;&lt;p&gt;▸ Use persistent rules, not per-session rituals&lt;br/&gt;
▸ Define workflows with explicit start/stop words&lt;br/&gt;
▸ One architectural decision per task, maximum&lt;br/&gt;
▸ Attach good code as examples, skip the motivational preamble&lt;br/&gt;
▸ Run the full validation pipeline (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;format&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;credo&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dialyzer&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;test&lt;/code&gt;) after every step, not at the end&lt;br/&gt;
▸ Read generated tests as if they were written by someone who wants to trick you (they were not, but the effect is the same)&lt;br/&gt;
▸ Three failed iterations = kill the session, rethink the task&lt;br/&gt;
▸ Never let the assistant commit without your explicit say-so&lt;br/&gt;
▸ If you could not solve the problem yourself, the assistant cannot solve it &lt;em&gt;for&lt;/em&gt; you—it can only type faster&lt;/p&gt;&lt;p&gt;The last point deserves a tattoo. An AI assistant is a force multiplier, not a force. Multiply zero by anything you like.&lt;/p&gt;&lt;p&gt;Happy prompting.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Mocks Are Your Friends, Not Your Servants</title>
<link>https://rocket-science.ru/hacking/2026/05/17/mocks-are-your-friends</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">24nWcNfiDTqnapHrTllRG-SjwLMwHSxIpCUCdA==</guid>
<pubDate>Sun, 17 May 2026 18:03:59 +0000</pubDate>
<description>On treating mocks as protagonists of the testing narrative rather than disposable stand-ins hired to nod along</description>
<content:encoded>&lt;p&gt;There is a peculiar tradition in our industry: the moment someone says “mock,” half the room recoils as if you have just proposed replacing the database with a spreadsheet. The other half nods enthusiastically, having already replaced the database with a spreadsheet.&lt;/p&gt;&lt;p&gt;Both camps are wrong, and for the same reason: they think of “mock” as a verb.&lt;/p&gt;&lt;h2&gt;Mock Is a Noun&lt;/h2&gt;&lt;p&gt;José Valim wrote a &lt;a href=&quot;https://dashbit.co/blog/mocks-and-explicit-contracts&quot;&gt;splendid piece on the subject&lt;/a&gt; some years ago, and yet the industry continues to collectively ignore its central thesis with the dedication of a cat ignoring an expensive toy. The key insight is disarmingly simple: &lt;strong&gt;consider “mock” to be a noun, never a verb.&lt;/strong&gt; You do not “mock a module.” You create a mock—an entity, a collaborator, a contract participant—that implements a well-defined behaviour.&lt;/p&gt;&lt;p&gt;The difference is not cosmetic. When you “mock” a function (verb), you are lying to your test. You are saying: “pretend this hole in the wall is a door.” When you create a mock (noun), you are building an actual door—one that opens and closes according to the same interface contract as the production door, just perhaps into a smaller room.&lt;/p&gt;&lt;h2&gt;Stubs Are for Candles, Not for Software&lt;/h2&gt;&lt;p&gt;The prevailing use of mocks in the wild amounts to: “I need this function to return 42 so my other function does not crash.” This is not testing. This is bribery. You have paid off a witness to give the testimony you wanted, and now you are surprised when the real trial goes sideways.&lt;/p&gt;&lt;p&gt;A mock that merely returns canned responses is a stub wearing a fake moustache. It tells you nothing about whether your code actually &lt;em&gt;interoperates&lt;/em&gt; with the expected behaviour. It only tells you that, given a universe where everything behaves exactly as you imagined, your code does not crash. Congratulations—that was never the hard part.&lt;/p&gt;&lt;p&gt;The hard part is the contract. Does your code send the right messages? Does it handle the responses defined by the behaviour? Does it &lt;em&gt;respect the protocol&lt;/em&gt;, not just survive it? A proper mock enforces these questions. A stub sweeps them under the carpet and charges you for the cleaning.&lt;/p&gt;&lt;h2&gt;Promote Your Mocks to Lead Actors&lt;/h2&gt;&lt;p&gt;Here is the heresy: mocks should not sit quietly in the corner of your test, responding when spoken to like well-trained waitstaff. They should be protagonists. They should &lt;em&gt;drive&lt;/em&gt; the testing narrative.&lt;/p&gt;&lt;p&gt;Consider a finite state machine. It transitions through states, fires callbacks, notifies listeners. In production, the listener might persist data, send emails, ring bells. In tests, you do not care about the bells. You care about: did the FSM reach state X with payload Y after event Z?&lt;/p&gt;&lt;p&gt;A mock-as-protagonist answers this question directly. It is not merely absorbing calls—it is &lt;em&gt;reporting back&lt;/em&gt; to the test, asserting that the system did what it promised. The mock becomes your embedded journalist, filing reports from inside the process under test.&lt;/p&gt;&lt;p&gt;This is a fundamentally different posture. The mock is no longer a shift worker you hire to stand in for the real employee. It is a first-class participant in the test, with its own responsibilities, its own assertions, its own voice.&lt;/p&gt;&lt;h2&gt;OTP, Race Conditions, and the Debugger You Deserve&lt;/h2&gt;&lt;p&gt;Anyone who has tested concurrent OTP systems knows the special joy of a test that passes ninety-nine times and fails on the hundredth, always at random time, always on CI, never on your machine. The core problem is structural: you fire an asynchronous message and then try to assert about a state that may or may not have been reached yet. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Process.sleep(200)&lt;/code&gt; is not a solution—it is a prayer in milliseconds.&lt;/p&gt;&lt;p&gt;Mocks offer something better. When a mock is registered as a listener, every state transition sends a message back to the test process. You can then use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert_receive&lt;/code&gt; to &lt;em&gt;wait&lt;/em&gt; for the transition, with a timeout, deterministically. No sleep. No prayer. No coin flip.&lt;/p&gt;&lt;p&gt;In effect, the mock becomes a breakpoint in a debugger you never had to open. It declares: “I expect to be called with &lt;em&gt;these&lt;/em&gt; arguments, in &lt;em&gt;this&lt;/em&gt; order,” and it sends a message to the test process confirming each call. You get both the expectation (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expect/3&lt;/code&gt;) and the synchronization (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert_receive/2&lt;/code&gt;) in one mechanism. The mock is simultaneously the probe and the signal.&lt;/p&gt;&lt;p&gt;This is not theoretical. In OTP, where processes communicate via message passing and the scheduler is free to interleave execution in whatever order amuses it most, this pattern transforms flaky tests into deterministic ones. You are no longer guessing when the process reached the desired state. You are being &lt;em&gt;told&lt;/em&gt;.&lt;/p&gt;&lt;h2&gt;Listeners, Visibility, and the Joy of Seeing What Happens&lt;/h2&gt;&lt;p&gt;There is a secondary benefit that deserves its own paragraph: visibility.&lt;/p&gt;&lt;p&gt;When you plug a mock in as a listener—not as a replacement for an implementation, but as an &lt;em&gt;observer&lt;/em&gt; of one—the test code becomes radically more readable. Each assertion is a statement about what the system &lt;em&gt;did&lt;/em&gt;, not about what you had to simulate. The reader does not need to mentally reconstruct the production flow from a pile of stubs. The flow is right there, reported by the mock, step by step.&lt;/p&gt;&lt;p&gt;Test code that reads like a narrative of actual system behaviour is test code that people trust. And test code that people trust is test code that people maintain. And test code that people maintain is test code that catches bugs. The chain of causation is longer than a Dickensian sentence, but every link holds.&lt;/p&gt;&lt;h2&gt;Finitomata: A Case Study in Mock-Driven Testing&lt;/h2&gt;&lt;p&gt;All of this is not armchair philosophy. The &lt;a href=&quot;https://github.com/am-kantox/finitomata/blob/main/lib/finitomata/test/ex_unit.ex&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Finitomata.ExUnit&lt;/code&gt;&lt;/a&gt; module is a working testing framework built entirely on this premise. It uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Mox&lt;/code&gt; not as a crutch but as the foundation.&lt;/p&gt;&lt;p&gt;The setup declares a mock listener. The mock is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;allow&lt;/code&gt;-ed to the FSM process and given &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expect&lt;/code&gt;ations for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;after_transition/3&lt;/code&gt; callbacks. Each expectation sends &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:on_transition, id, state, payload}&lt;/code&gt; back to the test process. The test then walks the FSM through its transitions and asserts each state deterministically via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert_receive&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The result looks like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;test_path &amp;quot;respectful passenger&amp;quot;, %{passengers: initial_passengers} do
  :coin_in -&amp;gt;
    assert_state :opened do
      assert_payload do
        data.passengers ~&amp;gt; ^initial_passengers
      end
    end

  :walk_in -&amp;gt;
    assert_state :closed do
      assert_payload do
        data.passengers ~&amp;gt; one_more when one_more == 1 + data.passengers
      end
    end

  :switch_off -&amp;gt;
    assert_state :switched_off
    assert_state :*
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;No &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Process.sleep&lt;/code&gt;. No polling. No race conditions. Each transition is confirmed by the mock-listener before the test proceeds. And crucially: &lt;em&gt;intermediate&lt;/em&gt; states—including those triggered by automatic (bang!) transitions that the test never explicitly fires—are observable and assertable, because the mock reports every single one.&lt;/p&gt;&lt;p&gt;The mock here is not a stand-in. It is the entire testing infrastructure. Remove it, and you are back to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Process.sleep(200)&lt;/code&gt; and crossed fingers.&lt;/p&gt;&lt;h2&gt;The Moral&lt;/h2&gt;&lt;p&gt;Mocks have suffered from a branding problem. They were introduced to the mainstream as “a way to avoid calling the real thing,” which is roughly equivalent to introducing a violin as “a way to avoid silence.” Technically correct, but missing the point so thoroughly that it constitutes its own genre of wrongness.&lt;/p&gt;&lt;p&gt;A mock is a collaborator. A contract enforcer. A synchronisation primitive. A visibility layer. A debugger breakpoint. A protagonist.&lt;/p&gt;&lt;p&gt;Treat it accordingly, and your tests will repay you with the one thing no amount of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Process.sleep&lt;/code&gt; can buy: confidence.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Running local models on an M4 with 24GB memory | jola.dev</title>
<link>https://jola.dev/posts/running-local-models-on-m4</link>
<enclosure type="image/jpeg" length="0" url="https://jola.dev/images/og-image-2b7872671fc7c11e464dac899d8d3068.png?vsn=d"></enclosure>
<guid isPermaLink="false">eciczdkFiUmNnZYmmB-tlkiY4-avgJJsf1_MrQ==</guid>
<pubDate>Tue, 12 May 2026 02:29:47 +0000</pubDate>
<description>Experiments with getting usable outputs out of local models on a standard Macbook</description>
<content:encoded>&lt;p&gt;
I’ve been experimenting with running local models on and off for a bit and I’ve finally found a setup that seems to work reasonably. It’s nothing like the output of a SOTA model, but the excitement of being able to have a local model do basic tasks, research, and planning, more than makes up for it! No internet connection required! Not to mention that it’s a way of reducing your dependence on big US tech, even if just a tiny bit.&lt;/p&gt;&lt;p&gt;
I gotta say though, it’s not easy to get this stuff set up. First you have to choose how you’re running the model: &lt;a href=&quot;https://ollama.com/&quot;&gt;Ollama&lt;/a&gt;, &lt;a href=&quot;https://github.com/ggml-org/llama.cpp&quot;&gt;llama.cpp&lt;/a&gt; or &lt;a href=&quot;https://lmstudio.ai/&quot;&gt;LM Studio&lt;/a&gt;. Each one comes with its own quirks and limitations, and they don’t offer all the same models. Then of course, you have to pick your model. You want the best model available that fits in memory and still gives you enough headroom to run your regular assortment of Electron apps, not to mention something where you can have at least a 64K context window, but ideally 128K or more. Most recently I’ve tried Qwen 3.6 Q3, GPT-OSS 20B, Devstral Small 24B, which all technically fit in memory but were in practice unusable, and Gemma 4B that would run fine but really struggle with tool use.&lt;/p&gt;&lt;p&gt;
Then there’s a plethora of configuration options to tweak. From the more well-known, like temperature, to more esoteric options like K Cache Quantization Type. Many of these tools come with a basic recommended set of options, but the appropriate ones can depend on things like whether you’re enabling thinking or not!&lt;/p&gt;&lt;h2&gt;
Qwen 3.5-9B (4b quant)&lt;/h2&gt;&lt;p&gt;
&lt;code class=&quot;makeup ok&quot;&gt;qwen3.5-9b@q4_k_s&lt;/code&gt; (&lt;a href=&quot;https://huggingface.co/unsloth/Qwen3.5-9B-GGUF&quot;&gt;HuggingFace link&lt;/a&gt;) is the best model I’ve gotten working with a reasonable ~40 tokens per second, thinking enabled, successful tool use, and a 128K context window, running on LM Studio. Compared to a SOTA model, it gets distracted more easily, sometimes it gets stuck in loops, it’ll misinterpret asks etc. But it’s surprisingly good for something that can run on a 24GB Macbook Pro while leaving space for lots of other things running too!&lt;/p&gt;&lt;p&gt;
These are the recommended settings for thinking mode and coding work:&lt;/p&gt;&lt;blockquote&gt;
  &lt;p&gt;
Thinking mode for precise coding tasks (e.g., WebDev):  &lt;/p&gt;
  &lt;p&gt;
temperature=0.6, top_p=0.95, top_k=20, min_p=0.0, presence_penalty=0.0, repetition_penalty=1.0  &lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;
To enable thinking I also had to select the model, go to configuration, scroll to the bottom of the Inference tab, and add &lt;code class=&quot;makeup ok&quot;&gt;{%- set enable_thinking = true %}&lt;/code&gt; to the Prompt Template.&lt;/p&gt;&lt;p&gt;
I’ve been using it through both &lt;a href=&quot;https://pi.dev/&quot;&gt;pi&lt;/a&gt; and &lt;a href=&quot;https://opencode.ai/&quot;&gt;OpenCode&lt;/a&gt;. I still haven’t quite made my mind up on with one I prefer. Pi feels a bit snappier, but although I really appreciate the idea of the harness building itself and all that customization, I can’t help but wish it came with some sensible defaults. I feel like you could easily end up spending more time tweaking your pi set up to be just right, than you do on your actual projects!&lt;/p&gt;&lt;h2&gt;
Pi setup&lt;/h2&gt;&lt;p&gt;
Here’s the &lt;code class=&quot;makeup ok&quot;&gt;~/.pi/agent/models.json&lt;/code&gt;:&lt;/p&gt;&lt;h2&gt;
OpenCode setup&lt;/h2&gt;&lt;p&gt;
&lt;code class=&quot;makeup ok&quot;&gt;~/.config/opencode/opencode.json&lt;/code&gt;&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup ok&quot;&gt;{
  &amp;quot;$schema&amp;quot;: &amp;quot;https://opencode.ai/config.json&amp;quot;,
  &amp;quot;provider&amp;quot;: {
    &amp;quot;lmstudio&amp;quot;: {
      &amp;quot;npm&amp;quot;: &amp;quot;@ai-sdk/openai-compatible&amp;quot;,
      &amp;quot;name&amp;quot;: &amp;quot;LM Studio (local)&amp;quot;,
      &amp;quot;options&amp;quot;: {
        &amp;quot;baseURL&amp;quot;: &amp;quot;http://127.0.0.1:1234/v1&amp;quot;
      },
      &amp;quot;models&amp;quot;: {
        &amp;quot;qwen3.5-9b@q4_k_s&amp;quot;: {
          &amp;quot;name&amp;quot;: &amp;quot;Qwen 3.5 9B Q4_K_S&amp;quot;,
          &amp;quot;tools&amp;quot;: true,
          &amp;quot;context_length&amp;quot;: 131072,
          &amp;quot;max_tokens&amp;quot;: 32768
        }
      }
    }
  },
  &amp;quot;model&amp;quot;: &amp;quot;lmstudio/qwen3.5-9b@q4_k_s&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;
Comparison with SOTA models&lt;/h2&gt;&lt;p&gt;
A model like Qwen 3.5 9B (Q4) is nowhere near being able to independently solve complex problems over longer period of time, like SOTA models are. There’s no point in asking it to build an entire app in one go, you’ll just burn your thighs without getting anything out of it.&lt;/p&gt;&lt;p&gt;
What does work is a more interactive workflow where you’re clearly communicating with the model step by step, and giving it a lot of guidance. I’m sure that sounds pointless to many of you, why use a model where you have to babysit it as it works, but I actually found that it encouraged me to be more engaged. The downside to working with SOTA models is that they make it too easy to offload all cognitive effort, even where you’re trying to actively prevent that from happening.&lt;/p&gt;&lt;p&gt;
With the local modal I have to take on a lot more of the thinking and planning, I have to be a lot more specific, but it will still act as a research assistant, a rubber duck, and a savant with instant recall of a lot of programming language details and command line evocations. It’s not the 10x productivity boost that the big AI companies market, but it’s something, and it’s interesting.&lt;/p&gt;&lt;p&gt;
To give you an idea of some things that work and don’t, here are some very basic examples.&lt;/p&gt;&lt;h3&gt;
Example 1:&lt;/h3&gt;&lt;p&gt;
I wanted to bump the Elixir linter &lt;code class=&quot;makeup ok&quot;&gt;credo&lt;/code&gt; to the latest version and I got warnings out of my code. As an experiment, I asked Qwen to take a look.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup ok&quot;&gt;me:
  run mix credo --strict and suggest how to solve it, don&amp;#39;t edit

Qwen:
    Credo Warnings
    There are 4 warnings in your test files. The issue is using length/1 to check if lists are non-empty. Credo recommends comparing against an empty list instead.
    Warnings:
    1. test/jola_dev_web/controllers/blog_controller_test.exs:11
       - assert length(conn.assigns.posts) &amp;gt; 0
    2. test/jola_dev_web/controllers/blog_controller_test.exs:22
       - assert length(conn.assigns.posts) &amp;gt; 0
    3. test/jola_dev/blog_test.exs:10
       - assert length(posts) &amp;gt; 0
    4. test/jola_dev/blog_test.exs:23
       - assert length(tags) &amp;gt; 0
    How to Fix
    Replace each length(list) &amp;gt; 0 with list != []:
    blog_controller_test.exs:
    # Line 11
    assert conn.assigns.posts != []
    # Line 22
    assert conn.assigns.posts != []
    blog_test.exs:
    # Line 10
    assert posts != []
    # Line 23
    assert tags != []
    This is more idiomatic in Elixir and avoids the overhead of computing the list length.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
I asked Qwen to make the edits and it did 4 parallel edits, very clean. Overall this was a very simple task that I could’ve done myself by tabbing back and forth between the terminal and my editor to get the line numbers to edit. Not a big difference in effort, but nice and convenient.&lt;/p&gt;&lt;h3&gt;
Example 2:&lt;/h3&gt;&lt;p&gt;
After some dependency bumps I had a dependabot PR with git conflicts that dependabot for some reason was refusing to rebase. I pulled it down, rebased, and asked Qwen to take a look. It was a very simple conflict to resolve, just take the newer version of each, and the model recognized that.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup ok&quot;&gt;I can see the conflict. Here&amp;#39;s what&amp;#39;s happening:
HEAD (your current branch):
- sentry: 13.0.1
- tailwind: 0.3.1
Other side (commit being rebased):
- sentry: 12.0.3
- tailwind: 0.4.1
You have divergent dependency bumps. Here are your options:
Option 1: Take both updates (recommended)
- Keep sentry 13.0.1 from HEAD
- Keep tailwind 0.4.1 from the other side
- This gives you the latest of both deps
Option 2: Take only sentry update
- Keep sentry 13.0.1
- Keep tailwind 0.3.1
Option 3: Take only tailwind update
- Keep sentry 12.0.3
- Keep tailwind 0.4.1
Which approach do you want to take?&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
However, when asked to make the change, it forgot to make the edit and instead just tried to add the changes and continue the rebase, with the conflict markers still in place.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;makeup ok&quot;&gt;&amp;gt; Thinking: The user wants to take both updates - sentry 13.0.1 and tailwind 0.4.1. I need to resolve the conflict by keeping the HEAD version for sentry and the other side version for tailwind. I&amp;#39;ll use git add to stage the resolved file.
----
# Stage resolved mix.lock and continue rebase
$ git add mix.lock &amp;amp;&amp;amp; git rebase --continue
Vim: Warning: Output is not to a terminal
Vim: Warning: Input is not from a terminal
&amp;quot;~/workspace/mixtli/.git/COMMIT_EDITMSG&amp;quot; 32L, 1047BBump tailwind from 0.3.1 to 0.4.1
Bumps [tailwind](https://github.com/phoenixframework/tailwind) from 0.3.1 to 0.44.1.
- [Changelog](https://github.com/phoenixframework/tailwind/blob/main/CHANGELOG.mmd)
- [Commits](https://github.com/phoenixframework/tailwind/compare/v0.3.1...v0.4.11)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;
Additionally it failed to recognize that &lt;code class=&quot;makeup ok&quot;&gt;git rebase --continue&lt;/code&gt; opens an editor, and OpenCode then proceeded to hang, although that might just have been a one-off.&lt;/p&gt;&lt;h2&gt;
Conclusion&lt;/h2&gt;&lt;p&gt;
Anyway, LLMs are incredibly flexible and there’s a million things even a local model could do. Your imagination is the limit. Local models have serious tradeoffs, but they come with some pretty attractive benefits:&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;
No internet connection required, you can work on the plane!  &lt;/li&gt;
  &lt;li&gt;
The cost is limited to the electricity you’re using, assuming you were gonna buy a computer anyway. No subscription required.  &lt;/li&gt;
  &lt;li&gt;
There’s still going to be a serious environmental cost of training these models, but the open model companies are nowhere near the top of the list in environmental impact, and using your own hardware means less data centers.  &lt;/li&gt;
  &lt;li&gt;
It’s fun to tinker.  &lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;
LLMs have had a huge impact on our world, and much of it not great, but it’s obvious that they’re here to stay. Experimenting with local models feels like a more sustainable and positive way to interact with this technology. And honestly, it’s a lot of fun, even when it does the wrong thing!&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Elixir versus Python for Data Science - DockYard</title>
<link>https://dockyard.com//blog/2022/07/12/elixir-versus-python-for-data-science</link>
<enclosure type="image/jpeg" length="0" url="https://assets.dockyard.com/images/Elixir versus Python.jpg"></enclosure>
<guid isPermaLink="false">EFhYYmahrkt-Hhp6iYnXXHCHNEbDNgZPOnXdYw==</guid>
<pubDate>Fri, 08 May 2026 19:20:08 +0000</pubDate>
<description>Machine learning capabilities with Elixir have expanded significantly in recent years. Sean Moriarity discusses some of those developments and compares Elixir&#39;s machine learning abilities with Python&#39;s.</description>
<content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;&lt;p&gt;Over the last year, thanks to the efforts of the amazing Elixir community, the Elixir &lt;a href=&quot;https://dockyard.com/blog/2022/05/10/deploying-machine-learning-models-with-elixir&quot;&gt;machine learning&lt;/a&gt; ecosystem has grown at an impressive rate, with more and more libraries filling more and more gaps in the Elixir data science and machine learning ecosystem.&lt;/p&gt;&lt;p&gt;A common argument against using Nx for a new &lt;a href=&quot;https://dockyard.com/blog/2022/06/21/what-is-machine-learning-anyway&quot;&gt;machine learning&lt;/a&gt; project is its perceived lack of a library/support for some common task that is available in Python. In this post, I&amp;#39;ll do my best to highlight areas where this is &lt;strong&gt;not&lt;/strong&gt; the case, and compare and contrast Elixir projects with their Python equivalents. Additionally, I&amp;#39;ll discuss areas where the Elixir ecosystem still comes up short, and using Nx for a new project might not be the best idea.&lt;/p&gt;&lt;h2&gt;Numerical Computing&lt;/h2&gt;&lt;p&gt;The obvious place to start this post is to compare Nx to its Python equivalents. At its core, Nx is intended to serve as a &lt;a href=&quot;https://numpy.org&quot;&gt;NumPy&lt;/a&gt; equivalent with support for automatic differentiation and acceleration via GPUs. In this respect, its main inspiration is &lt;a href=&quot;https://github.com/google/jax&quot;&gt;JAX&lt;/a&gt;—a Python library that supports automatic differentiation and JIT compilation to accelerators via composable function transformations.&lt;/p&gt;&lt;p&gt;The Nx API is (intentionally) considerably smaller than the NumPy API. Because Nx relies on JIT compilation, the API builds around a smaller amount of powerful primitive operations which &lt;a href=&quot;https://dockyard.com/blog/2021/04/08/up-and-running-nx&quot;&gt;can be used to build out more complex functions&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;NumPy does not have the same luxury, instead needing to rely on specialized implementations for most of the functions in its API. JAX also builds on a set of core primitive operations; however, they intentionally provide wrappers around the NumPy API due to its ubiquity in numerical computing community.&lt;/p&gt;&lt;p&gt;There are pros and cons to having a smaller API. From a learning perspective, a beginner can reasonably pick up and understand 90% of the functions in the Nx API rather quickly.&lt;/p&gt;&lt;p&gt;Unfortunately, this trade-off means Nx implementations can at times be more verbose than their NumPy counterparts. Due to the sheer size of the API, there are often times when a few lines of NumPy translate to considerably more lines in Nx. Additionally, the Nx API falls short of the NumPy API in some areas. For example, Nx PRNG support is not as feature complete as JAX/NumPy, the Nx Linear Algebra module is not as in-depth as NumPy, and Nx does not have support for string data types.&lt;/p&gt;&lt;p&gt;The API shortcomings of Nx are mostly active areas of work. Even with these shortcomings, I&amp;#39;ve found I can be just as productive writing Nx as I can writing NumPy.&lt;/p&gt;&lt;p&gt;From a performance perspective, if you&amp;#39;re using the &lt;a href=&quot;https://github.com/elixir-nx/nx/&quot;&gt;EXLA&lt;/a&gt; compiler, Nx will have (mostly) equivalent performance to JAX. Nx/EXLA relies on the same JIT compiler as JAX in XLA. That means that essentially all of the areas that JAX beats NumPy, Nx will also beat NumPy; and in all of the areas that NumPy beats JAX, NumPy will also beat Nx.&lt;/p&gt;&lt;p&gt;One advantage that Nx has over JAX is its first-class support for pluggable compilers and backends. While the JAX project seems to be moving in the direction of supporting multiple pluggable compilers/runtimes, &lt;a href=&quot;https://dockyard.com/blog/2022/03/15/nx-for-absolute-beginners&quot;&gt;Nx was built with this flexibility in mind&lt;/a&gt;, and thus is positioned for rapid integration with any existing/future tensor compilers and backends.&lt;/p&gt;&lt;p&gt;JAX is ahead in terms of parallelization; however, there are plans to integrate parallel primitives into the Nx API on the roadmap. Given that Nx can build on the same parallelism/sharding implementations as JAX in XLA, Nx can catch up to JAX relatively quickly in this respect.&lt;/p&gt;&lt;h2&gt;Deep Learning&lt;/h2&gt;&lt;p&gt;One of the initial ambitions for the Nx project was to support creating and training deep-learning-type models in Elixir. This is now possible with the &lt;a href=&quot;https://github.com/elixir-nx/axon&quot;&gt;Axon&lt;/a&gt; library.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://dockyard.com/blog/2022/01/11/getting-started-with-axon&quot;&gt;Axon is built directly on top of Nx&lt;/a&gt; and thus can take advantage of all of the things Nx offers, including JIT compilation and automatic differentiation. Axon most directly compares to tools like &lt;a href=&quot;https://pytorch.org&quot;&gt;PyTorch&lt;/a&gt; and &lt;a href=&quot;https://tensorflow.org&quot;&gt;TensorFlow&lt;/a&gt;/&lt;a href=&quot;https://keras.io&quot;&gt;Keras&lt;/a&gt; in the Python ecosystem.&lt;/p&gt;&lt;p&gt;From an API perspective, Axon is somewhat even with both PyTorch and TensorFlow/Keras. Aside from Attention/Transformer layers (which are on the roadmap), Axon has an essentially identical offering of model building blocks. Additionally, with its custom layer API, creating and using new layers is as easy as defining an Nx implementation of the layer. More or less any model you can create and perform inference in PyTorch/TensorFlow, you can also create and perform inference in with Axon.&lt;/p&gt;&lt;p&gt;Axon also has a robust training API inspired by libraries in the Python ecosystem such as &lt;a href=&quot;https://pytorch.org/ignite/index.html&quot;&gt;PyTorch Ignite&lt;/a&gt; and &lt;a href=&quot;https://www.pytorchlightning.ai/&quot;&gt;PyTorch Lightning&lt;/a&gt;. The training API supports out-of-the-box callbacks such as model checkpoints, early stopping, and model validation, as well as an API for integrating custom callbacks. Similar to Keras, the Axon training API offers increasing levels of flexibility at increasing levels of complexity. In other words, you can sacrifice simplicity to have more control over training your models.&lt;/p&gt;&lt;p&gt;One area of concern when migrating to Elixir is the ability to make use of pre-trained models. Thanks to &lt;a href=&quot;https://github.com/elixir-nx/axon_onnx&quot;&gt;AxonOnnx&lt;/a&gt; this is possible for (almost) any model you might have.&lt;/p&gt;&lt;p&gt;If you&amp;#39;re able to export an ONNX version of your model (e.g using &lt;code class=&quot;py-px text-sm leading-6 rounded&quot;&gt;torch.onnx&lt;/code&gt; or &lt;a href=&quot;https://github.com/onnx/tensorflow-onnx&quot;&gt;tf2onnx&lt;/a&gt;), you can probably import your model with Axon. AxonOnnx has even been tested to work with pre-trained transformers from the popular &lt;a href=&quot;https://huggingface.co/transformers&quot;&gt;transformers&lt;/a&gt; library.&lt;/p&gt;&lt;p&gt;While Axon supports importing pre-trained models, there are still some aspects of working with pre-trained models that need ironing out. For example, fine-tuning, while possible, does not yet have a first-class API in Axon. Additionally, features such as mixed-precision and multi-device training that make training large models possible are not 100% supported in Axon yet.&lt;/p&gt;&lt;h2&gt;Traditional Machine Learning&lt;/h2&gt;&lt;p&gt;Along with deep learning, gradient boosting and decision tree algorithms are perhaps the most popular machine learning algorithms in use. These classes of algorithms typically outperform deep learning with tabular and time-series data, and are often significantly less expensive to train and deploy.&lt;/p&gt;&lt;p&gt;Unfortunately, this is an area still under active development in the Elixir ecosystem. Python has popular libraries such as &lt;a href=&quot;https://xgboost.readthedocs.io/en/stable/&quot;&gt;XGBoost&lt;/a&gt;, but there is still no Elixir equivalent. I expect this to change over the next six months; however, for the time being, Elixir is behind in this area.&lt;/p&gt;&lt;p&gt;Elixir also falls short of Python in other traditional machine learning applications. While Python has the excellent &lt;a href=&quot;https://scikit-learn.org&quot;&gt;scikit-learn&lt;/a&gt;, Elixir has the relatively new &lt;a href=&quot;https://github.com/elixir-nx/scholar&quot;&gt;Scholar&lt;/a&gt; library. Because Scholar is new, it&amp;#39;s lacking in features that allow it to be a competitive alternative to sklearn. This is another area of active development on the Nx roadmap, and thus I expect things to look significantly different here in the next six months.&lt;/p&gt;&lt;h2&gt;Data Analysis&lt;/h2&gt;&lt;p&gt;Essentially any data scientist that has worked with Python for any amount of time is familiar with the &lt;a href=&quot;https://pandas.pydata.org/&quot;&gt;pandas&lt;/a&gt; library for data analysis. Pandas is a library for working with structured, columnar data. It&amp;#39;s popular as a library for any sort of analysis or munging tasks you might need to perform. The Elixir equivalent to Pandas is &lt;a href=&quot;https://github.com/elixir-nx/explorer&quot;&gt;Explorer&lt;/a&gt;. Explorer is built on top of the &lt;a href=&quot;https://docs.rs/polars/latest/polars/&quot;&gt;polars&lt;/a&gt; library which implements DataFrames in Rust.&lt;/p&gt;&lt;p&gt;From an API perspective, the Explorer API is different from what you might be used to using with Python. Given that Elixir is a functional language, the Explorer library builds on immutable abstractions, which can feel quite different for somebody migrating from Python and pandas&amp;#39; mutability. Explorer, like Nx, is notably more succinct than its Python counterpart. Despite this, there isn&amp;#39;t much you &lt;em&gt;can&amp;#39;t&lt;/em&gt; do in Explorer that you can do in Pandas.&lt;/p&gt;&lt;p&gt;From a performance perspective, Explorer benefits from the speed of Polars. There are a number of &lt;a href=&quot;https://blog.dominodatalab.com/polars-a-lightning-fast-dataframes-library&quot;&gt;articles&lt;/a&gt; that laud Polars as the fastest DataFrame library. Given that Explorer builds on that performance, you might see significant performance improvements migrating from Pandas to Explorer.&lt;/p&gt;&lt;h2&gt;Data Presentation/Visualization&lt;/h2&gt;&lt;p&gt;In data science, presentations and visualizations are where the money is made. Having a good tool for presenting and visualizing data is a must for any language looking to position itself in the data science sphere. Python has a number of excellent visualization libraries such as &lt;a href=&quot;https://plotly.com/python/plotly-express/&quot;&gt;Plotly Express&lt;/a&gt; and &lt;a href=&quot;https://matplotlib.org/&quot;&gt;matplotlib&lt;/a&gt;. The Elixir equivalent is its &lt;a href=&quot;https://github.com/livebook-dev/vega_lite&quot;&gt;VegaLite&lt;/a&gt; library which provides bindings around the VegaLite graphics library.&lt;/p&gt;&lt;p&gt;Functionally, you can get essentially equivalent visualizations from both Elixir and Python. The VegaLite API might feel unfamiliar to users coming from Plotly Express and matplotlib; however, the abstractions are incredibly powerful and allow for composing and creating evermore complex graphics with code.&lt;/p&gt;&lt;p&gt;For the most part, I&amp;#39;ve found it possible to perform equivalent visualizations in both Elixir and Python; however, Python seems to have an edge in network visualizations and geographic visualizations. Elixir has no equivalents to Python&amp;#39;s &lt;a href=&quot;https://networkx.org/&quot;&gt;NetworkX&lt;/a&gt; and the &lt;a href=&quot;https://python-visualization.github.io/folium/&quot;&gt;Folium&lt;/a&gt; library. I suspect with companies like &lt;a href=&quot;https://felt.com/&quot;&gt;Felt&lt;/a&gt; using Elixir in the map-making space that we might see geographic visualizations in Elixir improve (fingers crossed).&lt;/p&gt;&lt;p&gt;The Python ecosystem also has a number of libraries concerned with Dashboard creation. Tools such as &lt;a href=&quot;https://plotly.com/dash/&quot;&gt;Dash&lt;/a&gt; allow for the creation of interactive demos with a few lines of code. There are no direct equivalents in Elixir just yet; however, the direction of &lt;a href=&quot;https://livebook.dev&quot;&gt;Livebook&lt;/a&gt; is promising for the prospects of interactive and shareable demos in Elixir.&lt;/p&gt;&lt;h2&gt;Pipelines / Orchestration&lt;/h2&gt;&lt;p&gt;Whether it be training large models or creating production-ready data ingest/management solutions, the task of data orchestration and pipeline is an important one for data science/machine learning. There are a large number of Python libraries built specifically to create and orechestrate data pipelines. In Elixir, there are a few; however, this is an area I would personally argue Elixir has a strong edge over Python. Given the Elixir is built on the BEAM, which is designed for concurrency, the task of &lt;a href=&quot;https://pragprog.com/titles/sgdpelixir/concurrent-data-processing-in-elixir/&quot;&gt;Concurrent Data Processing in Elixir&lt;/a&gt; is a natural extension of the language. Python is just not designed with concurrency in mind. From simple language-level abstractions such as &lt;code class=&quot;py-px text-sm leading-6 rounded&quot;&gt;Task&lt;/code&gt; to library-level abstractions such as &lt;code class=&quot;py-px text-sm leading-6 rounded&quot;&gt;Flow&lt;/code&gt; and &lt;code class=&quot;py-px text-sm leading-6 rounded&quot;&gt;Broadway&lt;/code&gt;, creating scalable input processing pipelines is incredibly easy with Elixir by default.&lt;/p&gt;&lt;p&gt;That&amp;#39;s not to say that Python doesn&amp;#39;t have some nice libraries for achieving the same results. Both PyTorch and TensorFlow offer nice data loading abstractions in &lt;code class=&quot;py-px text-sm leading-6 rounded&quot;&gt;tf.data&lt;/code&gt; and &lt;code class=&quot;py-px text-sm leading-6 rounded&quot;&gt;DataLoader&lt;/code&gt;. Additionally, there are a number of libraries designed for building and orchestrating data pipelines at scale (e.g. &lt;a href=&quot;https://prefect.io&quot;&gt;Prefect&lt;/a&gt; and &lt;a href=&quot;https://airflow.apache.org/docs/apache-airflow/stable/python-api-ref.html&quot;&gt;AirFlow&lt;/a&gt;). The biggest advantage Elixir has in this space is that it is concurrent and fault-tolerant by design. I don&amp;#39;t think Python can ever beat Elixir in this regard.&lt;/p&gt;&lt;h2&gt;Domain-Specific Libraries&lt;/h2&gt;&lt;p&gt;There are a number of &amp;quot;domain-specific&amp;quot; libraries that don&amp;#39;t neatly fall into any of the categories I&amp;#39;ve written so far, but which are worth a brief mention in this article—namely &lt;strong&gt;computer vision&lt;/strong&gt;, &lt;strong&gt;natural language processing&lt;/strong&gt;, and &lt;strong&gt;signal processing&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;There are a number of computer vision-related libraries in the Python ecosystem that generally streamline the task of working with images. This includes &lt;a href=&quot;https://pillow.readthedocs.io/en/stable/&quot;&gt;Pillow&lt;/a&gt; and &lt;a href=&quot;https://docs.opencv.org/4.x/d6/d00/tutorial_py_root.html&quot;&gt;OpenCV&lt;/a&gt; among others. In the Elixir ecosystem, there is &lt;a href=&quot;https://github.com/elixir-nx/evision&quot;&gt;Evision&lt;/a&gt;, which provides bindings to OpenCV implementations. From both an API and performance perspective, this means working with images in Evision will be somewhat similar to working with images in Python&amp;#39;s OpenCV bindings.&lt;/p&gt;&lt;p&gt;For NLP tasks, Elixir does not have a library that is equivalent to Python&amp;#39;s &lt;a href=&quot;https://spacy.io&quot;&gt;spacy&lt;/a&gt; or &lt;a href=&quot;https://realpython.com/nltk-nlp-python/&quot;&gt;NLTK&lt;/a&gt;. However, it does offer bindings to Huggingface&amp;#39;s &lt;a href=&quot;https://github.com/elixir-nx/tokenizers&quot;&gt;tokenizers&lt;/a&gt;, and the ability to import a significant number of HuggingFace models for performing NLP tasks with neural networks.&lt;/p&gt;&lt;p&gt;The Elixir ecosystem still falls behind Python in the area of signal processing; however, with recent work to add FFT support to Nx, and other dedicated efforts, I expect this area to improve in the near future.&lt;/p&gt;&lt;h2&gt;Conclusion&lt;/h2&gt;&lt;p&gt;This post was meant to serve as a high-level comparison of the Elixir machine learning and data science ecosystem with the Python ecosystem. While there are still many gaps in the Elixir ecosystem, the progress over the last year has been rapid. Almost every library I&amp;#39;ve mentioned in this post is less than two years old, and I suspect there will be many more projects to fill some of the gaps I&amp;#39;ve mentioned in the coming months.&lt;/p&gt;&lt;p&gt;If you&amp;#39;re interested in helping in any of our areas of active development, join us at the &lt;a href=&quot;https://erlef.org/&quot;&gt;EEF ML Working Group&lt;/a&gt; to drive machine learning on the BEAM forward. Until next time!&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Passkey Sign In with Elixir and Phoenix</title>
<link>https://shadowfacts.net/2023/phoenix-passkeys/</link>
<enclosure type="image/jpeg" length="0" url="https://shadowfacts.net/shadowfacts.png"></enclosure>
<guid isPermaLink="false">VczlMu0F-895eAWwo-kF-ZStbBe9ejiafL56TQ==</guid>
<pubDate>Fri, 08 May 2026 09:29:25 +0000</pubDate>
<description>The outer part of a shadow is called the penumbra.</description>
<content:encoded>&lt;p&gt;Passkeys are a replacement for passwords that use public/private cryptographic key pairs for login in a way that can be more user-friendly and resistant to a number of kinds of attacks. Let’s implement account registration and login with passkeys in a simple Phoenix web app.&lt;/p&gt;&lt;p&gt;This work is heavily based on &lt;a href=&quot;https://www.imperialviolet.org/2022/09/22/passkeys.html&quot;&gt;this article&lt;/a&gt; by Adam Langley, which provides a great deal of information about implementing passkeys. My goal here is to fill in some more of the details, and provide some Elixir-specific information. As with Adam’s article, I’m not going to use any WebAuthn libraries (even though that may be advisable from a security/maintenance perspective) since I think it’s interesting and helpful to understand how things actually work.&lt;/p&gt;&lt;p&gt;Providing an exhaustive, production-ready implementation is a non-goal of this post. I’m going to make some slightly odd decisions for pedagogical reasons, and leave some things incomplete. That said, I’ll try to note when I’m doing so.&lt;/p&gt;&lt;p&gt;To start, I’m using the default Phoenix template app (less Tailwind) in which I’ve also generated the default, controller-based &lt;a href=&quot;https://hexdocs.pm/phoenix/mix_phx_gen_auth.html&quot;&gt;authentication system&lt;/a&gt;. Some parts of the password-specific stuff have been stripped out altogether, others will get changed to fit with the passkey authentication setup we’re going to build.&lt;/p&gt;&lt;h2&gt;Database schema §&lt;/h2&gt;&lt;p&gt;The first thing we’ll need is a backend schema for passkeys. The only data we need to store for a particular passkey is a unique identifier, and the public key itself. We also want users to be able to have multiple passkeys associated with their account (since they may want to login from multiple platforms that don’t cross-sync passkeys), so the users schema will have a one-to-many relationship with the passkeys.&lt;/p&gt;&lt;p&gt;The actual model I’ll call &lt;code&gt;UserCredential&lt;/code&gt;, since that’s closer to what the WebAuthn spec calls them. Here’s the schema, it’s pretty simple:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeys.Accounts.UserCredential do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary, []}

  schema &amp;quot;users_credentials&amp;quot; do
    # DER-encoded Subject Public Key Info: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.7
    field :public_key_spki, :binary
    belongs_to :user, PhoenixPasskeys.Accounts.User
    timestamps()
  end

  def changeset(credential, attrs) do
    credential
    |&amp;gt; cast(attrs, [:id, :public_key_spki, :user_id])
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;There are a couple things to note here:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;First, we’re explicitly specifying that the primary key is a binary, since that’s what the WebAuthn API provides as the credential ID.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;public_key_spki&lt;/code&gt; field contains the data for the public key itself and the algorithm being used. We don’t care about the specific format, though, since we don’t have to parse it ourselves.&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;To the generated &lt;code&gt;User&lt;/code&gt; schema, I also added the &lt;code&gt;has_many&lt;/code&gt; side of the relationship. There’s also a migration to create the credentials table. I won’t show it here, since it’s exactly what you’d expect—just make sure the &lt;code&gt;id&lt;/code&gt; column is a binary.&lt;/p&gt;&lt;h2&gt;Registration JavaScript §&lt;/h2&gt;&lt;p&gt;WebAuthn, being a modern web API, is a JavaScript API. This is a distinct disadvantage, if your users expect to be able to login from JavaScript-less browsers. Nonetheless, we proceed with the JS. Here’s the first bit:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, () =&amp;gt; {
    const registrationForm = document.getElementById(&amp;quot;registration-form&amp;quot;);
    if (registrationForm) {
        registrationForm.addEventListener(&amp;quot;submit&amp;quot;, (event) =&amp;gt; {
            event.preventDefault();
            registerWebAuthnAccount(registrationForm);
        });
    }
});&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If we find the registration &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element (note that I’ve added an ID (and also removed the password field)), we add a submit listener to it which prevents the form submission and instead calls the &lt;code&gt;registerWebAuthnAccount&lt;/code&gt; function we’ll create next.&lt;/p&gt;&lt;p&gt;Before we get there, we’ll also write a brief helper function to check whether passkeys are actually available:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function supportsPasskeys() {
    if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
		return false;
	}
	const [conditional, userVerifiying] = await Promise.all([
		PublicKeyCredential.isConditionalMediationAvailable(),
		PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
	]);
	return conditional &amp;amp;&amp;amp; userVerifiying;
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The conditional mediation check establishes that WebAuthn conditional UI is available. This is what we’ll use for login, and is part of what makes using passkeys a nice, slick experience. Conditional UI will let us start a request for a passkey that doesn’t present anything until the user interacts with the browser’s passkey autofill UI.&lt;/p&gt;&lt;p&gt;The user-verifying platform authenticator check establishes that, well, there is a user-verifying platform authenticator. The platform authenticator part means an authenticator that’s part of the user’s device, not removable, and the user-verifying part means that the authenticator verifies the presence of the user (such as via biometrics).&lt;/p&gt;&lt;p&gt;In the &lt;code&gt;registerWebAuthnAccount&lt;/code&gt; function, the first thing we need to do is check that both these conditions are met and passkeys are supported by the browser. If not, we’ll just bail out and registration won’t be possible.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function registerWebAuthnAccount(form) {
    if (!(await supportsPasskeys())) {
        return;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Next, we’ll setup a big ol’ options object that we’ll pass to the WebAuthn API to specify what sort of credential we want. There’s going to be a whole lot of stuff here, so lets take it one piece at a time.&lt;/p&gt;&lt;p&gt;The RP is the Relying Party—that is, us, the party which relies on the credentials for authentication. The ID is the &lt;a href=&quot;https://w3c.github.io/webauthn/#rp-id&quot;&gt;domain of the RP&lt;/a&gt;—just localhost for this example, though in reality you’d need to use your actual domain in production. The name is just a user-facing name for the RP.&lt;/p&gt;&lt;p&gt;Next up is some info about the user who the credential is for. The user’s “name” will just be the email that they entered in the form. The &lt;code&gt;displayName&lt;/code&gt; value is required, per the spec, but we don’t have any other information so we just leave it blank and let the browser display the &lt;code&gt;name&lt;/code&gt; only. The ID is where this gets a little weird, since we’re just generating a random 64-byte value:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;function generateUserID() {
	const userID = new Uint8Array(64);
	crypto.getRandomValues(userID);
	return userID;
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If you were adding a passkey to an existing user account, you could use a server-specified value for the user ID and the browser would replace any existing credentials with the same user ID and RP ID. But, since the user is registering a new account at this point, we assume they don’t want to do that (and, moreover, we don’t have any ID we can use yet). The user ID will also be returned upon a successful login, which would let us look up the user that’s logging in. However, since we’re only going to allow a credential to belong to a single user, we can use the credential’s ID to uniquely determine the user.&lt;/p&gt;&lt;p&gt;Next up, we specify the types of public keys that we’ll accept. We’re going to support Ed25519, ES256, and RS256, since those are recommended by the WebAuthn spec:&lt;/p&gt;&lt;p&gt;During the login process, the server will generate a challenge value that the client will sign and the server will verify was signed with the user’s private key. The spec also requires that we provide a challenge when creating a credential, but we’re not going to use it for anything (since the user is just now creating the credential, we have nothing trusted that we can verify the initial challenge against), so we just provide an empty value:&lt;/p&gt;&lt;p&gt;Lastly, we give our requirements for authenticators that we want to use:&lt;/p&gt;&lt;p&gt;We want only the platform authenticator, not anything else, like removable security keys. We also specify that we want a resident key. This usage of “resident” is deprecated terminology that’s enshrined in the API. Really it means we want a &lt;em&gt;discoverable&lt;/em&gt; credential, so that the authenticator will surface it during the login process without us having to request the credential by ID. This is important to note, since it’s what prevents needing a separate username entry step.&lt;/p&gt;&lt;p&gt;Now that we (finally) have all the configuration options in place, we can actually proceed with the credential creation. We pass the options to &lt;code&gt;navigator.credentials.create&lt;/code&gt; to actually create the WebAuthn credential. If that fails, we’ll just take the easy way out and alert to inform the user (in an actual service, you’d probably want better error handling).&lt;/p&gt;&lt;p&gt;From the credential we get back, we need a few pieces of information. First is the decoded client data, which is an object that contains information about the credential creation request that occurred.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;clientDataJSON&lt;/code&gt; field of the response object contains the JSON-serialized object in an &lt;code&gt;ArrayBuffer&lt;/code&gt;, so we decode that to text and then parse the JSON. With the decoded object, we do a consistency check with a couple pieces of data: the type of the request, whether or not it was cross-origin, and the actual origin being used.&lt;/p&gt;&lt;p&gt;If it was not a creation request, the request was cross-origin, or the origin doesn’t match, we bail out. Note that in production, the origin should be checked against the actual production origin, not localhost. And again, in reality you’d want better error handling than just an alert.&lt;/p&gt;&lt;p&gt;Next, we need to get the authenticator data which is encoded in a binary format and pull a few pieces of data out of it. You can see the full format of the authenticator data &lt;a href=&quot;https://w3c.github.io/webauthn/#sctn-authenticator-data&quot;&gt;in the spec&lt;/a&gt;, but the parts we’re interested in are the backed-up state and the credential ID, which is part of the &lt;a href=&quot;https://w3c.github.io/webauthn/#attested-credential-data&quot;&gt;attested credential data&lt;/a&gt;.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function registerWebAuthnAccount(form) {
    // ...
    const authenticatorData = new Uint8Array(credential.response.getAuthenticatorData());
    const backedUp = (authenticatorData[32] &amp;gt;&amp;gt; 4) &amp;amp; 1;
    const idLength = (authenticatorData[53] &amp;lt;&amp;lt; 8) | authenticatorData[54];
    const id = authenticatorData.slice(55, 55 + idLength);
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We get the backed-up bit from the flags byte at offset 32. Then we get the length of the credential ID, which is encoded as a big-endian, 16-bit integer at bytes 53 and 54. The ID itself immediately follows the length, thus starting at byte 55.&lt;/p&gt;&lt;p&gt;Before proceeding, we check that the &lt;a href=&quot;https://w3c.github.io/webauthn/#sctn-credential-backup&quot;&gt;backed-up bit&lt;/a&gt; is set, indicating that the credential is backed-up and safe from the user losing the current device. If it’s not, we won’t let the user register with this passkey. I choose to do this since it’s recommended by Adam Langley’s blog post, but whether it’s actually necessary may depend on your specific circumstances.&lt;/p&gt;&lt;p&gt;The last piece of data we need out of the credential is the actual public key:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function registerWebAuthnAccount(form) {
    // ...
    const publicKey = credential.response.getPublicKey();
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And with all that in place, we can actually initiate the registration. We’ll assemble a form data payload with all of the requisite values:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function registerWebAuthnAccount(form) {
    // ...
    const body = new FormData();
    body.append(&amp;quot;_csrf_token&amp;quot;, form._csrf_token.value);
    body.append(&amp;quot;email&amp;quot;, form[&amp;quot;user[email]&amp;quot;].value);
    body.append(&amp;quot;credential_id&amp;quot;, arrayBufferToBase64(id));
    body.append(&amp;quot;public_key_spki&amp;quot;, arrayBufferToBase64(publicKey));
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We’ll use the body in a POST request to the registration endpoint. The response we get back will contain a status value to indicate whether the request was successful or not. If it was successful, the backend will have set the session cookie, and we can redirect to the home page and the new user will be logged in. If registration failed, we’ll alert the user.&lt;/p&gt;&lt;p&gt;And with that, we’re done with JavaScript (for now) and can move on to the backend part of registering an account.&lt;/p&gt;&lt;h2&gt;Creating a user §&lt;/h2&gt;&lt;p&gt;In the &lt;code&gt;UserRegistrationController&lt;/code&gt; module that comes with the Phoenix auth template, we’ll change the &lt;code&gt;create&lt;/code&gt; function. By default, it registers the user using the parameters from the signup form and then redirects to the homepage. Instead, we’re going to register using the passkey we created on the client and then respond with the JSON that our JavaScript is expecting.&lt;/p&gt;&lt;p&gt;The first thing we need to do is extract the values that were sent by the frontend and decode the base 64-encoded ones.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserRegistrationController do
  def create(conn, %{
        &amp;quot;email&amp;quot; =&amp;gt; email,
        &amp;quot;credential_id&amp;quot; =&amp;gt; credential_id,
        &amp;quot;public_key_spki&amp;quot; =&amp;gt; public_key_spki
      }) do
    credential_id = Base.decode64!(credential_id)
    public_key_spki = Base.decode64!(public_key_spki)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Those get passed to the &lt;code&gt;Accounts.register_user&lt;/code&gt; function, which we’ll update shortly to handle. If account creation succeeded, we’ll still send the confirmation email as the existing code did. After that, instead of redirecting, we’ll log the user in by setting the session cookie and then respond with the “ok” status for the frontend. If account creation fails, we’ll just respond with the “error” status so the frontend can alert the user.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserRegistrationController do
  def create(...) do
    # ...
    case Accounts.register_user(email, credential_id, public_key_spki) do
      {:ok, user} -&amp;gt;
        # Send confirmation email...

        conn
        |&amp;gt; UserAuth.log_in_user_without_redirect(user)
        |&amp;gt; json(%{status: :ok})

      {:error, _changeset} -&amp;gt;
        json(conn, %{status: :error})
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Let’s update the &lt;code&gt;register_user&lt;/code&gt; function. Instead of just creating a changeset for the user and then inserting it, we need to also create the authenticator. To avoid potentially leaving things in a broken state, we wrap both of these in a database transaction.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeys.Accounts do
  def register_user(email, credential_id, public_key_spki) do
    Repo.transaction(fn -&amp;gt;
      user =
        %User{}
        |&amp;gt; User.registration_changeset(%{email: email})
        |&amp;gt; Repo.insert()
        |&amp;gt; case do
          {:ok, user} -&amp;gt; user
          {:error, changeset} -&amp;gt; Repo.rollback(changeset)
        end
    end)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;First, we create a user with the given email. If the user creation fails, we abort and rollback the transaction. Then, we can create a credential belonging to the new user with the credential ID and public key we received from the client. As with the user, if creating the credential fails, we rollback the transaction.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeys.Accounts do
  def register_user(email, credential_id, public_key_spki) do
    Repo.transaction(fn -&amp;gt;
      # ...
      %UserCredential{}
      |&amp;gt; UserCredential.changeset(%{
        id: credential_id,
        public_key_spki: public_key_spki,
        user_id: user.id
      })
      |&amp;gt; Repo.insert()
      |&amp;gt; case do
        {:ok, _credential} -&amp;gt; nil
        {:error, changeset} -&amp;gt; Repo.rollback(changeset)
      end
    end)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We don’t need to do anything with the newly created credential, so we can just ignore it once it’s been created.&lt;/p&gt;&lt;p&gt;And finally, from the transaction function, we return the user:&lt;/p&gt;&lt;p&gt;The last thing we need to to complete the registration flow is to set the new user’s session cookie so that they’re logged in immediately. The &lt;code&gt;UserAuth&lt;/code&gt; module that’s generated as part of the Phoenix auth template has a &lt;code&gt;log_in_user&lt;/code&gt; function that does exactly this. But it also redirects the connection to another endpoint. We don’t want to do that, since we’re sending a JSON response, so I’ve split the function into two: one that only sets the session, and the existing function that sets the session and then redirects.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserAuth do
  def log_in_user(conn, user, params \\ %{}) do
    user_return_to = get_session(conn, :user_return_to)
    conn
    |&amp;gt; log_in_user_without_redirect(user, params)
    |&amp;gt; redirect(to: user_return_to || signed_in_path(conn))
  end

  def log_in_user_without_redirect(conn, user, params \\ %{}) do
    token = Accounts.generate_user_session_token(user)
    conn
    |&amp;gt; renew_session()
    |&amp;gt; put_token_in_session(token)
    |&amp;gt; maybe_write_remember_me_cookie(token, params)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And with that, everything is in place and you can now create an account with a passkey!&lt;/p&gt;&lt;h2&gt;Login form §&lt;/h2&gt;&lt;p&gt;Now that the user’s got an account, they need to be able to login with it. That means once again interacting with the WebAuthn API and writing a bunch of JavaScript. But before we get there, we need some slight changes to the backend.&lt;/p&gt;&lt;p&gt;In the HTML for the login page, we’ll add an ID to the &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element so that we can find it from JS. We’ll also remove the password field, which is obviously no longer necessary. Lastly, but certainly not least, we need to send a challenge to the client.&lt;/p&gt;&lt;p&gt;The challenge is a value that the user’s device will cryptographically sign with their private key. The result will get sent back to the server, and we’ll verify it against the public key we have stored thus authenticating them. We’ll send the challenge just in a hidden form field:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;&amp;lt;input type=&amp;quot;hidden&amp;quot; id=&amp;quot;challenge&amp;quot; value={@webauthn_challenge} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In the session controller, we’ll need to generate and assign the challenge to the connection.&lt;/p&gt;&lt;p&gt;WebAuthn expects the challenge to be a value up to 64 bytes long, so we’ll use the Erlang crypto module to generate one of that length. The value is encoded as &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc4648#section-5&quot;&gt;URL-safe base 64&lt;/a&gt; (the same as normal base 64, but with dash and underscore rather than plus and slash) without padding. We encode it this way since that’s the format in which it will later be &lt;a href=&quot;https://w3c.github.io/webauthn/#dom-collectedclientdata-challenge&quot;&gt;returned&lt;/a&gt; as part of the &lt;code&gt;clientDataJSON&lt;/code&gt;, so when we extract that value we can directly compare it to the challenge value we generated.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserSessionController do
  defp put_webauthn_challenge(conn) do
    challenge =
      :crypto.strong_rand_bytes(64)
      |&amp;gt; Base.url_encode64(padding: false)

    conn
    |&amp;gt; put_session(:webauthn_challenge, challenge)
    |&amp;gt; assign(:webauthn_challenge, challenge)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Note that the challenge string is also stored in the session, so that we can later check that the challenge that the client signed matches the challenge we generated. It’s safe to store this in the session, even though it’s sent to the client, because the cookie is encrypted and signed so the client can’t tamper with it.&lt;/p&gt;&lt;h2&gt;Login JavaScript §&lt;/h2&gt;&lt;p&gt;With the first part of the backend changes taken care of, it’s time for more JavaScript, baby!&lt;/p&gt;&lt;p&gt;We’ll follow a similar outline to the registration setup (and the same caveat applies about error handling).&lt;/p&gt;&lt;pre&gt;&lt;code&gt;document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, () =&amp;gt; {
    // ...
    const loginForm = document.getElementById(&amp;quot;login-form&amp;quot;);
	if (loginForm) {
        loginWebAuthnAccount(loginForm);
    }
});

async function loginWebAuthnAccount(loginForm) {
    if (!(await supportsPasskeys())) {
        return;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The first thing we need to do is grab the challenge from the hidden form field, then we can construct the options object for getting the credential (which is, thankfully, much simpler than for creation).&lt;/p&gt;&lt;p&gt;In the options, we specify that we want conditional mediation. As noted before, this means that the browser won’t display any UI, except for autofill, for this credential request until the user accepts the autofill suggestion. In the public key options, we also give the decoded challenge value and specify the our Relying Party ID (again, this would need to be the actual domain in production).&lt;/p&gt;&lt;p&gt;Now, we can actually make the credential request and then, if we get a credential back, encode and send all the values to the backend. We need to send the ID of the credential, so that the backend can find its public key and the corresponding user. We also need the client data JSON, which we send as text decoded from the &lt;code&gt;ArrayBuffer&lt;/code&gt; it’s returned as. We also need to send the authenticator data as well as the signature itself.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function loginWebAuthnAccount(loginForm) {
    // ...
    const credential = await navigator.credentials.get(getOptions);
    if (!credential) {
        alert(&amp;quot;Could not get credential&amp;quot;);
        return;
    }

    const clientDataJSON = new TextDecoder().decode(credential.response.clientDataJSON);

    const body = new FormData();
    body.append(&amp;quot;_csrf_token&amp;quot;, loginForm._csrf_token.value);
    body.append(&amp;quot;raw_id&amp;quot;, arrayBufferToBase64(credential.rawId));
    body.append(&amp;quot;client_data_json&amp;quot;, clientDataJSON);
    body.append(&amp;quot;authenticator_data&amp;quot;, arrayBufferToBase64(credential.response.authenticatorData));
    body.append(&amp;quot;signature&amp;quot;, arrayBufferToBase64(credential.response.signature));
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We can then send a request to the login endpoint. If the backend request fails (or if we failed to get the credential) we just alert the user (again, you’d probably want something better in reality). If the login attempt was successful, the server will have set the session cookie, and so we can just redirect to the homepage and the user will be logged in.&lt;/p&gt;&lt;p&gt;With that in place, let’s move on to the backend half of the login request.&lt;/p&gt;&lt;h2&gt;Validating a login attempt §&lt;/h2&gt;&lt;p&gt;As with signup, we’ll modify the existing log in endpoint to actually validate the WebAuthn login attempt.&lt;/p&gt;&lt;p&gt;The first step is extracting all of the information the frontend provides in the params and decoding it:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserSessionController do
  def create(conn, params) do
    id = params |&amp;gt; Map.get(&amp;quot;raw_id&amp;quot;) |&amp;gt; Base.decode64!()
    authenticator_data = params |&amp;gt; Map.get(&amp;quot;authenticator_data&amp;quot;) |&amp;gt; Base.decode64!()
    client_data_json_str = params |&amp;gt; Map.get(&amp;quot;client_data_json&amp;quot;)
    signature = params |&amp;gt; Map.get(&amp;quot;signature&amp;quot;) |&amp;gt; Base.decode64!()
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Next, we’re going to validate all of the information we got from the client. Before we get to that, there are a handful of helper functions we’ll use. First, looking up a credential by its ID:&lt;/p&gt;&lt;p&gt;Next, a function in the controller that verifies the signature against the provided data:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserSessionController do
  defp verify_signature(credential, client_data_json_str, authenticator_data, signature) do
    with {:ok, pubkey} &amp;lt;- X509.PublicKey.from_der(credential.public_key_spki),
         client_data_json_hash &amp;lt;- :crypto.hash(:sha256, client_data_json_str),
         signed_message &amp;lt;- authenticator_data &amp;lt;&amp;gt; client_data_json_hash,
         true &amp;lt;- :public_key.verify(signed_message, :sha256, signature, pubkey) do
      true
    else
      _ -&amp;gt;
        false
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;X509&lt;/code&gt; comes from the &lt;a href=&quot;https://hex.pm/packages/x509&quot;&gt;&lt;code&gt;x509&lt;/code&gt;&lt;/a&gt; package, which is the only third-party piece of code we’re using. It’s a fairly thin wrapper around the Erlang &lt;code&gt;public_key&lt;/code&gt; and &lt;code&gt;crypto&lt;/code&gt; modules, and mostly serves to save me from having to deal with Erlang records in my code. Its &lt;code&gt;from_der&lt;/code&gt; helper function is used to parse the public key from the encoded format.&lt;/p&gt;&lt;p&gt;Next, we hash the client data JSON and append that hash to the authenticator data from the client. This value is what should match the signature using the public key we’ve got, so finally we check that. If all these steps succeeded, we return true, and false otherwise.&lt;/p&gt;&lt;p&gt;The last helper function will receive the decoded client data make sure it’s got all of the values that we expect. If the &lt;code&gt;crossOrigin&lt;/code&gt; value is present and is not false, the client data is invalid and the login attempt will be rejected.&lt;/p&gt;&lt;p&gt;Otherwise, we check that the data has the expected type and origin, and we extract the challenge value (note that we’re checking the origin again here, and this would need to change in production):&lt;/p&gt;&lt;p&gt;And lastly, if neither of the previous patterns matched, the client data fails validation:&lt;/p&gt;&lt;p&gt;Now, let’s put all those parts together and validate the login attempt.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserSessionController do
  def create(conn, params) do
    # ...
    with credential when not is_nil(credential) &amp;lt;- Accounts.get_credential(id),
         true &amp;lt;- verify_signature(credential, client_data_json_str, authenticator_data, signature),
         {:ok, client_data_json} &amp;lt;- Jason.decode(client_data_json_str),
         {:ok, challenge} &amp;lt;- check_client_data_json(client_data_json),
         true &amp;lt;- challenge == get_session(conn, :webauthn_challenge),
         true &amp;lt;- :binary.part(authenticator_data, 0, 32) == :crypto.hash(:sha256, &amp;quot;localhost&amp;quot;),
         true &amp;lt;- (:binary.at(authenticator_data, 32) &amp;amp;&amp;amp;&amp;amp; 1) == 1 do
      conn
      |&amp;gt; delete_session(:webauthn_challenge)
      |&amp;gt; UserAuth.log_in_user_without_redirect(credential.user)
      |&amp;gt; json(%{status: :ok})
    else
      _ -&amp;gt;
        json(conn, %{status: :error})
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Here’s everything that we’re doing:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;Lookup the credential with the given ID.&lt;/li&gt;
&lt;li&gt;Use the public key we had stored to verify the signature on the authenticator data and client data JSON.&lt;/li&gt;
&lt;li&gt;Decode the client data JSON.&lt;/li&gt;
&lt;li&gt;Check that all the values in the client data are what we expect, and extract the challenge that was signed.&lt;/li&gt;
&lt;li&gt;Ensure the challenge that the user signed matches what we previously generated.&lt;/li&gt;
&lt;li&gt;Extract the hash of the origin from the &lt;a href=&quot;https://w3c.github.io/webauthn/#authenticator-data&quot;&gt;authenticator data&lt;/a&gt; and ensure it matches our origin (this would not be localhost in production).&lt;/li&gt;
&lt;li&gt;Check that the authenticator data has the user presence bit set (indicating that a person was actually present on the client’s end).&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;If all of those steps succeeded, we remove the old challenge value from the session (since it’s no longer needed), actually log the user in, and then respond with the “ok” status that the JavaScript is expecting. If any step failed, we’ll respond with the “error” status and the frontend will alert the user.&lt;/p&gt;&lt;p&gt;Since there’s a lot going on here, it’s worth being clear about what exactly in this process lets us authenticate and prove that the user is who they claim to be. Since the signature verification step is using the public key that we stored during the registration process, we know that anyone that can produce a valid signature using that public key must be the user (or at any rate, have their private key). The value that they’re signing is, essentially, the challenge: a securely generated random value. The user isn’t directly signing the challenge, but this is still safe, since the challenge value is included in the client data JSON, that hash of which is included in the signed message.&lt;/p&gt;&lt;p&gt;So: the challenge value that was signed by the user must be in the client data, and the challenge value in the client data must be the one we generated. Given that, we know that the user whose key was used to sign the message is the one trying to log in now. That we’re verifying with the stored public key prevents an attacker from using an arbitrary key to sign the login attempt. And that the signed challenge matches the challenge the server generated means an attacker can’t reuse a previous response to login (a replay attack).&lt;/p&gt;&lt;p&gt;At long last, we finally have the ability to log in to our application using a passkey. Only a few minor things to go, so let’s forge ahead.&lt;/p&gt;&lt;h2&gt;Handling login if the user enters an email §&lt;/h2&gt;&lt;p&gt;Although we’re presenting the conditional UI, there’s nothing preventing the user from typing their email into the field and then clicking “Sign in,” so we should probably handle that to. This can be done fairly simply by reusing our existing code for conditional login.&lt;/p&gt;&lt;p&gt;We’ll change the &lt;code&gt;loginWebAuthnAccount&lt;/code&gt; to take an additional parameter, &lt;code&gt;conditional&lt;/code&gt;, which will be a boolean indicating whether this login attempt is to setup the conditional UI or triggered by submitting the login form.&lt;/p&gt;&lt;p&gt;If it’s false, we won’t request conditional mediation and instead we’ll look up the credentials corresponding to the email the user entered and ask WebAuthn for one of those:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;async function loginWebAuthnAccount(loginForm, conditional) {
    // ...
    let allowCredentials = [];
    if (!conditional) {
		const email = loginForm[&amp;quot;user[email]&amp;quot;].value;
		const resp = await fetch(`/users/log_in/credentials?email=${email}`);
		const respJSON = await resp.json();
		allowCredentials = respJSON.map((id) =&amp;gt; {
			return {
				type: &amp;quot;public-key&amp;quot;,
				id: base64ToArrayBuffer(id),
			};
		});
    }

    const getOptions = {
		mediation: conditional ? &amp;quot;conditional&amp;quot; : &amp;quot;optional&amp;quot;,
		publicKey: {
			challenge: base64URLToArrayBuffer(challenge),
			rpId: &amp;quot;localhost&amp;quot;,
			allowCredentials,
		}
    };
    // ...
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The “optional” value for the &lt;code&gt;mediation&lt;/code&gt; option means that the authenticator isn’t required to display UI, but will do so if its policies dictate that. The &lt;code&gt;allowCredentials&lt;/code&gt; array contains objects describing all of the credentials that we want to accept—specifically, their binary IDs as &lt;code&gt;ArrayBuffer&lt;/code&gt;s.&lt;/p&gt;&lt;p&gt;We look up the user’s credentials so that the one we actually request from the authenticator matches the account that the user is trying to log in with. To handle this, we’ll also wire up an additional route on the backend that returns the base 64-encoded IDs of all the credentials belonging to the user with a given email.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;get_credentials_by_email&lt;/code&gt; function is quite simple. It just looks up a user by email, preloading any credentials they have and then returning them:&lt;/p&gt;&lt;p&gt;Back in the JS, we can tweak the setup code to pass &lt;code&gt;true&lt;/code&gt; for the conditional parameter in the initial request and also register a submit handler on the login form that will invoke it with &lt;code&gt;false&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, () =&amp;gt; {
    // ...
	const loginForm = document.getElementById(&amp;quot;login-form&amp;quot;);
	if (loginForm) {
		loginWebAuthnAccount(loginForm, true);
		loginForm.addEventListener(&amp;quot;submit&amp;quot;, (event) =&amp;gt; {
			event.preventDefault();
			loginWebAuthnAccount(loginForm, false);
		});
	}
});&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And so we’ve handled the case where the user ignores the conditional UI and still types in their email to log in.&lt;/p&gt;&lt;h2&gt;Passkey reset §&lt;/h2&gt;&lt;p&gt;A not-infrequent argument against passkeys is that if your phone falls into a lake, you lose access to all of your passkey-backed accounts. One could argue that this isn’t true because the definition of passkeys means that they’re backed up and thus protected from this sort of event (and indeed, earlier we only permitted registering with backed-up credentials). I think the argument isn’t particularly interesting, however, because you can still have a “Forgot my passkey” option that works just like it does now with passwords.&lt;/p&gt;&lt;p&gt;This is less secure than a passkey implementation that has no “Forgot” option. But it’s no less secure than current password-based systems, and I think the UX/security tradeoff here falls on the UX side—people will, inevitably, lose access to their passkeys while retaining access to their email.&lt;/p&gt;&lt;p&gt;Implementing isn’t too complicated, fortunately, since we can reuse much of the registration code. First, the JavaScript. The only change necessary is attaching the registration function to the reset form as well.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, () =&amp;gt; {
    // ...
	const resetForm = document.getElementById(&amp;quot;reset-passkey-form&amp;quot;);
	if (resetForm) {
		resetForm.addEventListener(&amp;quot;submit&amp;quot;, (event) =&amp;gt; {
			event.preventDefault();
			registerWebAuthnAccount(resetForm);
		});
	}
});&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In the HTML for the reset form, we we need to include the email in the same form field as the registration form (and also add an ID to the &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element):&lt;/p&gt;&lt;pre&gt;&lt;code&gt;&amp;lt;input type=&amp;quot;hidden&amp;quot; name=&amp;quot;user[email]&amp;quot; value={@user.email} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In the function for the reset route (which is changed to POST from PUT, to match the signup route), we take the credential ID and public key and use them to update the user’s credentials:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeysWeb.UserResetPasswordController do
  def update(conn, %{
        &amp;quot;credential_id&amp;quot; =&amp;gt; credential_id,
        &amp;quot;public_key_spki&amp;quot; =&amp;gt; public_key_spki
      }) do
    credential_id = Base.decode64!(credential_id)
    public_key_spki = Base.decode64!(public_key_spki)
    case Accounts.reset_user_credentials(conn.assigns.user, credential_id, public_key_spki) do
      :ok -&amp;gt;
        conn
        |&amp;gt; put_flash(:info, &amp;quot;Passkey reset successfully.&amp;quot;)
        |&amp;gt; UserAuth.log_in_user_without_redirect(conn.assigns.user)
        |&amp;gt; json(%{status: :ok})

      {:error, _} -&amp;gt;
        conn
        |&amp;gt; put_flash(:error, &amp;quot;Error resetting passkey&amp;quot;)
        |&amp;gt; json(%{status: :error})
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After updating, we log the user in if applicable and return JSON with the appropriate status.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;reset_user_credentials&lt;/code&gt; function works very similarly to the original reset password function that was part of the template: it deletes all the user’s existing sessions, and then removes their existing credentials and creates a new one:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;defmodule PhoenixPasskeys.Accounts do
  def reset_user_credentials(user, credential_id, public_key_spki) do
    Ecto.Multi.new()
    |&amp;gt; Ecto.Multi.delete_all(
      :old_credentials,
      from(a in UserCredential, where: a.user_id == ^user.id)
    )
    |&amp;gt; Ecto.Multi.insert(
      :new_credential,
      UserCredential.changeset(%UserCredential{}, %{
        id: credential_id,
        public_key_spki: public_key_spki,
        user_id: user.id
      })
    )
    |&amp;gt; Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
    |&amp;gt; Repo.transaction()
    |&amp;gt; case do
      {:ok, _} -&amp;gt; :ok
      {:error, _, changeset, _} -&amp;gt; {:error, changeset}
    end
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It’s worth noting that this does have slightly weaker security properties than the &lt;code&gt;phx.gen.auth&lt;/code&gt; reset password implementation. With that approach, leaking a password reset token does not necessarily result in an account takeover, since whoever obtained the leaked token may not know the target user’s email address. Since the auth template forces the user to re-login after a reset, this prevents someone without the email address from gaining access even if they change the password.&lt;/p&gt;&lt;p&gt;But since logging in with a passkey is functionally a single factor, resetting it means gaining access to the account. So, a leaked reset token gives the bearer control over the account. This is an argument against having a reset option, but whether this is a concern in practice depends on your specific circumstances.&lt;/p&gt;&lt;h2&gt;Conclusion §&lt;/h2&gt;&lt;p&gt;As noted, this is not a complete implementation. There are a handful of places where I’ve left things unfinished since this isn’t meant to be production-level code. There are also a few places where there are security decisions that need to be made on a more contextual basis, that I’ve tried to note. And, of course, you wouldn’t really want to only permit signing in with passkeys and wholesale drop the passwords column from your database.&lt;/p&gt;&lt;p&gt;Nonetheless, I hope this has been a helpful look at how to implement passkeys in an Elixir/Phoenix application. You can find the complete repo &lt;a href=&quot;https://git.shadowfacts.net/shadowfacts/phoenix_passkeys&quot;&gt;here&lt;/a&gt;, and it may also be useful to look at the &lt;a href=&quot;https://git.shadowfacts.net/shadowfacts/phoenix_passkeys/commit/ce4f485dbc4e528bdd13a57b0d379012dd893338&quot;&gt;specific commit&lt;/a&gt; where passkey support was added.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>From legacy code to verifiable specifications with Surveyor</title>
<link>https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor</link>
<guid isPermaLink="false">OwKRRarDBvCxLjw8oGuHThV0mW_dK7NcEu5NrA==</guid>
<pubDate>Wed, 29 Apr 2026 20:10:39 +0000</pubDate>
<description>Take ownership of code you don&#39;t trust. Surveyor extracts a verifiable behavioral specification of a legacy system. Rewrite with certainty.</description>
<content:encoded>&lt;h2&gt;What is legacy code?&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#what-is-legacy-code&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Sometimes that&amp;#39;s the fifteen-year-old monolith.
Sometimes it&amp;#39;s the service that grew complicated faster than the team could keep up with.
Sometimes it&amp;#39;s that project that started as a shortcut and turned into a roadblock. Legacy code has been exposed to real world demands long enough to gather fixes for edge cases we are not aware of anymore.&lt;/p&gt;&lt;p&gt;And — increasingly — sometimes it&amp;#39;s the repo your CEO vibe-coded last weekend and that they proudly present on Monday.
It runs. Now you have to own it.&lt;/p&gt;&lt;h2&gt;Legacy software is the code we lost confidence in&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#legacy-software-is-the-code-we-lost-confidence-in&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;That last category is genuinely new and growing fast. Code written once, never reviewed, tested a bit, deployed one day. Or, worse, released as a library and never maintained after that.&lt;/p&gt;&lt;p&gt;A new kind of technical dept has evolved: &lt;strong&gt;&lt;em&gt;&amp;quot;The code we should review thoroughly one day&lt;/em&gt;&lt;/strong&gt;.&amp;quot;&lt;/p&gt;&lt;h2&gt;&amp;quot;Can we rewrite this?&amp;quot;&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#can-we-rewrite-this&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The actual problem is not the writing of the new code, but the discovery of what the old code does, and the certainty that the new one does the same thing.&lt;/p&gt;&lt;p&gt;We&amp;#39;ve been building two small Elixir tools to help with both halves of that problem.&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Surveyor&lt;/strong&gt; scans a codebase in any language and produces an architectural model.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Assay&lt;/strong&gt; runs the same plain-text behavioral specs against both the legacy and the rewrite, so you can prove they behave the same.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Together they cover the workflow: Figure out the structure, capture the behavior, rewrite, prove the rewrite works.&lt;/p&gt;&lt;div&gt;&lt;div&gt;TL;DR&lt;/div&gt;&lt;div&gt;&lt;p&gt;Surveyor produces an &lt;strong&gt;automatically verifiable behavioral specification&lt;/strong&gt; of a legacy system.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;h2&gt;How we got here&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#how-we-got-here&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Before going into either tool, it&amp;#39;s worth walking through the shape of the problem.
The picture builds up step by step, and the final diagram only makes sense once you&amp;#39;ve seen the holes in the earlier ones.&lt;/p&gt;&lt;h3&gt;What we don&amp;#39;t want&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#what-we-dont-want&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-0-158d6135059c381671508607696828b4.png&quot; alt=&quot;Legacy Codebase with a dashed arrow pointing directly at a Target Codebase, with no intermediate steps&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;A direct rewrite also copies bugs, dead code and unused features.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;Point an agent at a legacy codebase, ask it to produce a new one, hope for the best.
Call it &amp;quot;the Claude zombie rewrite.&amp;quot;
You end up with a target codebase that nobody understands either, plus a generation gap between the old assumptions and the new. What has the agent overlooked, misunderstood, assumed?
A black box gets replaced with a different black box.
That is not a rewrite, that is a transcoding.&lt;/p&gt;&lt;h3&gt;What we want instead&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#what-we-want-instead&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-1-b16e88b3f686956678092c1d93021641.png&quot; alt=&quot;Legacy Codebase with an arrow into a TXT file labelled &amp;amp;quot;Readable Specification&amp;amp;quot;.&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The features of the legacy codebase need to be revised&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;The valuable artifact in the middle is not new code — it&amp;#39;s a
&lt;strong&gt;readable specification&lt;/strong&gt; of what the old code &lt;strong&gt;actually does&lt;/strong&gt;. Plain text,
human-reviewable, version-controllable. The kind of thing you need when you
want to ask the product owner if a feature is &lt;strong&gt;really&lt;/strong&gt; wanted that way. - Or if it is just a legacy quirk.&lt;/p&gt;&lt;p&gt;If you have it, you can use it for estimates. It can be your map to plan the
implementation and might even give you a hint about that stakeholder that you
might have forgotten about otherwise.&lt;/p&gt;&lt;h3&gt;Organised and illustrated&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#organised-and-illustrated&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-2-9f42ae2f41b319470f65ad9ec5928524.png&quot; alt=&quot;Legacy Codebase pointing into an Architecture Map of Module A, Module B, Module C, each with its own .md spec.&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The specification&amp;#39;s organisation should mirror the codebase&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;A single big text file describing a whole legacy system is only useful for very small projects. You want the spec broken
down by module — by bounded context — and the modules themselves arranged on a map of the architecture. Once each module
has its own spec associated to it, two things become possible: you can divide the work, and you can reason about
coverage.&lt;/p&gt;&lt;h3&gt;Job done! Or is it?&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#job-done-or-is-it&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;We now have and organised map of the system and a set of specifications. We have an overview, and the details. We could
now talk to the stakeholders, the development team, and set to work.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-3-f8cc173a463a67d9b7f04b22acfa704a.png&quot; alt=&quot;A graphic showing rectangles representing the software parts map to rectangles representing the specification&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The specification&amp;#39;s organisation should mirror the codebase&lt;/figcaption&gt;&lt;/figure&gt;&lt;p&gt;&lt;strong&gt;However, we have not yet verified our findings.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;What we are looking at is not yet a specification of the legacy system — it is a &lt;em&gt;speculation&lt;/em&gt; of the legacy system.
A document that &lt;strong&gt;claims&lt;/strong&gt; to describe what the code does, written by reading the code (or by asking an LLM to read the code).
Plausible, well-organised, reviewable. But unverified.&lt;/p&gt;&lt;h4&gt;1. Verifiability&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#1-verifiability&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;This is &lt;strong&gt;speculation as specification&lt;/strong&gt;, and it&amp;#39;s the trap most &amp;quot;let&amp;#39;s document the legacy system&amp;quot; projects fall into.
A Word document of &amp;quot;what the system does&amp;quot; can be worse than no document at all, because people start trusting it.
But even if the specification is done as a group effort by all stakeholders involved, and is thoroughly verified, it
still lacks one critical property:&lt;/p&gt;&lt;h4&gt;2. Reproducibility&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#2-reproducibility&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;What we actually want is a &lt;strong&gt;verifiable specification&lt;/strong&gt;: Not &amp;quot;verified once,&amp;quot; but verifiable on demand, any time someone
asks the question. You could verify a paper spec by hand, of course — sit down, read it, run the system, tick off the
scenarios — but manual verification is expensive enough that it gets done once at sign-off and never again.&lt;/p&gt;&lt;p&gt;A verification you don&amp;#39;t actually do is verification that doesn&amp;#39;t exist. And unless the legacy codebase is a museum
exhibit, it&amp;#39;s a moving target:&lt;/p&gt;&lt;p&gt;The team is still shipping fixes, the system is still drifting, and a paper spec written on Monday is no longer accurate
by Friday.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-6-ff8b98aeca5f9fdf5732fe355a44fbc0.png&quot; alt=&quot;Same picture but with a red horse galloping across the bottom and the words &amp;amp;quot;BUT SPECS GO STALE&amp;amp;quot;. The Legacy Codebase is now annotated as &amp;amp;quot;A moving target&amp;amp;quot;.&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;h3&gt;Runnable specs&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#runnable-specs&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;The fix is to make the specs &lt;strong&gt;executable&lt;/strong&gt; — to wire them into something that can run them against the actual legacy system, on demand, and tell you whether they still hold.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-7-444bd86f1323e81fa52511f1d57830f1.png&quot; alt=&quot;The earlier picture with a new &amp;amp;quot;SOME MAGIC&amp;amp;quot; box added below, with arrows looping the .assay specs back through the magic box and into the legacy codebase.&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;That is the move from speculation to verifiable specification: not a one-shot audit, but a button you can press on every commit, every nightly, every time anyone asks &amp;quot;is this still true?&amp;quot;&lt;/p&gt;&lt;p&gt;A &lt;strong&gt;green&lt;/strong&gt; run proves the spec is still true. A &lt;strong&gt;red&lt;/strong&gt; run is a useful signal: either the legacy drifted, or your spec was wrong.
Either way, you find out before the rewrite, not during it.&lt;/p&gt;&lt;h3&gt;That magic is Assay&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#that-magic-is-assay&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/step-8-c06ddef9a1740cfb355acb10dace6184.png&quot; alt=&quot;The bottom box is now labelled &amp;amp;quot;Assay — A framework to automate assertions&amp;amp;quot; with a Parser that reads .assay files and a &amp;amp;quot;(The parts you fill in)&amp;amp;quot; placeholder beneath it.&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;Assay is the runner.
It parses the &lt;code&gt;.assay&lt;/code&gt; specs Surveyor produced, matches each step against bindings you write, and exercises the real legacy system through whatever interface it actually exposes — HTTP, CLI, message queue, file drop, whatever.
The framework is small.&lt;/p&gt;&lt;p&gt;The runtime contract is &amp;quot;your bindings + your assertions + a deterministic runner.&amp;quot;&lt;/p&gt;&lt;h3&gt;The systemic approach&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#the-systemic-approach&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/overview-110149d565e655cbe254faa9456037a1.png&quot; alt=&quot;The full picture: Surveyor on top with Architecture Map and .assay specs; Assay below with Parser, Pattern Recognition, Bindings, and Given/When/Then automations; arrows looping back to the Legacy Codebase.&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;Open up that &amp;quot;parts you fill in&amp;quot; box and you find the actual surface area of work: pattern recognition for step matching, a binding per target system, and Given/When/Then automations for setup, action, and verification.&lt;/p&gt;&lt;p&gt;The end state has a property that nothing in the earlier pictures had: the problem space has been &lt;strong&gt;dissected into manageable pieces&lt;/strong&gt;.
Architecture, behavior, target adapters, assertions — each is small, each can be reviewed independently, and each can be picked up by a human or an agent without having to hold the whole system in their head at once.&lt;/p&gt;&lt;p&gt;That&amp;#39;s the goal of the toolkit.
Everything below is the detail of how each piece is built.&lt;/p&gt;&lt;h2&gt;Surveyor — discovering the architecture you inherited&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#surveyor--discovering-the-architecture-you-inherited&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Surveyor is a Mix project that takes a path to a codebase and produces a &lt;a href=&quot;https://structurizr.com/&quot;&gt;Structurizr&lt;/a&gt; DSL workspace file.
It runs in three phases mapping to the C4 model: C1 (system context), C2 (containers), C3 (components per container).&lt;/p&gt;&lt;p&gt;Crucially, Surveyor does &lt;strong&gt;not&lt;/strong&gt; parse code.
There are no language-specific parsers, no AST walkers, no fragile grammar files for thirty different ecosystems.&lt;/p&gt;&lt;p&gt;It scans the filesystem to identify what&amp;#39;s there — languages, frameworks, project files, entry points, deployment manifests — and then feeds meaningful chunks to an LLM. That sounds trivial, but is the same mechanism that commercial coding agents use when
they analyse your code.&lt;/p&gt;&lt;p&gt;Surveyor&amp;#39;s intelligence is in the prompts and the chunking, not in a stack of half-broken language adapters.&lt;/p&gt;&lt;p&gt;The CLI is interactive at every phase.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;$ surveyor ./legacy-monolith --phase c1

Phase 1 — System Context
  Scanning codebase...
  Querying LLM...

  System: &amp;quot;Order Management System&amp;quot;
  Description: Manages the full order lifecycle

  Actors:
    ✓ Customer — places and tracks orders via Web UI
    ✓ Warehouse Staff — manages fulfillment via Back Office
    ? Admin — found references in auth config (confidence: low)

  External Systems:
    ✓ Stripe — payment processing (REST API)
    ? SendGrid — found API key in config (confidence: low)

[a]ccept  [e]dit  [r]etry with more context  [q]uit
&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The LLM is asked to flag uncertainty.
Anything tagged &lt;code&gt;confidence: low&lt;/code&gt; shows up with a &lt;code&gt;?&lt;/code&gt; and a short reasoning string for human review.
That&amp;#39;s not just nice ergonomics — it&amp;#39;s the bit that makes the tool trustworthy.
An LLM that confidently invents a &lt;code&gt;PaymentReconciliation&lt;/code&gt; container that doesn&amp;#39;t exist is worse than no model at all.
An LLM that says &amp;quot;I see a &lt;code&gt;SENDGRID_API_KEY&lt;/code&gt; in &lt;code&gt;.env.example&lt;/code&gt; but no Sendgrid client in the source, please confirm&amp;quot; is doing the right kind of work.&lt;/p&gt;&lt;p&gt;Each phase is resumable.
Results are saved as JSON in &lt;code&gt;./surveyor/&lt;/code&gt;, so a thirty-container system doesn&amp;#39;t have to finish in one sitting.
You can hop in, accept C1, take a break, come back tomorrow, and resume at C2.&lt;/p&gt;&lt;p&gt;The end product is a &lt;code&gt;workspace.dsl&lt;/code&gt; you can render in Structurizr.
But more importantly, it&amp;#39;s a &lt;code&gt;workspace.dsl&lt;/code&gt; whose components are decorated with &lt;code&gt;assay.specs&lt;/code&gt; and &lt;code&gt;assay.schema&lt;/code&gt; properties, pointing at the behavioral specs and the domain schemas for each bounded context.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;orderLifecycle = component &amp;quot;Order Lifecycle&amp;quot; &amp;quot;Orders&amp;quot; &amp;quot;Bounded Context&amp;quot; {
    properties {
        &amp;quot;assay.specs&amp;quot; &amp;quot;specs/order-lifecycle&amp;quot;
        &amp;quot;assay.schema&amp;quot; &amp;quot;schemas/order_lifecycle.ex&amp;quot;
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;DDD pattern annotations from the LLM (&lt;code&gt;customer-supplier&lt;/code&gt;, &lt;code&gt;anticorruption-layer&lt;/code&gt;, …) ride along as properties on the relationship.
Every component gets &lt;code&gt;assay.specs&lt;/code&gt; and &lt;code&gt;assay.schema&lt;/code&gt; properties — the contract the next tool reads from.&lt;/p&gt;&lt;h3&gt;Examples&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#examples&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;h4&gt;The System Context of a Medical Application&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#the-system-context-of-a-medical-application&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Surveyor identified the actors and the external systems of the application. Without knowing the code, we would already
know which actor types there are&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/MeditechsystemContext-b7cb1953223f982d281b4b7110677683.png&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The System Context of a medical application&lt;/figcaption&gt;&lt;/figure&gt;&lt;h4&gt;An example of an assay spec created in the second phase:&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#an-example-of-an-assay-spec-created-in-the-second-phase&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/MeditechAssay-c4-46cf27120239ca00a3e2a7202cf32936.png&quot; alt=&quot;&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;The Account Creation Specs of a medical application&lt;/figcaption&gt;&lt;/figure&gt;&lt;h2&gt;Assay — proving the rewrite behaves the same&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#assay--proving-the-rewrite-behaves-the-same&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Assay is a minimal behavioral spec runner.
Specs are plain text and look like this:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;Component: [orderLifecycle] Order Lifecycle
Context: Order Placement

Definitions:
  - &amp;quot;a valid customer&amp;quot; means:
      a Customer with status Active, verified email,
      and a credit limit greater than zero

Invariants:
  - total must not exceed customer credit limit
  - at least one line item required

Rule: Customers can place orders for in-stock items

  @critical @phase-1
  Scenario: [OL-001] Place a simple order
    Given a valid customer &amp;quot;Alice&amp;quot; with credit limit €10,000
    And product &amp;quot;Widget&amp;quot; is in stock with 50 units available
    When Alice places an order for 3 units of &amp;quot;Widget&amp;quot;
    Then the order status becomes &amp;quot;Placed&amp;quot;
    And stock for &amp;quot;Widget&amp;quot; is reduced to 47 units&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The bracketed &lt;code&gt;[orderLifecycle]&lt;/code&gt; is a workspace.dsl identifier — the same identifier Surveyor wrote into the architecture model.
The bracketed &lt;code&gt;[OL-001]&lt;/code&gt; on the scenario is an optional free-form id that survives across rewrites of the spec text and lets you cross-reference from tickets or audit trails.&lt;/p&gt;&lt;p&gt;If that looks like Cucumber or Gherkin, it does — with one large difference.
There is no glue layer, no abstraction over what kind of system you are testing, no framework opinions about HTTP, databases, or browsers.
The bindings are just Elixir.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmoduleTargets.Legacy.OrderLifecycledo
useAssay.Binding,component:&amp;quot;orderLifecycle&amp;quot;

@baseSystem.get_env(&amp;quot;LEGACY_API_URL&amp;quot;)

  step :given,~r/a valid customer &amp;quot;(?P&amp;lt;name&amp;gt;.+)&amp;quot; with credit limit €(?P&amp;lt;limit&amp;gt;[\d,.]+)/do
{:ok, resp}=Req.post(&amp;quot;#{@base}/test/customers&amp;quot;,json:%{
name:params().name,
credit_limit:parse_money(params().limit),
status:&amp;quot;Active&amp;quot;
})
assign(customer_id: resp.body[&amp;quot;id&amp;quot;])
end

  step :action,~r/.+ places an order for (?P&amp;lt;qty&amp;gt;\d+) units? of &amp;quot;(?P&amp;lt;product&amp;gt;.+)&amp;quot;/do
{:ok, resp}=Req.post(&amp;quot;#{@base}/orders&amp;quot;,json:%{
customer_id:var(:customer_id),
lines:[%{product_id:var(:product_id),quantity:to_int(params().qty)}]
})
assign(order_id: resp.body[&amp;quot;id&amp;quot;],http_status: resp.status)
end

  step :expect,~r/the order status becomes &amp;quot;(?P&amp;lt;status&amp;gt;.+)&amp;quot;/do
{:ok, resp}=Req.get(&amp;quot;#{@base}/orders/#{var(:order_id)}&amp;quot;)
    assert resp.body[&amp;quot;status&amp;quot;]==params().status
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A binding is just a module that brings whatever it needs — &lt;code&gt;Req&lt;/code&gt; for an HTTP API, &lt;code&gt;AMQP&lt;/code&gt; for a message broker, &lt;code&gt;System.cmd&lt;/code&gt; for a batch processor, &lt;code&gt;File&lt;/code&gt; for a directory-watching pipeline.
Assay itself does not ship an HTTP client or a database adapter.&lt;/p&gt;&lt;p&gt;The framework provides parsing, step matching, scenario lifecycle, and assertions; the rest is your code.
That&amp;#39;s what makes the same spec runnable against an HTTP API, a CLI tool, a batch job, or a message queue, depending on what the legacy system happens to be.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;
🚀 Would you like to run this on your legacy codebase? Sign up for early access! &lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;Count me in!&lt;/a&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The crucial property is target swappability:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;assay run specs/order-lifecycle/ --target legacy
assay run specs/order-lifecycle/ --target new&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Same specs. Different bindings. Different systems.
The legacy binding hits the old SOAP API; the new binding hits the new Phoenix endpoint.
If both go green, the rewrite is behavior-equivalent for the things the spec covers — which, after a few months of writing specs, is most of the things that matter.&lt;/p&gt;&lt;p&gt;There is no separate &lt;code&gt;.exs&lt;/code&gt; file generated from the specs.
The runner parses &lt;code&gt;.assay&lt;/code&gt; files, matches each step against a regex on a binding function, executes them, and prints pass/fail.
That&amp;#39;s it.&lt;/p&gt;&lt;h3&gt;How Assay actually works&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#how-assay-actually-works&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;The runtime is around six hundred lines of Elixir.
Five design choices do most of the work.&lt;/p&gt;&lt;h4&gt;A two-pass parser&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#a-two-pass-parser&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;The first pass lifts triple-quoted doc strings out of the file (so the line tokenizer doesn&amp;#39;t have to reason about them).
The second pass dispatches on the first keyword on each line.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defparse_string(content)do
{filtered, doc_strings}=
    content
|&amp;gt;String.split(&amp;quot;\n&amp;quot;)
|&amp;gt;Enum.with_index(1)
|&amp;gt;extract_doc_strings()

Process.put(:assay_doc_strings, doc_strings)

  filtered
|&amp;gt;Enum.reject(fn{line, _}-&amp;gt;
    trimmed =String.trim(line)
    trimmed ==&amp;quot;&amp;quot;orString.starts_with?(trimmed,&amp;quot;#&amp;quot;)
end)
|&amp;gt;parse_lines(%Spec{})
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Output is a plain &lt;code&gt;%Spec{}&lt;/code&gt; struct. No AST nodes, no string interpolation.&lt;/p&gt;&lt;h4&gt;The step macro generates real module functions&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#the-step-macro-generates-real-module-functions&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Each &lt;code&gt;step :given, ~r/.../ do ... end&lt;/code&gt; call expands into a uniquely-named function plus a &lt;code&gt;%StepBinding{}&lt;/code&gt; record on an accumulating attribute.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defmacrostep(type, regex,do: block)
when type in[:given,:action,:expect]do
  fn_name = :&amp;quot;__step_#{:erlang.unique_integer([:positive])}__&amp;quot;

quotedo
@assay_steps{unquote(type),unquote(regex),&amp;amp;__MODULE__.unquote(fn_name)/0}

defunquote(fn_name)()do
unquote(block)
end
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;At compile time, &lt;code&gt;__before_compile__&lt;/code&gt; exposes &lt;code&gt;__steps__/0&lt;/code&gt; and &lt;code&gt;__component__/0&lt;/code&gt; for the runner to read.
There is no string codegen, no eval — just normal Elixir function definitions.&lt;/p&gt;&lt;h4&gt;Component-scoped step matching&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#component-scoped-step-matching&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Anyone who has used Cucumber has hit the global step-definition problem: the same step text means different things in different contexts, but Gherkin treats step definitions as one shared namespace.
Assay scopes bindings by component:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defprun_spec(spec, bindings, target, tags, exclude_tags)do
  component_bindings =
Enum.filter(bindings,fn b -&amp;gt; b.component == spec.component end)

# ... run each scenario against component_bindings only
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This is the direct reason Surveyor and Assay use the same identifier convention.
The architecture model and the spec runner share a vocabulary, and step text never collides across bounded contexts.&lt;/p&gt;&lt;h4&gt;Pre-check, then execute&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#pre-check-then-execute&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Before running any step, the runner walks the whole scenario and finds a binding for every step.
If anything is unbound, the scenario fails before any side effects.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;defrun_scenario(scenario, bindings)do
  matched_steps =
Enum.map(scenario.steps,fn step -&amp;gt;
casefind_binding(step, bindings)do
nil-&amp;gt;{:unbound, step}
        binding -&amp;gt;{:bound, step, binding}
end
end)

ifEnum.any?(matched_steps,&amp;amp;match?({:unbound, _},&amp;amp;1))do
%ScenarioResult{status::error,error:&amp;quot;Unbound steps found&amp;quot;}
else
execute_matched_steps(scenario, matched_steps, bindings)
end
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This is what stops a scenario from running half its Givens, hitting an unbound When, and leaving a dangling test customer in the database.&lt;/p&gt;&lt;h4&gt;Per-scenario state in the process dictionary&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#per-scenario-state-in-the-process-dictionary&quot;&gt;​&lt;/a&gt;&lt;/h4&gt;&lt;p&gt;Each scenario gets a fresh context — a tiny module that stores variables, params, doc strings, and data tables in the process dictionary.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code class=&quot;codeBlockLines_e6Vv&quot;&gt;def init do
Process.put(@vars_key,%{})
Process.put(@params_key,%{})
end

defget_var(name)do
Process.get(@vars_key,%{})|&amp;gt;Map.get(name)
end

defset_vars(keyword)do
  vars =Process.get(@vars_key,%{})
Process.put(@vars_key,Enum.into(keyword, vars))
end&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The runner can &lt;code&gt;save/0&lt;/code&gt; and &lt;code&gt;restore/1&lt;/code&gt; the snapshot, which is how Assay tests itself by running its own &lt;code&gt;.assay&lt;/code&gt; specs through itself in the same process.&lt;/p&gt;&lt;p&gt;There is no plugin system, no dependency injection, no scenario hooks beyond &lt;code&gt;cleanup&lt;/code&gt;.
Adding any of those would push complexity into the framework and out of the binding, which is the wrong direction.
The framework exists to be small enough that you can read it on a Friday afternoon and trust it on Monday.&lt;/p&gt;&lt;h2&gt;How they fit together&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#how-they-fit-together&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The flow is straightforward and it maps onto the five phases laid out in the Assay handbook.&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;strong&gt;Discovery (Surveyor).&lt;/strong&gt; Run Surveyor against the legacy codebase. Walk through the C1/C2/C3 output interactively. Edit, retry, accept. Produces &lt;code&gt;workspace.dsl&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Behavioral extraction.&lt;/strong&gt; For each component in the model, write &lt;code&gt;.assay&lt;/code&gt; specs and an Elixir schema. The &lt;code&gt;assay.specs&lt;/code&gt; and &lt;code&gt;assay.schema&lt;/code&gt; properties on every Structurizr component tell you exactly where they go: &lt;code&gt;specs/&amp;lt;context&amp;gt;/&lt;/code&gt; and &lt;code&gt;schemas/&amp;lt;context&amp;gt;.ex&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Validation.&lt;/strong&gt; Write bindings against the legacy system in &lt;code&gt;targets/legacy/&lt;/code&gt;. Run &lt;code&gt;assay run specs/ --target legacy&lt;/code&gt;. Iterate until green. You now have a verified, executable specification of the legacy system&amp;#39;s behavior.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Stabilization.&lt;/strong&gt; Review what&amp;#39;s covered, what&amp;#39;s missing, what&amp;#39;s flagged as ambiguous. Get sign-off on scope.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Rewrite.&lt;/strong&gt; Build the new system. Write bindings against it in &lt;code&gt;targets/new/&lt;/code&gt;. Run &lt;code&gt;assay run specs/ --target new&lt;/code&gt;. When that&amp;#39;s green, the rewrite is done — at least for the surface area the specs cover.&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;The reason the two tools sit next to each other is that the architecture model is what gives the spec work structure.
Without a model, &amp;quot;write specs for the legacy system&amp;quot; is an open-ended task with no obvious stopping point.
With the model, every component has a &lt;code&gt;specs/&lt;/code&gt; directory and the question becomes &amp;quot;is each context&amp;#39;s behavior covered?&amp;quot;
That&amp;#39;s tractable.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;
🚀 Would you like to run this on your legacy codebase? Sign up for early access! &lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;Count me in!&lt;/a&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The cross-target story is what makes the rewrite verifiable.
The same parsed &lt;code&gt;.assay&lt;/code&gt; scenarios are dispatched, step by step, through two different binding modules at two different points in the project&amp;#39;s life:&lt;/p&gt;&lt;p&gt;Same spec, two bindings, two systems.
The runner doesn&amp;#39;t know or care which target it&amp;#39;s dispatching to — that&amp;#39;s the property that makes the rewrite a measurable thing rather than a leap of faith.&lt;/p&gt;&lt;h2&gt;Four audiences, one artifact&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#four-audiences-one-artifact&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Four very different parties end up reading — or executing — the same &lt;code&gt;.assay&lt;/code&gt; file: the &lt;strong&gt;product owner&lt;/strong&gt;, the &lt;strong&gt;developer&lt;/strong&gt;, the &lt;strong&gt;coding agent&lt;/strong&gt;, and the &lt;strong&gt;test runner&lt;/strong&gt;.
The interesting observation is that they don&amp;#39;t conflict on &lt;strong&gt;content&lt;/strong&gt; — they conflict on &lt;strong&gt;form&lt;/strong&gt;.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Product owners&lt;/strong&gt; want to know what the system does in business terms, what would break if you changed X, and where the risk lives.
They don&amp;#39;t want UML.
They want to read scenarios in their domain vocabulary, in plain language, and trust them.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Developers&lt;/strong&gt; want to know how the system is structured, where the seams are, and what invariants they mustn&amp;#39;t break.
And — crucially — they want the documentation to be wrong less often than the code is.
The moment docs lie, developers stop reading them.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Coding agents&lt;/strong&gt; want machine-parseable, unambiguous, addressable artifacts with stable identifiers.
They want to be able to say &amp;quot;the assertion at &lt;code&gt;assay://orderLifecycle/order-placement/OL-001/step-3&lt;/code&gt; failed&amp;quot; and have that mean something durable.
They want types, schemas, and graphs they can traverse.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Automated tests&lt;/strong&gt; want executable bindings — the layer Assay provides — and they want failures that point back to the spec, not just to a line of code.&lt;/p&gt;&lt;p&gt;Same facts, four presentations.&lt;/p&gt;&lt;p&gt;The &lt;code&gt;.assay&lt;/code&gt; file gives the product owner readable scenarios in domain language.
The &lt;code&gt;workspace.dsl&lt;/code&gt; plus the schema modules give the developer the structural truth.
The bracketed identifiers (&lt;code&gt;Component: [orderLifecycle]&lt;/code&gt;, &lt;code&gt;Scenario: [OL-001]&lt;/code&gt;) give the agent its stable addresses.
The runner turns the whole thing into a regression suite.
None of these audiences is asked to read the others&amp;#39; representation; they all read the same spec, surfaced at the level of detail they need.&lt;/p&gt;&lt;h2&gt;Where the LLM lives, and where it doesn&amp;#39;t&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#where-the-llm-lives-and-where-it-doesnt&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;A reasonable question at this point: with all the recent enthusiasm for LLMs, why isn&amp;#39;t more of this LLM-driven?&lt;/p&gt;&lt;p&gt;The split is deliberate.&lt;/p&gt;&lt;p&gt;Surveyor uses an LLM because architectural discovery is genuinely a language task — you&amp;#39;re reading config files, route definitions, deployment manifests, dependency lock files, and inferring &amp;quot;this is the API, that&amp;#39;s the worker, that&amp;#39;s the read model.&amp;quot;
That&amp;#39;s exactly the kind of fuzzy, evidence-weighing job an LLM does well, especially when you tell it to flag uncertainty rather than guess.&lt;/p&gt;&lt;p&gt;Assay does &lt;strong&gt;not&lt;/strong&gt; use an LLM at runtime.
The runner is deterministic: parse the spec, match the regex, execute the binding, assert.
A behavioral spec that sometimes passes and sometimes does not, depending on which model variant happened to answer, is not a spec.&lt;/p&gt;&lt;p&gt;LLMs are very welcome on the authoring side — drafting &lt;code&gt;.assay&lt;/code&gt; files from legacy source is a great agent task, and the handbook has explicit guidance for coding agents about how to do that without inventing behavior.
But the green/red signal at the end has to come from a deterministic runner, or it is not really a signal.&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;
🚀 Sign up for early access:&lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;Count me in!&lt;/a&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;h2&gt;How you use them on a project&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#how-you-use-them-on-a-project&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;In practice, the first day of a legacy rewrite looks something like this:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;Clone the repo. Run Surveyor with &lt;code&gt;--phase c1&lt;/code&gt; to get the system in context. Discuss the actors and external systems with the client — this alone surfaces &amp;quot;wait, who is the Admin role?&amp;quot; conversations that would otherwise happen three months in.&lt;/li&gt;&lt;li&gt;Run &lt;code&gt;--phase c2&lt;/code&gt; to map containers. This is where you discover the data plane that nobody documented, the cron job running on a forgotten server, the queue that two services share. Surveyor flags &lt;code&gt;confidence: low&lt;/code&gt; items; the human resolves them.&lt;/li&gt;&lt;li&gt;Run &lt;code&gt;--phase c3&lt;/code&gt; per container. By the end you have a &lt;code&gt;workspace.dsl&lt;/code&gt; that is, for the first time in the project&amp;#39;s history, an accurate picture of what is deployed.&lt;/li&gt;&lt;li&gt;Pick the highest-risk bounded context. Read the legacy source. Draft &lt;code&gt;.assay&lt;/code&gt; specs for its behavior. Write a legacy binding. Run it. Iterate until green.&lt;/li&gt;&lt;li&gt;Repeat per context, in priority order, until coverage is good enough to start the rewrite.&lt;/li&gt;&lt;li&gt;Build the new system, one bounded context at a time, with &lt;code&gt;targets/new/&lt;/code&gt; bindings going green as each context comes online.&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;The thing both tools are optimized for is the same: making the work legible.
A legacy rewrite is a long project with shifting personnel and a nervous client.
Both Surveyor and Assay produce artifacts — a &lt;code&gt;workspace.dsl&lt;/code&gt; and a directory of &lt;code&gt;.assay&lt;/code&gt; files — that survive turnover, show progress, and that the client can actually read.&lt;/p&gt;&lt;h2&gt;Status and next steps&lt;a href=&quot;https://bitcrowd.dev/from-legacy-code-to-verifiable-specifications-with-surveyor#status-and-next-steps&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Both tools are ready to be tested in real world scenarios. &lt;strong&gt;This is where we need your help&lt;/strong&gt;:&lt;/p&gt;&lt;p&gt;If you have a legacy system staring you down and a rewrite on the roadmap, &lt;a href=&quot;https://73f4313d.sibforms.com/serve/MUIFAGf7xJuMm4W5YSsztruKlELH459zdPyB50IhmpgBnS6wyrTGqz7dQ55IJLSVa7CUPFvAHrUEvEYbHEn5tBivo8SQPN_cJIMdBr_O1xiQ9ug64k46sfNBIFuTHK1rNKqvqytuDkTUoh0C5XMyUKOPZy7whNQM8zviGihyCCrZYBTq7uTf5-35fK6Ki2YBOVnEAmV_WwrgoT1-wg==&quot;&gt;get in touch&lt;/a&gt;. We would love to hear from you!&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Elixir: Introduction</title>
<link>https://priver.dev/post/elixir-introduction</link>
<guid isPermaLink="false">hs1NTOkg8A_vQRj0zfvHsjQluwDtepppWf87Bw==</guid>
<pubDate>Sun, 26 Apr 2026 14:28:15 +0000</pubDate>
<description>Elixir: Introduction</description>
<content:encoded>Elixir: Introduction</content:encoded>
</item>
<item>
<title>Head of the Agents and Assistants Department</title>
<link>https://rocket-science.ru/hacking/2026/04/23/developing-as-ai</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">WkNZM12AigiuASQEvEpBhyPVLPzlG-ZnZzS21g==</guid>
<pubDate>Thu, 23 Apr 2026 10:20:28 +0000</pubDate>
<description>A first-hand account of building the Cure programming language with AI agents as co-developers—what worked, what crashed spectacularly, and the distilled rules that emerged from the wreckage.</description>
<content:encoded>&lt;p&gt;Let me state upfront: my attitude toward AI assistants cannot be expressed as a boolean value. If you need an answer to the question posed point-blank—“New York Yankees or Boston Red Sox?”—I do not watch baseball at all; I’m a Barça fan. That said, I find AI assistants a perfectly legitimate and even liquid asset. The text below is an account of what made my work with agents pleasant and reduced their errors and rough edges to an acceptable minimum.&lt;/p&gt;&lt;p&gt;A little under a year ago I began working on &lt;a href=&quot;https://cure-lang.org&quot;&gt;Cure&lt;/a&gt;, a programming language with dependent types, finite state machines as first-class citizens, SMT verification, and other niceties, compiling to the BEAM.&lt;/p&gt;&lt;p&gt;My first approach to the apparatus ended in ignominious failure. I got tangled in my own architectural decisions, started piling on crutches wherever they fit, turned the code completely into an Italian restaurant menu, and lost heart. In a fit of idiocy I had an assistant generate the website and showed it to the public—the ideas themselves were intriguing enough, dependent types and SMT solvers on the BEAM are hardly redundant, and I harboured a quiet hope for community interest. The community correctly identified the language’s site as slop generated by a language model and received my attempt with arctic indifference. I got a great many “nothing works” responses and not a single coherent suggestion for improvement (no fault of the community’s—against the backdrop of the slop-flood of those days, my project would not have looked like Noah’s Ark even to an extremely charitable observer).&lt;/p&gt;&lt;p&gt;I stepped back, examined my creation from all angles, and was forced to admit: I had produced a monster. I saw no chance of licking it into shape, even through a global refactor. I bought a ream of paper and some pencils and started drawing, in order to understand exactly where I had gone wrong. (Spoiler: I had been so enchanted by the idea itself, and so desperate to get something to launch and run, that I had done literally everything wrong.)&lt;/p&gt;&lt;p&gt;I had no intention of giving up—and before me, full height, loomed the necessity of rewriting everything from scratch without repeating the mistakes. By that point I knew that artificial assistants could significantly accelerate the actual writing of code, so I started by erecting scaffolding around the future project. It was obvious to me then (I can now confirm that former intuition with experience) that all those prompts along the lines of “You are a genius architect with three hundred years of experience designing languages with dependent types and SMT solvers” work no better than the morning pep talk to an intern at standup: “You are a great programmer who has written three hundred million billion lines of code without a debugger.” If an assistant tasked with writing coherent code to a spec needed motivational gibberish to function, it would not be worth using under any circumstances whatsoever. Seriously, think about it: a model is asked to implement a GCD module using the Euclidean algorithm—are you really suggesting its deeply baked-in internal rules will not guide it down the correct branches of the conditional operators without first being told it has the soul of a prima ballerina and an avant-garde poet? What on earth does “You are a &lt;em&gt;senior architect&lt;/em&gt;” actually mean? Do the people who advocate this believe that without such a preamble the training pathways via the memoirs of axolotl breeders will activate instead?&lt;/p&gt;&lt;p&gt;So, instead of all those skills/agents/whatever, I started by feeding this mechanised beast the source code of all my own libraries, lovingly written by hand, with the note: “Here are examples of good code. Write like this. Not like that—do not write like that.” I know it is immodest, but it is my assistant. You are welcome to feed yours your own code.&lt;/p&gt;&lt;p&gt;Then I reclaimed the wasteland: thus were born &lt;a href=&quot;https://hexdocs.pm/metastatic&quot;&gt;Metastatic&lt;/a&gt;, implementing MetaAST for different languages across different paradigms, and &lt;a href=&quot;https://hexdocs.pm/ragex&quot;&gt;Ragex&lt;/a&gt;—a RAG built on AST rather than plain text (my hunch that AST fits into a context window far more easily and is far better structured than raw source code turned out to be correct).&lt;/p&gt;&lt;p&gt;My task was building a new language; constructing a new ecosystem from scratch was not part of the brief. So I analysed existing solutions—Rust, Go, Elm, Gleam—and chose the one I considered most mature (I never promised the project would be neutral with respect to my tastes and preferences). I simply copied the Elixir ecosystem and added to it what I had personally found lacking over the last ten years. Thanks to standing on the shoulders of giants in this regard, the model wrote almost the entire ecosystem for me; I simply told it: “Look how beautifully new project creation is handled in Elixir—do the same for Cure.” Language models are strong at translation, and Elixir is &lt;a href=&quot;https://dashbit.co/blog/why-elixir-best-language-for-ai&quot;&gt;considerably more intelligible&lt;/a&gt; to them compared to almost every other language.&lt;/p&gt;&lt;p&gt;So, before the first line of code, my backpack already held: the right AST—a language in which the assistant and I can communicate far more easily than in homespun English—a handcrafted RAG, and a clear understanding that every step must be a simple, atomic change. &lt;strong&gt;The fewer choices the assistant has to make between two paths, the cleaner the result.&lt;/strong&gt; This principle outweighs the quality of all prompts combined.&lt;/p&gt;&lt;p&gt;Next I had to solve the problem of validating the produced code. My eyes are sharp, but they occasionally miss non-obvious bad decisions in review. Thus was born &lt;a href=&quot;https://hexdocs.pm/oeditus_credo&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;oeditus_credo&lt;/code&gt;&lt;/a&gt;—a set of nearly forty additional &lt;a href=&quot;https://hexdocs.pm/credo&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;credo&lt;/code&gt;&lt;/a&gt; checks covering vulnerabilities, anti-patterns, and the like. The library also ships a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mix oeditus_assistant_rules&lt;/code&gt; command, a rules generator for the soulless assistant. To those rules I also added: after each stage, verify that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mix format &amp;amp;&amp;amp; mix credo --strict &amp;amp;&amp;amp; mix dialyzer &amp;amp;&amp;amp; mix test&lt;/code&gt; passes; update all documentation; add regression tests for new code; then run all regression tests and confirm they are still as green as my face the morning after a party.&lt;/p&gt;&lt;p&gt;Every reasonably significant stage also ends with creating an “example” in the &lt;a href=&quot;https://github.com/am-kantox/cure-lang/tree/main/examples&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;examples&lt;/code&gt;&lt;/a&gt; folder. Something for people to look at, and the regression tests never idle.&lt;/p&gt;&lt;p&gt;At this point I felt ready to start writing actual code. Cure has certain critical parts I wrote by hand from scratch. Every other pull request gets manual edits from me. Every bug I find through manual testing I fix by hand (the obvious ones aside). And yet I reached the desired result considerably faster than if I had written every line in Vim.&lt;/p&gt;&lt;p&gt;Over the course of working on Cure I learned to keep the number of errors—and consequently manual edits—to a minimum. A fairly substantial release, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v0.26.0&lt;/code&gt;, required not a single correction, for example. Here is the distillate of my rules for communicating with an assistant, in case it proves useful to anyone:&lt;/p&gt;&lt;ul&gt;
  &lt;li&gt;the task must be self-contained but not too large; “first define the types for numeric, then we will write the converters” does not work&lt;/li&gt;
  &lt;li&gt;inside the task there must be no undefined ambiguities that will derail our &lt;em&gt;T9&lt;/em&gt;; there must be exactly one path to the solution&lt;/li&gt;
  &lt;li&gt;before tackling any task, demand an implementation plan and edit it until all ambiguities have vanished&lt;/li&gt;
  &lt;li&gt;the task must be roughly solved in your own head before turning to the assistant; otherwise the odds of agreeing with an incorrect solution are uncomfortably high&lt;/li&gt;
  &lt;li&gt;generated code must be comprehensible and elegant, “rewrite this nicely, I fed you three gigabytes of rules” does not work; if the solution is aesthetically repellent, there is a problem with the task formulation—close the session and start over&lt;/li&gt;
  &lt;li&gt;setting a task and going for coffee is the direct path to the infinite iterations described in the previous point; the stream of unconsciousness must be watched and any attempt to stray from the planned path killed without mercy&lt;/li&gt;
  &lt;li&gt;finally, if it seems to you that the cognitive load is decreasing and that any cook could now implement such a project—you need pharmaceutical intervention; your fingers tire less, yes, but if you could not have implemented the project from scratch in a text editor, the LLM is no help to you; it will generate something suspiciously twitching, no argument there, but the first halfway-serious complexity requiring a considered architectural decision will put a cross on the whole enterprise.&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;That is my experience. Yours may differ; I can bear with you on that matter.&lt;/p&gt;&lt;p&gt;Try &lt;a href=&quot;https://cure-lang.org&quot;&gt;https://cure-lang.org&lt;/a&gt; and maybe you will like it. The site now has a &lt;a href=&quot;https://cure-lang.org/playground&quot;&gt;playground&lt;/a&gt; where you can experiment with types in real time, and an almost &lt;a href=&quot;https://cure-lang.org/repl&quot;&gt;real console&lt;/a&gt; where you can play with the REPL without installing anything locally.&lt;/p&gt;&lt;p&gt;Happy curing!&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Scotty, I need warp speed in three minutes - Hauleth</title>
<link>https://hauleth.dev/post/things-about-elixir-you-probably-will-never-need/</link>
<enclosure type="image/jpeg" length="0" url="https://hauleth.dev/banner.png"></enclosure>
<guid isPermaLink="false">LgEc9iGNIbYFt5qz01nZzQVKxWy9JGcaHjxqjg==</guid>
<pubDate>Mon, 20 Apr 2026 23:02:18 +0000</pubDate>
<description>My journey into optimising Elixir codebase of Ultravisor (my fork of Supabase&#39;s Supavisor). This story is not about a goal, but ~~friends~~ optimisations we met along the way.</description>
<content:encoded>&lt;p&gt;In my last larger gig I worked on fascinating project - &lt;a href=&quot;https://github.com/supabase/supavisor&quot;&gt;Postgres connection pooler written in Elixir&lt;/a&gt;. Unfortunately, due to different circumstances, this project burned me out to the ground. However what doesn&amp;#39;t kill you &lt;del&gt;is crap not a weapon&lt;/del&gt; can become great learning experience.&lt;/p&gt;&lt;p&gt;Most of my achievements in this project were related to performance. This project contains &lt;strong&gt;very&lt;/strong&gt; tight loop in form of query handler, that needed to run hundreds of thousands times per second per user connection. That mean that this functions are &lt;em&gt;very&lt;/em&gt; sensitive to even slightest performance changes. And that was my task - to find potential improvements that can be made to make this codebase be much faster.&lt;/p&gt;&lt;p&gt;After departing from Supabase I liked the project so much (mostly as learning ground) that I have created my own fork, where unrestrained from all business side of the project I could focus purely on squeezing as much of performance as I can. This project now lives as &lt;a href=&quot;https://github.com/Ultravisor/ultravisor&quot;&gt;Ultravisor&lt;/a&gt; - it is still nowhere near being done in a way that I like, but I still go back to work on it from time to time to find potential performance improvements.&lt;/p&gt;&lt;p&gt;This is a story of things that I have done and learned during that journey.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Beware&lt;/strong&gt;: It is a retrospection, so in some places my memory may be not the best.&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Here I need to provide some explanation first, about how Ultravisor works with database connections. It provides 2 modes of operation:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;code&gt;session&lt;/code&gt; - where each connection from user to Ultravisor checks out one connection from Ultravisor to database. It checks out once, at the start of connection, and then holds connection until the end;&lt;/li&gt;&lt;li&gt;&lt;code&gt;transaction&lt;/code&gt; - where on connection there is nothing done. Client connects to the Ultravisor and can keep that connection indefinitely without ever bothering database. Database connection is checked out &lt;em&gt;only&lt;/em&gt; when there is some request from user and is returned to the pool as soon as that result of that query is returned and DB is ready for next one.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;While &lt;code&gt;session&lt;/code&gt; mode is quite on par with other implementations of connection pooling for Postgres, &lt;code&gt;transaction&lt;/code&gt; mode is where performance is lacking and is the main focus is put. In whole article (unless mentioned otherwise) I will speak about &lt;code&gt;transaction&lt;/code&gt; mode of Ultravisor.&lt;/p&gt;&lt;h2&gt;Lesson: Flame graphs and call tracing is essential#&lt;/h2&gt;&lt;p&gt;Pretty obvious thing, but still valuable lesson for any performance optimisation endeavour. For that the great thanks to &lt;a href=&quot;https://github.com/Stratus3D&quot;&gt;Trevor Brown&lt;/a&gt; and his awesome project &lt;a href=&quot;https://github.com/Stratus3D/eflambe&quot;&gt;eFlambè&lt;/a&gt;. This helped a lot in tracing hot points in the running code.&lt;/p&gt;&lt;p&gt;Unfortunately this project seems to be less active recently and has some missing features, like &lt;a href=&quot;https://github.com/Stratus3D/eflambe/issues/48&quot;&gt;listening for given duration instead of function calls count&lt;/a&gt;. This can be partially fixed by simply listening for count of calls to &lt;code&gt;handle_event/4&lt;/code&gt; function given times and then running &lt;code&gt;cat *.bggg&lt;/code&gt; to concatenate all files into larger trace. That has disadvantages, but at least it was workable within &lt;a href=&quot;https://speedoscope.app&quot;&gt;Speedoscope&lt;/a&gt; which I also highly recommend to anyone who needs to work on such optimisation.&lt;/p&gt;&lt;p&gt;While flame graphs are awesome, there is cost to gathering them with eFlambè - it greatly affects performance. Fortunately Erlang has some built in tools that have lesser performance impact, and the &amp;quot;most modern&amp;quot; of these is &lt;a href=&quot;https://erlang.org/doc/man/tprof.html&quot;&gt;&lt;code&gt;tprof&lt;/code&gt;&lt;/a&gt;. This tool is pretty easy to use, but is less detailed than eFlambè. But even with that limitation, it provides superb insight into stuff that has greatest impact on performance, as well as it make it easier to work on long running processes, as it work asynchronously, so you can &amp;quot;manually&amp;quot; decide how long you want to trace your process.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt; Knowing where your bottlenecks are is essential for performance optimisations.&lt;/p&gt;&lt;h2&gt;Lesson: Doing less can improve performance#&lt;/h2&gt;&lt;p&gt;Obvious thing that need to be stated - doing nothing is faster than doing something. Extracting amount of data sent over given socket using &lt;code&gt;:inet.getstat/2&lt;/code&gt; call is fast, but not free. That involves some waiting for response from either port or process handling connection, which introduces slowdown. Two possible solutions there are:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;Do not gather that metric at all - sensible, but not feasible, especially when you use that metric to charge your users.&lt;/li&gt;&lt;li&gt;Gather that data less often.&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;The approach I have taken there is obviously 2., and the solution is dumb simple - debouncer.&lt;/p&gt;&lt;p&gt;Debouncing is an interesting technique often used in user interfaces where you accept some event, and then for some period you ignore repeated events. The reason for that is that our interfaces may have flaws that send repeated events one after another.&lt;/p&gt;&lt;p&gt;In this case Ultravisor tries to store amount of sent data after each query, but that can get expensive for many short queries. Instead I have implemented simple per-process debouncer:&lt;/p&gt;&lt;p&gt;This stores returned data in process dictionary (per-process mutable space with quick access) and if there was no call in given time-period, then we process data again. This is safe way to do so, as &lt;code&gt;:inet.getstat/2&lt;/code&gt; will always return amount of data that socket processed since it started, so data between calls will be accounted.&lt;/p&gt;&lt;p&gt;Before:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 79401.392762 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After (10ms of debouncing):&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 80069.646510 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After (100ms of debouncing):&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 80568.825937 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Summary&lt;/strong&gt;: Doing noting is more performant than doing something. Sometimes doing nothing can be quite easy.&lt;/p&gt;&lt;h2&gt;Lesson: Telemetry is not free#&lt;/h2&gt;&lt;p&gt;When working on most projects, especially Phoenix-based, one can slap &lt;code&gt;:telemetry.execute/3&lt;/code&gt; calls everywhere and notice no performance degradation&lt;sup&gt;1&lt;/sup&gt;. Unfortunately, when you do hundreds of thousands calls a second - that is not a case.&lt;/p&gt;&lt;div&gt;&lt;sup&gt;1&lt;/sup&gt;&lt;p&gt;For unaware readers - &lt;a href=&quot;https://github.com/beam-telemetry/telemetry&quot;&gt;Telemetry&lt;/a&gt; is Erlang event dispatching system for observability events.&lt;/p&gt;&lt;/div&gt;&lt;p&gt;In this project the metrics are exposed in Prometheus/OpenMetrics format, which means that there needs to be collection system within the application. In BEAM applications the standard way to implement that is to use ETS tables to store recorded values. Fortunately there are libraries to handle that for you, and for the longest time &amp;quot;gold standard&amp;quot; for it was &lt;code&gt;telemetry_prometheus_core&lt;/code&gt; library created by Telemetry core team.&lt;/p&gt;&lt;p&gt;While for most projects that library is performant enough (because metrics aren&amp;#39;t recorded in quite tight loops), in case of this project that was not a case. There, metrics gathering is still one of the hottest spot in the codebase, even with all improvements that have been done.&lt;/p&gt;&lt;p&gt;Excerpt from &lt;code&gt;tprof&lt;/code&gt; profile:&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Function&lt;/th&gt;&lt;th&gt;Calls count&lt;/th&gt;&lt;th&gt;Per call (μs)&lt;/th&gt;&lt;th&gt;Percentage&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Peep.EventHandler.store_metrics/5&lt;/code&gt;&lt;/td&gt;&lt;td&gt;1421911&lt;/td&gt;&lt;td&gt;0.13&lt;/td&gt;&lt;td&gt;4.21%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Peep.Storage.Striped.insert_metric/5&lt;/code&gt;&lt;/td&gt;&lt;td&gt;904852&lt;/td&gt;&lt;td&gt;0.26&lt;/td&gt;&lt;td&gt;5.11%&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;p&gt;This is with awesome library &lt;a href=&quot;https://github.com/rkallos/peep&quot;&gt;Peep&lt;/a&gt; by &lt;a href=&quot;https://github.com/rkallos&quot;&gt;Richard Kallos&lt;/a&gt;. When using &lt;code&gt;telemetry_prometheus_core&lt;/code&gt; it was simply the most expensive thing in whole loop. Just replacing metrics gathering library with Peep gave us about 2x bump in TPS.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt; Telemetry handler can matter in tight loops. Fast metrics gathering isn&amp;#39;t easy.&lt;/p&gt;&lt;h2&gt;Lesson: Records instead of maps or structs#&lt;/h2&gt;&lt;p&gt;Elixir uses structs for structured data. That gives a lot nice features wrt. hot code reloads, compilation graph dependencies, and other. However, because structs are maps, there is a cost. Maps have O(log n) access time to the fields, this is how maps are constructed in memory. While smaller maps have slightly different (better in most cases) characteristics, there is strict requirement that you keep your structure with less than 31 fields&lt;sup&gt;2&lt;/sup&gt; and it still has slight memory overhead. The alternative is to use &lt;a href=&quot;https://hexdocs.pm/elixir/Record.html&quot;&gt;records&lt;/a&gt;. These have better performance characteristic (always constant) irrelevant of the amount of fields at the cost of being slightly more rigid (records are tuple based) and less convenient to use (experience may vary). Additional advantage in my opinion is that it is harder to add incorrect field by using &lt;code&gt;Map&lt;/code&gt; module.&lt;/p&gt;&lt;div&gt;&lt;sup&gt;2&lt;/sup&gt;&lt;p&gt;Current (OTP 28) limit for small map is 32 keys, but Elixir uses one key for struct name, hence 31 fields is the limit.&lt;/p&gt;&lt;/div&gt;&lt;p&gt;Before you will run and change all structs in your system to records, just remember - most of the time the difference doesn&amp;#39;t matter - just use structures.&lt;/p&gt;&lt;p&gt;Before:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 81765.266264 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 82147.855889 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt; &lt;code&gt;Record&lt;/code&gt;s are super handy when you need to squeeze each bit of performance. It doesn&amp;#39;t provide much, but these adds up.&lt;/p&gt;&lt;h2&gt;Lesson: ETS tables are super fast, but not always#&lt;/h2&gt;&lt;p&gt;&lt;a href=&quot;https://www.erlang.org/doc/apps/stdlib/ets.html&quot;&gt;ETS&lt;/a&gt; is Erlang&amp;#39;s built-in module for storing key-value data in mutable way. Like built-in Redis. This structure allows for sharing some data in a way, that is easy to access from different parts of the system. One example of system that is using ETS for storing their information is Telemetry (mentioned above).&lt;/p&gt;&lt;p&gt;While for 99% of the use cases Telemetry will be fast enough, it has some problems with tight loops. Main problem is that it will always copy data from table to the caller process. That mean that it can put high memory pressure on the process that tries to retrieve data.&lt;/p&gt;&lt;p&gt;Fortunately Erlang supports another mechanism for storing globally accessible data - &lt;code&gt;persistent_term&lt;/code&gt;. Of course, there is no such thing as &amp;quot;free lunch&amp;quot; so it has substantial disadvantage - it works poorly&lt;sup&gt;3&lt;/sup&gt; with data that changes often, as removing or changing data in a key will require walk through all processes to copy data from it to processes that may use it into process memory. However - Telemetry handlers should not change a lot, you should just set them once as soon as your system start, and then ideally they will not change ever again.&lt;/p&gt;&lt;div&gt;&lt;sup&gt;3&lt;/sup&gt;&lt;p&gt;There is slight optimisation that makes it fast in some cases (single word values, like atoms), but that is not the case there, so we can ignore that.&lt;/p&gt;&lt;/div&gt;&lt;p&gt;Before&lt;sup&gt;4&lt;/sup&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 76914.004685 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 78479.006634 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;sup&gt;4&lt;/sup&gt;&lt;p&gt;If you wonder why these results are lower than in previous section, it is because test conditions are identical only per section, not cross sections. In this particular case I have ran benchmark while collecting metrics (to show difference in &lt;code&gt;persistent_term&lt;/code&gt; change) while other are ran without metrics to not pollute results.&lt;/p&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt; &lt;code&gt;persistent_term&lt;/code&gt; is awesome and super fast, so if you know that you have some data that will probably never change and will be requested &lt;em&gt;constantly&lt;/em&gt;, then it may be good place to store that data.&lt;/p&gt;&lt;h2&gt;Lesson: Calling your &lt;code&gt;GenServer&lt;/code&gt;s is fast, but not 90k times per second fast#&lt;/h2&gt;&lt;p&gt;One of the interesting observations that I have spotted is that if there are longer running queries, ones that send more data over the network than just simple short responses, then the difference between Ultravisor and &amp;quot;state of the art&amp;quot; tools like &lt;a href=&quot;https://www.pgbouncer.org&quot;&gt;PgBouncer&lt;/a&gt; or &lt;a href=&quot;https://pgdog.dev&quot;&gt;PgDog&lt;/a&gt; (that are written in non-managed languages like C and Rust) is much smaller (obviously it is still there, but it is on par, not substantially off).&lt;/p&gt;&lt;p&gt;I needed to dig more, what can be the cause of such strange behaviour. The reason was found in place where I least expected it - checking out database connection to be used.&lt;/p&gt;&lt;p&gt;Flame graph showed that almost third of the time is spent on checking out database connections, and most of that time is spent in 2 function calls, both of them are internally &lt;code&gt;gen_statem&lt;/code&gt; calls and in both most time is spent on sleeping (aka, waiting for reply).&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://hauleth.dev/post/things-about-elixir-you-probably-will-never-need/sleep.png&quot; alt=&quot;Image showing left-heavy flamegraph of the profiled application&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;Now, this one is hard thing to optimise, as in Elixir there is no mutability (almost, we will get there). This mean that if I want some form of shared queue of processes, then I need to use separate process to keep state of the queue for us, and then do &lt;code&gt;GenServer&lt;/code&gt; calls to fetch that state. What I did in such situation? What any unreasonable Elixir developer obsessed with performance would do - NIF&lt;sup&gt;5&lt;/sup&gt;.&lt;/p&gt;&lt;div&gt;&lt;sup&gt;5&lt;/sup&gt;&lt;p&gt;I wanted to use ETS there, but for that to work it lacks function like &lt;code&gt;ets:take/2&lt;/code&gt; that would return only one element from the tables with type &lt;code&gt;bag&lt;/code&gt; or &lt;code&gt;duplicate_bag&lt;/code&gt;. Or any other form of just taking out any (possibly random) element from ETS table in atomic way.&lt;/p&gt;&lt;/div&gt;&lt;p&gt;The implementation is rather basic wrapper over &lt;a href=&quot;https://doc.rust-lang.org/1.94.0/std/collections/struct.VecDeque.html&quot;&gt;&lt;code&gt;VecDeque&lt;/code&gt;s&lt;/a&gt; that allow popping single element from that queue without any message passing. The implementation is very crude, nowhere production ready. It doesn&amp;#39;t provide any form of worker restarts or anything, but works quite well as PoC of what is possible.&lt;/p&gt;&lt;p&gt;New queue also provides a way to store additional &amp;quot;metadata&amp;quot; alongside the worker PID. This allows me to store DB connection socket next to connection process, which removes need for additional call to extract that data to pass requests directly to other DB, without copying data between processes.&lt;/p&gt;&lt;p&gt;Before:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 83619.640673 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;tps = 94191.475386 (without initial connection time)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt; Sometimes one need to get creative to get around platform limitations. This may require some pesky NIFs though.&lt;/p&gt;&lt;h2&gt;Conclusions#&lt;/h2&gt;&lt;p&gt;Optimising such project was enormous fun and I think that at the current state there is nothing extra that can be done to optimise it more without optimising generated JIT-ed native code or optimising Erlang scheduler.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;https://hauleth.dev/post/things-about-elixir-you-probably-will-never-need/final.png&quot; alt=&quot;Final flamegraph of application&quot; title=&quot;&quot;/&gt;&lt;/p&gt;&lt;p&gt;There are some flags, that affect performance, but as it is currently unclear why these work at all (probably it is related more to the OS scheduler rather than Erlang performance), I left them out of this article for now.&lt;/p&gt;&lt;h2&gt;Post Scriptum: Good tooling helps a lot#&lt;/h2&gt;&lt;p&gt;Just after I have started that optimisation project after leaving Supabase I started using &lt;a href=&quot;https://docs.jj-vcs.dev/latest/&quot;&gt;Jujutsu&lt;/a&gt; for version control. That one thing helped me &lt;strong&gt;a lot&lt;/strong&gt; with being able to have separate branches/PRs for each of the changes, while at the same being able to work with &lt;a href=&quot;https://steveklabnik.github.io/jujutsu-tutorial/advanced/simultaneous-edits.html&quot;&gt;mega-merge&lt;/a&gt; of them all.&lt;/p&gt;&lt;p&gt;That allows me to profile code with all other noise removed, while still exposing the changes as separate reviewable units. Without that support I would need to decipher what have already been changed and/or removed from the profile.&lt;/p&gt;&lt;p&gt;Additional feature that I heavily used there is &amp;quot;anonymous branching&amp;quot;. As when working with JJ I do not need to create new name for each branch that I want to try, it was way easier to implement one idea, then just do &lt;code&gt;jj new @-&lt;/code&gt; (which branches off at the commit that is parent of the current one) and just implement alternative idea. I used that constantly to compare ideas and reject failed concepts.&lt;/p&gt;&lt;div&gt;∎&lt;/div&gt;</content:encoded>
</item>
<item>
<title>Resist Vendor Lock-In With Supabase</title>
<link>https://www.thestackcanary.com/supasafe-supabase/</link>
<guid isPermaLink="false">Se4vQzxkzzKERy-Gt6r5mjM4TxDJj9Dpcg6PoA==</guid>
<pubDate>Sun, 19 Apr 2026 04:26:32 +0000</pubDate>
<description>Not only is Supabase the open-source Firebase alternative, it just might be safer too.</description>
<content:encoded>&lt;img src=&quot;https://www.thestackcanary.com/content/images/2024/04/Dark-Blue-Virtual-Tech-Banner--1-.png&quot; alt=&quot;Resist Vendor Lock-In With Supabase&quot; title=&quot;&quot;/&gt;&lt;p&gt;In one of his recent YouTube videos, &lt;a href=&quot;https://twitter.com/t3dotgg?ref=thestackcanary.com&quot;&gt;Theo Browne&lt;/a&gt; highlights the potential pitfalls that comes with using a Platform-as-a-Service. &lt;/p&gt;&lt;p&gt;The video goes over recent work done to fuzz suspected Firebase-backed services looking for leaked credentials. As is shown in the video, this is an all-too common pattern in these platforms, where the &amp;quot;backend&amp;quot; is being directly manipulated from client-side SDKs, such that theres a high likelihood that credentials are carelessly exposed to the client. In addition to potential secret leakage, many of these platforms, including Firestore, do not have great default security settings.  This has obvious MAJOR security concerns is a horrible practice for the industry to standardize on, but yet these platforms still remain extremely popular despite known security concerns due to their ease of use and how they enable rapid development. &lt;/p&gt;&lt;p&gt;In addition to these security concerns, Firebase has long drawn the ire of many developers due to its tight coupling with Google&amp;#39;s proprietary ecosystem, and its cost structure which has lead to more than a few horror stories about unexpectedly high bills (although these issues can often be attributed to poor database practices that lead to exponentially ballooning costs). With much apprehension towards Firebase, many looked for alternatives, and found themselves needing to piece together a solution from many different parts. Here enters &lt;a href=&quot;https://supabase.com/?ref=thestackcanary.com&quot;&gt;Supabase&lt;/a&gt;, which pitches itself as the &amp;quot;open-source Firebase alternative.&amp;quot;  Supabase offers similar solutions as Firebase, such as a realtime database, object storage, user authentication and management, edge functions, and much more.  Supabase, however, is built entirely from open-source software (in fact, they stipulate that &lt;a href=&quot;https://github.com/supabase/supabase?tab=readme-ov-file&amp;amp;ref=thestackcanary.com#how-it-works&quot;&gt;Supabase will only ever include software that comes with an MIT, Apache 2, or equivalent license&lt;/a&gt;).&lt;/p&gt;&lt;p&gt; When asked about his thoughts on Supabase, Theo rightly points out that these problems still exist when using their client SDKs, but he stresses that unlike Firebase, since Supabase is built entirely on open-source software (namely &lt;a href=&quot;https://www.postgresql.org/?ref=thestackcanary.com&quot;&gt;PostgreSQL&lt;/a&gt;) you not only can connect directly to the database, but it is actively encouraged by the CEO himself.&lt;/p&gt;&lt;figure&gt;&lt;div&gt;&lt;blockquote&gt;&lt;p&gt;cc &lt;a href=&quot;https://twitter.com/chasers?ref_src=twsrc%5Etfw&amp;amp;ref=thestackcanary.com&quot;&gt;@chasers&lt;/a&gt;&lt;a href=&quot;https://twitter.com/filipecabaco?ref_src=twsrc%5Etfw&amp;amp;ref=thestackcanary.com&quot;&gt;@filipecabaco&lt;/a&gt;&lt;a href=&quot;https://twitter.com/wenboxie?ref_src=twsrc%5Etfw&amp;amp;ref=thestackcanary.com&quot;&gt;@wenboxie&lt;/a&gt;&lt;br/&gt;&lt;br/&gt;Alternatively: just connect to Postgres&lt;br/&gt;¯\_(ツ)_/¯&lt;/p&gt;— Paul Copplestone — e/postgres (@kiwicopple) &lt;a href=&quot;https://twitter.com/kiwicopple/status/1781161242637602896?ref_src=twsrc%5Etfw&amp;amp;ref=thestackcanary.com&quot;&gt;April 19, 2024&lt;/a&gt;&lt;/blockquote&gt;&lt;/div&gt;&lt;/figure&gt;&lt;p&gt;Wow! What a breath of fresh air that is! And as it turns out, even when &amp;quot;just&amp;quot; using Supabase in this way, it is still great! You get an authentication table with Row-Level-Security and robust security practices out of the box, access to their wonderful front-end database management, and the ability to easily integrate their other features such as &lt;a href=&quot;https://supabase.com/edge-functions?ref=thestackcanary.com&quot;&gt;Edge Functions&lt;/a&gt;, &lt;a href=&quot;https://supabase.com/storage?ref=thestackcanary.com&quot;&gt;Object Storage&lt;/a&gt;, all while maintaining tight control over your backend. It just makes sense! It offers the rapid development experience that is all too valuable, while not compromising on security. &lt;/p&gt;&lt;p&gt;Still, you might be wondering what this looks like from the developer perspective. Surely if you&amp;#39;re writing directly to the database this must be more unwieldily than using those client SDKs right? Well, not necessarily! &lt;/p&gt;&lt;p&gt;Supabase still offer &lt;a href=&quot;https://postgrest.org/en/v12/?ref=thestackcanary.com&quot;&gt;PostgREST&lt;/a&gt; out of the box, which exposes a REST API for database operations, but you have control over where you fire off those requests from. And since you have complete control over your database, you can even do a hybrid approach, which is what I prefer. Since Supabase takes care of the &lt;code&gt;auth.users&lt;/code&gt; table (and even makes it read-only through the web frontend), you might want to just use the REST APIs they expose to handle authentication, but then handle everything else yourself.  &lt;/p&gt;&lt;div&gt;&lt;div&gt;💡&lt;/div&gt;&lt;div&gt;If you want to have a table NOT be exposed as an API through PostgREST, just add it to a different schema from the public schema. All tables under the public schema will be exposed.&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Even though you might want Supabase to handle authentication, you still might want to extend features from that &lt;code&gt;users.auth&lt;/code&gt; table. Since they discourage modifying that table directly, the suggested approach is to make a table in the &lt;code&gt;public&lt;/code&gt; schema (which is the default schema – the &lt;code&gt;auth&lt;/code&gt; schema is managed by Supabase) to manage any additional information related to the user. The documentation goes into much more detail about &lt;a href=&quot;https://supabase.com/docs/guides/auth/managing-user-data?ref=thestackcanary.com#creating-user-tables&quot;&gt;Managing User Data&lt;/a&gt;, but that is the gist of the situation. Well, if you want this new table (let&amp;#39;s call it &lt;code&gt;Profiles&lt;/code&gt;) to stay in sync with the &lt;code&gt;auth.users&lt;/code&gt; table, it&amp;#39;s best to have the &lt;code&gt;Profiles&lt;/code&gt; table refer to the &lt;code&gt;auth.users&lt;/code&gt; table, and add a Postgres trigger to run a function to create a new entry in the &lt;code&gt;Profiles&lt;/code&gt; table whenever a new user is registered. &lt;/p&gt;&lt;div&gt;&lt;div&gt;💡&lt;/div&gt;&lt;div&gt;It&amp;#39;s also worth noting that in their recent General Availability Launch Week, Supabase also announced new efforts to increase security practices across projects, including a Postgres Linter and new Security Advisor and Performance Advisor dashboards to help you maintain good security posture. &lt;/div&gt;&lt;/div&gt;&lt;figure&gt;&lt;a href=&quot;https://github.com/supabase/splinter?ref=thestackcanary.com&quot;&gt;&lt;div&gt;&lt;div&gt;GitHub - supabase/splinter: Supabase Postgres Linter&lt;/div&gt;&lt;div&gt;Supabase Postgres Linter. Contribute to supabase/splinter development by creating an account on GitHub.&lt;/div&gt;&lt;div&gt;&lt;img src=&quot;https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg&quot; alt=&quot;Resist Vendor Lock-In With Supabase&quot; title=&quot;&quot;/&gt;&lt;span&gt;GitHub&lt;/span&gt;&lt;span&gt;supabase&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;img src=&quot;https://opengraph.githubassets.com/966032ded7ca25ff60a2b6d2cb729f96ee103f143195ce1c3991b19e2032b39e/supabase/splinter&quot; alt=&quot;Resist Vendor Lock-In With Supabase&quot; title=&quot;&quot;/&gt;&lt;/div&gt;&lt;/a&gt;&lt;/figure&gt;&lt;figure&gt;&lt;a href=&quot;https://supabase.com/blog/security-performance-advisor?ref=thestackcanary.com&quot;&gt;&lt;div&gt;&lt;div&gt;Supabase Security Advisor &amp;amp; Performance Advisor&lt;/div&gt;&lt;div&gt;We’re making it easier to build a secure and high-performing application.&lt;/div&gt;&lt;div&gt;&lt;img src=&quot;https://supabase.com/favicon/mstile-310x310.png&quot; alt=&quot;Resist Vendor Lock-In With Supabase&quot; title=&quot;&quot;/&gt;&lt;span&gt;Supabase&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;img src=&quot;https://supabase.com/images/blog/ga-week/security-peformance-advisor/og.png?v=3&quot; alt=&quot;Resist Vendor Lock-In With Supabase&quot; title=&quot;&quot;/&gt;&lt;/div&gt;&lt;/a&gt;&lt;/figure&gt;&lt;p&gt;Let&amp;#39;s walk through an example of what this might look like using my favorite backend language, &lt;a href=&quot;https://elixir-lang.org/?ref=thestackcanary.com&quot;&gt;Elixir&lt;/a&gt; (it&amp;#39;s also a favorite of Supabase themselves).  I&amp;#39;ll be using Elixir&amp;#39;s very nice ORM, &lt;a href=&quot;https://hexdocs.pm/ecto/Ecto.html?ref=thestackcanary.com&quot;&gt;Ecto&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;One you make your new project (using &lt;code&gt;mix new&lt;/code&gt; or perhaps more commonly in this situation, &lt;code&gt;mix phx.new&lt;/code&gt;) you&amp;#39;ll want to make sure you have Ecto as a dependency, and then you&amp;#39;ll want to model the &lt;code&gt;auth.users&lt;/code&gt; table as well as the new &lt;code&gt;public.profiles&lt;/code&gt; table. &lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule User do
  @moduledoc &amp;quot;&amp;quot;&amp;quot;
  This schema represents the default Supabase users table, which is under the &amp;#39;auth&amp;#39; schema.

  Since we don&amp;#39;t actually manage this schema, we will not make any migrations for it.

  This is mainly for convenience when unmarshalling data and working with users, so we
  can refer to the User struct rather than a generic map.

  Notice that we specify the primary key which will be referred to later

  Ecto schemas do not have to have 1-to-1 fields match the table in the database, so we can use whatever minimal fields we want to mirror in the profiles table (`id` at a minimum).
  &amp;quot;&amp;quot;&amp;quot;
  use Ecto.Schema
  import Ecto.Changeset

  # id is a UUID
  @primary_key {:id, :binary_id, autogenerate: false}
  schema &amp;quot;auth.users&amp;quot; do
    field :created_at, :naive_datetime_usec
    field :updated_at, :naive_datetime_usec
  end

end&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule Profile do
  @moduledoc &amp;quot;&amp;quot;&amp;quot;
  This schema holds extra information about users
  &amp;quot;&amp;quot;&amp;quot;
  use Ecto.Schema
  import Ecto.Changeset

  schema &amp;quot;profiles&amp;quot; do
    field :first_name, :string
    field :last_name, :string

    embeds_one :settings, Settings do
      field :default_portfolio, :string
      field :theme, Ecto.Enum, values: [dark: &amp;quot;Dark&amp;quot;, light: &amp;quot;Light&amp;quot;, system: &amp;quot;System&amp;quot;]
    end

    # This is the most important line and the one that is required to
    # properly link this table to `auth.users`
    # Make sure to set the type to :binary_id, which is what the Supabase 
    # auth uses
    belongs_to :user, User, type: :binary_id, references: :id, primary_key: true

    timestamps()
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now we would create the necessary migrations, starting with creating the &lt;code&gt;public.profiles&lt;/code&gt; table.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule CreateProfiles do
  use Ecto.Migration

  def change do
    create table(:profiles, primary_key: false) do
      # notice the `prefix` since `public` is the default prefix
      # notice the specifying the type to match the Supabase defaults
      # make sure to set this as the primary key
      add :id, references(:users, on_delete: :delete_all, prefix: &amp;quot;auth&amp;quot;, type: :uuid),
        primary_key: true

      # These fields should match what you have in your schema
      add :first_name, :string
      add :last_name, :string
      add :settings, :map

      # This represents the `inserted_at` and `updated_at` fields in the 
      # schema, and are required by default
      timestamps()
    end

    # You might also want to add indexes to improve performance and ensure data integrity
    create index(:profiles, [:id])
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now we add a migration to add the trigger:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule CreateProfilesTrigger do
  use Ecto.Migration

  def up do
    # Function to insert a new profile
    execute &amp;quot;&amp;quot;&amp;quot;
    CREATE OR REPLACE FUNCTION public.create_profile_for_new_user()
    RETURNS TRIGGER AS $$
    BEGIN
      INSERT INTO public.profiles (id, inserted_at, updated_at)
      VALUES (NEW.id, now(), now());
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql SECURITY DEFINER;
    &amp;quot;&amp;quot;&amp;quot;

    # Trigger to call the function after a user is inserted
    execute &amp;quot;&amp;quot;&amp;quot;
    CREATE TRIGGER trigger_create_profile_after_user_insert
    AFTER INSERT ON auth.users
    FOR EACH ROW
    EXECUTE FUNCTION public.create_profile_for_new_user();
    &amp;quot;&amp;quot;&amp;quot;
  end

  def down do
    execute &amp;quot;DROP TRIGGER IF EXISTS trigger_create_profile_after_user_insert ON auth.users;&amp;quot;
    execute &amp;quot;DROP FUNCTION IF EXISTS create_profile_for_new_user;&amp;quot;
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And that&amp;#39;s it, you now have extended the ability to store information for the users while keeping the security provided by the protected &lt;code&gt;auth.users&lt;/code&gt; table. But now how do you use it?&lt;/p&gt;&lt;p&gt;Well, let me show you how in fewer than 90 lines of code:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule UserManagement do
  @req Req.new(
         base_url: Application.compile_env(:myapp, [:supabase, :base_url]),
         headers: [apikey: Application.compile_env(:myapp, [:supabase, :api_key])],
         url: &amp;quot;/auth/v1/:action&amp;quot;
       )

  def get_current_user(bearer_token) do
    Req.get!(
      @req,
      auth: {:bearer, bearer_token},
      path_params: [action: &amp;quot;user&amp;quot;]
    )
    |&amp;gt; Map.get(:body)
  end

  def signup_with_username_and_password(email, password) do
    Req.post!(
      @req,
      path_params: [action: &amp;quot;signup&amp;quot;],
      json: %{email: email, password: password}
    )
    |&amp;gt; Map.get(:body)
  end

  def login_with_email_and_password(email, password) do
    Req.post!(
      @req,
      path_params: [action: &amp;quot;token&amp;quot;],
      params: [grant_type: &amp;quot;password&amp;quot;],
      json: %{email: email, password: password}
    )
    |&amp;gt; Map.get(:body)
  end

  def send_password_recovery_email(email) do
    Req.post!(
      @req,
      path_params: [action: &amp;quot;recover&amp;quot;],
      json: %{email: email}
    )
    |&amp;gt; Map.get(:body)
  end

  def update_user(bearer_token, data \\ %{}) do
    {email, data} = Map.pop(data, &amp;quot;email&amp;quot;)
    {password, data} = Map.pop(data, &amp;quot;password&amp;quot;)

    body = %{
      &amp;quot;data&amp;quot; =&amp;gt; data
    }

    body = if email, do: Map.put(body, &amp;quot;email&amp;quot;, email), else: body
    body = if password, do: Map.put(body, &amp;quot;password&amp;quot;, password), else: body

    Req.put!(
      @req,
      path_params: [action: &amp;quot;user&amp;quot;],
      auth: {:bearer, bearer_token},
      json: body
    )
    |&amp;gt; Map.get(:body)
  end

  def logout(bearer_token) do
    Req.post!(
      @req,
      auth: {:bearer, bearer_token},
      path_params: [action: &amp;quot;logout&amp;quot;]
    )
    |&amp;gt; Map.get(:body)
  end

  def send_email_invite(bearer_token, email) do
    Req.post!(
      @req,
      auth: {:bearer, bearer_token},
      json: %{email: email},
      path_params: [action: &amp;quot;invite&amp;quot;]
    )
    |&amp;gt; Map.get(:body)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Pretty simple right? You could surely condense this more too if you so choose. &lt;/p&gt;&lt;div&gt;&lt;div&gt;💡&lt;/div&gt;&lt;div&gt;The wonderful &lt;a href=&quot;https://hexdocs.pm/req/readme.html?ref=thestackcanary.com&quot;&gt;Req&lt;/a&gt; library takes care of a lot of the tedious parts of these requests, such as JSON-encoding and Bearer authentication. I highly recommend it over other HTTP clients for these reasons. &lt;/div&gt;&lt;/div&gt;&lt;p&gt;As long as you set your API key and Supabase instance URL into the application environment, this will have you ready to perform all of your user management tasks, and upon registration have the new user reflected in the &lt;code&gt;auth.users&lt;/code&gt; table as well as the &lt;code&gt;public.profiles&lt;/code&gt; table. &lt;/p&gt;&lt;p&gt;And lastly, make sure you connect to your database using the connection string (or you can specify each field if you&amp;#39;d like).&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;import Config

config :myapp, MyApp.Repo,
  url:
    &amp;quot;myconnectionstring&amp;quot;,&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This barely scratched the surface of what you can do with Supabase, but I hope it at least demonstrates how quick it is to get started with it and how flexible it is to have complete control over your stack. &lt;/p&gt;&lt;p&gt;Of course this example was in Elixir, but you could extend this to any other backend language and get the same benefits, and avoid falling victim to vendor lock-in! &lt;/p&gt;</content:encoded>
</item>
<item>
<title>Elevate Your Elixir With Sigils</title>
<link>https://www.thestackcanary.com/elevate-your-elixir-with-sigils/</link>
<guid isPermaLink="false">BjZvqJdgixq9fEOBlRwQGOfD0wLBimvMMXCzfw==</guid>
<pubDate>Sun, 19 Apr 2026 04:26:32 +0000</pubDate>
<description>Adding support for real-valued intervals implementing the Enumerable Protocol in Elixir.</description>
<content:encoded>&lt;h2&gt;Motivation&lt;/h2&gt;&lt;img src=&quot;https://www.thestackcanary.com/content/images/2024/01/Elevate_Elxiir_With_Sigils.png&quot; alt=&quot;Elevate Your Elixir With Sigils&quot; title=&quot;&quot;/&gt;&lt;p&gt;In a &lt;a href=&quot;https://www.thestackcanary.com/elixir-nimble-options/&quot;&gt;previous article&lt;/a&gt; of mine, I wrote about using &lt;a href=&quot;https://github.com/dashbitco/nimble_options?ref=thestackcanary.com&quot;&gt;NimbleOptions&lt;/a&gt; to add extremely powerful option handling to your Elixir applications.  One of the custom validations I was using was a function  &lt;code&gt;in_range&lt;/code&gt; that would check if an option fell within a real-valued interval. This differs from Elixir&amp;#39;s built-in &lt;code&gt;Range&lt;/code&gt; in that it needed to be real-valued (rather than discrete integer steps). Additionally, mostly due to aesthetic and personal opinion, I wanted to be able to express the intervals using mathematical notation such as &lt;code&gt;(0,1]&lt;/code&gt; to mean &amp;quot;allow any value greater than 0 and less than or equal to 1&amp;quot;. I find Elixir to be such a beautiful language with a unique capacity for extensions that it felt wrong to use a function such as &lt;code&gt;in_range&lt;/code&gt; or &lt;code&gt;in_interval&lt;/code&gt;. Additionally, some implementations I&amp;#39;ve come across have somewhat unintuitive APIs, such as the following spec:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;@spec in_range(float, float, float, bool, bool) :: bool
@doc &amp;quot;&amp;quot;&amp;quot;
  * `:value` - Value to test for inclusion
  * `:min` - Minimum value in range
  * `:max` - Maximum value in range
  * `:left` - Whether the left boundary is inclusive (true) or exclusive (false)
  * `:right` - Whether the right boundary is inclusive (true) or exclusive (false)
&amp;quot;&amp;quot;&amp;quot;
def in_range(value, min \\ 0, max \\ 1, left \\ true, right \\ true)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;There&amp;#39;s nothing expressly wrong with this implementation, but with my use cases and as Elixir is being used more in the domain of Machine Learning which deals with these intervals quite often, I wanted a solution that felt a bit more integrated.&lt;/p&gt;&lt;h2&gt;Solution&lt;/h2&gt;&lt;p&gt;This led me to create a small 1-file library called &lt;a href=&quot;https://github.com/acalejos/exterval?ref=thestackcanary.com&quot;&gt;&lt;code&gt;Exterval&lt;/code&gt;&lt;/a&gt; which is available on &lt;a href=&quot;https://hex.pm/packages/exterval?ref=thestackcanary.com&quot;&gt;Hex&lt;/a&gt; and can be installed with:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def deps do
[
  {:exterval, &amp;quot;~&amp;gt; 0.1.0&amp;quot;}
]
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;To make the interval feel more native to Elixir, I implemented it as a sigil that implements the&lt;a href=&quot;https://hexdocs.pm/elixir/1.12/Enumerable.html?ref=thestackcanary.com&quot;&gt;&lt;code&gt;Enumerable&lt;/code&gt; Protocol&lt;/a&gt;, which gives you several nice benefits:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Takes advantage of the &lt;code&gt;member?/2&lt;/code&gt; function which means we can use the &lt;code&gt;in&lt;/code&gt; keyword to check for membership&lt;/li&gt;&lt;li&gt;Allows for checking of sub-interval membership (&lt;a href=&quot;https://github.com/acalejos/exterval?tab=readme-ov-file&amp;amp;ref=thestackcanary.com#membership&quot;&gt;with some caveats&lt;/a&gt;)&lt;/li&gt;&lt;li&gt;Implements an optional &lt;code&gt;step&lt;/code&gt; parameter that allows you to iterate/reduce over the interval&lt;/li&gt;&lt;li&gt;Implements a &lt;code&gt;size&lt;/code&gt; function (remember, &lt;code&gt;size&lt;/code&gt; refers to the ability to count the number of members without reducing over the whole structure, whereas &lt;code&gt;lengths&lt;/code&gt; implies a need to reduce).&lt;/li&gt;&lt;li&gt;Allows for &lt;code&gt;:infinity&lt;/code&gt; and &lt;code&gt;:neg_infinity&lt;/code&gt; to be specified in the interval&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;This lets us write more succinct checks like:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;iex&amp;gt; import Exterval
iex&amp;gt; ~i&amp;lt;[1, 10)//2&amp;gt;
[1, 10)//2
iex&amp;gt; ~i&amp;lt;[1, 10)//2&amp;gt; |&amp;gt; Enum.to_list()
[1.0, 3.0, 5.0, 7.0, 9.0]
iex&amp;gt; ~i&amp;lt;[1, 10)//2&amp;gt; |&amp;gt; Enum.sum()
25.0
iex&amp;gt; ~i&amp;lt;[-1, 3)//-0.5&amp;gt; |&amp;gt; Enum.to_list()
[2.5, 2.0, 1.5, 1.0, 0.5, 0.0, -0.5, -1.0]
iex&amp;gt; ~i&amp;lt;[1, 10]&amp;gt; |&amp;gt; Enum.count()
:infinity
iex&amp;gt; ~i&amp;lt;[1, 10)//2&amp;gt; |&amp;gt; Enum.count()
4
iex&amp;gt; ~i&amp;lt;[-2,-2]//1.0&amp;gt; |&amp;gt; Enum.count()
1
iex&amp;gt; ~i&amp;lt;[1,2]//0.5&amp;gt; |&amp;gt; Enum.count()
3
iex&amp;gt; ~i&amp;lt;[-2,-1]//0.75&amp;gt; |&amp;gt; Enum.count()
2
iex&amp;gt; 1 in ~i&amp;lt;[1, 10]&amp;gt;
true
iex&amp;gt; 1 in ~i&amp;lt;[1, 10)//2&amp;gt;
true
iex&amp;gt; 3 in ~i&amp;lt;(1, 10)//2&amp;gt;
true
# You can even do variable substitution using string interpolation syntax, since the sigil parameter is just a string
iex&amp;gt; min = 2
iex&amp;gt; 3 in ~i&amp;lt;(#{min + 1}, 10)//2&amp;gt;
false&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Design Details&lt;/h2&gt;&lt;p&gt;The decision to implement the interval as a sigil was not as straightforward as it might seem. As I mentioned before, Elixir is an extremely extensible language with superior support for meta-programming, so implementing this as a macro was my first instinct. I considered commandeering the opening brackets &lt;code&gt;(&lt;/code&gt; and &lt;code&gt;[&lt;/code&gt; to trigger the macro, or something similar with the comma &lt;code&gt;,&lt;/code&gt; , but fortunately, I hit a brick wall with that effort. I say fortunately not only because it would have been a bad idea from a design perspective, but it certainly would have been a messier implementation and would have overly complicated it in addition to actually making the code less clear. I appreciate the usage of the sigil &lt;code&gt;~I&lt;/code&gt; because it makes it clear that the range that follows is not to be confused with the built-in &lt;code&gt;Range&lt;/code&gt;. &lt;/p&gt;&lt;div&gt;&lt;div&gt;💡&lt;/div&gt;&lt;div&gt;You can read more about Elixir sigils &lt;a href=&quot;https://hexdocs.pm/elixir/main/sigils.html?ref=thestackcanary.com&quot;&gt;here&lt;/a&gt; and see their syntax reference &lt;a href=&quot;https://hexdocs.pm/elixir/main/syntax-reference.html?ref=thestackcanary.com#sigils&quot;&gt;here&lt;/a&gt;. Of note, you can use any of the allowed delimiter pairs that it lists to capture your sigil. I chose [ and ] so as to not conflict with the brackets used in the interval. You could also use something like ~i|[0,1)| if you prefer. &lt;/div&gt;&lt;/div&gt;&lt;p&gt;Once I decided on the usage of the &lt;code&gt;Enumerable&lt;/code&gt; protocol, I knew I wanted to allow some way for an optional step size to be specified so that &lt;code&gt;reduce&lt;/code&gt; could be used on the structure. Elixir sigils allow for parameters to be passed after the closing sigil, so initially,  I considered passing in the step size as a parameter since zero or more ASCII letters and digits can be given as a modifier to the sigil, but this would prohibit having floats as step sizes. Another constraint to consider when using sigils is that string interpolation is only allowed within the sigil when using a lowercase sigil. Sigils start with &lt;code&gt;~&lt;/code&gt; and are followed by one lowercase letter or by one or more uppercase letters, immediately followed by one of the allowed delimiter pairs. Within the context of our use case, we happen to be able to get by without having string interpolation since we use it within pre-defined parameters that are hardcoded, but the library becomes much more useful if we can have dynamically defined intervals, so this limits how the sigil is named.&lt;/p&gt;&lt;p&gt;Another major design decision was how to actually parse the sigil. I ultimately landed on the straightforward answer of just using a regex, but I had a decent back-and-forth with my friend &lt;a href=&quot;https://twitter.com/polvalente?ref=thestackcanary.com&quot;&gt;Paulo&lt;/a&gt; from the &lt;a href=&quot;https://github.com/elixir-nx/nx/tree/main/nx?ref=thestackcanary.com#readme&quot;&gt;Elixir-Nx&lt;/a&gt; core team regarding other options. He provided some nice proofs of concept using binary pattern matching as well as &lt;a href=&quot;https://github.com/dashbitco/nimble_parsec?ref=thestackcanary.com&quot;&gt;NimbleParsec&lt;/a&gt;, but I decided on a regex due to my familiarity, its ability to reduce the amount of code, and because I was not too concerned with performance concerns with what will typically be short patterns. &lt;/p&gt;&lt;p&gt;One of the last design details finalized was how to treat the step size and its effect on item membership. Paulo and I discussed whether it should support ranges where the min and max values did not necessarily have to be in the correct order (e.g. &lt;code&gt;~i&amp;lt;1,-1//0.5&amp;gt;&lt;/code&gt;) which would essentially imply that any iteration would start at &lt;code&gt;1&lt;/code&gt; in this instance and would work towards &lt;code&gt;-1&lt;/code&gt; in steps of &lt;code&gt;0.5&lt;/code&gt;. This was discussed since it can be seen in some other implementations throughout other ecosystems.  We decided that the most clear solution, as well as the solution that fit best within the spirit of the library, was to enforce that the first value specified be less than or equal to the second value, and any desire to iterate starting with the max value could be specified using a negative step size. &lt;/p&gt;&lt;h2&gt;Implementation Details&lt;/h2&gt;&lt;h3&gt;Creation&lt;/h3&gt;&lt;p&gt;An interval is stored as a struct with the following fields:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;code&gt;left&lt;/code&gt; - the left bracket, either &lt;code&gt;[&lt;/code&gt; or &lt;code&gt;(&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;&lt;code&gt;right&lt;/code&gt; - the right bracket, either &lt;code&gt;]&lt;/code&gt; or &lt;code&gt;)&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;&lt;code&gt;min&lt;/code&gt; - the lower bound of the interval. Can be &lt;code&gt;:neg_infinity&lt;/code&gt; or any number.&lt;/li&gt;&lt;li&gt;&lt;code&gt;max&lt;/code&gt; - the upper bound of the interval. Can be &lt;code&gt;:infinity&lt;/code&gt; or any number.&lt;/li&gt;&lt;li&gt;&lt;code&gt;step&lt;/code&gt; - the step size of the interval. If &lt;code&gt;nil&lt;/code&gt;, the interval is continuous.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;To define a sigil, you create a function with the name of the sigil prefixed by &lt;code&gt;sigil_&lt;/code&gt;, so since I wish to use this sigil using &lt;code&gt;~i&lt;/code&gt; I define it as&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def sigil_i(pattern, []) do
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The second parameter are the options to the sigil I mentioned earlier. For now these are unused.&lt;/p&gt;&lt;p&gt;I parse the input to the sigil using the following &lt;a href=&quot;https://regex101.com/r/MJyFE4/1?ref=thestackcanary.com&quot;&gt;regex&lt;/a&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-regex&quot;&gt;^(?P&amp;lt;left&amp;gt;\[|\()\s*(?P&amp;lt;min&amp;gt;[-+]?(?:\d+|\d+\.\d+)(?:[eE][-+]?\d+)?|:neg_infinity)\s*,\s*(?P&amp;lt;max&amp;gt;[-+]?(?:\d+|\d+\.\d+)(?:[eE][-+]?\d+)?|:infinity)\s*(?P&amp;lt;right&amp;gt;]|\))(?:\/\/(?P&amp;lt;step&amp;gt;[-+]?(?:[1-9]+|\d+\.\d+)(?:[eE][-+]?\d+)?))?$&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Using the named capture groups I perform some additional validation such as ensuring that the interval goes from the minimum value to the maximum.&lt;/p&gt;&lt;h3&gt;Enumerable – Size / Count&lt;/h3&gt;&lt;p&gt;The first function I need to implement for the protocol is the &lt;code&gt;Enumerable.count/1&lt;/code&gt; function. Logically, there are three conditions to account for. First are the instances where the size is either zero or infinity. Since &lt;code&gt;Enumerable.count/1&lt;/code&gt; must return a number on success, I choose to return &lt;code&gt;{:error, Infinity}&lt;/code&gt; from &lt;code&gt;Enumerable.count/1&lt;/code&gt; when I wish to return &lt;code&gt;:infinity&lt;/code&gt;. This would normally be used to return a module which can perform a reduction to compute the count, but if we just make a simple helper module &lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule Infinity do
  @moduledoc false
  def reduce(%Exterval{}, _, _), do: {:halt, :infinity}
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now I can get my desired behavior. I implement these cases with the following:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def size(interval)
def size(%__MODULE__{step: nil}), do: {:error, Infinity}
def size(%__MODULE__{max: :neg_infinity}), do: 0
def size(%__MODULE__{min: :infinity}), do: 0

def size(%__MODULE__{min: min, max: max})
    when min in [:infinity, :neg_infinity] or max in [:infinity, :neg_infinity],
    do: {:error, Infinity}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Lastly I separate cases where the step size is negative and where its positive since the logic is different.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def size(%__MODULE__{left: left, right: right, min: min, max: max, step: step}) when step &amp;lt; 0 do
  case {left, right} do
    {&amp;quot;[&amp;quot;, &amp;quot;]&amp;quot;} -&amp;gt;
      abs(trunc((max - min) / step)) + 1

    {&amp;quot;(&amp;quot;, &amp;quot;]&amp;quot;} -&amp;gt;
      abs(trunc((max - (min - step)) / step)) + 1

    {&amp;quot;[&amp;quot;, &amp;quot;)&amp;quot;} -&amp;gt;
      abs(trunc((max + step - min) / step)) + 1

    {&amp;quot;(&amp;quot;, &amp;quot;)&amp;quot;} -&amp;gt;
      abs(trunc((max + step - (min - step)) / step)) + 1
  end
end

def size(%__MODULE__{left: left, right: right, min: min, max: max, step: step}) when step &amp;gt; 0 do
  case {left, right} do
    {&amp;quot;[&amp;quot;, &amp;quot;]&amp;quot;} -&amp;gt;
      abs(trunc((max - min) / step)) + 1

    {&amp;quot;(&amp;quot;, &amp;quot;]&amp;quot;} -&amp;gt;
      abs(trunc((max - (min + step)) / step)) + 1

    {&amp;quot;[&amp;quot;, &amp;quot;)&amp;quot;} -&amp;gt;
      abs(trunc((max - step - min) / step)) + 1

    {&amp;quot;(&amp;quot;, &amp;quot;)&amp;quot;} -&amp;gt;
      abs(trunc((max - step - (min + step)) / step)) + 1
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Enumerable – Reduce&lt;/h3&gt;&lt;p&gt;The implementation for &lt;code&gt;reduce&lt;/code&gt; is a great example of how Elixir&amp;#39;s pattern matching in function headers can reduce visual complexity and even the implementation itself. First, we return &lt;code&gt;:infinity&lt;/code&gt; if  &lt;code&gt;step&lt;/code&gt; is &lt;code&gt;nil&lt;/code&gt;.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def reduce(%Exterval{step: nil}, acc, _fun) do
  {:done, acc}
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Next, we again have different clauses depending on if the &lt;code&gt;step&lt;/code&gt; is positive or negative, since that dictates which direction with respect to the interval the reduction occur.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir &quot;&gt;def reduce(%Exterval{left: left, right: right, min: min, max: max, step: step}, acc, fun)
    when step &amp;gt; 0 do
  case left do
    &amp;quot;[&amp;quot; -&amp;gt;
      reduce(min, max, right, acc, fun, step)

    &amp;quot;(&amp;quot; -&amp;gt;
      reduce(min + step, max, right, acc, fun, step)
  end
end

def reduce(%Exterval{left: left, right: right, min: min, max: max, step: step}, acc, fun)
    when step &amp;lt; 0 do
  case right do
    &amp;quot;]&amp;quot; -&amp;gt;
      reduce(min, max, left, acc, fun, step)

    &amp;quot;)&amp;quot; -&amp;gt;
      reduce(min, max + step, left, acc, fun, step)
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Notice that these clauses to the &lt;code&gt;reduce/3&lt;/code&gt; implementation return a different &lt;code&gt;reduce/6&lt;/code&gt; function which is specific to our module. &lt;/p&gt;&lt;p&gt;Next we handle conditions where the reduction is halted or suspended:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;efp reduce(_min, _max, _closing, {:halt, acc}, _fun, _step) do
  {:halted, acc}
end

defp reduce(min, max, closing, {:suspend, acc}, fun, step) do
  {:suspended, acc, &amp;amp;reduce(min, max, closing, &amp;amp;1, fun, step)}
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Next we handle edge cases involving &lt;code&gt;:infinity&lt;/code&gt; and &lt;code&gt;:neg_infinity&lt;/code&gt; where we have no way to begin the reduction since we cannot move &lt;code&gt;step&lt;/code&gt; increments away from either of these when they are our starting point:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defp reduce(:neg_infinity, _max, _closing, {:cont, acc}, _fun, step) when step &amp;gt; 0 do
  {:done, acc}
end

defp reduce(_min, :infinity, _closing, {:cont, acc}, _fun, step) when step &amp;lt; 0 do
  {:done, acc}
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Interestingly, these are cases where the size of the intervals would be &lt;code&gt;:infinity&lt;/code&gt; but we cannot reduce over them at all, as opposed to other infinitely sized intervals where we can begin iteration which will never end, such as &lt;code&gt;~i&amp;lt;[0,:infinity]//1&amp;gt;&lt;/code&gt; which would effectively be an infinite stream starting at &lt;code&gt;0&lt;/code&gt; and incrementing by &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Next we add all of the main logic for the &amp;quot;typical&amp;quot; cases:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defp reduce(min, max, &amp;quot;]&amp;quot; = closing, {:cont, acc}, fun, step)
     when min &amp;lt;= max do
  reduce(min + step, max, closing, fun.(min, acc), fun, step)
end

defp reduce(min, max, &amp;quot;)&amp;quot; = closing, {:cont, acc}, fun, step)
     when min &amp;lt; max do
  reduce(min + step, max, closing, fun.(min, acc), fun, step)
end

defp reduce(min, max, &amp;quot;[&amp;quot; = closing, {:cont, acc}, fun, step)
     when min &amp;lt;= max do
  reduce(min, max + step, closing, fun.(max, acc), fun, step)
end

defp reduce(min, max, &amp;quot;(&amp;quot; = closing, {:cont, acc}, fun, step)
     when min &amp;lt; max do
  reduce(min, max + step, closing, fun.(max, acc), fun, step)
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And lastly we add the final case where the condition that &lt;code&gt;min &amp;lt; max&lt;/code&gt; (or &lt;code&gt;min &amp;lt;= max&lt;/code&gt; depending on the brackets) is no longer met, which means the reduction is complete:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defp reduce(_, _, _, {:cont, acc}, _fun, _up) do
  {:done, acc}
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Just like that the &lt;code&gt;reduce/3&lt;/code&gt; implementation is complete! As I mentioned before and notes in more detail, there are some opinions inherit to this implementation having to do with &lt;code&gt;:infinity&lt;/code&gt; and &lt;code&gt;:neg_infinity&lt;/code&gt; bounds as well as empty intervals, but I tried to keep the behavior consistent throughout.&lt;/p&gt;&lt;h3&gt;Enumerable – Membership&lt;/h3&gt;&lt;p&gt;Now on to the part that I was most interested in, which is interval membership. First, let&amp;#39;s add support for checking membership between two intervals, which is essentially a check for one interval being a sub-interval of another. &lt;/p&gt;&lt;p&gt;Sub-interval must satisfy the following to be a subset:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;The minimum value of the subset must belong to the superset.&lt;/li&gt;&lt;li&gt;The maximum value of the subset must belong to the superset.&lt;/li&gt;&lt;li&gt;The step size of the subset must be a multiple of the step size of the superset.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;If the superset has no step size, then only the first two conditions must be satisfied.&lt;/p&gt;&lt;p&gt;if the superset has a step size, and the subset doesn&amp;#39;t then membership is &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def member?(%Exterval{step: nil} = outer, %Exterval{} = inner) do
  res = inner.max in outer &amp;amp;&amp;amp; inner.min in outer
  {:ok, res}
end

def member?(%Exterval{}, %Exterval{step: nil}) do
  {:ok, false}
end

def member?(%Exterval{} = outer, %Exterval{} = inner) do
  res = inner.max in outer &amp;amp;&amp;amp; inner.min in outer &amp;amp;&amp;amp; :math.fmod(inner.step, outer.step) == 0
  {:ok, res}
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then that just leaves the main implementation for membership checks, which is basically just a &lt;code&gt;case&lt;/code&gt; statement which changes the output depending on the brackets supplied. Additionally, if the interval contains a &lt;code&gt;step&lt;/code&gt; then the value being checked must be a multiple of the &lt;code&gt;step&lt;/code&gt;.&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def member?(%Exterval{} = rang, value) when is_number(value) do
  res =
    if Exterval.size(rang) == 0 do
      {:ok, false}
    else
      case {rang.left, rang.min, rang.max, rang.right} do
        {_, :neg_infinity, :infinity, _} -&amp;gt;
          true

        {_, :neg_inf, max_val, &amp;quot;]&amp;quot;} -&amp;gt;
          value &amp;lt;= max_val

        {_, :neg_infinity, max_val, &amp;quot;)&amp;quot;} -&amp;gt;
          value &amp;lt; max_val

        {&amp;quot;[&amp;quot;, min_val, :infinity, _} -&amp;gt;
          value &amp;gt;= min_val

        {&amp;quot;(&amp;quot;, min_val, :infinity, _} -&amp;gt;
          value &amp;gt; min_val

        {&amp;quot;[&amp;quot;, min_val, max_val, &amp;quot;]&amp;quot;} -&amp;gt;
          value &amp;gt;= min_val and value &amp;lt;= max_val

        {&amp;quot;(&amp;quot;, min_val, max_val, &amp;quot;]&amp;quot;} -&amp;gt;
          value &amp;gt; min_val and value &amp;lt;= max_val

        {&amp;quot;[&amp;quot;, min_val, max_val, &amp;quot;)&amp;quot;} -&amp;gt;
          value &amp;gt;= min_val and value &amp;lt; max_val

        {&amp;quot;(&amp;quot;, min_val, max_val, &amp;quot;)&amp;quot;} -&amp;gt;
          value &amp;gt; min_val and value &amp;lt; max_val

        _ -&amp;gt;
          raise ArgumentError, &amp;quot;Invalid range specification&amp;quot;
      end
    end

  res =
    unless is_nil(rang.step) || rang.min == :neg_infinity || rang.max == :infinity do
      res &amp;amp;&amp;amp; :math.fmod(value - rang.min, rang.step) == 0
    else
      res
    end

  {:ok, res}
end&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Inspect&lt;/h3&gt;&lt;p&gt;Lastly, to make the user experience a bit better, it&amp;#39;s not too difficult to implement the &lt;a href=&quot;https://hexdocs.pm/elixir/1.16.0/Inspect.html?ref=thestackcanary.com&quot;&gt;&lt;code&gt;Inpect&lt;/code&gt;&lt;/a&gt; Protocol to provide a cleaner output:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defimpl Inspect do
  import Inspect.Algebra
  import Kernel, except: [inspect: 2]

  def inspect(%Exterval{left: left, right: right, min: min, max: max, step: nil}, opts) do
    concat([string(left), to_doc(min, opts), &amp;quot;,&amp;quot;, to_doc(max, opts), string(right)])
  end

  def inspect(%Exterval{left: left, right: right, min: min, max: max, step: step}, opts) do
    concat([
      string(left),
      to_doc(min, opts),
      &amp;quot;,&amp;quot;,
      to_doc(max, opts),
      string(right),
      &amp;quot;//&amp;quot;,
      to_doc(step, opts)
    ])
  end
end&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Future Plans&lt;/h2&gt;&lt;p&gt;Currently I am weighing the options between adding more functionality to the library or keeping it as thin as it currently is. The main additions could be more robust set operations on the intervals, but I currently do not have a need for it so it will probably not make it into the library in the near future. &lt;/p&gt;&lt;p&gt;For now, I hope this provided a detailed look at the process of identifying a problem, and subsequently designing and implementing the solution. I found this to be an elegant solution to the problem, but as I mentioned it was not a straight-line path. I would be interested to hear about any other solutions people have seen!&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;h2&gt;&lt;span&gt;Sign up for The Stack Canary&lt;/span&gt;&lt;/h2&gt;&lt;p&gt;&lt;span&gt;If you enjoyed reading, let me know!&lt;/span&gt;&lt;/p&gt;&lt;div&gt;
                Email sent! Check your inbox to complete your signup.
            &lt;/div&gt;&lt;p&gt;&lt;span&gt;No spam. Unsubscribe anytime.&lt;/span&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;</content:encoded>
</item>
<item>
<title>From Python to Elixir Machine Learning</title>
<link>https://www.thestackcanary.com/from-python-pytorch-to-elixir-nx/</link>
<guid isPermaLink="false">k8co6KGJFymIITBZMxGpxvlwk_pc1H1S1bO29w==</guid>
<pubDate>Sun, 19 Apr 2026 04:26:32 +0000</pubDate>
<description>Moving on from Python Machine Learning might seem impossible. Let me break down why and how you can do it.</description>
<content:encoded>&lt;img src=&quot;https://www.thestackcanary.com/content/images/2023/07/Dark-Blue-Simple-Modern-Virtual-Technology-Banner-1.png&quot; alt=&quot;From Python to Elixir Machine Learning&quot; title=&quot;&quot;/&gt;&lt;p&gt;As Elixir&amp;#39;s Machine Learning (ML) ecosystem grows, many Elixir enthusiasts who wish to adopt the new machine learning libraries in their projects are stuck at a crossroads of wanting to move away from their existing ML stack (typically Python) while not having a clear path of how to do so. I would like to take some time to talk to WHY I believe now is a good time to start porting over Machine Learning code into Elixir, and HOW I went about doing just this for two libraries I wrote: &lt;a href=&quot;https://github.com/acalejos/exgboost?ref=thestackcanary.com&quot;&gt;EXGBoost&lt;/a&gt; (from Python XGBoost) and &lt;a href=&quot;https://github.com/acalejos/mockingjay?ref=thestackcanary.com&quot;&gt;Mockingjay&lt;/a&gt; (from Python Hummingbird).&lt;/p&gt;&lt;h2&gt;Why is Python not Sufficient?&lt;/h2&gt;&lt;p&gt;There&amp;#39;s a common saying in programming languages that no language is perfect, but that different languages are suited for different jobs. Languages such as C, Rust, and now even Zig are known for their targeting systems development, while languages such as C++, C#, and Java are more commonly used for application development, and obviously there are the web languages such as JavaScript/TypeScript, PHP, Ruby (on Rails), and more. There are gradations to these rules of course, but more often than not there are good reasons that languages tend to exist within the confines of particular use cases. &lt;/p&gt;&lt;p&gt; Languages such as Elixir and Go tend to be used in large distributed systems because they place an emphasis on having great support for common concurrency patterns, which can come at the cost of supporting other domains. Go, for example, has barely (if any?) support for machine learning libraries, but it&amp;#39;s also not trying to cater to that as a target domain. For a long time, the same could have been said about Elixir, but over the past two or so years, there has been a massive concerted push from the Elixir community to not only have support for machine learning, but to push the envelope with the maintaining state of the art libraries that are beginning to compete with the other dominant machine learning languages - namely Python. &lt;/p&gt;&lt;p&gt;Python has long been the gold standard in the realm of machine learning. The breadth of libraries and the low entry barrier makes Python a great language to work with, but it does create a bit of a bottleneck. Any application that wishes to integrate machine learning has historically had only a couple of options: have a Python component or reach into the underlying libraries that power much of the Python libraries directly. Despite all the good parts of Python I mentioned before, speed and support for concurrency are not on that list. Elixir-Nx is striving to give another option - an option that can take advantage of the native distributed support that Elixir and the BEAM VM have to offer. Nx&amp;#39;s &lt;code&gt;Nx.Serving&lt;/code&gt; construct is a drop-in solution for serving distributed machine-learning models. &lt;/p&gt;&lt;h2&gt;How to Proceed&lt;/h2&gt;&lt;p&gt;Sean Moriarity, the co-creator of &lt;a href=&quot;https://github.com/elixir-nx/nx?ref=thestackcanary.com&quot;&gt;Nx&lt;/a&gt;, creator of &lt;a href=&quot;https://github.com/elixir-nx/axon?ref=thestackcanary.com&quot;&gt;Axon&lt;/a&gt;, and author of &lt;a href=&quot;https://pragprog.com/titles/smelixir/machine-learning-in-elixir/?ref=thestackcanary.com&quot;&gt;Machine Learning in Elixir&lt;/a&gt;, has talked many times about how the initial creation of Nx and Axon involved hours upon hours of reading source code from reference implementations of libraries in Python and C++, namely the TensorFlow source code. While I was writing &lt;a href=&quot;https://github.com/acalejos/exgboost?ref=thestackcanary.com&quot;&gt;EXGBoost&lt;/a&gt; and &lt;a href=&quot;https://github.com/acalejos/mockingjay?ref=thestackcanary.com&quot;&gt;Mockingjay&lt;/a&gt;, much of my time, especially towards the beginning, was spent referencing the Python and C++ implementations of the original libraries.  This builds a great fundamental understanding of the libraries as well as taught me how to identify patterns in Python and C++ and identify the Elixir pattern that could express the same ideas. This skill is invaluable, and the better I got at it the faster I could write. Below is a summary and key takeaways from my process of porting Python / PyTorch to Elixir / Nx. &lt;/p&gt;&lt;h2&gt;Workflow Overview&lt;/h2&gt;&lt;p&gt;Before I get to the examples from the code bases, I would like to briefly explain the high-level cyclical workflow I established while working on this effort, and what I would recommend to anyone pursuing a similar endeavor. &lt;/p&gt;&lt;h3&gt;Understand the Macro System&lt;/h3&gt;&lt;p&gt; Much like how there&amp;#39;s a common strategy to reading comprehension which involves reading through the entire document once to get a high-level understanding and then doing subsequent shorter reads to gain more in-depth understanding with the added context of the entire piece, you can consider doing the same when reading code. My first step was to follow the logical flow from the call of &lt;code&gt;hummingbird.ml.convert&lt;/code&gt; to the final result. You can use tools such as function tracers and callgraph generators to accelerate this part of the process, or manually trace depending on the extent of the codebase. I felt in my case that it was manageable to trace myself. &lt;/p&gt;&lt;h3&gt;Read the Documentation&lt;/h3&gt;&lt;p&gt;Once you have a general understanding of the flow and process of the original system, you can start referring to the documentation for some additional context. In my case, this lead me to the academic paper &lt;a href=&quot;https://scnakandala.github.io/papers/TR_2020_Hummingbird.pdf?ref=thestackcanary.com&quot;&gt;&lt;em&gt;Taming Model Serving Complexity, Performance and Cost: A Compilation to Tensor Computations Approach&lt;/em&gt;&lt;/a&gt;&lt;em&gt;, &lt;/em&gt;which was the underlying ground work and basis for their implementation. I could write a whole other blog post about the process of transcribing algorithms and code from academic papers and pseudocode, but for now just know that these are some of the most important pieces you can refer to while re-implementing or porting over a piece of source code. &lt;/p&gt;&lt;h3&gt;Read the Source Code in Detail&lt;/h3&gt;&lt;p&gt;This is the point in which you want to disambiguate the higher-level ideas from the first step and really gain a fine, high-resolution understanding of what is happening. There might even be some points in which you need to deconflict the source code with its documentation and/or paper reference. In those cases, the source code almost always wins, and if not, then you likely have a bug report you can file. If you see things you don&amp;#39;t fully understand, you don&amp;#39;t necessarily need to address it here, but you should make note of it and keep it in mind while working in case new details help resolve it. &lt;/p&gt;&lt;h3&gt;Implement the New Code&lt;/h3&gt;&lt;p&gt;At this point, you should feel comfortable enough to start implementing the code. I found this to be a very iterative process, meaning I would think I had a grasp on something, then would start working on implementing it, then would realize I did not understand it as well as I had thought and would work my way back through the previous steps. &lt;/p&gt;&lt;h2&gt;Example&lt;/h2&gt;&lt;div&gt;&lt;div&gt;💡&lt;/div&gt;&lt;div&gt;In case you would like to follow along going forward, the Python code I will be referencing is the &lt;a href=&quot;https://github.com/microsoft/hummingbird/blob/main/hummingbird/ml/operator_converters/_tree_implementations.py?ref=thestackcanary.com&quot;&gt;Microsoft Hummingbird source code&lt;/a&gt; (specifically their implementation of Decision Tree Compilation), and the Elixir code is from the &lt;a href=&quot;https://github.com/acalejos/mockingjay/tree/main/lib/mockingjay/strategies?ref=thestackcanary.com&quot;&gt;Mockingjay source code&lt;/a&gt;.&lt;/div&gt;&lt;/div&gt;&lt;h2&gt;Class vs. Behaviour&lt;/h2&gt;&lt;p&gt;As a result of the reading and comprehension I did of the Hummingbird code base, I realized fairly early on that my library was going to have some key differences. One of the main reasons for these differences was the fact that the Hummingbird code base was built as a retroactive library that needed to cater to existing APIs that existed throughout the Python ecosystem. They chose to only add support for converting decision trees according to the SKLearn API. I, conversely, chose to write Mockingjay in such a way that it would be incumbent upon the authors of decision tree libraries to implement a protocol to interface with Mockingjay&amp;#39;s &lt;code&gt;convert&lt;/code&gt; function. This difference meant that I could establish a &lt;code&gt;Mockingjay.Tree&lt;/code&gt; data structure that I would use throughout my library, rather than having to reconstruct tree features from various other APIs as is done in Hummingbird. &lt;/p&gt;&lt;p&gt;Next, Hummingbird approaches its pipeline in a very-object oriented manner, as makes sense when using Python. Here&amp;#39; we are focusing on the implementation of the three decision tree conversion strategies: GEMM, Tree Traversal, and PErfect Tree Traversal.  It implements the following base class for tree conversions as well as PyTorch networks. &lt;/p&gt;&lt;div&gt;&lt;div&gt;💡&lt;/div&gt;&lt;div&gt;Since they&amp;#39;re inheriting from torch.nn.model they must also implement the forward method.&lt;/div&gt;&lt;/div&gt;&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class AbstracTreeImpl(PhysicalOperator):
    &amp;quot;&amp;quot;&amp;quot;
    Abstract class definig the basic structure for tree-base models.
    &amp;quot;&amp;quot;&amp;quot;

    def __init__(self, logical_operator, **kwargs):
        super().__init__(logical_operator, **kwargs)

    @abstractmethod
    def aggregation(self, x):
        &amp;quot;&amp;quot;&amp;quot;
        Method defining the aggregation operation to execute after the model is evaluated.

        Args:
            x: An input tensor

        Returns:
            The tensor result of the aggregation
        &amp;quot;&amp;quot;&amp;quot;
        pass

class AbstractPyTorchTreeImpl(AbstracTreeImpl, torch.nn.Module):
    &amp;quot;&amp;quot;&amp;quot;
    Abstract class definig the basic structure for tree-base models implemented in PyTorch.
    &amp;quot;&amp;quot;&amp;quot;

    def __init__(
        self, logical_operator, tree_parameters, n_features, classes, n_classes, decision_cond=&amp;quot;&amp;lt;=&amp;quot;, extra_config={}, **kwargs
    ):
        &amp;quot;&amp;quot;&amp;quot;
        Args:
            tree_parameters: The parameters defining the tree structure
            n_features: The number of features input to the model
            classes: The classes used for classification. None if implementing a regression model
            n_classes: The total number of used classes
            decision_cond: The condition of the decision nodes in the x &amp;lt;cond&amp;gt; threshold order. Default &amp;#39;&amp;lt;=&amp;#39;. Values can be &amp;lt;=, &amp;lt;, &amp;gt;=, &amp;gt;
        &amp;quot;&amp;quot;&amp;quot;
        super(AbstractPyTorchTreeImpl, self).__init__(logical_operator, **kwargs)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;They then proceed to inherit from these base classes and have different classes for each of the three decision tree strategies as well as their gradient-boosted counterparts, leaving them with three classes for each strategies (1 base class per strategy, 1 for ensemble implementations, and 1 for normal implementations) and nine total classes. &lt;/p&gt;&lt;p&gt;I chose to approach this using a &lt;code&gt;behaviour&lt;/code&gt;&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defmodule Mockingjay.Strategy do
  @moduledoc false
  @type t :: Nx.Container.t()

  @callback init(data :: any(), opts :: Keyword.t()) :: term()
  @callback forward(x :: Nx.Container.t(), term()) :: Nx.Tensor.t()
  ...
end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;init&lt;/code&gt; will perform setup functionality depending on the strategy and return the parameters that will need to be passed to &lt;code&gt;forward&lt;/code&gt; later on. This allows for a very simple top-level api. The whole top-level &lt;code&gt;mockingjay.ex&lt;/code&gt; file can fit here:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;def convert(data, opts \\ []) do
    {strategy, opts} = Keyword.pop(opts, :strategy, :auto)

    strategy =
      case strategy do
        :gemm -&amp;gt;
          Mockingjay.Strategies.GEMM

        :tree_traversal -&amp;gt;
          Mockingjay.Strategies.TreeTraversal

        :perfect_tree_traversal -&amp;gt;
          Mockingjay.Strategies.PerfectTreeTraversal

        :auto -&amp;gt;
          Mockingjay.Strategy.get_strategy(data, opts)

        _ -&amp;gt;
          raise ArgumentError,
                &amp;quot;strategy must be one of :gemm, :tree_traversal, :perfect_tree_traversal, or :auto&amp;quot;
      end

    {post_transform, opts} = Keyword.pop(opts, :post_transform, nil)
    state = strategy.init(data, opts)

    fn data -&amp;gt;
      result = strategy.forward(data, state)
      {_, n_trees, n_classes} = Nx.shape(result)

      result
      |&amp;gt; aggregate(n_trees, n_classes)
      |&amp;gt; post_transform(post_transform, n_classes)
    end
  end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;As you can see, the use of a behaviour here allows a strategy-agnostic approach to generating a prediction pipeline. In the object-oriented implementation, each class implements &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;forward&lt;/code&gt;, &lt;code&gt;aggregate&lt;/code&gt;, and &lt;code&gt;post_transform&lt;/code&gt;. We get the same result from a functional pipeline approach, where each step generates the needed information as input parameters for the next step. So, instead of storing intermediate results as object properties or values in an object&amp;#39;s &lt;strong&gt;&lt;code&gt;__dict__&lt;/code&gt;&lt;/strong&gt;, we just pass them along in the pipeline. I would argue this creates a much simpler and easier to follow implementation (but I am also quite biased).&lt;/p&gt;&lt;h2&gt;PyTorch to Nx &lt;/h2&gt;&lt;p&gt;For these examples, we will be looking at porting the implementations of the &lt;code&gt;forward&lt;/code&gt; function for the three conversion strategies from Python to Nx.&lt;/p&gt;&lt;h4&gt;GEMM&lt;/h4&gt;&lt;p&gt;Next, let&amp;#39;s look at the &lt;code&gt;forward&lt;/code&gt; function implementation for GEMM, one of the three conversion strategies. In Hummingbird, they implemented the &lt;code&gt;forward&lt;/code&gt; step in the base class for each strategy. So given three GEMM classes with the signatures of &lt;code&gt;GEMMTreeImpl(AbstractPyTorchTreeImpl)&lt;/code&gt;, &lt;code&gt;GEMMDecisionTreeImpl(GEMMTreeImpl)&lt;/code&gt;, and &lt;code&gt;GEMMGBDTImpl(GEMMTreeImpl)&lt;/code&gt;, the &lt;code&gt;forward&lt;/code&gt; function is defined in the &lt;code&gt;GEMMTreeImpl&lt;/code&gt; class, since both ensemble and non-ensemble decision tree models share the same forward step. &lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def forward(self, x):
      x = x.t()
      x = self.decision_cond(torch.mm(self.weight_1, x), self.bias_1)
      x = x.view(self.n_trees, self.hidden_one_size, -1)
      x = x.float()

      x = torch.matmul(self.weight_2, x)

      x = x.view(self.n_trees * self.hidden_two_size, -1) == self.bias_2
      x = x.view(self.n_trees, self.hidden_two_size, -1)
      if self.tree_op_precision_dtype == &amp;quot;float32&amp;quot;:
          x = x.float()
      else:
          x = x.double()

      x = torch.matmul(self.weight_3, x)
      x = x.view(self.n_trees, self.hidden_three_size, -1)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now, here is the Nx implementation:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;@impl true
  deftransform forward(x, {arg, opts}) do
    opts =
      Keyword.validate!(opts, [
        :condition,
        :n_trees,
        :n_classes,
        :max_decision_nodes,
        :max_leaf_nodes,
        :n_weak_learner_classes,
        :custom_forward
      ])

    _forward(x, arg, opts)
  end

  defnp _forward(x, arg, opts \\ []) do
    %{mat_A: mat_A, mat_B: mat_B, mat_C: mat_C, mat_D: mat_D, mat_E: mat_E} = arg

    condition = opts[:condition]
    n_trees = opts[:n_trees]
    n_classes = opts[:n_classes]
    max_decision_nodes = opts[:max_decision_nodes]
    max_leaf_nodes = opts[:max_leaf_nodes]
    n_weak_learner_classes = opts[:n_weak_learner_classes]

    mat_A
    |&amp;gt; Nx.dot([1], x, [1])
    |&amp;gt; condition.(mat_B)
    |&amp;gt; Nx.reshape({n_trees, max_decision_nodes, :auto})
    |&amp;gt; then(&amp;amp;Nx.dot(mat_C, [2], [0], &amp;amp;1, [1], [0]))
    |&amp;gt; Nx.reshape({n_trees * max_leaf_nodes, :auto})
    |&amp;gt; Nx.equal(mat_D)
    |&amp;gt; Nx.reshape({n_trees, max_leaf_nodes, :auto})
    |&amp;gt; then(&amp;amp;Nx.dot(mat_E, [2], [0], &amp;amp;1, [1], [0]))
    |&amp;gt; Nx.reshape({n_trees, n_weak_learner_classes, :auto})
    |&amp;gt; Nx.transpose()
    |&amp;gt; Nx.reshape({:auto, n_trees, n_classes})
  end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Do not be distracted by the length of this code snippet, as much of the lines are taken up by validating arguments. Let&amp;#39;s look at a more stripped-down version without that:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;@impl true
  deftransform forward(x, {arg, opts}) do
    _forward(x, arg, opts)
  end

  defnp _forward(x, arg, opts \\ []) do
    mat_A
    |&amp;gt; Nx.dot([1], x, [1])
    |&amp;gt; condition.(mat_B)
    |&amp;gt; Nx.reshape({n_trees, max_decision_nodes, :auto})
    |&amp;gt; then(&amp;amp;Nx.dot(mat_C, [2], [0], &amp;amp;1, [1], [0]))
    |&amp;gt; Nx.reshape({n_trees * max_leaf_nodes, :auto})
    |&amp;gt; Nx.equal(mat_D)
    |&amp;gt; Nx.reshape({n_trees, max_leaf_nodes, :auto})
    |&amp;gt; then(&amp;amp;Nx.dot(mat_E, [2], [0], &amp;amp;1, [1], [0]))
    |&amp;gt; Nx.reshape({n_trees, n_weak_learner_classes, :auto})
    |&amp;gt; Nx.transpose()
    |&amp;gt; Nx.reshape({:auto, n_trees, n_classes})
  end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Let&amp;#39;s take a look at some obvious difference:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;The &lt;code&gt;Nx&lt;/code&gt; code does not have to transpose in the first step since &lt;code&gt;Nx.dot/4&lt;/code&gt; allows you to specify the contracting axes.&lt;/li&gt;&lt;li&gt;You can use &lt;code&gt;Nx.dot/6&lt;/code&gt; to get the same behavior as &lt;code&gt;torch.matmul&lt;/code&gt;&lt;ul&gt;&lt;li&gt;&lt;code&gt;torch.matmul&lt;/code&gt; does a lot of wizardry with broadcasting to make this instance work&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;li&gt;We use functions such as &lt;code&gt;Nx.equal&lt;/code&gt; to fit into the pipeline rather than using the &lt;code&gt;==&lt;/code&gt; oeprator (which would work outside of a pipeline)&lt;/li&gt;&lt;li&gt;&lt;code&gt;torch.view&lt;/code&gt; is equivalent to &lt;code&gt;Nx.reshape&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;code&gt;Nx&lt;/code&gt; uses the &lt;code&gt;:auto&lt;/code&gt; atom to where &lt;code&gt;torch&lt;/code&gt; uses &lt;code&gt;-1&lt;/code&gt; to reference infering the sie of an axis&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Outside of these differences, the code translates fairly easily. Let&amp;#39;s take a look at a bit of a more complex instance.&lt;/p&gt;&lt;h4&gt;Tree Traversal&lt;/h4&gt;&lt;p&gt;Here is the Python implementation:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _expand_indexes(self, batch_size):
        indexes = self.nodes_offset
        indexes = indexes.expand(batch_size, self.num_trees)
        return indexes.reshape(-1)

def forward(self, x):
        indexes = self.nodes_offset
        indexes = indexes.expand(batch_size, self.num_trees).reshape(-1)

        for _ in range(self.max_tree_depth):
            tree_nodes = indexes
            feature_nodes = torch.index_select(self.features, 0, tree_nodes).view(-1, self.num_trees)
            feature_values = torch.gather(x, 1, feature_nodes)

            thresholds = torch.index_select(self.thresholds, 0, indexes).view(-1, self.num_trees)
            lefts = torch.index_select(self.lefts, 0, indexes).view(-1, self.num_trees)
            rights = torch.index_select(self.rights, 0, indexes).view(-1, self.num_trees)

            indexes = torch.where(self.decision_cond(feature_values, thresholds), lefts, rights).long()
            indexes = indexes + self.nodes_offset
            indexes = indexes.view(-1)

        output = torch.index_select(self.values, 0, indexes).view(-1, self.num_trees, self.n_classes)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And here is the Nx implementation:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defn _forward(x, features, lefts, rights, thresholds, nodes_offset, values, opts \\ []) do
    max_tree_depth = opts[:max_tree_depth]
    num_trees = opts[:num_trees]
    n_classes = opts[:n_classes]
    condition = opts[:condition]
    unroll = opts[:unroll]

    batch_size = Nx.axis_size(x, 0)

    indices =
      nodes_offset
      |&amp;gt; Nx.broadcast({batch_size, num_trees})
      |&amp;gt; Nx.reshape({:auto})

    {indices, _} =
      while {tree_nodes = indices, {features, lefts, rights, thresholds, nodes_offset, x}},
            _ &amp;lt;- 1..max_tree_depth,
            unroll: unroll do
        feature_nodes = Nx.take(features, tree_nodes) |&amp;gt; Nx.reshape({:auto, num_trees})
        feature_values = Nx.take_along_axis(x, feature_nodes, axis: 1)
        local_thresholds = Nx.take(thresholds, tree_nodes) |&amp;gt; Nx.reshape({:auto, num_trees})
        local_lefts = Nx.take(lefts, tree_nodes) |&amp;gt; Nx.reshape({:auto, num_trees})
        local_rights = Nx.take(rights, tree_nodes) |&amp;gt; Nx.reshape({:auto, num_trees})

        result =
          Nx.select(
            condition.(feature_values, local_thresholds),
            local_lefts,
            local_rights
          )
          |&amp;gt; Nx.add(nodes_offset)
          |&amp;gt; Nx.reshape({:auto})

        {result, {features, lefts, rights, thresholds, nodes_offset, x}}
      end

    values
    |&amp;gt; Nx.take(indices)
    |&amp;gt; Nx.reshape({:auto, num_trees, n_classes})
  end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Here there are some much more striking differences, namely the use of &lt;code&gt;Nx&lt;/code&gt;&amp;#39;s &lt;code&gt;while&lt;/code&gt; expression compared to a &lt;code&gt;for&lt;/code&gt; loop in Python. We use &lt;code&gt;while&lt;/code&gt; in this case since it can achieve the same purpose as the Python &lt;code&gt;for&lt;/code&gt; loop and it is supported by &lt;code&gt;Nx&lt;/code&gt; within a &lt;code&gt;defn&lt;/code&gt; expression. Otherwise, we might have to perform some of the calculations within a &lt;code&gt;deftransform&lt;/code&gt;, as we will see in the next example.  Another obvious difference is that in the Nx implementation, we have to pass the required variables around throughout these operation, whereas Python can use stored class attributes. &lt;/p&gt;&lt;p&gt;Still, the conversion is quite straightforward. I hope you are beginning to see that this is not an impossible effort, and can be accomplished given you have a firm understanding of the source material.&lt;/p&gt;&lt;h4&gt;Perfect Tree Traversal&lt;/h4&gt;&lt;p&gt;Lastly, let&amp;#39;s look at the last conversion strategy. Yet again, this conversion is even slightly more complex, but hopefully seeing this example will help you in your case:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def forward(self, x):
        prev_indices = (self.decision_cond(torch.index_select(x, 1, self.root_nodes), self.root_biases)).long()
        prev_indices = prev_indices + self.tree_indices
        prev_indices = prev_indices.view(-1)

        factor = 2
        for nodes, biases in zip(self.nodes, self.biases):
            gather_indices = torch.index_select(nodes, 0, prev_indices).view(-1, self.num_trees)
            features = torch.gather(x, 1, gather_indices).view(-1)
            prev_indices = (
                factor * prev_indices + self.decision_cond(features, torch.index_select(biases, 0, prev_indices)).long()
            )

        output = torch.index_select(self.leaf_nodes, 0, prev_indices).view(-1, self.num_trees, self.n_classes)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And the Elixir implementation:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-elixir&quot;&gt;defnp _forward(
          x,
          root_features,
          root_thresholds,
          features,
          thresholds,
          values,
          indices,
          opts \\ []
        ) do
    prev_indices =
      x
      |&amp;gt; Nx.take(root_features, axis: 1)
      |&amp;gt; opts[:condition].(root_thresholds)
      |&amp;gt; Nx.add(indices)
      |&amp;gt; Nx.reshape({:auto})
      |&amp;gt; forward_reduce_features(x, features, thresholds, opts)

    Nx.take(values, prev_indices)
    |&amp;gt; Nx.reshape({:auto, opts[:num_trees], opts[:n_classes]})
  end

  deftransformp forward_reduce_features(prev_indices, x, features, thresholds, opts \\ []) do
    Enum.zip_reduce(
      Tuple.to_list(features),
      Tuple.to_list(thresholds),
      prev_indices,
      fn nodes, biases, acc -&amp;gt;
        gather_indices = nodes |&amp;gt; Nx.take(acc) |&amp;gt; Nx.reshape({:auto, opts[:num_trees]})
        features = Nx.take_along_axis(x, gather_indices, axis: 1) |&amp;gt; Nx.reshape({:auto})

        acc
        |&amp;gt; Nx.multiply(@factor)
        |&amp;gt; Nx.add(opts[:condition].(features, Nx.take(biases, acc)))
      end
    )
  end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You can see that in this case, we have a function defined in a &lt;code&gt;deftransform&lt;/code&gt; within our &lt;code&gt;forward&lt;/code&gt; pipeline. Why is this so? Well, when writing definitions within &lt;code&gt;defn&lt;/code&gt; you forfeit the use of the default Elixir kernel for the &lt;code&gt;Nx.Kernel&lt;/code&gt; module. If you want full access to all of the normal Elixir modules, you need to use a &lt;code&gt;deftransform&lt;/code&gt;. We needed to use &lt;code&gt;Enum.zip_reduce&lt;/code&gt; in this instance (rather than &lt;code&gt;Nx&lt;/code&gt;&amp;#39;s &lt;code&gt;while&lt;/code&gt; like before) since the &lt;code&gt;features&lt;/code&gt; and &lt;code&gt;thresholds&lt;/code&gt; lists are not of uniform shape. Their shape represents the length of a given depth of a binary tree, so they will be a nested list of lengths &lt;code&gt;[1,2,4,8...]&lt;/code&gt;. This is an optimization as opposed to normal &lt;code&gt;TreeTraversal&lt;/code&gt;, but required a bit of a different approach as opposed to the Python implementation which took advantage of &lt;code&gt;torch.nn.ParameterList&lt;/code&gt; to build out the same lists. You might also notice the use of &lt;code&gt;Tuple.to_list&lt;/code&gt; on lines 25 and 26. This was required since we needed &lt;code&gt;features&lt;/code&gt; and &lt;code&gt;thresholds&lt;/code&gt; to be stored in &lt;code&gt;Nx.container&lt;/code&gt;&amp;#39;s when passed into the &lt;code&gt;deftransform&lt;/code&gt;, and &lt;code&gt;Tuple&lt;/code&gt; implements the &lt;code&gt;Nx.Container&lt;/code&gt; protocol, while lists do not. Even still, given that knowledge of the intricacies of &lt;code&gt;defn&lt;/code&gt; and &lt;code&gt;deftransform&lt;/code&gt;, the final ported solution is very similar to the reference solution.&lt;/p&gt;&lt;h1&gt;Conclusion&lt;/h1&gt;&lt;p&gt;In this post, I tried to accomplish several things at once, and perhaps that lead to a cluttered article, but I felt the need to address all of these points at once. I do not mean to suggest that Machine Learning has no place in Python or that Python will not continue to be the most dominant player in Machine Learning, but that I think some healthy competition is a good thing, and that perhaps Python does have some shortcomings that might give other languages valid reasons to coexist in the space. &lt;/p&gt;&lt;p&gt;Next, I wanted to address some specifics as to what Elixir has to offer to the machine learning space. I think it is uniquely positioned to be quite competitive considering the large community push to support more and more libraries, as well as the large application development community that can benefit from an in-house solution.&lt;/p&gt;&lt;p&gt;Lastly, I wanted to share some practical tips for those looking to move on from Python to Elixir, but feeling somewhat helpless in the process. I think that Sean Moriarity&amp;#39;s book that I mentioned at the beginning of this article is an invaluable resource and great step in the education of machine learning for Elixir developers, but it can nonetheless feel daunting to seemingly throw out existing working solutions for new-fangled, perhaps not as well respected solutions. I hope I showed how anybody can approach this problem, and any existing Elixir developer can be a machine learning developer going forward. The ground work has been laid, and the tools are available. Thank you for reading (especially if you made it to the end)!&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Cure, Four Releases Deep: From FSMs to Furniture</title>
<link>https://rocket-science.ru/hacking/2026/04/18/cure-v016-to-v019</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">NqWEtqE765iKeAlfG-VPuRWvFXYIkLwy5jAlUA==</guid>
<pubDate>Sat, 18 Apr 2026 11:56:20 +0000</pubDate>
<description>A walk through Cure v0.16.0 through v0.19.1. A Finitomata-inspired FSM rewrite, the dependent-types core finally behaving like it promised, a pattern engine that actually matches, and a stack of ergonomics that should have been there six versions ago.</description>
<content:encoded>&lt;p&gt;There is a particular kind of intellectual cowardice endemic to programming-language design, which consists of writing a feature that almost works, convincing oneself that it works enough, and then moving on to the next item on the roadmap before the cracks start to show. I have indulged in this pastime for years. Cure, for the first dozen or so releases, indulged in it with me. The last four tags—v0.16.0, v0.17.0 with its two patch siblings, v0.18.0, and v0.19.0 followed by v0.19.1—are my belated attempt to stop. Each of them picks one area of the language that had previously been garnished with a thin coat of varnish and strips it down to bare wood.&lt;/p&gt;&lt;p&gt;What follows is the tour. I will try to explain both what changed and why it had to. If the tone reads as exasperated in places, that is because I am mostly exasperated at the person who wrote the earlier versions, who was, as luck would have it, me.&lt;/p&gt;&lt;h2&gt;v0.16.0: the turnstile that could not contain itself&lt;/h2&gt;&lt;p&gt;For fifteen releases Cure had finite state machines as a language primitive, and for fifteen releases the canonical FSM example—the turnstile—looked like this: four lines of beautifully declarative transition graph in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turnstile.cure&lt;/code&gt;, followed by a hundred and twenty lines of Elixir GenServer plumbing in a wrapper module that had nothing to do with turnstiles and everything to do with bridging the gap between a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen_statem&lt;/code&gt; process and the rest of the application. The FSM definition said what you meant. The wrapper said what the runtime demanded. You read one of them to understand the domain and the other to make the program run. I cannot in good conscience call that a first-class primitive. It was more of a first-class primitive’s slightly embarrassed cousin, the one who turns up at family dinners and talks about his crypto portfolio.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://hexdocs.pm/finitomata&quot;&gt;Finitomata&lt;/a&gt;, my Elixir library for finite automata that has been in production for years, got this right on the first attempt by insisting that the graph and the transition handler belong in the same module and behind the same abstraction. v0.16.0 borrows that insight wholesale, and then some. The turnstile now reads:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;fsm Turnstile with Integer
  Locked   --coin--&amp;gt;  Unlocked
  Unlocked --push--&amp;gt;  Locked
  Unlocked --coin--&amp;gt;  Unlocked
  Locked   --push--&amp;gt;  Locked

  on_transition
    (:locked, :coin, _payload, data)   -&amp;gt; %[:ok, :unlocked, data + 1]
    (:unlocked, :push, _payload, data) -&amp;gt; %[:ok, :locked,   data]
    (:unlocked, :coin, _payload, data) -&amp;gt; %[:ok, :unlocked, data + 1]
    (_, _, _, data)                    -&amp;gt; %[:ok, :__same__, data]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The four transition lines on top are the graph you always wrote. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_transition&lt;/code&gt; block underneath takes pattern-matching clauses of shape &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(current_state, event, event_payload, state_payload)&lt;/code&gt; and returns either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;%[:ok, next_state, new_payload]&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;%[:error, reason]&lt;/code&gt;. When the compiler sees an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_transition&lt;/code&gt; block it silently changes mode: instead of generating raw &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen_statem&lt;/code&gt; Erlang abstract forms, it produces a GenServer-based Elixir module with an embedded transition table, pre-dispatch validation, compiled &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;do_on_transition/4&lt;/code&gt; clauses, and the optional lifecycle hooks &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_enter&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_exit&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_failure&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_timer&lt;/code&gt;. If you do not write &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_transition&lt;/code&gt;, the FSM compiles the way it always did, through Erlang abstract forms to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen_statem&lt;/code&gt;. No flags, no configuration—the compiler decides based on whether you asked for the new thing.&lt;/p&gt;&lt;p&gt;Finitomata also contributed two conventions that I refused to live without once I tried them. A hard event, written with a trailing exclamation mark, must be the sole outgoing event of its source state, and fires automatically on arrival, so initialisation chains and guaranteed progressions stop requiring baby-sitting from the caller. A soft event, written with a trailing question mark, silently leaves the state alone if it cannot transition, instead of logging a warning and invoking &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on_failure&lt;/code&gt;. Health checks, optimistic polling, any kind of “try it, and if it does not apply this tick, never mind”—all become one-liners. The lexer grew a small counter that tracks whether it is currently inside an arrow &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--...--&amp;gt;&lt;/code&gt;, and only inside an arrow does it absorb the trailing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;!&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt; into the identifier. Everywhere else, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;!&lt;/code&gt; stays reserved for effect annotations and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt; for predicates and holes. The verifier enforces the hard-event rule; the compiler uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{:continue, ...}&lt;/code&gt; from the GenServer return tuple to fire hard events without yielding.&lt;/p&gt;&lt;p&gt;The turnstile’s wrapper is now fifty lines rather than one-hundred-and-twenty, and the fifty it retains are real application logic: counting how many people went through today. The twelve turnstile tests pass without modification, which is the only part of this release I will allow myself to take unironic pride in.&lt;/p&gt;&lt;h2&gt;v0.17.0: toward Idris, at last&lt;/h2&gt;&lt;p&gt;For seven versions Cure had been marketing itself as a dependently-typed language while behaving, in every practical respect, like a vaguely refinement-typed one. You could write &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Vector(T, n)&lt;/code&gt; in a signature. The checker would nod politely. It would not, however, verify that the length you promised was the length you produced, because the machinery to do so did not exist. v0.17.0 stops nodding.&lt;/p&gt;&lt;p&gt;Three type shapes that should have been there from the start arrive simultaneously. Sigma types pair a value with a type that depends on it; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Sigma(n: Nat, Vector(T, n))&lt;/code&gt; is “a natural number together with a vector of exactly that length”. Pi types let a function’s return type depend on its arguments, so the canonical example&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;fn append(xs: Vector(T, m), ys: Vector(T, n)) -&amp;gt; Vector(T, m + n)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;now actually means what it reads as: at each call site the checker substitutes the concrete arguments into the return type, normalises with a tiny terminating reducer called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Types.Reduce&lt;/code&gt;, and resolves the result. Closed type-level arithmetic never troubles the SMT solver—&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Vector(T, 2 + 3)&lt;/code&gt; is syntactically the same type as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Vector(T, 5)&lt;/code&gt; before Z3 is even woken up. Equality types arrive with the single constructor &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refl(x) : Eq(T, x, x)&lt;/code&gt; and the single eliminator &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rewrite eq in expr&lt;/code&gt;, both erased at codegen to the atom &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:cure_refl&lt;/code&gt;. The standard library gains a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Std.Equal&lt;/code&gt; module exposing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;refl&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sym&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trans&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cong&lt;/code&gt;, which is to say the usual suspects.&lt;/p&gt;&lt;p&gt;The second big arrival is implicit arguments with proper first-order unification. Write &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fn id({T}, x: T) -&amp;gt; T = x&lt;/code&gt;, call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id(42)&lt;/code&gt;, and at the call site an occurs-check-equipped unifier resolves &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;T&lt;/code&gt; from the explicit argument. When resolution fails—and it does, more often than one would like—the pipeline emits a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:unification_trace&lt;/code&gt; event carrying the argument, the position, and the substitution that killed it. The LSP renders the trace in hover; the CLI prints it in error output. No more staring at “cannot infer &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;T&lt;/code&gt;” the way one stares at a Rorschach blot. Implicit parameters are erased at codegen, so they cost exactly nothing at runtime.&lt;/p&gt;&lt;p&gt;What ties the dependent-type stack together, in practical terms, is hole-driven development. Write&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;fn safe_head(xs: List(T)) -&amp;gt; T = ?body&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and compile, and the compiler does not merely tell you “&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?body&lt;/code&gt; is missing”: it tells you &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?body : T in scope: xs : List(T)&lt;/code&gt;. Anonymous holes (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;??&lt;/code&gt;) get numbered &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?_1&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?_2&lt;/code&gt;, in source order. Every encountered hole emits a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:hole_goal&lt;/code&gt; event with the goal type and the local context, and the REPL’s new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:holes&lt;/code&gt; meta-command lists everything recorded during the last evaluation. This is how Idris programmers write programs. It is now, finally, how Cure programmers can.&lt;/p&gt;&lt;p&gt;Totality joins the party, gently. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Types.Totality&lt;/code&gt; classifies every function as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:total&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:partial&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:unknown&lt;/code&gt;, combining pattern-coverage (via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Types.PatternChecker&lt;/code&gt;) with a structural-recursion check. By default totality is report-only; decorating a function with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#[total]&lt;/code&gt; promotes the classification to a hard error if it fails:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;#[total]
fn factorial(n: Int) -&amp;gt; Int
  | 0 -&amp;gt; 1
  | n -&amp;gt; n * factorial(n - 1)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Only direct structural recursion is caught in v0.17.0. Mutual recursion has to wait for v0.19.0.&lt;/p&gt;&lt;p&gt;Refinement types grow a backbone. Path-sensitive refinement flows along &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;match&lt;/code&gt; guards, so inside&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;if x != 0 then 100 / x else 0&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;then&lt;/code&gt; branch sees &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x : {x: Int | x != 0}&lt;/code&gt;, and the division is safe without an explicit refinement annotation. The new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Std.Refine&lt;/code&gt; module ships drop-in refinements one does not wish to keep rewriting: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NonZero&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Positive&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Negative&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NonNegative&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Percentage&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Probability&lt;/code&gt;, plus predicate helpers. These are the kinds of tiny conveniences that, had they been present in v0.10.0, would have saved everyone a small amount of grief each day for a year.&lt;/p&gt;&lt;p&gt;The tooling catches up in the same release. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.REPL&lt;/code&gt; is a complete rewrite: multi-line input (terminated by a blank line or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;;;&lt;/code&gt;), the meta-commands &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:t&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:doc&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:effects&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:load&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:reload&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:use&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:holes&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:env&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:reset&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:fmt&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:help&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:quit&lt;/code&gt;, and command history persisted to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.cure_history&lt;/code&gt;. A watch mode, invoked as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cure watch lib/ --action check&lt;/code&gt;, recompiles, type-checks, or tests on every save with a 200 ms debounce, and works without &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:file_system&lt;/code&gt; thanks to a small polling fallback. The LSP server, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.LSP.Server&lt;/code&gt;, acquires inlay hints, signature help, formatting via a round-trip-tested source-preserving printer, prepare-rename and rename, code lenses, semantic tokens, and workspace symbols—seven capabilities in one commit, which is the sort of thing that happens when one has been putting off the LSP for three releases in a row. Doctests arrive, too: any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;##&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;###&lt;/code&gt; docstring immediately above a function whose body contains &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cure&amp;gt;&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;=&amp;gt;&lt;/code&gt; pairs is executed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cure test --doctests&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;The patch releases tidied up. v0.17.1 stopped the stdlib preloader from polluting the code path, fixed two LSP crashes around inlay hints and semantic tokens, and retargeted the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vicure&lt;/code&gt; grammar at v0.17.0 syntax. v0.17.2 re-enabled LSP formatting once the new source-preserving formatter proved it would not eat users’ comments for breakfast—a promise the old AST-based formatter had never quite been willing to make.&lt;/p&gt;&lt;h2&gt;v0.18.0: pattern matching grows up&lt;/h2&gt;&lt;p&gt;Here is a question I did not want to answer for a long time. If one writes&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;match value
  %{list: [h | t]} -&amp;gt; handle(h, t)
  _                -&amp;gt; default&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and the subject value is a map whose &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list&lt;/code&gt; field is not present, what does the old compiler do? The correct answer is “fails the match and falls through to the wildcard”. The answer the old compiler produced is “succeeds, because the map pattern is miscompiled into Erlang’s construction form (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;K =&amp;gt; V&lt;/code&gt;) rather than the match form (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;K := V&lt;/code&gt;), so it accepts any subject, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;h&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t&lt;/code&gt; are bound to whatever happens to be lying around, possibly nothing sensible”. There were tests pointing straight at this. What there was not, was a pattern compiler willing to recurse.&lt;/p&gt;&lt;p&gt;v0.18.0 replaces the pattern layer wholesale. The headline is that this now actually works:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;match value
  %[_, %{list: [head | tail]}, _]          -&amp;gt; handle(head, tail)
  Person{name, address: Address{city}}     -&amp;gt; greet(name, city)
  [Ok(v) | _]                              -&amp;gt; v
  _                                        -&amp;gt; default&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Every sub-pattern compiles as a real pattern. Nested map patterns use exact matching and actually require the key. Record patterns check the struct tag and each field. The cons pattern binds &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v&lt;/code&gt; through the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ok(...)&lt;/code&gt; constructor. The wildcard mops up the rest. There is no magic here; merely a module (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Compiler.PatternCompiler&lt;/code&gt;) that does the one thing its name promises, and which every other codegen path—&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compile_multi_clause_function&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compile_pattern_match&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compile_assignment&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compile_comprehension&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compile_catch_and_finally&lt;/code&gt;—now routes through rather than reinventing pattern handling locally. The original sin, I discovered after far too long, was treating patterns and expressions uniformly because their AST nodes have the same shape. Patterns are not evaluated; they are matched against a subject with bindings as side effects. Nothing good comes of pretending otherwise.&lt;/p&gt;&lt;p&gt;Alongside the compiler rewrite, the type checker’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bind_pattern_vars/3&lt;/code&gt; was rewritten to thread the scrutinee type through every pattern shape, so nested pattern variables now carry the tightest type the structure allows rather than the old &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:any&lt;/code&gt;. Tuple patterns zip element-wise. List and cons patterns bind head and tail at the correct element and list types respectively. Map patterns look up each key through the scrutinee’s schema when it is a known record, or through the map value type otherwise. Record patterns resolve every field against the schema registered at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rec&lt;/code&gt; time, and unknown fields emit a warning under the new error code &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E021&lt;/code&gt;. The practical consequence is that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;match p { Person{age: a} when a &amp;gt; 17 -&amp;gt; ... }&lt;/code&gt; finally knows &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a : Int&lt;/code&gt;, so subsequent refinement fires against the right type.&lt;/p&gt;&lt;p&gt;Field punning arrives in both directions: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Point{x, y}&lt;/code&gt; desugars to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Point{x: x, y: y}&lt;/code&gt; in pattern position and in construction position. It is purely a parser change, and if you have ever found yourself writing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Person{name: name, age: age, email: email, ...}&lt;/code&gt; seven fields deep you will be glad of it. Repeated occurrences of the same variable in one pattern now do what a reasonable person would expect: the first occurrence binds, later ones lower to equality guards. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;%[x, x]&lt;/code&gt; matches exactly the pairs whose slots are equal, which I should have supported five releases ago.&lt;/p&gt;&lt;p&gt;The pin operator (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;^name&lt;/code&gt;) lands behind &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--experimental-pin&lt;/code&gt; as the official escape hatch for “compare against this already-bound value”:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;let target = get_tag()
match event.tag
  ^target -&amp;gt; :hit
  _       -&amp;gt; :miss&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Internally it lowers to a fresh variable plus a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;V_fresh =:= V_target&lt;/code&gt; guard—the same transformation one used to write by hand. Zero runtime cost, considerably fewer characters on screen. It is promoted to default in v0.19.0.&lt;/p&gt;&lt;p&gt;Exhaustiveness checking grows a second pass. The old flat classifier (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:wildcard | :empty_list | :cons | {:literal, ...} | {:constructor, ...} | {:tuple, n}&lt;/code&gt;) is still there, because it is fast and covers the common case; a new Maranget-style column walker, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Types.PatternChecker.check_nested/2&lt;/code&gt;, now descends into tuple scrutinees whose element types are enumerable and emits concrete, source-shaped witnesses for missing patterns:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;Warning: match expression has nested non-exhaustive cases (E025)
  missing: %[Error(_), _]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Five new error codes—&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E021&lt;/code&gt; through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E025&lt;/code&gt;—land in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Compiler.Errors&lt;/code&gt; for unknown record fields in patterns, record field type mismatches, non-literal map-pattern keys, unbound pin variables, and non-exhaustive nested matches. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cure explain E0xx&lt;/code&gt; works for every one.&lt;/p&gt;&lt;p&gt;There is a caveat worth naming explicitly. Map patterns that used to silently succeed against arbitrary subjects no longer do so. If your code relied on the old broken behaviour—for example, a map pattern whose key was not in the subject but which the compiler accepted anyway—you will now see either a runtime &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;badmatch&lt;/code&gt; or a compile-time non-exhaustive warning. Fix the pattern. The compiler is right and you are wrong; I say this with the humility of the person who wrote the earlier compiler.&lt;/p&gt;&lt;h2&gt;v0.19.0: the furniture arrives&lt;/h2&gt;&lt;p&gt;After the pattern engine settled, a queue of previously-slated features was ready to land. v0.19.0 is called “Bring the Furniture” because every item on it is the kind of thing one should not have to talk about in a release announcement; one should merely discover, pleasantly, that it is there.&lt;/p&gt;&lt;p&gt;Propositions acquire a syntactic home. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;proof&lt;/code&gt; container is a module-shaped block whose bindings must return &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Eq(...)&lt;/code&gt; or a refinement witness:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;proof Std.Proof
  fn plus_zero(_n: Int)       -&amp;gt; Eq(Int, n, n)        = :cure_refl
  fn append_nil(_xs: List(T)) -&amp;gt; Eq(List(T), xs, xs)  = :cure_refl&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The compiler enforces the shape under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E026&lt;/code&gt;. Runtime values are plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:cure_refl&lt;/code&gt; atoms; the checker does the interesting work; the resulting BEAM module is ordinary-looking code you can load alongside any other. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assert_type expr : T&lt;/code&gt; arrives as a zero-cost compile-time assertion: the checker verifies the type, the codegen strips the wrapper, and mismatches surface as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E027&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fn doubled(n: Int) -&amp;gt; Int = assert_type n * 2 : Int&lt;/code&gt; does what it looks like.&lt;/p&gt;&lt;p&gt;Records gain field defaults, which is the sort of feature whose absence one does not forgive oneself:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;rec Person
  name: String = &amp;quot;Anonymous&amp;quot;
  age: Int = 0
  active: Bool = true&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Omitted fields fall back to the declared defaults at construction time; any caller-supplied value always wins. Type mismatches between the default and the declared field type are caught as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E028&lt;/code&gt;. In the same spirit, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@derive(Show, Eq, Ord)&lt;/code&gt; is finally wired end-to-end. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Types.Derive&lt;/code&gt; module had been sitting on disk since v0.12.0 without a codegen path; v0.19.0 plumbs it through so that decorating&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;@derive(Show, Eq, Ord)
rec Point
  x: Int
  y: Int&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;synthesises plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;show/1&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;eq/2&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compare/2&lt;/code&gt; exports. The accompanying example, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;examples/derived_show.cure&lt;/code&gt;, constructs two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Point&lt;/code&gt; values, asks &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;eq(p, q)&lt;/code&gt;, and returns one if they compare equal—which, naturally, they do.&lt;/p&gt;&lt;p&gt;Property-based testing shows up via two cooperating stdlib modules. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Std.Gen&lt;/code&gt; ships tiny stateless generators (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;int_in&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bool&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list_int&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list_of_int&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;one_of&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;constant&lt;/code&gt;) backed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:rand&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Std.Test.forall(gen, property, runs)&lt;/code&gt; runs the property against samples and returns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:ok&lt;/code&gt; or raises &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:property_failed&lt;/code&gt;:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;mod Laws
  use Std.Gen
  use Std.Test

  fn test_plus_zero() -&amp;gt; Atom =
    forall(fn(_) -&amp;gt; int_in(-100, 100), fn(n) -&amp;gt; n + 0 == n, 100)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Shrinking, histograms, and stateful generators are future work; this is deliberately the minimum viable property tester, and it is enough to catch the kind of bug one always catches with a property tester in the first week.&lt;/p&gt;&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Std.Iter&lt;/code&gt; is the matching minimal lazy iterator protocol. Constructors &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;empty/0&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;from_list/1&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;range/2&lt;/code&gt;; consumers &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fold/3&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;take/2&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;to_list/1&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;take/2&lt;/code&gt; stops before materialising the tail, so unbounded ranges are safe as long as you only peek at a prefix:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;use Std.Iter

fn sum_range(n: Int) -&amp;gt; Int =
  let it = range(1, n)
  let add = fn(x) -&amp;gt; fn(acc) -&amp;gt; acc + x
  fold(it, 0, add)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The package-registry groundwork lands as a version parser and a dependency resolver. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Project.Version&lt;/code&gt; handles SemVer plus compound constraints (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~&amp;gt;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;gt;=&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;=&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;gt;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;==&lt;/code&gt;, combined with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;and&lt;/code&gt;), and accepts MAJOR.MINOR as shorthand for MAJOR.MINOR.0. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Project.Resolver.resolve/2&lt;/code&gt; is a deterministic backtracking resolver over a local registry that picks the newest compatible version and surfaces conflicts as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E030&lt;/code&gt;. A remote index service, signing, and Hex.pm cross-publishing are what v0.20.0 is for.&lt;/p&gt;&lt;p&gt;Totality catches up with multi-function cycles. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Types.Totality.check_mutual/1&lt;/code&gt; runs Tarjan’s SCC algorithm on the module call graph, then classifies each non-trivial strongly-connected component as either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:ok&lt;/code&gt; (structural decrease proven on at least one path) or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:suspect&lt;/code&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E029&lt;/code&gt;). It is not a full termination checker—nothing running at compile time on the BEAM is going to be—but it is strictly more than nothing, and it catches the obvious cases.&lt;/p&gt;&lt;p&gt;One last syntactic nicety: multi-head cons patterns,&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-cure&quot;&gt;match xs
  [a, b, c | rest] -&amp;gt; a + b + c
  _                -&amp;gt; 0&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;desugared by the parser into right-associated cons cells. Works in pattern and construction position. It is the sort of feature that every functional language grows eventually; we might as well have grown ours here.&lt;/p&gt;&lt;p&gt;Five new error codes (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E026&lt;/code&gt; through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;E030&lt;/code&gt;) round out the error catalog for the release, all of them available via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cure explain&lt;/code&gt;.&lt;/p&gt;&lt;h2&gt;v0.19.1: Dialyzer and the small sins&lt;/h2&gt;&lt;p&gt;The point release should never be the interesting one, and v0.19.1 honours that convention. Its job was to add Dialyzer to the CI matrix (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.github/workflows/ci.yml&lt;/code&gt;), resolve the backlog of specs it turned up across &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Compiler&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Compiler.Codegen&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.FSM.Compiler&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.Types.Env&lt;/code&gt;, and friends, and tidy a handful of things that were unclean but not broken: the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mix cure.escript&lt;/code&gt; task got proper CLI integration, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mix.exs&lt;/code&gt; lost a dead dependency, and the stdlib preloader started force-compiling in tests instead of hoping the beams from the last run would still be fresh. None of this is exciting. All of it needed to happen, because a language without Dialyzer in CI is a language whose maintainer is lying to themselves about how robust its internals are.&lt;/p&gt;&lt;p&gt;The small print, for anyone upgrading. The lexer rule for trailing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt; on identifiers introduced in v0.17.0 means &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x?y&lt;/code&gt; now tokenises as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x?&lt;/code&gt; followed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;y&lt;/code&gt;; add whitespace or parentheses if you need the old behaviour. Map patterns that relied on the pre-v0.18.0 construction-form bug will now fail matches honestly. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;None&lt;/code&gt; versus &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;None()&lt;/code&gt; inside a record pattern now emits a warning when you probably meant the nullary constructor. And &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cure.lock&lt;/code&gt; lockfiles produced by v0.19.0 and v0.19.1 remain source-compatible with v0.17.0 and later, but a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mix cure.compile_stdlib &amp;amp;&amp;amp; mix cure.check&lt;/code&gt; after pulling is cheap insurance.&lt;/p&gt;&lt;h2&gt;What all this adds up to&lt;/h2&gt;&lt;p&gt;If you have read this far you may have noticed a pattern. Each of these releases took a subsystem that the earlier Cure treated as decorative and made it load-bearing. FSMs stopped being a transition graph with a separately-authored runtime and became a language primitive one can actually define in one place. The dependent-type core stopped nodding politely at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Vector(T, m + n)&lt;/code&gt; and started checking it. The pattern engine stopped accepting whatever came past and started matching. The furniture release filled in the items one would notice the absence of but would not necessarily think to name: defaults, derive, property tests, a lazy iterator, a version parser, mutual-recursion totality. And the point release added the tool (Dialyzer) that would have prevented some of these embarrassments from landing in the first place.&lt;/p&gt;&lt;p&gt;There is still a long queue. Full bitstring pattern specifiers. Refinement narrowing through nested record and map patterns. The remote package-registry index service with its signing and Hex.pm cross-publishing. True dependent types in their full glory—&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fn len(l: Vector(_, n)) -&amp;gt; NonNegInt = n&lt;/code&gt; remains, for the moment, a thing I write in the v0.2x plan rather than in actual Cure. None of this will be done quickly. All of it will be done, if the last four releases are any indication, by ripping out the almost-works version and replacing it with one that does.&lt;/p&gt;&lt;p&gt;Clone it, build it, break it:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;pre&gt;&lt;code&gt;git clone https://github.com/am-kantox/cure-lang.git
cd cure
mix deps.get &amp;amp;&amp;amp; mix test
mix escript.build
./cure version
./cure run examples/destructuring.cure
./cure run examples/derived_show.cure
./cure run examples/lazy_iter.cure&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The repository is at &lt;a href=&quot;https://github.com/am-kantox/cure-lang&quot;&gt;github.com/am-kantox/cure-lang&lt;/a&gt;, the site is at &lt;a href=&quot;https://cure-lang.org&quot;&gt;cure-lang.org&lt;/a&gt;, and the furniture, as of v0.19.1, is indoors.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>SAFE: Bringing Real Static Analysis to the BEAM - Erlang Solutions</title>
<link>https://www.erlang-solutions.com/blog/safe-bringing-real-static-analysis-to-the-beam/</link>
<enclosure type="image/jpeg" length="0" url="https://www.erlang-solutions.com/wp-content/uploads/2026/04/SAFE-Bringing-Real-Static-Analysis-to-the-BEAM.png"></enclosure>
<guid isPermaLink="false">vwQg0k1lGSwbkxsGBf4aoBpY58iucyofhyWdsQ==</guid>
<pubDate>Sat, 18 Apr 2026 03:56:15 +0000</pubDate>
<description>Explore SAFE, Erlang Solutions’ BEAM analysis tool, and how data-flow analysis improves security detection with fewer false positives.</description>
<content:encoded>&lt;p&gt;In recent years, software security has become a hot topic due to regulatory pressure (NIS2, EU Cyber Resilience Act, etc.). Beyond regulations, software communities and open source maintainers have also put security into focus, because open source libraries are often part of commercial software supply chains. This has reached the Erlang/Elixir ecosystem as well, and that is a good thing.&lt;/p&gt;&lt;p&gt;In this post, the SAFE team takes us through SAFE, Erlang Solutions’ security analysis tool for the BEAM, covering what it does, how it works, and what makes it effective in practice.&lt;/p&gt;&lt;h1&gt;&lt;strong&gt;The BEAM is secure by default (Up to a point)&lt;/strong&gt;&lt;/h1&gt;&lt;p&gt;The BEAM’s architecture eliminates entire classes of bugs for free. Isolated process memory means processes can’t manipulate each other’s state, they only communicate through messages. Immutable data structures rule out a whole category of aliasing bugs. These are real wins, and they come without any effort from the developer.&lt;/p&gt;&lt;p&gt;But “secure by default” only goes so far. Application-level vulnerabilities, e.g., XSS, SQL injection, CSRF, unsafe deserialization, atom exhaustion, are just as possible in Erlang and Elixir as anywhere else. That’s the gap SAFE exists to cover.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;What is SAFE?&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;&lt;a href=&quot;https://www.erlang-solutions.com/services/security-audit-for-erlang-and-elixir/&quot;&gt;SAFE&lt;/a&gt; (Security Analysis for Erlang/Elixir) is Erlang Solutions’ static analysis tool for the BEAM. It analyses compiled BEAM files rather than source code, so it works consistently across Erlang, Elixir, and Phoenix, including mixed-language codebases. SAFE is free for open source projects (subject to approval) and commercially available for other use cases.&lt;/p&gt;&lt;p&gt;It is developed in collaboration with academic research on static analysis from &lt;a href=&quot;https://www.elte.hu/en&quot;&gt;Eötvös Loránd University&lt;/a&gt; , and are aligned with the security recommendations of the &lt;a href=&quot;https://security.erlef.org/&quot;&gt;Erlang Ecosystem Foundation&lt;/a&gt; (EEF).&lt;/p&gt;&lt;p&gt;SAFE detects a broad range of vulnerabilities, including:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;Cross-Site Scripting (XSS)&lt;/li&gt;



&lt;li&gt;SQL injection&lt;/li&gt;



&lt;li&gt;Command injection&lt;/li&gt;



&lt;li&gt;Remote Code Execution&lt;/li&gt;



&lt;li&gt;Denial of Service (e.g. atom exhaustion)&lt;/li&gt;



&lt;li&gt;Unsafe serialisation&lt;/li&gt;



&lt;li&gt;Cross-Site Request Forgery (CSRF)&lt;/li&gt;



&lt;li&gt;Session hijacking, fixation, and information leakage&lt;/li&gt;



&lt;li&gt;Content Security Policy (CSP) misconfigurations&lt;/li&gt;
&lt;/ul&gt;&lt;h2&gt;&lt;strong&gt;Data-flow analysis: the core of what makes SAFE different&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;The central feature that sets SAFE apart is &lt;strong&gt;data-flow analysis&lt;/strong&gt;. Most static analysis tools work by pattern matching, they look for known dangerous function calls and flag them. The problem is that not every call to a dangerous function is actually dangerous. Without understanding the possible values flowing through the code, a tool has no way to tell the difference, and the result is a high rate of false positives.&lt;/p&gt;&lt;p&gt;SAFE takes a different approach. Data-flow analysis tracks what values variables can hold at each point in the program. This information is then used to filter the initial list of vulnerability candidates, eliminating findings where the data can be proven safe, and surfacing only the ones that represent real risk.&lt;/p&gt;&lt;p&gt;The practical impact of this is significant. In our tests across 7 popular open source BEAM projects (~70,000 lines of code), SAFE produced a false positive rate of &lt;strong&gt;7.78%&lt;/strong&gt; and that number continues to improve as we refine our analysis. We also manually review findings during development to further sharpen the filtering.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;How data-flow analysis eliminates false positives&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Example 1 — guarded atom creation&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;A common pattern in Elixir is to convert a binary to an atom only after validating it against an allowlist:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;     
    def safe_to_atom(binary, allowed) do    
      if Enum.member?(allowed, binary), do: String.to_atom(binary)    
    end    
&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A pattern-matching tool sees &lt;code&gt;String.to_atom/1&lt;/code&gt; and flags it. SAFE’s data-flow analysis traces the possible values of&lt;code&gt;binary&lt;/code&gt;at the point of the call and determines that it is always a member of a finite, controlled list so it eliminates the finding entirely.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Example 2 — finite compile-time atom generation&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;Metaprogramming is common in Elixir. Consider this pattern where atoms are generated at compile time:&lt;/p&gt;&lt;div&gt;&lt;pre&gt; @variants [:case_a, :case_b, :case_c]    
    #    
    # ...    
    #    
    for var &amp;lt;- @variants do    
      defp unquote(var)() do    
        env = Application.get_env(:my_app, :environment)    
        if env == &amp;quot;test&amp;quot; do    
          unquote(Macro.escape(Module.get_attribute(__MODULE__, :&amp;quot;test_#{var}&amp;quot;)))    
        else    
          unquote(Macro.escape(Module.get_attribute(__MODULE__, var)))    
        end    
      end    
    end    
         
&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The number of atoms created here is strictly bounded by the length of &lt;code&gt;@variants&lt;/code&gt;, a compile-time constant. SAFE calculates this and correctly determines the atom count is finite hence no vulnerability. A tool without data-flow analysis cannot make this determination.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;What SAFE catches in practice&lt;/strong&gt;&lt;/h2&gt;&lt;h3&gt;&lt;strong&gt;Session management vulnerabilities&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Session management vulnerabilities allow attackers to gain unauthorised access to user sessions, which can lead to data theft, unauthorised actions, and account takeover. SAFE detects session hijacking, session fixation, and session information leakage. &lt;strong&gt;Session hijacking&lt;/strong&gt; occurs when an attacker gains access to cookie contents. To prevent this, the&lt;code&gt;http_only&lt;/code&gt; and &lt;code&gt;secure&lt;/code&gt; attributes should both be set to &lt;code&gt;true&lt;/code&gt; when setting a cookie. Below is a vulnerable example:&lt;/p&gt;&lt;div&gt;&lt;pre&gt; @spec set_cookie(Plug.Conn.t()) :: Plug.Conn.t()    
    def set_cookie(conn) do    
      Plug.Conn.put_resp_cookie(conn, &amp;quot;my_cookie&amp;quot;, &amp;quot;true&amp;quot;,    
        http_only: false,    
        max_age: @max_age # an integer    
      )    
    end    
         
&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Session fixation&lt;/strong&gt; is an attack where a malicious user plants a session ID for a victim to use, then hijacks their account after login. The &lt;code&gt;Plug.Session&lt;/code&gt; API provides the &lt;code&gt;configure_session/2&lt;/code&gt; function for renewing the session ID. When this function is misconfigured by setting the &lt;code&gt;renew&lt;/code&gt; option to &lt;code&gt;false&lt;/code&gt;, session fixation can occur.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Session information leakage&lt;/strong&gt; can be prevented by encrypting cookie contents. Encryption can be enabled by setting the &lt;code&gt;encryption_salt&lt;/code&gt;in &lt;code&gt;Plug.Session&lt;/code&gt;. For non-session cookies, encryption can be enabled via the &lt;code&gt;encrypt&lt;/code&gt; option in &lt;code&gt;Plug.Conn.put_resp_cookie/4&lt;/code&gt;.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Content Security Policy misconfigurations&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;When it comes to Content Security Policy (CSP), any policy is better than none. Using :&lt;code&gt;put_secure_browser_headers&lt;/code&gt; without a custom policy won’t be enough on its own:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;   plug :put_secure_browser_headers, %{    
      &amp;quot;content-security-policy&amp;quot;: &amp;quot;[Your Policy]&amp;quot;    
    }    
         
&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;There are also dedicated plugs for this, such as &lt;code&gt;PlugContentSecurityPolicy&lt;/code&gt;:&lt;/p&gt;&lt;div&gt;&lt;pre&gt;   plug PlugContentSecurityPolicy,    
&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;SAFE inspects the policy content itself and will flag an overly permissive default. You should define your own policy, keeping it as restrictive as possible to avoid unintentionally whitelisting too much. Beyond that, SAFE checks that all pipelines accepting HTML have CSP protection at all, a gap that is easy to miss during development.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Results from the field&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;The top three vulnerability types are XSS, DoS, and CSP. Notably, a large share of DoS findings trace back to unguarded &lt;code&gt;String.to_atom/1 calls&lt;/code&gt;, a known footgun that is consistently flagged in Erlang/Elixir documentation, yet still appears frequently in practice.&lt;/p&gt;&lt;p&gt;SAFE found a total of 90 vulnerabilities, of which 7 were false positives after manual investigation, a false positive ratio of 7.78%, and an area of active improvement.&lt;/p&gt;&lt;p&gt;The projects tested are anonymized to avoid identification, since the disclosed vulnerabilities may still be present in production systems. While this limits full reproducibility, responsible disclosure takes priority. Contact us at &lt;em&gt;safe@erlang-solutions.com &lt;/em&gt;to discuss the methodology in detail.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Closing notes&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Security analysis on the BEAM is a genuinely hard problem. The same flexibility that makes Erlang and Elixir so expressive also makes it easy to introduce subtle vulnerabilities without realising it. SAFE is built specifically for this environment, grounded in academic research, and designed to give you signals you can act on rather than noise you have to filter.&lt;/p&gt;&lt;p&gt;If you maintain an open-source project, SAFE is free: reach out to us at&lt;em&gt; safe@erlang-solutions.com &lt;/em&gt;and after a short approval process you’ll receive a licence at no cost. For commercial use or a third-party security review of your system, &lt;a href=&quot;https://www.erlang-solutions.com/contact/&quot;&gt;get in touch with the team.&lt;/a&gt;&lt;/p&gt;</content:encoded>
</item>
<item>
<title>The Long Road to Cure</title>
<link>https://rocket-science.ru/hacking/2026/04/16/long-way-to-cure</link>
<enclosure type="image/jpeg" length="0" url="https://rocket-science.ru/img/logo/logo-orig.png"></enclosure>
<guid isPermaLink="false">9Unqq7pTf_6Hvc4t1YKoeNTe4LperDBfq71MfQ==</guid>
<pubDate>Thu, 16 Apr 2026 09:19:20 +0000</pubDate>
<description>Just under a year ago I started building a BEAM language with dependent types and first-class FSMs—chose the wrong implementation language, wrote spaghetti, had an LLM tell me it was garbage, shipped something embarrassing, then spent months rethinking. Here is what I learned and where Cure stands today.</description>
<content:encoded>&lt;p&gt;Just under a year ago I set out to fulfil a long-standing ambition: building a language that compiles to BEAM and implements two capabilities essential to my daily work—dependent types resolved and verified at compile time, and verifiable finite state machines as a first-class language primitive.&lt;/p&gt;&lt;p&gt;I chose Erlang as the implementation language because it seemed to me that writing a compiler would be easier that way. I piled together a heap of attractive little baubles, many of which were added simply “because it is fun to have them.” Quite quickly I lost my way, and before long I lost control of the resulting spaghetti. I stopped understanding what was breaking and why whenever I added something that looked innocuous. I brought in an LLM, and the blasted thing informed me that the codebase had been authored by a drunk lumberjack with a primary-school education—someone who could not be trusted with anything more complex than bubble sort. Not in those exact words, but close enough.&lt;/p&gt;&lt;p&gt;That upset me. I lost my temper, demanded that the wretched language model knock together a presentation site in five minutes on the spot (without specifying any details whatsoever) and shipped it. The announcements, predictably, were received with polite indifference. People liked the ambition behind the idea (naturally!), but calling the implementation good was something not even my mother would have attempted.&lt;/p&gt;&lt;p&gt;I shelved the coding—and recoding—for several months and set about thinking about what I had done wrong.&lt;/p&gt;&lt;p&gt;First: over the last decade I had worked primarily with Elixir, and choosing Erlang had been a momentary, entirely unjustified whim. One can call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:compile.forms/2&lt;/code&gt; perfectly well from Elixir. Moreover, the ecosystem allowed me to abandon &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;make&lt;/code&gt;-files and a rather crude build setup.&lt;/p&gt;&lt;p&gt;Second: on my first approach to the apparatus I was trying to hunt ducks, hares, and wild boar simultaneously. There was nothing systematic about that approach—I just kept adding and adding new things, propping up the collapsing frame with sticks as I went. Adding a new operator could break module parsing entirely. In the current version I followed the plan strictly: better slow and coherent than fast and obscure.&lt;/p&gt;&lt;p&gt;And most importantly: in the first version I was in such a hurry that I repeated the mistake of almost every existing language—the AST was present as an annexe, a shed around the back. The lexer and parser could hand control directly to the compiler. That turned out to be the critical error.&lt;/p&gt;&lt;p&gt;Having reconsidered all this, I decided to start the rebuilding from the foundations. That is how the &lt;a href=&quot;https://hexdocs.pm/metastatic&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;metastatic&lt;/code&gt;&lt;/a&gt; library came into being. Put another way: I not only placed the AST at the centre of things but made it tower above every other entity in the system.&lt;/p&gt;&lt;blockquote&gt;
  &lt;p&gt;Metastatic is a library that provides a unified MetaAST (Meta-level Abstract Syntax Tree) intermediate representation for parsing, transforming, and analyzing code across multiple programming languages using a three-layer meta-model architecture.
Build tools once, apply them everywhere. Create a universal meta-model for program syntax that enables cross-language code analysis, transformation, and tooling. Metastatic provides the foundation—the MetaAST meta-model and language adapters. Tools that leverage this foundation (mutation testing, purity analysis, complexity metrics) are built separately.&lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;If you look closely, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MetaAST&lt;/code&gt; bears a strong resemblance to Elixir’s own AST, because there is simply no better way to represent a tree than &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{node, meta, children}&lt;/code&gt;. In any case, I invested considerable effort in creating and debugging this new AST. It became the foundation of my second attempt at a “dependently-typed programming language for the BEAM virtual machine with first-class finite state machines and SMT-backed verification,” as it reads on the landing page.&lt;/p&gt;&lt;p&gt;After that I opened the casket marked “things from the previous century,” pulled out an actual pen and a notebook, and wrote out all the features from the first version that I had managed to implement, however imperfectly. I grouped them by importance, utility, and complexity. At this first stage I decided to forgo support for true dependent types—&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fn len(l: Vector(_, n)) -&amp;gt; NonNegInt = n&lt;/code&gt;—but &lt;a href=&quot;https://cure-lang.org/type-system&quot;&gt;a great deal of interesting things survived&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;FSMs as first-class citizens are still in a fairly embryonic state, but I am in no hurry now, and I shall bring them to completion steadily and carefully.&lt;/p&gt;&lt;p&gt;Give &lt;a href=&quot;https://cure-lang.org&quot;&gt;https://cure-lang.org&lt;/a&gt; a try—perhaps you will like it.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Avoiding Platform Lock-In in Regulated Environments</title>
<link>https://www.erlang-solutions.com/blog/avoiding-platform-lock-in-in-regulated-environments/</link>
<guid isPermaLink="false">pu9AyKEbT4seK_YxzQZuyRmos0LRZzOzMyrwVw==</guid>
<pubDate>Mon, 13 Apr 2026 05:37:46 +0000</pubDate>
<description>Platform lock-in risks in regulated systems and how to avoid vendor dependency with scalable architecture. The post Avoiding Platform Lock-In in Regulated Environments appeared first on Erlang Solutions.</description>
<content:encoded>&lt;p&gt;&lt;strong&gt;Platform lock-in&lt;/strong&gt; is often discussed as a commercial issue. Organisations adopt infrastructure that works well initially and later realise that moving away from those services becomes expensive or operationally disruptive.&lt;/p&gt;&lt;p&gt;For platforms that run continuously under heavy demand, the consequences appear somewhere else first. They appear in architecture.&lt;/p&gt;&lt;p&gt;Infrastructure choices influence how systems scale, how faults are contained, and how easily the platform can evolve as requirements change. In regulated environments those decisions often remain in place for years, which means architectural flexibility matters as much as technical capability.&lt;/p&gt;&lt;p&gt;When infrastructure becomes tightly coupled to a particular provider, systems may still perform well day to day. The real impact usually surfaces later when workloads grow, regulations change, or operational expectations increase. At that point &lt;strong&gt;platform lock-in risks&lt;/strong&gt; begin to affect reliability as well as flexibility.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Why Platform Lock-in Matters in Regulated Environments&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;These architectural constraints become particularly visible in regulated industries where infrastructure decisions cannot be changed casually.&lt;/p&gt;&lt;p&gt;Financial services platforms must maintain traceable transactions and strict audit trails. Betting platforms process large volumes of activity during live sporting events. Streaming platforms deliver real-time content to global audiences who expect uninterrupted interaction.&lt;/p&gt;&lt;p&gt;Systems supporting these environments often remain active for long periods, which means infrastructure decisions made early in the system’s lifecycle can shape how the platform changes years later.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;The Operational Impact of Vendor Lock-in&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Many organisations already recognise the risks associated with vendor lock-in. The &lt;a href=&quot;https://www.flexera.com/blog/finops/cloud-computing-trends-flexera-2024-state-of-the-cloud-report/&quot;&gt;2024 Flexera State of the Cloud Report&lt;/a&gt; found that 89% of organisations now operate multi-cloud strategies, with reducing infrastructure dependency and avoiding vendor concentration cited as key motivations.&lt;/p&gt;&lt;p&gt;The concern goes beyond procurement strategy. When platforms rely heavily on provider-specific services for messaging, orchestration, or event processing, those dependencies begin shaping how the system behaves under load.&lt;/p&gt;&lt;p&gt;In regulated environments that dependency can become a reliability concern. Infrastructure decisions that once simplified development may later restrict how systems scale, evolve, or respond to operational change.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Distributed Systems Architecture and Long-Running Platforms&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;The reason &lt;strong&gt;platform lock-in&lt;/strong&gt; becomes particularly serious in regulated environments is tied to how many of these platforms operate: as long-running distributed systems.&lt;/p&gt;&lt;p&gt;Large-scale entertainment services rarely behave like short-lived workloads that restart frequently. Messaging layers, real-time interaction systems, and event pipelines maintain persistent connections while processing continuous streams of activity.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Why Long-Running Systems Behave Differently&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Gaming platforms illustrate this clearly. Competitive environments host thousands of players interacting simultaneously, all of whom expect consistent state across the system. Betting platforms experience similar behaviour during major sporting events when users react instantly to changing odds. Streaming platforms see comparable spikes as audiences interact during live broadcasts.&lt;/p&gt;&lt;p&gt;These platforms rely on &lt;strong&gt;distributed systems architecture&lt;/strong&gt; that must coordinate large numbers of connections and events while remaining continuously available.&lt;/p&gt;&lt;p&gt;Research published by &lt;a href=&quot;https://queue.acm.org/detail.cfm?id=3595878&quot;&gt;&lt;strong&gt;ACM Queue&lt;/strong&gt;&lt;/a&gt; examining large-scale distributed systems highlights how persistent connections and real-time workloads increase coordination pressure across system components, particularly during sudden spikes in concurrency.&lt;/p&gt;&lt;p&gt;When coordination layers rely heavily on platform-specific services, architectural dependency gradually builds. Over time the system begins to inherit those infrastructure constraints.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Reliability Requirements in High Reliability Systems&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Systems operating under these conditions often prioritise stability over rapid iteration. Platforms designed as &lt;strong&gt;high reliability systems&lt;/strong&gt; must remain available while managing constant traffic, evolving workloads, and unpredictable user behaviour.&lt;/p&gt;&lt;p&gt;Infrastructure decisions therefore have long-term consequences. When coordination, messaging, or state management rely on proprietary platform services, architectural flexibility narrows over time.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Why Gaming, Betting and Streaming Platforms Reveal Infrastructure Limits&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Systems built as long-running distributed environments face their toughest tests during moments of concentrated demand. Entertainment platforms provide a clear example.&lt;/p&gt;&lt;p&gt;Large audiences often react simultaneously. A football match entering extra time can trigger thousands of betting transactions within seconds. A major esports tournament can bring large numbers of players online at once. Streaming platforms experience bursts of interaction as viewers respond together during live broadcasts.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Traffic Spikes and Scalable Distributed Systems&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Systems supporting these environments must function as &lt;strong&gt;scalable distributed systems&lt;/strong&gt; capable of handling sudden increases in activity without losing consistency or responsiveness.&lt;/p&gt;&lt;p&gt;Instead of steady growth, activity often arrives in waves. Large numbers of users connect, interact, and generate events within very short timeframes. The system must coordinate these interactions across multiple nodes while maintaining reliable communication between services.&lt;/p&gt;&lt;p&gt;Infrastructure that appears sufficient under normal conditions can struggle during these spikes if the surrounding architecture relies too heavily on provider-specific services.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Real-World Example: BET Software&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;These architectural pressures are particularly visible in betting platforms where activity surges during live sporting events.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.erlang-solutions.com/case-studies/bet-software/&quot;&gt;BET Software&lt;/a&gt; operates large-scale betting technology platforms where thousands of users interact with markets simultaneously. During major sporting events systems must process rapid updates, recalculate market information, and distribute new data to users in real time.&lt;/p&gt;&lt;div&gt;&lt;figure&gt;&lt;img src=&quot;https://www.erlang-solutions.com/wp-content/uploads/2026/03/image-2.png&quot; alt=&quot;BET Software:  Avoiding platform lock-in in regulated environments

&quot; title=&quot;&quot;/&gt;&lt;/figure&gt;&lt;/div&gt;&lt;p&gt;Their distributed systems illustrate how reliability and responsiveness become essential in environments where activity concentrates around shared moments.&lt;/p&gt;&lt;p&gt;Architectures designed with flexibility across infrastructure layers tend to scale and recover more predictably than those tightly coupled to provider-specific services.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Architectural Patterns to Avoid Vendor Lock-in&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Recognising the risks of &lt;strong&gt;vendor lock-in&lt;/strong&gt; is useful only if it leads to better architectural decisions. Systems that remain adaptable across infrastructure layers often share several structural characteristics.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Decoupling Infrastructure Dependencies&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Architectures designed to &lt;strong&gt;avoid vendor lock-in&lt;/strong&gt; typically separate application logic from infrastructure services wherever possible. This allows teams to evolve system components independently without redesigning the entire system,&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Designing Fault Tolerant Systems&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Platforms that must operate continuously also benefit from architectures designed as &lt;strong&gt;fault tolerant systems&lt;/strong&gt;, where failures can be contained locally rather than cascading across the entire platform.&lt;/p&gt;&lt;p&gt;Common patterns include:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Decoupled services that scale independently&lt;/li&gt;&lt;li&gt;Communication through open protocols rather than proprietary messaging layers&lt;/li&gt;&lt;li&gt;Distributed state management instead of provider-specific coordination services&lt;/li&gt;&lt;li&gt;Horizontal scaling across nodes&lt;/li&gt;&lt;li&gt;Infrastructure abstraction layers separating application logic from provider-specific implementations&lt;/li&gt;&lt;li&gt;These approaches help ensure that infrastructure choices support the system rather than define its limitations.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;These patterns help ensure that infrastructure choices support the system rather than define its limitations.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Where Elixir Supports High Reliability Systems&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Technology choices also influence how easily distributed systems can maintain reliability while remaining adaptable.&lt;/p&gt;&lt;p&gt;Languages built on the Erlang virtual machine, including Elixir, were designed for environments where systems must remain available while handling large numbers of concurrent processes. The runtime emphasises process isolation and supervision structures that allow failures to be contained locally rather than cascading across the system.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Building Fault Tolerant Systems for Long-Running Platforms&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;These characteristics make the platform particularly well suited for &lt;strong&gt;high reliability systems&lt;/strong&gt; that must remain active while managing heavy concurrency.&lt;/p&gt;&lt;p&gt;The advantage lies in the runtime model rather than any single infrastructure provider. Systems built around resilient distributed behaviour are easier to evolve because they remain stable even as infrastructure decisions change around them.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Designing Systems That Reduce Platform Lock-in&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Looking across these examples reveals a consistent pattern.&lt;/p&gt;&lt;p&gt;Platform lock-in becomes most visible in systems that must operate continuously while adapting to changing demand. Regulated environments amplify the challenge because infrastructure decisions often remain in place for years while platforms continue to evolve.&lt;/p&gt;&lt;p&gt;Gaming, betting, and streaming services make these limits easier to see. Sudden spikes in activity quickly expose architectural weaknesses, and systems designed with flexible infrastructure tend to scale and recover more predictably.&lt;/p&gt;&lt;p&gt;If you are building platforms where reliability and long-running distributed workloads matter, it may be worth assessing how your architecture handles platform lock-in. To explore these challenges further,&lt;a href=&quot;https://www.erlang-solutions.com/contact/&quot;&gt; get in touch&lt;/a&gt; with the Erlang Solutions team.&lt;/p&gt;&lt;p&gt;The post &lt;a href=&quot;https://www.erlang-solutions.com/blog/avoiding-platform-lock-in-in-regulated-environments/&quot;&gt;Avoiding Platform Lock-In in Regulated Environments&lt;/a&gt; appeared first on &lt;a href=&quot;https://www.erlang-solutions.com&quot;&gt;Erlang Solutions&lt;/a&gt;.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Reliability is a Product Decision</title>
<link>https://www.erlang-solutions.com/blog/reliability-is-a-product-decision/</link>
<guid isPermaLink="false">u4Ho_YMqCrPC-AcCMiV_ZIiEkJUQsCi-th_kaA==</guid>
<pubDate>Mon, 13 Apr 2026 05:37:45 +0000</pubDate>
<description>Erik Schön explains why reliability in distributed systems is shaped by early architecture decisions, not just operational fixes. The post Reliability is a Product Decision appeared first on Erlang Solutions.</description>
<content:encoded>&lt;p&gt;Reliability is often treated as something that can be improved once a system is live. When things break, the focus shifts to monitoring, incident response, and recovery, with the belief that resilience can be strengthened over time as scale reveals weaknesses.&lt;/p&gt;&lt;p&gt;In reality, most of it is set much earlier.&lt;/p&gt;&lt;p&gt;Long before a system faces sustained demand, its underlying design has already shaped how it will respond under pressure. Choices about service boundaries, data handling, deployment models, and fault management influence whether a problem stays contained or spreads.&lt;/p&gt;&lt;p&gt;The conversation is gradually moving from reliability to resilience because distributed systems rarely operate without failure. The more useful question is how a platform continues running when parts of it inevitably fail. The sections that follow explore how early architectural decisions shape that outcome, why their impact becomes more visible at scale, and what it means to build resilience from the beginning rather than react to it later.&lt;/p&gt;&lt;h1&gt;&lt;strong&gt;Early Decisions Create Long-Term Behaviour&lt;/strong&gt;&lt;/h1&gt;&lt;p&gt;Large-scale failures rarely emerge without warning. What appears sudden at scale is often the predictable outcome of structural decisions made earlier, when different commercial pressures shaped priorities. &lt;/p&gt;&lt;p&gt;In the early stages of a product, the focus is understandably on delivering value quickly, reducing development friction, and validating the market. These are rational business decisions. However, architecture chosen primarily for speed can quietly define the operational ceiling of the system, setting limits that only become visible once demand increases.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Systems Behave as They Were Built to Behave&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Outages are often described as “unexpected events,” but distributed systems typically respond to pressure in ways that reflect their design. How services communicate, how state is shared, where dependencies sit, and how failure is managed all influence whether disruption remains contained within a single component or spreads across the wider platform.&lt;/p&gt;&lt;p&gt;Research from &lt;a href=&quot;https://sre.google/sre-book/introduction/&quot;&gt;Google’s Site Reliability Engineering&lt;/a&gt; work shows that around 70% of outages are caused by changes to a live system, such as configuration updates, deployments, or operational changes, rather than by hardware failures. Similarly, the &lt;a href=&quot;https://uptimeinstitute.com/resources/research-and-reports/annual-outage-analysis-2025&quot;&gt;Uptime Institute’s Annual Outage Analysis&lt;/a&gt; identifies configuration errors and dependency failures as leading causes of major disruption.&lt;/p&gt;&lt;p&gt;These findings are unsurprising. In distributed environments, dependencies increase and recovery paths become harder to trace, which means that architectural shortcuts that once seemed minor can have disproportionate impact under sustained load. Systems tend to fail along the structural lines already drawn into them, and those lines are shaped by early design decisions, even when those decisions were commercially sensible at the time.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Trade-offs That Compound Over Time&lt;/strong&gt;&lt;br/&gt;&lt;/h2&gt;&lt;p&gt;Architectural decisions are rarely made under ideal conditions. Early on, speed to market matters, simplicity reduces friction, and shipping is the priority. A tightly coupled service can help teams move faster, a single-region deployment keeps things straightforward, and limited observability may feel acceptable when traffic is still modest.&lt;/p&gt;&lt;p&gt;But overtime, these trade-offs compound.&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Limited isolation between services makes it easier for problems in one area to affect others.&lt;/li&gt;&lt;li&gt;Shared infrastructure can create hidden dependencies that only become visible under heavy demand.&lt;br/&gt;Concentrated regional deployments increase the impact of a local outage or cloud disruption.&lt;/li&gt;&lt;li&gt;Observability that felt sufficient at launch can fall short when trying to understand complex behaviour at scale.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;At a smaller scale, these constraints can go largely unnoticed. As usage increases and demand becomes less predictable, they start to shape how the system responds under pressure. What once felt manageable begins to show its limits.&lt;/p&gt;&lt;p&gt;This is rarely about a lack of technical ability. It is simply what happens as complexity builds over time. Every system reflects the trade-offs made in its early stages, whether those choices were deliberate or just practical at the time.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;When Architecture Becomes Business Exposure&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;As systems grow in scale and complexity, the way they are built starts to show up in practical ways. When services are tightly connected, recovery takes longer. When failures are not well contained, a problem in one area can disrupt others. Incidents become harder to resolve and more expensive to manage.&lt;/p&gt;&lt;p&gt;The cost of disruption is not abstract.&lt;a href=&quot;https://astecno.com.br/wp-content/uploads/2023/09/ITIC-2023-Global-Server-Hardware-Server-OS-Reliability-Report.pdf&quot;&gt; ITIC’s 2023 Hourly Cost of Downtime Survey &lt;/a&gt;reports that more than 90% of mid-size and large enterprises estimate a single hour of downtime costs over $300,000, and roughly 41% place that figure between $1 million and $5 million per hour. At that level, even short-lived incidents carry material financial impact.&lt;/p&gt;&lt;p&gt;For organisations that rely on digital platforms to generate revenue, those numbers represent missed transactions, operational strain, and damage to customer trust. At that point, system design is no longer just an engineering decision. It becomes a business decision with measurable financial consequences.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;When Failure Is Public&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Some systems fail quietly, disrupting internal workflows or back-office processes with limited external visibility. Others operate in real time, where performance issues are experienced directly by customers, investors, and partners.&lt;/p&gt;&lt;p&gt;In sectors such as entertainment, demand is often synchronised and predictable. Premieres, sporting events, ticket releases, and major launches concentrate traffic into specific windows, placing simultaneous pressure on application layers, databases, and third-party services. These moments are not unusual spikes; they are built into the operating model. Platforms designed for large-scale engagement are expected to handle peak demand as part of normal business activity.&lt;/p&gt;&lt;p&gt;That expectation changes the stakes. When performance degrades in these environments, it is noticed immediately and often publicly. Frustration spreads quickly, confidence can shift in hours, and what might have been an operational issue becomes a visible business problem.&lt;/p&gt;&lt;p&gt;In this context, resilience shapes whether a high-demand event reinforces confidence in the platform or exposes its limits. When failure is experienced directly by users, it moves beyond internal metrics and becomes part of the customer experience itself.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Designing for Resilience&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;If failure is inevitable in distributed systems, then resilience has to be built in from the start. It cannot be something added later when the first serious incident forces the issue.&lt;/p&gt;&lt;p&gt;Resilient systems are structured so that problems stay contained. A fault in one component should not automatically take others down with it, and services should be able to keep operating even when parts of the system are degraded. External dependencies will fail. Traffic will spike. The design needs to account for that reality.&lt;/p&gt;&lt;p&gt;This way of thinking shifts the focus. Instead of trying to prevent every possible issue, teams concentrate on limiting the impact when something goes wrong. Speed still matters, but so does the ability to grow without introducing instability.&lt;/p&gt;&lt;p&gt;Technology choices can support that approach.&lt;a href=&quot;https://www.erlang-solutions.com/technologies/elixir/&quot;&gt; Elixir programming language&lt;/a&gt;, running on the BEAM, was designed for environments where downtime had real consequences. Its structure reflects that:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Applications are made up of many small, independent processes rather than large, tightly connected components.&lt;/li&gt;&lt;li&gt;Failures are expected and handled locally.&lt;/li&gt;&lt;li&gt;Supervision and recovery are built into the runtime so the wider system keeps running.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;No language guarantees reliability, but tools built around fault tolerance make it easier to create systems that continue operating under pressure.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;To conclude&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;By the time serious issues appear at scale, most of the important decisions have already been made.&lt;/p&gt;&lt;p&gt;Failure is part of running distributed systems. What matters is whether problems stay contained and whether the platform keeps operating when something goes wrong.&lt;/p&gt;&lt;p&gt;Thinking about resilience early makes growth easier later. It helps protect revenue, maintain trust, and avoid the instability that forces costly redesigns.If you are building distributed platforms where reliability directly affects performance and reputation, now is the time to treat resilience as a core design decision. &lt;a href=&quot;https://www.erlang-solutions.com/contact/&quot;&gt;Get in touch&lt;/a&gt; to discuss how to build it into your architecture from the start.&lt;/p&gt;&lt;p&gt;The post &lt;a href=&quot;https://www.erlang-solutions.com/blog/reliability-is-a-product-decision/&quot;&gt;Reliability is a Product Decision&lt;/a&gt; appeared first on &lt;a href=&quot;https://www.erlang-solutions.com&quot;&gt;Erlang Solutions&lt;/a&gt;.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>The Always-On Economy: Fintech as Critical Infrastructure</title>
<link>https://www.erlang-solutions.com/blog/the-always-on-economy-fintech-as-critical-infrastructure/</link>
<guid isPermaLink="false">FfEF5xaKSjU2qDYk3ZhVmQxm2-H22qf4IpQ4zg==</guid>
<pubDate>Mon, 13 Apr 2026 05:37:45 +0000</pubDate>
<description>Erik Schön explains how the always-on economy is raising the bar for fintech resilience and scalability. The post The Always-On Economy: Fintech as Critical Infrastructure appeared first on Erlang Solutions.</description>
<content:encoded>&lt;p&gt;​​We are living in an economy that rarely sleeps. Payments clear late at night. Payroll runs in the background. Businesses expect every digital touchpoint to work when they need it.&lt;/p&gt;&lt;p&gt;Most people do not think about this shift. They assume the systems behind it will hold.&lt;/p&gt;&lt;p&gt;That assumption carries weight.&lt;/p&gt;&lt;p&gt;Fintechs, including small and mid-sized ones, now sit inside the basic infrastructure of how money moves. Their uptime affects cash flow. Their stability affects trust. When something breaks, real businesses feel it immediately.&lt;/p&gt;&lt;p&gt;Expectations changed faster than most systems did.This follows on from our earlier piece, &lt;a href=&quot;https://www.erlang-solutions.com/blog/from-prototype-to-production-scaling-fintech-for-smes/&quot;&gt;&lt;em&gt;From Prototype to Production&lt;/em&gt;&lt;/a&gt;, which explored how early technical shortcuts surface as systems scale. Here, we look at what happens next, when those systems become part of the infrastructure businesses rely on every day.&lt;/p&gt;&lt;h1&gt;&lt;strong&gt;When “tech downtime” becomes infrastructure failure&lt;/strong&gt;&lt;/h1&gt;&lt;p&gt;Outages no longer feel contained. A single failure can affect services that millions of people and businesses depend on.&lt;/p&gt;&lt;p&gt;Large providers experience this as much as small ones. When a shared platform falters, the impact spreads quickly. More than &lt;a href=&quot;https://www.networkworld.com/article/972102/10-things-to-know-about-data-center-outages.html#:~:text=Third%2Dparty%20providers%20are%20behind,is%20struck%2C%E2%80%9D%20Brown%20said.&quot;&gt;60 percent of outages&lt;/a&gt; now come from third-party providers rather than internal systems, highlighting how tightly connected the ecosystem has become.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;The true cost is trust&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;For fintechs, the impact is immediate because money is involved.&lt;/p&gt;&lt;p&gt;A payment delay blocks cash flow. A failed identity check stops onboarding. A stalled platform damages credibility.&lt;/p&gt;&lt;p&gt;For an SME, this can play out over the course of a single day. Payroll does not process in the morning. Supplier payments stall in the afternoon. Customer support queues fill up while teams wait for systems to recover. Even short interruptions create knock-on effects that last far longer than the outage itself.&lt;/p&gt;&lt;p&gt;And the numbers reflect that risk:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;a href=&quot;https://itic-corp.com/itic-2024-hourly-cost-of-downtime-part-2/&quot;&gt;£25,000&lt;/a&gt; is the average cost of downtime for SMEs.&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://www.cloudgateway.co.uk/knowledge-centre/articles/fintech-hidden-infrastructure-pain-points-that-drain-capital/&quot;&gt;40% &lt;/a&gt;of customers consider switching providers after a single outage.&lt;/li&gt;&lt;li&gt;Fintech revenue losses account for approximately &lt;a href=&quot;https://resolvepay.com/blog/statistics-indicating-api-downtimes-cost-to-finance-operations&quot;&gt;US$37 million&lt;/a&gt; of downtime-related costs each year.&lt;br/&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;At this level of dependency, downtime stops being a technical issue. It becomes an infrastructure failure.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Fintechs have become infrastructure whether they intended to or not&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Fintech services have moved beyond convenience. They now underpin everyday economic activity for businesses that depend on constant access to money, credit, and financial data.&lt;/p&gt;&lt;p&gt;This shift shows up in uptime expectations. Platforms that handle financial activity are measured against standards once reserved for mission-critical systems. Even brief disruption can have outsized consequences when services are expected to remain available throughout the day..&lt;/p&gt;&lt;p&gt;Customers do not adjust expectations based on company size or stage. If money flows through a service, users expect it to be available around the clock. When it is not, the failure feels systemic rather than technical. What breaks is not just functionality, but confidence.&lt;/p&gt;&lt;p&gt;That is the environment fintech leaders are operating in now.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Where resilience typically breaks down&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Most fintech systems do not fail because the idea was weak. They fail because early decisions prioritised speed over durability.&lt;/p&gt;&lt;p&gt;Teams optimise for launch. They prove demand. They ship quickly. That works early on, but systems designed for experimentation often struggle once demand becomes constant rather than occasional.&lt;/p&gt;&lt;p&gt;Shortcuts that felt harmless early on start to surface under pressure. Shared components become bottlenecks. Manual processes turn into operational risk. Integrations that worked at low volume become fragile at scale. Recent analysis shows average API uptime has fallen year over year, &lt;a href=&quot;https://www.uptrends.com/state-of-api-reliability-2025&quot;&gt;adding more than 18 hours of downtime &lt;/a&gt;annually for systems dependent on third-party APIs.&lt;br/&gt;&lt;a href=&quot;https://www.uptrends.com/state-of-api-reliability-2025?utm_source=chatgpt.com&quot;&gt; &lt;/a&gt;&lt;/p&gt;&lt;p&gt;Common pressure points include:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;Shared components that act as single points of failure&lt;br/&gt;&lt;/li&gt;&lt;li&gt;Manual operational work that cannot keep up with growth&lt;br/&gt;&lt;/li&gt;&lt;li&gt;Third-party dependencies with limited visibility or control&lt;br/&gt;&lt;/li&gt;&lt;li&gt;Architecture built for bursts of usage instead of continuous demand&lt;br/&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;These are not accidental outcomes. They are the result of trade-offs made under pressure. Funding milestones, launch timelines, and growth targets shape architecture as much as technical skill does.&lt;/p&gt;&lt;p&gt;When outages happen, they rarely trace back to a single bug. They trace back to earlier choices about what mattered and what could wait.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;From product thinking to infrastructure grade systems&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;Product thinking is about features and speed. Infrastructure thinking is about continuity.&lt;/p&gt;&lt;p&gt;Infrastructure-grade systems assume failure will happen. They are built to contain it, recover quickly, and keep the wider platform running. The goal is not perfection. The goal is staying available.&lt;/p&gt;&lt;p&gt;The goal is not perfection. The goal is staying available.&lt;/p&gt;&lt;p&gt;Continuous availability is now expected in financial services. Systems are updated and maintained without noticeable downtime because users do not tolerate interruptions when money is involved.&lt;/p&gt;&lt;p&gt;This approach does not slow teams down. It reduces risk. Deployments feel routine instead of stressful. Engineering effort shifts away from incident response and toward steady improvement.&lt;/p&gt;&lt;p&gt;Over time, this changes how organisations operate. Teams plan differently. Roadmaps become more realistic. Reliability becomes part of delivery rather than a separate concern.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;Elixir and the always-on economy&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;&lt;a href=&quot;https://www.erlang-solutions.com/technologies/elixir/&quot;&gt;Elixir programming language&lt;/a&gt; is designed for systems that are expected to stay available. It runs on the BEAM virtual machine, which was built in environments where downtime carried real consequences.&lt;/p&gt;&lt;p&gt;That background shows up in how Elixir applications are structured. Systems are composed of many small, isolated processes rather than large, tightly coupled components. When something fails, it fails locally. Recovery is expected. The wider system continues to operate.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Elixir in Fintech&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;This matters in fintech, where failure is inevitable and interruptions are costly. External services misbehave. Load changes without warning. Elixir applications are built to absorb those conditions and recover quickly without cascading outages.&lt;/p&gt;&lt;p&gt;Teams working in Elixir tend to spend less time managing fragile behaviour and more time improving core functionality. Systems evolve instead of being replaced. Reliability becomes part of the foundation rather than a promise teams struggle to maintain.&lt;/p&gt;&lt;p&gt;For fintechs operating in an always-on economy, that approach aligns with the expectations already placed on them.&lt;/p&gt;&lt;h3&gt;&lt;strong&gt;Reliability as a competitive advantage&lt;/strong&gt;&lt;/h3&gt;&lt;p&gt;Reliable systems can completely change how a fintech operates day to day.&lt;/p&gt;&lt;p&gt;For growing fintechs, uptime supports trust and regulatory confidence. Customers stay because the platform behaves predictably. Growth becomes steadier because teams are not constantly reacting to incidents. Downtime costs make this real, especially for small businesses that lose revenue when systems are unavailable.&lt;/p&gt;&lt;p&gt;For larger providers, reliability reduces operational strain. Fewer incidents mean fewer emergency fixes and fewer difficult conversations with partners and regulators. Teams spend more time improving core services and less time managing fallout.&lt;/p&gt;&lt;p&gt;Reliability also shapes perception. Platforms that stay up become easier to trust with deeper integrations and higher volumes. Over time, that trust compounds and turns stability into a real advantage, even if it is rarely visible from the outside.&lt;/p&gt;&lt;h2&gt;&lt;strong&gt;To conclude&lt;/strong&gt;&lt;/h2&gt;&lt;p&gt;The always-on economy creates real opportunity for fintechs, but it also raises expectations that many platforms were not originally built to meet.&lt;/p&gt;&lt;p&gt;The question is whether your system has the resilience to operate as infrastructure day after day. If you are a fintech and want to build with reliability in mind, &lt;a href=&quot;https://www.erlang-solutions.com/contact/&quot;&gt;get in touch&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;Designing for resilience early makes it far easier to scale without introducing fragility later on.&lt;br/&gt;&lt;/p&gt;&lt;p&gt;&lt;br/&gt;&lt;/p&gt;&lt;p&gt;The post &lt;a href=&quot;https://www.erlang-solutions.com/blog/the-always-on-economy-fintech-as-critical-infrastructure/&quot;&gt;The Always-On Economy: Fintech as Critical Infrastructure&lt;/a&gt; appeared first on &lt;a href=&quot;https://www.erlang-solutions.com&quot;&gt;Erlang Solutions&lt;/a&gt;.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Building a performance evaluation toolkit and a dataplane PoC for atproto</title>
<link>https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto</link>
<guid isPermaLink="false">-SKqIXPjXO-YHUAmfTvjtQPzL0jq7ouh15Xymw==</guid>
<pubDate>Mon, 13 Apr 2026 04:05:45 +0000</pubDate>
<description>There is a new open social ecosystem emerging around atproto.</description>
<content:encoded>&lt;p&gt;There is a new open social ecosystem emerging around atproto.
Never heard of it?
You should check it out, it&amp;#39;s &lt;a href=&quot;https://overreacted.io/open-social/&quot;&gt;cool tech&lt;/a&gt; with an &lt;a href=&quot;https://www.youtube.com/watch?v=1A-0k58TfPo&quot;&gt;ethos&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;The open system is a dream for builders: from day one you can tap into the social graph of more than 40 million Bluesky users.
Building on this userbase, you can create everyones new favorite social network on atproto.
It&amp;#39;s no surprise that there is already a number of alternative communities emerging: Northsky, Eurosky, Blacksky, to name a few.&lt;/p&gt;&lt;p&gt;This is a blog post though, so you already know there is a problem.&lt;/p&gt;&lt;h2&gt;The Bluesky dataplane&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#the-bluesky-dataplane&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Bluesky runs almost entirely on open sourced components which enables alternatives to get started quickly.&lt;/p&gt;&lt;p&gt;There is one notable exception: the part of the stack that processes the event stream of the atproto firehose, the AppView, needs one component to digest and store data.
This part is called dataplane, and it does the most heavy lifting in processing the event stream.
However, the open source dataplane implementation is not very performant.
Bluesky uses a closed source dataplane implementation in production for this reason.&lt;/p&gt;&lt;p&gt;The open source dataplane functions on fan-in principle:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;events are streamed into the system (e.g. &amp;quot;new post&amp;quot;)&lt;/li&gt;&lt;li&gt;the dataplane stores and indexes the events in ordinary Postgres tables&lt;/li&gt;&lt;li&gt;on user request, data is queried and presented to the user (fan-in)&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;This approach leads to the classic Twitter timeline problem, and in fact limits the user numbers that can safely be served to 100k users, often less.&lt;/p&gt;&lt;h2&gt;Fan-out to ETS dataplane&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#fan-out-to-ets-dataplane&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The trick to solve this problem is to change the approach.
Instead of querying the data on user request, we prepare data in a dedicated place for each user on write (fan-out).
When the user makes a request, the data is already sitting there, just waiting to be served to the user.&lt;/p&gt;&lt;p&gt;This is the also the approach followed by the Bluesky closed source dataplane which builds on ScyllaDB.
From all publicly available information, it seems to be pretty optimized to their hardware, and pretty expensive to run.&lt;/p&gt;&lt;p&gt;As you know, we are big fans of Elixir at bitcrowd, so we&amp;#39;ve built a Proof of Concept of a dataplane with &lt;a href=&quot;https://www.erlang.org/doc/apps/stdlib/ets.html&quot;&gt;ETS&lt;/a&gt; taking on the role of ScyllaDB.
The idea here is that we can build something that runs on commodity hardware but scales way better than Bluesky&amp;#39;s open source dataplane implementation.&lt;/p&gt;&lt;h2&gt;A peek into performance&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#a-peek-into-performance&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;To evaluate the PoC, we measured reading and writing directly to the dataplane.
We compared a Postgres backed version (representing Bluesky&amp;#39;s open source implementation) with an ETS backed version.&lt;/p&gt;&lt;p&gt;We simulated write traffic and let a number of users make requests to load their timeline (&lt;code&gt;get_timeline&lt;/code&gt;).
In the dashboards below, you can see the number of simultaneously active users (&amp;quot;Active Sessions&amp;quot;), the write traffic (&amp;quot;Posts created&amp;quot;), the latencies of &lt;code&gt;get_timeline&lt;/code&gt; requests, and the throughput of &lt;code&gt;get_timeline&lt;/code&gt; requests.&lt;/p&gt;&lt;p&gt;Under heavy load, the Postgres backed fan-in implementation showed significantly larger latencies and lower throughput in comparison to the ETS backed fan-out implementation.&lt;/p&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/Postgres-timeline-55b5a181aaa3c6f81e5d8eaf4cd205c9.png&quot; alt=&quot;Dashboard for Postgres backed fan-in implementation shows p95 latency at almost 1s at 105 get_timeline requests per second&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;Dashboard showing data for Postgres backed fan-in implementation&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure&gt;&lt;img src=&quot;https://bitcrowd.dev/assets/images/ETS-timeline-3679509482db499737014f46a5b96e09.png&quot; alt=&quot;Dashboard for ETS backed fan-out implementation shows p95 latency of less than 20ms at more than 200 get_timeline requests per second&quot; title=&quot;&quot;/&gt;&lt;figcaption&gt;Dashboard showing data for ETS backed fan-in implementation&lt;/figcaption&gt;&lt;/figure&gt;&lt;h2&gt;Advanced simulations&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#advanced-simulations&quot;&gt;​&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;We extended this PoC into a performance toolkit you can use to simulate traffic via atproto events and user requests.&lt;/p&gt;&lt;p&gt;As a basic principle, we want to be able to repeatedly simulate scenarios.
Therefore, you provide the configuration for the scenario as file that can be repeatedly loaded into the system.&lt;/p&gt;&lt;p&gt;We provide three components:&lt;/p&gt;&lt;h3&gt;Base data&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#base-data&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;You can quickly create base data, such as users, to prepare your application instance for the simulated scenario.
It makes a difference for your app&amp;#39;s performance whether you already have millions of users in your database or none.&lt;/p&gt;&lt;h3&gt;Traffic simulation&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#traffic-simulation&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Based on the simulation plan, we perform requests or emit events to create read and write traffic.&lt;/p&gt;&lt;h3&gt;Measuring performance&lt;a href=&quot;https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto#measuring-performance&quot;&gt;​&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Based on the simulated traffic, we can measure the performance users would experience.
For instance, how long would it take for their timeline to load?&lt;/p&gt;</content:encoded>
</item>
</channel>
</rss>
