Omicron
Working on Nexus

omicron-uuid-kinds

uuid-kinds/README.adoc

In omicron and other Oxide projects, we use UUIDs to identify many different kinds of entities. Using a single Uuid type for all of them risks mixing up all the different kinds of IDs.

To address that, we’re actively moving towards typed UUIDs with the newtype-uuid crate. The goal is that each kind of entity will have a marker type representing a UUID kind associated with it. Then, the type system will make it significantly harder to mix different kinds of entities.

omicron-uuid-kinds is a centralized registry for UUID kinds, one that can be shared across Oxide repos. omicron-uuid-kinds supports no-std so the kinds can be shared with embedded code as well.

Adding a new UUID kind

Start by adding a new element to the invocation of the impl_typed_uuid_kind! macro in src/lib.rs. For example:

impl_typed_uuid_kind! {
    // ...
    Widget => "widget",
    // ...
}

This will:

  • Create a new TypedUuidKind called WidgetKind. This kind will become the type parameter to TypedUuid and other related generic types.

  • Create a type alias type WidgetUuid = TypedUuid<WidgetKind>.

Then, start changing your UUID types over. It’s generally easiest to change the type of a UUID in a struct or enum field, and start pulling that thread.

  • If your UUID isn’t used in too many places, you can usually just change all users in one go. For an example, see the conversions of several UUID types in #5135.

  • If your UUID is widely used, you may need to break your change up across several commits. It’s easiest to carve out a section of your code to make changes in, and use the GenericUuid conversions into and out of this code. For an example, see the ongoing conversion for sled UUIDs in #5404 and #5488.

Important
Using type aliases

For TypedUuid<T>, prefer to use the type aliases. For example, SledUuid rather than TypedUuid<SledKind>.

Some older code is still being ported over to type aliases; see #5511 for an example.

Other types that use the same kinds, like nexus_db_model::DbTypedUuid<T> and typed_rng::TypedUuidRng<T>, don’t have aliases defined for them. That’s because their frequency of use falls below the threshold at which the benefits of type aliases outweigh the costs.

Some special cases:

  1. If part of your change is at an OpenAPI schema boundary, then you generally also want to have clients use the same UUID types. The best way to do that currently is to use a replace directive, as in #5135. There is ongoing design work to make this work automatically, in typify#503 and elsewhere.

  2. For Nexus database storage, nexus-db-model has a DbTypedUuid generic type which can be used. DbTypedUuid should not be exposed outside of nexus-db-model; instead, prefer to use the regular TypedUuid and only convert to DbTypedUuid within the lowest layers (i.e. using getters/setters and not making it a public field). This is because the regular TypedUuid has much more infrastructure built around it.

Determinations

As part of this effort, we’ve made several decisions that could have gone a different way. This section documents those choices.

One big registry or several smaller ones?

Rather than having a single registry, an option is to have several smaller ones (perhaps one to two in each repo). The best answer to that is currently unclear, but putting them in the same crate for now has several advantages:

  • The macros updated in this PR know where uuid kinds will be found, so they let you specify just a bare name as the kind.

  • It’s simpler.

The disadvantage of this is that any change to this registry will cause all of omicron to be rebuilt. Hopefully, once most UUIDs are converted over, this crate won’t be touched too much.

This decision involves some level of commitment, because splitting up a registry will probably require several days of work. For example, macros may need to be updated to handle multiple sources, import paths would have to be changed across several repos, and so on.

Crates which solve more abstract problems and are completely independent of omicron, like Dropshot and Steno, will have their own registries (probably as a module within the crates themselves).

Where should the registry live?

Once we’ve decided on a single registry, the next question is where this registry should be stored. There was some debate in #oxide-control-plane, and two options stood out:

  1. In omicron itself.

    • The upside of this is that it is easy to add new UUID kinds within omicron, especially as we transition over existing untyped UUIDs.

    • The downside is that we also want to use these kinds in crucible and propolis — and pulling in omicron-uuid-kinds as a Git dependency would typically cause two copies of omicron-uuid-kinds to be floating around. That can result in a lot of pain, including confusing errors.

  2. In a separate repo. While it eliminates the issue with duplicated dependencies, it does add a fair bit more friction. That’s because users updating omicron would now have to make two separate, coordinated PRs.

We’ve chosen 1 for now, because agility within omicron outweighs other concerns. The downside is mitigated by using a [patch] directive; see the workspace root’s Cargo.toml for more about this.

This is straightforward to change in the future. If we find that 1 is too much of a bother, we’ll easily be able to switch to 2 while keeping the name of the crate the same (minimizing churn).