Skip to content

Fallback store#50

Merged
faukah merged 19 commits intomainfrom
fallback-store
Jan 30, 2026
Merged

Fallback store#50
faukah merged 19 commits intomainfrom
fallback-store

Conversation

@Dragyx
Copy link
Collaborator

@Dragyx Dragyx commented Jan 29, 2026

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 .

Copilot AI review requested due to automatic review settings January 29, 2026 19:08
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 StoreFrontend trait to abstract database operations
  • Refactors Connection into DBConnection implementing the new trait
  • Adds CombinedStoreFrontend wrapper that attempts multiple backends with fallback logic
  • Changes query return types from impl Iterator to Box<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.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Dragyx
Copy link
Collaborator Author

Dragyx commented Jan 29, 2026

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.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Comment on lines 548 to 571
/// 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(())
}
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +43
/// 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)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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.

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
Comment on lines 450 to 453
/// 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.
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."

Suggested change
/// 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.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +517 to +169
combined_err = match combined_err {
Some(combined) => Some(combined.context(err)),
None => Some(err),
}
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
.arg("--closure-size")
.arg(path.join("sw"))
.output()
.context(anyhow!("Encountered error while executing nix command"))?;
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")?.

Suggested change
.context(anyhow!("Encountered error while executing nix command"))?;
.context("Encountered error while executing nix command")?;

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
.arg("--closure-size")
.arg(path.join("sw"))
.output()
.context(anyhow!("Encountered error while executing nix command"))?;
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.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
));
}

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
) -> Result<Box<dyn Iterator<Item = StorePath>>> {
let references = Command::new("nix-store").args(args).output();

let query = references?;
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
));
}

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
Comment on lines 221 to 234
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))
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
Comment on lines 657 to 677
.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(),
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
Comment on lines 628 to 629
let path = StorePath::try_from(PathBuf::from(line))
.context(anyhow!("encountered invalid path in shell output: {line}"))?;
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
}
}

impl<'a> StoreBackend<'a> for DBConnection<'_> {
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
impl<'a> StoreBackend<'a> for DBConnection<'_> {
impl<'a> StoreBackend<'a> for DBConnection<'a> {

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +43
/// 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)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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`.

Copilot uses AI. Check for mistakes.
src/store.rs Outdated
Comment on lines 618 to 619
fn shell_query<'a>(
args: &'a [&'a str],
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]).

Suggested change
fn shell_query<'a>(
args: &'a [&'a str],
fn shell_query(
args: &[&str],

Copilot uses AI. Check for mistakes.
@Dragyx Dragyx force-pushed the fallback-store branch 2 times, most recently from a233c4d to cbe1d85 Compare January 30, 2026 14:04
@Dragyx Dragyx mentioned this pull request Jan 30, 2026
Dragyx and others added 11 commits January 30, 2026 18:13
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.
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.
@Dragyx
Copy link
Collaborator Author

Dragyx commented Jan 30, 2026

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.

@faukah faukah merged commit c260c5d into main Jan 30, 2026
4 checks passed
@faukah faukah deleted the fallback-store branch January 30, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

attempt to write a readonly database

2 participants