Inside the RubyGems Compact Index: How bundle install Finds Gems, and What's Changing

Open on YouTube ↗
Overview

Jenny Shen, a senior developer at Shopify based in Ottawa and a maintainer and operator of rubygems.org, gave the last talk before the closing keynote at Rails World. The subject was the RubyGems compact index, the system Bundler queries whenever it needs to resolve dependencies. Few people in the audience raised their hands when Shen asked who had heard of it. Shen's case for discussing it at a Rails conference was that installing gems is a core part of running a Rails application. The team found that the compact index sat at the center of recent work on performance, reliability, and security for that process.

12 min read

The talk had three parts: how the index works internally and how Bundler talks to it, two recent changes (dependency cooldowns and content-addressable gems), and ideas the team is considering next.

0:59

A Magic Trick as a Metaphor

Shen opened with a small magic trick. Michelle, the host, was asked which major Rails version they had first developed on. The answer was "four." Michelle then pulled a card with a four on it out of Shen's pocket. Shen explained that the trick relied on a "Quiver compact index," a wallet that holds up to 16 cards and lets you pull out a specific one quickly. The commercial version had sold out online, so Shen built a homemade "V2."

Shen also mentioned a compact index algorithm from finance, which reduces hundreds or thousands of stocks to a handful while preserving diversification. The common thread is compressing and organizing a lot of information so it can be retrieved quickly or used simply. The RubyGems compact index does the same job for Bundler: it compresses information about more than 200,000 gems on rubygems.org so Bundler can resolve dependencies.

5:53

The Three Endpoints

Shen said the compact index runs on three endpoints and serves more than 50 million requests per day. The endpoints are info, versions, and names. Bundler uses only the first two.

Bundler has to re-resolve and consult the index in three situations: there is no Gemfile.lock, the Gemfile has changed, or some dependencies are not available locally. Its first step is to make sure it has an up-to-date list of gems, which comes from the versions endpoint. Each line in that file lists a gem name, the versions available, and an "info checksum" that Bundler uses later.

The index is also stored on your machine in Bundler's cache directory, and Bundler tries to keep the local copy in sync with the server. To update the local versions file, Bundler sends a request with an ETag. The server returns only the new bytes, meaning the newly added or updated entries. In Shen's example, one gem's line gets updated locally this way.

Next, Bundler needs more detail about each gem to decide whether it can be installed. That detail includes transitive dependencies and their version constraints, Ruby and RubyGems requirements, and the gem checksum. The checksum matters if you store checksums in your lockfile and Bundler needs to compare against them. The info endpoint provides all of this, with one row per version listing the dependencies and the requirements, including the checksum. Info files are cached locally too. To decide whether a cached info file is stale, Bundler hashes its contents and compares the result with the info checksum from the versions file. If they match, Bundler skips the refetch. If they differ, it downloads the new info file.

9:04

What Happens When a Gem Is Pushed or Yanked

On the server side, pushing a gem to RubyGems triggers background jobs that update the versions file and the gem's info file. Both are written to an AWS bucket. Shen explained the bucket's role: the CDN checks each incoming request, and compact index requests are redirected to the bucket and served from it. If the file doesn't exist or something is wrong with the bucket, the CDN falls back to the application server.

A push appends a new line to the versions file and updates the info file. A yank also adds a line to the versions file, one that subtracts the version, and removes that version from the info file. Many pushes and yanks would make the versions file grow large, so a monthly rake task consolidates each gem's redundant lines into a single line.

10:57

Dependency Cooldowns

The first new feature Shen covered is security-related: dependency cooldowns. If you set a cooldown of, say, seven days, Bundler installs a new gem version only after it has been public for seven days. A Rails release published today would not be installable under that setting until a week had passed.

Shen's motivation was a recent rise in package takeover attacks, where an attacker gains control of a maintainer's account and pushes a malicious version. Shen attributed part of that rise to AI making account takeovers easier. The incident that "inspired it all," in Shen's words, happened earlier this year in the npm ecosystem, when Axios was compromised: someone took over the maintainer's account and pushed a malicious release. A cooldown gives scanners time to detect malicious versions, and gives maintainers time to yank them, before they reach most users. Shen noted that PyPI and npm had quickly added cooldowns to their tooling and said RubyGems should follow.

12:39

Adding Timestamps to the Index

To enforce a cooldown, Bundler has to know when each version was published, and the info file had no timestamps. The compact index spec allows additional entries in each row's requirements section, so adding a created_at value there was simple in principle.

The difficult part was operational. All the info files live in the AWS bucket, so supporting the new field meant regenerating about 200,000 files, one per gem. The team compared approaches and chose a blue-green migration. Shen described the steps:

  1. The CDN kept pointing at the current index.
  2. Every new push and yank updated both the current index and a new copy under a v2 subdirectory.
  3. The info files for all ~200,000 gems were backfilled into the new directory.
  4. When the backfill finished, the CDN was switched to serve the new directory.

The deciding factor, Shen said, was reversibility. If something went wrong, the team could point the CDN back at the existing version.

The compact index now includes creation timestamps, and HSBT added the cooldown feature to Bundler. You can pass a cooldown flag to install commands, set it in your configuration, or declare it in your Gemfile. Shen pointed the audience to a blog post with more details.

14:56

The Performance Problem: Native Extensions

Shen's second topic was performance. Shen held up uv, the Python package manager, as an example of how fast package installation can be and said RubyGems should keep pace. The team found that more than 70% of bundle install time goes to compiling native extensions.

Native extensions are C or Rust code that some gems include for performance, and that code must be compiled before use. During bundle install, Bundler runs the compilation script specified in the gemspec.

The existing remedy is precompiled gems. Shen used Nokogiri as the example: it publishes separate builds for each platform, and each platform build contains binaries for several Ruby ABI versions. Shen explained that the ABI (application binary interface) changes with each minor Ruby version because internal data structures change, so a binary compiled for one Ruby version won't work on another. Each version needs its own compiled binary.

More precompiled gems mean faster installs, but Shen described several drawbacks of the current format:

  • Precompiled gems can be large, because one gem file bundles binaries for multiple ABIs, while most users run only one Ruby version and need only one of them.
  • Maintainers have to write code in their gem to load the correct binary at runtime.
  • When a new Ruby version comes out, an existing gem release can't be updated to add a binary for the new ABI.

Publishing a separate gem for each ABI would produce smaller gems, faster downloads, and less configuration for maintainers. It would also let maintainers add support for new Ruby ABIs after a release.

18:04

Content-Addressable Gems

The team added exactly that capability under the name content-addressable gems. Shen credited the original idea to Aaron Patterson. A content-addressable gem is a precompiled gem scoped to a single Ruby ABI.

The naming had to change. Today a precompiled gem's name carries a platform suffix and the gem contains multiple ABIs. A content-addressable gem instead carries a SHA suffix and contains a single ABI. Shen said the rename was required because the current scheme can't publish several ABI-specific builds of the same version without the names colliding.

In the compact index, the slot that used to hold the platform now holds a suffix equal to the first eight characters of the checksum. The platform moves into the requirements section, so Bundler still gets that information, and the Ruby requirement is narrowed to a single Ruby ABI.

One caveat came with the rename: older Bundler clients interpret the new suffix as a platform and will crash if they try to install such a gem. The team therefore made content-addressable gems visible only to new clients by adding a required RubyGems version to their requirements, which excludes older clients.

The change has landed in rubygems.org and in the RubyGems and Bundler clients. Initial support ships in RubyGems 4.1 beta, which Shen said had been released a week or two before the talk.

20:21

What It Means for Maintainers and Users

Maintainers of gems with native extensions can push content-addressable gems using the Ruby ABI option, specifying the required Ruby version and required RubyGems version. The team is updating build tooling to support this. A pull request is open against rake-compiler, and Shen hoped that within a few weeks people who use rake-compiler to precompile their gems could try publishing content-addressable gems with it.

Shen also asked maintainers whose gems have native extensions but no precompiled builds to start precompiling. Shen recommended trying a cibuildgem created by Edouard, which generates a GitHub workflow that precompiles the gem for each platform and pushes the builds to rubygems.org.

For users, Bundler prefers content-addressable gems over the legacy multi-ABI versions when both exist. They appear in a new section of Gemfile.lock.

21:59

Measured Impact

To estimate the impact, Shen pushed single-ABI versions of every precompiled gem in a new Rails application's bundle. Shen reported that this cut the bundle's total download size by about 20% and called that result "okay." Shen said the larger payoff lies elsewhere: more than a dozen gems in that bundle have native extensions but aren't precompiled, so the bigger savings will come as those gems adopt precompilation.

22:29

Future Ideas

Shen closed with ideas that have not been built.

The first is security warnings. Shen would like Bundler to tell you when you're using a vulnerable version of a gem, possibly by adding advisory information to the compact index.

The second is more requirements on precompiled gems to guarantee compatibility, starting with the Ruby engine. Shen described a current RubyGems bug: on an alternative implementation such as TruffleRuby, binaries built for CRuby can get installed. Recording the engine in the index would prevent that and would make it possible to publish binaries for other engines.

24:02

Closing

Shen asked attendees to turn on cooldowns and to test the RubyGems beta releases, because finding bugs now gets them fixed before the stable release. Shen pointed to the release page for details on the new features, said blog posts on them are coming, including one from Shen on content-addressable gems, and thanked the people who worked on the project.