Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Added** `vp run --dry-run` shows whether each task would hit the local cache, or why it would miss, without running anything ([#792](https://github.com/voidzero-dev/vite-task/pull/792)).
- **Changed** When a task isn't cached because it wrote a file it also read, `vp run --last-details` now says the task read and wrote the file, and shows the `cache: { input, output }` exclusions that let the task be cached ([#784](https://github.com/voidzero-dev/vite-task/pull/784)).
- **Changed** The run summary now says a task that wrote a file it also read was `not cached because it modified its inputs`, and the statistics in `vp run --verbose` and `vp run --last-details` use the singular for a count of one, e.g. `1 task • 1 cache miss` ([#783](https://github.com/voidzero-dev/vite-task/pull/783)).
- **Fixed** An invalid glob in `--filter` no longer shows its error message twice ([#763](https://github.com/voidzero-dev/vite-task/pull/763)).
Expand Down
14 changes: 13 additions & 1 deletion crates/vt/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ pub struct RunCommand {
#[clap(long, exclusive = true)]
pub(crate) last_details: bool,

/// Show whether each task would hit the local cache, without running anything.
#[clap(long)]
pub(crate) dry_run: bool,

#[clap(
allow_hyphen_values = true,
value_names = ["TASK_SPECIFIER", "ADDITIONAL_ARGS"],
Expand Down Expand Up @@ -200,6 +204,9 @@ pub struct ResolvedRunCommand {

pub flags: RunFlags,

/// Report predicted cache results instead of running tasks (`--dry-run`).
pub dry_run: bool,

/// Additional arguments to pass to the tasks.
pub additional_args: Vec<Str>,
}
Expand All @@ -214,7 +221,12 @@ impl RunCommand {
let mut iter = self.task_and_args.into_iter();
let task_specifier = iter.next();
let additional_args: Vec<Str> = iter.collect();
ResolvedRunCommand { task_specifier, flags: self.flags, additional_args }
ResolvedRunCommand {
task_specifier,
flags: self.flags,
dry_run: self.dry_run,
additional_args,
}
}
}

Expand Down
49 changes: 44 additions & 5 deletions crates/vt/src/session/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ pub use display::{
SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str,
format_spawn_change,
};
use rusqlite::{Connection, OptionalExtension as _};
use rusqlite::{Connection, OpenFlags, OptionalExtension as _};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
Expand Down Expand Up @@ -318,6 +318,24 @@ impl ExecutionCache {
})
}

/// Open the cache database at `path` read-only, for `--dry-run`. Returns
/// `None` if there is no database yet. Nothing is created or written.
pub fn open_read_only(path: &AbsolutePath) -> anyhow::Result<Option<Self>> {
let db_path = path.join("cache.db");
if !db_path.as_path().exists() {
return Ok(None);
}
let conn = Connection::open_with_flags(
db_path.as_path(),
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
Ok(Some(Self {
conn: Mutex::new(conn),
remote_clients: RemoteClients::default(),
uploads: RemoteUploads::default(),
}))
}

#[tracing::instrument]
pub async fn save(self) -> anyhow::Result<()> {
// do some cleanup in the future
Expand Down Expand Up @@ -348,7 +366,13 @@ impl ExecutionCache {
.try_hit_local(cache_metadata, &cache_key, globbed_inputs, workspace_root)
.await?
{
Ok(value) => return Ok(Ok(CacheHit { value, source: CacheHitSource::Local })),
Ok(value) => {
// Associate the execution key to the cache entry key if not already,
// so that next time we can find it and report what changed
self.upsert_task_fingerprint(&cache_metadata.execution_cache_key, &cache_key)
.await?;
return Ok(Ok(CacheHit { value, source: CacheHitSource::Local }));
}
Err(miss) => miss,
};
#[expect(
Expand Down Expand Up @@ -385,6 +409,24 @@ impl ExecutionCache {
}))
}

/// Look up the task in the local cache without writing to it, for
/// `--dry-run`. Returns `Ok(Ok(_))` on a hit and `Ok(Err(miss))` otherwise.
/// The remote cache is never queried.
pub async fn peek_local(
&self,
cache_metadata: &CacheMetadata,
globbed_inputs: &BTreeMap<RelativePathBuf, u64>,
workspace_root: &AbsolutePath,
) -> anyhow::Result<Result<(), CacheMiss>> {
let cache_key = CacheEntryKey::from_metadata(cache_metadata);
Ok(self
.try_hit_local(cache_metadata, &cache_key, globbed_inputs, workspace_root)
.await?
.map(drop))
}

/// Look up the task in the local cache. Read-only: on a hit, the caller
/// decides whether to record the execution key.
async fn try_hit_local(
&self,
cache_metadata: &CacheMetadata,
Expand All @@ -403,9 +445,6 @@ impl ExecutionCache {
)? {
return Ok(Err(CacheMiss::FingerprintMismatch(mismatch)));
}
// Associate the execution key to the cache entry key if not already,
// so that next time we can find it and report what changed
self.upsert_task_fingerprint(execution_cache_key, cache_key).await?;
return Ok(Ok(cache_value));
}

Expand Down
148 changes: 148 additions & 0 deletions crates/vt/src/session/execute/dry_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! `vp run --dry-run`: walk the planned graph in dependency order and predict
//! each task's cache result from the local cache, without running anything or
//! writing to the cache.

use std::{io::Write as _, sync::Arc};

use rustc_hash::FxHashMap;
use vt_path::AbsolutePath;
use vt_plan::{
ExecutionGraph, ExecutionItemDisplay, ExecutionItemKind, LeafExecutionKind, SpawnExecution,
execution_graph::ExecutionNodeIndex,
};
use vt_str::Str;

use super::glob::compute_globbed_inputs;
use crate::{
Session,
session::{
cache::ExecutionCache,
reporter::{
dry_run::{DryRunPrediction, format_dry_run_line, format_remote_not_checked},
summary::SavedCacheMissReason,
},
},
};

struct DryRun<'a> {
/// `None` when there is no local cache yet, so every lookup is a miss.
cache: Option<&'a ExecutionCache>,
workspace_root: &'a Arc<AbsolutePath>,
out: Vec<u8>,
/// Set when a task has a remote cache configured. It isn't queried.
remote_cache_configured: bool,
}

impl DryRun<'_> {
/// Predict every task in `graph`, dependencies first. `runs_after` names a
/// task outside `graph` that runs first and isn't a cache hit.
///
/// Returns whether every task in `graph` is a predicted hit.
async fn walk_graph(
&mut self,
graph: &ExecutionGraph,
runs_after: Option<&Str>,
) -> anyhow::Result<bool> {
// Tasks that aren't predicted hits, by node. Edge A→B means A depends
// on B, so reversing the topological order puts dependencies first.
let mut not_hit: FxHashMap<ExecutionNodeIndex, Str> = FxHashMap::default();
for node_ix in graph.graph.compute_topological_order().into_iter().rev() {
let task = &graph.graph[node_ix];
let mut blocker: Option<Str> = runs_after.cloned().or_else(|| {
graph.graph.neighbors(node_ix).find_map(|dep| not_hit.get(&dep).cloned())
});
let mut task_hit = true;
for item in &task.items {
let display = &item.execution_item_display;
let item_hit = match &item.kind {
ExecutionItemKind::Leaf(LeafExecutionKind::InProcess(_)) => {
// Built-ins don't change files, so they don't block later parts.
self.write(display, &DryRunPrediction::BuiltIn);
true
}
ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn)) => {
self.spawn_leaf(display, spawn, blocker.as_ref()).await?
}
ExecutionItemKind::Expanded(nested) => {
Box::pin(self.walk_graph(nested, blocker.as_ref())).await?
}
};
if !item_hit {
task_hit = false;
// Later `&&` parts run after this one.
blocker.get_or_insert_with(|| display.command.clone());
}
}
if !task_hit {
not_hit.insert(node_ix, vt_str::format!("{}", task.task_display));
}
}
Ok(not_hit.is_empty())
}

/// Predict one spawned process and write its line. Returns whether it's a
/// predicted hit.
async fn spawn_leaf(
&mut self,
display: &ExecutionItemDisplay,
spawn: &SpawnExecution,
runs_after: Option<&Str>,
) -> anyhow::Result<bool> {
let Some(metadata) = &spawn.cache_metadata else {
self.write(display, &DryRunPrediction::Disabled);
return Ok(false);
};
self.remote_cache_configured |= metadata.remote_cache.is_some();

let prediction = if let Some(runs_after) = runs_after {
DryRunPrediction::Unknown { runs_after: runs_after.clone() }
} else {
let globbed_inputs = compute_globbed_inputs(
self.workspace_root,
&metadata.input_config.positive_globs,
&metadata.input_config.negative_globs,
)?;
let lookup = match self.cache {
Some(cache) => {
cache.peek_local(metadata, &globbed_inputs, self.workspace_root).await?
}
None => Err(crate::session::CacheMiss::NotFound),
};
match lookup {
Ok(()) => DryRunPrediction::Hit,
Err(miss) => DryRunPrediction::Miss(SavedCacheMissReason::from_cache_miss(&miss)),
}
};
let hit = matches!(prediction, DryRunPrediction::Hit);
self.write(display, &prediction);
Ok(hit)
}

fn write(&mut self, display: &ExecutionItemDisplay, prediction: &DryRunPrediction) {
self.out.extend_from_slice(
format_dry_run_line(display, self.workspace_root, prediction).as_bytes(),
);
}
}

impl Session<'_> {
/// Report what running `graph` would do with the local cache, without
/// running any task. Nothing is written to the cache directory.
pub(crate) async fn dry_run(&self, graph: &ExecutionGraph) -> anyhow::Result<()> {
let cache = ExecutionCache::open_read_only(&self.cache_path)?;
let mut dry_run = DryRun {
cache: cache.as_ref(),
workspace_root: &self.workspace_path,
out: Vec::new(),
remote_cache_configured: false,
};
dry_run.walk_graph(graph, None).await?;
if dry_run.remote_cache_configured {
dry_run.out.extend_from_slice(format_remote_not_checked().as_bytes());
}
let mut stdout = std::io::stdout().lock();
stdout.write_all(&dry_run.out)?;
stdout.flush()?;
Ok(())
}
}
1 change: 1 addition & 0 deletions crates/vt/src/session/execute/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod cache_update;
mod dry_run;
pub mod fingerprint;
pub mod glob;
mod hash;
Expand Down
57 changes: 37 additions & 20 deletions crates/vt/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ impl vt_plan::PlanRequestParser for PlanRequestParser<'_> {
command.to_synthetic_plan_request(UserCacheConfig::disabled()),
)))
}
ResolvedCommand::Run(run_command) if run_command.dry_run => {
// A nested `--dry-run` runs no tasks, so it isn't expanded into
// the parent's graph.
Ok(Some(PlanRequest::Synthetic(
command.to_synthetic_plan_request(UserCacheConfig::disabled()),
)))
}
ResolvedCommand::Run(run_command) => {
match run_command.into_query_plan_request(&command.cwd) {
Ok((query_plan_request, _)) => {
Expand Down Expand Up @@ -336,6 +343,10 @@ impl<'a> Session<'a> {
self.plan_from_query(qpr).await?
};

if run_command.dry_run {
return self.dry_run(&graph).await.map_err(SessionError::from);
}

let workspace_path = self.workspace_path();
let writer: Box<dyn std::io::Write> = Box::new(std::io::stdout());

Expand All @@ -349,26 +360,12 @@ impl<'a> Session<'a> {
stderr: stderr_supports_color(),
};

let inner: Box<dyn reporter::GraphExecutionReporterBuilder> = match run_command
.flags
.log
{
crate::cli::LogMode::Interleaved => Box::new(InterleavedReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
};
let inner = log_reporter_builder(
run_command.flags.log,
Arc::clone(&workspace_path),
writer,
color_support,
);

let builder = Box::new(SummaryReporterBuilder::new(
inner,
Expand Down Expand Up @@ -875,3 +872,23 @@ fn stderr_supports_color() -> bool {
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stderr).is_some())
}

/// Build the reporter for the `--log` mode.
fn log_reporter_builder(
log: crate::cli::LogMode,
workspace_path: Arc<AbsolutePath>,
writer: Box<dyn std::io::Write>,
color_support: ColorSupport,
) -> Box<dyn reporter::GraphExecutionReporterBuilder> {
match log {
crate::cli::LogMode::Interleaved => {
Box::new(InterleavedReporterBuilder::new(workspace_path, writer, color_support))
}
crate::cli::LogMode::Labeled => {
Box::new(LabeledReporterBuilder::new(workspace_path, writer, color_support))
}
crate::cli::LogMode::Grouped => {
Box::new(GroupedReporterBuilder::new(workspace_path, writer, color_support))
}
}
}
Loading