<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
      <title>Viv&#x27;s blog</title>
      <link>https://vivax.dev</link>
      <description>Viv&#x27;s blog about software engineering</description>
      <generator>Zola</generator>
      <language>en</language>
      <atom:link href="https://vivax.dev/rss.xml" rel="self" type="application/rss+xml"/>
      <lastBuildDate>Sat, 28 Mar 2026 00:00:00 +0000</lastBuildDate>
      <item>
          <title>Caching, the most leaky abstraction.</title>
          <pubDate>Sat, 28 Mar 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://vivax.dev/blog/ci-caching/</link>
          <guid>https://vivax.dev/blog/ci-caching/</guid>
          <description xml:base="https://vivax.dev/blog/ci-caching/">&lt;p&gt;I have been writing complex integration suites for my projects for a while, the most complex by far being the one for &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;serpent-Tools&#x2F;natrix&quot;&gt;Natrix&lt;&#x2F;a&gt;, which involves running around 4 different kinds of integration tests, alongside two unit test suites, and a whole range of linters.&lt;&#x2F;p&gt;
&lt;p&gt;And through all of that I jumped between a few different tools for this, raw github actions, dagger, and eventually writing my own.
There were many ergonomics issues and similar with the previous tools that didn&#x27;t work for my kind of integration tests, but the thing that was ultimately the biggest limiting factor was caching, especially in github runners.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;caching-should-be-invisible&quot;&gt;Caching should be invisible.&lt;&#x2F;h2&gt;
&lt;p&gt;This is, in my opinion, one of the most important aspects of a good cache. The only signs that a cache was used should be lower execution time, and less log output. There should be no way to tell that the cache was used based on the final output. And it&#x27;s at this point a lot of edge cases pop up, but we won&#x27;t get into them here.&lt;&#x2F;p&gt;
&lt;p&gt;If you in your user code of a system ever have to write &lt;code&gt;if is_cached(): ...&lt;&#x2F;code&gt; something has gone very wrong with the cache design in said system.
As I said before, at its core caching is an optimization, and optimizations shouldn&#x27;t affect semantics.&lt;&#x2F;p&gt;
&lt;p&gt;Naturally this stuff can never be fully perfect, for example docker assumes any step can be cached, but ultimately &lt;code&gt;apt install ...&lt;&#x2F;code&gt; can&#x27;t be correctly cached because it talks to an external system, in general stuff that talks to external systems is harder to cache correctly, but in general we bite the bullet and do so anyway because in most cases it&#x27;s okay, especially if you pin versions. In other words making caches invisible isn&#x27;t just on the cache implementation itself but also on its users to write code that is able to be cached.&lt;&#x2F;p&gt;
&lt;blockquote class=&quot;markdown-alert-note&quot;&gt;
&lt;p&gt;I am speaking from the perspective of most caches one touches during development work, in other domains this constraint might not hold.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;h3 id=&quot;what-should-we-cache&quot;&gt;What should we cache?&lt;&#x2F;h3&gt;
&lt;p&gt;In general this is a question that has been solved before, if you are looking at your CI and wondering what to cache, a good first bet is to cache the caches.
What I mean is, most build systems maintain caches of artifacts built to avoid work, you certainly notice this locally. So a good first candidate for caching in your CI, is your build system&#x27;s caches, because those are already designed to be persisted between runs.
And here is a very important abstraction boundary to consider when it comes to caching in CI, which comes in two flavors.&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Caching info so that you can explicitly skip steps on cache hit.&lt;&#x2F;li&gt;
&lt;li&gt;Caching a tools cache so it can skip steps automatically for you.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The second one is almost always better, because you can then entrust a widely tested tool to have figured out the cache invalidation for you, your job is just persisting its cache between runs. This is the path cargo, npm, docker, lend themselves to.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;caching-in-github-actions&quot;&gt;Caching in GitHub Actions&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.github.com&#x2F;en&#x2F;actions&quot;&gt;GitHub Actions&lt;&#x2F;a&gt; are maybe one of the most popular CI platforms, and as such it naturally has a caching story.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;actions-cache&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;actions&#x2F;cache&quot;&gt;&lt;code&gt;actions&#x2F;cache&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;&lt;&#x2F;h3&gt;
&lt;p&gt;GitHub&#x27;s official cache action is amazing for caching caches like we talked about above, this action lets you very easily store and restore a given folder or file, which is exactly what you need for caching build caches. It&#x27;s easy to use, and when given a good cache key will mostly just work.
So whenever what you need caching is nicely exposed as a well documented folder or file the solution is simple and easy.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;swatinem-rust-cache&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;Swatinem&#x2F;rust-cache&quot;&gt;&lt;code&gt;swatinem&#x2F;rust-cache&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;&lt;&#x2F;h3&gt;
&lt;p&gt;Now sometimes it&#x27;s nice to have abstractions that hide what folders and files to hide, and use domain knowledge to make the cache smaller. For example this rust cache strips out unneeded data from &lt;code&gt;.&#x2F;target&lt;&#x2F;code&gt;, but at its core it&#x27;s still just caching a folder or file, simple easy.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;caching-docker-layers&quot;&gt;Caching docker layers?&lt;&#x2F;h2&gt;
&lt;p&gt;What if your CI builds docker images? Well as you might know docker does cache layers, but where?
Well docker stores its metadata in a few internal places, and trying to cache these would be a losing battle, luckily docker uses buildkit, which does expose some &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.docker.com&#x2F;build&#x2F;cache&#x2F;backends&#x2F;&quot;&gt;options&lt;&#x2F;a&gt; here:&lt;&#x2F;p&gt;
&lt;h3 id=&quot;local-files&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.docker.com&#x2F;build&#x2F;cache&#x2F;backends&#x2F;local&#x2F;&quot;&gt;Local Files&lt;&#x2F;a&gt;&lt;&#x2F;h3&gt;
&lt;p&gt;Docker supports exporting its cache to a local file, or more correctly &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.docker.com&#x2F;build&#x2F;buildkit&#x2F;&quot;&gt;buildkit&lt;&#x2F;a&gt; does, and docker exposes wrapper options for it.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;font-style: italic;&quot;&gt;docker&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; buildx build --push -t&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt; &amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;registr&lt;&#x2F;span&gt;&lt;span&gt;y&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;&#x2F;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;imag&lt;&#x2F;span&gt;&lt;span&gt;e&lt;&#x2F;span&gt;&lt;span style=&quot;color: #94E2D5;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F5C2E7;&quot;&gt; \&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;  --cache-to type=local,dest=path&#x2F;to&#x2F;local&#x2F;dir[,parameters...]&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F5C2E7;&quot;&gt; \&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt;  --cache-from type=local,src=path&#x2F;to&#x2F;local&#x2F;dir .&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;&#x2F;blockquote&gt;
&lt;p&gt;And now you can use &lt;code&gt;actions&#x2F;cache&lt;&#x2F;code&gt; to save and restore this folder to gain the benefit of dockers layer caching in your CI, or in any CI for that matter.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;github-actions-cache&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.docker.com&#x2F;build&#x2F;cache&#x2F;backends&#x2F;gha&#x2F;&quot;&gt;GitHub Actions Cache&lt;&#x2F;a&gt;&lt;&#x2F;h3&gt;
&lt;p&gt;Buildkit&#x2F;docker also supports talking to the GitHub Actions cache directly, this is easiest done via the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;docker&#x2F;build-push-action&quot;&gt;docker&#x2F;build-push-action&lt;&#x2F;a&gt; action as it will ensure the proper GitHub tokens are set in the docker arguments etc.
This is more efficient because instead of needing to save and restore the entire cache to and from the local runner, this action will lazily pull layer data as it gets cache hits, and only upload the new layers.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dagger&quot;&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;dagger.io&#x2F;&quot;&gt;Dagger&lt;&#x2F;a&gt;&lt;&#x2F;h2&gt;
&lt;p&gt;Now GitHub Actions is all fine and good, until you want to run CI locally as well, here we have some interesting tools, one I really enjoyed for a while was Dagger. Its programming based interface for docker was really nice to work with, and let me do some stuff I had been wanting to express in docker files for a while. Eventually I ran into some UX issues that drove me away, but their core tool is really unique and cool!&lt;&#x2F;p&gt;
&lt;p&gt;Now let&#x27;s look at their caching story, at its core it&#x27;s essentially the same caching system as buildkit and docker, because well dagger uses buildkit internally. Locally all works well, now since it&#x27;s based on buildkit in theory the CI caching story should be the same!&lt;&#x2F;p&gt;
&lt;p&gt;To quote their &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.dagger.io&#x2F;getting-started&#x2F;ci-integrations&#x2F;github-actions&quot;&gt;docs&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Dagger has also partnered with Depot to provide managed, Dagger Powered GitHub Actions runners. These runners, which serve as drop-in replacements for GitHub&#x27;s own runners, come with Dagger pre-installed and pre-configured to best practices, &lt;em&gt;automatic persistent layer caching&lt;&#x2F;em&gt;, and multi-architecture support. They make it faster and easier to run Dagger pipelines in GitHub repositories.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Oh, well I guess they need to make money somehow, that&#x27;s fair! Let&#x27;s just find where they talk about gha or local file based caches so we can make this work on GitHub runners... okay nothing in the main docs, let&#x27;s check the docs for &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;dagger&#x2F;dagger-for-github&quot;&gt;dagger&#x2F;dagger-for-github&lt;&#x2F;a&gt;, hmm nothing there. Okay for these, apparently very niche use cases, GitHub issues is the goto! Aha! found it, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;dagger&#x2F;dagger-for-github&#x2F;issues&#x2F;39&quot;&gt;#39&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;jpadams closed this as not plannedon Feb 13, 2023&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Oh... well I guess moving on then.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-about-my-tool&quot;&gt;How about my tool?&lt;&#x2F;h2&gt;
&lt;p&gt;I am developing my own workflow runner called &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;Serpent-Tools&#x2F;serpentine&quot;&gt;Serpentine&lt;&#x2F;a&gt;, let&#x27;s take a look at how it handles caching in CI.&lt;&#x2F;p&gt;
&lt;p&gt;By default serpentine stores a &lt;em&gt;non-portable&lt;&#x2F;em&gt; cache in the platforms default cache directory, okay so no just slapping &lt;code&gt;actions&#x2F;cache&lt;&#x2F;code&gt; on it right away, but it does expose some cli arguments to make the cache portable between systems, &lt;code&gt;--standalone-cache&lt;&#x2F;code&gt;, and naturally also a flag to control the cache location, now we can &lt;code&gt;actions&#x2F;cache&lt;&#x2F;code&gt; it!&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #CDD6F4; background-color: #1E1E2E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #89B4FA;font-style: italic;&quot;&gt;serpentine&lt;&#x2F;span&gt;&lt;span style=&quot;color: #A6E3A1;&quot;&gt; run --cache &#x2F;tmp&#x2F;serpentine.cache --standalone-cache --ci&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;blockquote class=&quot;markdown-alert-note&quot;&gt;
&lt;p&gt;The reason serpentine&#x27;s cache isn&#x27;t portable by default is that it&#x27;s actually storing most of its data in a &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;containerd.io&#x2F;&quot;&gt;containerd&lt;&#x2F;a&gt; daemon, what the standalone flag does is instruct serpentine to export this data to the cache file, which on local only runs would be wasted time.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;h2 id=&quot;so-whats-the-hardest-thing-in-cs&quot;&gt;So whats the hardest thing in CS?&lt;&#x2F;h2&gt;
&lt;p&gt;Honestly cache invalidation is hard, I won&#x27;t deny that, but tools keep getting it right, what we do see big tools still fail at is the user facing &lt;em&gt;cache api design&lt;&#x2F;em&gt;. Caching is an optimization, and if your optimization requires user collaboration it better be easy and clear what they need to do. &lt;code&gt;cargo&lt;&#x2F;code&gt; just asks you to save a target directory, &lt;code&gt;npm&lt;&#x2F;code&gt; asks you to save &lt;code&gt;node_modules&lt;&#x2F;code&gt;, and &lt;code&gt;dagger&lt;&#x2F;code&gt; just asks you to please pay them.&lt;&#x2F;p&gt;
&lt;h1 id=&quot;further-reading&quot;&gt;Further reading&lt;&#x2F;h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Cache_(computing)&quot;&gt;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Cache_(computing)&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;docs.docker.com&#x2F;build&#x2F;cache&#x2F;&quot;&gt;https:&#x2F;&#x2F;docs.docker.com&#x2F;build&#x2F;cache&#x2F;&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
    </channel>
</rss>
