[Proposal] Update strategy for mix deps.update that touches as few dependencies as possible

18 views
Skip to first unread message

Tobias Pfeiffer

unread,
11:57 AM (11 hours ago) 11:57 AM
to elixir-l...@googlegroups.com
Hello everyone!

Huge fan as I believe y'all know 💚

tldr; Would be great to have something like `mix deps.update --conservative` that updates as few dependencies as it can, as opposed to the normal behavior that updates all the transitive dependencies

Current Behavior
`mix deps.update dependency` will update transitive dependencies as well, sometimes resulting in 6+ dependencies updated

Wished for behavior
In short: minimal 
If dependency A depends on dependency B with ~> 1.0 and I currently have B installed at 1.0.0 then an update to A (that still has B ~> 1.0) with a `--conservative` (or similar) flag should keep B at 1.0.0 even if `1.0.1` or `1.1.0` are already out.

However, let's say I have A 1.0 installed and A 2.0 bumped its dependency to B ~> 1.1 then `--conservative` should of course update B to 1.1. However, if B has a dependency C that is still satisfied, we shouldn't update C as well.

Naturally naming for such a flag could be many more:
* minimum
* required
* ...

Prior Art
This was first raised in #7030 ~9 years ago and ultimately solved by documenting the `mix deps.unlock dep; mix deps.get` workaround.

I thought it may be worth discussing again because:
* quite a lot of time has passed and through features like the dependency cooldown (thank you for that!) we're a bit more cautious around updating dependencies due to supply chain attacks
* a downside of the workaround wasn't discussed (imo) - if the new version needs a bump in dependency the resolver will fail - then you have to either unlock multiple dependencies or use normal `mix deps.update` with transitive updates again (or temporarlily "lock" them inside your mix.exs when you don't want them to change)
* personally, the scenario pops up a lot with automatic dependency updaters which are a lot more common these days (example, one library update results in 6 dependency updates - 5 after I pinned a breaking one)
* we have a new dependency solver on hex since the original issue was opened, which might make things easier/better (I don't know)

Other package managers
* bundler supports --conservative
* cargo update just works like this by default, i.e. the docs say:

If packages are specified with spec, then a conservative update of the lockfile will be performed. This means that only the dependency specified by SPEC will be updated. Its transitive dependencies will be updated only if SPEC cannot be updated without updating dependencies. All other dependencies will remain locked at their currently recorded versions.
The `--recursive` options opts it in to the more aggressive/update all transitive dependencies behavior.

From my reading npm always re-resolves the entire tree while yarn tries to only update the specified dependency. But the JS dependency ecosystem and resolution is different enough that I didn't want to dive too deep there.

--------

Any which way, thank you all for your work as usual! 💚
Tobi

José Valim

unread,
12:52 PM (10 hours ago) 12:52 PM
to elixir-l...@googlegroups.com
Hi Tobi,

Thanks for the proposal.

> if the new version needs a bump in dependency the resolver will fail - then you have to either unlock multiple dependencies or use normal `mix deps.update` with transitive updates again (or temporarlily "lock" them inside your mix.exs when you don't want them to change)

My understanding is that this is an intrinsic issue with conservative updates. If we don't want it to fail, then we need to run resolution again, with the dependencies unlocked. Perhaps one option is to automatically relax if the conservative one does not work but I am not sure if that behaviour should be the default or opt-in.


José Valim


--
You received this message because you are subscribed to the Google Groups "elixir-lang-core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to elixir-lang-co...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/elixir-lang-core/CAG3Z5YT1U7dA8gG8MVkTjS4%3Dmm7exs7%2Bgm6Tdm0Gjq%2BgwTiB%3Dg%40mail.gmail.com.

José Valim

unread,
12:54 PM (10 hours ago) 12:54 PM
to elixir-l...@googlegroups.com
Btw, a compromise would be "mix deps.update --unlock foo bar", which does the combination of unlocking and then fetching dependencies. It could automatically retry the broader approach too. But, in case both fails, foo and bar remain unlocked.


José Valim

Tobias Pfeiffer

unread,
1:36 PM (9 hours ago) 1:36 PM
to elixir-l...@googlegroups.com
Heyo,

thanks for the quick reply!

> My understanding is that this is an intrinsic issue with conservative updates. If we don't want it to fail, then we need to run resolution again, with the dependencies unlocked. Perhaps one option is to automatically relax if the conservative one does not work but I am not sure if that behaviour should be the default or opt-in.

So, I've never built a dependency resolver so I don't know how it works. I thought it could work in a way that if it fails to resolve without unlocking any transitive dependency, it'd take the first dependency and unlock that one. So for (A --> B --> C) if A fails to update because we need B on 1.1 we'd then update B but not touch C (no full unlock). So basically "gradually unlock".

Could check how cargo for instance deals with this, to see what's out there.

On Mon, Sep 21, 2026 at 6:54 PM José Valim <jose....@gmail.com> wrote:
Btw, a compromise would be "mix deps.update --unlock foo bar", which does the combination of unlocking and then fetching dependencies. It could automatically retry the broader approach too. But, in case both fails, foo and bar remain unlocked.
I did think about that to make the workaround basically "more baked in" - I think it'd be an improvement but still somewhat manual (although retrying with the broader approach would really help). I think foo and bar remaining unlocked on failure is a fine failure mode, you likely wouldn't commit that anyhow.

I didn't write this into the proposal (didn't wanna open too many discussions at once), but for `--conservative` another question would be how far it should update transitive dependencies. My preferred, but probably too complex/opinionated, option would be "lowest minor, highest patch level". So A 1.1 --> B ~> 1.1 and B 1.2.0 and B 1.1.0..1.1.9 then resolving to B 1.1.9 instead of B 1.2.0 sounds good (in the sense of keeping the updates "safer"). Technically not a big difference for minors but f.ex. if we do `>= 1.1` in that scenario I'd much prefer to get a 1.1.9 instead of a 2.0 update (and tackle the latter separately).
I bring this up because that'd be another point where it _could_ differ from unlocking.

I'm aware that dependency resolvers are very complex and that I'm probably thinking of this from a wildly too simple pov.

Thanks!
Tobi
 

José Valim

unread,
1:49 PM (9 hours ago) 1:49 PM
to elixir-l...@googlegroups.com
The constraint is most likely time. We can probably unlock dependencies one by one, but then it means you need to resolve dozens of times for large graphs, which can be very time consuming. So mix deps.update goes from instantaneous to taking several minutes.


José Valim


Reply all
Reply to author
Forward
0 new messages