-
Notifications
You must be signed in to change notification settings - Fork 225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Remove pkgs from nix profile that are no longer on devbox.json #1181
Conversation
We would need to save normalized names for devbox.json packages and also profile unlocked references. I would suggest local.lock instead since it's purely a caching mechanism and not data that we care about. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! this LGTM. Just need to test perf edge cases. Especially flakes. You can try:
- Add many devbox packages (~20)
- Add flakes (local and remote)
- Add php Haskel packages (that have plugins that create flakes)
- Manually edit devbox.json to remove a few things.
- Call something that triggers tidy and time it.
Also, take a look at these optimizations to make sure nothing regressed:
#1050
return usererr.WithUserMessage( | ||
err, | ||
"There was an error installing nix packages", | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ineffectual change
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed, but I included it on purpose, to save on vertical space.
@@ -219,6 +216,16 @@ func (d *Devbox) profilePath() (string, error) { | |||
return absPath, errors.WithStack(os.MkdirAll(filepath.Dir(absPath), 0755)) | |||
} | |||
|
|||
func (d *Devbox) syncPackagesToProfile(ctx context.Context, mode installMode) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add docblock
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
if len(devboxInputs) == len(profileItems) { | ||
// Optimization: skip comparison if number of packages are the same. This only works | ||
// because we assume that all packages in `devbox.json` have just been added to the | ||
// profile. | ||
return nil, nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if this is always strictly true (as in does it ever falsly return when in fact packages don't match).
because we always add new package to profile first, I think any change do devbox.json will cause these lengths to missmatch if they are in fact different. So I think it works as expected, but relies on how add packages is implemented.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah it definitely relies on having added all devbox.json packages to the profile immediately beforehand.
I think it's a good compromise for now to avoid the performance hit. But it's better if we can merge the adding/removing into a single pass so we don't have to make these assumptions (and also save some computation time).
internal/nix/input.go
Outdated
@@ -24,6 +24,8 @@ type Input struct { | |||
url.URL | |||
lockfile lock.Locker | |||
Raw string | |||
|
|||
normalizedAttributePath string // memoized value from normalizedPackageAttributePath() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
normalizedAttributePathCache
so we can rename the method below to normalizedAttributePath
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. renamed to normalizedPackageAttributePathCache
. IT's long af but consistent with the other PackageAttributePath. I think we should drop the "Package" everywhere, but I didn't want to add scope to this PR. We can change that separately.
if i.IsDevboxPackage() { | ||
if i.isVersioned() { | ||
entry, err := i.lockfile.Resolve(i.Raw) | ||
if err != nil { | ||
return "", err | ||
} | ||
query = entry.Resolved | ||
} else { | ||
query = i.lockfile.LegacyNixpkgsPath(i.String()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did this logic change? Seems like an ineffectual change but more nesting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is a no-op change. I thought it was more clear this way.
It's just that isVersioned
implies isDevboxPkg
, but that's not immediately clear to a reader. And then the reader might think that the else-if branch applies to devbox packages. But it only really applies to non-versioned devbox packages. So, written this way makes the intention immediately clear without having to jump around the code.
But yeah, it's a bit subjective. I actually had also made the change on line 125 (that condition is always true).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess it's just weird/confusing to me to see control flow like:
if A && B {
...
} else if A {
...
} else {
...
}
internal/nix/profile.go
Outdated
cmd := exec.Command("nix", "profile", "remove", | ||
"--profile", profilePath, | ||
"--impure", // for NIXPKGS_ALLOW_UNFREE | ||
strings.Join(indexes, " "), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this behave OK when multiple indexes? I think we can also remove by unlocked reference but that may be a problem with duplicates
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I tested removing multiple things at the same time. Also tested passing indexes out-of-bounds. The command always succeeds (exit code 0). If any of the indexes passed are invalid, it prints a warning to stderr. Any valid indexes are still removed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I looked the nix profile remove --help
to decide what to use here. The only options are: by position (what we're doing), by attribute path (can use regex), or by nix store path.
Position seemed easiest to implement and hardest to mess up.
Using attribute paths would require us to split the unlockedRef by #
, and assume uniqueness (maybe I have two packages with the same attribute path but at different commits for some reason).
Update: I thought we didn't have the nix store path, but we do! But I don't see any benefit to using that instead of the position... hmm maybe a better error message in case of failure?
> nix profile remove --profile .devbox/nix/profile/default /nix/store/asdf
warning: '/nix/store/asdf' does not match any packages
warning: Use 'nix profile list' to see the current profile.
> nix profile remove --profile .devbox/nix/profile/default 4
warning: '4' is not a valid index
warning: Use 'nix profile list' to see the current profile.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add php Haskel packages (that have plugins that create flakes)
Do you have an example? If not, I'll use haskellPackages.acme-php
.
Sidenote: I tried searching for that using devbox search but failed, probably because we only allow single-word searches. Would be nice to allow multiple words in the future.
return usererr.WithUserMessage( | ||
err, | ||
"There was an error installing nix packages", | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed, but I included it on purpose, to save on vertical space.
if len(devboxInputs) == len(profileItems) { | ||
// Optimization: skip comparison if number of packages are the same. This only works | ||
// because we assume that all packages in `devbox.json` have just been added to the | ||
// profile. | ||
return nil, nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah it definitely relies on having added all devbox.json packages to the profile immediately beforehand.
I think it's a good compromise for now to avoid the performance hit. But it's better if we can merge the adding/removing into a single pass so we don't have to make these assumptions (and also save some computation time).
if i.IsDevboxPackage() { | ||
if i.isVersioned() { | ||
entry, err := i.lockfile.Resolve(i.Raw) | ||
if err != nil { | ||
return "", err | ||
} | ||
query = entry.Resolved | ||
} else { | ||
query = i.lockfile.LegacyNixpkgsPath(i.String()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is a no-op change. I thought it was more clear this way.
It's just that isVersioned
implies isDevboxPkg
, but that's not immediately clear to a reader. And then the reader might think that the else-if branch applies to devbox packages. But it only really applies to non-versioned devbox packages. So, written this way makes the intention immediately clear without having to jump around the code.
But yeah, it's a bit subjective. I actually had also made the change on line 125 (that condition is always true).
if i.IsDevboxPackage() { | ||
if i.isVersioned() { | ||
entry, err := i.lockfile.Resolve(i.Raw) | ||
if err != nil { | ||
return "", err | ||
} | ||
query = entry.Resolved | ||
} else { | ||
query = i.lockfile.LegacyNixpkgsPath(i.String()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess it's just weird/confusing to me to see control flow like:
if A && B {
...
} else if A {
...
} else {
...
}
internal/nix/profile.go
Outdated
cmd := exec.Command("nix", "profile", "remove", | ||
"--profile", profilePath, | ||
"--impure", // for NIXPKGS_ALLOW_UNFREE | ||
strings.Join(indexes, " "), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I tested removing multiple things at the same time. Also tested passing indexes out-of-bounds. The command always succeeds (exit code 0). If any of the indexes passed are invalid, it prints a warning to stderr. Any valid indexes are still removed.
internal/nix/profile.go
Outdated
cmd := exec.Command("nix", "profile", "remove", | ||
"--profile", profilePath, | ||
"--impure", // for NIXPKGS_ALLOW_UNFREE | ||
strings.Join(indexes, " "), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I looked the nix profile remove --help
to decide what to use here. The only options are: by position (what we're doing), by attribute path (can use regex), or by nix store path.
Position seemed easiest to implement and hardest to mess up.
Using attribute paths would require us to split the unlockedRef by #
, and assume uniqueness (maybe I have two packages with the same attribute path but at different commits for some reason).
Update: I thought we didn't have the nix store path, but we do! But I don't see any benefit to using that instead of the position... hmm maybe a better error message in case of failure?
> nix profile remove --profile .devbox/nix/profile/default /nix/store/asdf
warning: '/nix/store/asdf' does not match any packages
warning: Use 'nix profile list' to see the current profile.
> nix profile remove --profile .devbox/nix/profile/default 4
warning: '4' is not a valid index
warning: Use 'nix profile list' to see the current profile.
internal/nix/input.go
Outdated
@@ -24,6 +24,8 @@ type Input struct { | |||
url.URL | |||
lockfile lock.Locker | |||
Raw string | |||
|
|||
normalizedAttributePath string // memoized value from normalizedPackageAttributePath() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do
@@ -219,6 +216,16 @@ func (d *Devbox) profilePath() (string, error) { | |||
return absPath, errors.WithStack(os.MkdirAll(filepath.Dir(absPath), 0755)) | |||
} | |||
|
|||
func (d *Devbox) syncPackagesToProfile(ctx context.Context, mode installMode) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do
Hmm, why? Anyway, my original suggestion was to save the normalized package, like:
instead of what we do today, which is this:
But I guess that won't really work, because the normalized attribute paths are platform-dependent. We could save all of them, sure, but at that point it's probably not worth it to save a call to -- My other suggestion was that, when we convert a |
OK I finished testing the performance stuff, and also local and remote flakes. Performance-wise, the comparisons are fairly quick. It at most took ~4 seconds with doing it with ~20 packages. Since this scenario should not happen very often, I think it's fine to ship. I did notice that sometimes there's a lot of time spent after the profile syncing, and I'm not sure what is it, but it's unrelated and should be part of our efforts to improve performance overall. I think there's some relatively low-hanging fruit. Merging after tests pass. |
Summary
The goal is simple: if a package no longer exists in
devbox.json
, then it should be removed from the nix profile. The implementation is not as straightforward, because comparing packages can be very slow. Here we:nix.Input
so that it remembers the normalized package attribute path if it ever had to resolve it.Notes/questions for future improvements:
nix.Input
s in a bunch of places, and we end up making more calls to get the normalized attribute path when comparing packages. We should add a global cache or something to avoid duplicate calls.devbox.lock
to already contain the normalized attribute paths in itsresolved
field? That way we can probably skip many of the normalization calls.Fixes #1138
How was it tested?
Added a bunch of packages with
devbox add
. Removed them manually fromdevbox.json
and called a devbox command. Added fine logging to inspect what was going on and ensure not too many extra expensive calls are being added. Example output below: