|
3 | 3 | //! When passing large file lists to shell commands, the total argument length can exceed |
4 | 4 | //! the operating system's `ARG_MAX` limit, causing "Argument list too long" errors. |
5 | 5 | //! |
6 | | -//! This module provides automatic batching that: |
7 | | -//! 1. Estimates the shell-quoted size of file lists |
8 | | -//! 2. Uses binary search to find optimal batch sizes |
9 | | -//! 3. Splits jobs to stay within safe limits |
| 6 | +//! This module renders the actual run command for each job and only splits jobs whose |
| 7 | +//! rendered command exceeds the safe limit. Steps whose commands don't reference |
| 8 | +//! `{{files}}` (or render to a small string for any other reason) are left as a |
| 9 | +//! single job, even when the underlying file list is large. |
10 | 10 |
|
11 | 11 | use crate::env; |
12 | 12 | use crate::step_job::StepJob; |
| 13 | +use crate::tera; |
13 | 14 | use std::path::PathBuf; |
| 15 | +use std::sync::Arc; |
14 | 16 |
|
15 | 17 | use super::types::Step; |
16 | 18 |
|
17 | 19 | impl Step { |
18 | 20 | /// Estimates the size of the `{{files}}` template variable expansion. |
19 | 21 | /// |
20 | | - /// This includes shell quoting overhead and spaces between files. |
21 | | - /// Uses a conservative estimate (2x + 2 for quotes, +1 for space) to ensure |
22 | | - /// we don't underestimate. |
23 | | - /// |
24 | | - /// # Arguments |
25 | | - /// |
26 | | - /// * `files` - The list of files to estimate |
27 | | - /// |
28 | | - /// # Returns |
29 | | - /// |
30 | | - /// Estimated byte size of the quoted file list string |
| 22 | + /// Used as a fallback when rendering the run command fails (e.g. the |
| 23 | + /// template references a variable not yet present in the context). |
31 | 24 | pub(crate) fn estimate_files_string_size(&self, files: &[PathBuf]) -> usize { |
32 | 25 | files |
33 | 26 | .iter() |
34 | 27 | .map(|f| { |
35 | 28 | let path_str = f.to_str().unwrap_or(""); |
36 | | - // Estimate quoted size: conservative estimate assuming worst-case quoting |
37 | | - // For shell quoting, worst case is roughly 2x + 2 (quotes) |
38 | | - path_str.len() * 2 + 2 + 1 // +1 for space separator |
| 29 | + // Worst-case quoted size: 2x + 2 (quotes), +1 for space separator |
| 30 | + path_str.len() * 2 + 2 + 1 |
39 | 31 | }) |
40 | 32 | .sum() |
41 | 33 | } |
42 | 34 |
|
43 | | - /// Automatically batch jobs if the file list would exceed safe ARG_MAX limits. |
44 | | - /// |
45 | | - /// This prevents "Argument list too long" errors when passing large file lists |
46 | | - /// to commands. Uses binary search to find the largest batch size that fits |
47 | | - /// within a safe limit (50% of ARG_MAX to account for environment variables |
48 | | - /// and the command itself). |
49 | | - /// |
50 | | - /// # Arguments |
51 | | - /// |
52 | | - /// * `jobs` - The jobs to potentially batch |
| 35 | + /// Render the run command for a hypothetical job containing `files` and return |
| 36 | + /// its byte length. Returns `None` if rendering fails (e.g. the template |
| 37 | + /// references a variable not in the context). |
| 38 | + fn render_run_command_size( |
| 39 | + &self, |
| 40 | + original_job: &StepJob, |
| 41 | + files: &[PathBuf], |
| 42 | + base_tctx: &tera::Context, |
| 43 | + ) -> Option<usize> { |
| 44 | + let run_cmd = if original_job.check_first { |
| 45 | + self.check_first_cmd() |
| 46 | + } else { |
| 47 | + self.run_cmd(original_job.run_type) |
| 48 | + }?; |
| 49 | + let run = run_cmd.to_string(); |
| 50 | + if run.trim().is_empty() { |
| 51 | + return None; |
| 52 | + } |
| 53 | + let run = if let Some(prefix) = &self.prefix { |
| 54 | + format!("{prefix} {run}") |
| 55 | + } else { |
| 56 | + run |
| 57 | + }; |
| 58 | + |
| 59 | + let mut temp = StepJob::new( |
| 60 | + Arc::clone(&original_job.step), |
| 61 | + files.to_vec(), |
| 62 | + original_job.run_type, |
| 63 | + ); |
| 64 | + temp.check_first = original_job.check_first; |
| 65 | + if let Some(wi) = original_job.workspace_indicator() { |
| 66 | + temp = temp.with_workspace_indicator(wi.clone()); |
| 67 | + } |
| 68 | + let tctx = temp.tctx(base_tctx); |
| 69 | + tera::render(&run, &tctx).ok().map(|s| s.len()) |
| 70 | + } |
| 71 | + |
| 72 | + /// Automatically batch jobs whose rendered run command would exceed the safe ARG_MAX limit. |
53 | 73 | /// |
54 | | - /// # Returns |
| 74 | + /// Uses 50% of `ARG_MAX` as the safety margin (accounts for env vars and the command itself). |
| 75 | + /// Renders the actual run command with each candidate file subset; if the rendered command |
| 76 | + /// fits, no batching is performed. Otherwise binary-searches the largest batch size whose |
| 77 | + /// rendered command still fits. |
55 | 78 | /// |
56 | | - /// A new list of jobs, potentially with large jobs split into multiple smaller ones |
57 | | - pub(crate) fn auto_batch_jobs_if_needed(&self, jobs: Vec<StepJob>) -> Vec<StepJob> { |
58 | | - // Use 50% of ARG_MAX as a safety margin, accounting for environment variables |
59 | | - // and the command itself |
60 | | - let safe_limit = *env::ARG_MAX / 2; |
| 79 | + /// If rendering fails for any reason, falls back to estimating the size of the quoted |
| 80 | + /// file-list expansion — preserves the previous (purely size-based) behavior as a safety net. |
| 81 | + pub(crate) fn auto_batch_jobs( |
| 82 | + &self, |
| 83 | + jobs: Vec<StepJob>, |
| 84 | + base_tctx: &tera::Context, |
| 85 | + ) -> Vec<StepJob> { |
| 86 | + if self.stdin.is_some() { |
| 87 | + // stdin path doesn't pass files via argv; never auto-batch |
| 88 | + return jobs; |
| 89 | + } |
61 | 90 |
|
62 | | - let mut batched_jobs = Vec::new(); |
| 91 | + let safe_limit = *env::ARG_MAX / 2; |
| 92 | + let mut batched_jobs = Vec::with_capacity(jobs.len()); |
63 | 93 |
|
64 | 94 | for job in jobs { |
65 | | - let estimated_size = self.estimate_files_string_size(&job.files); |
66 | | - |
67 | | - if estimated_size > safe_limit && job.files.len() > 1 { |
68 | | - // Need to batch this job |
69 | | - debug!( |
70 | | - "{}: auto-batching {} files (estimated size: {} bytes, limit: {} bytes)", |
71 | | - self.name, |
72 | | - job.files.len(), |
73 | | - estimated_size, |
74 | | - safe_limit |
75 | | - ); |
76 | | - |
77 | | - // Binary search to find the largest batch_size where files fit within safe_limit |
78 | | - let mut low = 1; |
79 | | - let mut high = job.files.len(); |
80 | | - |
81 | | - while low < high { |
82 | | - let mid = (low + high).div_ceil(2); |
83 | | - let test_size = self.estimate_files_string_size(&job.files[..mid]); |
84 | | - |
85 | | - if test_size <= safe_limit { |
86 | | - // mid files fit, try larger |
87 | | - low = mid; |
88 | | - } else { |
89 | | - // mid files don't fit, try smaller |
90 | | - high = mid - 1; |
91 | | - } |
| 95 | + if job.skip_reason.is_some() || job.files.len() <= 1 { |
| 96 | + batched_jobs.push(job); |
| 97 | + continue; |
| 98 | + } |
| 99 | + |
| 100 | + // Try render-based sizing first; fall back to byte estimation on render failure. |
| 101 | + let full_size = self |
| 102 | + .render_run_command_size(&job, &job.files, base_tctx) |
| 103 | + .unwrap_or_else(|| self.estimate_files_string_size(&job.files)); |
| 104 | + |
| 105 | + if full_size <= safe_limit { |
| 106 | + batched_jobs.push(job); |
| 107 | + continue; |
| 108 | + } |
| 109 | + |
| 110 | + debug!( |
| 111 | + "{}: auto-batching {} files (rendered size: {} bytes, limit: {} bytes)", |
| 112 | + self.name, |
| 113 | + job.files.len(), |
| 114 | + full_size, |
| 115 | + safe_limit |
| 116 | + ); |
| 117 | + |
| 118 | + // Binary search the largest batch size whose rendered command fits. |
| 119 | + let mut low = 1; |
| 120 | + let mut high = job.files.len(); |
| 121 | + while low < high { |
| 122 | + let mid = (low + high).div_ceil(2); |
| 123 | + let test_size = self |
| 124 | + .render_run_command_size(&job, &job.files[..mid], base_tctx) |
| 125 | + .unwrap_or_else(|| self.estimate_files_string_size(&job.files[..mid])); |
| 126 | + if test_size <= safe_limit { |
| 127 | + low = mid; |
| 128 | + } else { |
| 129 | + high = mid - 1; |
92 | 130 | } |
| 131 | + } |
| 132 | + let batch_size = low.max(1); |
| 133 | + |
| 134 | + debug!( |
| 135 | + "{}: using batch size of {} files per batch", |
| 136 | + self.name, batch_size |
| 137 | + ); |
93 | 138 |
|
94 | | - // After binary search, low contains the largest batch size that fits |
95 | | - let batch_size = low.max(1); // Ensure at least 1 file per batch |
96 | | - |
97 | | - debug!( |
98 | | - "{}: using batch size of {} files per batch", |
99 | | - self.name, batch_size |
100 | | - ); |
101 | | - |
102 | | - // Create batched jobs - use the StepJob constructor to properly handle private fields |
103 | | - for chunk in job.files.chunks(batch_size) { |
104 | | - let new_job = StepJob::new(job.step.clone(), chunk.to_vec(), job.run_type); |
105 | | - // Note: we can't preserve workspace_indicator or other private fields |
106 | | - // without adding public methods to StepJob. For now, batching will |
107 | | - // break workspace_indicator jobs, but that's acceptable since those |
108 | | - // are typically small workspaces. |
109 | | - batched_jobs.push(new_job); |
| 139 | + for chunk in job.files.chunks(batch_size) { |
| 140 | + let mut new_job = StepJob::new(Arc::clone(&job.step), chunk.to_vec(), job.run_type); |
| 141 | + // Preserve job-level state that isn't reconstructed by StepJob::new. |
| 142 | + new_job.check_first = job.check_first; |
| 143 | + new_job.skip_reason = job.skip_reason.clone(); |
| 144 | + if let Some(wi) = job.workspace_indicator() { |
| 145 | + new_job = new_job.with_workspace_indicator(wi.clone()); |
110 | 146 | } |
111 | | - } else { |
112 | | - // No batching needed |
113 | | - batched_jobs.push(job); |
| 147 | + batched_jobs.push(new_job); |
114 | 148 | } |
115 | 149 | } |
116 | 150 |
|
|
0 commit comments