How Rust Solved Dependency Hell
Every once in a while I’ll be involved in a conversation about dependency management and versions, often at work, in which the subject of “dependency hell” will come up. If you’re not familiar with the term, then I encourage you to look it up. A brief summary might be: “The frustration that comes from dealing with application dependency versions and dependency conflicts”. With that in mind, let’s get a little technical about dependency resolution.
The Problem
This topic typically enters into the discussion when debating on what kinds of dependencies a package should have, and which dependencies might cause problems. As a real-world example, at Widen Enterprises we have an internal, reusable Java framework that consists of several packages that gives us a base for creating many of our internal services (micro-services, if you will). This is fine and dandy, but what if you want to create a reusable library of shared code that depends on something in the framework? If you attempted to use a library like this in an application, you might end up with a dependency graph like this:
graph LR
A[app] --> F1[framework 21.1.1]
A --> L[library 0.2.0]
L --> F2[framework 21.2.0]
Just like in this example, any time you attempted to use the library in a service, there’s a high chance that your service and the library will depend on different versions of the framework, and this is when “dependency hell” begins.
Now at this point, a good development platform will give you some combination of the following two choices:
- Fail the build and warn us that
framework
versions21.1.1
and21.2.0
clash with each other. - Use semantic versioning to allow packages to define a range of versions they are compatible with. If you’re lucky, the set of versions that both packages are compatible with is non-empty, and you can automatically use one of those in the final application.
Both of these seem reasonable, right? If two packages really aren’t compatible with each other, then we simply can’t use them together without modifying one or the other. It’s a tough situation to be in, but the alternatives are usually much worse. In fact, Java is a good example of what not to do:
- The default behavior is to allow multiple versions of the dependency to be added to the classpath (Java’s way of locating classes). Which version actually gets used when the application needs a class from the library? In practice, the order classes are loaded varies between environments or even runs in a non-deterministic way, and so you really have no idea which one will be used. Yikes!
- Another option that we use at Widen is forced version alignment. This is similar to the second reasonable option from before, except in Java land there’s no way for dependencies to express a compatibility range and so we just pick the newer of the possible dependencies and cross our fingers that it will still work. In the dependency graph example shown earlier, we would force
app
to upgrade toframework 21.2.0
.
This seems like a lose-lose situation, so as you can imagine, we’re very adverse to adding dependencies, and indeed have made it a defacto policy that nothing is allowed to depend on our core framework except actual applications.
Rust’s Solution
When having these kinds of discussions, I’ll often mention in passing that this is a problem that doesn’t apply to all languages, and that Rust “solved” this problem as an example. I do often joke about how Rust solves all the world’s problems, but there’s usually a kernel of truth in there somewhere. So let’s dive in to what I mean when I say that Rust “solved” this problem and how it works.
Rust’s solution involves a fair number of moving parts, but it essentially boils down to challenging a core assumption that we have made up until this point:
Only one version of any given package should exist in the final application.
Rust challenges this in order to reframe the problem to see if there’s a better solution sitting just outside of dependency hell. There are primarily two features of the Rust platform that work in tandem to provide the groundwork for solving these kinds of dependency problems, and today we’ll look at both individually and what the result looks like.
Cargo and Crates
The first piece of the puzzle is naturally Cargo, the official Rust dependency manager. Cargo is similar to tools like NPM or Maven, and has some interesting features that make it a really high quality dependency manager (it’s my favorite along with Composer, a really well designed dependency manager for PHP). Cargo is responsible for downloading Rust libraries, called crates, that your project depends on, and orchestrates calling the Rust compiler for you to get a final result.
Note that crates are a first-class construct in the compiler. This will be important later.
Like NPM and Composer, Cargo allows you to specify a range of dependency versions that your project is compatible with based on the compatibility rules of Semantic Versioning. This allows you to describe one or more versions that are (or might be) compatible with your code. For example, I might add
[dependencies]
log = "0.4.*"
to my Cargo.toml
file to indicate that my code works with any patch version of the log
crate in the 0.4
series. Perhaps in a final application we get this dependency tree:
graph LR
A[app] --> L1[log 0.4.4]
A --> P[my-project]
P --> L2[log 0.4.*]
Since in my-project
I declared compatibility with log
version 0.4.*
, we can safely select version 0.4.4 for log
since it meets all the requirements. (If the log
crate follows the principles of semantic versioning, which admittedly isn’t always the case for published libraries, then we can be mostly assured that this bump did not include any breaking changes that would break our code.) You can find a better explanation of version ranges and how they apply to Cargo in the Cargo docs.
Great, so instead of bailing if we have a version conflict or simply choosing the newer one and crossing our fingers, we can instead choose the newest versions of everything that satisfies every project’s version requirements. But what if we reach something unsolvable, like this:
graph LR
A[app] --> L1[log 0.5.0]
A --> P[my-project]
P --> L2[log 0.4.*]
There’s no version of log
that can be chosen that meets all the requirements! What do we do next?
Name Mangling
In order to answer that question, we need to talk about name mangling. Generally speaking, name mangling is a process used by some compilers for various languages that takes a symbol name as input and produces a simpler string as output that can be used to disambiguate similarly-named symbols at link time. For example, Rust lets you re-use identifiers across different modules:
mod en {
fn greet() {
println!("Hello");
}
}
mod es {
fn greet() {
println!("Hola");
}
}
Here we have two different functions named greet()
, but of course this is fine to do because they’re in different modules. This is handy, but generally application binary formats don’t have the concept of modules; instead all symbols exist in a single global namespace, very much like names in C. Since greet()
can’t show up twice in the final binary file, compilers might use more explicit names than your source code does. For example:
en::greet()
becomesen__greet
es::greet()
becomeses__greet
Problem solved! As long as we ensure that this name mangling scheme is deterministic and is used everywhere during compilation, code will know how to reach for the correct function.
Now this isn’t an entirely complete name mangling scheme, because there’s a lot of other things we haven’t accounted for, like generic type parameters, overloading, and such. This feature also isn’t unique to Rust, and indeed has been used for a very long time in languages such as C++ and Fortran.
How does name mangling help Rust solve dependency hell? It’s all in Rust’s name mangling scheme, which seems to be fairly unique across the languages that I looked into. So let’s look under the hood, shall we?
Finding the code for name mangling in the Rust compiler turned out to be easy; it’s all in a file aptly named symbol_names.rs
. I recommend reading the comments in this file if you want to learn a whole lot more, but I’ll include the highlights. It seems there’s four basic components incorporated in a mangled symbol name:
- The fully qualified name of the symbol.
- Generic type parameters.
- The name of the crate containing the symbol. (Remember how crates are first-class in the compiler?)
- An arbitrary “disambiguator” string that can be passed in through the command line.
When using Cargo, the “disambiguator” is supplied to the compiler by Cargo itself, so let’s look in compilation_files.rs
to see what that includes:
- Package name
- Package source
- Package version
- Enabled compile-time features
- A bunch of other stuff
The end result of this complex system is that even the same function across different versions of a crate has a different mangled symbol name, and thus can both coexist in a single application, as long as each component knows which version of the function to call.
All Together Now
Now back to our “unsolvable” dependency graph from earlier:
graph LR
A[app] --> L1[log 0.5.0]
A --> P[my-project]
P --> L2[log 0.4.*]
With the power of dependency ranges, and Cargo and the Rust compiler working together, we can now actually solve this dependency graph by including both log 0.5.0
and log 0.4.4
into our application. Any code inside app
that uses log
will be compiled to reach for symbols generated from version 0.5.0
, while code inside my-project
will make use of symbols generated for version 0.4.4
instead.
Now that we see the big picture, this actually seems pretty intuitive and solves an entire swath of dependency problems that would plague users of other languages. This solution isn’t perfect though:
- Since different versions produce different unique identifiers, we can’t pass objects around between different versions of a library. For example, we can’t create a
LogLevel
withlog 0.5.0
and pass it intomy-project
to use, because it expects aLogLevel
fromlog 0.4.4
, and they have to be treated as separate types. - Any static variables or global state will be duplicated for each instance of a library, and they can’t communicate without some hackery.
- Our binary size increases necessarily for every instance of a library we have included in our app.
Because of these downsides, Cargo only employs this technique when it is required in order to solve the dependency graph.
These seem like worthwhile tradeoffs for Rust in order to solve the general use case, but for other languages, adopting something like this could be significantly more difficult. Taking Java as an example, Java heavily relies on static fields and global state, so simply adopting Rust’s approach wholesale would certainly produce broken code more times than not, whereas Rust is a bit more heavy-handed about limiting global state to a bare minimum. This design also says nothing about loading arbitrary libraries at runtime or reflection, both of which are popular features offered by many other languages.
Conclusion
Rust’s careful design in both compilation and packaging pays dividends in the form of (mostly) painless dependency management that often eliminates an entire class of problems that can be a developer’s worst nightmare in other languages. While I certainly liked what I saw when I first started playing around with Rust, diving deep into the internals to see great architecture, thoughtful design, and well-reasoned tradeoffs being made is even more impressive to me. This was but one example of that.
Even if you aren’t using Rust, hopefully this gives you a new respect for dependency managers, compilers, and the tough problems they have to solve. (Though I’d encourage you to at least give Rust a try, of course…)
19 comments
Let me know what you think in the comments below. Remember to keep it civil!
Subscribe to this thread
Great post Stephen! Informed, well linked, good length
Thanks!
I really like your writing, thanks for this well-versed post.
P.S. for some reason even though this post was only submitted a day ago, the website shows that ‘Aleks’ reply was submitted ’2 months ago.
Thanks!
Aleks’ message shows
Yesterday
for me; the time labels are translated from a timestamp with momentjs. Perhaps a bug?Stumbled on your article via Reddit, is very well explained on dependency management.thanks for your efforts.
This is cool. In c++ you ususally version up the namespace when you change your lib so much that it is no longer backward compattible. It’s not automatic like rust, but it ensures symbols don’t collide. It’s a pretty standard way of working.
I suspect this only works as long as
log
does not interact with a C interface (assuming the C interface does not uphold the same naming schemes).That’s true, I believe there’s some more complex resolution rules involved in Cargo when non-Rust dependencies are being used. I think you are limited to a single instance of a C library, determined by name. And of course, if a symbol name does exist more than once, the build will fail at the linker step.
Great post. Just a clarification. On Maven and Gradle you can define the a compatibility range of dependencies. In this case it would be something like [4.4, 4.5). There are some issues with snapshots, but can be solved.
Interesting, I did not know that. I’ll have to take a look into it!
For clarification:
There is also a trick that allows you to keep your trait compatible accross different major crate versions: https://github.com/dtolnay/semver-trick
This is a good article which clearly illustrates the problem at hand and how it is solved in Rust and Cargo. However there are a few comments about Java which are not entirely accurate:
“Java is a good example of what not to do” The default dependency model available in plain JVM (the Maven model) is as you mentioned. However there is a dependency and runtime model for Java called OSGi, that solves that problem, in essentially the same way as Cargo. It allows multiple versions of a library (called bundles in OSGi) to exist at runtime. It does so by leveraging some advanced JVM classloader functionality to provide that sort of isolation. It has the same cons as Cargo (bigger binary sizes, types with same name of different instances of the library are effectively different types and so are incompatible). The Eclipse IDE and Netbeans are examples of Java apps that use OSGi.
Unfortunately OSGi also aims to achieve other heftier goals, like being able to load and unload bundles dynamically. As such it requires a complex runtime, and perhaps because of that additional complexity (or other historical reasons) it never took off in mainstream Java, where the Maven model is dominant.
“Java heavily relies on static fields and global state” I don’t think this is true. Many Java libraries have no global state at all. Collection libraries, parsing libraries (XML, JSON, etc.), HTTP libraries, and so on. Logging libraries do tend to have global state, and for those, yes there can be issues when multiple instances of the same library. But I wonder if Rust and Cargo would not have similar complications in a similar scenario (two logging libraries trying to write to same file, etc.) ?
Interesting, I’ve been working in Java for years and I never knew what OSGi actually did. I learned something knew, thanks!
As for this phrase:
Looking at the source code of just some standard classes included with the JRE I’ve found some really astonishing uses of static fields. Common libraries maybe not as much. Generally the Java model seems to be to provide functionality that is easy and useful to developers as first priority. The end result though is (in my opinion) overuse of complex static initialization and reflection in order to give the appearance of simplicity.
Good information. However, the downsides you mentioned are not something that we can just skip. Especially, using the same class from different versions in various places to exchange data, or for some other things. Log library is convenient because it spits out to a file most of the time. But if it would have been another essential library (like we use Parameter library for a robust parameter handling and passing all around), it would make no sense. Anyway, I am aware that this problem is not easy to solve and may not be solveable at all.
“Any static variables or global state will be duplicated for each instance of a library, and they can’t communicate without some hackery.” - to me this is a plus not a drawback. Incompatible interfaces (function signatures) are much easier to see than incompatible state. State separation might be a burden but it is a safe default.
Great article Stephen, I am currently learning rust (just a beginner) and really in love with the language and the thought process and decisions made by the language designers. I am looking forward to be working as a full-time Rust developer (if I ever mastered it to that level).
Great post!
Wow, the first time I considered this question (I didn’t know dependencyhell that time), I got the same solution!