<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://blog.mogery.me/</id>
    <title>mogery's blog</title>
    <updated>2026-04-13T21:29:51.574Z</updated>
    <generator>mogery's blog</generator>
    <author>
        <name>mogery</name>
        <email>mo.geryy@gmail.com</email>
        <uri>https://mogery.me</uri>
    </author>
    <link rel="alternate" href="https://blog.mogery.me/"/>
    <link rel="self" href="https://blog.mogery.me/atom.xml"/>
    <rights>mogery's blog by mogery is licensed under CC BY-NC-SA 4.0</rights>
    <entry>
        <title type="html"><![CDATA[Egy Szabad Magyarországról / On a Free Hungary]]></title>
        <id>https://blog.mogery.me/a-free-hungary</id>
        <link href="https://blog.mogery.me/a-free-hungary"/>
        <updated>2026-04-13T22:00:00.000Z</updated>
        <content type="html"><![CDATA[<p><em>EN: An english version of this post is below the dividing line.</em></p>
<p>Életemben először büszke lehetek arra, hogy magyar vagyok. Ez egy elképesztő érzés.</p>
<p>Megszabadultunk azoktól, akik kisajátították a magyarságot. Megszabadultunk azoktól, akik vizuális szennyezéssel ellepték az ország összes reklámfelületét, hogy egymásnak ugrasszanak egy egész népet. Megszabadultunk azoktól, akik miatt nagyszüleink rekordmagas vérnyomással, frontint szedve rettegtek. Megszabadultunk azoktól, akik hagyták, hogy honfitársaink péneszes, rodhadó kórtermekben haljanak meg kórházi fertőzésekben. Megszabadultunk azoktól, akik szégyent hoztak Magyarországra és a magyarságra.</p>
<p>Az ünneplés után egy dolgot tilos elfelejtenünk. Akiket megválasztottunk, azokat azért választottuk meg, hogy közszolgák legyenek. Azért választottuk meg, hogy megvalósítsák, amit megígértek. Ezeknek az ígéreteknek a betartatása a magyar társadalom jellemhibája. Ha ezt a következő években nem tartatjuk be, azt soha nem tudnánk megbocsátani magunknak. Ha hagyjuk, hogy a leköszönő rendszer bármelyik bűne megbocsátva legyen, azt SOHA NEM TUDNÁNK MEGBOCSÁTANI MAGUNKNAK.</p>
<p>A munkának nincs itt vége, hanem csak most kezdődik. Nagyon sok berögződést kell kiölnünk magunkból, és nagyon sok új tudást kell elsajátítanunk, hogy egy demokratikus társadalommá váljunk, és az is maradjunk.</p>
<p>Boldog rendszerváltást mindenkinek.</p>
<hr />

<p>For the first time in my life, I feel proud to be a Hungarian. It&#39;s an incredible feeling.</p>
<p>We&#39;ve rid ourselves of those who&#39;ve stolen what it means to be Hungarian. We&#39;ve rid ourselves of those who took over all our billboards with visual pollution, in order to turn a whole nation against one another. We&#39;ve rid ourselves of those who terrified our record-high-blood-pressure-having, benzo-popping grandparents. We&#39;ve rid ourselves of those who let our people die of hospital infections in modly, rotting wards. We&#39;ve rid ourselves of those who brought shame to Hungary, and to being Hungarian.</p>
<p>After the celebration, we must not forget one thing. Those we elected were elected to be public servants. We elected them to deliver on their promises. The failure to enforce these promises is a character flaw of the Hungarian society. If we do not enforce these promises in the coming years, we will never be able to forgive ourselves. If we allow any of the crimes of the outgoing system to be forgiven, WE WILL NEVER BE ABLE TO FORGIVE OURSELVES.</p>
<p>The work doesn&#39;t end here, it&#39;s only now beginning. We have to unlearn a lot of old habits and learn a lot of new knowledge to become and remain a democratic society.</p>
<p>Happy regime change to everyone.</p>
]]></content>
        <author>
            <name>mogery</name>
            <email>mo.geryy@gmail.com</email>
            <uri>https://mogery.me</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Spotify on the Nintendo Switch]]></title>
        <id>https://blog.mogery.me/90spot-repost</id>
        <link href="https://blog.mogery.me/90spot-repost"/>
        <updated>2024-09-22T14:58:00.000Z</updated>
        <content type="html"><![CDATA[<p>About 2 years ago I built a Spotify demo for the Nintendo Switch.</p>
<video controls style="width: 100%">
    <source src="/media/90spot.mp4">
</video>

<p>This is basically a reimplementation of <a href="https://github.com/librespot-org/librespot">librespot</a> in C, adapted to work with the Nintendo Switch&#39;s network stack.</p>
<p>I&#39;ve always wanted to make a writeup or give a talk about how horrid the Spotify protocol is. (It&#39;s really janky, and more security by obscurity than anything.) Maybe I will someday.</p>
<p>Anyways, I&#39;ve never had the time or motivation to do anything with it since then. I don&#39;t own a Switch anymore, I don&#39;t use Spotify anymore, so on. Here&#39;s the <a href="https://github.com/mogery/90spot">source code</a> if you wish to do something with it.</p>
]]></content>
        <author>
            <name>mogery</name>
            <email>mo.geryy@gmail.com</email>
            <uri>https://mogery.me</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[pure-CSS warioware]]></title>
        <id>https://blog.mogery.me/pure-css-warioware</id>
        <link href="https://blog.mogery.me/pure-css-warioware"/>
        <updated>2024-09-17T19:28:00.000Z</updated>
        <content type="html"><![CDATA[<p>ported <a href="https://cohost.org/mog/post/15769-warioware">one of my cohost classics</a> to this format. this is a CSS-only implementation of a WarioWare minigame.</p>
<p>this may SUCK if you&#39;re using an RSS reader. sorry. you will also need to do a cache clear refresh to retry if you&#39;re on firefox.</p>
<style>
@keyframes bounce {
    0%, 100% {
      transform: translateY(-25%);
      animation-timing-function:cubic-bezier(0.8, 0, 1, 1)
    }

    50% {
      transform: none;
      animation-timing-function:cubic-bezier(0, 0, 0.2, 1)
    }
  }
</style>

<div style="background-color: white; margin: 16px; position: relative; color: black; font-family: Atkinson Hyperlegible, ui-sans-serif, system-ui, sans-serif; font-size: 16px; line-height: 28px;">
<details>
<summary style="cursor: pointer; width: 240px; height: 160px; padding: 15px; border-radius: 5px; border: 1px solid black; display: block; justify-content: center; align-items: center; text-align: center; list-style: none; color: transparent;" tabindex="0">
<div style="display: flex; width: 100%; height: 100%; justify-content: center; align-items: center; color: black;">
click here to play WarioWare<br>(click to catch the mug)
</div>
</summary>
<div>
<details>
<summary style="width: 240px; height: 160px; position: absolute; left: 0.75rem; top: -1rem; cursor: pointer; user-select: none; list-style: none; display: block;" tabindex="-1"><img src="/media/warioware-failure.gif" style="width: 240px; height: 160px; z-index: 199;"></summary>
<div style="display: block; cursor: pointer; position: absolute; left: 0.75rem; top: 1rem; width: 240px; height: 160px; background-color: transparent; z-index: 1;">
</div>
<p style="position: absolute; left: calc(240px + 1rem); top: -0.5rem; font-weight: bold;">too early/late!</p>
</details>
<details>
<summary style="display: block; cursor: pointer; position: absolute; left: 0.75rem; top: calc(-480px + 1rem); width: 240px; height: calc(640px); opacity: 0.5; background: transparent; animation: 1.5s linear 1.5s bounce; transform: translateY(-25%); user-select: none;" tabindex="-1"></summary>
<img src="/media/warioware-success.gif" style="width: 240px; height: 160px; position: absolute; left: 0.75rem; top: -1rem; cursor: pointer; user-select: none; z-index: 200;">
<p style="position: absolute; left: calc(240px + 1rem); top: -0.5rem; font-weight: bold;">right on!</p>
</details>
<div style="position: absolute; left: 0.75rem; top: 0px; width: 240px; height: 1rem; background: white; user-select: none;"></div>
</div></details>
</div>

<h2>wait how does this work tho</h2>
<p>it uses <code>&lt;details&gt;</code> tags which allow for collapsible content. here&#39;s an example:</p>
<pre><code class="hljs language-html"><span class="hljs-tag">&lt;<span class="hljs-name">details</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">summary</span>&gt;</span>hello<span class="hljs-tag">&lt;/<span class="hljs-name">summary</span>&gt;</span>
here&#x27;s my content
<span class="hljs-tag">&lt;/<span class="hljs-name">details</span>&gt;</span>
</code></pre><p>this becomes:</p>
<details>
<summary>hello</summary>
here's my content
</details>

<p>first, you click the top-level summary tag. that reveals the first gif, which plays the whole sequence as if you failed. it has two details tags inside:</p>
<ul>
<li>one which&#39;s summary is always in front of the gif. it reveals the &quot;too early/late!&quot; text on the right side if you click it.</li>
<li>one which&#39;s summary bounces in front of the gif when you&#39;re supposed to click the gif. it reveals the good ending image and the &quot;right on!&quot; text.</li>
</ul>
<p>how does it bounce in though? CSS animations. mostly abusing the <code>bounce</code> keyframes that came with cohost css.</p>
<video controls style="border: 1px white solid; border-radius: 8px;">
<source src="/media/warioware-demo.webm">
</video>

<p>if you want anything more in depth, be my guest to right click and inspect elements.</p>
]]></content>
        <author>
            <name>mogery</name>
            <email>mo.geryy@gmail.com</email>
            <uri>https://mogery.me</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Handling 300k requests per day: an adventure in scaling]]></title>
        <id>https://blog.mogery.me/firecrawl-scaling-1</id>
        <link href="https://blog.mogery.me/firecrawl-scaling-1"/>
        <updated>2024-09-17T11:48:00.000Z</updated>
        <content type="html"><![CDATA[<p><em>(This was originally posted on the <a href="https://firecrawl.dev/blog/an-adventure-in-scaling">Firecrawl blog</a>)</em></p>
<p>When I joined the Firecrawl team in early July, we spent most of our time working on new features and minor bugfixes. Life was good — we could focus mostly on shipping shiny new stuff without worrying as much about architecture and server load. However, as we grew over time, we started experiencing the &quot;hug of death&quot; a lot more. People loved our product so much that our architecture couldn&#39;t take it anymore, and every day there was a brand new fire to put out. We knew that this was unsustainable, and ultimately it damages our DX more than any new feature we could put out could make up for. We knew we had to change things, stat.</p>
<h2>Our architecture, before the storm</h2>
<p>We host our API service on <a href="https://fly.io/">Fly.io</a>, which allows us to easily deploy our code in a Docker container. It also manages load balancing, log collection, zero-downtime deployment strategies, VPC management, and a whole load of other stuff for us, which is very useful.</p>
<p>Our main API service has two kinds of &quot;processes&quot;, as Fly calls it: <code>app</code> and <code>worker</code>.</p>
<p><code>app</code> processes use Express to serve the main API, perform scrape requests (which take a relatively short time), and delegate crawls to <code>worker</code> processes using the <a href="https://github.com/OptimalBits/bull">Bull</a> job queue.</p>
<p><code>worker</code> processes register themselves as workers on the job queue, and perform crawls (which take a relatively long time).</p>
<p>Both processes use Supabase to handle authentication and store data in Postgres. Bull also runs on top of Redis, which we deployed on <a href="https://railway.app">Railway</a>, since it&#39;s super easy to use.</p>
<h2>Locks are hard</h2>
<p>As more and more people started using us, more and more people started finding bugs. We started getting odd issues with crawls sometimes being stuck for hours without any progress. I charted the timing of these crawls, and I saw that it was happening every time we redeployed.</p>
<p>Due to some miscellaneous memory leak issues, we were redeploying our entire service every 2 hours via GitHub Actions, in order to essentially restart all our machines. This killed all our workers, which had acquired locks for these crawl jobs. I was not too familiar with the codebase at this point, and I thought that these locks got hard-stuck on the dead workers, so I to add some code to release all of the current worker&#39;s locks on termination.</p>
<p>This ended up being really complicated, due to multiple factors:</p>
<ol>
<li>Other libraries we used also had cleanup code on <code>SIGTERM</code>. When you listen to <code>SIGTERM</code>, your app doesn&#39;t actually quit until the handler calls <code>process.exit()</code>. So, the other library&#39;s handler called <code>process.exit()</code> when its handler finished, which caused a race condition with our cleanup handler. (This was absolute hell to debug.)</li>
<li>Fly.io sometimes didn&#39;t respect our configuration, and hard-<code>SIGKILL</code>ed our application before the 30 second timeout we specified our config. This cut our cleanup code short.</li>
<li>There was no easy way to remove a lock via the Bull API. The only legitimate way it could be done was to:<ol>
<li>Get all in-progress jobs of this worker</li>
<li>Set their status to failed</li>
<li>Delete them from the queue</li>
<li>Re-insert them to the queue</li>
</ol>
</li>
<li>While the cleanup code was running, there was no easy way to disable the current worker, so sometimes jobs the cleanup code re-inserted were immediately picked up by the same worker that was about to be shut down.</li>
<li>Due to our rollover deployment strategy, during a deployment, the re-inserted jobs were picked up by workers that have not been updated yet. This caused all the jobs to be piled up on the last worker to be updated, which caused the cleanup code to run longer than Fly&#39;s maximum process shutdown timeout.</li>
</ol>
<p>While I was going down a rabbithole that was spiraling out of control, Thomas (another Firecrawl engineer who mainly works on <a href="https://www.firecrawl.dev/blog/introducing-fire-engine-for-firecrawl">Fire-Engine</a>, which used a similar architecture) discovered that our queue lock options were grossly misconfigured:</p>
<pre><code class="hljs language-typescript">webScraperQueue = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Queue</span>(<span class="hljs-string">&#x27;web-scraper&#x27;</span>, process.<span class="hljs-property">env</span>.<span class="hljs-property">REDIS_URL</span>, {
  <span class="hljs-attr">settings</span>: {
    <span class="hljs-attr">lockDuration</span>: <span class="hljs-number">2</span> * <span class="hljs-number">60</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>, <span class="hljs-comment">// 2 hours in milliseconds</span>
    <span class="hljs-attr">lockRenewTime</span>: <span class="hljs-number">30</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span> <span class="hljs-comment">// 30 minutes in milliseconds</span>
  }
});
</code></pre><p>This was originally written with the understanding that <code>lockDuration</code> would be the maximum amount of time a job could take — which is not true. When a worker stops renewing the lock every <code>lockRenewTime</code> milliseconds, <code>lockDuration</code> specifies the amount of time to wait before declaring the job as <code>stalled</code> and giving it to another worker. This was causing the crawls to be locked up for 2 hours, similar to what our customers were reporting.</p>
<p>After I got rid of all my super-complex cleanup code, the fix ended up being this:</p>
<pre><code class="hljs language-typescript">webScraperQueue = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Queue</span>(<span class="hljs-string">&#x27;web-scraper&#x27;</span>, process.<span class="hljs-property">env</span>.<span class="hljs-property">REDIS_URL</span>, {
  <span class="hljs-attr">settings</span>: {
    <span class="hljs-attr">lockDuration</span>: <span class="hljs-number">2</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>, <span class="hljs-comment">// 1 minute in milliseconds</span>
    <span class="hljs-attr">lockRenewTime</span>: <span class="hljs-number">15</span> * <span class="hljs-number">1000</span> <span class="hljs-comment">// 15 seconds in milliseconds</span>
  }
});
</code></pre><p>Thank you Thomas for spotting that one and keeping me from going off the deep end!</p>
<h2>Scaling scrape requests, the easy way</h2>
<p>As you might have noticed in the architecture description, we were running scrape requests on the <code>app</code> process, the same one that serves our API. We were just starting a scrape in the <code>/v0/scrape</code> endpoint handler, and returning the results. This is simple to build, but it isn&#39;t sustainable.</p>
<p>We had no idea how many scrape requests we were running and when, there was no way to retry failed scrape requests, we had no data source to scale the <code>app</code> process on (other than are we down or not), and we had to scrape Express along with it. We needed to move scraping to our <code>worker</code> process.</p>
<p>We ended up choosing to just add scrape jobs to the same queue as crawling jobs. This way the <code>app</code> submitted the job, the <code>worker</code> completed it, and the <code>app</code> waited for it to be done and returned the data. <a href="https://blog.taskforce.sh/do-not-wait-for-your-jobs-to-complete/">We read the old advice about &quot;never wait for jobs to finish&quot;</a>, but we decided to cautiously ignore it, since it would have ruined the amazing simplicity that the scrape endpoint has.</p>
<p>This ended up <a href="https://github.com/mendableai/firecrawl/commit/6798695ee4daf1ce1b289db494d260d718b6752b#diff-6753e371514e1d188e797436080479e7c781d96183601ab8fa203e4df6ca0400">being surprisingly simple</a>, only slightly affected by Bull&#39;s odd API. We had to add a global event handler to check if the job had completed, since it lacked the <a href="https://api.docs.bullmq.io/classes/v5.Job.html#waitUntilFinished"><code>Job.waitUntilFinished</code></a> function that its successor <a href="https://github.com/taskforcesh/bullmq">BullMQ</a> already had.</p>
<p>We saw a huge drop in weird behaviour on our <code>app</code> machines, and we were able to scale them down in exchange for more <code>worker</code> machines, making us way faster.</p>
<h2>Smaller is better</h2>
<p>The redeploy crawl fiasco made us worried about handling big crawls. We could essentially 2x the time a big crawl ran if it was caught in the middle of a redeploy, which is sub-optimal. Some of our workers were also crashing with an OOM error when working on large crawls. We instead decided to break crawls down to individual scrape jobs that chain together and spawn new jobs when they find new URLs.</p>
<p>We decided to make every job in the queue have a scrape type. Scrape jobs that are associated with crawls have an extra bit of metadata tying them to the crawlId. This crawlId refers to some redis keys that coordinate the crawling process.</p>
<p>The crawl itself has some basic data including the origin URL, the team associated with the request, the robots.txt file, and others:</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> <span class="hljs-title class_">StoredCrawl</span> = {
  <span class="hljs-attr">originUrl</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">crawlerOptions</span>: <span class="hljs-built_in">any</span>;
  <span class="hljs-attr">pageOptions</span>: <span class="hljs-built_in">any</span>;
  <span class="hljs-attr">team_id</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">plan</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">robots</span>?: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">cancelled</span>?: <span class="hljs-built_in">boolean</span>;
  <span class="hljs-attr">createdAt</span>: <span class="hljs-built_in">number</span>;
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">saveCrawl</span>(<span class="hljs-params"><span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">crawl</span>: <span class="hljs-title class_">StoredCrawl</span></span>) {
  <span class="hljs-keyword">await</span> redisConnection.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;crawl:&#x27;</span> + id, <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(crawl));
  <span class="hljs-keyword">await</span> redisConnection.<span class="hljs-title function_">expire</span>(<span class="hljs-string">&#x27;crawl:&#x27;</span> + id, <span class="hljs-number">24</span> * <span class="hljs-number">60</span> * <span class="hljs-number">60</span>, <span class="hljs-string">&#x27;NX&#x27;</span>);
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getCrawl</span>(<span class="hljs-params"><span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">StoredCrawl</span> | <span class="hljs-literal">null</span>&gt; {
  <span class="hljs-keyword">const</span> x = <span class="hljs-keyword">await</span> redisConnection.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;crawl:&#x27;</span> + id);

  <span class="hljs-keyword">if</span> (x === <span class="hljs-literal">null</span>) {
    <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  }

  <span class="hljs-keyword">return</span> <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(x);
}
</code></pre><p>We also make heavy use of Redis sets to determine which URLs have been already visited when discovering new pages. The Redis <code>SADD</code> command adds a new element to a set. Since sets can only store unique values, it returns 1 or 0 based on whether the element was added or not. (The element does not get added if it was already in the set before.) We use this as a lock mechanism, to make sure two workers don&#39;t discover the same URL at the same time and add two jobs for them.</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">lockURL</span>(<span class="hljs-params"><span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">boolean</span>&gt; {
  <span class="hljs-comment">// [...]</span>
  <span class="hljs-keyword">const</span> res =
    (<span class="hljs-keyword">await</span> redisConnection.<span class="hljs-title function_">sadd</span>(<span class="hljs-string">&#x27;crawl:&#x27;</span> + id + <span class="hljs-string">&#x27;:visited&#x27;</span>, url)) !== <span class="hljs-number">0</span>;
  <span class="hljs-comment">// [...]</span>
  <span class="hljs-keyword">return</span> res;
}

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">onURLDiscovered</span>(<span class="hljs-params"><span class="hljs-attr">crawl</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span></span>) {
  <span class="hljs-keyword">if</span> (<span class="hljs-keyword">await</span> <span class="hljs-title function_">lockURL</span>(crawl, url)) {
    <span class="hljs-comment">// we are the first ones to discover this URL</span>
    <span class="hljs-keyword">await</span> <span class="hljs-title function_">addScrapeJob</span>(<span class="hljs-comment">/* ... */</span>); <span class="hljs-comment">// add new job for this URL</span>
  }
}
</code></pre><p>You can take a look at the whole Redis logic around orchestrating crawls <a href="https://github.com/mendableai/firecrawl/blob/main/apps/api/src/lib/crawl-redis.ts">here</a>.</p>
<p>With this change, we saw a huge performance improvement on crawls. This change also allowed us to perform multiple scrape requests of one crawl at the same time, while the old crawler had no scrape concurrency. We were able to stretch a crawl over all of our machines, maximizing the worth we get for each machine we pay for.</p>
<h2>Goodbye Bull, hello BullMQ</h2>
<p>Every time we encountered Bull, we were slapped in the face by how much better BullMQ was. It had a better API, new features, and the most important thing of all: active maintenance. We decided to make the endeavour to switch over to it, first on Fire-Engine, and then on Firecrawl.</p>
<p>With this change, we were able to drop the horrible code for <a href="https://github.com/mendableai/firecrawl/blob/6798695ee4daf1ce1b289db494d260d718b6752b/apps/api/src/controllers/scrape.ts#L59-L89">waiting for a job to complete</a>, and replace it all with <code>job.waitUntilFinished()</code>. We were also able to customize our workers to add Sentry instrumentation (more on that later), and to take on jobs based on CPU and RAM usage, instead of a useless max concurrency constant that we had to use with Bull.</p>
<p>BullMQ still has its API quirks (e.g. don&#39;t you dare call <code>Job.moveToCompleted</code> / <code>Job.moveToFailed</code> with the 3rd argument not set to <code>false</code>, otherwise you will check out and lock a job that will be returned to you that you&#39;re probably dropping)</p>
<h2>Our egress fee horror story</h2>
<p>Our changes made us super scalable, but they also meant that a lot more traffic was going through Redis. We ended up racking up a 15000$ bill on Railway in August, mostly on Redis egress fees only. This wasn&#39;t sustainable, and we needed to switch quickly.</p>
<p>After being disappointed with Upstash, and having issues with Dragonfly, we found a way to deploy Redis to Fly.io natively. <a href="https://github.com/mendableai/firecrawl/blob/f7c4cee404e17b3ed201e005185a5041009d0e6f/apps/redis/fly.toml">We put our own spin on the config</a>, and deployed it to our account. However, we were not able to reach the instance from the public IP using <code>redis-cli</code> (netcat worked though?!?!), which caused some confusion.</p>
<p>We decided to go another way and use Fly&#39;s <a href="https://fly.io/docs/networking/private-networking/">Private Networking</a>, which provides a direct connection to a Fly app/machine without any load balancer being in front. We crafted a connection string, SSH&#39;d into one of our worker machines, installed <code>redis-cli</code>, tried to connect, and... it worked! We had a reachable, stable Redis instance in front of us.</p>
<p>So, we went to change the environment variable to the fancy new Fly.io Redis, we deployed the application, and... we crashed. After a quick revert, we noticed that <a href="https://github.com/redis/ioredis">IORedis</a> wasn&#39;t able to connect to the Redis instance, but <code>redis-cli</code> stilled worked fine. So... what gives?</p>
<p>Turns out, <code>ioredis</code> only performs a lookup for an IPv4 address, unless you specify <code>?family=6</code>, in which case it only performs a lookup for an IPv6 address. This is not documented anywhere, except in a couple of GitHub issues which are hard to search for. I have been coding for almost 11 years now, and this is the worst configuration quirk I have ever seen. (And I use Nix daily!) In 2024, it would be saner to look for IPv6 by default instead of IPv4. Why not look for both? This is incomprehensible to me.</p>
<p>Anyways, after appending <code>?family=6</code> to the string, everything worked, except, sometimes not...</p>
<h2>Awaiting forever</h2>
<p>We started having huge waves of scrape timeouts. After a bit of investigation, the <code>Job.waitUntilFinished()</code> Promise never returned, but after looking at our BullMQ dashboard, we saw that jobs were actually being completed.</p>
<p>BullMQ uses Redis streams for all of its event firing/handling code, including <code>waitUntilFinished</code>, which waits until the job&#39;s <code>finished</code> event fires. BullMQ enforces a maximum length for the event stream, in order to purge old events that have presumably already been handled, and it defaults to about 10000 maximum events. Under heavy load, our queue was firing so many events, that BullMQ was trimming events before they could be processed. This caused everything that depends on queue events to fail.</p>
<p>This maximum events parameter is configurable, however, it seems like a parameter that we&#39;d have to babysit, and it&#39;s way too cryptic and too easy to forget about. Instead, we opted to rewrite the small amount of code that uses queue events to do polling instead, which is not affected by pub/sub issues like this.</p>
<p>Inexplicably, this never happened on the old Railway Redis instance, but it happened on every alternative we tried (including Upstash and Dragonfly). We&#39;re still not sure why we didn&#39;t run into this issue earlier, and BullMQ queue events still work happily on the Fire-Engine side under Dragonfly.</p>
<h2>Adding monitoring</h2>
<p>We were growing tired of going through console logs to diagnose things. We were also worried about how many issues we could potentially be missing. So, we decided to integrate <a href="https://sentry.io">Sentry</a> for error and performance monitoring, because I had some great experiences with it in the past.</p>
<p>The moment we added it, we found about 10 high-impact bugs that we had no idea about. I fixed them the day after. We also had an insight into what our services were actively doing — I was able to add custom instrumentation to BullMQ, and pass trace IDs over to Fire-Engine, so now we can view the entire process a scrape or crawl goes through until it finishes, all organized in one place.</p>
<p><img src="/media/firecrawl-scaling-1-sentry.png" alt="scaling-sentry"></p>
<p>(The creation of this image for this post lead me to <a href="https://github.com/mendableai/firecrawl/commit/000a316cc362b935976ac47b73ec02923f4175c5">decrease the time Firecrawl spends after Fire-Engine is already finished</a>. Thanks, Sentry!)</p>
<p>Sentry has been immensely useful in finding errors, debugging incidents, and improving performance. There is no longer a chance that we have an issue invisibly choking us. With Sentry we see everything that could be going wrong.</p>
<h2>The future</h2>
<p>We are currently stable. I was on-call last weekend and I forgot about it. The phone never rang. It felt very weird after putting out fires for so long, but our investment absolutely paid off. It allowed us to do <a href="https://firecrawl.dev/blog/firecrawl-launch-week-1-recap">our launch week</a>, which would not have been possible if we were in panic mode 24/7. It has also allowed our customers to build with confidence, as the increased reliabilty adds another layer of greatness to Firecrawl.</p>
<p>However, there are still things we&#39;re unhappy with. Fly, while very useful early-stage, doesn&#39;t let us smoothly autoscale. We are currently setting up Kubernetes to give us more control over our scaling.</p>
<p>I love making Firecrawl better, be it with features or with added reliability. We&#39;re in a good place right now, but I&#39;m sure there will be a lot more adventures with scaling in the future. I hope this post has been useful, since surprisingly few people talk about all this stuff. (We sure had trouble finding resources when we were trying to fix things.) I will likely be back with a part 2 when there&#39;s more exciting things to talk about.</p>
]]></content>
        <author>
            <name>mogery</name>
            <email>mo.geryy@gmail.com</email>
            <uri>https://mogery.me</uri>
        </author>
    </entry>
</feed>