Viewing public RFDs.
RFD 634
Git ref files for Dropshot versioned APIs
RFD
634
Authors
Updated

[rfd421] and [rfd532] introduced versioning for Dropshot HTTP APIs, enabling automated system software updates [rfd418]. As part of this, we’ve built a Dropshot API manager which stores OpenAPI documents corresponding to each version of an API within a directory. This document introduces Git ref files and describes how they solve a set of issues related to versioned OpenAPI document storage and diffing.

Motivation

As part of the self-service update work, we introduced a distinction between lockstep and versioned APIs. For the purpose of this RFD, we’re focused on versioned APIs.

Versioned API

A versioned API is one where not all clients are expected to be at the same version, typically because an automated update is in progress. Unlike a lockstep API, which is stored as a single file, a versioned API is stored as a directory of files, one per version. Once a version of an API is integrated into main, it is considered blessed: an immutable source of truth.

For example, the Sled Agent API is a versioned API, since a newer version of Sled Agent might have older versions of Nexus and other system components as clients. The Dropshot API manager stores each version of the Sled Agent API as a separate JSON file, and also manages a latest symlink pointing to the latest version.

% tree openapi/sled-agent
openapi/sled-agent
├── sled-agent-10.0.0-898597.json
├── sled-agent-1.0.0-2da304.json
├── sled-agent-11.0.0-5f3d9f.json
├── sled-agent-12.0.0-ffacab.json
├── sled-agent-2.0.0-a3e161.json
├── sled-agent-3.0.0-f44f77.json
├── sled-agent-4.0.0-fd6727.json
├── sled-agent-5.0.0-253577.json
├── sled-agent-6.0.0-d37dd7.json
├── sled-agent-7.0.0-62acb3.json
├── sled-agent-8.0.0-0e6bcf.json
├── sled-agent-9.0.0-12ab86.json
└── sled-agent-latest.json -> sled-agent-12.0.0-ffacab.json

The [progenitor] client for Sled Agent refers to the latest symlink, so that when a new version of the Sled Agent API is added, the in-tree client automatically picks up the update.

Issues with versioned API storage

New API versions result in large diffs

When a new version of a versioned API is added, Git detects it as a brand new file. For example, Omicron PR #9434 added version 12 of the Sled Agent API. Though the actual diff between versions 11 and 12 was small, GitHub displayed over 9,000 added lines for the new sled-agent-12.0.0-ffacab.json file. GitHub’s web UI could not detect that the new Sled Agent API version was a modification of the previous version.

Even simple doc comment updates result in extremely large files: Omicron PR #9623 changes just a few lines of formatting, but results in a diff of approximately 30,000 lines.

This issue has two important consequences: it is hard to review the changes in that API; and the number of lines changed becomes misleading.

For more about why Git cannot detect new versions of APIs as copies of previous versions, see [why-no-git-copy-detection].

Blame on versioned API documents is not meaningful

It is sometimes useful to be able to run blame (typically via the GitHub blame view) on an OpenAPI document, particularly to be able to tell when a new type was added or changed. For lockstep APIs, blame is functional. But for versioned APIs, since Git treats each version of an API as a brand new file, blame is not meaningful.

As an alternative, it is possible to use blame on the .rs files that act as the source of truth. But deeply nested type hierarchies can be scattered across many files, and having a single top-level file to blame is valuable.

Working copy size

Each new version of an API increases working copy size. In Omicron, as of revision 90a0c4b, the openapi subdirectory is 12 MiB out of a total working copy size of 63 MiB (full output). This is a relatively minor issue compared to the first two, but it is worth noting.

Determinations

To resolve the [issues] outlined above, this RFD proposes a new method of storage for older API versions: Git ref files.

Git ref files

A Git ref is a text file with a .gitref extension, containing a single line of the form <commit-hash>:<path-to-file> followed by a newline. (This usage of "Git ref" is unrelated to Git references such as branches and tags.)

An example Git ref is:

99c3f3ef97f80d1401c54ce0c625af125d4faef3:openapi/sled-agent/sled-agent-11.0.0-5f3d9f.json

The <commit-hash> field is a full Git commit hash: 40 hexadecimal characters for SHA-1 repositories, or 64 for SHA-256. The <path-to-file> field is the path to a file at that commit, using forward slashes on all platforms, including Windows.

A Git ref can be dereferenced, or followed, by reading the file contents at the specified revision. The format is designed so it can directly be provided as an argument to commands like git show:

% git show $(cat sled-agent-11.0.0-5f3d9f.json.gitref)
{
"openapi": "3.0.3",
"info": {
"title": "Oxide Sled Agent API",
"description": "API for interacting with individual sleds",
"contact": {
"url": "https://oxide.computer",
"email": "api@oxide.computer"
},
"version": "11.0.0"
},
// ...
}

For commands that don’t accept arguments in a <commit>:<path> format similar to git show, standard Unix tools such as cut can be used to extract the components:

$ jj file show -r $(cut -d: -f1 sled-agent-11.0.0-5f3d9f.json.gitref) root:$(cut -d: -f2 sled-agent-11.0.0-5f3d9f.json.gitref)
{
"openapi": "3.0.3",
"info": {
"title": "Oxide Sled Agent API",
"description": "API for interacting with individual sleds",
"contact": {
"url": "https://oxide.computer",
"email": "api@oxide.computer"
},
"version": "11.0.0"
},
// ...
}

Alternatively: IFS=: read -r c p < file.gitref; jj file show -r "$c" "root:$p".

(The Jujutsu root: fileset resolves paths from the root of the repository, regardless of the current working directory.)

Conversion to Git ref files

This RFD proposes that the Dropshot API manager optionally convert eligible API versions to Git ref files. This capability is called Git ref storage.

A versioned API document is stored as a Git ref if all of the following are true:

  1. Git ref storage is enabled for this API.

  2. The API is blessed (i.e. present in main).

  3. The API is not the latest version.

  4. The API was not introduced in the same commit as the latest version. (Otherwise, the Git ref would point to a commit not yet on main; see [multiple-api-versions].)

If an API should be stored as a Git ref, but is currently stored as a JSON file, then the Dropshot API manager will automatically convert the JSON to a Git ref, storing it as filename.json.gitref. The <commit-hash> refers to the commit that most recently added the file. (If a file is removed and later re-added, the commit that re-added it is used, not the original.)

If an API should be stored as JSON, but is currently stored as a Git ref, then the Dropshot API manager will automatically convert the Git ref back to a JSON file.

If both .json and .json.gitref are found for an API, the Dropshot API manager will delete whichever one is redundant, based on the above rules.

Desired behavior with Git ref storage enabled

On main

  • The latest version of an API is stored as a JSON file.

  • All prior versions of the API are stored as Git ref files, with each Git ref’s <commit-hash> component being the commit the corresponding API version was introduced in.

For example:

% tree openapi/sled-agent
openapi/sled-agent
├── sled-agent-10.0.0-898597.json.gitref
├── sled-agent-1.0.0-2da304.json.gitref
├── sled-agent-11.0.0-5f3d9f.json.gitref
├── sled-agent-12.0.0-ffacab.json
├── sled-agent-2.0.0-a3e161.json.gitref
├── sled-agent-3.0.0-f44f77.json.gitref
├── sled-agent-4.0.0-fd6727.json.gitref
├── sled-agent-5.0.0-253577.json.gitref
├── sled-agent-6.0.0-d37dd7.json.gitref
├── sled-agent-7.0.0-62acb3.json.gitref
├── sled-agent-8.0.0-0e6bcf.json.gitref
├── sled-agent-9.0.0-12ab86.json.gitref
└── sled-agent-latest.json -> sled-agent-12.0.0-ffacab.json

The -latest.json symlink still points to a valid JSON file, so existing clients are unaffected.

When adding a new API version

When a developer adds a new API version:

  • A new OpenAPI document corresponding to the new version is added, as before.

  • The latest.json symlink is updated to the new version, as before.

  • New: The previous latest document is converted into a Git ref, with the <commit-hash> component being the commit that document was introduced in.

For example, if a developer adds a new version 13 of the Sled Agent API, the Dropshot API manager:

  • adds sled-agent-13.0.0-<hash>.json;

  • adds sled-agent-12.0.0-ffacab.json.gitref, with the contents 63c01899a7668044841021075711919160c90b1e:openapi/sled-agent/sled-agent-12.0.0-ffacab.json (the commit hash being where version 12 was added); and

  • removes sled-agent-12.0.0-ffacab.json.

Removing the old JSON file is what enables Git to detect the new version as a rename rather than a new file (see [rationale-git-refs] for details). An example is Omicron PR #9572: the GitHub diff correctly shows version 13 as a rename of version 12, and blame works across versions.

When the latest version is removed

In the situation where the latest version of an API is removed, e.g. if a developer added a new version locally and then changed their mind:

  • The Dropshot API manager will convert the previous-latest version of the API from a Git ref to a JSON file.

  • If other API versions were introduced in the same commit as the previous-latest version of the API, the Dropshot API manager will convert those to JSON files as well.

This behavior follows the eligibility conditions listed in [conversion-to-git-refs] above, avoiding path dependence (where the final state depends on the sequence of operations): adding an API version and then immediately removing it should leave the repository in the same state as if the version had never been added.

Resolving merge conflicts in API documents

With the proposal in the RFD, Git will see version bumps as renames. If, in parallel branches, the API is changed in different ways, a rename-aware merge operation will result in conflicting changes.

See [tradeoff-rename-rename-conflicts] for some discussion of alternatives.

Merge conflicts with Git

Git’s merge algorithm is rename-aware by default, so a git merge or git rebase will result in a rename/rename conflict with conflict markers in the OpenAPI document JSON files.

This RFD proposes that developers resolve these conflicts by running cargo xtask openapi generate. Because OpenAPI documents are generated from source code as described in [rfd479], regeneration produces the correct result regardless of which conflicting version is present on disk.

Merge conflicts with Jujutsu

As of version 0.36, Jujutsu's merge algorithm is not rename-aware, so it won’t produce a rename-rename conflict. However, the scenario described in [merge-conflicts-git] above will result in a conflict in the -latest.json symlink. In such cases, Jujutsu turns the conflicting symlink into a regular file.

This RFD proposes extending the Dropshot API manager to handle -latest.json symlinks becoming regular files. When Jujutsu’s merge algorithm becomes rename-aware, it will automatically benefit from the Git conflict handling.

Merge resolution mechanics

The mechanics of merge conflict resolution are not discussed in the RFD; it is a somewhat tricky algorithm with many edge cases, each with a deterministic resolution. For an implementation of the resolution algorithm, see dropshot-api-manager PR #39.

Special case: multiple API versions added in the same commit

In the uncommon situation where multiple versions of the same API are added in the same commit, the Dropshot API manager treats all such versions as if they were the latest: none of them are converted to Git ref files, until another new version is added in the future, at which point all of them are converted.

This is condition 4 in [conversion-to-git-refs]—without it, CI would fail when multiple new versions land in one commit.

Rationale

Rationale for Git ref files

This RFD introduces an approach for referring to an old version of a file. The general strategy is inspired by [git-lfs], which stores pointer files with the format:

version https://git-lfs.github.com/spec/v1
oid sha256:4bd049d85f06029d28bd94eae6da2b6eb69c0b2d25bac8c30ac1b156672c4082
size 3771098624

One key difference between Git LFS and Git ref files is that with the former, the referred-to files are stored on an external server. With Git ref files, they are stored within the same repository. The respective choices make sense because Git LFS is meant for files that are too large to comfortably fit in a Git repository. OpenAPI documents are small enough to store directly.

Alternative: keep current storage, tooling on top. We can choose not to change how documents are stored in source control, and instead write a utility to perform the appropriate diffs (and leave, e.g., a comment on the PR). That is simpler to implement, but it doesn’t solve the issue that API change diffs appear to be very large on GitHub. It also doesn’t restore the ability to perform blame operations in the GitHub UI.

Alternative: use Git LFS. There are two main downsides to using Git LFS: first, an external server is required to serve objects; and second, LFS files can only be diffed with some difficulty. These tradeoffs are reasonable for the kinds of large binary assets Git LFS is designed for, but not so much for OpenAPI documents.

Alternative: do not store Git ref files, always do history lookups. We could store only the latest version, and use git log on a directory to find old versions of APIs. However, that results in a significant legibility loss: making a list of old APIs requires looking at Git history. It also becomes unclear when support for an API version is dropped. With Git ref files, dropping support for an API version means deleting the corresponding .gitref file.

Alternative: don’t store Git ref files as regular files. There are several alternatives to storing Git ref files as regular files, such as Git notes. Regular files on disk are significantly more legible than these less common Git features.

Alternative: smudge/clean filters. We could have fully materialized files for each API version on disk, but when storing them in the repository, use a Git clean filter to convert them to Git ref files. Then, when updating to a revision, use a smudge filter to materialize them. This option has a few issues:

  1. Smudge and clean filters only work on file contents. They cannot change the names of files, i.e., they cannot turn sled-agent-4.0.0-abcdef.json into sled-agent-4.0.0-abcdef.json.gitref, or vice versa. As discussed in [why-no-git-copy-detection] below, for rename detection to work, the old files must be deleted. Merely changing the contents is not enough for rename detection to work.

  2. They introduce additional complexity. Developers would have to configure a filter driver in their own .git/config. This can be done as part of the setup script, but it introduces one more way things can go wrong.

  3. Smudge and clean filters are incompatible with Jujutsu (as of version 0.36), which several developers working on Omicron—including this RFD’s author—use.

By being regular files with a .gitref suffix, Git ref files address all these concerns.

Alternative: lobby GitHub to turn on --find-copies-harder. Given the inherent computational complexity of --find-copies-harder and the general priorities of GitHub, a lobbying effort seems unlikely to succeed. The problem discussed in [why-no-git-copy-detection] below—that a copy might be detected against a different version than desired—also remains.

Alternative: convert API versions to Git ref files via a separate command. This alternative has the downside of inconsistent results when one side of a merge does conversions to Git ref files and the other side does not. Having a uniform, automatic strategy removes this possibility and reduces developer burden.

Rationale for Git ref format

The Git ref format is chosen to make git show $(cat file.gitref) work without manipulation of the Git ref’s contents. This is the most natural format for Git ref files.

Alternative: a single manifest listing all prior versions. One option is to have, for example, an old-versions.json file listing out all prior versions. This is not unreasonable, but it would complicate git show usage. It also has a greater chance of resulting in merge conflicts, particularly around situations where one side of a merge adds multiple versions while the other side only adds one version. Separate Git ref files per API version address both these concerns.

Rationale for commit hash selection

Alternative: use the merge base. This RFD proposes storing the commit where a file was most recently introduced. An alternative is to store the merge base between the current branch and main. However, this leads to merge conflicts when multiple developers work on the same API but with different merge bases:

In this scenario, the merge base of feature1 and main is M2, and the merge base of feature2 and main is M3. If the Dropshot API manager stored Git ref files using the merge base, the Git ref files would have different contents, leading to a merge conflict. The choice of using the first commit when the file was most recently introduced produces results independent of developers' merge bases.

Alternative: use the first commit when the file was first introduced. This RFD specifies that we use the first commit when the file was most recently introduced. Another option is to use the first commit when the file was first introduced. For example, for an API version v1:

In this scenario, the RFD proposes storing commit C within the Git ref. The alternative approach would store commit A.

A core assumption behind API versioning is that blessed versions are immutable. Under this assumption, in the situation described above, both A and C are valid options for the Git ref. But we have to pick a consistent approach, and commit C is slightly better because it represents an unbroken history.

If, for some reason, this assumption is not upheld, then the hash in the file name will almost certainly be different, and C would be the only valid option.

Tradeoffs

Git ref storage for older versions introduces significant tradeoffs. On balance, the benefits are large and the drawbacks can be mitigated, but the tradeoffs are worth documenting and considering. While the change impacts the whole team, if we eventually decide the costs outweigh the benefits, it is reversible: the Dropshot API manager can convert Git ref files back to JSON files, and the original JSON contents remain in Git history.

Dereferencing Git ref files requires Git history

Git ref files are pointers to older versions in Git history. Dereferencing a Git ref requires that the history be available.

Shallow clones

One common situation where history is unavailable is with the --depth option, which creates a shallow clone. Shallow clones typically lack the objects corresponding to old API versions, and with insufficient history, merge-base computations may be incorrect.

This is particularly relevant for GitHub Actions, which does shallow clones with depth 1 (i.e., just the commit being checked out) by default.

However, versioned APIs are already incompatible with shallow clones, since fetching blessed versions involves merge base computations. So Git ref storage doesn’t introduce a new downside, but it does deepen the existing incompatibility.

For GitHub Actions, a full clone can be done through fetch-depth: 0:

- uses: actions/checkout@v6
with:
fetch-depth: 0
Source archives

Source archives (tarballs) without Git history, such as those traditionally distributed by open source projects, will not have materialized JSON files in them. At the moment, we do not have a need for source archives at Oxide. Should the need arise, and if prior versions of APIs need to be made available as part of that archive, then whatever tooling builds the archive will need to be taught how to dereference Git ref files.

Tool support for dereferencing Git ref files

For APIs with Git ref storage enabled, if a tool or other program needs access to older API versions, it will need to know how to dereference Git ref files. This is relatively straightforward: git cat-file blob $(cat file.gitref). But tools that previously only relied on files on disk would now have a dependency on Git. (In practice, accessing old API versions is uncommon.)

The most interesting case is [progenitor], which generates Rust HTTP clients from OpenAPI documents. For more discussion, see [progenitor-git-refs] below.

Rename-rename conflicts

As discussed in [resolving-merge-conflicts] above, the fact that Git will observe renames means that users making changes to an API in parallel will see rename-rename conflicts. This is a notable downside. The current system was carefully designed—with features like hashes in file names—to avoid merge conflicts in OpenAPI documents.

This RFD proposes that the Dropshot API manager handle merge conflicts (whether rename/rename, or for other reasons). This capability may not be obvious to developers and will need to be documented. The implementation also introduces notable complexity.

Another option is for users to disable rename detection during merges: pass -X no-renames, or set merge.renames to false. But most of the time, rename detection during merges is valuable, so turning it off globally is generally incorrect.

A third option is for users to resolve conflicts themselves with git restore or jj restore. This approach will continue to work, and if users make a mistake in this process, the API manager will correct any errors the next time generate is run.

Future work

Making Progenitor understand Git ref files

At Oxide, we maintain [progenitor] to generate Rust HTTP clients from OpenAPI documents. With server-side-versioned APIs [rfd532], clients typically only need the latest version (accessed via the -latest.json symlink). With Git ref storage, the latest version is always available as a materialized JSON file, so this common case is unaffected.

In some cases, clients for older versions of APIs are required:

  • For cross-version compatibility tests (example).

  • For client-side-versioned APIs [rfd567].

For now, the workaround is to disable Git ref storage for these APIs. But in the future, we can build into Progenitor the ability to dereference Git ref files (either as a fully integrated feature, or by enabling Progenitor to run scripts to fetch OpenAPI document contents).

History rewrites

In the rare scenario where Git history is rewritten with commands like [git-filter-repo], the Git ref files may become invalid. Should that need arise, we can build the required tooling to map Git ref files over to their new versions.

Current status

An initial implementation of this RFD is present in dropshot-api-manager PR #38.

The merge conflict resolution discussed in [resolving-merge-conflicts] above is implemented in dropshot-api-manager PR #39.

A demo showing conversion of Omicron’s versioned APIs to Git ref files is at Omicron PR #9571, and a demo of adding a new version with the Git ref scheme enabled is at Omicron PR #9572.

Security considerations

None. This RFD introduces no external dependencies or new attack surfaces.

External References

Appendix: Why can Git not detect copies?

To understand why Git cannot detect copies in this situation, a comparison with other version control systems is instructive. Many systems (Subversion, Mercurial) allow developers to mark a file as a copy of another file, such that this information is tracked within the system. (A rename is generally tracked as a copy and a delete.) If a file is copied and this copy is changed in the same commit, these systems can show the appropriate diff, log, and blame output[1].

In contrast, Git does not track copies; rather, it performs detection when a diff, log, or blame command is run. But searching for copies can be algorithmically expensive, so Git performs limited detection by default.

  • By default, Git only detects renames, where a file is deleted and another file is added with similar contents. This is algorithmically cheapest, because only removed files need to be compared with added ones. In other words, for each added file, O(removed files) comparisons need to be performed. GitHub’s web UI has rename detection turned on.

  • With the optional --find-copies argument, Git also detects copies when the original file is changed. For each added file, this can be done in O(changed files) comparisons, so it is somewhat more expensive, though not prohibitively so. GitHub’s web UI has --find-copies detection turned off.

  • With --find-copies-harder, Git detects copies even when the original file is unchanged. For each added file, this requires O(number of files in repository) comparisons. This is prohibitively expensive for large projects, so it is rarely used.

Note
For more details on copy tracking, see [jj-copy-tracking].

The predicament is clear: since, with our current storage model, blessed API versions are immutable by definition, Git would never be able to detect copies across API versions without --find-copies-harder. GitHub’s web UI has no option for --find-copies-harder, even for specific paths, so a new API version would always register as a brand new file. GitHub’s blame UI also becomes unhelpful when used on the OpenAPI document.

And even with --find-copies-harder, there’s no guarantee that the previous version is the most similar to the new one. Git might detect version 12 as a copy of version 10, 9, or even earlier, leading to misleading and confusing output.

Footnotes
  • 1

    If we used one of these systems, the Dropshot API manager could mark the file as a copy, and we wouldn’t need this RFD.

    View