Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a fallback store mechanism to handle database connection failures gracefully. It refactors the existing database connection code into a trait-based abstraction that allows multiple database connection backends to be combined with automatic fallback behavior.
Changes:
- Introduces
StoreFrontendtrait to abstract database operations - Refactors
ConnectionintoDBConnectionimplementing the new trait - Adds
CombinedStoreFrontendwrapper that attempts multiple backends with fallback logic - Changes query return types from
impl IteratortoBox<dyn Iterator>to support trait objects
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
| src/store.rs | Introduces StoreFrontend trait, DBConnection struct, CombinedStoreFrontend wrapper for fallback behavior, and refactors query methods to return boxed iterators |
| src/diff.rs | Updates to use new CombinedStoreFrontend::default() instead of store::connect() |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
5de3f1d to
7d0e1d6
Compare
|
I also plan to add a fallback to traditional nix shell commands to ensure that there is a worst case fallback option should something change with the database location / schema. Although I am not sure if I will accomplish this in this PR. |
c894457 to
fffac74
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/store.rs
Outdated
| /// Closes all connected frontends. | ||
| /// | ||
| /// If some fail to close, the combined error is returned. | ||
| fn close(&mut self) -> Result<()> { | ||
| let mut combined_err: Option<anyhow::Error> = None; | ||
| for (i, frontend) in self.frontends.iter_mut().enumerate() { | ||
| if frontend.connected() { | ||
| if let Err(err) = frontend.close() { | ||
| warn!( | ||
| "Unable to close store frontend {i}: {frontend}. (error: {err})" | ||
| ); | ||
| combined_err = match combined_err { | ||
| Some(combined) => Some(combined.context(err)), | ||
| None => Some(err), | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| if let Some(err) = combined_err { | ||
| Err(err.context("One or more frontends failed to close.")) | ||
| } else { | ||
| Ok(()) | ||
| } | ||
| } |
There was a problem hiding this comment.
The close functionality of CombinedStoreFrontend lacks test coverage. Consider adding a test to verify that: (1) all connected frontends are closed, (2) errors from individual close operations are properly collected and combined, and (3) the close method handles cases where some frontends are not connected.
| /// A backup database connection that can access the database | ||
| /// even in a read-only environment | ||
| /// | ||
| /// This might produce incorrect results as the connection is not guaranteed | ||
| /// to be the only one accessing the database. (There might be e.g. a | ||
| /// `nixos-rebuild` modifying the database) |
There was a problem hiding this comment.
The documentation comment could be clearer. Consider rephrasing to: "A fallback database connection for read-only environments (e.g., when the filesystem is mounted read-only). Uses the immutable flag which tells SQLite the database won't change, enabling optimizations. However, if other processes modify the database while this connection is active, queries may return stale or incorrect results." This makes it clearer that immutable mode is a fallback for read-only environments, not a preferred mode.
| /// A backup database connection that can access the database | |
| /// even in a read-only environment | |
| /// | |
| /// This might produce incorrect results as the connection is not guaranteed | |
| /// to be the only one accessing the database. (There might be e.g. a | |
| /// `nixos-rebuild` modifying the database) | |
| /// A fallback database connection for read-only environments | |
| /// (e.g., when the filesystem is mounted read-only). | |
| /// | |
| /// Uses the SQLite `immutable=1` flag, which tells SQLite the database | |
| /// will not change, enabling certain optimizations. However, if other | |
| /// processes modify the database while this connection is active, queries | |
| /// may return stale or incorrect results. |
src/store.rs
Outdated
| /// combines multiple store frontends by falling back to the next one if the | ||
| /// current one fails. | ||
| /// | ||
| /// Currently, the first frontend that works when connecting is used. |
There was a problem hiding this comment.
The documentation comment is misleading. The implementation attempts to connect to ALL frontends, not just the first one that works. During queries, it tries each connected frontend in order until one succeeds. Consider updating the comment to: "Attempts to connect to all frontends during initialization. During queries, frontends are tried in order until one succeeds."
| /// combines multiple store frontends by falling back to the next one if the | |
| /// current one fails. | |
| /// | |
| /// Currently, the first frontend that works when connecting is used. | |
| /// Combines multiple store frontends by falling back to the next one if the | |
| /// current one fails. | |
| /// | |
| /// Attempts to connect to all frontends during initialization. During | |
| /// queries, frontends are tried in order until one succeeds. |
aec8d01 to
476f927
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 14 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| combined_err = match combined_err { | ||
| Some(combined) => Some(combined.context(err)), | ||
| None => Some(err), | ||
| } |
There was a problem hiding this comment.
The error combination logic uses .context(err) to chain errors, but this may not produce the most useful error messages. When multiple backends fail, the error chain will show errors in reverse order (most recent first), which could be confusing. Consider using a different approach like collecting all errors into a list and formatting them clearly, or using something like anyhow::Error::new() with a custom message that lists all failures.
src/store.rs
Outdated
| .arg("--closure-size") | ||
| .arg(path.join("sw")) | ||
| .output() | ||
| .context(anyhow!("Encountered error while executing nix command"))?; |
There was a problem hiding this comment.
The error handling pattern .context(anyhow!("message")) is redundant. The .context() method from anyhow already creates an anyhow::Error with the provided message. Using anyhow!() inside .context() is unnecessary. This should be simplified to .context("Encountered error while executing nix command")?.
| .context(anyhow!("Encountered error while executing nix command"))?; | |
| .context("Encountered error while executing nix command")?; |
src/store.rs
Outdated
| .arg("--closure-size") | ||
| .arg(path.join("sw")) | ||
| .output() | ||
| .context(anyhow!("Encountered error while executing nix command"))?; |
There was a problem hiding this comment.
The shell command execution does not check the exit status of the command. If nix or nix-store commands fail (non-zero exit code), the function still attempts to parse stdout, which could lead to silent failures or misleading error messages. The implementation should check cmd_res.status.success() before attempting to parse the output and provide a clear error message if the command failed.
| .context(anyhow!("Encountered error while executing nix command"))?; | |
| .context(anyhow!("Encountered error while executing nix command"))?; | |
| if !cmd_res.status.success() { | |
| let stderr = String::from_utf8_lossy(&cmd_res.stderr); | |
| return Err(anyhow!( | |
| "nix path-info --closure-size failed with status {:?}: {}", | |
| cmd_res.status, | |
| stderr | |
| )); | |
| } |
src/store.rs
Outdated
| ) -> Result<Box<dyn Iterator<Item = StorePath>>> { | ||
| let references = Command::new("nix-store").args(args).output(); | ||
|
|
||
| let query = references?; |
There was a problem hiding this comment.
The shell command execution in shell_query does not check the exit status of the command. If nix-store fails (non-zero exit code), the function will attempt to parse stdout anyway, which could lead to silent failures or unexpected errors. The implementation should check query.status.success() before attempting to parse the output and provide a clear error message if the command failed.
| let query = references?; | |
| let query = references?; | |
| if !query.status.success() { | |
| let stderr = String::from_utf8_lossy(&query.stderr); | |
| return Err(anyhow!( | |
| "nix-store command failed with status {:?}: {}", | |
| query.status.code(), | |
| stderr | |
| )); | |
| } |
src/store.rs
Outdated
| pub(crate) fn execute_row_query_with_path<T, M>( | ||
| &self, | ||
| query: &str, | ||
| path: &Path, | ||
| map: M, | ||
| ) -> Result<impl Iterator<Item = T>> | ||
| ) -> Result<Box<dyn Iterator<Item = T> + '_>> | ||
| where | ||
| T: 'static, | ||
| M: Fn(&Row) -> rusqlite::Result<T>, | ||
| M: Fn(&Row) -> rusqlite::Result<T> + 'static, | ||
| { | ||
| let path = path_to_canonical_string(path)?; | ||
| let stmt = self.prepare_cached(query)?; | ||
| QueryIterator::try_new(stmt, [path], map) | ||
| let stmt = self.get_inner()?.prepare_cached(query)?; | ||
| let iter = QueryIterator::try_new(stmt, [path], map)?; | ||
| Ok(Box::new(iter)) |
There was a problem hiding this comment.
The return type has changed from Result<impl Iterator<Item = T>> to Result<Box<dyn Iterator<Item = T> + '_>>, introducing heap allocation and dynamic dispatch where it wasn't needed before. This has a performance cost. While boxing is necessary for trait object returns (as used in the StoreBackend trait), this internal helper function could still use the concrete type to avoid the overhead. Consider keeping the original return type for this internal method and only boxing when implementing the trait methods.
src/store.rs
Outdated
| .arg(path.join("sw")) | ||
| .output() | ||
| .context(anyhow!("Encountered error while executing nix command"))?; | ||
| let text = str::from_utf8(&cmd_res.stdout)?; | ||
| if let Some(bytes_text) = text.split_whitespace().last() | ||
| && let Ok(bytes) = bytes_text.parse::<u64>() | ||
| { | ||
| Ok(Size::from_bytes(bytes)) | ||
| } else { | ||
| Err(anyhow!("Unable to parse closure size from nix output")) | ||
| } | ||
| } | ||
|
|
||
| fn query_system_derivations( | ||
| &self, | ||
| system: &Path, | ||
| ) -> Result<Box<dyn Iterator<Item = StorePath> + '_>> { | ||
| shell_query(&[ | ||
| "--query", | ||
| "--references", | ||
| &*system.join("sw").to_string_lossy(), |
There was a problem hiding this comment.
The code assumes that system paths should have "/sw" appended to them when querying derivations and closure size. This assumption is hard-coded in the ShellBackend implementation but may not be universally true. If a caller provides a path that already includes the "/sw" component or doesn't need it, this could lead to incorrect results. Consider documenting this assumption or validating that the path structure matches expectations before appending "/sw".
src/store.rs
Outdated
| let path = StorePath::try_from(PathBuf::from(line)) | ||
| .context(anyhow!("encountered invalid path in shell output: {line}"))?; |
There was a problem hiding this comment.
The error handling pattern .context(anyhow!("message")) is redundant. The .context() method from anyhow already creates an anyhow::Error with the provided message. Using anyhow!() inside .context() is unnecessary. This should be simplified to .context(format!("encountered invalid path in shell output: {line}"))? or use a string literal if the variable interpolation isn't needed in the context.
src/store.rs
Outdated
| } | ||
| } | ||
|
|
||
| impl<'a> StoreBackend<'a> for DBConnection<'_> { |
There was a problem hiding this comment.
The trait implementation has a lifetime mismatch. The trait StoreBackend<'a> is parameterized with lifetime 'a, but the implementation impl<'a> StoreBackend<'a> for DBConnection<'_> uses a wildcard lifetime for DBConnection while declaring a separate 'a for the trait. These lifetimes should be aligned. The implementation should be impl<'a> StoreBackend<'a> for DBConnection<'a> to properly bind the lifetimes.
| impl<'a> StoreBackend<'a> for DBConnection<'_> { | |
| impl<'a> StoreBackend<'a> for DBConnection<'a> { |
| /// The normal database connection | ||
| pub const DATABASE_PATH: &str = "file:/nix/var/nix/db/db.sqlite"; | ||
| /// A backup database connection that can access the database | ||
| /// even in a read-only environment | ||
| /// | ||
| /// This might produce incorrect results as the connection is not guaranteed | ||
| /// to be the only one accessing the database. (There might be e.g. a | ||
| /// `nixos-rebuild` modifying the database) |
There was a problem hiding this comment.
The database path constants have changed. The old code used DATABASE_PATH with the immutable=1 parameter, but now DATABASE_PATH refers to the normal (non-immutable) connection and DATABASE_PATH_IMMUTABLE is the immutable one. This is a breaking change in behavior: the old connect() function always used the immutable mode, but now the default CombinedStoreBackend tries the normal connection first. This could lead to connection failures in read-only environments that worked before. Consider whether the order should be reversed, or document this breaking change clearly.
| /// The normal database connection | |
| pub const DATABASE_PATH: &str = "file:/nix/var/nix/db/db.sqlite"; | |
| /// A backup database connection that can access the database | |
| /// even in a read-only environment | |
| /// | |
| /// This might produce incorrect results as the connection is not guaranteed | |
| /// to be the only one accessing the database. (There might be e.g. a | |
| /// `nixos-rebuild` modifying the database) | |
| /// The normal database connection (read/write, when permitted by the OS). | |
| /// | |
| /// Note: In older versions, the default `connect()` implementation used the | |
| /// immutable connection by default. The current implementation treats this | |
| /// constant as the primary database path, and code such as | |
| /// `CombinedStoreBackend` may try this path first before falling back to an | |
| /// immutable connection (if it does so at all). | |
| /// | |
| /// This change is a **breaking behavior change** for users running in | |
| /// read-only environments: if the directory is not writable, attempts to use | |
| /// this path may now fail where previous versions succeeded. In such cases, | |
| /// callers should explicitly use `DATABASE_PATH_IMMUTABLE`. | |
| pub const DATABASE_PATH: &str = "file:/nix/var/nix/db/db.sqlite"; | |
| /// A backup database connection that can access the database | |
| /// even in a read-only environment using SQLite's `immutable=1` mode. | |
| /// | |
| /// This might produce incorrect results as the connection is not guaranteed | |
| /// to be the only one accessing the database. (There might be e.g. a | |
| /// `nixos-rebuild` modifying the database.) | |
| /// | |
| /// Historically, code like `connect()` used this immutable connection by | |
| /// default. If you are upgrading from such a version and rely on read-only | |
| /// behavior, ensure that you explicitly select this constant rather than | |
| /// relying on the default `DATABASE_PATH`. |
src/store.rs
Outdated
| fn shell_query<'a>( | ||
| args: &'a [&'a str], |
There was a problem hiding this comment.
The shell_query function signature declares an unused lifetime parameter 'a. The function signature is fn shell_query<'a>(args: &'a [&'a str]) but the returned iterator doesn't use this lifetime - it owns the data (Vec). The lifetime parameter should be removed for clarity: fn shell_query(args: &[&str]).
| fn shell_query<'a>( | |
| args: &'a [&'a str], | |
| fn shell_query( | |
| args: &[&str], |
a233c4d to
cbe1d85
Compare
9400add to
edaf486
Compare
Co-authored-by: faukah <fau@faukah.com>
The initial motivation for the name `StoreFrontend` was that the trait provides a unified interface / frontend to the actual backend querying the data from the database. While this is what the trait is doing, it is still more a backend for querying data that is then served by the code in `diff.rs`. Therefor, this commit changes the naming to backend.
prepare for shell backend.
If for some reason the direct SQL queries to the sqlite database of the Nix store fail, we fall back to `nix` and `nix-store` shell commands (and pray).
Focuses on correctness over speed.
This flag is added in preparation for a later inclusion of machine-readable output. It allows the user to specify that the output should prioritize correctness at all costs. This rules out the use of immutable database connections or lazy queries. The former have no correctness guarantees and lazy queries could (theoretically) raise an error for a later row e.g. because the path returned from the store is not a valid `StorePath`.
This query was never actually used by dix. We can include it again if we ever need it.
edaf486 to
424c915
Compare
|
There is not really much of a noticeable speed difference when switching between the EagerDBConnection and the LazyDBConnection (the eager one collects the results, the lazy one doesn't). I would still argue to keep both of them since the lazy connection might be useful when using dix as a connection to the nix DB in a different crate. |
At the moment, it is not possible to fall back to e.g. an immutable DB connection should something go wrong. This PR attempts to fix this by creating a wrapper that combines multiple database connection implementations into one and falls back should something fail.
Attempts to fix #47 .