Technology

04 Derivation: Fixing Layer Leakage

Author Photo

Johannes Kirschbauer

· 10 min read
Thumbnail

Introduction

Picking up reference the latest development efforts in the modules working group; Modules allow us to segregate functionalities, reduce complexities, and enhance the reusability of our codebase. But how we approach modularity, especially in the context of Nix, can significantly determine its efficency.

The Modules Working Group is a collective effort aimed at finding the right module system and championing best practices. Their dedication to making modules more accessible and functional is commendable, but there’s an underlying principle I’d like to discuss: the approach of building from the bottom up.

Starting from the ground up, addressing the actual core (derivation) and then building upon it, is often more impactful than merely layering fixes or wrappers over flawed interfaces. Instead of sealing imperfections behind additional overhead wrappers, we should focus on creating a robust foundation that inherently addresses those concerns. This blog post will delve into this philosophy, discussing its potential merits and presenting an alternative perspective on achieving the foundation for genuine modularity in Nix.

A new approach

In one of my previous posts I discussed the type of builtins.derivation.

Expanding on this topic, I will delve into how Nix might modify its interface to streamline and simplify the derivation type.

I believe such a change could promote higher quality Nix code and improved architecture. The concept of first defining the types of a problem and then tackling the real issue with that abstraction as a foundation has always intrigued me. (type-driven-development)

With that in mind i’ll present the type signature below for a deeper discussion on the type.

If you’re interested how i came up with that type, read the post about that

let 
    Derivation :: {
        all :: [ Derivation ];
        builder :: String;
        drvAttrs :: {
            builder = String; 
            name = String;
            system = String;
            outputs = [ output :: String ]; 
            ${additionalArgs} :: String;
        }
        drvPath :: String;
        name :: String;
        outPath :: String;
        outputName :: String;
        outputs :: [ output :: String ];
        system :: String;
        type :: "derivation";
        ${output} :: Derivation;
        ${additionalArgs} :: String;
    };
in
    builtins.derivation :: {
        name :: String;
        builder :: String;
        system :: String;
        outputs :: [ output :: String ]?;
        ${additionalArgs} :: String;
    } -> Derivation

In my view, a major shortcoming is that every input attribute passed is invariably present in the returned attribute set. This causes unintended overlap between abstraction layers. Rather than clearly distinguishing between layers, subsequent layers must continually consider the potential impacts on those beneath them. This approach feels counterintuitive and is generally regarded as an antipattern in programming.

If you’re wondering what those layers of abstraction could be give let me give you a concrete example:

              [SomeActualPackage]   <Random Github repository>
            buildPythonPackage      <nixpkgs>
          stdenv.mkDerivation       <nixpkgs>
      builtins.derivation           <nix>
  builtins.derivationStrict         <primop>

With every new layer of abstraction the amount of returned attributes grows

length (attrNames pkgs.bash) 
=> 54
[
  "PKG_CONFIG_ALLOW_CROSS",
  "__ignoreNulls",
  "all",
  "args",
  "buildAndTestSubdir",
  "buildInputs",
  "builder",
  "cargoBuildFeatures",
  "cargoBuildNoDefaultFeatures",
  "cargoBuildType",
  "cargoCheckFeatures",
  "cargoCheckNoDefaultFeatures",
  "cargoCheckType",
  "cargoDeps",
  "cargoSha256",
  "configureFlags",
  "configurePhase",
  "depsBuildBuild",
  "depsBuildBuildPropagated",
  "depsBuildTarget",
  "depsBuildTargetPropagated",
  "depsHostHost",
  "depsHostHostPropagated",
  "depsTargetTarget",
  "depsTargetTargetPropagated",
  "doCheck",
  "doInstallCheck",
  "drvAttrs",
  "drvPath",
  "inputDerivation",
  "meta",
  "name",
  "nativeBuildInputs",
  "out",
  "outPath",
  "outputName",
  "outputs",
  "override",
  "overrideAttrs",
  "overrideDerivation",
  "passthru",
  "patchRegistryDeps",
  "patches",
  "pname",
  "postUnpack",
  "propagatedBuildInputs",
  "propagatedNativeBuildInputs",
  "src",
  "stdenv",
  "strictDeps",
  "system",
  "type",
  "userHook",
  "version"
]

Many packages return roughly 50 attributes, with the majority being superfluous. In my view, accessing attributes of a derivation, which were intended as input options, is an ill-advised approach.

A derivation should ideally be viewed as possessing only predefined attributes. Any meta information can be seamlessly integrated using an additional abstraction layer, perhaps through a wrapper. The existing method doesn’t offer developers sufficient control over their attributes, potentially leading them to rely on this unintended behavior rather than utilizing a distinct layer for variable management.

One possible approach to clean the implementation could be to redesign the interface from bottom up. Starting with the builtins.derivation interface. Luckily this is relatively easy to do, as derivation is not an actual primop. For it to change, we can just experiment with some nix code, inspired by the current implementation which is based on builtins.derivationStrict.

Type conflicts

From a type perspective having two dynamic entries with different types each is also a problem:

# { ...
${output} :: Derivation;
${additionalArgs} :: String;
# }...

Although nix might not have a static typing system; consistency and predictability are more than just ideals – they’re fundamental to ensuring reliability and clarity in our code. Now, let’s consider a scenario where we have two dynamic entries, each with a different type. At first glance, it might seem like a mere quirk or a minor inconsistency. But let’s delve deeper.

When you’re dealing with dynamic entries of varying types, it introduces a layer of unpredictability. Each entry behaves differently, operates under its own set of rules, and interacts uniquely with other components. This disparity can lead to increased complexity, not just in understanding the system but also in troubleshooting and extending it.

Furthermore, from a maintenance perspective, it poses a challenge. Developers must remember the nuances of each dynamic entry, its associated type, and potential side effects. This can lead to inadvertent mistakes by even seasoned developers.

In essence, while having dynamic entries with different types might appear as a flexible feature, it also brings forth challenges in consistency, predictability, and maintainability. Ensuring homogeneity in type systems, especially for dynamic entries, can significantly streamline both development and future iterations.

Thus, I suggest separating these dynamic entries from one another.

Derivation

When conducting experiments, it’s been observed that the Nix build-plane requires specific attributes for a derivation to be successfully constructed. These essential attributes and their respective types are listed below:

Note: I researched the following via reverse engineering, as I’m not deeply familiar with the nix implementation under its hood.

name

name :: String        
  • Name of the derivation
  • Must be context free

outputs

outputs :: [ String@output ]
  • List of all outputs
  • Every given output being will produce one derivation (recursively, lazy)
  • Must be context free

outputName

outputName :: String@output
  • outputName
  • MUST be one of the outputs
  • MUST be context free

Interestingly nix-build vs nix build behaves slightly different; nix-build doesn’t necessarily need the outputs list, just outputName is enough. The newer version nix build has a fallback implementation on the outputs list, if there is no list present, it uses the internally defined list [ "out" ], which may lead to unexpected behavior if omitted.

outPath

outPath :: Path
  • Enables the nix dependency chain.
  • Is not strictly required for building.
  • Is required for “toString” or string interpolation ${}.

For example:

derivation {
  # ...
  PATH  = "${python}/bin";
  # ...
}

Note: String coercion could also be achieved with a dedicated __toString method, although this is slightly less performant than my recommended form: outPath = strict.${outputName};, as it requires creating and invoking an additional lambda.

See reference here

drvPath

drvPath :: Path
  • Store path to the .drv file.
  • The actual build instructions.

type

type    :: "derivation"
  • MUST always be this exact string literal.
  • Otherwise nix will treat this attribute set like a generic attribute set and thus will not generate build outcomes.
  • String context allowed but ignored.

Wrapping Up

As we’ve journeyed through the intricacies of the current derivation in Nix, it becomes evident that there’s room for improvement, particularly in streamlining the flow of attributes and ensuring a more intuitive developer experience.

Now, with some insights gathered and understanding of the limitations of the current method, I’d like to introduce a new and simplified implementation of the derivation function. This recommendation is designed to reduce potential pitfalls, and more closely align with established programming best practices.

Alternative derivation

You can visit the original derivation for comparison.

For clarity and brevity, I’ll first outline my suggested modifications before delving into the specifics. Don’t worry I’ll explain the changes and why the new implementation still works:

derivation.nix

drvAttrs @ { outputs ? [ "out" ], ... }:

let
  strict = derivationStrict drvAttrs;

  commonAttrs = (builtins.listToAttrs outputsList) //
  { 
    inherit (drvAttrs) name outputs;
  };

  outputToAttrListElement = outputName:
    { name = outputName;
      value = commonAttrs // {
        outPath = strict.${outputName};
        drvPath = strict.drvPath;
        type = "derivation";
        inherit outputName;
      };
    };

  outputsList = map outputToAttrListElement outputs;

in (builtins.head outputsList).value

Remove drvAttrs

/*L12*/ inherit drvAttrs;

I’ve eliminated drvAttrs as it deliberately exposes information from the underlying layer. Anything not essential for the build process should be excluded from the foundational layer.

Solution: Delete the line

Don’t extend drvAttrs

/*L10*/ commonAttrs = drvAttrs // {

Rather than modifying the nonspecific drvAttrs, I suggest explicitly defining all output attributes where feasible.

For clarity the resulting commonAttrs currently has the following type:

commonAttrs :: { 
      name :: String;
      outputs :: String;
      builder :: String;
      system :: String;
      all :: [ Derivation ];
      ${output} :: Derivation;
      ...

With Derivation having the value of the final derivation, although we didn’t define how to construct it yet.

Solution: Delete drvAttrs here. Inherit needed attributes explicitly into the actual value.

inherit (drvAttrs) name outputs;

Use .${} instead of getAttr

/*L18*/ outPath = builtins.getAttr outputName strict;

Rather than accessing the output value by invoking a function, we should access it with a simple outPath = strict.${outputName}; this is both more intuitive but also slightly more performant.

No ‘all’

/*L11*/ all = map (x: x.value) outputsList;

Regarding the all attribute. I’d suggest removing it from here as this is the foundational layer and everything not needed by the build plane should live in higher abstraction layers.

Note the ‘all’ attribute can be easily constructed anytime from outside the derivation.

The new type

let 
    Derivation :: {
        name :: String;
        drvPath :: String;
        outPath :: String;
        outputName :: String;
        outputs :: [ output :: String ];
        type :: "derivation";
        ${output} :: Derivation;
    };
in
    builtins.derivation :: {
        name :: String;
        builder :: String;
        system :: String;
        outputs :: [ output :: String ]?;
        ${additionalArgs} :: String;
    } -> Derivation

If required we could also add drvAttrs back in, but keep it nested:

let 
    Derivation :: {
        name :: String;
        outputs :: [ output :: String ];
        drvAttrs :: {
            name = String;
            builder = String; 
            system = String;
            outputs = [ output :: String ]; 
            ${additionalArgs} :: String;
        };
        drvPath :: String;
        outPath :: String;
        outputName :: String;
        type :: "derivation";
        ${output} :: Derivation;
    };
in
    builtins.derivation :: {
        name :: String;
        builder :: String;
        system :: String;
        outputs :: [ output :: String ]?;
        ${additionalArgs} :: String;
    } -> Derivation

This solves my current issues with derivation and i’m happy to try it out:

Try It

With some simple tests we can verify that the proposed changes work properly:

pkg.nix

let 
  pkgs = import <nixpkgs> {};
  drv = import ./drv.nix; #The new derivation function
  pkg = drv {
    name = "pkg";
    system = "x86_64-linux";
    outputs = [ "output" ];
    builder = "${pkgs.bash}/bin/bash";
    args = [ "-c" "echo $PATH > $output"];
  };
in {
  inherit pkg;
}

The derivation can be built

$> nix-build ./pkg.nix -A pkg
/nix/store/ryy0bhs3l87viimrg3wzmffahn5gydxq-pkg-output

$> cat /nix/store/ryy0bhs3l87viimrg3wzmffahn5gydxq-pkg-output
/path-not-set

The derivation has only the expected attributes

$> nix repl -f pkg.nix
nix-repl> pkg.    
pkg.drvPath     pkg.outPath     pkg.outputs
pkg.name        pkg.outputName  pkg.type

Conclusion

Checks

  • cross compatibility with all advanced attributes
  • dependency behavior: should work as before
  • Functional Integrity
  • Performance, do a performance test. We could see a marginal performance gain.
  • Error handling: Does the current nix error handling depend on the (old) implementation details?
  • Tests: Migrate some packages in the newly proposed format and use them for validation.

Downsides

  • Backward Compatibility: This is not backwards compatible to nixpkgs because many packages already rely on attribute leakage

Known Limitations

  • This new approach may cause confusion on somebody that is used to the current behaviors.
  • Breaking changes; It is not feasible to replace builtins.derivation. Instead this is a new solution that needs to be maintained in parallel.

Integration Points

To begin, we could establish a test repository dedicated to package declarations in the new format. This would not only serve as a structured foundation to advocate for its integration into nixpkgs, but also as a proactive response to concerns regarding clarity and organization. We could find the best patterns first, which we could then use as an argument for nix or nixpkgs integration.

Outlook

In future posts I’ll try drafting intermediate abstraction layers to achieve user experiences similar to what stdenv.mkDerivation currently offers.

Thanks for reading; 😃

#nix#derivation#experiment