/build/source/nativelink-worker/src/running_actions_manager.rs
Line | Count | Source |
1 | | // Copyright 2024 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | use core::cmp::min; |
16 | | use core::convert::Into; |
17 | | use core::fmt::Debug; |
18 | | use core::pin::Pin; |
19 | | use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; |
20 | | use core::time::Duration; |
21 | | use std::borrow::Cow; |
22 | | use std::collections::vec_deque::VecDeque; |
23 | | use std::collections::{BTreeMap, HashMap, HashSet}; |
24 | | use std::env; |
25 | | use std::ffi::{OsStr, OsString}; |
26 | | #[cfg(target_family = "unix")] |
27 | | use std::fs::Permissions; |
28 | | #[cfg(target_family = "unix")] |
29 | | use std::os::unix::fs::{MetadataExt, PermissionsExt}; |
30 | | #[cfg(target_family = "unix")] |
31 | | use std::os::unix::process::ExitStatusExt; |
32 | | use std::path::{Path, PathBuf}; |
33 | | use std::process::Stdio; |
34 | | use std::sync::{Arc, Weak}; |
35 | | use std::time::SystemTime; |
36 | | |
37 | | use bytes::{Bytes, BytesMut}; |
38 | | use filetime::{FileTime, set_file_mtime}; |
39 | | use formatx::Template; |
40 | | use futures::future::{ |
41 | | BoxFuture, Fuse, Future, FutureExt, TryFutureExt, join_all, try_join, try_join_all, |
42 | | }; |
43 | | use futures::stream::{FuturesUnordered, StreamExt, TryStreamExt}; |
44 | | use nativelink_config::cas_server::{ |
45 | | Buck2FileCaptureConfig, DiskEnforcement, EnvironmentSource, MemoryEnforcement, |
46 | | ResourceEnforcementConfig, UploadActionResultConfig, UploadCacheResultsStrategy, |
47 | | }; |
48 | | use nativelink_error::{Code, Error, ResultExt, make_err, make_input_err}; |
49 | | use nativelink_metric::MetricsComponent; |
50 | | use nativelink_proto::build::bazel::remote::execution::v2::{ |
51 | | Action, ActionResult as ProtoActionResult, Command as ProtoCommand, |
52 | | Directory as ProtoDirectory, Directory, DirectoryNode, ExecuteResponse, FileNode, SymlinkNode, |
53 | | Tree as ProtoTree, UpdateActionResultRequest, |
54 | | }; |
55 | | use nativelink_proto::com::github::trace_machina::nativelink::remote_execution::{ |
56 | | ActionResourceUsage, HistoricalExecuteResponse, Reservation, ResourceOutcome, StartExecute, |
57 | | }; |
58 | | use nativelink_store::ac_utils::{ |
59 | | ESTIMATED_DIGEST_SIZE, compute_buf_digest, get_and_decode_digest, serialize_and_upload_message, |
60 | | }; |
61 | | use nativelink_store::cas_utils::is_zero_digest; |
62 | | use nativelink_store::fast_slow_store::FastSlowStore; |
63 | | use nativelink_store::filesystem_store::{FileEntry, FileEntryImpl, FilesystemStore}; |
64 | | use nativelink_store::grpc_store::GrpcStore; |
65 | | use nativelink_util::action_messages::{ |
66 | | ActionInfo, ActionResult, DirectoryInfo, ExecutionMetadata, FileInfo, NameOrPath, OperationId, |
67 | | STORE_UNREACHABLE_ERROR_TIP, SymlinkInfo, to_execute_response, |
68 | | }; |
69 | | use nativelink_util::common::{DigestInfo, fs}; |
70 | | use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc}; |
71 | | use nativelink_util::metrics::{INPUT_MOUNT_METRICS, record_input_relink, record_prepare_retry}; |
72 | | use nativelink_util::metrics_utils::{AsyncCounterWrapper, CounterWithTime}; |
73 | | use nativelink_util::store_trait::{Store, StoreLike, UploadSizeInfo}; |
74 | | use nativelink_util::task::JoinHandleDropGuard; |
75 | | use nativelink_util::{background_spawn, spawn, spawn_blocking}; |
76 | | use parking_lot::Mutex; |
77 | | use prost::Message; |
78 | | use relative_path::RelativePath; |
79 | | use scopeguard::{ScopeGuard, guard}; |
80 | | use serde::Deserialize; |
81 | | use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; |
82 | | use tokio::process; |
83 | | use tokio::sync::{Notify, Semaphore, SemaphorePermit, oneshot, watch}; |
84 | | use tokio::time::Instant; |
85 | | use tokio_stream::wrappers::ReadDirStream; |
86 | | use tonic::Request; |
87 | | use tracing::{debug, error, info, trace, warn}; |
88 | | use uuid::Uuid; |
89 | | |
90 | | use crate::buck2_file_capture::Buck2FileCapture; |
91 | | use crate::input_mounts::InputMounts; |
92 | | use crate::persistent_worker::{ |
93 | | Input as PersistentWorkerInput, PersistentWorkerPool, PoolConfig, WireFormat, WorkRequest, |
94 | | WorkerKey, |
95 | | }; |
96 | | use crate::process_table::{ |
97 | | EXACT_MEMORY_READ_WALKS_PAGES, ProcessTableCache, live_process_table, process_exists, |
98 | | read_process_cpu_ticks, read_process_memory_bound_kb, read_process_memory_kb, ticks_to_millis, |
99 | | worker_pgid, |
100 | | }; |
101 | | |
102 | | /// For simplicity we use a fixed exit code for cases when our program is terminated |
103 | | /// due to a signal. |
104 | | const EXIT_CODE_FOR_SIGNAL: i32 = 9; |
105 | | |
106 | | const SUPPORTS_WORKERS_PROPERTY: &str = "supports-workers"; |
107 | | const REQUIRES_WORKER_PROTOCOL_PROPERTY: &str = "requires-worker-protocol"; |
108 | | |
109 | | /// Default strategy for uploading historical results. |
110 | | /// Note: If this value changes the config documentation |
111 | | /// should reflect it. |
112 | | const DEFAULT_HISTORICAL_RESULTS_STRATEGY: UploadCacheResultsStrategy = |
113 | | UploadCacheResultsStrategy::FailuresOnly; |
114 | | |
115 | | const RESOURCE_USAGE_SAMPLE_INTERVAL: Duration = Duration::from_millis(250); |
116 | | |
117 | | /// How many output files are open for upload at once, across the whole |
118 | | /// output tree: one semaphore, taken around each file from open to upload, |
119 | | /// so nested directories cannot multiply the bound. An unbounded set has hit |
120 | | /// the descriptor limit on actions with tens of thousands of outputs. The |
121 | | /// same number bounds each stream's fan-out. |
122 | | const UPLOAD_CONCURRENCY: usize = 64; |
123 | | |
124 | | /// What the sampler observed over an action's lifetime. |
125 | | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
126 | | struct SampledResourceUsage { |
127 | | peak_memory_kb: u64, |
128 | | cpu_time_ms: u64, |
129 | | /// The last reading before the group was gone, which is what a kernel |
130 | | /// kill is judged against. |
131 | | last_memory_kb: u64, |
132 | | /// The most the action's own files under its directory came to, when |
133 | | /// disk was measured; 0 otherwise. |
134 | | peak_disk_kb: u64, |
135 | | } |
136 | | |
137 | | /// Why the worker ended an action before the action ended itself. |
138 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
139 | | pub enum KillReason { |
140 | | Timeout, |
141 | | Memory, |
142 | | Disk, |
143 | | External, |
144 | | } |
145 | | |
146 | | const SIGKILL_NUMBER: i32 = 9; |
147 | | |
148 | | pub use nativelink_util::action_messages::MISSING_INPUT_ERROR_TIP; |
149 | | |
150 | 5 | fn tag_missing_input(err: Error) -> Error { |
151 | 5 | if err.code == Code::NotFound { |
152 | 1 | err.append(MISSING_INPUT_ERROR_TIP) |
153 | | } else { |
154 | 4 | err |
155 | | } |
156 | 5 | } |
157 | | |
158 | | /// How long a killed action's output pipes, or those of one that left |
159 | | /// stragglers behind, get to drain. They close when every process holding |
160 | | /// them is gone, which the SIGKILL to the group sees to; a process that |
161 | | /// left the group could still hold them, so that wait is bounded, and what |
162 | | /// was read by then is kept. An action that exited cleanly with nothing |
163 | | /// left behind is read to the end. |
164 | | const OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); |
165 | | |
166 | | /// Classifies how an action ended. A kill the worker sent names itself; a |
167 | | /// SIGKILL nobody here sent, with the last memory sample within 10% of the |
168 | | /// limit, is the kernel's OOM killer; any other signal is external. |
169 | 56 | pub fn classify_outcome( |
170 | 56 | kill_reason: Option<KillReason>, |
171 | 56 | signal: Option<i32>, |
172 | 56 | last_memory_kb: u64, |
173 | 56 | memory_limit_kb: Option<u64>, |
174 | 56 | ) -> (ResourceOutcome, bool) { |
175 | 56 | match (kill_reason, signal) { |
176 | 4 | (Some(KillReason::Memory), _) => (ResourceOutcome::KilledMemory, true), |
177 | 1 | (Some(KillReason::Disk), _) => (ResourceOutcome::KilledDisk, true), |
178 | 6 | (Some(KillReason::Timeout), _) => (ResourceOutcome::KilledTimeout, false), |
179 | 37 | (None, None) => (ResourceOutcome::Completed, false), |
180 | | (None, Some(SIGKILL_NUMBER)) => { |
181 | 3 | let near_limit = memory_limit_kb.is_some_and(|limit_kb| {2 |
182 | 2 | limit_kb > 0 && last_memory_kb.saturating_mul(10) >= limit_kb.saturating_mul(9) |
183 | 2 | }); |
184 | 3 | if near_limit { |
185 | 1 | (ResourceOutcome::KilledMemory, false) |
186 | | } else { |
187 | 2 | (ResourceOutcome::KilledExternal, false) |
188 | | } |
189 | | } |
190 | | (Some(KillReason::External), _) | (None, Some(_)) => { |
191 | 5 | (ResourceOutcome::KilledExternal, false) |
192 | | } |
193 | | } |
194 | 56 | } |
195 | | |
196 | | #[cfg(target_os = "linux")] |
197 | | type SpawnedChild = crate::namespace_utils::MaybeNamespacedChild; |
198 | | #[cfg(not(target_os = "linux"))] |
199 | | type SpawnedChild = process::Child; |
200 | | |
201 | | /// SIGKILL to every process group holding the action's processes, then |
202 | | /// through the child wrapper, which takes the action's own group with it; |
203 | | /// logged if it fails. |
204 | 11 | async fn hard_kill(child: &mut SpawnedChild, watch: &ActionMemoryWatch, why: &str) { |
205 | | #[cfg(target_family = "unix")] |
206 | 11 | if let Some(pgid) = child.id() { |
207 | 11 | signal_action(pgid, watch, libc::SIGKILL); |
208 | 11 | }0 |
209 | | #[cfg(not(target_family = "unix"))] |
210 | | let _ = watch; |
211 | 11 | if let Err(err0 ) = child.kill().await { |
212 | 0 | error!(?err, why, "Could not kill process in RunningActionsManager"); |
213 | 11 | } |
214 | 11 | } |
215 | | |
216 | | /// Starts stopping an action. With a grace period the process group gets |
217 | | /// SIGTERM and the returned future fires when SIGKILL is due; without one |
218 | | /// SIGKILL goes out now and the future never fires. |
219 | 0 | async fn stop_action( |
220 | 0 | child: &mut SpawnedChild, |
221 | 0 | watch: &ActionMemoryWatch, |
222 | 0 | grace: Duration, |
223 | 0 | why: &str, |
224 | 8 | ) -> Fuse<BoxFuture<'static, ()>> { |
225 | 8 | debug!(grace_ms = grace.as_millis(), why, "Stopping action"); |
226 | | #[cfg(target_os = "linux")] |
227 | 8 | if !grace.is_zero() |
228 | 1 | && let Some(pgid) = child.id() |
229 | | { |
230 | 1 | signal_action(pgid, watch, libc::SIGTERM); |
231 | 1 | return tokio::time::sleep(grace).boxed().fuse(); |
232 | 7 | } |
233 | 7 | hard_kill(child, watch, why).await; |
234 | 7 | Fuse::terminated() |
235 | 8 | } |
236 | | |
237 | | /// The reservation the action was admitted with, read back from its |
238 | | /// platform properties so the measurement travels with its yardstick. |
239 | 48 | fn reservation_of( |
240 | 48 | properties: &HashMap<String, String>, |
241 | 48 | memory_property: Option<&str>, |
242 | 48 | disk_property: Option<&str>, |
243 | 48 | ) -> Option<Reservation> { |
244 | 144 | let read48 = |name: &str| { |
245 | 144 | properties |
246 | 144 | .get(name) |
247 | 144 | .and_then(|v| v.trim()6 .parse6 ::<u64>().ok6 ()) |
248 | 144 | }; |
249 | 48 | let reservation = Reservation { |
250 | 48 | cpu_count: read("cpu_count").unwrap_or(0), |
251 | 48 | memory_kb: read(memory_property.unwrap_or("memory_kb")).unwrap_or(0), |
252 | 48 | disk_kb: read(disk_property.unwrap_or("disk_kb")).unwrap_or(0), |
253 | 48 | }; |
254 | 48 | (reservation.cpu_count > 0 || reservation.memory_kb > 0 || reservation.disk_kb > 043 ) |
255 | 48 | .then_some(reservation) |
256 | 48 | } |
257 | | |
258 | | #[cfg(target_os = "linux")] |
259 | 82 | fn pod_memory_limit_kb() -> Option<u64> { |
260 | 82 | crate::capacity::observe_cgroup().map(|observed| observed.memory_kb) |
261 | 82 | } |
262 | | #[cfg(not(target_os = "linux"))] |
263 | | const fn pod_memory_limit_kb() -> Option<u64> { |
264 | | None |
265 | | } |
266 | | |
267 | | struct ActionResourceUsageSampler { |
268 | | stop_tx: watch::Sender<bool>, |
269 | | handle: tokio::task::JoinHandle<SampledResourceUsage>, |
270 | | } |
271 | | |
272 | | /// What the sampler found over a ceiling, with the figure it saw. |
273 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
274 | | enum OverLimit { |
275 | | Memory(u64), |
276 | | Disk(u64), |
277 | | /// The worker's cgroup stayed under memory pressure and this action |
278 | | /// held the most memory of those running; the figures are its last |
279 | | /// sample and the pressure that chose it. |
280 | | MemoryPressure { |
281 | | observed_kb: u64, |
282 | | full_avg10_percent: u64, |
283 | | }, |
284 | | } |
285 | | |
286 | | /// What one running action publishes for the worker-wide memory pressure |
287 | | /// monitor, and the kill it can ask of the action's sampler: the sampler |
288 | | /// writes `last_memory_kb` every sample and 0 once it stops; the monitor |
289 | | /// sets `kill_requested_percent` (the pressure it saw, never 0) and the |
290 | | /// sampler ends the action on its next pass through the same channel a |
291 | | /// ceiling breach uses. |
292 | | #[derive(Debug, Default)] |
293 | | pub struct ActionMemoryWatch { |
294 | | last_memory_kb: AtomicU64, |
295 | | /// The highest figure the sampler has read for the action; it only |
296 | | /// rises. Reported on the keepalive so a scheduler that loses this |
297 | | /// worker can tell which action grew past its reservation. |
298 | | peak_memory_kb: AtomicU64, |
299 | | /// The memory reservation the action runs under, 0 when it has none. |
300 | | reserved_memory_kb: AtomicU64, |
301 | | kill_requested_percent: AtomicU64, |
302 | | /// Every process group the sampler last found holding the action's |
303 | | /// processes. A test that `set -m` put in a group of its own keeps |
304 | | /// nothing linking it to the action once its wrapper exits; this is |
305 | | /// how the sampler still counts it and a kill still reaches it. Only |
306 | | /// the Linux sampler fills it. |
307 | | process_groups: Mutex<Vec<u32>>, |
308 | | } |
309 | | |
310 | | impl ActionMemoryWatch { |
311 | 8 | pub fn last_memory_kb(&self) -> u64 { |
312 | 8 | self.last_memory_kb.load(Ordering::Relaxed) |
313 | 8 | } |
314 | | |
315 | 6 | pub fn peak_memory_kb(&self) -> u64 { |
316 | 6 | self.peak_memory_kb.load(Ordering::Relaxed) |
317 | 6 | } |
318 | | |
319 | | /// Records a sample: the last figure, and the peak if it is higher. |
320 | 101 | pub fn observe(&self, memory_kb: u64) { |
321 | 101 | self.last_memory_kb.store(memory_kb, Ordering::Relaxed); |
322 | 101 | self.peak_memory_kb.fetch_max(memory_kb, Ordering::Relaxed); |
323 | 101 | } |
324 | | |
325 | 0 | pub fn reserved_memory_kb(&self) -> u64 { |
326 | 0 | self.reserved_memory_kb.load(Ordering::Relaxed) |
327 | 0 | } |
328 | | |
329 | 0 | pub fn kill_requested(&self) -> bool { |
330 | 0 | self.kill_requested_percent.load(Ordering::Relaxed) > 0 |
331 | 0 | } |
332 | | } |
333 | | |
334 | | /// One running action as the memory pressure kill sees it. |
335 | | #[derive(Debug, Clone, Copy)] |
336 | | pub struct PressureCandidate<T> { |
337 | | pub item: T, |
338 | | /// The sampler's last figure, 0 before the first sample and after the |
339 | | /// sampler stopped. |
340 | | pub last_kb: u64, |
341 | | /// Its memory reservation, 0 when it has none. |
342 | | pub reserved_kb: u64, |
343 | | pub kill_requested: bool, |
344 | | } |
345 | | |
346 | | /// Picks the action a memory pressure kill lands on, among those with a |
347 | | /// sample and no kill already asked: the one furthest over its |
348 | | /// reservation, or, when none is over, the one whose last sample is |
349 | | /// largest. An action that reserves `whole_worker_kb` or more is never |
350 | | /// picked: it already has the worker to itself, and a memory kill there |
351 | | /// would leave the scheduler nothing larger to requeue it on. `None` when |
352 | | /// nothing qualifies, so a worker under pressure from something other than |
353 | | /// its actions kills nothing. |
354 | 8 | pub fn pick_pressure_victim<T>( |
355 | 8 | candidates: impl IntoIterator<Item = PressureCandidate<T>>, |
356 | 8 | whole_worker_kb: Option<u64>, |
357 | 8 | ) -> Option<T> { |
358 | 8 | let eligible: Vec<PressureCandidate<T>> = candidates |
359 | 8 | .into_iter() |
360 | 12 | .filter8 (|c| c.last_kb > 0 && !c.kill_requested10 ) |
361 | 9 | .filter8 (|c| { |
362 | 9 | !whole_worker_kb.is_some_and(|whole_kb| whole_kb > 05 && c.reserved_kb >= whole_kb5 ) |
363 | 9 | }) |
364 | 8 | .collect(); |
365 | 8 | let furthest_over = eligible |
366 | 8 | .iter() |
367 | 8 | .enumerate() |
368 | 8 | .filter(|(_, c)| c.last_kb6 > c.reserved_kb6 ) |
369 | 8 | .max_by_key(|(_, c)| c.last_kb3 - c.reserved_kb3 ) |
370 | 8 | .map(|(index, _)| index); |
371 | 8 | let index4 = furthest_over.or_else(|| {5 |
372 | 5 | eligible |
373 | 5 | .iter() |
374 | 5 | .enumerate() |
375 | 5 | .max_by_key(|(_, c)| c.last_kb) |
376 | 5 | .map(|(index, _)| index) |
377 | 5 | })?4 ; |
378 | 4 | eligible.into_iter().nth(index).map(|c| c.item) |
379 | 8 | } |
380 | | |
381 | | /// Decides when sustained memory pressure becomes a kill: `observe` is |
382 | | /// handed each `memory.pressure` `full avg10` reading and answers true |
383 | | /// once the reading has stayed at or above the threshold for `after`. |
384 | | /// A reading under the threshold starts over, and so does a kill, so the |
385 | | /// next one waits a whole window again while the freed memory takes |
386 | | /// effect. |
387 | | #[derive(Debug, Clone, Copy)] |
388 | | pub struct PressureWindow { |
389 | | threshold_percent: f64, |
390 | | after: Duration, |
391 | | over_since: Option<Instant>, |
392 | | } |
393 | | |
394 | | impl PressureWindow { |
395 | 1 | pub const fn new(threshold_percent: u64, after: Duration) -> Self { |
396 | 1 | Self { |
397 | 1 | threshold_percent: threshold_percent as f64, |
398 | 1 | after, |
399 | 1 | over_since: None, |
400 | 1 | } |
401 | 1 | } |
402 | | |
403 | 9 | pub fn observe(&mut self, full_avg10_percent: f64, now: Instant) -> bool { |
404 | 9 | if full_avg10_percent < self.threshold_percent { |
405 | 1 | self.over_since = None; |
406 | 1 | return false; |
407 | 8 | } |
408 | 8 | let since = *self.over_since.get_or_insert(now); |
409 | 8 | if now.saturating_duration_since(since) >= self.after { |
410 | 2 | self.over_since = None; |
411 | 2 | return true; |
412 | 6 | } |
413 | 6 | false |
414 | 9 | } |
415 | | } |
416 | | |
417 | | /// How often the worker reads its cgroup's `memory.pressure`. |
418 | | #[cfg(target_os = "linux")] |
419 | | const MEMORY_PRESSURE_SAMPLE_INTERVAL: Duration = Duration::from_secs(2); |
420 | | |
421 | | /// A PSI percentage as a whole number for the kill's message: clamped to |
422 | | /// 0 to 100 first, so the cast cannot truncate or lose a sign. |
423 | | #[cfg(target_os = "linux")] |
424 | | #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] |
425 | 0 | const fn whole_percent(value: f64) -> u64 { |
426 | 0 | value.clamp(0.0, 100.0).round() as u64 |
427 | 0 | } |
428 | | |
429 | | /// The ceilings the sampler enforces. Memory: two consecutive samples over |
430 | | /// `memory_limit_kb`, or one at twice it. Disk: one sample of the action's |
431 | | /// own files over `disk_limit_kb`. The first breach is sent on |
432 | | /// `over_limit_tx` and the caller kills the action; nothing is sent when no |
433 | | /// ceiling is set, and the caller keeps the sender alive so the receiver |
434 | | /// stays pending. |
435 | | struct Ceilings { |
436 | | memory_limit_kb: Option<u64>, |
437 | | disk_limit_kb: Option<u64>, |
438 | | over_limit_tx: Option<oneshot::Sender<OverLimit>>, |
439 | | /// Where each sample is published and where a pressure kill arrives. |
440 | | watch: Arc<ActionMemoryWatch>, |
441 | | } |
442 | | |
443 | | /// One disk sample every this many memory samples: a walk of the action |
444 | | /// directory is a stat per file, so it runs every ten seconds, not four |
445 | | /// times a second. |
446 | | const DISK_SAMPLE_EVERY: u32 = 40; |
447 | | |
448 | | /// `oom_limit_kb` is what a SIGKILL nobody here sent is judged against (see |
449 | | /// [`classify_outcome`]): the enforced ceiling, or the pod's limit without |
450 | | /// one. It is not enforced; the sampler keeps its figure exact near it. |
451 | 48 | fn start_action_resource_usage_sampler( |
452 | 48 | pgid: u32, |
453 | 48 | ceilings: Ceilings, |
454 | 48 | oom_limit_kb: Option<u64>, |
455 | 48 | disk_directory: Option<(PathBuf, SystemTime)>, |
456 | 48 | process_table: Arc<ProcessTableCache>, |
457 | 48 | ) -> ActionResourceUsageSampler { |
458 | 48 | let (stop_tx, stop_rx) = watch::channel(false); |
459 | 48 | let handle = background_spawn!( |
460 | | "action_resource_usage_sampler", |
461 | 48 | sample_action_resource_usage( |
462 | 48 | pgid, |
463 | 48 | stop_rx, |
464 | 48 | ceilings, |
465 | 48 | oom_limit_kb, |
466 | 48 | disk_directory, |
467 | 48 | process_table |
468 | | ) |
469 | | ); |
470 | 48 | ActionResourceUsageSampler { stop_tx, handle } |
471 | 48 | } |
472 | | |
473 | | /// KiB on disk of the files under `directory` that the action wrote: regular |
474 | | /// files modified at or after `since`, the directory's stamp from just |
475 | | /// before its command started (see [`directory_stamp`]). Its |
476 | | /// inputs were materialized before that, as hard links into the CAS store |
477 | | /// or private copies, and keep their earlier times, so they are told apart |
478 | | /// by time rather than by link count: a hard link drops to one link the |
479 | | /// moment the store evicts the blob, and would then pass for the action's |
480 | | /// own. Symlinks and the directories' own blocks are not counted. Blocking; |
481 | | /// run it on the blocking pool. |
482 | | #[cfg(target_family = "unix")] |
483 | 5 | pub fn directory_private_kb(directory: &Path, since: SystemTime) -> u64 { |
484 | 5 | let mut total_blocks: u64 = 0; |
485 | 5 | let mut pending = vec![directory.to_path_buf()]; |
486 | 15 | while let Some(dir10 ) = pending.pop() { |
487 | 10 | let Ok(entries) = std::fs::read_dir(&dir) else { |
488 | 0 | continue; |
489 | | }; |
490 | 13 | for entry in entries10 .flatten10 () { |
491 | 13 | let Ok(metadata) = entry.metadata() else { |
492 | 0 | continue; |
493 | | }; |
494 | 13 | let file_type = metadata.file_type(); |
495 | 13 | if file_type.is_dir() { |
496 | 5 | pending.push(entry.path()); |
497 | 8 | } else if file_type.is_file() |
498 | 7 | && metadata.modified().is_ok_and(|modified| modified >= since) |
499 | 4 | { |
500 | 4 | total_blocks = total_blocks.saturating_add(metadata.blocks()); |
501 | 4 | } |
502 | | } |
503 | | } |
504 | | // st_blocks are 512-byte units whatever the filesystem's block size. |
505 | 5 | total_blocks / 2 |
506 | 5 | } |
507 | | |
508 | | #[cfg(not(target_family = "unix"))] |
509 | | pub fn directory_private_kb(_directory: &Path, _since: SystemTime) -> u64 { |
510 | | 0 |
511 | | } |
512 | | |
513 | | /// Touches `directory` and returns the time the filesystem recorded for it. |
514 | | /// File mtimes come from the kernel's coarse clock, which on Linux can lag |
515 | | /// the wall clock by a tick, so a file written right after |
516 | | /// `SystemTime::now()` can look older than that moment. The walk compares |
517 | | /// mtimes against this stamp, taken from the same clock, so a file the |
518 | | /// action writes afterwards is never older than it. |
519 | | #[cfg(target_family = "unix")] |
520 | 3 | pub fn directory_stamp(directory: &Path) -> Result<SystemTime, Error> { |
521 | | use std::os::unix::io::AsRawFd; |
522 | 3 | let dir = std::fs::File::open(directory) |
523 | 3 | .err_tip(|| format!0 ("Opening {} to stamp it", directory0 .display0 ()))?0 ; |
524 | | // SAFETY: a zeroed timespec is valid; only tv_nsec is set. |
525 | 3 | let mut now: libc::timespec = unsafe { core::mem::zeroed() }; |
526 | 3 | now.tv_nsec = libc::UTIME_NOW; |
527 | 3 | let times = [now, now]; |
528 | | // SAFETY: futimens takes an open fd and a pointer to two timespecs that |
529 | | // outlive the call. |
530 | 3 | if unsafe { libc::futimens(dir.as_raw_fd(), times.as_ptr()) } != 0 { |
531 | 0 | return Err(std::io::Error::last_os_error()) |
532 | 0 | .err_tip(|| format!("Stamping {}", directory.display())); |
533 | 3 | } |
534 | 3 | dir.metadata() |
535 | 3 | .and_then(|metadata| metadata.modified()) |
536 | 3 | .err_tip(|| format!0 ("Reading the stamp on {}", directory0 .display0 ())) |
537 | 3 | } |
538 | | |
539 | | #[cfg(not(target_family = "unix"))] |
540 | | pub fn directory_stamp(_directory: &Path) -> Result<SystemTime, Error> { |
541 | | Ok(SystemTime::now()) |
542 | | } |
543 | | |
544 | | /// Starts a disk sample: the action's own files, walked on the blocking |
545 | | /// pool as a task of its own, so the memory samples carry on while a large |
546 | | /// tree is being walked. `None` when there is no directory to measure. |
547 | 50 | fn start_disk_sample( |
548 | 50 | directory: Option<&(PathBuf, SystemTime)>, |
549 | 50 | ) -> Option<JoinHandleDropGuard<u64>> { |
550 | 50 | let (directory4 , since4 ) = directory?46 .clone4 (); |
551 | 4 | Some(spawn_blocking!("action_disk_sample", move || { |
552 | 4 | directory_private_kb(&directory, since) |
553 | 4 | })) |
554 | 50 | } |
555 | | |
556 | | /// A signal to every process in the action's group. The action is its own |
557 | | /// group leader, so this reaches the children a shell forked. The SIGKILL |
558 | | /// at the end of a kill goes through the child wrapper, which takes the |
559 | | /// group with it (or tells the stub, namespaced); this is for the SIGTERM |
560 | | /// that gives an action its grace, and for the stragglers left once the |
561 | | /// action's own process has exited. Returns whether any process got it. |
562 | | #[cfg(target_family = "unix")] |
563 | 59 | fn signal_process_group(pgid: u32, signal: i32) -> bool { |
564 | 59 | let Ok(pgid) = i32::try_from(pgid) else { |
565 | 0 | return false; |
566 | | }; |
567 | | // SAFETY: killpg only takes integers and has no memory safety |
568 | | // considerations; a stale group id is reported as ESRCH, not acted on. |
569 | 59 | unsafe { libc::killpg(pgid, signal) == 0 } |
570 | 59 | } |
571 | | |
572 | | /// A signal to every process group holding the action's processes: its |
573 | | /// own group, the groups the sampler last found, and whatever those reach |
574 | | /// now (see [`action_membership`]). The action's own group alone misses a |
575 | | /// test that `set -m` moved to a group of its own, which would survive the |
576 | | /// kill and keep its memory. Returns whether any process got it. |
577 | | #[cfg(target_family = "unix")] |
578 | 58 | fn signal_action(action_pgid: u32, watch: &ActionMemoryWatch, signal: i32) -> bool { |
579 | 58 | let mut seeds = watch.process_groups.lock().clone(); |
580 | 58 | seeds.push(action_pgid); |
581 | 58 | let mut groups = action_membership_live(&seeds).groups; |
582 | 58 | groups.insert(action_pgid); |
583 | 58 | let mut delivered = false; |
584 | 59 | for pgid in groups58 { |
585 | 59 | delivered |= signal_process_group(pgid, signal); |
586 | 59 | } |
587 | 58 | delivered |
588 | 58 | } |
589 | | |
590 | 48 | async fn finish_action_resource_usage_sampler( |
591 | 48 | sampler: ActionResourceUsageSampler, |
592 | 48 | ) -> Option<SampledResourceUsage> { |
593 | 48 | let _ = sampler.stop_tx.send(true); |
594 | 48 | sampler.handle.await.ok() |
595 | 48 | } |
596 | | |
597 | 48 | async fn sample_action_resource_usage( |
598 | 48 | pgid: u32, |
599 | 48 | mut stop_rx: watch::Receiver<bool>, |
600 | 48 | mut ceilings: Ceilings, |
601 | 48 | oom_limit_kb: Option<u64>, |
602 | 48 | disk_directory: Option<(PathBuf, SystemTime)>, |
603 | 48 | process_table: Arc<ProcessTableCache>, |
604 | 48 | ) -> SampledResourceUsage { |
605 | 48 | let mut peak_memory_kb = 0; |
606 | 48 | let mut last_memory_kb = 0; |
607 | 48 | let mut peak_disk_kb = 0; |
608 | 48 | let mut samples_over_limit = 0u32; |
609 | 48 | let mut samples_since_disk = DISK_SAMPLE_EVERY; |
610 | | // The walk in flight, if one is; polled, never awaited, from the loop. |
611 | 48 | let mut disk_sample: Option<JoinHandleDropGuard<u64>> = None; |
612 | | // CPU time is cumulative per process and a process that exits stops |
613 | | // appearing, so summing the live group at the end would lose everything |
614 | | // short-lived. Keep the last figure seen for each pid and total them at |
615 | | // the end instead. |
616 | 48 | let mut cpu_ticks_by_pid = HashMap::new(); |
617 | | // What the group had already spent when sampling began. A one-shot |
618 | | // process starts at nothing; a pooled process carries every request it |
619 | | // served before, which is not this action's. |
620 | 48 | let mut baseline_ticks: Option<u64> = None; |
621 | | |
622 | 48 | let mut memory_gate = ExactMemoryGate::new(oom_limit_kb); |
623 | | |
624 | 48 | let watch = ceilings.watch.clone(); |
625 | 48 | let sample = async |peak_memory_kb: &mut u64, |
626 | | cpu_ticks_by_pid: &mut HashMap<u32, u64>, |
627 | | memory_gate: &mut ExactMemoryGate, |
628 | 316 | exact: bool| { |
629 | 158 | let table = process_table.current().await; |
630 | 158 | let observed = |
631 | 158 | sample_process_group(pgid, &table, &watch, cpu_ticks_by_pid, memory_gate, exact); |
632 | 158 | if let Some(memory_kb98 ) = observed { |
633 | 98 | *peak_memory_kb = (*peak_memory_kb).max(memory_kb); |
634 | 98 | }60 |
635 | 158 | observed |
636 | 316 | }; |
637 | | |
638 | | loop { |
639 | 110 | let observed = sample( |
640 | 110 | &mut peak_memory_kb, |
641 | 110 | &mut cpu_ticks_by_pid, |
642 | 110 | &mut memory_gate, |
643 | 110 | false, |
644 | 110 | ) |
645 | 110 | .await; |
646 | 110 | if baseline_ticks.is_none() { |
647 | 48 | baseline_ticks = Some(cpu_ticks_by_pid.values().sum()); |
648 | 62 | } |
649 | 110 | if let Some(memory_kb96 ) = observed { |
650 | 96 | last_memory_kb = memory_kb; |
651 | 96 | ceilings.watch.observe(memory_kb); |
652 | 96 | }14 |
653 | 110 | if observed.is_none() && !process_exists(pgid)14 { |
654 | | // The group leader has been reaped and no member process remains, |
655 | | // so the action is finished. |
656 | 0 | break; |
657 | 110 | } |
658 | | // Two consecutive samples over the ceiling, so one spike between |
659 | | // reads is not a kill; one sample at twice the ceiling is, because |
660 | | // an allocation that fast reaches the pod's limit before the next |
661 | | // read (observed: twelve 20 GiB allocators filled a 52 GiB pod in |
662 | | // under a second). |
663 | 110 | if let (Some(observed_kb10 ), Some(limit_kb10 )) = (observed, ceilings.memory_limit_kb) { |
664 | 10 | if observed_kb > limit_kb { |
665 | 2 | samples_over_limit += 1; |
666 | 8 | } else { |
667 | 8 | samples_over_limit = 0; |
668 | 8 | } |
669 | 10 | let gross = observed_kb >= limit_kb.saturating_mul(2); |
670 | 10 | if (samples_over_limit >= 2 || gross9 ) |
671 | 1 | && let Some(over_limit_tx) = ceilings.over_limit_tx.take() |
672 | 1 | && over_limit_tx.send(OverLimit::Memory(observed_kb)).is_err() |
673 | | { |
674 | | // The receiver is gone only when the action already ended. |
675 | 0 | debug!(observed_kb, "Memory ceiling breached, action already ended"); |
676 | 10 | } |
677 | 100 | } |
678 | | // The worker-wide monitor chose this action under sustained memory |
679 | | // pressure; the kill goes the way a ceiling breach does. |
680 | 110 | let full_avg10_percent = ceilings |
681 | 110 | .watch |
682 | 110 | .kill_requested_percent |
683 | 110 | .load(Ordering::Relaxed); |
684 | 110 | if full_avg10_percent > 0 |
685 | 2 | && let Some(over_limit_tx) = ceilings.over_limit_tx.take() |
686 | 2 | && over_limit_tx |
687 | 2 | .send(OverLimit::MemoryPressure { |
688 | 2 | observed_kb: last_memory_kb, |
689 | 2 | full_avg10_percent, |
690 | 2 | }) |
691 | 2 | .is_err() |
692 | | { |
693 | 0 | debug!( |
694 | | last_memory_kb, |
695 | | "Memory pressure kill asked, action already ended" |
696 | | ); |
697 | 110 | } |
698 | | |
699 | | // A walk that finished since the last sample; the memory samples |
700 | | // above never waited for it. |
701 | 110 | if let Some(handle2 ) = disk_sample.as_mut() |
702 | 2 | && let core::task::Poll::Ready(finished) = futures::poll!(handle) |
703 | | { |
704 | 2 | disk_sample = None; |
705 | 2 | if let Ok(disk_kb) = finished { |
706 | 2 | peak_disk_kb = peak_disk_kb.max(disk_kb); |
707 | 2 | if let Some(limit_kb) = ceilings.disk_limit_kb |
708 | 2 | && disk_kb > limit_kb |
709 | 1 | && let Some(over_limit_tx) = ceilings.over_limit_tx.take() |
710 | 1 | && over_limit_tx.send(OverLimit::Disk(disk_kb)).is_err() |
711 | | { |
712 | 0 | debug!(disk_kb, "Disk ceiling breached, action already ended"); |
713 | 2 | } |
714 | 0 | } |
715 | 108 | } |
716 | | // The periodic walk belongs to the soft limit; the guard alone |
717 | | // measures once, at the end, and pays no walk while running. |
718 | 110 | samples_since_disk += 1; |
719 | 110 | if ceilings.disk_limit_kb.is_some() |
720 | 42 | && samples_since_disk >= DISK_SAMPLE_EVERY |
721 | 2 | && disk_sample.is_none() |
722 | 2 | { |
723 | 2 | samples_since_disk = 0; |
724 | 2 | disk_sample = start_disk_sample(disk_directory.as_ref()); |
725 | 108 | } |
726 | | |
727 | 110 | if *stop_rx.borrow() { |
728 | 0 | break; |
729 | 110 | } |
730 | | |
731 | 110 | tokio::select! { |
732 | 110 | changed48 = stop_rx.changed() => { |
733 | 48 | if changed.is_ok() && *stop_rx.borrow() { |
734 | 48 | break; |
735 | 0 | } |
736 | | } |
737 | 110 | () = tokio::time::sleep(RESOURCE_USAGE_SAMPLE_INTERVAL) => {}62 |
738 | | } |
739 | | } |
740 | | // The final figure settles the peak and how the action ended, so every |
741 | | // way out takes it exact. A pooled process is still there to read; a |
742 | | // finished group has nothing left and changes nothing. |
743 | 48 | if let Some(memory_kb2 ) = sample( |
744 | 48 | &mut peak_memory_kb, |
745 | 48 | &mut cpu_ticks_by_pid, |
746 | 48 | &mut memory_gate, |
747 | 48 | true, |
748 | 48 | ) |
749 | 48 | .await |
750 | 2 | { |
751 | 2 | last_memory_kb = memory_kb; |
752 | 46 | } |
753 | | // What the action left on disk at the end is the figure that matters |
754 | | // for sizing; this walk, one stat per entry under the action directory, |
755 | | // is the only one the guard mode pays. |
756 | | // Nothing is sampled from here on, so the pressure monitor must not |
757 | | // pick this action on a stale figure while its outputs upload. |
758 | 48 | ceilings.watch.last_memory_kb.store(0, Ordering::Relaxed); |
759 | 48 | drop(disk_sample.take()); |
760 | 48 | if let Some(handle2 ) = start_disk_sample(disk_directory.as_ref()) |
761 | 2 | && let Ok(disk_kb) = handle.await |
762 | 2 | { |
763 | 2 | peak_disk_kb = peak_disk_kb.max(disk_kb); |
764 | 46 | } |
765 | | |
766 | 48 | SampledResourceUsage { |
767 | 48 | peak_memory_kb, |
768 | 48 | cpu_time_ms: ticks_to_millis( |
769 | 48 | cpu_ticks_by_pid |
770 | 48 | .values() |
771 | 48 | .sum::<u64>() |
772 | 48 | .saturating_sub(baseline_ticks.unwrap_or(0)), |
773 | 48 | ), |
774 | 48 | last_memory_kb, |
775 | 48 | peak_disk_kb, |
776 | 48 | } |
777 | 48 | } |
778 | | |
779 | | /// One process as `/proc/<pid>/stat` describes it: its parent and its |
780 | | /// process group. |
781 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
782 | | pub struct ProcEntry { |
783 | | pub pid: u32, |
784 | | pub ppid: u32, |
785 | | pub pgrp: u32, |
786 | | } |
787 | | |
788 | | /// The pids that belong to the action, to a fixpoint: every process in the |
789 | | /// action's process group, every child of a member, and every process in a |
790 | | /// member's process group. The action is spawned as its own group leader |
791 | | /// (`process_group(0)` in `inner_execute`), so `action_pgid` is the spawned |
792 | | /// child's pid and its direct descendants inherit the group. |
793 | | /// |
794 | | /// Neither link alone is enough. An intermediate shell often exits and |
795 | | /// reparents the real workload to the worker (pid 1 in a pod), so a walk by |
796 | | /// parent from the spawned pid sees a childless zombie; the group survives |
797 | | /// that. Bazel's `test-setup.sh` runs under `set -m`, which puts the test |
798 | | /// binary in a process group of its own, so the group alone sees the few MB |
799 | | /// of the wrapper shell and never the test; the parent link reaches it, and |
800 | | /// its group then covers whatever it reparents. Nothing in the worker's own |
801 | | /// group is ever a member. |
802 | 2 | pub fn action_member_pids(action_pgid: u32, worker_pgid: u32, procs: &[ProcEntry]) -> HashSet<u32> { |
803 | 2 | action_membership(&[action_pgid], worker_pgid, procs).pids |
804 | 2 | } |
805 | | |
806 | | /// The processes that belong to an action and the process groups they are |
807 | | /// in. |
808 | | #[derive(Debug, Default, Clone, PartialEq, Eq)] |
809 | | pub struct ActionMembers { |
810 | | pub pids: HashSet<u32>, |
811 | | /// Every group holding a member. A seed group with no process left is |
812 | | /// not here, so a group id the kernel reuses later is never signalled. |
813 | | pub groups: HashSet<u32>, |
814 | | } |
815 | | |
816 | | /// [`action_member_pids`] from several starting groups: the action's own |
817 | | /// and the groups found on earlier samples, which still hold a `set -m` |
818 | | /// test after its wrapper exited and nothing else links it to the action. |
819 | 222 | pub fn action_membership( |
820 | 222 | seed_groups: &[u32], |
821 | 222 | worker_pgid: u32, |
822 | 222 | procs: &[ProcEntry], |
823 | 222 | ) -> ActionMembers { |
824 | 222 | let mut members = HashSet::new(); |
825 | 222 | let mut groups: HashSet<u32> = seed_groups |
826 | 222 | .iter() |
827 | 222 | .copied() |
828 | 389 | .filter222 (|pgid| *pgid != worker_pgid) |
829 | 222 | .collect(); |
830 | | loop { |
831 | 387 | let mut grew = false; |
832 | 2.29k | for proc_entry in procs387 { |
833 | 2.29k | if proc_entry.pgrp == worker_pgid || members707 .contains707 (&proc_entry.pid707 ) { |
834 | 1.92k | continue; |
835 | 373 | } |
836 | 373 | if groups.contains(&proc_entry.pgrp) || members43 .contains43 (&proc_entry.ppid43 ) { |
837 | 334 | members.insert(proc_entry.pid); |
838 | 334 | groups.insert(proc_entry.pgrp); |
839 | 334 | grew = true; |
840 | 334 | }39 |
841 | | } |
842 | 387 | if !grew { |
843 | 222 | let groups = procs |
844 | 222 | .iter() |
845 | 1.28k | .filter222 (|proc_entry| members.contains(&proc_entry.pid)) |
846 | 222 | .map(|proc_entry| proc_entry.pgrp) |
847 | 222 | .collect(); |
848 | 222 | return ActionMembers { |
849 | 222 | pids: members, |
850 | 222 | groups, |
851 | 222 | }; |
852 | 165 | } |
853 | | } |
854 | 222 | } |
855 | | |
856 | | /// The action's members on the live system; see [`action_membership`]. |
857 | | /// The kills resolve the groups to signal through this. |
858 | 60 | pub fn action_membership_live(seed_groups: &[u32]) -> ActionMembers { |
859 | 60 | let table: Vec<ProcEntry> = live_process_table() |
860 | 60 | .into_iter() |
861 | 60 | .map(|(entry, _)| entry) |
862 | 60 | .collect(); |
863 | 60 | action_membership(seed_groups, worker_pgid(), &table) |
864 | 60 | } |
865 | | |
866 | | /// Sums the resident memory of every process that belongs to the action |
867 | | /// (see [`action_membership`] and [`members_memory_kb`]) and records their |
868 | | /// CPU ticks, and the groups they are in in `watch`. Returns `None` when no |
869 | | /// member process can be read (the action has no processes left). |
870 | 158 | fn sample_process_group( |
871 | 158 | pgid: u32, |
872 | 158 | table: &[(ProcEntry, Option<u64>)], |
873 | 158 | watch: &ActionMemoryWatch, |
874 | 158 | cpu_ticks_by_pid: &mut HashMap<u32, u64>, |
875 | 158 | memory_gate: &mut ExactMemoryGate, |
876 | 158 | exact: bool, |
877 | 158 | ) -> Option<u64> { |
878 | 158 | let entries: Vec<ProcEntry> = table.iter().map(|(entry, _)| *entry).collect(); |
879 | 158 | let mut seeds = watch.process_groups.lock().clone(); |
880 | 158 | seeds.push(pgid); |
881 | | let ActionMembers { |
882 | 158 | pids: members, |
883 | 158 | groups, |
884 | 158 | } = action_membership(&seeds, worker_pgid(), &entries); |
885 | 158 | *watch.process_groups.lock() = groups.into_iter().collect(); |
886 | 158 | let mut member_pids = Vec::new(); |
887 | 983 | for &(entry, ticks) in table158 { |
888 | 983 | if !members.contains(&entry.pid) { |
889 | 684 | continue; |
890 | 299 | } |
891 | 299 | member_pids.push(entry.pid); |
892 | | // Read now, not from the shared snapshot: a request to a pooled |
893 | | // process can start and end within one snapshot's lifetime, and |
894 | | // would then measure nothing. |
895 | 299 | if let Some(ticks) = read_process_cpu_ticks(entry.pid).or(ticks) { |
896 | 299 | // Monotonic per process, but a pid could in principle be reused |
897 | 299 | // within one action, so never let the figure go backwards. |
898 | 299 | let slot = cpu_ticks_by_pid.entry(entry.pid).or_insert(0); |
899 | 299 | *slot = (*slot).max(ticks); |
900 | 299 | }0 |
901 | | } |
902 | 158 | members_memory_kb(&member_pids, memory_gate, exact) |
903 | 158 | } |
904 | | |
905 | | /// How long the sampler may reuse an exact memory figure; see |
906 | | /// [`ExactMemoryGate`]. |
907 | | const EXACT_MEMORY_MAX_AGE: Duration = Duration::from_secs(2); |
908 | | |
909 | | /// How far, in percent, the members' RSS may move from where it was when |
910 | | /// the exact memory figure was read before the sampler reads it again; see |
911 | | /// [`ExactMemoryGate`]. |
912 | | const EXACT_MEMORY_MAX_DRIFT_PERCENT: u64 = 10; |
913 | | |
914 | | /// Decides which samples read the exact memory figure. On Linux that is |
915 | | /// `Pss` from `smaps_rollup`, a walk of the page tables that costs several |
916 | | /// milliseconds per resident GiB, four times a second for every running |
917 | | /// action. The members' RSS is a counter read that `Pss` never exceeds, so |
918 | | /// a sample reuses the last exact figure, capped at the RSS, only while: |
919 | | /// - the RSS is under 90% of the limit a kill is judged against, so no |
920 | | /// ceiling kill and no near-limit OOM classification (see |
921 | | /// [`classify_outcome`]) can follow from the figure; |
922 | | /// - the RSS is within [`EXACT_MEMORY_MAX_DRIFT_PERCENT`] of where it was |
923 | | /// at the last exact read, so an action is read again as it grows, and |
924 | | /// one that ends before the next refresh still reports its peak; |
925 | | /// - that read is under [`EXACT_MEMORY_MAX_AGE`] old. `Pss` can rise while |
926 | | /// the RSS stays flat, when another process sharing the pages exits, so |
927 | | /// this age alone bounds how far the peak and the figure the pressure |
928 | | /// monitor compares can trail. |
929 | | #[derive(Debug, Clone, Copy)] |
930 | | pub struct ExactMemoryGate { |
931 | | oom_limit_kb: Option<u64>, |
932 | | last: Option<ExactMemoryReading>, |
933 | | } |
934 | | |
935 | | #[derive(Debug, Clone, Copy)] |
936 | | struct ExactMemoryReading { |
937 | | read_at: Instant, |
938 | | bound_kb: u64, |
939 | | exact_kb: u64, |
940 | | } |
941 | | |
942 | | impl ExactMemoryGate { |
943 | | /// `oom_limit_kb` is what a SIGKILL nobody here sent is judged against. |
944 | 50 | pub const fn new(oom_limit_kb: Option<u64>) -> Self { |
945 | 50 | Self { |
946 | 50 | oom_limit_kb, |
947 | 50 | last: None, |
948 | 50 | } |
949 | 50 | } |
950 | | |
951 | | /// The figure for a sample whose members' RSS is `bound_kb`, when the |
952 | | /// last exact one serves; `None` when the sample must read it. |
953 | 117 | pub fn reuse(&self, bound_kb: u64, now: Instant) -> Option<u64> { |
954 | 117 | let last73 = self.last?44 ; |
955 | 73 | let under_ceiling = self |
956 | 73 | .oom_limit_kb |
957 | 73 | .is_none_or(|limit_kb| bound_kb16 .saturating_mul16 (10) < limit_kb16 .saturating_mul16 (9)); |
958 | 73 | let fresh = now.saturating_duration_since(last.read_at) < EXACT_MEMORY_MAX_AGE; |
959 | 73 | let steady = bound_kb.abs_diff(last.bound_kb).saturating_mul(100) |
960 | 73 | <= last.bound_kb.saturating_mul(EXACT_MEMORY_MAX_DRIFT_PERCENT); |
961 | 73 | (under_ceiling && fresh71 && steady65 ).then(|| last.exact_kb52 .min52 (bound_kb52 )) |
962 | 117 | } |
963 | | |
964 | 56 | pub const fn record(&mut self, bound_kb: u64, exact_kb: u64, now: Instant) { |
965 | 56 | self.last = Some(ExactMemoryReading { |
966 | 56 | read_at: now, |
967 | 56 | bound_kb, |
968 | 56 | exact_kb, |
969 | 56 | }); |
970 | 56 | } |
971 | | } |
972 | | |
973 | | /// The members' memory summed, `None` when none of them can be read. Where |
974 | | /// the exact figure walks pages (see [`EXACT_MEMORY_READ_WALKS_PAGES`]) it |
975 | | /// is read only when `exact` asks for it or `memory_gate` cannot reuse the |
976 | | /// last one. |
977 | 158 | fn members_memory_kb(pids: &[u32], memory_gate: &mut ExactMemoryGate, exact: bool) -> Option<u64> { |
978 | 221 | let sum158 = |read: fn(u32) -> Option<u64>| { |
979 | 221 | pids.iter() |
980 | 425 | .filter_map221 (|&pid| read(pid)) |
981 | 221 | .reduce(|total_kb, kb| total_kb150 + kb150 ) |
982 | 221 | }; |
983 | 158 | if !EXACT_MEMORY_READ_WALKS_PAGES { |
984 | 0 | return sum(read_process_memory_kb); |
985 | 158 | } |
986 | 158 | let bound_kb108 = sum(read_process_memory_bound_kb)?50 ; |
987 | 108 | let now = Instant::now(); |
988 | 108 | if !exact && let Some(memory_kb45 ) = memory_gate105 .reuse105 (bound_kb, now) { |
989 | 45 | return Some(memory_kb); |
990 | 63 | } |
991 | 63 | let exact_kb53 = sum(read_process_memory_kb)?10 ; |
992 | 53 | memory_gate.record(bound_kb, exact_kb, now); |
993 | 53 | Some(exact_kb) |
994 | 158 | } |
995 | | |
996 | | /// The value of a `Field: 1234 kB` line in `/proc` text, in KiB. |
997 | 120 | pub fn parse_kb_field(text: &str, field: &str) -> Option<u64> { |
998 | 859 | text.lines()120 .find_map120 (|line| { |
999 | 859 | let rest106 = line.strip_prefix(field)?753 ; |
1000 | 106 | rest.split_whitespace().next()?0 .parse().ok() |
1001 | 859 | }) |
1002 | 120 | } |
1003 | | |
1004 | | /// Parses the process group id (`pgrp`, field 5) from the contents of a |
1005 | | /// `/proc/<pid>/stat` line. |
1006 | | /// |
1007 | | /// `comm` (field 2) can contain spaces and parentheses, so fields are parsed |
1008 | | /// relative to the final `)` to avoid miscounting. Returns `None` when the |
1009 | | /// line is malformed. |
1010 | | #[cfg(target_os = "linux")] |
1011 | 6 | pub fn parse_pgid_from_stat(stat: &str) -> Option<u32> { |
1012 | 6 | parse_links_from_stat(stat).map(|(_, pgrp)| pgrp) |
1013 | 6 | } |
1014 | | |
1015 | | /// Parses the parent pid (`ppid`, field 4) and the process group id |
1016 | | /// (`pgrp`, field 5) from the contents of a `/proc/<pid>/stat` line, located |
1017 | | /// relative to the final `)` as [`parse_pgid_from_stat`] does. |
1018 | 967 | pub fn parse_links_from_stat(stat: &str) -> Option<(u32, u32)> { |
1019 | 967 | let after_comm965 = stat.rsplit_once(')')?2 .1; |
1020 | | // Fields after the final ')': state(0) ppid(1) pgrp(2) ... |
1021 | 965 | let mut fields = after_comm.split_whitespace().skip(1); |
1022 | 965 | let ppid964 = fields.next()?1 .parse964 ().ok964 ()?0 ; |
1023 | 964 | let pgrp = fields.next()?0 .parse().ok()?0 ; |
1024 | 964 | Some((ppid, pgrp)) |
1025 | 967 | } |
1026 | | |
1027 | | /// Sums user and system CPU ticks (`utime` + `stime`, fields 14 and 15) from |
1028 | | /// the contents of a `/proc/<pid>/stat` line. |
1029 | | /// |
1030 | | /// Parsed relative to the final `)` for the same reason as the pgid above: |
1031 | | /// `comm` can contain spaces and parentheses. Returns `None` when the line is |
1032 | | /// malformed. |
1033 | | /// |
1034 | | /// `cutime`/`cstime` are deliberately not included. They only cover children |
1035 | | /// this process has already reaped, so adding them here would double count |
1036 | | /// every child that is still its own entry in the group. |
1037 | | #[cfg(target_os = "linux")] |
1038 | 1.18k | pub fn parse_cpu_ticks_from_stat(stat: &str) -> Option<u64> { |
1039 | 1.18k | let after_comm1.17k = stat.rsplit_once(')')?2 .1; |
1040 | 1.17k | let mut fields = after_comm.split_whitespace(); |
1041 | | // Fields after the final ')': state(0) ppid(1) pgrp(2) session(3) tty(4) |
1042 | | // tpgid(5) flags(6) minflt(7) cminflt(8) majflt(9) cmajflt(10) |
1043 | | // utime(11) stime(12) ... |
1044 | 1.17k | let utime1.17k : u641.17k = fields.nth(11)?1 .parse1.17k ().ok1.17k ()?0 ; |
1045 | 1.17k | let stime: u64 = fields.next()?0 .parse().ok()?0 ; |
1046 | 1.17k | Some(utime.saturating_add(stime)) |
1047 | 1.18k | } |
1048 | | |
1049 | | /// Valid string reasons for a failure. |
1050 | | /// Note: If these change, the documentation should be updated. |
1051 | | #[derive(Debug, Deserialize)] |
1052 | | #[serde(rename_all = "snake_case")] |
1053 | | enum SideChannelFailureReason { |
1054 | | /// Task should be considered timed out. |
1055 | | Timeout, |
1056 | | } |
1057 | | |
1058 | | /// This represents the json data that can be passed from the running process |
1059 | | /// to the parent via the `SideChannelFile`. See: |
1060 | | /// `config::EnvironmentSource::sidechannelfile` for more details. |
1061 | | /// Note: Any fields added here must be added to the documentation. |
1062 | | #[derive(Debug, Deserialize, Default)] |
1063 | | struct SideChannelInfo { |
1064 | | /// If the task should be considered a failure and why. |
1065 | | failure: Option<SideChannelFailureReason>, |
1066 | | } |
1067 | | |
1068 | 56 | fn action_supports_persistent_workers( |
1069 | 56 | action_info: &ActionInfo, |
1070 | 56 | ) -> Option<Result<WireFormat, Error>> { |
1071 | 56 | if action_info |
1072 | 56 | .platform_properties |
1073 | 56 | .get(SUPPORTS_WORKERS_PROPERTY) |
1074 | 56 | .is_none_or(|value| value2 != "1"2 ) |
1075 | | { |
1076 | 54 | return None; |
1077 | 2 | } |
1078 | | |
1079 | 2 | let protocol = action_info |
1080 | 2 | .platform_properties |
1081 | 2 | .get(REQUIRES_WORKER_PROTOCOL_PROPERTY) |
1082 | 2 | .map_or("proto", String::as_str); |
1083 | 2 | Some(WireFormat::parse(protocol)) |
1084 | 56 | } |
1085 | | |
1086 | 2 | fn os_args_to_strings(args: &[&OsStr]) -> Result<Vec<String>, Error> { |
1087 | 2 | args.iter() |
1088 | 4 | .map2 (|arg| { |
1089 | 4 | arg.to_str().map(str::to_owned).ok_or_else(|| {0 |
1090 | 0 | make_err!( |
1091 | 0 | Code::InvalidArgument, |
1092 | | "Persistent worker command arguments must be valid UTF-8: {arg:?}" |
1093 | | ) |
1094 | 0 | }) |
1095 | 4 | }) |
1096 | 2 | .collect() |
1097 | 2 | } |
1098 | | |
1099 | 2 | fn persistent_worker_request_arguments(argv: &[String]) -> Vec<String> { |
1100 | 2 | argv.iter() |
1101 | 2 | .skip(1) |
1102 | 2 | .skip_while(|arg| !arg.starts_with('@')) |
1103 | 2 | .cloned() |
1104 | 2 | .collect() |
1105 | 2 | } |
1106 | | /// Maximum number of file-materialization (hardlink) or subdirectory |
1107 | | /// recursion futures polled concurrently per directory level. Higher values |
1108 | | /// drown APFS's per-volume metadata lock with `hardlink(2)` syscalls and |
1109 | | /// regress overall throughput vs lower-contention concurrency. |
1110 | | /// |
1111 | | /// 64 is well above the inflection point on any modern Linux filesystem, |
1112 | | /// so this is also a no-op on Linux beyond replacing tokio scheduling |
1113 | | /// overhead. |
1114 | | const DOWNLOAD_TO_DIRECTORY_CONCURRENCY: usize = 64; |
1115 | | |
1116 | | /// One permit per blob fetched into the worker's store, from the pool the |
1117 | | /// worker shares across every action it is staging. The pool is never |
1118 | | /// closed, so a refusal here is a bug, not a condition. |
1119 | 295 | async fn acquire_download_permit(downloads: &Semaphore) -> Result<SemaphorePermit<'_>, Error> { |
1120 | 295 | downloads |
1121 | 295 | .acquire() |
1122 | 295 | .await |
1123 | 295 | .map_err(|e| make_err!0 (Code::Internal0 , "Input download pool closed: {e:?}")) |
1124 | 295 | } |
1125 | | |
1126 | | /// The pool `max_concurrent_input_downloads` asks for. At 0, the default, |
1127 | | /// the pool is too large to bound anything: each action then fetches its |
1128 | | /// inputs on its own, limited only by the per-level fan-out above. |
1129 | 95 | fn input_download_pool(max_concurrent_input_downloads: usize) -> Semaphore { |
1130 | 95 | Semaphore::new(match max_concurrent_input_downloads { |
1131 | 91 | 0 => Semaphore::MAX_PERMITS, |
1132 | 4 | n => n.min(Semaphore::MAX_PERMITS), |
1133 | | }) |
1134 | 95 | } |
1135 | | |
1136 | | /// How often a worker without `max_concurrent_input_downloads` may suggest |
1137 | | /// setting it. The signs that call for it arrive in storms; one line per |
1138 | | /// storm is advice, one per failed fetch would bury it. |
1139 | | const INPUT_POOL_HINT_INTERVAL: Duration = Duration::from_mins(10); |
1140 | | |
1141 | | /// Suggests a shared input pool to a worker running without one, at most |
1142 | | /// once per `INPUT_POOL_HINT_INTERVAL`. |
1143 | | #[derive(Debug, Default)] |
1144 | | struct InputPoolHint { |
1145 | | last: Mutex<Option<Instant>>, |
1146 | | } |
1147 | | |
1148 | | impl InputPoolHint { |
1149 | | /// Whether the hint is due at `now`. When it is, the next one is not |
1150 | | /// due until a full interval has passed. |
1151 | 19 | fn due(&self, now: Instant) -> bool { |
1152 | 19 | let mut last = self.last.lock(); |
1153 | 19 | if last.is_some_and(|last| now11 .saturating_duration_since(last) < INPUT_POOL_HINT_INTERVAL11 ) { |
1154 | 10 | return false; |
1155 | 9 | } |
1156 | 9 | *last = Some(now); |
1157 | 9 | true |
1158 | 19 | } |
1159 | | } |
1160 | | |
1161 | | /// Keeps every digest used by an action present in locally eviction-managed |
1162 | | /// CAS tiers. |
1163 | | /// |
1164 | | /// A lease is registered before a digest is populated. This is important for |
1165 | | /// blobs that are not yet in the fast tier: reserving the key first prevents |
1166 | | /// the insertion that follows from immediately evicting the blob under pressure. |
1167 | | /// Every directly discoverable filesystem tier of the worker's |
1168 | | /// `FastSlowStore` is leased when present (including a `RefStore` target). |
1169 | | /// Transforming wrappers remain opaque by design. The reference count lets |
1170 | | /// concurrent actions share a digest safely. |
1171 | | #[derive(Debug)] |
1172 | | struct ActionInputLease { |
1173 | | filesystem_stores: Vec<Arc<FilesystemStore>>, |
1174 | | digests: Mutex<Option<HashSet<DigestInfo>>>, |
1175 | | } |
1176 | | |
1177 | | impl ActionInputLease { |
1178 | 9 | fn new( |
1179 | 9 | cas_store: &FastSlowStore, |
1180 | 9 | command_digest: DigestInfo, |
1181 | 9 | input_root_digest: DigestInfo, |
1182 | 9 | ) -> Arc<Self> { |
1183 | 9 | let lease = Arc::new(Self { |
1184 | 9 | filesystem_stores: cas_store.get_filesystem_stores(), |
1185 | 9 | digests: Mutex::new(Some(HashSet::new())), |
1186 | 9 | }); |
1187 | 9 | lease.lease_digest(&command_digest); |
1188 | 9 | lease.lease_digest(&input_root_digest); |
1189 | 9 | lease |
1190 | 9 | } |
1191 | | |
1192 | 80 | fn lease_digest(&self, digest: &DigestInfo) { |
1193 | 80 | let mut digests = self.digests.lock(); |
1194 | 80 | let Some(digests) = digests.as_mut() else { |
1195 | 0 | return; |
1196 | | }; |
1197 | 80 | if digests.insert(*digest) { |
1198 | 38 | for filesystem_store in &self.filesystem_stores { |
1199 | 38 | filesystem_store.lease_digest(digest); |
1200 | 38 | } |
1201 | 42 | } |
1202 | 80 | } |
1203 | | |
1204 | 25 | fn take_release_work(&self) -> Option<(Vec<Arc<FilesystemStore>>, Vec<DigestInfo>)> { |
1205 | 25 | let digests9 = self.digests.lock().take()?16 .into_iter9 ().collect9 (); |
1206 | 9 | Some((self.filesystem_stores.clone(), digests)) |
1207 | 25 | } |
1208 | | |
1209 | 25 | fn spawn_release(&self) -> Option<tokio::task::JoinHandle<()>> { |
1210 | 25 | let (filesystem_stores9 , digests9 ) = self.take_release_work()?16 ; |
1211 | 9 | Some(background_spawn!( |
1212 | | "action_input_lease_release", |
1213 | 9 | release_filesystem_stores(filesystem_stores, digests) |
1214 | | )) |
1215 | 25 | } |
1216 | | |
1217 | 17 | async fn release(self: Arc<Self>) {16 |
1218 | 16 | let Some(handle9 ) = self.spawn_release() else { |
1219 | 7 | return; |
1220 | | }; |
1221 | | // Detach the actual release before awaiting it so cancellation of the |
1222 | | // action cleanup future cannot strand leases in a later filesystem |
1223 | | // tier. |
1224 | 9 | let _result = handle.await; |
1225 | 16 | } |
1226 | | } |
1227 | | |
1228 | | impl crate::directory_cache::DigestLease for ActionInputLease { |
1229 | 21 | fn acquire(&self, digest: &DigestInfo) { |
1230 | 21 | self.lease_digest(digest); |
1231 | 21 | } |
1232 | | } |
1233 | | |
1234 | | impl Drop for ActionInputLease { |
1235 | 9 | fn drop(&mut self) { |
1236 | 9 | drop(self.spawn_release()); |
1237 | 9 | } |
1238 | | } |
1239 | | |
1240 | 9 | async fn release_filesystem_stores( |
1241 | 9 | filesystem_stores: Vec<Arc<FilesystemStore>>, |
1242 | 9 | digests: Vec<DigestInfo>, |
1243 | 9 | ) { |
1244 | 9 | join_all( |
1245 | 9 | filesystem_stores |
1246 | 9 | .iter() |
1247 | 9 | .map(|filesystem_store| filesystem_store.release_digests(&digests)), |
1248 | | ) |
1249 | 9 | .await; |
1250 | 9 | } |
1251 | | |
1252 | | /// Input bytes an action's fetch hardlinked, and how many of them the local |
1253 | | /// store already held. Counted per fetch attempt: [`INPUT_FETCH_STATS`] is |
1254 | | /// scoped around one attempt's download, so a retried attempt starts again |
1255 | | /// from zero instead of counting the tree twice. What a failed attempt |
1256 | | /// fetched is carried over by `fetch_with_retry`. |
1257 | | #[derive(Debug, Default)] |
1258 | | pub struct InputFetchStats { |
1259 | | total_bytes: AtomicU64, |
1260 | | cached_bytes: AtomicU64, |
1261 | | } |
1262 | | |
1263 | | impl InputFetchStats { |
1264 | 17 | fn record(&self, digest: &DigestInfo, cached: bool) { |
1265 | 17 | let bytes = digest.size_bytes(); |
1266 | 17 | self.total_bytes.fetch_add(bytes, Ordering::Relaxed); |
1267 | 17 | if cached { |
1268 | 7 | self.cached_bytes.fetch_add(bytes, Ordering::Relaxed); |
1269 | 10 | } |
1270 | 17 | } |
1271 | | |
1272 | | /// Total input bytes and the cached part of them. |
1273 | 86 | pub fn bytes(&self) -> (u64, u64) { |
1274 | 86 | ( |
1275 | 86 | self.total_bytes.load(Ordering::Relaxed), |
1276 | 86 | self.cached_bytes.load(Ordering::Relaxed), |
1277 | 86 | ) |
1278 | 86 | } |
1279 | | } |
1280 | | |
1281 | | tokio::task_local! { |
1282 | | /// The counters of the fetch attempt running on this task, if any. A |
1283 | | /// task local rather than a parameter: the download functions are |
1284 | | /// public and recursive, and the counts are a report, not an input. |
1285 | | pub static INPUT_FETCH_STATS: Arc<InputFetchStats>; |
1286 | | } |
1287 | | |
1288 | | /// Runs `fut`, one input fetch attempt, with fresh counters in scope and |
1289 | | /// returns them with its output. |
1290 | 2 | pub async fn with_input_fetch_stats<F: Future>(fut: F) -> (F::Output, Arc<InputFetchStats>) { |
1291 | 2 | let stats = Arc::new(InputFetchStats::default()); |
1292 | 2 | let out = INPUT_FETCH_STATS.scope(stats.clone(), fut).await; |
1293 | 2 | (out, stats) |
1294 | 2 | } |
1295 | | |
1296 | | /// Counts one resolved input file against the attempt in scope; a no-op |
1297 | | /// outside one, as in the download functions' own tests. |
1298 | 149 | fn record_input_file(digest: &DigestInfo, cached: bool) { |
1299 | 149 | let _ = INPUT_FETCH_STATS.try_with(|stats| stats17 .record17 (digest17 , cached17 )); |
1300 | 149 | } |
1301 | | |
1302 | | /// Milliseconds from `start` to `end`; zero when either was never set or |
1303 | | /// the clock ran backwards between them. |
1304 | 4 | fn phase_ms(start: SystemTime, end: SystemTime) -> u64 { |
1305 | 4 | if start == SystemTime::UNIX_EPOCH || end == SystemTime::UNIX_EPOCH2 { |
1306 | 2 | return 0; |
1307 | 2 | } |
1308 | 2 | end.duration_since(start) |
1309 | 2 | .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) |
1310 | 4 | } |
1311 | | |
1312 | | /// Fills the parts of an action's usage report that are known only once its |
1313 | | /// result is uploaded: how long the input fetch and the output upload took, |
1314 | | /// from the result's execution metadata, and how many bytes it produced. |
1315 | 2 | pub fn with_phases(usage: &mut ActionResourceUsage, result: &ActionResult) { |
1316 | 2 | let m = &result.execution_metadata; |
1317 | 2 | usage.input_fetch_ms = phase_ms( |
1318 | 2 | m.input_fetch_start_timestamp, |
1319 | 2 | m.input_fetch_completed_timestamp, |
1320 | 2 | ); |
1321 | 2 | usage.output_upload_ms = phase_ms( |
1322 | 2 | m.output_upload_start_timestamp, |
1323 | 2 | m.output_upload_completed_timestamp, |
1324 | 2 | ); |
1325 | 2 | usage.output_bytes = result |
1326 | 2 | .output_files |
1327 | 2 | .iter() |
1328 | 2 | .map(|file| file.digest1 .size_bytes1 ()) |
1329 | 2 | .chain( |
1330 | 2 | result |
1331 | 2 | .output_folders |
1332 | 2 | .iter() |
1333 | 2 | .map(|dir| dir.tree_digest0 .size_bytes0 ()), |
1334 | | ) |
1335 | 2 | .chain([ |
1336 | 2 | result.stdout_digest.size_bytes(), |
1337 | 2 | result.stderr_digest.size_bytes(), |
1338 | 2 | ]) |
1339 | 2 | .sum(); |
1340 | 2 | } |
1341 | | |
1342 | | /// Aggressively download the digests of files and make a local folder from it. This function |
1343 | | /// gates each directory level to at most `DOWNLOAD_TO_DIRECTORY_CONCURRENCY` |
1344 | | /// concurrent in-flight materialization futures. |
1345 | | /// We require the `FilesystemStore` to be the `fast` store of `FastSlowStore`. This is for |
1346 | | /// efficiency reasons. We will request the `FastSlowStore` to populate the entry then we will |
1347 | | /// assume the `FilesystemStore` has the file available immediately after and hardlink the file |
1348 | | /// to a new location. |
1349 | | // Sadly we cannot use `async fn` here because the rust compiler cannot determine the auto traits |
1350 | | // of the future. So we need to force this function to return a dynamic future instead. |
1351 | | // see: https://github.com/rust-lang/rust/issues/78649 |
1352 | 10 | pub fn download_to_directory<'a>( |
1353 | 10 | cas_store: &'a FastSlowStore, |
1354 | 10 | filesystem_store: Pin<&'a FilesystemStore>, |
1355 | 10 | digest: &'a DigestInfo, |
1356 | 10 | current_directory: &'a str, |
1357 | 10 | ) -> BoxFuture<'a, Result<(), Error>> { |
1358 | 10 | async move { |
1359 | | // No shared pool, as for a worker without |
1360 | | // `max_concurrent_input_downloads`; the worker's own arrives through |
1361 | | // `prepare_action_inputs`. |
1362 | 10 | let downloads = input_download_pool(0); |
1363 | 10 | download_to_directory_bounded( |
1364 | 10 | cas_store, |
1365 | 10 | filesystem_store, |
1366 | 10 | digest, |
1367 | 10 | current_directory, |
1368 | 10 | &downloads, |
1369 | 10 | ) |
1370 | 10 | .await |
1371 | 10 | } |
1372 | 10 | .boxed() |
1373 | 10 | } |
1374 | | |
1375 | | /// `download_to_directory` drawing its fetches from `downloads`, the pool |
1376 | | /// the worker shares across the actions it stages. |
1377 | 13 | pub fn download_to_directory_bounded<'a>( |
1378 | 13 | cas_store: &'a FastSlowStore, |
1379 | 13 | filesystem_store: Pin<&'a FilesystemStore>, |
1380 | 13 | digest: &'a DigestInfo, |
1381 | 13 | current_directory: &'a str, |
1382 | 13 | downloads: &'a Semaphore, |
1383 | 13 | ) -> BoxFuture<'a, Result<(), Error>> { |
1384 | 13 | download_to_directory_with_mounts( |
1385 | 13 | cas_store, |
1386 | 13 | filesystem_store, |
1387 | 13 | digest, |
1388 | 13 | current_directory, |
1389 | 13 | None, |
1390 | 13 | None, |
1391 | 13 | downloads, |
1392 | 13 | ) |
1393 | 13 | } |
1394 | | |
1395 | 97 | fn download_to_directory_with_mounts<'a>( |
1396 | 97 | cas_store: &'a FastSlowStore, |
1397 | 97 | filesystem_store: Pin<&'a FilesystemStore>, |
1398 | 97 | digest: &'a DigestInfo, |
1399 | 97 | current_directory: &'a str, |
1400 | 97 | input_lease: Option<Arc<ActionInputLease>>, |
1401 | 97 | input_mounts: Option<Arc<InputMounts>>, |
1402 | 97 | downloads: &'a Semaphore, |
1403 | 97 | ) -> BoxFuture<'a, Result<(), Error>> { |
1404 | 97 | async move { |
1405 | 97 | let (dirs96 , files96 , inline_nodes96 ) = collect_download_links( |
1406 | 97 | cas_store, |
1407 | 97 | digest, |
1408 | 97 | current_directory, |
1409 | 97 | input_lease, |
1410 | 97 | input_mounts, |
1411 | 97 | downloads, |
1412 | 97 | ) |
1413 | 97 | .await?1 ; |
1414 | | |
1415 | 96 | let (exec_files, plain_files): (Vec<_>, Vec<_>) = |
1416 | 96 | files.into_iter().partition(|file| file.executable); |
1417 | 73 | let mut links = |
1418 | 96 | resolve_plain_files(cas_store, filesystem_store, plain_files, downloads).await?21 ; |
1419 | 73 | links.extend(resolve_exec_files(cas_store, filesystem_store, exec_files, downloads).await?0 ); |
1420 | | |
1421 | | // Every directory exists before anything is written into one, so no |
1422 | | // later pass has to create a parent it happens to need. |
1423 | 73 | fs::create_dir_many(dirs).await?0 ; |
1424 | 73 | materialize_inline_nodes(cas_store, filesystem_store, inline_nodes, downloads).await?0 ; |
1425 | 73 | materialize_links(cas_store, filesystem_store, links, downloads).await |
1426 | 95 | } |
1427 | 97 | .boxed() |
1428 | 97 | } |
1429 | | |
1430 | | /// A hardlink deferred to the end of the input-tree walk, so the whole tree is |
1431 | | /// materialized by one batched [`fs::hard_link_many`] pass instead of one |
1432 | | /// `spawn_blocking` dispatch per file. |
1433 | | struct PendingLink { |
1434 | | src: PathBuf, |
1435 | | dst: PathBuf, |
1436 | | /// What `src` was resolved from, so a link whose source is gone by the |
1437 | | /// time the batch runs can be resolved and made again. |
1438 | | digest: DigestInfo, |
1439 | | executable: bool, |
1440 | | /// Keeps the CAS blob's `FileEntry` alive until the link is made, so the |
1441 | | /// inode cannot be dropped out from under the batch. `None` for an |
1442 | | /// executable variant, which is a private file outside the entry map and so |
1443 | | /// has no entry to hold. |
1444 | | /// |
1445 | | /// A held entry pins the inode but does not stop an eviction from renaming |
1446 | | /// the blob, and a variant is deleted outright by the eviction callback, so |
1447 | | /// a link in the batch can still find its source gone; [`relink_evicted_input`] |
1448 | | /// then fetches the blob back and links it at once. An operator who sees |
1449 | | /// those repairs often is running the store at its ceiling and should enable |
1450 | | /// `experimental_active_input_leases`, since a leased digest is not evicted, |
1451 | | /// or raise the store's `max_bytes`. |
1452 | | _keepalive: Option<Arc<FileEntryImpl>>, |
1453 | | } |
1454 | | |
1455 | | /// A file whose CAS blob has not been resolved to a path yet. |
1456 | | struct PendingFile { |
1457 | | digest: DigestInfo, |
1458 | | dst: String, |
1459 | | executable: bool, |
1460 | | } |
1461 | | |
1462 | | /// The directories to create, the files to hardlink, and the nodes to |
1463 | | /// materialize one at a time, for one input subtree. |
1464 | | type CollectedTree = (Vec<PathBuf>, Vec<PendingFile>, Vec<InlineNode>); |
1465 | | |
1466 | | /// How many evicted inputs of one tree are fetched back at once. |
1467 | | const INPUT_RELINK_CONCURRENCY: usize = 32; |
1468 | | |
1469 | | /// How many times an input is fetched into the fast store and resolved |
1470 | | /// before the action fails. A store at its ceiling can take a blob away |
1471 | | /// again between the fetch and the lookup, or between the lookup and the |
1472 | | /// link. |
1473 | | const INPUT_FETCH_ATTEMPTS: u32 = 3; |
1474 | | |
1475 | | /// The pause before a retry grows by this much per attempt, plus a jitter |
1476 | | /// of up to the same amount taken from the digest, so the retry does not |
1477 | | /// land in the eviction pass that took the blob and a tree's inputs do not |
1478 | | /// all come back in one burst. |
1479 | | const INPUT_FETCH_PAUSE_STEP_MS: u64 = 20; |
1480 | | |
1481 | 18 | fn input_fetch_pause(attempt: u32, digest: &DigestInfo) -> Duration { |
1482 | 18 | let attempt = u64::from(attempt); |
1483 | 18 | let jitter = (u64::from(digest.packed_hash()[0]) * attempt) % INPUT_FETCH_PAUSE_STEP_MS; |
1484 | 18 | Duration::from_millis(INPUT_FETCH_PAUSE_STEP_MS * attempt + jitter) |
1485 | 18 | } |
1486 | | |
1487 | | /// The pause before a whole input tree is fetched again doubles from here. |
1488 | | const PREPARE_RETRY_PAUSE_MS: u64 = 1000; |
1489 | | |
1490 | | /// The pause before a whole input tree is fetched again never exceeds this. |
1491 | | const PREPARE_RETRY_PAUSE_CAP_MS: u64 = 30_000; |
1492 | | |
1493 | | /// Whether a failed input fetch is worth trying again from this worker. |
1494 | | /// These are the codes the store retrier itself treats as transient; a |
1495 | | /// throttled object store surfaces as `Internal` once the retrier gives up, |
1496 | | /// and an unclassified store failure as `Unknown`. The worker's own |
1497 | | /// filesystem lands on those two codes as well: a full disk, a blob at |
1498 | | /// its link limit or a read-only mount map to `Unknown`, a broken pipe to |
1499 | | /// `Internal`. A worker whose disk is the problem has to hand the action |
1500 | | /// back so it lands on a healthy one, not fetch into the same disk until |
1501 | | /// the deadline, so an operating system error is never retried here. Such |
1502 | | /// an error carries the OS error number in its message; a store's does not. |
1503 | 46 | fn is_transient_fetch_error(err: &Error) -> bool { |
1504 | 46 | match err.code { |
1505 | | Code::Unavailable |
1506 | | | Code::ResourceExhausted |
1507 | | | Code::Aborted |
1508 | | | Code::DeadlineExceeded |
1509 | 11 | | Code::DataLoss => true, |
1510 | 25 | Code::Internal | Code::Unknown => !is_os_error(err), |
1511 | 10 | _ => false, |
1512 | | } |
1513 | 46 | } |
1514 | | |
1515 | | /// Whether the error was raised by the operating system on this worker: |
1516 | | /// `std::io::Error` prints those as `... (os error N)`. |
1517 | 25 | fn is_os_error(err: &Error) -> bool { |
1518 | 25 | err.messages |
1519 | 25 | .iter() |
1520 | 215 | .any25 (|message| message.contains("(os error ")) |
1521 | 25 | } |
1522 | | |
1523 | | /// Pause before fetching the input tree again: 1 s, 2 s, 4 s ... capped at |
1524 | | /// 30 s, shortened by up to a quarter with a jitter taken from the digest so |
1525 | | /// the workers that failed together do not all come back together. |
1526 | 81 | fn prepare_retry_pause(attempt: u32, digest: &DigestInfo) -> Duration { |
1527 | 81 | let doublings = attempt.saturating_sub(1).min(5); |
1528 | 81 | let base_ms = (PREPARE_RETRY_PAUSE_MS << doublings).min(PREPARE_RETRY_PAUSE_CAP_MS); |
1529 | 81 | let jitter_byte = (u64::from(digest.packed_hash()[0]) * u64::from(attempt)) % 256; |
1530 | 81 | Duration::from_millis(base_ms - base_ms * jitter_byte / 1024) |
1531 | 81 | } |
1532 | | |
1533 | | /// Counts the retries of one input fetch on `worker.prepare.retries` and |
1534 | | /// settles it on exactly one terminal outcome once a retry was made: |
1535 | | /// `recovered`, `exhausted`, `failed` when a later attempt failed for good, |
1536 | | /// or `abandoned` when the fetch was dropped mid-retry by a kill or the |
1537 | | /// download timeout. A fetch that succeeded or failed first time records |
1538 | | /// nothing, so the terminal outcomes add up to the fetches retried. |
1539 | | struct PrepareRetries<R: Fn(&'static str)> { |
1540 | | record: R, |
1541 | | retries: u32, |
1542 | | settled: bool, |
1543 | | } |
1544 | | |
1545 | | impl<R: Fn(&'static str)> PrepareRetries<R> { |
1546 | 353 | const fn new(record: R) -> Self { |
1547 | 353 | Self { |
1548 | 353 | record, |
1549 | 353 | retries: 0, |
1550 | 353 | settled: false, |
1551 | 353 | } |
1552 | 353 | } |
1553 | | |
1554 | 24 | fn retried(&mut self) { |
1555 | 24 | self.retries += 1; |
1556 | 24 | (self.record)("retried"); |
1557 | 24 | } |
1558 | | |
1559 | 349 | fn settle(mut self, outcome: &'static str) { |
1560 | 349 | self.settled = true; |
1561 | 349 | if self.retries > 0 { |
1562 | 11 | (self.record)(outcome); |
1563 | 338 | } |
1564 | 349 | } |
1565 | | } |
1566 | | |
1567 | | impl<R: Fn(&'static str)> Drop for PrepareRetries<R> { |
1568 | 353 | fn drop(&mut self) { |
1569 | 353 | if !self.settled && self.retries > 04 { |
1570 | 2 | (self.record)("abandoned"); |
1571 | 351 | } |
1572 | 353 | } |
1573 | | } |
1574 | | |
1575 | | /// Records on `worker.prepare.retries`. A named function rather than the |
1576 | | /// helper itself, so the metrics reference generator sees the call. |
1577 | 32 | fn record_prepare_outcome(outcome: &'static str) { |
1578 | 32 | record_prepare_retry(outcome); |
1579 | 32 | } |
1580 | | |
1581 | | /// Fetches a blob into the fast store with `populate` and resolves it there |
1582 | | /// with `resolve`, again when the store has taken it away in between. |
1583 | 172 | async fn fetch_and_resolve<T, P, PFut, R, RFut>( |
1584 | 172 | digest: DigestInfo, |
1585 | 172 | populate: P, |
1586 | 172 | resolve: R, |
1587 | 172 | ) -> Result<T, Error> |
1588 | 172 | where |
1589 | 172 | P: Fn() -> PFut, |
1590 | 172 | PFut: Future<Output = Result<(), Error>>, |
1591 | 172 | R: Fn() -> RFut, |
1592 | 172 | RFut: Future<Output = Result<T, Error>>, |
1593 | 172 | { |
1594 | 172 | let mut attempt = 1; |
1595 | | loop { |
1596 | 176 | populate() |
1597 | 176 | .await |
1598 | 173 | .err_tip(|| format!22 ("Fetching {digest} for an input"))?22 ; |
1599 | 151 | match resolve().await { |
1600 | 6 | Err(err5 ) if err.code == Code::NotFound5 => { |
1601 | 5 | if attempt >= INPUT_FETCH_ATTEMPTS { |
1602 | 1 | record_input_relink("exhausted"); |
1603 | 1 | return Err(err); |
1604 | 4 | } |
1605 | 4 | tokio::time::sleep(input_fetch_pause(attempt, &digest)).await; |
1606 | 4 | attempt += 1; |
1607 | | } |
1608 | 146 | result => return result, |
1609 | | } |
1610 | | } |
1611 | 169 | } |
1612 | | |
1613 | | /// Executes every deferred hardlink of an input tree in one batched pass. |
1614 | | /// |
1615 | | /// A link whose source has gone missing since it was resolved is made again |
1616 | | /// from a fresh copy of the blob; any other failure fails the tree. |
1617 | 73 | async fn materialize_links( |
1618 | 73 | cas_store: &FastSlowStore, |
1619 | 73 | filesystem_store: Pin<&FilesystemStore>, |
1620 | 73 | links: Vec<PendingLink>, |
1621 | 73 | downloads: &Semaphore, |
1622 | 73 | ) -> Result<(), Error> { |
1623 | 73 | let pairs = links |
1624 | 73 | .iter() |
1625 | 149 | .map73 (|link| (link.src.clone(), link.dst.clone())) |
1626 | 73 | .collect(); |
1627 | 73 | let mut evicted = Vec::new(); |
1628 | 149 | for (link, result) in links.iter()73 .zip73 (fs::hard_link_many73 (pairs).await?0 ) { |
1629 | 149 | let Err(err2 ) = result else { |
1630 | 147 | continue; |
1631 | | }; |
1632 | | // `NotFound` is the source: this walk made the destination's parent. |
1633 | | // The store evicted the blob, or deleted the variant, after it was |
1634 | | // resolved. Looking for the source now would prove nothing, since a |
1635 | | // neighbouring action that stages the same file builds the variant |
1636 | | // again within milliseconds; the link is made again from a fetch. |
1637 | 2 | if err.code != Code::NotFound { |
1638 | 0 | return Err(hardlink_error(filesystem_store, link, err)); |
1639 | 2 | } |
1640 | 2 | evicted.push(link); |
1641 | | } |
1642 | 73 | if evicted.is_empty() { |
1643 | 71 | return Ok(()); |
1644 | 2 | } |
1645 | | // The store at its ceiling is where several inputs of one tree go at |
1646 | | // once, so they are fetched back together, not one round trip each. |
1647 | 2 | let repairs: Vec<BoxFuture<'_, Result<(), Error>>> = evicted |
1648 | 2 | .into_iter() |
1649 | 2 | .map(|link| relink_and_report(cas_store, filesystem_store, link, downloads).boxed()) |
1650 | 2 | .collect(); |
1651 | 2 | futures::stream::iter(repairs) |
1652 | 2 | .buffer_unordered(INPUT_RELINK_CONCURRENCY) |
1653 | 2 | .try_collect::<()>() |
1654 | 2 | .await |
1655 | 73 | } |
1656 | | |
1657 | 2 | async fn relink_and_report( |
1658 | 2 | cas_store: &FastSlowStore, |
1659 | 2 | filesystem_store: Pin<&FilesystemStore>, |
1660 | 2 | link: &PendingLink, |
1661 | 2 | downloads: &Semaphore, |
1662 | 2 | ) -> Result<(), Error> { |
1663 | 2 | let _permit = acquire_download_permit(downloads).await?0 ; |
1664 | 2 | info!( |
1665 | | digest = %link.digest, |
1666 | | src_path = ?link.src, |
1667 | | "Hardlink source was evicted while the input tree was staged, fetching it again" |
1668 | | ); |
1669 | 2 | relink_evicted_input(cas_store, filesystem_store, link) |
1670 | 2 | .await |
1671 | 2 | .map_err(|err| hardlink_error0 (filesystem_store0 , link0 , err0 )) |
1672 | 2 | } |
1673 | | |
1674 | | /// The error a failed input hardlink surfaces as, with what an operator needs |
1675 | | /// to tell an evicted source from everything else. |
1676 | 0 | fn hardlink_error(filesystem_store: Pin<&FilesystemStore>, link: &PendingLink, e: Error) -> Error { |
1677 | 0 | let (src_path, dest) = (&link.src, &link.dst); |
1678 | 0 | let src_metadata = std::fs::metadata(src_path); |
1679 | 0 | let dest_metadata = std::fs::metadata(dest); |
1680 | 0 | let dest_parent_metadata = dest.parent().map(Path::metadata); |
1681 | 0 | let snapshot = filesystem_store.get_eviction_snapshot(); |
1682 | 0 | warn!(?e, fs_eviction_snapshot = %snapshot, ?src_path, ?src_metadata, ?dest, ?dest_metadata, ?dest_parent_metadata, "Could not make hardlink"); |
1683 | 0 | if e.code == Code::NotFound { |
1684 | 0 | e.append( |
1685 | 0 | format!( |
1686 | | "Could not make hardlink from {} to {}, file was likely evicted from cache.\n\ |
1687 | | This error often occurs when the filesystem store's max_bytes is too small for your workload.\n\ |
1688 | | To fix this issue:\n\ |
1689 | | 1. Increase the 'max_bytes' value in your filesystem store configuration\n\ |
1690 | | 2. Example: Change 'max_bytes: 10000000000' to 'max_bytes: 50000000000' (or higher)\n\ |
1691 | | 3. The setting is typically found in your nativelink.json config under:\n\ |
1692 | | stores -> [your_filesystem_store] -> filesystem -> eviction_policy -> max_bytes\n\ |
1693 | | 4. Restart NativeLink after making the change\n\n\ |
1694 | | If this error persists after increasing max_bytes several times, please report at:\n\ |
1695 | | https://github.com/TraceMachina/nativelink/issues\n\ |
1696 | | Include your config file and both server and client logs to help us assist you.", |
1697 | 0 | src_path.display(), dest.display() |
1698 | | )) |
1699 | | } else { |
1700 | 0 | e.append(format!( |
1701 | | "Could not make hardlink from {} to {}", |
1702 | 0 | src_path.display(), |
1703 | 0 | dest.display() |
1704 | | )) |
1705 | | } |
1706 | 0 | } |
1707 | | |
1708 | | /// Makes one hardlink again after the batch found its source gone. |
1709 | | /// |
1710 | | /// Every source of an input tree is resolved before the first link is made, |
1711 | | /// and a store at its ceiling can evict a blob in between: eviction renames a |
1712 | | /// plain blob out from under the path that was resolved, and deletes an |
1713 | | /// executable variant outright. The blob is still in the slow tier, so it is |
1714 | | /// fetched back and linked at once. A plain blob is linked inside its entry's |
1715 | | /// path lock, which an eviction has to take to rename it, so this link cannot |
1716 | | /// lose the same race again; a variant is linked straight after it is |
1717 | | /// resolved, which leaves a window of one call where the batch left a whole |
1718 | | /// tree's worth. |
1719 | 2 | async fn relink_evicted_input( |
1720 | 2 | cas_store: &FastSlowStore, |
1721 | 2 | filesystem_store: Pin<&FilesystemStore>, |
1722 | 2 | link: &PendingLink, |
1723 | 2 | ) -> Result<(), Error> { |
1724 | 2 | if link.executable { |
1725 | 1 | fetch_and_resolve( |
1726 | 1 | link.digest, |
1727 | 1 | || cas_store.populate_fast_store(link.digest.into()), |
1728 | 1 | || async { |
1729 | 1 | let src = filesystem_store |
1730 | 1 | .get_executable_hardlink_source(&link.digest) |
1731 | 1 | .await |
1732 | 1 | .err_tip(|| {0 |
1733 | 0 | format!("Resolving the executable variant for {} again", link.digest) |
1734 | 0 | })?; |
1735 | 1 | fs::hard_link(src, &link.dst).await |
1736 | 2 | }, |
1737 | | ) |
1738 | 1 | .await?0 ; |
1739 | 1 | record_input_relink("executable"); |
1740 | 1 | return Ok(()); |
1741 | 1 | } |
1742 | 1 | fetch_and_resolve( |
1743 | 1 | link.digest, |
1744 | 1 | || cas_store.populate_fast_store(link.digest.into()), |
1745 | 1 | || async { |
1746 | 1 | let file_entry = filesystem_store |
1747 | 1 | .get_file_entry_for_digest(&link.digest) |
1748 | 1 | .await |
1749 | 1 | .err_tip(|| "Resolving an evicted hardlink source again")?0 ; |
1750 | 1 | let dst = link.dst.clone(); |
1751 | 1 | file_entry |
1752 | 2 | .get_file_path_locked1 (|src| async move {1 fs::hard_link1 (src1 , dst).await }) |
1753 | 1 | .await |
1754 | 2 | }, |
1755 | | ) |
1756 | 1 | .await?0 ; |
1757 | 1 | record_input_relink("plain"); |
1758 | 1 | Ok(()) |
1759 | 2 | } |
1760 | | |
1761 | | /// Resolves every non-executable file of an input tree to its CAS blob path. |
1762 | | /// |
1763 | | /// The blob is nearly always resident, and `populate_fast_store` would take the |
1764 | | /// store's eviction lock only to discover that, so resolving the entry directly |
1765 | | /// answers the same question and yields the path. Only a genuine miss pays the |
1766 | | /// populate round trip. |
1767 | 96 | async fn resolve_plain_files( |
1768 | 96 | cas_store: &FastSlowStore, |
1769 | 96 | filesystem_store: Pin<&FilesystemStore>, |
1770 | 96 | files: Vec<PendingFile>, |
1771 | 96 | downloads: &Semaphore, |
1772 | 96 | ) -> Result<Vec<PendingLink>, Error> { |
1773 | 96 | let mut tasks = Vec::with_capacity(files.len()); |
1774 | 164 | for file in files96 { |
1775 | 164 | tasks.push(async move { |
1776 | 164 | let file_entry140 = if let Ok(entry3 ) = filesystem_store |
1777 | 164 | .get_file_entry_for_digest(&file.digest) |
1778 | 164 | .await |
1779 | | { |
1780 | 3 | record_input_file(&file.digest, true); |
1781 | 3 | entry |
1782 | | } else { |
1783 | 161 | let _permit = acquire_download_permit(downloads).await?0 ; |
1784 | 161 | let entry137 = fetch_and_resolve( |
1785 | 161 | file.digest, |
1786 | 161 | || cas_store.populate_fast_store(file.digest.into()), |
1787 | 137 | || filesystem_store.get_file_entry_for_digest(&file.digest), |
1788 | | ) |
1789 | 161 | .await |
1790 | 158 | .err_tip(|| "During hard link")?21 ; |
1791 | 137 | record_input_file(&file.digest, false); |
1792 | 137 | entry |
1793 | | }; |
1794 | | // TODO: add a test for #2051: deadlock with large number of files |
1795 | 140 | let src_path = file_entry |
1796 | 280 | .get_file_path_locked140 (|src| async move {140 Ok(src)140 }) |
1797 | 140 | .await |
1798 | 140 | .err_tip(|| format!0 ("for digest {}", file.digest))?0 ; |
1799 | 140 | Ok::<_, Error>(PendingLink { |
1800 | 140 | src: PathBuf::from(src_path), |
1801 | 140 | dst: PathBuf::from(file.dst), |
1802 | 140 | digest: file.digest, |
1803 | 140 | executable: false, |
1804 | 140 | _keepalive: Some(file_entry), |
1805 | 140 | }) |
1806 | 161 | }); |
1807 | | } |
1808 | 96 | collect_links(tasks).await |
1809 | 94 | } |
1810 | | |
1811 | | /// Runs the resolution tasks of one input tree, bounded, into the link batch. |
1812 | 169 | async fn collect_links( |
1813 | 169 | tasks: Vec<impl Future<Output = Result<PendingLink, Error>>>, |
1814 | 169 | ) -> Result<Vec<PendingLink>, Error> { |
1815 | 169 | futures::stream::iter(tasks) |
1816 | 169 | .buffer_unordered(DOWNLOAD_TO_DIRECTORY_CONCURRENCY) |
1817 | 169 | .try_collect() |
1818 | 169 | .await |
1819 | 167 | } |
1820 | | |
1821 | | /// Resolves every executable file of an input tree to its 0o555 variant. |
1822 | | /// |
1823 | | /// The shared 0o444 CAS blob cannot carry the executable bit, so each of these |
1824 | | /// digests hardlinks a private variant instead, and the batch checks the whole |
1825 | | /// tree's variants for existence in one dispatch. |
1826 | | /// |
1827 | | /// A variant cannot be built from a blob that is not resident, and only this |
1828 | | /// caller can fetch one, so those digests are fetched and resolved here, again |
1829 | | /// if the store evicts the blob before the variant is built. |
1830 | 73 | async fn resolve_exec_files( |
1831 | 73 | cas_store: &FastSlowStore, |
1832 | 73 | filesystem_store: Pin<&FilesystemStore>, |
1833 | 73 | files: Vec<PendingFile>, |
1834 | 73 | downloads: &Semaphore, |
1835 | 73 | ) -> Result<Vec<PendingLink>, Error> { |
1836 | 73 | let digests: Vec<DigestInfo> = files.iter().map(|file| file.digest).collect(); |
1837 | 73 | let sources = filesystem_store |
1838 | 73 | .get_executable_hardlink_sources(&digests) |
1839 | 73 | .await; |
1840 | | |
1841 | 73 | let mut tasks = Vec::with_capacity(files.len()); |
1842 | 73 | for (file9 , source9 ) in files.into_iter().zip(sources) { |
1843 | 9 | tasks.push(async move { |
1844 | 9 | let src_path = if let Ok(src_path4 ) = source { |
1845 | 4 | record_input_file(&file.digest, true); |
1846 | 4 | src_path |
1847 | | } else { |
1848 | 5 | let _permit = acquire_download_permit(downloads).await?0 ; |
1849 | 5 | let src_path = fetch_and_resolve( |
1850 | 5 | file.digest, |
1851 | 5 | || cas_store.populate_fast_store(file.digest.into()), |
1852 | 5 | || filesystem_store.get_executable_hardlink_source(&file.digest), |
1853 | | ) |
1854 | 5 | .await |
1855 | 5 | .err_tip(|| format!0 ("Resolving executable variant for {}", file.digest))?0 ; |
1856 | 5 | record_input_file(&file.digest, false); |
1857 | 5 | src_path |
1858 | | }; |
1859 | 9 | Ok::<_, Error>(PendingLink { |
1860 | 9 | src: PathBuf::from(src_path), |
1861 | 9 | dst: PathBuf::from(file.dst), |
1862 | 9 | digest: file.digest, |
1863 | 9 | executable: true, |
1864 | 9 | _keepalive: None, |
1865 | 9 | }) |
1866 | 9 | }); |
1867 | | } |
1868 | 73 | collect_links(tasks).await |
1869 | 73 | } |
1870 | | |
1871 | | /// Walks the input tree, reading its `Directory` protos and sorting every node |
1872 | | /// into one of the passes the caller then runs: directories to create, files to |
1873 | | /// hardlink in a batch, and the few nodes that need individual treatment. |
1874 | | /// |
1875 | | /// Nothing is written to disk here, which is what lets the caller create every |
1876 | | /// directory before any node is materialized into one. |
1877 | 127 | fn collect_download_links<'a>( |
1878 | 127 | cas_store: &'a FastSlowStore, |
1879 | 127 | digest: &'a DigestInfo, |
1880 | 127 | current_directory: &'a str, |
1881 | 127 | input_lease: Option<Arc<ActionInputLease>>, |
1882 | 127 | input_mounts: Option<Arc<InputMounts>>, |
1883 | 127 | downloads: &'a Semaphore, |
1884 | 127 | ) -> BoxFuture<'a, Result<CollectedTree, Error>> { |
1885 | 127 | async move { |
1886 | 127 | if let Some(input_lease24 ) = &input_lease { |
1887 | 24 | input_lease.lease_digest(digest); |
1888 | 103 | } |
1889 | 127 | if let Some(mounts21 ) = &input_mounts { |
1890 | 21 | let lease = input_lease |
1891 | 21 | .as_deref() |
1892 | 21 | .map(|lease| -> &dyn crate::directory_cache::DigestLease { lease }); |
1893 | 21 | if mounts |
1894 | 21 | .prepare(Path::new(current_directory), *digest, lease) |
1895 | 21 | .await?1 |
1896 | | { |
1897 | 6 | return Ok((Vec::new(), Vec::new(), Vec::new())); |
1898 | 14 | } |
1899 | 106 | } |
1900 | | // The permit covers the fetch alone, never the walk below it: a |
1901 | | // parent holding one while its children wait for theirs would |
1902 | | // deadlock a tree deeper than the pool. |
1903 | 120 | let directory = { |
1904 | 120 | let _permit = acquire_download_permit(downloads).await?0 ; |
1905 | 120 | get_and_decode_digest::<ProtoDirectory>(cas_store, digest.into()) |
1906 | 120 | .await |
1907 | 120 | .err_tip(|| "Converting digest to Directory")?0 |
1908 | | }; |
1909 | 120 | let mut futures = Vec::new(); |
1910 | 120 | let mut pending_files = Vec::new(); |
1911 | 120 | let mut inline_nodes = Vec::new(); |
1912 | | |
1913 | 180 | for file in directory.files120 { |
1914 | 180 | let digest: DigestInfo = file |
1915 | 180 | .digest |
1916 | 180 | .err_tip(|| "Expected Digest to exist in Directory::file::digest")?0 |
1917 | 180 | .try_into() |
1918 | 180 | .err_tip(|| "In Directory::file::digest")?0 ; |
1919 | 180 | let dest = format!("{}/{}", current_directory, file.name); |
1920 | 180 | let is_executable = file.is_executable; |
1921 | 180 | let (mtime, custom_unix_mode) = match file.node_properties { |
1922 | 3 | Some(properties) => (properties.mtime, properties.unix_mode), |
1923 | 177 | None => (None, None), |
1924 | | }; |
1925 | 180 | if let Some(input_lease1 ) = &input_lease { |
1926 | 1 | input_lease.lease_digest(&digest); |
1927 | 179 | } |
1928 | | // Hot path: nothing to stamp, so the file needs nothing from the |
1929 | | // walk beyond its digest. Hand it up unresolved and let the caller |
1930 | | // resolve and link the whole tree in one batch. |
1931 | 180 | if mtime.is_none() && custom_unix_mode178 .is_none178 () && !is_zero_digest(digest)177 { |
1932 | 173 | pending_files.push(PendingFile { |
1933 | 173 | digest, |
1934 | 173 | dst: dest, |
1935 | 173 | executable: is_executable, |
1936 | 173 | }); |
1937 | 173 | continue; |
1938 | 7 | } |
1939 | 7 | inline_nodes.push(InlineNode::File { |
1940 | 7 | digest, |
1941 | 7 | dst: dest, |
1942 | 7 | is_executable, |
1943 | 7 | mtime: mtime |
1944 | 7 | .map(|mtime| FileTime::from_unix_time2 (mtime.seconds2 , mtime.nanos as u322 )), |
1945 | 7 | custom_unix_mode, |
1946 | | }); |
1947 | | } |
1948 | | |
1949 | 120 | for sub_directory30 in directory.directories { |
1950 | 30 | let digest: DigestInfo = sub_directory |
1951 | 30 | .digest |
1952 | 30 | .err_tip(|| "Expected Digest to exist in Directory::directories::digest")?0 |
1953 | 30 | .try_into() |
1954 | 30 | .err_tip(|| "In Directory::file::digest")?0 ; |
1955 | 30 | let new_directory_path = format!("{}/{}", current_directory, sub_directory.name); |
1956 | 30 | if let Some(input_lease16 ) = &input_lease { |
1957 | 16 | // Reserve queued child directories before their futures are |
1958 | 16 | // polled. Without this, a large parent can leave later |
1959 | 16 | // directory digests exposed to slow-tier eviction while the |
1960 | 16 | // first batch of children is being materialized. |
1961 | 16 | input_lease.lease_digest(&digest); |
1962 | 16 | }14 |
1963 | 30 | let input_lease = input_lease.clone(); |
1964 | 30 | let input_mounts = input_mounts.clone(); |
1965 | 30 | futures.push( |
1966 | 30 | async move { |
1967 | 30 | let (mut sub_dirs29 , files29 , inline29 ) = collect_download_links( |
1968 | 30 | cas_store, |
1969 | 30 | &digest, |
1970 | 30 | &new_directory_path, |
1971 | 30 | input_lease, |
1972 | 30 | input_mounts, |
1973 | 30 | downloads, |
1974 | 30 | ) |
1975 | 30 | .await |
1976 | 30 | .err_tip(|| format!1 ("in download_to_directory : {new_directory_path}"))?1 ; |
1977 | 29 | sub_dirs.push(PathBuf::from(new_directory_path)); |
1978 | 29 | Ok::<_, Error>((sub_dirs, files, inline)) |
1979 | 30 | } |
1980 | 30 | .boxed(), |
1981 | | ); |
1982 | | } |
1983 | | |
1984 | | #[cfg(target_family = "unix")] |
1985 | 120 | for symlink_node4 in directory.symlinks { |
1986 | 4 | inline_nodes.push(InlineNode::Symlink { |
1987 | 4 | target: symlink_node.target, |
1988 | 4 | dst: format!("{}/{}", current_directory, symlink_node.name), |
1989 | 4 | }); |
1990 | 4 | } |
1991 | | |
1992 | | // Gate concurrency: at most DOWNLOAD_TO_DIRECTORY_CONCURRENCY futures |
1993 | | // polled at once for this directory level. Previously all futures were |
1994 | | // pushed into an unbounded FuturesUnordered, which on macOS produced |
1995 | | // thousands of parallel hardlink(2) calls fighting APFS's per-volume |
1996 | | // metadata lock and regressing throughput vs serial. |
1997 | 120 | let collected119 : Vec<CollectedTree>119 = futures::stream::iter(futures) |
1998 | 120 | .buffer_unordered(DOWNLOAD_TO_DIRECTORY_CONCURRENCY) |
1999 | 120 | .try_collect() |
2000 | 120 | .await?1 ; |
2001 | | |
2002 | 119 | let mut dirs = Vec::new(); |
2003 | 119 | for (sub_dirs28 , sub_files28 , sub_inline28 ) in collected { |
2004 | 28 | dirs.extend(sub_dirs); |
2005 | 28 | pending_files.extend(sub_files); |
2006 | 28 | inline_nodes.extend(sub_inline); |
2007 | 28 | } |
2008 | 119 | Ok((dirs, pending_files, inline_nodes)) |
2009 | 127 | } |
2010 | 127 | .boxed() |
2011 | 127 | } |
2012 | | |
2013 | | /// A node materialized one at a time rather than through a batch, because it |
2014 | | /// needs a private inode or is not a hardlink at all. |
2015 | | /// |
2016 | | /// These are a tiny fraction of an input tree, and deferring them alongside the |
2017 | | /// batched files keeps one ordering rule for the whole walk: nothing is written |
2018 | | /// until every directory exists. |
2019 | | enum InlineNode { |
2020 | | /// A zero-digest file, or one carrying metadata that must be stamped onto |
2021 | | /// an inode private to this action. |
2022 | | File { |
2023 | | digest: DigestInfo, |
2024 | | dst: String, |
2025 | | is_executable: bool, |
2026 | | mtime: Option<FileTime>, |
2027 | | custom_unix_mode: Option<u32>, |
2028 | | }, |
2029 | | #[cfg(target_family = "unix")] |
2030 | | Symlink { target: String, dst: String }, |
2031 | | } |
2032 | | |
2033 | | /// Stamps the mode an input file asked for. `dest` must be an inode private to |
2034 | | /// this action: the same call on a hardlinked CAS blob would mutate the inode |
2035 | | /// every other action shares. |
2036 | | #[cfg(target_family = "unix")] |
2037 | 7 | async fn stamp_unix_mode( |
2038 | 7 | dest: &str, |
2039 | 7 | is_executable: bool, |
2040 | 7 | custom_unix_mode: Option<u32>, |
2041 | 7 | ) -> Result<(), Error> { |
2042 | 7 | let mode3 = match (custom_unix_mode, is_executable) { |
2043 | 1 | (Some(mode), true) => mode | 0o111, |
2044 | 2 | (Some(mode), false) => mode, |
2045 | 0 | (None, true) => 0o555, |
2046 | 4 | (None, false) => return Ok(()), |
2047 | | }; |
2048 | 3 | fs::set_permissions(dest, Permissions::from_mode(mode)) |
2049 | 3 | .await |
2050 | 3 | .err_tip(|| format!0 ("Could not set unix mode in download_to_directory {dest}")) |
2051 | 7 | } |
2052 | | |
2053 | | /// Non-unix has no mode to stamp. |
2054 | | #[cfg(not(target_family = "unix"))] |
2055 | | async fn stamp_unix_mode( |
2056 | | _dest: &str, |
2057 | | _is_executable: bool, |
2058 | | _custom_unix_mode: Option<u32>, |
2059 | | ) -> Result<(), Error> { |
2060 | | Ok(()) |
2061 | | } |
2062 | | |
2063 | | /// Materializes the nodes the batched passes cannot handle. |
2064 | 73 | async fn materialize_inline_nodes( |
2065 | 73 | cas_store: &FastSlowStore, |
2066 | 73 | filesystem_store: Pin<&FilesystemStore>, |
2067 | 73 | nodes: Vec<InlineNode>, |
2068 | 73 | downloads: &Semaphore, |
2069 | 73 | ) -> Result<(), Error> { |
2070 | 73 | let mut futures = Vec::with_capacity(nodes.len()); |
2071 | 73 | for node11 in nodes { |
2072 | 11 | futures.push(async move { |
2073 | 11 | match node { |
2074 | | #[cfg(target_family = "unix")] |
2075 | 4 | InlineNode::Symlink { target, dst } => fs::symlink(&target, &dst) |
2076 | 4 | .await |
2077 | 4 | .err_tip(|| format!0 ("Could not create symlink {target} -> {dst}")), |
2078 | | InlineNode::File { |
2079 | 7 | digest, |
2080 | 7 | dst: dest, |
2081 | 7 | is_executable, |
2082 | 7 | mtime, |
2083 | 7 | custom_unix_mode, |
2084 | | } => { |
2085 | | { |
2086 | 7 | let _permit = acquire_download_permit(downloads).await?0 ; |
2087 | 7 | cas_store.populate_fast_store(digest.into()).await?0 ; |
2088 | | } |
2089 | 7 | async move { |
2090 | 7 | if is_zero_digest(digest) { |
2091 | | // Zero-digest files are never persisted by the |
2092 | | // FilesystemStore, so materialise them directly in |
2093 | | // the worker exec dir. |
2094 | 4 | let mut file_slot = fs::create_file(&dest) |
2095 | 4 | .await |
2096 | 4 | .err_tip(|| format!0 ("Could not create zero-digest file at {dest}"))?0 ; |
2097 | 4 | file_slot |
2098 | 4 | .write_all(&[]) |
2099 | 4 | .await |
2100 | 4 | .err_tip(|| format!0 ("Could not write zero-digest file at {dest}"))?0 ; |
2101 | | } else { |
2102 | | // Rare path: per-file metadata (a custom unix_mode or |
2103 | | // an mtime) must land on a PRIVATE inode. A |
2104 | | // chmod/utimes on a hardlink mutates the shared CAS |
2105 | | // inode for every other action that hardlinked it |
2106 | | // (the #2347 corruption class). Copy the blob into a |
2107 | | // private inode, then stamp mode/mtime onto it below. |
2108 | 3 | let file_entry = filesystem_store |
2109 | 3 | .get_file_entry_for_digest(&digest) |
2110 | 3 | .await |
2111 | 3 | .err_tip(|| "During private copy")?0 ; |
2112 | 3 | let src_path = file_entry |
2113 | 6 | .get_file_path_locked3 (|src| async move {3 Ok(src)3 }) |
2114 | 3 | .await?0 ; |
2115 | 3 | let spawned_dest = dest.clone(); |
2116 | 3 | spawn_blocking!("download_to_directory_private_copy", move || { |
2117 | 3 | std::fs::copy(&src_path, &spawned_dest).map(|_| ()).map_err(|e| {0 |
2118 | 0 | make_err!( |
2119 | 0 | Code::Internal, |
2120 | | "Failed to copy CAS blob into a private inode at {spawned_dest}: {e:?}" |
2121 | | ) |
2122 | 0 | }) |
2123 | 3 | }) |
2124 | 3 | .await |
2125 | 3 | .err_tip(|| {0 |
2126 | 0 | "Failed to launch spawn_blocking private copy in download_to_directory" |
2127 | 0 | })??; |
2128 | | } |
2129 | | |
2130 | | // Private-inode tail (zero-digest or private copy only): |
2131 | | // stamp the requested mode and mtime. Safe because the |
2132 | | // inode is private to this action. |
2133 | 7 | stamp_unix_mode(&dest, is_executable, custom_unix_mode).await?0 ; |
2134 | 7 | if let Some(mtime2 ) = mtime { |
2135 | 2 | let spawned_dest = dest.clone(); |
2136 | 2 | spawn_blocking!("download_to_directory_set_mtime", move || { |
2137 | 2 | set_file_mtime(&spawned_dest, mtime).err_tip(|| {0 |
2138 | 0 | format!("Failed to set mtime in download_to_directory {spawned_dest}") |
2139 | 0 | }) |
2140 | 2 | }) |
2141 | 2 | .await |
2142 | 2 | .err_tip( |
2143 | | || "Failed to launch spawn_blocking in download_to_directory", |
2144 | 0 | )??; |
2145 | 5 | } |
2146 | 7 | Ok(()) |
2147 | 7 | } |
2148 | 7 | .await |
2149 | 7 | .map_err(|e: Error| e0 .append0 (format!0 ("for digest {digest}"))) |
2150 | | } |
2151 | | } |
2152 | 11 | }); |
2153 | | } |
2154 | 73 | futures::stream::iter(futures) |
2155 | 73 | .buffer_unordered(DOWNLOAD_TO_DIRECTORY_CONCURRENCY) |
2156 | 73 | .try_collect() |
2157 | 73 | .await |
2158 | 73 | } |
2159 | | |
2160 | | /// Prepares action inputs by first trying the directory cache (if available), |
2161 | | /// then falling back to traditional `download_to_directory`. |
2162 | | /// |
2163 | | /// This provides a significant performance improvement for repeated builds |
2164 | | /// with the same input directories. |
2165 | | /// |
2166 | | /// `work_directory` must already exist and be empty when this is called: the |
2167 | | /// caller pre-creates it so that, on the fallback path, `download_to_directory` |
2168 | | /// has a destination to write into. The directory cache, however, materializes |
2169 | | /// the tree with `hardlink_directory_tree` / APFS `clonefile(2)`, both of which |
2170 | | /// require the destination to *not* exist — so this function removes the empty |
2171 | | /// directory before invoking the cache and recreates it if the cache fails. |
2172 | 0 | pub async fn prepare_action_inputs( |
2173 | 0 | directory_cache: &Option<Arc<crate::directory_cache::DirectoryCache>>, |
2174 | 0 | cas_store: &FastSlowStore, |
2175 | 0 | filesystem_store: Pin<&FilesystemStore>, |
2176 | 0 | digest: &DigestInfo, |
2177 | 0 | work_directory: &str, |
2178 | 0 | downloads: &Semaphore, |
2179 | 0 | ) -> Result<(), Error> { |
2180 | | prepare_action_inputs_with_lease( |
2181 | | directory_cache, |
2182 | | cas_store, |
2183 | | filesystem_store, |
2184 | | digest, |
2185 | | work_directory, |
2186 | | None, |
2187 | | downloads, |
2188 | | ) |
2189 | | .await |
2190 | | } |
2191 | | |
2192 | 79 | async fn prepare_action_inputs_with_lease( |
2193 | 79 | directory_cache: &Option<Arc<crate::directory_cache::DirectoryCache>>, |
2194 | 79 | cas_store: &FastSlowStore, |
2195 | 79 | filesystem_store: Pin<&FilesystemStore>, |
2196 | 79 | digest: &DigestInfo, |
2197 | 79 | work_directory: &str, |
2198 | 79 | input_lease: Option<Arc<ActionInputLease>>, |
2199 | 79 | downloads: &Semaphore, |
2200 | 79 | ) -> Result<(), Error> { |
2201 | | // Try cache first if available. Cache hits are safe while an action lease |
2202 | | // is active because the directory cache pins its own materialized tree |
2203 | | // across the hardlink operation. On a miss, pass the action lease into |
2204 | | // cache construction so every CAS digest is reserved before it is |
2205 | | // fetched/populated. |
2206 | 79 | if let Some(cache3 ) = directory_cache { |
2207 | | // `clonefile(2)` and `hardlink_directory_tree` both require the |
2208 | | // destination to not exist. Remove the empty directory the caller |
2209 | | // pre-created; without this the cache fails its precondition on every |
2210 | | // action and silently falls back to the slow download path. |
2211 | 3 | fs::remove_dir(work_directory) |
2212 | 3 | .await |
2213 | 3 | .err_tip(|| format!0 ("Failed to clear pre-created work directory {work_directory}"))?0 ; |
2214 | 3 | let cache_result = if let Some(input_lease2 ) = &input_lease { |
2215 | 2 | cache |
2216 | 2 | .get_or_create_with_lease( |
2217 | 2 | *digest, |
2218 | 2 | Path::new(work_directory), |
2219 | 2 | Some(input_lease.as_ref()), |
2220 | 2 | ) |
2221 | 2 | .await |
2222 | | } else { |
2223 | 1 | cache |
2224 | 1 | .get_or_create(*digest, Path::new(work_directory)) |
2225 | 1 | .await |
2226 | | }; |
2227 | 3 | match cache_result { |
2228 | 3 | Ok(cache_hit) => { |
2229 | | // The materialized tree is already usable. The directory |
2230 | | // cache locks each entry down with `set_readonly_recursive`, |
2231 | | // which leaves directories writable (0o755) and only makes |
2232 | | // files read-only (0o555). The macOS `clonefile(2)` path |
2233 | | // copies those modes verbatim and the Linux hardlink walk |
2234 | | // creates fresh writable directories, so every directory in |
2235 | | // the materialized tree already accepts the nested output |
2236 | | // files Bazel actions declare — no separate per-materialize |
2237 | | // recursive chmod walk is needed here. Files stay read-only, |
2238 | | // preserving the hermeticity contract and the CAS-hardlink |
2239 | | // shared-inode invariant. |
2240 | 3 | trace!( |
2241 | | ?digest, |
2242 | | work_directory, cache_hit, "Successfully prepared inputs via directory cache" |
2243 | | ); |
2244 | 3 | return Ok(()); |
2245 | | } |
2246 | 0 | Err(e) => { |
2247 | 0 | warn!( |
2248 | | ?digest, |
2249 | | ?e, |
2250 | | "Directory cache failed, falling back to traditional download" |
2251 | | ); |
2252 | | // The cache may have materialized a partial tree before |
2253 | | // failing. `download_to_directory` needs an existing, empty |
2254 | | // destination, so discard any partial state and recreate the |
2255 | | // work directory. |
2256 | 0 | let _cleanup = fs::remove_dir_all(work_directory).await; |
2257 | 0 | fs::create_dir(work_directory) |
2258 | 0 | .await |
2259 | 0 | .err_tip(|| format!("Failed to recreate work directory {work_directory}"))?; |
2260 | | } |
2261 | | } |
2262 | 76 | } |
2263 | | |
2264 | | // Traditional path (cache disabled or failed) |
2265 | 76 | download_to_directory_with_mounts( |
2266 | 76 | cas_store, |
2267 | 76 | filesystem_store, |
2268 | 76 | digest, |
2269 | 76 | work_directory, |
2270 | 76 | input_lease, |
2271 | 76 | None, |
2272 | 76 | downloads, |
2273 | 76 | ) |
2274 | 76 | .await |
2275 | 77 | } |
2276 | | |
2277 | | #[cfg(target_family = "windows")] |
2278 | | fn is_executable(_metadata: &std::fs::Metadata, full_path: &impl AsRef<Path>) -> bool { |
2279 | | static EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "com"]; |
2280 | | EXECUTABLE_EXTENSIONS |
2281 | | .iter() |
2282 | | .any(|ext| full_path.as_ref().extension().map_or(false, |v| v == *ext)) |
2283 | | } |
2284 | | |
2285 | | #[cfg(target_family = "unix")] |
2286 | 280 | fn is_executable(metadata: &std::fs::Metadata, _full_path: &impl AsRef<Path>) -> bool { |
2287 | 280 | (metadata.mode() & 0o111) != 0 |
2288 | 280 | } |
2289 | | |
2290 | | type DigestUploader = Arc<tokio::sync::OnceCell<()>>; |
2291 | | |
2292 | 280 | async fn upload_file( |
2293 | 280 | cas_store: Pin<&impl StoreLike>, |
2294 | 280 | full_path: impl AsRef<Path> + Debug + Send + Sync, |
2295 | 280 | hasher: DigestHasherFunc, |
2296 | 280 | metadata: std::fs::Metadata, |
2297 | 280 | digest_uploaders: Arc<Mutex<HashMap<DigestInfo, DigestUploader>>>, |
2298 | 280 | uploads: Arc<Semaphore>, |
2299 | 280 | ) -> Result<FileInfo, Error> { |
2300 | | // Held from the open to the end of the upload: this is the bound on |
2301 | | // files open at once for the whole output tree. |
2302 | 280 | let _permit = uploads |
2303 | 280 | .acquire() |
2304 | 280 | .await |
2305 | 280 | .err_tip(|| "Upload semaphore closed")?0 ; |
2306 | 280 | let is_executable = is_executable(&metadata, &full_path); |
2307 | 280 | let file_size = metadata.len(); |
2308 | 280 | let file = fs::open_file(&full_path, 0, u64::MAX) |
2309 | 280 | .await |
2310 | 280 | .err_tip(|| format!0 ("Could not open file {full_path:?}"))?0 ; |
2311 | | |
2312 | 280 | let (digest, mut file) = hasher |
2313 | 280 | .hasher() |
2314 | 280 | .digest_for_file(&full_path, file.into_inner(), Some(file_size)) |
2315 | 280 | .await |
2316 | 280 | .err_tip(|| format!0 ("Failed to hash file in digest_for_file failed for {full_path:?}"))?0 ; |
2317 | | |
2318 | 280 | let digest_uploader = match digest_uploaders.lock().entry(digest) { |
2319 | 1 | std::collections::hash_map::Entry::Occupied(occupied_entry) => occupied_entry.get().clone(), |
2320 | 279 | std::collections::hash_map::Entry::Vacant(vacant_entry) => vacant_entry |
2321 | 279 | .insert(Arc::new(tokio::sync::OnceCell::new())) |
2322 | 279 | .clone(), |
2323 | | }; |
2324 | | |
2325 | | // Only upload a file with a given hash once. The file may exist multiple |
2326 | | // times in the output with different names. |
2327 | 280 | digest_uploader |
2328 | 558 | .get_or_try_init280 (async || { |
2329 | | // Only upload if the digest doesn't already exist, this should be |
2330 | | // a much cheaper operation than an upload. |
2331 | 279 | let cas_store = cas_store.as_store_driver_pin(); |
2332 | 279 | let store_key: nativelink_util::store_trait::StoreKey<'_> = digest.into(); |
2333 | 279 | let has_start = std::time::Instant::now(); |
2334 | 279 | if cas_store |
2335 | 279 | .has(store_key.borrow()) |
2336 | 279 | .await |
2337 | 279 | .is_ok_and(|result| result.is_some()) |
2338 | | { |
2339 | 7 | trace!( |
2340 | | ?digest, |
2341 | 7 | has_elapsed_ms = has_start.elapsed().as_millis(), |
2342 | | "upload_file: digest already exists in CAS, skipping upload", |
2343 | | ); |
2344 | 7 | return Ok(()); |
2345 | 272 | } |
2346 | 272 | trace!( |
2347 | | ?digest, |
2348 | 272 | has_elapsed_ms = has_start.elapsed().as_millis(), |
2349 | 272 | file_size = digest.size_bytes(), |
2350 | | "upload_file: digest not in CAS, starting upload", |
2351 | | ); |
2352 | | |
2353 | 272 | file.rewind().await.err_tip(|| "Could not rewind file")?0 ; |
2354 | | |
2355 | | // Note: For unknown reasons we appear to be hitting: |
2356 | | // https://github.com/rust-lang/rust/issues/92096 |
2357 | | // or a similar issue if we try to use the non-store driver function, so we |
2358 | | // are using the store driver function here. |
2359 | 272 | let store_key_for_upload = store_key.clone(); |
2360 | 272 | let file_upload_start = std::time::Instant::now(); |
2361 | 272 | let upload_result = cas_store |
2362 | 272 | .update_with_whole_file( |
2363 | 272 | store_key_for_upload, |
2364 | 272 | full_path.as_ref().into(), |
2365 | 272 | file, |
2366 | 272 | UploadSizeInfo::ExactSize(digest.size_bytes()), |
2367 | 272 | ) |
2368 | 272 | .await |
2369 | 272 | .map(|_slot| ()); |
2370 | 272 | trace!( |
2371 | | ?digest, |
2372 | 272 | upload_elapsed_ms = file_upload_start.elapsed().as_millis(), |
2373 | 272 | success = upload_result.is_ok(), |
2374 | | "upload_file: update_with_whole_file completed", |
2375 | | ); |
2376 | | |
2377 | 272 | match upload_result { |
2378 | 272 | Ok(()) => Ok(()), |
2379 | 0 | Err(err) => { |
2380 | | // Output uploads run concurrently and may overlap (e.g. a file is listed |
2381 | | // both as an output file and inside an output directory). When another |
2382 | | // upload has already moved the file into CAS, this update can fail with |
2383 | | // NotFound even though the digest is now present. Per the RE spec, missing |
2384 | | // outputs should be ignored, so treat this as success if the digest exists. |
2385 | 0 | if err.code == Code::NotFound |
2386 | 0 | && cas_store |
2387 | 0 | .has(store_key.borrow()) |
2388 | 0 | .await |
2389 | 0 | .is_ok_and(|result| result.is_some()) |
2390 | | { |
2391 | 0 | Ok(()) |
2392 | | } else { |
2393 | 0 | Err(err) |
2394 | | } |
2395 | | } |
2396 | | } |
2397 | 558 | }) |
2398 | 280 | .await |
2399 | 280 | .err_tip(|| format!0 ("for {full_path:?}"))?0 ; |
2400 | | |
2401 | 280 | let name = full_path |
2402 | 280 | .as_ref() |
2403 | 280 | .file_name() |
2404 | 280 | .err_tip(|| format!0 ("Expected file_name to exist on {full_path:?}"))?0 |
2405 | 280 | .to_str() |
2406 | 280 | .err_tip(|| {0 |
2407 | 0 | make_err!( |
2408 | 0 | Code::Internal, |
2409 | | "Could not convert {:?} to string", |
2410 | | full_path |
2411 | | ) |
2412 | 0 | })? |
2413 | 280 | .to_string(); |
2414 | | |
2415 | 280 | Ok(FileInfo { |
2416 | 280 | name_or_path: NameOrPath::Name(name), |
2417 | 280 | digest, |
2418 | 280 | is_executable, |
2419 | 280 | }) |
2420 | 280 | } |
2421 | | |
2422 | 2 | async fn upload_symlink( |
2423 | 2 | full_path: impl AsRef<Path> + Debug, |
2424 | 2 | full_work_directory_path: impl AsRef<Path>, |
2425 | 2 | ) -> Result<SymlinkInfo, Error> { |
2426 | 2 | let full_target_path = fs::read_link(full_path.as_ref()) |
2427 | 2 | .await |
2428 | 2 | .err_tip(|| format!0 ("Could not get read_link path of {full_path:?}"))?0 ; |
2429 | | |
2430 | | // Detect if our symlink is inside our work directory, if it is find the |
2431 | | // relative path otherwise use the absolute path. |
2432 | 2 | let target = if full_target_path.starts_with(full_work_directory_path.as_ref()) { |
2433 | 0 | let full_target_path = RelativePath::from_path(&full_target_path) |
2434 | 0 | .map_err(|v| make_err!(Code::Internal, "Could not convert {} to RelativePath", v))?; |
2435 | 0 | RelativePath::from_path(full_work_directory_path.as_ref()) |
2436 | 0 | .map_err(|v| make_err!(Code::Internal, "Could not convert {} to RelativePath", v))? |
2437 | 0 | .relative(full_target_path) |
2438 | 0 | .normalize() |
2439 | 0 | .into_string() |
2440 | | } else { |
2441 | 2 | full_target_path |
2442 | 2 | .to_str() |
2443 | 2 | .err_tip(|| {0 |
2444 | 0 | make_err!( |
2445 | 0 | Code::Internal, |
2446 | | "Could not convert '{:?}' to string", |
2447 | | full_target_path |
2448 | | ) |
2449 | 0 | })? |
2450 | 2 | .to_string() |
2451 | | }; |
2452 | | |
2453 | 2 | let name = full_path |
2454 | 2 | .as_ref() |
2455 | 2 | .file_name() |
2456 | 2 | .err_tip(|| format!0 ("Expected file_name to exist on {full_path:?}"))?0 |
2457 | 2 | .to_str() |
2458 | 2 | .err_tip(|| {0 |
2459 | 0 | make_err!( |
2460 | 0 | Code::Internal, |
2461 | | "Could not convert {:?} to string", |
2462 | | full_path |
2463 | | ) |
2464 | 0 | })? |
2465 | 2 | .to_string(); |
2466 | | |
2467 | 2 | Ok(SymlinkInfo { |
2468 | 2 | name_or_path: NameOrPath::Name(name), |
2469 | 2 | target, |
2470 | 2 | }) |
2471 | 2 | } |
2472 | | |
2473 | 16 | fn upload_directory<'a, P: AsRef<Path> + Debug + Send + Sync + Clone + 'a>( |
2474 | 16 | cas_store: Pin<&'a impl StoreLike>, |
2475 | 16 | full_dir_path: P, |
2476 | 16 | full_work_directory: &'a str, |
2477 | 16 | hasher: DigestHasherFunc, |
2478 | 16 | digest_uploaders: Arc<Mutex<HashMap<DigestInfo, DigestUploader>>>, |
2479 | 16 | uploads: Arc<Semaphore>, |
2480 | 16 | ) -> BoxFuture<'a, Result<(Directory, VecDeque<ProtoDirectory>), Error>> { |
2481 | 16 | Box::pin(async move { |
2482 | 16 | let mut file_futures = Vec::new(); |
2483 | 16 | let mut dir_futures = Vec::new(); |
2484 | 16 | let mut symlink_futures = Vec::new(); |
2485 | | { |
2486 | 16 | let (_permit, dir_handle) = fs::read_dir(&full_dir_path) |
2487 | 16 | .await |
2488 | 16 | .err_tip(|| format!0 ("Error reading dir for reading {full_dir_path:?}"))?0 |
2489 | 16 | .into_inner(); |
2490 | 16 | let mut dir_stream = ReadDirStream::new(dir_handle); |
2491 | | // Note: Try very hard to not leave file descriptors open. Try to keep them as short |
2492 | | // lived as possible. This is why we iterate the directory and then build a bunch of |
2493 | | // futures with all the work we are wanting to do then execute it. It allows us to |
2494 | | // close the directory iterator file descriptor, then open the child files/folders. |
2495 | 291 | while let Some(entry_result275 ) = dir_stream.next().await { |
2496 | 275 | let entry = entry_result.err_tip(|| "Error while iterating directory")?0 ; |
2497 | 275 | let file_type = entry |
2498 | 275 | .file_type() |
2499 | 275 | .await |
2500 | 275 | .err_tip(|| format!0 ("Error running file_type() on {entry:?}"))?0 ; |
2501 | 275 | let full_path = full_dir_path.as_ref().join(entry.path()); |
2502 | 275 | if file_type.is_dir() { |
2503 | 11 | let full_dir_path = full_dir_path.clone(); |
2504 | 11 | dir_futures.push( |
2505 | 11 | upload_directory( |
2506 | 11 | cas_store, |
2507 | 11 | full_path.clone(), |
2508 | 11 | full_work_directory, |
2509 | 11 | hasher, |
2510 | 11 | digest_uploaders.clone(), |
2511 | 11 | uploads.clone(), |
2512 | | ) |
2513 | 11 | .and_then(|(dir, all_dirs)| async move { |
2514 | 11 | let directory_name = full_path |
2515 | 11 | .file_name() |
2516 | 11 | .err_tip(|| {0 |
2517 | 0 | format!("Expected file_name to exist on {full_dir_path:?}") |
2518 | 0 | })? |
2519 | 11 | .to_str() |
2520 | 11 | .err_tip(|| {0 |
2521 | 0 | make_err!( |
2522 | 0 | Code::Internal, |
2523 | | "Could not convert {:?} to string", |
2524 | | full_dir_path |
2525 | | ) |
2526 | 0 | })? |
2527 | 11 | .to_string(); |
2528 | | |
2529 | 11 | let digest = |
2530 | 11 | serialize_and_upload_message(&dir, cas_store, &mut hasher.hasher()) |
2531 | 11 | .await |
2532 | 11 | .err_tip(|| format!0 ("for {}", full_path.display()0 ))?0 ; |
2533 | | |
2534 | 11 | Result::<(DirectoryNode, VecDeque<Directory>), Error>::Ok(( |
2535 | 11 | DirectoryNode { |
2536 | 11 | name: directory_name, |
2537 | 11 | digest: Some(digest.into()), |
2538 | 11 | }, |
2539 | 11 | all_dirs, |
2540 | 11 | )) |
2541 | 22 | }) |
2542 | 11 | .boxed(), |
2543 | | ); |
2544 | 264 | } else if file_type.is_file() { |
2545 | 263 | let digest_uploaders = digest_uploaders.clone(); |
2546 | 263 | let uploads = uploads.clone(); |
2547 | 263 | file_futures.push(async move { |
2548 | 263 | let metadata = fs::metadata(&full_path) |
2549 | 263 | .await |
2550 | 263 | .err_tip(|| format!0 ("Could not open file {}", full_path.display()0 ))?0 ; |
2551 | 263 | upload_file( |
2552 | 263 | cas_store, |
2553 | 263 | &full_path, |
2554 | 263 | hasher, |
2555 | 263 | metadata, |
2556 | 263 | digest_uploaders, |
2557 | 263 | uploads, |
2558 | 263 | ) |
2559 | 263 | .map_ok(TryInto::try_into) |
2560 | 263 | .await?0 |
2561 | 263 | }); |
2562 | 1 | } else if file_type.is_symlink() { |
2563 | 1 | symlink_futures.push( |
2564 | 1 | upload_symlink(full_path, &full_work_directory) |
2565 | 1 | .map(|symlink| symlink?0 .try_into()), |
2566 | | ); |
2567 | 0 | } |
2568 | | } |
2569 | | } |
2570 | | |
2571 | | // The stream bound keeps the fan-out in check at each level; the |
2572 | | // files open at once are bounded by `uploads`, which every level |
2573 | | // shares, since per-level bounds would multiply. |
2574 | 16 | let dir_entries = futures::stream::iter(dir_futures) |
2575 | 16 | .buffer_unordered(UPLOAD_CONCURRENCY) |
2576 | 16 | .try_collect::<Vec<(DirectoryNode, VecDeque<Directory>)>>() |
2577 | 16 | .await?0 ; |
2578 | 16 | let (mut file_nodes, mut symlinks) = try_join( |
2579 | 16 | futures::stream::iter(file_futures) |
2580 | 16 | .buffer_unordered(UPLOAD_CONCURRENCY) |
2581 | 16 | .try_collect::<Vec<FileNode>>(), |
2582 | 16 | futures::stream::iter(symlink_futures) |
2583 | 16 | .buffer_unordered(UPLOAD_CONCURRENCY) |
2584 | 16 | .try_collect::<Vec<SymlinkNode>>(), |
2585 | 16 | ) |
2586 | 16 | .await?0 ; |
2587 | | |
2588 | 16 | let mut directory_nodes = Vec::with_capacity(dir_entries.len()); |
2589 | | // For efficiency we use a deque because it allows cheap concat of Vecs. |
2590 | | // We make the assumption here that when performance is important it is because |
2591 | | // our directory is quite large. This allows us to cheaply merge large amounts of |
2592 | | // directories into one VecDeque. Then after we are done we need to collapse it |
2593 | | // down into a single Vec. |
2594 | 16 | let mut all_child_directories = VecDeque::with_capacity(dir_entries.len()); |
2595 | 16 | for (directory_node11 , mut recursive_child_directories11 ) in dir_entries { |
2596 | 11 | directory_nodes.push(directory_node); |
2597 | 11 | all_child_directories.append(&mut recursive_child_directories); |
2598 | 11 | } |
2599 | | |
2600 | 1.26k | file_nodes16 .sort_unstable_by16 (|a, b| a.name.cmp(&b.name)); |
2601 | 20 | directory_nodes16 .sort_unstable_by16 (|a, b| a.name.cmp(&b.name)); |
2602 | 16 | symlinks.sort_unstable_by(|a, b| a.name0 .cmp0 (&b.name0 )); |
2603 | | |
2604 | 16 | let directory = Directory { |
2605 | 16 | files: file_nodes, |
2606 | 16 | directories: directory_nodes, |
2607 | 16 | symlinks, |
2608 | 16 | node_properties: None, // We don't support file properties. |
2609 | 16 | }; |
2610 | 16 | all_child_directories.push_back(directory.clone()); |
2611 | | |
2612 | 16 | Ok((directory, all_child_directories)) |
2613 | 16 | }) |
2614 | 16 | } |
2615 | | |
2616 | 0 | async fn process_side_channel_file( |
2617 | 0 | side_channel_file: Cow<'_, OsStr>, |
2618 | 0 | args: &[&OsStr], |
2619 | 0 | timeout: Duration, |
2620 | 0 | ) -> Result<Option<Error>, Error> { |
2621 | 0 | let mut json_contents = String::new(); |
2622 | | { |
2623 | | // Note: Scoping `file_slot` allows the file_slot semaphore to be released faster. |
2624 | 0 | let mut file_slot = match fs::open_file(side_channel_file, 0, u64::MAX).await { |
2625 | 0 | Ok(file_slot) => file_slot, |
2626 | 0 | Err(e) => { |
2627 | 0 | if e.code != Code::NotFound { |
2628 | 0 | return Err(e).err_tip(|| "Error opening side channel file"); |
2629 | 0 | } |
2630 | | // Note: If file does not exist, it's ok. Users are not required to create this file. |
2631 | 0 | return Ok(None); |
2632 | | } |
2633 | | }; |
2634 | 0 | file_slot |
2635 | 0 | .read_to_string(&mut json_contents) |
2636 | 0 | .await |
2637 | 0 | .err_tip(|| "Error reading side channel file")?; |
2638 | | } |
2639 | | |
2640 | 0 | let side_channel_info: SideChannelInfo = serde_json5::from_str(&json_contents) |
2641 | 0 | .map_err(Error::from) |
2642 | 0 | .err_tip(|| "Could not convert contents of side channel file (json) to SideChannelInfo")?; |
2643 | 0 | Ok(side_channel_info.failure.map(|failure| match failure { |
2644 | | SideChannelFailureReason::Timeout => { |
2645 | 0 | let join_args = args.join(OsStr::new(" ")); |
2646 | 0 | let command = join_args.to_string_lossy(); |
2647 | 0 | warn!(%command, timeout=timeout.as_secs_f32(), "Side channel timeout for command"); |
2648 | 0 | Error::new( |
2649 | 0 | Code::DeadlineExceeded, |
2650 | 0 | format!( |
2651 | | "Command '{}' timed out after {} seconds", |
2652 | | command, |
2653 | 0 | timeout.as_secs_f32() |
2654 | | ), |
2655 | | ) |
2656 | | } |
2657 | 0 | })) |
2658 | 0 | } |
2659 | | |
2660 | 275 | async fn do_cleanup( |
2661 | 275 | running_actions_manager: &Arc<RunningActionsManagerImpl>, |
2662 | 275 | operation_id: &OperationId, |
2663 | 275 | action_directory: &str, |
2664 | 275 | ) -> Result<(), Error> { |
2665 | | // Mark this operation as being cleaned up. The other holders of the |
2666 | | // mark are the orphan sweep and a retry's stale-directory removal, both |
2667 | | // brief; waiting on them is what keeps this cleanup from being skipped, |
2668 | | // which would leave the action's entries and reservations in place for |
2669 | | // good. |
2670 | 275 | let _cleaning_guard = running_actions_manager |
2671 | 275 | .take_cleanup_mark(operation_id.clone()) |
2672 | 275 | .await; |
2673 | | |
2674 | 275 | let capture = running_actions_manager |
2675 | 275 | .buck2_captures |
2676 | 275 | .lock() |
2677 | 275 | .remove(operation_id); |
2678 | 275 | if let Some(capture4 ) = capture |
2679 | 4 | && let Err(err0 ) = capture.finish().await |
2680 | | { |
2681 | | // Capture completeness is independent of the execution result. |
2682 | 0 | warn!(%operation_id, ?err, "Buck2 archive finalization was partial"); |
2683 | 275 | } |
2684 | | |
2685 | | // Debug-only hack to not cleanup workers when we need to debug something |
2686 | 275 | if !env::var("NATIVELINK_DONT_CLEANUP") |
2687 | 275 | .unwrap_or_default() |
2688 | 275 | .is_empty() |
2689 | | { |
2690 | 0 | info!("Skip because env var"); |
2691 | 0 | return Ok(()); |
2692 | 275 | } |
2693 | | |
2694 | 275 | debug!(%action_directory, "Worker cleaning up"); |
2695 | | // Note: We need to be careful to keep trying to cleanup even if one of the steps fails. |
2696 | | // A fetch or an upload ended by a kill or a timeout can leave a blocking |
2697 | | // copy or a stamp finishing inside the tree for a moment; the removal is |
2698 | | // retried through that instead of failing on the first try. |
2699 | 275 | let remove_deadline = Instant::now() + running_actions_manager.max_cleanup_wait; |
2700 | 275 | let remove_dir_result = loop { |
2701 | 275 | match fs::remove_dir_all(action_directory).await { |
2702 | 275 | Ok(()) => break Ok(()), |
2703 | 0 | Err(err) if Instant::now() < remove_deadline => { |
2704 | 0 | debug!(%operation_id, ?err, "Removing the working directory again"); |
2705 | 0 | tokio::time::sleep(Duration::from_millis(100)).await; |
2706 | | } |
2707 | 0 | Err(err) => { |
2708 | 0 | break Err(err) |
2709 | 0 | .err_tip(|| format!("Could not remove working directory {action_directory}")); |
2710 | | } |
2711 | | } |
2712 | | }; |
2713 | | |
2714 | 275 | if let Err(err0 ) = running_actions_manager.cleanup_action(operation_id) { |
2715 | 0 | error!(%operation_id, ?err, "Error cleaning up action"); |
2716 | 0 | Result::<(), Error>::Err(err).merge(remove_dir_result) |
2717 | 275 | } else if let Err(err0 ) = remove_dir_result { |
2718 | 0 | error!(%operation_id, ?err, "Error removing working directory"); |
2719 | 0 | Err(err) |
2720 | | } else { |
2721 | 275 | Ok(()) |
2722 | | } |
2723 | 275 | } |
2724 | | |
2725 | | pub trait RunningAction: Sync + Send + Sized + Unpin + 'static { |
2726 | | /// Returns the action id of the action. |
2727 | | fn get_operation_id(&self) -> &OperationId; |
2728 | | |
2729 | | /// Anything that needs to execute before the actions is actually executed should happen here. |
2730 | | fn prepare_action(self: Arc<Self>) -> impl Future<Output = Result<Arc<Self>, Error>> + Send; |
2731 | | |
2732 | | /// Actually perform the execution of the action. |
2733 | | fn execute(self: Arc<Self>) -> impl Future<Output = Result<Arc<Self>, Error>> + Send; |
2734 | | |
2735 | | /// Any uploading, processing or analyzing of the results should happen here. |
2736 | | fn upload_results(self: Arc<Self>) -> impl Future<Output = Result<Arc<Self>, Error>> + Send; |
2737 | | |
2738 | | /// Cleanup any residual files, handles or other junk resulting from running the action. |
2739 | | fn cleanup(self: Arc<Self>) -> impl Future<Output = Result<Arc<Self>, Error>> + Send; |
2740 | | |
2741 | | /// Returns the final result. As a general rule this action should be thought of as |
2742 | | /// a consumption of `self`, meaning once a return happens here the lifetime of `Self` |
2743 | | /// is over and any action performed on it after this call is undefined behavior. |
2744 | | fn get_finished_result( |
2745 | | self: Arc<Self>, |
2746 | | ) -> impl Future<Output = Result<ActionResult, Error>> + Send; |
2747 | | |
2748 | | /// Returns worker-observed resource usage captured while this action ran. |
2749 | | fn resource_usage(&self) -> Option<ActionResourceUsage>; |
2750 | | |
2751 | | /// Returns the work directory of the action. |
2752 | | fn get_work_directory(&self) -> &String; |
2753 | | } |
2754 | | |
2755 | | /// What an action wrote to stdout or stderr: in memory when small, on disk |
2756 | | /// past `max_captured_output_bytes`, with the head kept for the log. |
2757 | | #[derive(Debug)] |
2758 | | pub enum CapturedOutput { |
2759 | | Memory(Bytes), |
2760 | | Spilled { |
2761 | | path: PathBuf, |
2762 | | len: u64, |
2763 | | head: Bytes, |
2764 | | }, |
2765 | | } |
2766 | | |
2767 | | impl CapturedOutput { |
2768 | 90 | const fn len(&self) -> u64 { |
2769 | 90 | match self { |
2770 | 89 | Self::Memory(bytes) => bytes.len() as u64, |
2771 | 1 | Self::Spilled { len, .. } => *len, |
2772 | | } |
2773 | 90 | } |
2774 | | |
2775 | | /// The first bytes, for a log line; all of them when in memory. |
2776 | 0 | fn head(&self) -> &[u8] { |
2777 | 0 | match self { |
2778 | 0 | Self::Memory(bytes) => bytes, |
2779 | 0 | Self::Spilled { head, .. } => head, |
2780 | | } |
2781 | 0 | } |
2782 | | } |
2783 | | |
2784 | | /// The first `limit` bytes of captured output as text for a log line. The |
2785 | | /// cut is by bytes, so one through a multibyte character becomes U+FFFD |
2786 | | /// rather than a panic. |
2787 | 33 | pub fn log_excerpt(bytes: &[u8], limit: usize) -> Cow<'_, str> { |
2788 | 33 | String::from_utf8_lossy(&bytes[..min(bytes.len(), limit)]) |
2789 | 33 | } |
2790 | | |
2791 | | impl From<Bytes> for CapturedOutput { |
2792 | 4 | fn from(bytes: Bytes) -> Self { |
2793 | 4 | Self::Memory(bytes) |
2794 | 4 | } |
2795 | | } |
2796 | | |
2797 | | /// Reads a child's stream to the end, or until `stop` says to keep what |
2798 | | /// has been read: an action can leave a process behind that holds the |
2799 | | /// pipe open after the action itself has exited, and its output must not |
2800 | | /// wait on that. Up to `cap` bytes stay in memory; the moment the buffer |
2801 | | /// passes the cap, everything so far and everything after goes to |
2802 | | /// `spill_path`, and the buffer is cut back to the cap as the head. |
2803 | 92 | pub async fn capture_output<R: tokio::io::AsyncRead + Unpin>( |
2804 | 92 | mut reader: R, |
2805 | 92 | cap: u64, |
2806 | 92 | spill_path: PathBuf, |
2807 | 92 | what: &str, |
2808 | 92 | mut stop: watch::Receiver<bool>, |
2809 | 92 | ) -> Result<CapturedOutput, Error> { |
2810 | | // `None` when told to stop; a closed sender counts as that too. |
2811 | 295 | async fn read_or_stop<R: tokio::io::AsyncRead + Unpin>( |
2812 | 295 | reader: &mut R, |
2813 | 295 | buf: &mut BytesMut, |
2814 | 295 | stop: &mut watch::Receiver<bool>, |
2815 | 295 | what: &str, |
2816 | 295 | ) -> Result<Option<usize>, Error> { |
2817 | 295 | if *stop.borrow() { |
2818 | 0 | return Ok(None); |
2819 | 295 | } |
2820 | 295 | tokio::select! { |
2821 | | // Data already in the pipe is read before a stop is honoured. |
2822 | | biased; |
2823 | 295 | read = reader.read_buf(buf) => read |
2824 | 295 | .map(Some) |
2825 | 295 | .err_tip(|| format!0 ("Error reading {what} stream")), |
2826 | 295 | _ = stop.changed() => Ok(None)0 , |
2827 | | } |
2828 | 295 | } |
2829 | 92 | let mut buf = BytesMut::new(); |
2830 | | // A raw handle, outside the open-file budget on purpose: the spill |
2831 | | // lives as long as the child's stream, an fd the budget never covered, |
2832 | | // and a permit held for a whole noisy action would starve the store |
2833 | | // paths the budget exists for. |
2834 | 92 | let mut spill: Option<(tokio::fs::File, u64)> = None; |
2835 | | loop { |
2836 | 295 | if let Some((file72 , len72 )) = spill.as_mut() { |
2837 | 72 | let mut chunk = BytesMut::with_capacity(64 * 1024); |
2838 | 72 | let Some(sz) = read_or_stop(&mut reader, &mut chunk, &mut stop, what).await?0 else { |
2839 | 0 | break; |
2840 | | }; |
2841 | 72 | if sz == 0 { |
2842 | 1 | break; |
2843 | 71 | } |
2844 | 71 | file.write_all(&chunk) |
2845 | 71 | .await |
2846 | 71 | .err_tip(|| format!0 ("Error spilling {what} to {}", spill_path.display()0 ))?0 ; |
2847 | 71 | *len += sz as u64; |
2848 | 71 | continue; |
2849 | 223 | } |
2850 | 223 | let Some(sz) = read_or_stop(&mut reader, &mut buf, &mut stop, what).await?0 else { |
2851 | 0 | break; |
2852 | | }; |
2853 | 223 | if sz == 0 { |
2854 | 91 | break; |
2855 | 132 | } |
2856 | 132 | if cap > 0 && buf.len() as u64 > cap116 { |
2857 | 1 | let mut file = tokio::fs::File::create(&spill_path) |
2858 | 1 | .await |
2859 | 1 | .err_tip(|| format!0 ("Error creating {what} spill file {}", spill_path.display()0 ))?0 ; |
2860 | 1 | file.write_all(&buf) |
2861 | 1 | .await |
2862 | 1 | .err_tip(|| format!0 ("Error spilling {what} to {}", spill_path.display()0 ))?0 ; |
2863 | 1 | let len = buf.len() as u64; |
2864 | 1 | buf.truncate(usize::try_from(cap).unwrap_or(usize::MAX)); |
2865 | 1 | spill = Some((file, len)); |
2866 | 131 | } |
2867 | | } |
2868 | 92 | match spill { |
2869 | 91 | None => Ok(CapturedOutput::Memory(buf.freeze())), |
2870 | 1 | Some((mut file, len)) => { |
2871 | 1 | file.flush() |
2872 | 1 | .await |
2873 | 1 | .err_tip(|| format!0 ("Error flushing {what} spill file"))?0 ; |
2874 | 1 | drop(file); |
2875 | 1 | Ok(CapturedOutput::Spilled { |
2876 | 1 | path: spill_path, |
2877 | 1 | len, |
2878 | 1 | head: buf.freeze(), |
2879 | 1 | }) |
2880 | | } |
2881 | | } |
2882 | 92 | } |
2883 | | |
2884 | | /// Uploads captured output to the CAS, from memory or from its spill file. |
2885 | 90 | async fn upload_captured_output( |
2886 | 90 | cas_store: Pin<&FastSlowStore>, |
2887 | 90 | output: CapturedOutput, |
2888 | 90 | hasher: DigestHasherFunc, |
2889 | 90 | what: &str, |
2890 | 90 | ) -> Result<DigestInfo, Error> { |
2891 | 90 | match output { |
2892 | 89 | CapturedOutput::Memory(data) => { |
2893 | 89 | let digest = compute_buf_digest(&data, &mut hasher.hasher()); |
2894 | 89 | cas_store |
2895 | 89 | .update_oneshot(digest, data) |
2896 | 89 | .await |
2897 | 87 | .err_tip(|| format!0 ("Uploading {what}"))?0 ; |
2898 | 87 | Ok(digest) |
2899 | | } |
2900 | 1 | CapturedOutput::Spilled { path, len, .. } => { |
2901 | 1 | let file = fs::open_file(&path, 0, u64::MAX) |
2902 | 1 | .await |
2903 | 1 | .err_tip(|| format!0 ("Could not open {what} spill file {}", path.display()0 ))?0 ; |
2904 | 1 | let (digest, file) = hasher |
2905 | 1 | .hasher() |
2906 | 1 | .digest_for_file(&path, file.into_inner(), Some(len)) |
2907 | 1 | .await |
2908 | 1 | .err_tip(|| format!0 ("Failed to hash {what} spill file {}", path.display()0 ))?0 ; |
2909 | 1 | cas_store |
2910 | 1 | .update_with_whole_file( |
2911 | 1 | digest, |
2912 | 1 | path.clone().into_os_string(), |
2913 | 1 | file, |
2914 | 1 | UploadSizeInfo::ExactSize(len), |
2915 | 1 | ) |
2916 | 1 | .await |
2917 | 1 | .err_tip(|| format!0 ("Uploading {what} from {}", path.display()0 ))?0 ; |
2918 | 1 | Ok(digest) |
2919 | | } |
2920 | | } |
2921 | 88 | } |
2922 | | |
2923 | | #[derive(Debug)] |
2924 | | struct RunningActionImplExecutionResult { |
2925 | | stdout: CapturedOutput, |
2926 | | stderr: CapturedOutput, |
2927 | | exit_code: i32, |
2928 | | resource_usage: Option<ActionResourceUsage>, |
2929 | | } |
2930 | | |
2931 | | #[derive(Debug)] |
2932 | | struct RunningActionImplState { |
2933 | | command_proto: Option<ProtoCommand>, |
2934 | | // TODO(palfrey) Kill is not implemented yet, but is instrumented. |
2935 | | // However, it is used if the worker disconnects to destroy current jobs. |
2936 | | kill_channel_tx: Option<oneshot::Sender<()>>, |
2937 | | kill_channel_rx: Option<oneshot::Receiver<()>>, |
2938 | | execution_result: Option<RunningActionImplExecutionResult>, |
2939 | | action_result: Option<ActionResult>, |
2940 | | resource_usage: Option<ActionResourceUsage>, |
2941 | | /// Input bytes of the fetch attempt that succeeded, and the cached part. |
2942 | | input_bytes: (u64, u64), |
2943 | | execution_metadata: ExecutionMetadata, |
2944 | | // If there was an internal error, this will be set. |
2945 | | // This should NOT be set if everything was fine, but the process had a |
2946 | | // non-zero exit code. Instead this should be used for internal errors |
2947 | | // that prevented the action from running, upload failures, timeouts, exc... |
2948 | | // but we have (or could have) the action results (like stderr/stdout). |
2949 | | error: Option<Error>, |
2950 | | input_mounts: Option<Arc<InputMounts>>, |
2951 | | } |
2952 | | |
2953 | | #[derive(Debug)] |
2954 | | pub struct RunningActionImpl { |
2955 | | operation_id: OperationId, |
2956 | | action_directory: String, |
2957 | | work_directory: String, |
2958 | | action_info: ActionInfo, |
2959 | | /// `Some` only when the manager was configured with |
2960 | | /// `experimental_active_input_leases`; `None` leaves eviction untouched. |
2961 | | input_lease: Option<Arc<ActionInputLease>>, |
2962 | | timeout: Duration, |
2963 | | running_actions_manager: Arc<RunningActionsManagerImpl>, |
2964 | | /// Set on a kill; every phase that can wait watches it, so a kill ends |
2965 | | /// a fetch or an upload as surely as it ends the command. |
2966 | | kill_token: watch::Sender<bool>, |
2967 | | state: Mutex<RunningActionImplState>, |
2968 | | has_manager_entry: AtomicBool, |
2969 | | did_cleanup: AtomicBool, |
2970 | | /// The sampler's latest memory figure and the pressure monitor's kill |
2971 | | /// request; see [`ActionMemoryWatch`]. |
2972 | | memory_watch: Arc<ActionMemoryWatch>, |
2973 | | } |
2974 | | |
2975 | | impl RunningActionImpl { |
2976 | | /// The memory the sampler last saw this action hold, 0 before a sample. |
2977 | 3 | pub fn last_memory_kb(&self) -> u64 { |
2978 | 3 | self.memory_watch.last_memory_kb() |
2979 | 3 | } |
2980 | | |
2981 | | /// Asks the action's sampler to end it for worker memory pressure, |
2982 | | /// naming the `full avg10` percent that chose it (never 0). Takes |
2983 | | /// effect on the sampler's next pass; a second request changes nothing. |
2984 | 2 | pub fn request_memory_pressure_kill(&self, full_avg10_percent: u64) { |
2985 | 2 | self.memory_watch |
2986 | 2 | .kill_requested_percent |
2987 | 2 | .store(full_avg10_percent.max(1), Ordering::Relaxed); |
2988 | 2 | } |
2989 | | |
2990 | 278 | pub fn new( |
2991 | 278 | execution_metadata: ExecutionMetadata, |
2992 | 278 | operation_id: OperationId, |
2993 | 278 | action_directory: String, |
2994 | 278 | action_info: ActionInfo, |
2995 | 278 | timeout: Duration, |
2996 | 278 | running_actions_manager: Arc<RunningActionsManagerImpl>, |
2997 | 278 | ) -> Self { |
2998 | 278 | let work_directory = format!("{}/{}", action_directory, "work"); |
2999 | 278 | let input_lease = running_actions_manager.active_input_leases.then(|| {9 |
3000 | 9 | ActionInputLease::new( |
3001 | 9 | running_actions_manager.cas_store.as_ref(), |
3002 | 9 | action_info.command_digest, |
3003 | 9 | action_info.input_root_digest, |
3004 | | ) |
3005 | 9 | }); |
3006 | 278 | let (kill_channel_tx, kill_channel_rx) = oneshot::channel(); |
3007 | 278 | let (kill_token, _) = watch::channel(false); |
3008 | 278 | Self { |
3009 | 278 | kill_token, |
3010 | 278 | operation_id, |
3011 | 278 | action_directory, |
3012 | 278 | work_directory, |
3013 | 278 | action_info, |
3014 | 278 | input_lease, |
3015 | 278 | timeout, |
3016 | 278 | running_actions_manager, |
3017 | 278 | state: Mutex::new(RunningActionImplState { |
3018 | 278 | command_proto: None, |
3019 | 278 | kill_channel_rx: Some(kill_channel_rx), |
3020 | 278 | kill_channel_tx: Some(kill_channel_tx), |
3021 | 278 | execution_result: None, |
3022 | 278 | action_result: None, |
3023 | 278 | resource_usage: None, |
3024 | 278 | input_bytes: (0, 0), |
3025 | 278 | execution_metadata, |
3026 | 278 | error: None, |
3027 | 278 | input_mounts: None, |
3028 | 278 | }), |
3029 | 278 | // Set to true only after the action is inserted into the manager. |
3030 | 278 | // The constructor can fail the operation-id uniqueness check |
3031 | 278 | // immediately after this object is created. |
3032 | 278 | has_manager_entry: AtomicBool::new(false), |
3033 | 278 | // Only needs to be cleaned up after a prepare_action call, set there. |
3034 | 278 | did_cleanup: AtomicBool::new(true), |
3035 | 278 | memory_watch: Arc::new(ActionMemoryWatch::default()), |
3036 | 278 | } |
3037 | 278 | } |
3038 | | |
3039 | | #[allow( |
3040 | | clippy::missing_const_for_fn, |
3041 | | reason = "False positive on stable, but not on nightly" |
3042 | | )] |
3043 | 0 | fn metrics(&self) -> &Arc<Metrics> { |
3044 | 0 | &self.running_actions_manager.metrics |
3045 | 0 | } |
3046 | | |
3047 | | /// Prepares any actions needed to execute this action. This action will do the following: |
3048 | | /// |
3049 | | /// * Download any files needed to execute the action |
3050 | | /// * Build a folder with all files needed to execute the action. |
3051 | | /// |
3052 | | /// This function will aggressively download and spawn potentially thousands of futures. It is |
3053 | | /// up to the stores to rate limit if needed. |
3054 | | /// Fetches the Command and the input tree into the work directory, |
3055 | | /// again after a transient store failure while `deadline` (none: |
3056 | | /// unbounded) leaves room for another attempt. A worker that already |
3057 | | /// holds part of the tree in its fast store finishes the job here rather |
3058 | | /// than handing the action back for another worker to fetch everything |
3059 | | /// against the same struggling store. The two fetches are retried |
3060 | | /// together: a Command read that fails during the same storm would |
3061 | | /// otherwise fail the action while the inputs were still being retried. |
3062 | | /// A `prefetched_command`, read earlier to choose the read-only input |
3063 | | /// mounts, is used instead of reading the Command again. |
3064 | 70 | async fn fetch_with_retry( |
3065 | 70 | &self, |
3066 | 70 | filesystem_store: Pin<&FilesystemStore>, |
3067 | 70 | deadline: Option<Instant>, |
3068 | 70 | prefetched_command: Option<ProtoCommand>, |
3069 | 70 | ) -> Result<ProtoCommand, Error> { |
3070 | 70 | let started = Instant::now(); |
3071 | 70 | let mut attempt: u32 = 1; |
3072 | 70 | let mut retries = PrepareRetries::new(record_prepare_outcome); |
3073 | | // Bytes earlier attempts fetched from the CAS. The next attempt finds |
3074 | | // them in the local store, so they are taken back off its cached part. |
3075 | 70 | let mut fetched_earlier: u64 = 0; |
3076 | | loop { |
3077 | 86 | let command_fut = async { |
3078 | 86 | match &prefetched_command { |
3079 | 8 | Some(command) => Ok(command.clone()), |
3080 | 78 | None => self.fetch_command().await, |
3081 | | } |
3082 | 86 | }; |
3083 | 86 | let stats = Arc::new(InputFetchStats::default()); |
3084 | 86 | let inputs_fut = INPUT_FETCH_STATS.scope( |
3085 | 86 | stats.clone(), |
3086 | 86 | self.metrics() |
3087 | 86 | .download_to_directory |
3088 | 86 | .wrap(self.download_inputs(filesystem_store)), |
3089 | | ); |
3090 | 86 | let result84 = try_join(command_fut, inputs_fut).await; |
3091 | 84 | let (total, cached) = stats.bytes(); |
3092 | 21 | let err20 = match result { |
3093 | 63 | Ok((command, ())) => { |
3094 | 63 | self.state.lock().input_bytes = (total, cached.saturating_sub(fetched_earlier)); |
3095 | 63 | if attempt > 1 { |
3096 | 5 | info!( |
3097 | | operation_id = %self.operation_id, |
3098 | | attempt, |
3099 | 5 | elapsed_s = started.elapsed().as_secs(), |
3100 | | "prepare_action: input fetch recovered" |
3101 | | ); |
3102 | 58 | } |
3103 | 63 | retries.settle("recovered"); |
3104 | 63 | return Ok(command); |
3105 | | } |
3106 | 21 | Err(err20 ) if is_transient_fetch_error(&err)20 => err20 , |
3107 | 1 | Err(err) => { |
3108 | 1 | retries.settle("failed"); |
3109 | 1 | return Err(err); |
3110 | | } |
3111 | | }; |
3112 | 20 | fetched_earlier = fetched_earlier.saturating_add(total - cached); |
3113 | 20 | self.running_actions_manager |
3114 | 20 | .suggest_input_download_pool(&self.operation_id, &err); |
3115 | 20 | let pause = prepare_retry_pause(attempt, &self.action_info.input_root_digest); |
3116 | 20 | if deadline.is_some_and(|deadline| Instant::now() + pause >= deadline) { |
3117 | 3 | retries.settle("exhausted"); |
3118 | 3 | return Err(err |
3119 | 3 | .append(format!( |
3120 | 3 | "Input fetch of operation {} failed {attempt} time(s) over {}s; the next retry would start after max_download_timeout", |
3121 | 3 | self.operation_id, |
3122 | 3 | started.elapsed().as_secs() |
3123 | 3 | )) |
3124 | 3 | .append(STORE_UNREACHABLE_ERROR_TIP)); |
3125 | 17 | } |
3126 | 17 | warn!( |
3127 | | operation_id = %self.operation_id, |
3128 | | attempt, |
3129 | 17 | wait_ms = pause.as_millis(), |
3130 | | error = %err, |
3131 | | "prepare_action: input fetch failed, retrying" |
3132 | | ); |
3133 | 17 | retries.retried(); |
3134 | | // Start the next attempt from an empty directory: the |
3135 | | // directories and links of the partial tree would otherwise |
3136 | | // fail it with AlreadyExists. |
3137 | 17 | if let Err(err0 ) = self.clear_work_directory().await { |
3138 | 0 | retries.settle("failed"); |
3139 | 0 | return Err(err); |
3140 | 17 | } |
3141 | 17 | tokio::time::sleep(pause).await; |
3142 | 16 | attempt += 1; |
3143 | | } |
3144 | 67 | } |
3145 | | |
3146 | | /// Empties the work directory between two fetch attempts. |
3147 | 17 | async fn clear_work_directory(&self) -> Result<(), Error>0 { |
3148 | 17 | if let Err(remove_err0 ) = fs::remove_dir_all(&self.work_directory).await |
3149 | 0 | && remove_err.code != Code::NotFound |
3150 | | { |
3151 | 0 | return Err(remove_err.append(format!( |
3152 | 0 | "Failed to clear work directory {} before fetching the inputs again", |
3153 | 0 | self.work_directory |
3154 | 0 | ))); |
3155 | 17 | } |
3156 | 17 | fs::create_dir(&self.work_directory) |
3157 | 17 | .await |
3158 | 17 | .err_tip(|| format!0 ("Error recreating work directory {}", self.work_directory)) |
3159 | 17 | } |
3160 | | |
3161 | | /// Fetches and decodes the action's Command. |
3162 | 86 | async fn fetch_command(&self) -> Result<ProtoCommand, Error> { |
3163 | 86 | self.metrics() |
3164 | 86 | .get_proto_command_from_store |
3165 | 86 | .wrap(async { |
3166 | 86 | get_and_decode_digest::<ProtoCommand>( |
3167 | 86 | self.running_actions_manager.cas_store.as_ref(), |
3168 | 86 | self.action_info.command_digest.into(), |
3169 | 86 | ) |
3170 | 86 | .await |
3171 | 86 | .err_tip(|| "Converting command_digest to Command") |
3172 | 86 | }) |
3173 | 86 | .await |
3174 | 86 | } |
3175 | | |
3176 | | /// Chooses the read-only input mounts for this action and keeps them in |
3177 | | /// its state, where their cache pins are held through cleanup. Whether |
3178 | | /// mounts apply depends on the Command's output paths, so with mounts |
3179 | | /// configured the Command is read here and returned for the fetch to |
3180 | | /// reuse; without them nothing is read, and the Command and inputs are |
3181 | | /// fetched concurrently. A failure here only means ordinary input setup: |
3182 | | /// the fetch that follows reads the Command and inputs with its retries. |
3183 | 70 | async fn select_input_mounts(&self) -> Option<ProtoCommand>0 { |
3184 | 70 | let manager = &self.running_actions_manager; |
3185 | | #[cfg(target_os = "linux")] |
3186 | 70 | let mount_paths = &manager.readonly_input_mounts; |
3187 | | #[cfg(not(target_os = "linux"))] |
3188 | | let mount_paths: &[String] = &[]; |
3189 | 70 | if mount_paths.is_empty() { |
3190 | 62 | return None; |
3191 | 8 | } |
3192 | 8 | let command = if action_supports_persistent_workers(&self.action_info).is_none() { |
3193 | 8 | match self.fetch_command().await { |
3194 | 8 | Ok(command) => Some(command), |
3195 | 0 | Err(error) => { |
3196 | 0 | warn!( |
3197 | | ?error, |
3198 | | "Reading the Command to choose input mounts failed; using ordinary input setup" |
3199 | | ); |
3200 | 0 | None |
3201 | | } |
3202 | | } |
3203 | | } else { |
3204 | 0 | None |
3205 | | }; |
3206 | 8 | let mut input_mounts = match (&command, &manager.directory_cache) { |
3207 | 8 | (Some(command), Some(cache)) => InputMounts::for_command( |
3208 | 8 | cache.clone(), |
3209 | 8 | mount_paths, |
3210 | 8 | Path::new(&self.work_directory), |
3211 | 8 | command, |
3212 | | ), |
3213 | 0 | _ => None, |
3214 | | }; |
3215 | 8 | if input_mounts.is_some() { |
3216 | 7 | match crate::input_mounts::has_mountable_inputs( |
3217 | 7 | &manager.cas_store, |
3218 | 7 | self.action_info.input_root_digest, |
3219 | 7 | mount_paths, |
3220 | 7 | self.input_lease |
3221 | 7 | .as_deref() |
3222 | 7 | .map(|lease| -> &dyn crate::directory_cache::DigestLease { lease }), |
3223 | | ) |
3224 | 7 | .await |
3225 | | { |
3226 | 7 | Ok(true) => {} |
3227 | 0 | Ok(false) => input_mounts = None, |
3228 | 0 | Err(error) => { |
3229 | 0 | warn!( |
3230 | | ?error, |
3231 | | "Checking for mountable inputs failed; using ordinary input setup" |
3232 | | ); |
3233 | 0 | input_mounts = None; |
3234 | | } |
3235 | | } |
3236 | 1 | } |
3237 | 8 | if input_mounts.is_none() { |
3238 | 1 | INPUT_MOUNT_METRICS.fallbacks.add(1, &[]); |
3239 | 7 | } |
3240 | 8 | self.state.lock().input_mounts.clone_from(&input_mounts); |
3241 | 8 | command |
3242 | 70 | } |
3243 | | |
3244 | | /// Materializes the input tree into the work directory: through the |
3245 | | /// read-only input mounts chosen for this action, which leave a mount |
3246 | | /// point for each selected subtree, or else through the directory cache |
3247 | | /// when one is configured. If preparing the mounts fails, they are |
3248 | | /// dropped and the tree is downloaded without them; later attempts of |
3249 | | /// the fetch then take the ordinary path. |
3250 | 86 | async fn download_inputs(&self, filesystem_store: Pin<&FilesystemStore>) -> Result<(), Error> { |
3251 | 86 | let manager = &self.running_actions_manager; |
3252 | 86 | let downloads = &manager.input_downloads; |
3253 | 86 | let input_mounts = self.state.lock().input_mounts.clone(); |
3254 | 86 | if input_mounts.is_none() { |
3255 | 79 | return prepare_action_inputs_with_lease( |
3256 | 79 | &manager.directory_cache, |
3257 | 79 | &manager.cas_store, |
3258 | 79 | filesystem_store, |
3259 | 79 | &self.action_info.input_root_digest, |
3260 | 79 | &self.work_directory, |
3261 | 79 | self.input_lease.clone(), |
3262 | 79 | downloads, |
3263 | 79 | ) |
3264 | 79 | .await; |
3265 | 7 | } |
3266 | 7 | let result = download_to_directory_with_mounts( |
3267 | 7 | &manager.cas_store, |
3268 | 7 | filesystem_store, |
3269 | 7 | &self.action_info.input_root_digest, |
3270 | 7 | &self.work_directory, |
3271 | 7 | self.input_lease.clone(), |
3272 | 7 | input_mounts, |
3273 | 7 | downloads, |
3274 | 7 | ) |
3275 | 7 | .await; |
3276 | 7 | if let Err(error1 ) = result { |
3277 | 1 | INPUT_MOUNT_METRICS.prepare_failures.add(1, &[]); |
3278 | 1 | INPUT_MOUNT_METRICS.fallbacks.add(1, &[]); |
3279 | 1 | warn!( |
3280 | | ?error, |
3281 | | "Input mount preparation failed; retrying without mounts" |
3282 | | ); |
3283 | | // No child has started. Discard partial inputs before |
3284 | | // releasing pins and retrying the ordinary download once. |
3285 | 1 | fs::remove_dir_all(&self.work_directory).await?0 ; |
3286 | 1 | self.state.lock().input_mounts.take(); |
3287 | 1 | fs::create_dir(&self.work_directory).await?0 ; |
3288 | 1 | download_to_directory_with_mounts( |
3289 | 1 | &manager.cas_store, |
3290 | 1 | filesystem_store, |
3291 | 1 | &self.action_info.input_root_digest, |
3292 | 1 | &self.work_directory, |
3293 | 1 | self.input_lease.clone(), |
3294 | 1 | None, |
3295 | 1 | downloads, |
3296 | 1 | ) |
3297 | 1 | .await?0 ; |
3298 | 6 | } |
3299 | 7 | Ok(()) |
3300 | 84 | } |
3301 | | |
3302 | 0 | async fn inner_prepare_action( |
3303 | 0 | self: Arc<Self>, |
3304 | 0 | download_deadline: Option<Instant>, |
3305 | 72 | ) -> Result<Arc<Self>, Error> { |
3306 | | // Before anything lands on disk: an action that reserved more than |
3307 | | // is free is refused as backpressure, not run into ENOSPC halfway |
3308 | | // through its inputs. Free means free after what the actions |
3309 | | // already admitted reserved: four 4 GiB actions dispatched in one |
3310 | | // second all see the same 10 GiB, so the disk alone would admit |
3311 | | // them all. The reservation is held until the action's cleanup. |
3312 | 72 | if let Some(property6 ) = self |
3313 | 72 | .running_actions_manager |
3314 | 72 | .execution_configuration |
3315 | 72 | .resource_enforcement |
3316 | 72 | .as_ref() |
3317 | 72 | .and_then(|enforcement| enforcement.disk_property_name10 .as_deref10 ()) |
3318 | 6 | && let Some(reserved_kb5 ) = self |
3319 | 6 | .action_info |
3320 | 6 | .platform_properties |
3321 | 6 | .get(property) |
3322 | 6 | .and_then(|value| value.parse::<u64>()5 .ok5 ()) |
3323 | 5 | && reserved_kb > 0 |
3324 | 5 | && let Some(free_kb) = free_disk_kb(&self.running_actions_manager.root_action_directory) |
3325 | | { |
3326 | | // Held across the check and the insert, so two actions |
3327 | | // preparing at once cannot both pass on the same free space. |
3328 | 5 | let mut reservations = self.running_actions_manager.disk_reservations_kb.lock(); |
3329 | 5 | let reserved_by_others_kb: u64 = reservations |
3330 | 5 | .iter() |
3331 | 5 | .filter(|(operation_id, _)| **operation_id1 != self.operation_id1 ) |
3332 | 5 | .map(|(_, kb)| *kb) |
3333 | 5 | .sum(); |
3334 | 5 | if free_kb.saturating_sub(reserved_by_others_kb) < reserved_kb { |
3335 | 2 | self.metrics().disk_guard_refusals.inc(); |
3336 | 2 | return Err(make_err!( |
3337 | 2 | Code::ResourceExhausted, |
3338 | 2 | "Not enough free disk for this action: reserved {reserved_kb} KiB ({property}), {free_kb} KiB free under {} with {reserved_by_others_kb} KiB reserved by the actions already admitted", |
3339 | 2 | self.running_actions_manager.root_action_directory |
3340 | 2 | )); |
3341 | 3 | } |
3342 | 3 | reservations.insert(self.operation_id.clone(), reserved_kb); |
3343 | 67 | } |
3344 | 70 | { |
3345 | 70 | let mut state = self.state.lock(); |
3346 | 70 | state.execution_metadata.input_fetch_start_timestamp = |
3347 | 70 | (self.running_actions_manager.callbacks.now_fn)(); |
3348 | 70 | } |
3349 | 63 | let command = { |
3350 | 70 | let filesystem_store_pin = |
3351 | 70 | Pin::new(self.running_actions_manager.filesystem_store.as_ref()); |
3352 | 70 | let prefetched_command = self.select_input_mounts().await; |
3353 | 70 | fs::create_dir(&self.work_directory) |
3354 | 70 | .await |
3355 | 70 | .err_tip(|| format!0 ("Error creating work directory {}", self.work_directory0 ))?0 ; |
3356 | | // Now the work directory has been created, we have to clean up. |
3357 | 70 | self.did_cleanup.store(false, Ordering::Release); |
3358 | | // Fetch and decode the Command and download the input tree into |
3359 | | // the work directory, retried together after a store failure. |
3360 | 70 | self.fetch_with_retry(filesystem_store_pin, download_deadline, prefetched_command) |
3361 | 70 | .await |
3362 | 67 | .map_err(tag_missing_input)?4 |
3363 | | }; |
3364 | | { |
3365 | | // Create all directories needed for our output paths. This is required by the bazel spec. |
3366 | 63 | let prepare_output_directories = |output_file| {27 |
3367 | 27 | let full_output_path = if command.working_directory.is_empty() { |
3368 | 11 | format!("{}/{}", self.work_directory, output_file) |
3369 | | } else { |
3370 | 16 | format!( |
3371 | | "{}/{}/{}", |
3372 | 16 | self.work_directory, command.working_directory, output_file |
3373 | | ) |
3374 | | }; |
3375 | 27 | async move { |
3376 | 27 | let full_parent_path = Path::new(&full_output_path) |
3377 | 27 | .parent() |
3378 | 27 | .err_tip(|| format!0 ("Parent path for {full_output_path} has no parent"))?0 ; |
3379 | 27 | fs::create_dir_all(full_parent_path).await.err_tip(|| {0 |
3380 | 0 | format!( |
3381 | | "Error creating output directory {} (file)", |
3382 | 0 | full_parent_path.display() |
3383 | | ) |
3384 | 0 | })?; |
3385 | 27 | Result::<(), Error>::Ok(()) |
3386 | 27 | } |
3387 | 27 | }; |
3388 | 63 | self.metrics() |
3389 | 63 | .prepare_output_files |
3390 | 63 | .wrap(try_join_all( |
3391 | 63 | command.output_files.iter().map(prepare_output_directories), |
3392 | 63 | )) |
3393 | 63 | .await?0 ; |
3394 | 63 | self.metrics() |
3395 | 63 | .prepare_output_paths |
3396 | 63 | .wrap(try_join_all( |
3397 | 63 | command.output_paths.iter().map(prepare_output_directories), |
3398 | 63 | )) |
3399 | 63 | .await?0 ; |
3400 | | } |
3401 | 63 | debug!(?command, "Worker received command"); |
3402 | 63 | { |
3403 | 63 | let mut state = self.state.lock(); |
3404 | 63 | state.command_proto = Some(command); |
3405 | 63 | state.execution_metadata.input_fetch_completed_timestamp = |
3406 | 63 | (self.running_actions_manager.callbacks.now_fn)(); |
3407 | 63 | } |
3408 | 63 | Ok(self) |
3409 | 69 | } |
3410 | | |
3411 | 51 | pub fn canonicalise_path( |
3412 | 51 | self: &Arc<Self>, |
3413 | 51 | arg: &OsStr, |
3414 | 51 | working_directory: &String, |
3415 | 51 | ) -> Result<PathBuf, Error> { |
3416 | | #[cfg(target_os = "linux")] |
3417 | 51 | if let Some(mounts5 ) = self.state.lock().input_mounts.as_ref() |
3418 | 5 | && let Some(path2 ) = mounts.executable_path(working_directory, arg)?1 |
3419 | | { |
3420 | 2 | return Ok(path); |
3421 | 48 | } |
3422 | | // If the program contains a slash, we treat it as a path and resolve it relative to the work directory. |
3423 | 48 | Ok(if Path::new(arg).components().count() > 1 { |
3424 | 14 | let canonical_path = PathBuf::from(&self.work_directory) |
3425 | 14 | .join(working_directory) |
3426 | 14 | .join(arg); |
3427 | 14 | if cfg!(target_os = "windows") { |
3428 | | // Workaround for https://github.com/rust-lang/rust/issues/42869 using a windows-specific crate |
3429 | 0 | dunce::canonicalize(canonical_path) |
3430 | | } else { |
3431 | 14 | canonical_path.canonicalize() |
3432 | | } |
3433 | 14 | .err_tip(|| {1 |
3434 | 1 | format!( |
3435 | | "Could not canonicalize path for command root {}.", |
3436 | 1 | arg.to_string_lossy() |
3437 | | ) |
3438 | 1 | })? |
3439 | | } else { |
3440 | 34 | PathBuf::from(arg) |
3441 | | }) |
3442 | 51 | } |
3443 | | |
3444 | | /// The environment an action's process starts with: the worker's |
3445 | | /// `additional_environment`, then the action's own variables on top, |
3446 | | /// and on Windows the `SystemRoot` and `PATH` defaults without which |
3447 | | /// nothing runs. With `per_action` the sources that belong to one |
3448 | | /// action (its timeout, a side channel file, its directory) are in and |
3449 | | /// the side channel file is reported through it; without it they are |
3450 | | /// left out, for a pooled process that serves many actions. |
3451 | 48 | fn action_environment( |
3452 | 48 | &self, |
3453 | 48 | command_proto: &ProtoCommand, |
3454 | 48 | requested_timeout: Duration, |
3455 | 48 | mut per_action: Option<&mut Option<Cow<'_, OsStr>>>, |
3456 | 48 | ) -> BTreeMap<String, String> { |
3457 | 48 | let mut env = BTreeMap::new(); |
3458 | 48 | if let Some(additional_environment2 ) = &self |
3459 | 48 | .running_actions_manager |
3460 | 48 | .execution_configuration |
3461 | 48 | .additional_environment |
3462 | | { |
3463 | 2 | for (name, source) in additional_environment { |
3464 | 2 | let value = match source { |
3465 | 2 | EnvironmentSource::Property(property) => self |
3466 | 2 | .action_info |
3467 | 2 | .platform_properties |
3468 | 2 | .get(property) |
3469 | 2 | .cloned() |
3470 | 2 | .unwrap_or_default(), |
3471 | 0 | EnvironmentSource::Value(value) => value.clone(), |
3472 | 0 | EnvironmentSource::FromEnvironment => env::var(name).unwrap_or_default(), |
3473 | | EnvironmentSource::TimeoutMillis => { |
3474 | 0 | if per_action.is_none() { |
3475 | 0 | continue; |
3476 | 0 | } |
3477 | 0 | requested_timeout.as_millis().to_string() |
3478 | | } |
3479 | | EnvironmentSource::SideChannelFile => { |
3480 | 0 | let Some(side_channel_file) = per_action.as_mut() else { |
3481 | 0 | continue; |
3482 | | }; |
3483 | 0 | let file = format!("{}/{}", self.action_directory, Uuid::new_v4().simple()); |
3484 | 0 | **side_channel_file = Some(Cow::Owned(file.clone().into())); |
3485 | 0 | file |
3486 | | } |
3487 | | EnvironmentSource::ActionDirectory => { |
3488 | 0 | if per_action.is_none() { |
3489 | 0 | continue; |
3490 | 0 | } |
3491 | 0 | self.action_directory.clone() |
3492 | | } |
3493 | | }; |
3494 | 2 | env.insert(name.clone(), value); |
3495 | | } |
3496 | 46 | } |
3497 | 48 | for variable42 in &command_proto.environment_variables { |
3498 | 42 | env.insert(variable.name.clone(), variable.value.clone()); |
3499 | 42 | } |
3500 | | // If SystemRoot is not set on windows we set it to default. Failing |
3501 | | // to do this causes all commands to fail. |
3502 | | #[cfg(target_family = "windows")] |
3503 | | { |
3504 | | if !env.keys().any(|name| name.to_uppercase() == "SYSTEMROOT") { |
3505 | | env.insert("SystemRoot".to_string(), "C:\\Windows".to_string()); |
3506 | | } |
3507 | | if !env.keys().any(|name| name.to_uppercase() == "PATH") { |
3508 | | env.insert("PATH".to_string(), "C:\\Windows\\System32".to_string()); |
3509 | | } |
3510 | | } |
3511 | 48 | env |
3512 | 48 | } |
3513 | | |
3514 | | /// What the scheduler's kill leaves behind for a pooled action, whether |
3515 | | /// it came while waiting for a worker process or while one served it. |
3516 | 0 | fn record_persistent_worker_kill(&self, command_proto: ProtoCommand, command: &str) { |
3517 | 0 | let mut state = self.state.lock(); |
3518 | 0 | state.error = Error::merge_option( |
3519 | 0 | state.error.take(), |
3520 | 0 | Some(Error::new( |
3521 | 0 | Code::Cancelled, |
3522 | 0 | format!("Persistent worker command '{command}' was killed by scheduler"), |
3523 | 0 | )), |
3524 | | ); |
3525 | 0 | state.command_proto = Some(command_proto); |
3526 | 0 | state.execution_result = Some(RunningActionImplExecutionResult { |
3527 | 0 | stdout: Bytes::new().into(), |
3528 | 0 | stderr: Bytes::new().into(), |
3529 | 0 | exit_code: EXIT_CODE_FOR_SIGNAL, |
3530 | 0 | resource_usage: None, |
3531 | 0 | }); |
3532 | 0 | state.execution_metadata.execution_completed_timestamp = |
3533 | 0 | (self.running_actions_manager.callbacks.now_fn)(); |
3534 | 0 | } |
3535 | | |
3536 | 51 | async fn inner_execute(self: Arc<Self>) -> Result<Arc<Self>, Error> { |
3537 | 50 | let (command_proto, mut kill_channel_rx) = { |
3538 | 51 | let mut state = self.state.lock(); |
3539 | 51 | state.execution_metadata.execution_start_timestamp = |
3540 | 51 | (self.running_actions_manager.callbacks.now_fn)(); |
3541 | | ( |
3542 | 51 | state |
3543 | 51 | .command_proto |
3544 | 51 | .take() |
3545 | 51 | .err_tip(|| "Expected state to have command_proto in execute()")?1 , |
3546 | 50 | state |
3547 | 50 | .kill_channel_rx |
3548 | 50 | .take() |
3549 | 50 | .err_tip(|| "Expected state to have kill_channel_rx in execute()")?0 |
3550 | | // This is important as we may be killed at any point. |
3551 | 50 | .fuse(), |
3552 | | ) |
3553 | | }; |
3554 | 50 | if command_proto.arguments.is_empty() { |
3555 | 0 | return Err(make_input_err!("No arguments provided in Command proto")); |
3556 | 50 | } |
3557 | 50 | let args: Vec<&OsStr> = if let Some(entrypoint0 ) = &self |
3558 | 50 | .running_actions_manager |
3559 | 50 | .execution_configuration |
3560 | 50 | .entrypoint |
3561 | | { |
3562 | 0 | core::iter::once(entrypoint.as_ref()) |
3563 | 0 | .chain(command_proto.arguments.iter().map(AsRef::as_ref)) |
3564 | 0 | .collect() |
3565 | | } else { |
3566 | 50 | command_proto.arguments.iter().map(AsRef::as_ref).collect() |
3567 | | }; |
3568 | 50 | let command_directory = if command_proto.working_directory.is_empty() { |
3569 | 16 | self.work_directory.clone() |
3570 | | } else { |
3571 | 34 | format!( |
3572 | | "{}/{}", |
3573 | 34 | self.work_directory, command_proto.working_directory |
3574 | | ) |
3575 | | }; |
3576 | | // TODO(palfrey): This should probably be in debug, but currently |
3577 | | // that's too busy and we often rely on this to |
3578 | | // figure out toolchain misconfiguration issues. |
3579 | | // De-bloat the `debug` level by using the `trace` |
3580 | | // level more effectively and adjust this. |
3581 | 50 | info!(?args, %command_directory, "Executing command"); |
3582 | | |
3583 | 50 | let program48 = self |
3584 | 50 | .canonicalise_path(args[0], &command_proto.working_directory) |
3585 | 50 | .err_tip(|| format!2 ("Canonicalisation failure. Command={args:#?}"))?2 ; |
3586 | 48 | let requested_timeout = if self.action_info.timeout.is_zero() { |
3587 | 41 | self.running_actions_manager.max_action_timeout |
3588 | | } else { |
3589 | 7 | self.action_info.timeout |
3590 | | }; |
3591 | 48 | if self |
3592 | 48 | .running_actions_manager |
3593 | 48 | .execution_configuration |
3594 | 48 | .persistent_workers |
3595 | 48 | .enabled |
3596 | 48 | && let Some(wire_format_result2 ) = action_supports_persistent_workers(&self.action_info) |
3597 | | { |
3598 | 2 | match wire_format_result { |
3599 | 2 | Ok(wire_format) => { |
3600 | 2 | let command_argv = os_args_to_strings(&args)?0 ; |
3601 | | // What a one-shot action gets, but for the sources that |
3602 | | // belong to one action: a process that serves many |
3603 | | // cannot carry a timeout, a side channel file or an |
3604 | | // action directory. |
3605 | 2 | let env = self.action_environment(&command_proto, requested_timeout, None); |
3606 | 2 | let key = WorkerKey::from_argv(&command_argv, wire_format)?0 |
3607 | 2 | .with_env(env.into_iter().collect()); |
3608 | 2 | let request = WorkRequest { |
3609 | 2 | arguments: persistent_worker_request_arguments(&command_argv), |
3610 | 2 | inputs: Vec::<PersistentWorkerInput>::new(), |
3611 | 2 | request_id: 0, |
3612 | 2 | cancel: false, |
3613 | 2 | verbosity: 0, |
3614 | 2 | sandbox_dir: command_directory.clone(), |
3615 | 2 | }; |
3616 | 2 | let worker_cwd = |
3617 | 2 | PathBuf::from(&self.running_actions_manager.root_action_directory); |
3618 | | |
3619 | | // The wait for a process is bounded by the action's own |
3620 | | // timeout as well as the pool's, and raced against the |
3621 | | // kill: a cancelled action must not hold a slot, and a |
3622 | | // short action cannot wait longer than it may run. |
3623 | 2 | let acquire_started = Instant::now(); |
3624 | 2 | let acquire_bound = self |
3625 | 2 | .running_actions_manager |
3626 | 2 | .persistent_worker_pool |
3627 | 2 | .acquire_timeout() |
3628 | 2 | .min(self.timeout); |
3629 | 2 | let acquired = { |
3630 | 2 | let acquire_fut = self |
3631 | 2 | .running_actions_manager |
3632 | 2 | .persistent_worker_pool |
3633 | 2 | .acquire(key.clone(), &program, &worker_cwd); |
3634 | 2 | tokio::pin!(acquire_fut); |
3635 | 2 | tokio::select! { |
3636 | 2 | result = &mut acquire_fut => Some(result), |
3637 | 2 | _ = &mut kill_channel_rx => None0 , |
3638 | 2 | () = tokio::time::sleep(acquire_bound) => Some(Err(make_err!( |
3639 | 0 | Code::DeadlineExceeded, |
3640 | 0 | "No persistent worker came back within the action's remaining time ({acquire_bound:?})" |
3641 | 0 | ))), |
3642 | | } |
3643 | | }; |
3644 | 2 | let Some(acquired) = acquired else { |
3645 | 0 | let command = args.join(OsStr::new(" ")).to_string_lossy().into_owned(); |
3646 | 0 | self.record_persistent_worker_kill(command_proto, &command); |
3647 | 0 | return Ok(self); |
3648 | | }; |
3649 | 2 | match acquired { |
3650 | 2 | Ok(mut lease) => { |
3651 | 2 | self.metrics().persistent_worker_dispatches.inc(); |
3652 | 2 | let timer = self.metrics().child_process.begin_timer(); |
3653 | 2 | let execution_started = Instant::now(); |
3654 | | // What the pooled process and its children use |
3655 | | // while they serve this request. The process is |
3656 | | // shared, so nothing here is enforced against |
3657 | | // the action's reservation; it is measured so |
3658 | | // the sizing loop learns what the tool needs. |
3659 | | // CPU is what the process spent from here on; |
3660 | | // peak memory is the whole process, which holds |
3661 | | // what earlier requests left in it. |
3662 | 2 | let sampler = lease.worker().pid().map(|pgid| { |
3663 | 2 | start_action_resource_usage_sampler( |
3664 | 2 | pgid, |
3665 | 2 | Ceilings { |
3666 | 2 | memory_limit_kb: None, |
3667 | 2 | disk_limit_kb: None, |
3668 | 2 | over_limit_tx: None, |
3669 | 2 | // A detached watch: the process |
3670 | 2 | // outlives this action and serves |
3671 | 2 | // others, so it is never a pressure |
3672 | 2 | // kill's victim. |
3673 | 2 | watch: Arc::new(ActionMemoryWatch::default()), |
3674 | 2 | }, |
3675 | | // Its outcome is classified against no limit. |
3676 | 2 | None, |
3677 | 2 | None, |
3678 | 2 | self.running_actions_manager.process_table.clone(), |
3679 | | ) |
3680 | 2 | }); |
3681 | 2 | let dispatch_result = { |
3682 | 2 | let remaining = |
3683 | 2 | self.timeout.saturating_sub(acquire_started.elapsed()); |
3684 | 2 | let dispatch_fut = |
3685 | 2 | lease.worker().dispatch_with_timeout(&request, remaining); |
3686 | 2 | tokio::pin!(dispatch_fut); |
3687 | 2 | tokio::select! { |
3688 | 2 | result = &mut dispatch_fut => Some(result), |
3689 | 2 | _ = &mut kill_channel_rx => None0 , |
3690 | | } |
3691 | | }; |
3692 | 2 | let sampled_usage = match sampler { |
3693 | 2 | Some(sampler) => finish_action_resource_usage_sampler(sampler) |
3694 | 2 | .await |
3695 | 2 | .unwrap_or_default(), |
3696 | 0 | None => SampledResourceUsage::default(), |
3697 | | }; |
3698 | 2 | let response = match dispatch_result { |
3699 | 2 | Some(Ok(response)) => { |
3700 | 2 | lease.release(true).await; |
3701 | 2 | response |
3702 | | } |
3703 | 0 | Some(Err(err)) => { |
3704 | 0 | lease.release(false).await; |
3705 | 0 | return Err(err).err_tip(|| { |
3706 | 0 | format!("Dispatching action to persistent worker {key:?}") |
3707 | 0 | }); |
3708 | | } |
3709 | | None => { |
3710 | 0 | drop(timer); |
3711 | 0 | lease.release(false).await; |
3712 | 0 | let command = |
3713 | 0 | args.join(OsStr::new(" ")).to_string_lossy().into_owned(); |
3714 | 0 | self.record_persistent_worker_kill(command_proto, &command); |
3715 | 0 | return Ok(self); |
3716 | | } |
3717 | | }; |
3718 | 2 | timer.measure(); |
3719 | | |
3720 | 2 | if response.exit_code == 0 { |
3721 | 2 | self.metrics().child_process_success_error_code.inc(); |
3722 | 2 | } else { |
3723 | 0 | self.metrics().child_process_failure_error_code.inc(); |
3724 | 0 | } |
3725 | 2 | info!(?args, ?key, "Persistent worker command complete"); |
3726 | 2 | let (memory_property, disk_property) = self |
3727 | 2 | .running_actions_manager |
3728 | 2 | .execution_configuration |
3729 | 2 | .resource_enforcement |
3730 | 2 | .as_ref() |
3731 | 2 | .map_or((None, None), |e| {0 |
3732 | | ( |
3733 | 0 | e.memory.as_ref().map(|m| m.property_name.as_str()), |
3734 | 0 | e.disk_property_name.as_deref(), |
3735 | | ) |
3736 | 0 | }); |
3737 | 2 | let (outcome, enforced) = |
3738 | 2 | classify_outcome(None, None, sampled_usage.last_memory_kb, None); |
3739 | 2 | let fetched_input = self.state.lock().input_bytes; |
3740 | 2 | let resource_usage = Some(ActionResourceUsage { |
3741 | 2 | peak_memory_kb: sampled_usage.peak_memory_kb, |
3742 | 2 | cpu_time_ms: sampled_usage.cpu_time_ms, |
3743 | 2 | sampled: sampled_usage.peak_memory_kb > 0 |
3744 | 0 | || sampled_usage.cpu_time_ms > 0, |
3745 | 2 | operation_id: String::new(), |
3746 | 2 | worker_id: String::new(), |
3747 | 2 | wall_time_ms: u64::try_from( |
3748 | 2 | execution_started.elapsed().as_millis(), |
3749 | | ) |
3750 | 2 | .unwrap_or(u64::MAX), |
3751 | | peak_disk_kb: 0, |
3752 | 2 | outcome: outcome.into(), |
3753 | 2 | enforced, |
3754 | 2 | reserved: reservation_of( |
3755 | 2 | &self.action_info.platform_properties, |
3756 | 2 | memory_property, |
3757 | 2 | disk_property, |
3758 | | ), |
3759 | 2 | input_bytes: fetched_input.0, |
3760 | 2 | input_cached_bytes: fetched_input.1, |
3761 | 2 | ..Default::default() |
3762 | | }); |
3763 | 2 | { |
3764 | 2 | let mut state = self.state.lock(); |
3765 | 2 | state.command_proto = Some(command_proto); |
3766 | 2 | state.execution_result = Some(RunningActionImplExecutionResult { |
3767 | 2 | stdout: Bytes::new().into(), |
3768 | 2 | stderr: Bytes::from(response.output).into(), |
3769 | 2 | exit_code: response.exit_code, |
3770 | 2 | resource_usage, |
3771 | 2 | }); |
3772 | 2 | state.execution_metadata.execution_completed_timestamp = |
3773 | 2 | (self.running_actions_manager.callbacks.now_fn)(); |
3774 | 2 | } |
3775 | 2 | return Ok(self); |
3776 | | } |
3777 | 0 | Err(err) => { |
3778 | 0 | self.metrics().persistent_worker_fallbacks.inc(); |
3779 | 0 | info!( |
3780 | | ?err, |
3781 | | ?key, |
3782 | | "Falling back to one-shot execution; persistent worker unavailable" |
3783 | | ); |
3784 | | } |
3785 | | } |
3786 | | } |
3787 | 0 | Err(err) => { |
3788 | 0 | self.metrics().persistent_worker_fallbacks.inc(); |
3789 | 0 | info!( |
3790 | | ?err, |
3791 | | "Falling back to one-shot execution; unsupported persistent worker protocol" |
3792 | | ); |
3793 | | } |
3794 | | } |
3795 | 46 | } |
3796 | | |
3797 | 46 | let mut command_builder = process::Command::new(program); |
3798 | | #[cfg(target_family = "unix")] |
3799 | 46 | command_builder.arg0(args[0]); |
3800 | 46 | command_builder |
3801 | 46 | .args(&args[1..]) |
3802 | 46 | .kill_on_drop(true) |
3803 | 46 | .stdin(Stdio::null()) |
3804 | 46 | .stdout(Stdio::piped()) |
3805 | 46 | .stderr(Stdio::piped()) |
3806 | 46 | .current_dir(command_directory.clone()) |
3807 | 46 | .env_clear(); |
3808 | | |
3809 | 46 | if self |
3810 | 46 | .running_actions_manager |
3811 | 46 | .execution_configuration |
3812 | 46 | .set_tmpdir |
3813 | | { |
3814 | | // One tmp per action, removed with the action directory, so two |
3815 | | // actions writing the same name under $TMPDIR no longer collide. |
3816 | | // Code that hardcodes /tmp still does. |
3817 | 1 | let tmp_directory = format!("{}/tmp", self.action_directory); |
3818 | 1 | fs::create_dir_all(&tmp_directory) |
3819 | 1 | .await |
3820 | 1 | .err_tip(|| format!0 ("Creating {tmp_directory} for TMPDIR"))?0 ; |
3821 | 1 | command_builder.env("TMPDIR", &tmp_directory); |
3822 | 45 | } |
3823 | | |
3824 | 46 | let mut maybe_side_channel_file: Option<Cow<'_, OsStr>> = None; |
3825 | 46 | for (name42 , value42 ) in self.action_environment( |
3826 | 46 | &command_proto, |
3827 | 46 | requested_timeout, |
3828 | 46 | Some(&mut maybe_side_channel_file), |
3829 | 46 | ) { |
3830 | 42 | command_builder.env(name, value); |
3831 | 42 | } |
3832 | | |
3833 | | // Sandboxing of the command if we are running on Linux, this resolves issues where |
3834 | | // children can spawn children and also provides better reproducibility. |
3835 | | #[cfg(target_os = "linux")] |
3836 | | { |
3837 | 46 | let use_namespaces = self.running_actions_manager.use_namespaces; |
3838 | | |
3839 | 46 | if !matches!38 (use_namespaces, UseNamespaces::No) { |
3840 | 38 | let (mount, isolate_tmp) = match use_namespaces { |
3841 | 38 | UseNamespaces::YesAndMount { isolate_tmp } => (true, isolate_tmp), |
3842 | 0 | UseNamespaces::No | UseNamespaces::Yes => (false, false), |
3843 | | }; |
3844 | 38 | let root_action_directory = std::ffi::CString::new( |
3845 | 38 | self.running_actions_manager.root_action_directory.clone(), |
3846 | | ) |
3847 | 38 | .err_tip(|| "In RunningActionImpl::inner_execute()")?0 ; |
3848 | 38 | let action_directory = std::ffi::CString::new(self.action_directory.clone()) |
3849 | 38 | .err_tip(|| "In RunningActionImpl::inner_execute()")?0 ; |
3850 | | // The action's private /tmp is its own tmp directory, the |
3851 | | // same one TMPDIR points at, bound over /tmp in the child. |
3852 | 38 | let tmp_directory = if isolate_tmp { |
3853 | 7 | let tmp_directory = format!("{}/tmp", self.action_directory); |
3854 | 7 | fs::create_dir_all(&tmp_directory) |
3855 | 7 | .await |
3856 | 7 | .err_tip(|| format!0 ("Creating {tmp_directory} for the private /tmp"))?0 ; |
3857 | | Some( |
3858 | 7 | std::ffi::CString::new(tmp_directory) |
3859 | 7 | .err_tip(|| "In RunningActionImpl::inner_execute()")?0 , |
3860 | | ) |
3861 | | } else { |
3862 | 31 | None |
3863 | | }; |
3864 | | |
3865 | 38 | let input_mounts = self.state.lock().input_mounts.clone(); |
3866 | 38 | let bind_mounts = input_mounts |
3867 | 38 | .as_ref() |
3868 | 38 | .map(|mounts| mounts4 .bind_mounts4 ()) |
3869 | 38 | .transpose()?0 |
3870 | 38 | .unwrap_or_default(); |
3871 | | // SAFETY: This function is specifically designed to operate in a async-signal-safe |
3872 | | // environment. |
3873 | | unsafe { |
3874 | 38 | command_builder.pre_exec(move || {0 |
3875 | 0 | crate::namespace_utils::configure_namespace_with_input_mounts( |
3876 | 0 | mount, |
3877 | 0 | tmp_directory.as_deref(), |
3878 | 0 | &root_action_directory, |
3879 | 0 | &action_directory, |
3880 | 0 | &bind_mounts, |
3881 | | ) |
3882 | 0 | }); |
3883 | | } |
3884 | 8 | } |
3885 | | } |
3886 | | // Run the action as its own process-group leader (pgid == child |
3887 | | // pid) on every unix. The resource-usage sampler attributes memory |
3888 | | // by process group, and the kill at exit ends the group, so this |
3889 | | // keeps the whole action together, including processes reparented |
3890 | | // to the worker when an intermediate shell exits, and never |
3891 | | // conflates it with the worker's own group. |
3892 | | #[cfg(target_family = "unix")] |
3893 | 46 | command_builder.process_group(0); |
3894 | | |
3895 | 46 | let mut child_process = command_builder |
3896 | 46 | .spawn() |
3897 | 46 | .err_tip(|| format!0 ("Could not execute command {args:?}"))?0 ; |
3898 | | // Ours until the wait below collects it; the reaper leaves it alone. |
3899 | 46 | let _owned_child = crate::reaper::OwnedChild::new(child_process.id()); |
3900 | 46 | let stdout_reader = child_process |
3901 | 46 | .stdout |
3902 | 46 | .take() |
3903 | 46 | .err_tip(|| "Expected stdout to exist on command this should never happen")?0 ; |
3904 | 46 | let stderr_reader = child_process |
3905 | 46 | .stderr |
3906 | 46 | .take() |
3907 | 46 | .err_tip(|| "Expected stderr to exist on command this should never happen")?0 ; |
3908 | | |
3909 | | #[cfg(target_os = "linux")] |
3910 | | // Wrap the child process to send SIGTERM rather than SIGKILL if namespaced to |
3911 | | // prevent zombie processes. |
3912 | 46 | let child_process = crate::namespace_utils::MaybeNamespacedChild::new( |
3913 | 38 | !matches!( |
3914 | 46 | self.running_actions_manager.use_namespaces, |
3915 | | UseNamespaces::No, |
3916 | | ), |
3917 | 46 | child_process, |
3918 | | ); |
3919 | | |
3920 | | // The reservation the scheduler placed this action by, if the |
3921 | | // worker is asked to hold it to that number. |
3922 | 46 | let memory_reservation = self |
3923 | 46 | .running_actions_manager |
3924 | 46 | .execution_configuration |
3925 | 46 | .resource_enforcement |
3926 | 46 | .as_ref() |
3927 | 46 | .and_then(|enforcement| {6 |
3928 | 6 | let memory4 = enforcement.memory.as_ref()?2 ; |
3929 | 4 | let reserved_kb = self |
3930 | 4 | .action_info |
3931 | 4 | .platform_properties |
3932 | 4 | .get(&memory.property_name)?0 |
3933 | 4 | .parse::<u64>() |
3934 | 4 | .ok()?0 ; |
3935 | 4 | (reserved_kb > 0).then(|| { |
3936 | 4 | let limit_kb = reserved_kb.saturating_mul(100 + memory.headroom_percent) / 100; |
3937 | 4 | (reserved_kb, limit_kb) |
3938 | 4 | }) |
3939 | 6 | }); |
3940 | 46 | self.memory_watch.reserved_memory_kb.store( |
3941 | 46 | memory_reservation.map_or(0, |(reserved_kb, _)| reserved_kb), |
3942 | 46 | Ordering::Relaxed, |
3943 | | ); |
3944 | | // The disk reservation, held to only with `disk: soft`; disk is |
3945 | | // measured whenever the enforcement names a disk property. |
3946 | 46 | let disk_enforcement = self |
3947 | 46 | .running_actions_manager |
3948 | 46 | .execution_configuration |
3949 | 46 | .resource_enforcement |
3950 | 46 | .as_ref() |
3951 | 46 | .and_then(|enforcement| {6 |
3952 | 6 | let property2 = enforcement.disk_property_name.as_deref()?4 ; |
3953 | 2 | let reserved_kb = self |
3954 | 2 | .action_info |
3955 | 2 | .platform_properties |
3956 | 2 | .get(property) |
3957 | 2 | .and_then(|value| value.parse::<u64>()1 .ok1 ()) |
3958 | 2 | .unwrap_or(0); |
3959 | 2 | let limit_kb = (enforcement.disk_soft && reserved_kb > 01 ).then(|| {1 |
3960 | 1 | reserved_kb.saturating_mul(100 + enforcement.disk_headroom_percent) / 100 |
3961 | 1 | }); |
3962 | 2 | Some((reserved_kb, limit_kb)) |
3963 | 6 | }); |
3964 | 46 | let (over_limit_tx, over_limit_rx) = oneshot::channel::<OverLimit>(); |
3965 | | // Holds the sender when no ceiling is enforced, so the receiver |
3966 | | // below stays pending instead of resolving closed. |
3967 | 46 | let mut over_limit_keepalive = Some(over_limit_tx); |
3968 | | // What a SIGKILL nobody here sent is judged against; the sampler |
3969 | | // keeps its figure exact near it. |
3970 | 46 | let oom_limit_kb = memory_reservation |
3971 | 46 | .map(|(_, limit_kb)| limit_kb) |
3972 | 46 | .or(self.running_actions_manager.pod_memory_limit_kb); |
3973 | 46 | let mut maybe_resource_usage_sampler = child_process.id().map(|pgid| { |
3974 | 46 | let memory_limit_kb = memory_reservation.map(|(_, limit_kb)| limit_kb); |
3975 | 46 | let disk_limit_kb = disk_enforcement.and_then(|(_, limit_kb)| limit_kb); |
3976 | 46 | let pressure_kill_armed = self |
3977 | 46 | .running_actions_manager |
3978 | 46 | .execution_configuration |
3979 | 46 | .resource_enforcement |
3980 | 46 | .as_ref() |
3981 | 46 | .is_some_and(|enforcement| enforcement.pressure_kill6 .is_some6 ()); |
3982 | 46 | let over_limit_tx = |
3983 | 46 | (memory_limit_kb.is_some() || disk_limit_kb42 .is_some42 () || pressure_kill_armed41 ) |
3984 | 46 | .then(|| over_limit_keepalive5 .take5 ()) |
3985 | 46 | .flatten(); |
3986 | 46 | let disk_directory = disk_enforcement.map(|_| {2 |
3987 | 2 | let directory = PathBuf::from(&self.action_directory); |
3988 | 2 | let since = directory_stamp(&directory).unwrap_or_else(|err| {0 |
3989 | 0 | warn!(?err, "Timing the disk walk by the wall clock instead"); |
3990 | 0 | SystemTime::now() |
3991 | 0 | }); |
3992 | 2 | (directory, since) |
3993 | 2 | }); |
3994 | 46 | start_action_resource_usage_sampler( |
3995 | 46 | pgid, |
3996 | 46 | Ceilings { |
3997 | 46 | memory_limit_kb, |
3998 | 46 | disk_limit_kb, |
3999 | 46 | over_limit_tx, |
4000 | 46 | watch: self.memory_watch.clone(), |
4001 | 46 | }, |
4002 | 46 | oom_limit_kb, |
4003 | 46 | disk_directory, |
4004 | 46 | self.running_actions_manager.process_table.clone(), |
4005 | | ) |
4006 | 46 | }); |
4007 | 46 | let mut over_limit_fut = over_limit_rx.fuse(); |
4008 | | |
4009 | | // The group to end when the action's own process has exited; taken |
4010 | | // before the guard owns the child. |
4011 | | #[cfg(target_family = "unix")] |
4012 | 46 | let action_pgid = child_process.id(); |
4013 | 46 | let mut child_process_guard = guard(child_process, |mut child_process| {0 |
4014 | 0 | let result: Result<Option<std::process::ExitStatus>, std::io::Error> = |
4015 | 0 | child_process.try_wait(); |
4016 | 0 | match result { |
4017 | 0 | Ok(res) if res.is_some() => { |
4018 | 0 | // The child already exited, probably a timeout or kill operation |
4019 | 0 | } |
4020 | 0 | result => { |
4021 | 0 | error!( |
4022 | | ?result, |
4023 | | "Child process was not cleaned up before dropping the call to execute(), killing in background spawn." |
4024 | | ); |
4025 | 0 | background_spawn!("running_actions_manager_kill_child_process", async move { |
4026 | 0 | drop(child_process.kill().await); |
4027 | 0 | }); |
4028 | | } |
4029 | | } |
4030 | 0 | }); |
4031 | | |
4032 | 46 | let output_cap = self |
4033 | 46 | .running_actions_manager |
4034 | 46 | .execution_configuration |
4035 | 46 | .max_captured_output_bytes; |
4036 | 46 | let stdout_spill = PathBuf::from(&self.action_directory).join("nativelink.stdout"); |
4037 | 46 | let stderr_spill = PathBuf::from(&self.action_directory).join("nativelink.stderr"); |
4038 | | // Flipped when the pipes have had their drain after the action |
4039 | | // ended; the readers then keep what they have. |
4040 | 46 | let (drain_stop_tx, drain_stop_rx) = watch::channel(false); |
4041 | 46 | let stdout_stop = drain_stop_rx.clone(); |
4042 | 46 | let all_stdout_fut = spawn!("stdout_reader", async move { |
4043 | 46 | capture_output( |
4044 | 46 | stdout_reader, |
4045 | 46 | output_cap, |
4046 | 46 | stdout_spill, |
4047 | 46 | "stdout", |
4048 | 46 | stdout_stop, |
4049 | 46 | ) |
4050 | 46 | .await |
4051 | 46 | }); |
4052 | 46 | let all_stderr_fut = spawn!("stderr_reader", async move { |
4053 | 46 | capture_output( |
4054 | 46 | stderr_reader, |
4055 | 46 | output_cap, |
4056 | 46 | stderr_spill, |
4057 | 46 | "stderr", |
4058 | 46 | drain_stop_rx, |
4059 | 46 | ) |
4060 | 46 | .await |
4061 | 46 | }); |
4062 | 46 | let mut kill_reason: Option<KillReason> = None; |
4063 | 46 | let kill_grace = self |
4064 | 46 | .running_actions_manager |
4065 | 46 | .execution_configuration |
4066 | 46 | .kill_grace; |
4067 | | // Armed by a soft kill; fires SIGKILL if the action is still there |
4068 | | // when the grace period ends. |
4069 | 46 | let mut grace_fut: Fuse<BoxFuture<'static, ()>> = Fuse::terminated(); |
4070 | 46 | let execution_started = std::time::Instant::now(); |
4071 | | |
4072 | 46 | let timer = self.metrics().child_process.begin_timer(); |
4073 | 46 | let mut sleep_fut = (self.running_actions_manager.callbacks.sleep_fn)(self.timeout).fuse(); |
4074 | | loop { |
4075 | 58 | tokio::select! { |
4076 | 58 | () = &mut sleep_fut => { |
4077 | 5 | self.running_actions_manager.metrics.task_timeouts.inc(); |
4078 | 5 | kill_reason = Some(KillReason::Timeout); |
4079 | 5 | grace_fut = stop_action(&mut child_process_guard, &self.memory_watch, kill_grace, "action timeout").await; |
4080 | | { |
4081 | 5 | let joined_command = args.join(OsStr::new(" ")); |
4082 | 5 | let command = joined_command.to_string_lossy(); |
4083 | 5 | info!( |
4084 | 5 | seconds = self.action_info.timeout.as_secs_f32(), |
4085 | | %command, |
4086 | | %command_directory, |
4087 | | "Command timed out" |
4088 | | ); |
4089 | 5 | let mut state = self.state.lock(); |
4090 | 5 | state.error = Error::merge_option(state.error.take(), Some(Error::new( |
4091 | 5 | Code::DeadlineExceeded, |
4092 | 5 | format!( |
4093 | 5 | "Command '{}' timed out after {} seconds", |
4094 | 5 | command, |
4095 | 5 | self.action_info.timeout.as_secs_f32() |
4096 | 5 | ) |
4097 | 5 | ))); |
4098 | | } |
4099 | | }, |
4100 | 58 | () = &mut grace_fut => { |
4101 | 0 | warn!( |
4102 | 0 | operation_id = ?self.operation_id, |
4103 | 0 | grace_ms = kill_grace.as_millis(), |
4104 | | "Action did not leave on SIGTERM within its grace period, sending SIGKILL" |
4105 | | ); |
4106 | 0 | hard_kill(&mut child_process_guard, &self.memory_watch, "grace period expired").await; |
4107 | | }, |
4108 | 58 | Ok(over_limit4 ) = &mut over_limit_fut => match over_limit4 { |
4109 | 1 | OverLimit::Disk(observed_kb) => { |
4110 | 1 | self.running_actions_manager.metrics.disk_reservation_kills.inc(); |
4111 | 1 | kill_reason = Some(KillReason::Disk); |
4112 | | // The files are still being written; no grace here. |
4113 | 1 | hard_kill(&mut child_process_guard, &self.memory_watch, "disk reservation").await; |
4114 | 1 | let (reserved_kb, limit_kb) = disk_enforcement |
4115 | 1 | .map_or((0, 0), |(reserved_kb, limit_kb)| { |
4116 | 1 | (reserved_kb, limit_kb.unwrap_or(0)) |
4117 | 1 | }); |
4118 | 1 | let (property, headroom) = self |
4119 | 1 | .running_actions_manager |
4120 | 1 | .execution_configuration |
4121 | 1 | .resource_enforcement |
4122 | 1 | .as_ref() |
4123 | 1 | .map_or(("", 0), |e| (e.disk_property_name.as_deref().unwrap_or(""), e.disk_headroom_percent)); |
4124 | 1 | warn!( |
4125 | 1 | operation_id = ?self.operation_id, |
4126 | | reserved_kb, |
4127 | | limit_kb, |
4128 | | observed_kb, |
4129 | | "Action exceeded its disk reservation, killed" |
4130 | | ); |
4131 | 1 | let mut state = self.state.lock(); |
4132 | 1 | state.error = Error::merge_option(state.error.take(), Some(Error::new( |
4133 | 1 | Code::FailedPrecondition, |
4134 | 1 | format!( |
4135 | 1 | "Action exceeded its disk reservation: reserved {reserved_kb} KiB ({property}), \ |
4136 | 1 | limit {limit_kb} KiB with {headroom}% headroom, its own files came to {observed_kb} KiB. \ |
4137 | 1 | Raise the reservation or write less." |
4138 | 1 | ), |
4139 | 1 | ))); |
4140 | | |
4141 | | } |
4142 | 1 | OverLimit::Memory(observed_kb) => { |
4143 | 1 | self.running_actions_manager.metrics.memory_reservation_kills.inc(); |
4144 | 1 | kill_reason = Some(KillReason::Memory); |
4145 | | // Memory is still growing; no grace here. |
4146 | 1 | hard_kill(&mut child_process_guard, &self.memory_watch, "memory reservation").await; |
4147 | 1 | let (reserved_kb, limit_kb) = memory_reservation.unwrap_or((0, 0)); |
4148 | 1 | let (property, headroom) = self |
4149 | 1 | .running_actions_manager |
4150 | 1 | .execution_configuration |
4151 | 1 | .resource_enforcement |
4152 | 1 | .as_ref() |
4153 | 1 | .and_then(|e| e.memory.as_ref()) |
4154 | 1 | .map_or(("", 0), |m| (m.property_name.as_str(), m.headroom_percent)); |
4155 | 1 | warn!( |
4156 | 1 | operation_id = ?self.operation_id, |
4157 | | reserved_kb, |
4158 | | limit_kb, |
4159 | | observed_kb, |
4160 | | "Action exceeded its memory reservation, killed" |
4161 | | ); |
4162 | 1 | let mut state = self.state.lock(); |
4163 | 1 | state.error = Error::merge_option(state.error.take(), Some(Error::new( |
4164 | 1 | Code::FailedPrecondition, |
4165 | 1 | format!( |
4166 | 1 | "Action exceeded its memory reservation: reserved {reserved_kb} KiB ({property}), \ |
4167 | 1 | limit {limit_kb} KiB with {headroom}% headroom, observed {observed_kb} KiB. \ |
4168 | 1 | Raise the reservation or shrink the action." |
4169 | 1 | ), |
4170 | 1 | ))); |
4171 | | |
4172 | | } |
4173 | 2 | OverLimit::MemoryPressure { observed_kb, full_avg10_percent } => { |
4174 | 2 | self.running_actions_manager.metrics.memory_pressure_kills.inc(); |
4175 | 2 | kill_reason = Some(KillReason::Memory); |
4176 | | // The worker is thrashing; no grace here either. |
4177 | 2 | hard_kill(&mut child_process_guard, &self.memory_watch, "memory pressure").await; |
4178 | 2 | let reserved_kb = memory_reservation.map_or(0, |(reserved_kb, _)| reserved_kb); |
4179 | 2 | let after_s = self |
4180 | 2 | .running_actions_manager |
4181 | 2 | .execution_configuration |
4182 | 2 | .resource_enforcement |
4183 | 2 | .as_ref() |
4184 | 2 | .and_then(|e| e.pressure_kill.as_ref()) |
4185 | 2 | .map_or(0, |p| p.after.as_secs()); |
4186 | 2 | warn!( |
4187 | 2 | operation_id = ?self.operation_id, |
4188 | | reserved_kb, |
4189 | | observed_kb, |
4190 | | full_avg10_percent, |
4191 | | "Action killed under sustained worker memory pressure" |
4192 | | ); |
4193 | 2 | let why = if observed_kb > reserved_kb { |
4194 | 0 | format!( |
4195 | | "this action was the furthest over its reservation of those running, \ |
4196 | | {observed_kb} KiB against {reserved_kb} KiB. The reservation is too small for it." |
4197 | | ) |
4198 | | } else { |
4199 | 2 | format!( |
4200 | | "the worker was out of memory and this action held the most of what was running, \ |
4201 | | {observed_kb} KiB, within its reservation of {reserved_kb} KiB." |
4202 | | ) |
4203 | | }; |
4204 | 2 | let mut state = self.state.lock(); |
4205 | 2 | state.error = Error::merge_option(state.error.take(), Some(Error::new( |
4206 | 2 | Code::FailedPrecondition, |
4207 | 2 | format!( |
4208 | 2 | "Action was killed under worker memory pressure: the worker's cgroup spent \ |
4209 | 2 | {full_avg10_percent}% of the last 10 s with every task stalled on memory for {after_s} s, \ |
4210 | 2 | and {why}" |
4211 | 2 | ), |
4212 | 2 | ))); |
4213 | | } |
4214 | | }, |
4215 | 58 | maybe_exit_status46 = child_process_guard.wait() => { |
4216 | | // Defuse our guard so it does not try to cleanup and make senseless logs. |
4217 | 46 | drop(ScopeGuard::<_, _>::into_inner(child_process_guard)); |
4218 | 46 | drop(over_limit_keepalive.take()); |
4219 | 46 | let exit_status = maybe_exit_status.err_tip(|| "Failed to collect exit code of process")?0 ; |
4220 | | // TODO(palfrey) We should implement stderr/stdout streaming to client here. |
4221 | | // If we get killed before the stream is started, then these will lock up. |
4222 | 46 | let killed_action = kill_reason.is_some(); |
4223 | 46 | if killed_action { |
4224 | 12 | drop(timer); |
4225 | 34 | } else { |
4226 | 34 | timer.measure(); |
4227 | 34 | } |
4228 | | // The action is over; anything still running in its |
4229 | | // group is a straggler (a background job, a helper that |
4230 | | // daemonized) holding the pipes open, and without this |
4231 | | // the action would sit here until its timeout. The |
4232 | | // sandboxes Bazel runs locally end them the same way. |
4233 | | #[cfg(target_family = "unix")] |
4234 | 46 | let had_stragglers = action_pgid |
4235 | 46 | .is_some_and(|pgid| signal_action(pgid, &self.memory_watch, libc::SIGKILL)); |
4236 | | #[cfg(not(target_family = "unix"))] |
4237 | | let had_stragglers = false; |
4238 | | // An action that exited on its own with nothing left in |
4239 | | // its group closes its pipes itself, and its output is |
4240 | | // the result, so that read runs to the end: a reader |
4241 | | // slowed by a busy spill must not lose bytes to a clock. |
4242 | | // The wait is bounded only when there was something to |
4243 | | // hold the pipes: a killed action, whose output is a |
4244 | | // bonus, or stragglers, one of which may have left the |
4245 | | // group. What was read by then is kept. |
4246 | 46 | let mut drain = core::pin::pin!(async { tokio::join!(all_stdout_fut, all_stderr_fut) }); |
4247 | 46 | let (maybe_all_stdout, maybe_all_stderr) = if killed_action || had_stragglers34 { |
4248 | 12 | tokio::select! { |
4249 | 12 | outputs = &mut drain => outputs, |
4250 | 12 | () = tokio::time::sleep(OUTPUT_DRAIN_TIMEOUT) => { |
4251 | 0 | warn!( |
4252 | 0 | operation_id = ?self.operation_id, |
4253 | 0 | drain_s = OUTPUT_DRAIN_TIMEOUT.as_secs(), |
4254 | | killed_action, |
4255 | | had_stragglers, |
4256 | | "A process held the action's output pipes open past the drain; keeping what was read" |
4257 | | ); |
4258 | 0 | drain_stop_tx.send_replace(true); |
4259 | 0 | drain.await |
4260 | | } |
4261 | | } |
4262 | | } else { |
4263 | 34 | drain.await |
4264 | | }; |
4265 | 46 | let (stdout, stderr) = ( |
4266 | 46 | maybe_all_stdout.err_tip(|| "Internal error reading from stdout of worker task")?0 ?0 , |
4267 | 46 | maybe_all_stderr.err_tip(|| "Internal error reading from stderr of worker task")?0 ?0 , |
4268 | | ); |
4269 | | |
4270 | 46 | let sampled_usage = match maybe_resource_usage_sampler.take() { |
4271 | 46 | Some(sampler) => finish_action_resource_usage_sampler(sampler) |
4272 | 46 | .await |
4273 | 46 | .unwrap_or_default(), |
4274 | 0 | None => SampledResourceUsage::default(), |
4275 | | }; |
4276 | | |
4277 | 46 | let memory_limit_kb = oom_limit_kb; |
4278 | | // The signal that ended the process, where the platform reports one. |
4279 | | #[cfg(target_family = "unix")] |
4280 | 46 | let signal = exit_status.signal(); |
4281 | | #[cfg(not(target_family = "unix"))] |
4282 | | let signal = None; |
4283 | 46 | let (outcome, enforced) = classify_outcome( |
4284 | 46 | kill_reason, |
4285 | 46 | signal, |
4286 | 46 | sampled_usage.last_memory_kb, |
4287 | 46 | memory_limit_kb, |
4288 | 46 | ); |
4289 | | |
4290 | 46 | let exit_code = exit_status.code().map_or_else(|| {11 |
4291 | 11 | self.metrics().child_process_failure_error_code.inc(); |
4292 | 11 | if kill_reason.is_none() { |
4293 | | // No exit code and no kill of ours: a signal from |
4294 | | // outside. SIGKILL near the memory limit is the |
4295 | | // kernel's OOM killer. |
4296 | 0 | warn!( |
4297 | | ?args, |
4298 | 0 | outcome = outcome.as_str_name(), |
4299 | | last_memory_kb = sampled_usage.last_memory_kb, |
4300 | | ?memory_limit_kb, |
4301 | | "Runner subprocess terminated by a signal nobody here sent" |
4302 | | ); |
4303 | 11 | } |
4304 | 11 | EXIT_CODE_FOR_SIGNAL |
4305 | 35 | }11 , |exit_code| { |
4306 | 35 | if exit_code == 0 { |
4307 | 32 | self.metrics().child_process_success_error_code.inc(); |
4308 | 32 | } else { |
4309 | 3 | self.metrics().child_process_failure_error_code.inc(); |
4310 | 3 | } |
4311 | 35 | exit_code |
4312 | 35 | }); |
4313 | 46 | if kill_reason.is_none() && outcome == ResourceOutcome::KilledMemory34 { |
4314 | 0 | let mut state = self.state.lock(); |
4315 | 0 | state.error = Error::merge_option(state.error.take(), Some(Error::new( |
4316 | 0 | Code::FailedPrecondition, |
4317 | 0 | format!( |
4318 | 0 | "Action was killed by the kernel at {} KiB, within 10% of the {} KiB memory limit: \ |
4319 | 0 | out of memory. Raise the reservation or shrink the action.", |
4320 | 0 | sampled_usage.last_memory_kb, |
4321 | 0 | memory_limit_kb.unwrap_or(0), |
4322 | 0 | ), |
4323 | 0 | ))); |
4324 | 46 | } |
4325 | | |
4326 | 46 | let (memory_property, disk_property) = self |
4327 | 46 | .running_actions_manager |
4328 | 46 | .execution_configuration |
4329 | 46 | .resource_enforcement |
4330 | 46 | .as_ref() |
4331 | 46 | .map_or((None, None), |e| {6 |
4332 | | ( |
4333 | 6 | e.memory.as_ref().map(|m| m.property_name4 .as_str4 ()), |
4334 | 6 | e.disk_property_name.as_deref(), |
4335 | | ) |
4336 | 6 | }); |
4337 | 46 | let fetched_input = self.state.lock().input_bytes; |
4338 | 46 | let resource_usage = Some(ActionResourceUsage { |
4339 | 46 | peak_memory_kb: sampled_usage.peak_memory_kb, |
4340 | 46 | cpu_time_ms: sampled_usage.cpu_time_ms, |
4341 | | // An action too short to catch a sample leaves both at |
4342 | | // zero; say so rather than report a misleading zero. |
4343 | 46 | sampled: sampled_usage.peak_memory_kb > 0 |
4344 | 14 | || sampled_usage.cpu_time_ms > 0 |
4345 | 14 | || sampled_usage.peak_disk_kb > 0, |
4346 | 46 | operation_id: String::new(), |
4347 | 46 | worker_id: String::new(), |
4348 | 46 | wall_time_ms: u64::try_from(execution_started.elapsed().as_millis()).unwrap_or(u64::MAX), |
4349 | 46 | peak_disk_kb: sampled_usage.peak_disk_kb, |
4350 | 46 | outcome: outcome.into(), |
4351 | 46 | enforced, |
4352 | 46 | reserved: reservation_of( |
4353 | 46 | &self.action_info.platform_properties, |
4354 | 46 | memory_property, |
4355 | 46 | disk_property, |
4356 | | ), |
4357 | 46 | input_bytes: fetched_input.0, |
4358 | 46 | input_cached_bytes: fetched_input.1, |
4359 | | // Phases and outputs are known only once the |
4360 | | // result is uploaded; `with_phases` fills them. |
4361 | 46 | ..Default::default() |
4362 | | }); |
4363 | | |
4364 | | // log something useful instead of repeating same ?arg |
4365 | 46 | info!(?exit_code, outcome = outcome.as_str_name(), "Command complete"); |
4366 | | |
4367 | 46 | let maybe_error_override = if let Some(side_channel_file0 ) = maybe_side_channel_file { |
4368 | 0 | process_side_channel_file(side_channel_file.clone(), &args, requested_timeout).await |
4369 | 0 | .err_tip(|| format!("Error processing side channel file: {}", side_channel_file.display()))? |
4370 | | } else { |
4371 | 46 | None |
4372 | | }; |
4373 | 46 | { |
4374 | 46 | let mut state = self.state.lock(); |
4375 | 46 | state.error = Error::merge_option(state.error.take(), maybe_error_override); |
4376 | 46 | |
4377 | 46 | state.command_proto = Some(command_proto); |
4378 | 46 | state.execution_result = Some(RunningActionImplExecutionResult{ |
4379 | 46 | stdout, |
4380 | 46 | stderr, |
4381 | 46 | exit_code, |
4382 | 46 | resource_usage, |
4383 | 46 | }); |
4384 | 46 | state.execution_metadata.execution_completed_timestamp = (self.running_actions_manager.callbacks.now_fn)(); |
4385 | 46 | } |
4386 | 46 | return Ok(self); |
4387 | | }, |
4388 | 58 | _ = &mut kill_channel_rx => { |
4389 | 3 | kill_reason = Some(KillReason::External); |
4390 | 3 | grace_fut = stop_action(&mut child_process_guard, &self.memory_watch, kill_grace, "scheduler kill").await; |
4391 | 3 | { |
4392 | 3 | let mut state = self.state.lock(); |
4393 | 3 | state.error = Error::merge_option(state.error.take(), Some(Error::new( |
4394 | 3 | Code::Aborted, |
4395 | 3 | format!( |
4396 | 3 | "Command '{}' was killed by scheduler", |
4397 | 3 | args.join(OsStr::new(" ")).to_string_lossy() |
4398 | 3 | ) |
4399 | 3 | ))); |
4400 | 3 | } |
4401 | | }, |
4402 | | } |
4403 | | } |
4404 | | // Unreachable. |
4405 | 51 | } |
4406 | | |
4407 | 45 | async fn inner_upload_results(self: Arc<Self>) -> Result<Arc<Self>, Error>0 { |
4408 | | enum OutputType { |
4409 | | None, |
4410 | | File(FileInfo), |
4411 | | Directory(DirectoryInfo), |
4412 | | FileSymlink(SymlinkInfo), |
4413 | | DirectorySymlink(SymlinkInfo), |
4414 | | } |
4415 | | |
4416 | 45 | let upload_start = std::time::Instant::now(); |
4417 | 45 | debug!( |
4418 | 45 | operation_id = ?self.operation_id, |
4419 | | "Worker uploading results - starting", |
4420 | | ); |
4421 | 45 | let (mut command_proto, execution_result, mut execution_metadata) = { |
4422 | 45 | let mut state = self.state.lock(); |
4423 | 45 | state.execution_metadata.output_upload_start_timestamp = |
4424 | 45 | (self.running_actions_manager.callbacks.now_fn)(); |
4425 | | ( |
4426 | 45 | state |
4427 | 45 | .command_proto |
4428 | 45 | .take() |
4429 | 45 | .err_tip(|| "Expected state to have command_proto in execute()")?0 , |
4430 | 45 | state |
4431 | 45 | .execution_result |
4432 | 45 | .take() |
4433 | 45 | .err_tip(|| "Execution result does not exist at upload_results stage")?0 , |
4434 | 45 | state.execution_metadata.clone(), |
4435 | | ) |
4436 | | }; |
4437 | 45 | let cas_store = self.running_actions_manager.cas_store.as_ref(); |
4438 | 45 | let hasher = self.action_info.unique_qualifier.digest_function(); |
4439 | | |
4440 | 45 | let mut output_path_futures: Vec<BoxFuture<'_, Result<OutputType, Error>>> = Vec::new(); |
4441 | 45 | let mut output_paths = command_proto.output_paths; |
4442 | 45 | if output_paths.is_empty() { |
4443 | 25 | output_paths |
4444 | 25 | .reserve(command_proto.output_files.len() + command_proto.output_directories.len()); |
4445 | 25 | output_paths.append(&mut command_proto.output_files); |
4446 | 25 | output_paths.append(&mut command_proto.output_directories); |
4447 | 25 | }20 |
4448 | 45 | let digest_uploaders = Arc::new(Mutex::new(HashMap::new())); |
4449 | 45 | let uploads = Arc::new(Semaphore::new(UPLOAD_CONCURRENCY)); |
4450 | 45 | for entry23 in output_paths { |
4451 | 23 | let full_path = OsString::from(if command_proto.working_directory.is_empty() { |
4452 | 8 | format!("{}/{}", self.work_directory, entry) |
4453 | | } else { |
4454 | 15 | format!( |
4455 | | "{}/{}/{}", |
4456 | 15 | self.work_directory, command_proto.working_directory, entry |
4457 | | ) |
4458 | | }); |
4459 | 23 | let work_directory = &self.work_directory; |
4460 | 23 | let digest_uploaders = digest_uploaders.clone(); |
4461 | 23 | let uploads = uploads.clone(); |
4462 | 23 | output_path_futures.push( |
4463 | 23 | async move { |
4464 | 8 | let metadata = { |
4465 | 23 | let metadata = match fs::symlink_metadata(&full_path).await { |
4466 | 23 | Ok(file) => file, |
4467 | 0 | Err(e) => { |
4468 | 0 | if e.code == Code::NotFound { |
4469 | | // In the event our output does not exist, according to the bazel remote |
4470 | | // execution spec, we simply ignore it continue. |
4471 | 0 | return Result::<OutputType, Error>::Ok(OutputType::None); |
4472 | 0 | } |
4473 | 0 | return Err(e).err_tip(|| { |
4474 | 0 | format!("Could not open file {}", full_path.display()) |
4475 | 0 | }); |
4476 | | } |
4477 | | }; |
4478 | | |
4479 | 23 | if metadata.is_file() { |
4480 | | return Ok(OutputType::File( |
4481 | 15 | upload_file( |
4482 | 15 | cas_store.as_pin(), |
4483 | 15 | &full_path, |
4484 | 15 | hasher, |
4485 | 15 | metadata, |
4486 | 15 | digest_uploaders, |
4487 | 15 | uploads, |
4488 | 15 | ) |
4489 | 15 | .await |
4490 | 15 | .map(|mut file_info| { |
4491 | 15 | file_info.name_or_path = NameOrPath::Path(entry); |
4492 | 15 | file_info |
4493 | 15 | }) |
4494 | 15 | .err_tip(|| format!0 ("Uploading file {}", full_path.display()0 ))?0 , |
4495 | | )); |
4496 | 8 | } |
4497 | 8 | metadata |
4498 | | }; |
4499 | 8 | if metadata.is_dir() { |
4500 | | Ok(OutputType::Directory( |
4501 | 4 | upload_directory( |
4502 | 4 | cas_store.as_pin(), |
4503 | 4 | &full_path, |
4504 | 4 | work_directory, |
4505 | 4 | hasher, |
4506 | 4 | digest_uploaders, |
4507 | 4 | uploads, |
4508 | | ) |
4509 | 4 | .and_then(|(root_dir, children)| async move { |
4510 | 4 | let tree = ProtoTree { |
4511 | 4 | root: Some(root_dir), |
4512 | 4 | children: children.into(), |
4513 | 4 | }; |
4514 | 4 | let tree_digest = serialize_and_upload_message( |
4515 | 4 | &tree, |
4516 | 4 | cas_store.as_pin(), |
4517 | 4 | &mut hasher.hasher(), |
4518 | 4 | ) |
4519 | 4 | .await |
4520 | 4 | .err_tip(|| format!0 ("While processing {entry}"))?0 ; |
4521 | 4 | Ok(DirectoryInfo { |
4522 | 4 | path: entry, |
4523 | 4 | tree_digest, |
4524 | 4 | }) |
4525 | 8 | }) |
4526 | 4 | .await |
4527 | 4 | .err_tip(|| format!0 ("Uploading directory {}", full_path.display()0 ))?0 , |
4528 | | )) |
4529 | 4 | } else if metadata.is_symlink() { |
4530 | | // Resolve the symlink to determine what it points to. |
4531 | | // Symlinks created by DirectoryCache (absolute paths into |
4532 | | // the cache directory) must NOT be uploaded as symlinks — |
4533 | | // the target path is worker-local and meaningless to the |
4534 | | // client. Instead, follow the symlink and upload the |
4535 | | // resolved content (file or directory). |
4536 | 4 | let target = fs::read_link(&full_path).await.err_tip(|| {0 |
4537 | 0 | format!("Reading symlink target for {}", full_path.display()) |
4538 | 0 | })?; |
4539 | 4 | let is_absolute_symlink = Path::new(&target).is_absolute(); |
4540 | | |
4541 | 4 | if is_absolute_symlink { |
4542 | | // Absolute symlink — resolve and upload contents. |
4543 | 3 | match fs::metadata(&full_path).await { |
4544 | 3 | Ok(resolved_meta) => { |
4545 | 3 | if resolved_meta.is_dir() { |
4546 | | // Upload as directory (Tree proto). |
4547 | | Ok(OutputType::Directory( |
4548 | 1 | upload_directory( |
4549 | 1 | cas_store.as_pin(), |
4550 | 1 | &full_path, |
4551 | 1 | work_directory, |
4552 | 1 | hasher, |
4553 | 1 | digest_uploaders, |
4554 | 1 | uploads, |
4555 | | ) |
4556 | 1 | .and_then(|(root_dir, children)| async move { |
4557 | 1 | let tree = ProtoTree { |
4558 | 1 | root: Some(root_dir), |
4559 | 1 | children: children.into(), |
4560 | 1 | }; |
4561 | 1 | let tree_digest = serialize_and_upload_message( |
4562 | 1 | &tree, |
4563 | 1 | cas_store.as_pin(), |
4564 | 1 | &mut hasher.hasher(), |
4565 | 1 | ) |
4566 | 1 | .await |
4567 | 1 | .err_tip(|| format!0 ("While processing {entry}"))?0 ; |
4568 | 1 | Ok(DirectoryInfo { |
4569 | 1 | path: entry, |
4570 | 1 | tree_digest, |
4571 | 1 | }) |
4572 | 2 | }) |
4573 | 1 | .await |
4574 | 1 | .err_tip( |
4575 | 0 | || { |
4576 | 0 | format!( |
4577 | | "Uploading symlinked directory {}", |
4578 | 0 | full_path.display() |
4579 | | ) |
4580 | 0 | }, |
4581 | 0 | )?, |
4582 | | )) |
4583 | | } else { |
4584 | | // Upload as file (follow symlink). |
4585 | | Ok(OutputType::File( |
4586 | 2 | upload_file( |
4587 | 2 | cas_store.as_pin(), |
4588 | 2 | &full_path, |
4589 | 2 | hasher, |
4590 | 2 | resolved_meta, |
4591 | 2 | digest_uploaders, |
4592 | 2 | uploads, |
4593 | 2 | ) |
4594 | 2 | .await |
4595 | 2 | .map(|mut file_info| { |
4596 | 2 | file_info.name_or_path = NameOrPath::Path(entry); |
4597 | 2 | file_info |
4598 | 2 | }) |
4599 | 2 | .err_tip( |
4600 | 0 | || { |
4601 | 0 | format!( |
4602 | | "Uploading symlinked file {}", |
4603 | 0 | full_path.display() |
4604 | | ) |
4605 | 0 | }, |
4606 | 0 | )?, |
4607 | | )) |
4608 | | } |
4609 | | } |
4610 | 0 | Err(e) => { |
4611 | 0 | if e.code != Code::NotFound { |
4612 | 0 | return Err(e).err_tip(|| { |
4613 | 0 | format!( |
4614 | | "While resolving absolute symlink {}", |
4615 | 0 | full_path.display() |
4616 | | ) |
4617 | 0 | }); |
4618 | 0 | } |
4619 | 0 | Ok(OutputType::None) |
4620 | | } |
4621 | | } |
4622 | | } else { |
4623 | | // Relative symlink — action intentionally created it. |
4624 | | // Upload as a proper symlink. |
4625 | 1 | let output_symlink = upload_symlink(&full_path, work_directory) |
4626 | 1 | .await |
4627 | 1 | .map(|mut symlink_info| { |
4628 | 1 | symlink_info.name_or_path = NameOrPath::Path(entry); |
4629 | 1 | symlink_info |
4630 | 1 | }) |
4631 | 1 | .err_tip(|| format!0 ("Uploading symlink {}", full_path.display()0 ))?0 ; |
4632 | 1 | match fs::metadata(&full_path).await { |
4633 | 1 | Ok(metadata) => { |
4634 | 1 | if metadata.is_dir() { |
4635 | 0 | Ok(OutputType::DirectorySymlink(output_symlink)) |
4636 | | } else { |
4637 | | // Note: If it's anything but directory we put it as a file symlink. |
4638 | 1 | Ok(OutputType::FileSymlink(output_symlink)) |
4639 | | } |
4640 | | } |
4641 | 0 | Err(e) => { |
4642 | 0 | if e.code != Code::NotFound { |
4643 | 0 | return Err(e).err_tip(|| { |
4644 | 0 | format!( |
4645 | | "While querying target symlink metadata for {}", |
4646 | 0 | full_path.display() |
4647 | | ) |
4648 | 0 | }); |
4649 | 0 | } |
4650 | | // If the file doesn't exist, we consider it a file. Even though the |
4651 | | // file doesn't exist we still need to populate an entry. |
4652 | 0 | Ok(OutputType::FileSymlink(output_symlink)) |
4653 | | } |
4654 | | } |
4655 | | } |
4656 | | } else { |
4657 | 0 | Err(make_err!( |
4658 | 0 | Code::Internal, |
4659 | 0 | "{full_path:?} was not a file, folder or symlink. Must be one.", |
4660 | 0 | )) |
4661 | | } |
4662 | 23 | } |
4663 | 23 | .boxed(), |
4664 | | ); |
4665 | | } |
4666 | 45 | let mut output_files = vec![]; |
4667 | 45 | let mut output_folders = vec![]; |
4668 | 45 | let mut output_directory_symlinks = vec![]; |
4669 | 45 | let mut output_file_symlinks = vec![]; |
4670 | | |
4671 | 45 | if execution_result.exit_code != 0 { |
4672 | 14 | error!( |
4673 | | exit_code = ?execution_result.exit_code, |
4674 | 14 | stdout = ?log_excerpt(execution_result.stdout.head(), 1000), |
4675 | 14 | stderr = ?log_excerpt(execution_result.stderr.head(), 1000), |
4676 | | command = ?command_proto.arguments, |
4677 | | "Command returned non-zero exit code", |
4678 | | ); |
4679 | 31 | } |
4680 | | |
4681 | 45 | let stdout_digest_fut = self.metrics().upload_stdout.wrap(async { |
4682 | 45 | let start = std::time::Instant::now(); |
4683 | 45 | let data = execution_result.stdout; |
4684 | 45 | let data_len = data.len(); |
4685 | | // Boxed: the upload future is large, and two of them inline |
4686 | | // pushed the debug test binary past its thread stack. |
4687 | 45 | let digest44 = Box::pin(upload_captured_output( |
4688 | 45 | cas_store.as_pin(), |
4689 | 45 | data, |
4690 | 45 | hasher, |
4691 | 45 | "stdout", |
4692 | 45 | )) |
4693 | 45 | .await?0 ; |
4694 | 44 | debug!( |
4695 | | ?digest, |
4696 | | data_len, |
4697 | 44 | elapsed_ms = start.elapsed().as_millis(), |
4698 | | "upload_results: stdout upload completed", |
4699 | | ); |
4700 | 44 | Result::<DigestInfo, Error>::Ok(digest) |
4701 | 44 | }); |
4702 | 45 | let stderr_digest_fut = self.metrics().upload_stderr.wrap(async { |
4703 | 45 | let start = std::time::Instant::now(); |
4704 | 45 | let data = execution_result.stderr; |
4705 | 45 | let data_len = data.len(); |
4706 | | // Boxed: the upload future is large, and two of them inline |
4707 | | // pushed the debug test binary past its thread stack. |
4708 | 45 | let digest44 = Box::pin(upload_captured_output( |
4709 | 45 | cas_store.as_pin(), |
4710 | 45 | data, |
4711 | 45 | hasher, |
4712 | 45 | "stderr", |
4713 | 45 | )) |
4714 | 45 | .await?0 ; |
4715 | 44 | debug!( |
4716 | | ?digest, |
4717 | | data_len, |
4718 | 44 | elapsed_ms = start.elapsed().as_millis(), |
4719 | | "upload_results: stderr upload completed", |
4720 | | ); |
4721 | 44 | Result::<DigestInfo, Error>::Ok(digest) |
4722 | 44 | }); |
4723 | | |
4724 | 45 | debug!( |
4725 | 45 | operation_id = ?self.operation_id, |
4726 | 45 | num_output_paths = output_path_futures.len(), |
4727 | | "upload_results: starting stdout/stderr/output_paths uploads", |
4728 | | ); |
4729 | 45 | let mut output_path_futures = |
4730 | 45 | futures::stream::iter(output_path_futures).buffer_unordered(UPLOAD_CONCURRENCY); |
4731 | 45 | let join_start = std::time::Instant::now(); |
4732 | 45 | let upload_result44 = futures::try_join!(stdout_digest_fut, stderr_digest_fut, async { |
4733 | 68 | while let Some(output_type23 ) = output_path_futures.try_next().await?0 { |
4734 | 23 | match output_type { |
4735 | 17 | OutputType::File(output_file) => output_files.push(output_file), |
4736 | 5 | OutputType::Directory(output_folder) => output_folders.push(output_folder), |
4737 | 1 | OutputType::FileSymlink(output_symlink) => { |
4738 | 1 | output_file_symlinks.push(output_symlink); |
4739 | 1 | } |
4740 | 0 | OutputType::DirectorySymlink(output_symlink) => { |
4741 | 0 | output_directory_symlinks.push(output_symlink); |
4742 | 0 | } |
4743 | 0 | OutputType::None => { /* Safe to ignore */ } |
4744 | | } |
4745 | | } |
4746 | 45 | Ok(()) |
4747 | 45 | }); |
4748 | 44 | drop(output_path_futures); |
4749 | 44 | debug!( |
4750 | 44 | operation_id = ?self.operation_id, |
4751 | 44 | elapsed_ms = join_start.elapsed().as_millis(), |
4752 | 44 | success = upload_result.is_ok(), |
4753 | | "upload_results: all uploads completed", |
4754 | | ); |
4755 | 44 | let resource_usage = execution_result.resource_usage.clone(); |
4756 | 44 | let (stdout_digest, stderr_digest) = match upload_result { |
4757 | 44 | Ok((stdout_digest, stderr_digest, ())) => (stdout_digest, stderr_digest), |
4758 | 0 | Err(e) => return Err(e).err_tip(|| "Error while uploading results"), |
4759 | | }; |
4760 | | |
4761 | 44 | execution_metadata.output_upload_completed_timestamp = |
4762 | 44 | (self.running_actions_manager.callbacks.now_fn)(); |
4763 | 44 | output_files.sort_unstable_by(|a, b| a.name_or_path0 .cmp0 (&b.name_or_path0 )); |
4764 | 44 | output_folders.sort_unstable_by(|a, b| a.path0 .cmp0 (&b.path0 )); |
4765 | 44 | output_file_symlinks.sort_unstable_by(|a, b| a.name_or_path0 .cmp0 (&b.name_or_path0 )); |
4766 | 44 | output_directory_symlinks.sort_unstable_by(|a, b| a.name_or_path0 .cmp0 (&b.name_or_path0 )); |
4767 | 44 | let num_output_files = output_files.len(); |
4768 | 44 | let num_output_folders = output_folders.len(); |
4769 | 44 | { |
4770 | 44 | let mut state = self.state.lock(); |
4771 | 44 | execution_metadata.worker_completed_timestamp = |
4772 | 44 | (self.running_actions_manager.callbacks.now_fn)(); |
4773 | 44 | state.action_result = Some(ActionResult { |
4774 | 44 | output_files, |
4775 | 44 | output_folders, |
4776 | 44 | output_directory_symlinks, |
4777 | 44 | output_file_symlinks, |
4778 | 44 | exit_code: execution_result.exit_code, |
4779 | 44 | stdout_digest, |
4780 | 44 | stderr_digest, |
4781 | 44 | execution_metadata, |
4782 | 44 | server_logs: HashMap::default(), // TODO(palfrey) Not implemented. |
4783 | 44 | error: state.error.clone(), |
4784 | 44 | message: String::new(), // Will be filled in on cache_action_result if needed. |
4785 | 44 | }); |
4786 | 44 | state.resource_usage = resource_usage; |
4787 | 44 | } |
4788 | 44 | debug!( |
4789 | 44 | operation_id = ?self.operation_id, |
4790 | 44 | total_elapsed_ms = upload_start.elapsed().as_millis(), |
4791 | | num_output_files, |
4792 | | num_output_folders, |
4793 | | "upload_results: inner_upload_results completed successfully", |
4794 | | ); |
4795 | 44 | Ok(self) |
4796 | 44 | } |
4797 | | |
4798 | 44 | async fn inner_get_finished_result(self: Arc<Self>) -> Result<ActionResult, Error> { |
4799 | 44 | let mut state = self.state.lock(); |
4800 | 44 | state |
4801 | 44 | .action_result |
4802 | 44 | .take() |
4803 | 44 | .err_tip(|| "Expected action_result to exist in get_finished_result") |
4804 | 44 | } |
4805 | | } |
4806 | | |
4807 | | impl Drop for RunningActionImpl { |
4808 | 278 | fn drop(&mut self) { |
4809 | 278 | if self.did_cleanup.load(Ordering::Acquire) { |
4810 | 276 | if self.has_manager_entry.load(Ordering::Acquire) { |
4811 | 2 | drop( |
4812 | 2 | self.running_actions_manager |
4813 | 2 | .cleanup_action(&self.operation_id), |
4814 | 2 | ); |
4815 | 274 | } |
4816 | 276 | if let Some(input_lease8 ) = self.input_lease.clone() { |
4817 | 8 | background_spawn!( |
4818 | | "running_action_impl_release_input_lease", |
4819 | 8 | input_lease.release() |
4820 | | ); |
4821 | 268 | } |
4822 | 276 | return; |
4823 | 2 | } |
4824 | 2 | let operation_id = self.operation_id.clone(); |
4825 | 2 | error!( |
4826 | | %operation_id, |
4827 | | "RunningActionImpl did not cleanup. This is a violation of the requirements, will attempt to do it in the background." |
4828 | | ); |
4829 | 2 | let running_actions_manager = self.running_actions_manager.clone(); |
4830 | 2 | let action_directory = self.action_directory.clone(); |
4831 | 2 | let input_lease = self.input_lease.clone(); |
4832 | 2 | let input_mounts = self.state.get_mut().input_mounts.take(); |
4833 | 2 | background_spawn!("running_action_impl_drop", async move {1 |
4834 | 1 | let cleanup_result = |
4835 | 1 | do_cleanup(&running_actions_manager, &operation_id, &action_directory).await; |
4836 | 1 | drop(input_mounts); |
4837 | 1 | if let Some(input_lease) = input_lease { |
4838 | 1 | input_lease.release().await; |
4839 | 0 | } |
4840 | 1 | if let Err(err0 ) = cleanup_result { |
4841 | 0 | error!( |
4842 | | %operation_id, |
4843 | | ?action_directory, |
4844 | | ?err, |
4845 | | "Error cleaning up action" |
4846 | | ); |
4847 | 1 | } |
4848 | 1 | }); |
4849 | 278 | } |
4850 | | } |
4851 | | |
4852 | | impl RunningAction for RunningActionImpl { |
4853 | 0 | fn get_operation_id(&self) -> &OperationId { |
4854 | 0 | &self.operation_id |
4855 | 0 | } |
4856 | | |
4857 | 72 | async fn prepare_action(self: Arc<Self>) -> Result<Arc<Self>, Error> { |
4858 | 72 | let download_timeout = self.running_actions_manager.max_download_timeout; |
4859 | 72 | let operation_id = self.operation_id.clone(); |
4860 | 72 | let manager = self.running_actions_manager.clone(); |
4861 | 72 | let metrics = self.metrics().clone(); |
4862 | 72 | let mut kill_rx = self.kill_token.subscribe(); |
4863 | | // The retry loop inside stops short of this; the timeout below is |
4864 | | // the backstop for an attempt that never answers. The retries are |
4865 | | // also bounded by the action's own timeout: the scheduler ends the |
4866 | | // action soon after that passes, whatever the worker is still |
4867 | | // fetching. A window too far out to represent leaves the retries |
4868 | | // unbounded, as the timeout is. |
4869 | 72 | let action_timeout = self.action_info.timeout; |
4870 | 72 | let retry_window = if action_timeout.is_zero() { |
4871 | 64 | download_timeout |
4872 | | } else { |
4873 | 8 | download_timeout.min(action_timeout) |
4874 | | }; |
4875 | 72 | let download_deadline = Instant::now().checked_add(retry_window); |
4876 | | // Boxed: the prepare state machine is large, and pinning it on the |
4877 | | // stack next to the select below overflowed a test thread. |
4878 | 72 | let mut prepare_fut = Box::pin( |
4879 | 72 | metrics |
4880 | 72 | .prepare_action |
4881 | 72 | .wrap(Self::inner_prepare_action(self, download_deadline)), |
4882 | | ); |
4883 | | // A fetch that never answers would otherwise hold the slot for good |
4884 | | // while the worker's keepalives say it is fine: name it every minute |
4885 | | // and give up at the timeout, so the scheduler can retry elsewhere. |
4886 | 72 | let stall_warn_fut = async { |
4887 | 72 | let mut elapsed_secs = 0u64; |
4888 | | loop { |
4889 | 72 | tokio::time::sleep(Duration::from_mins(1)).await; |
4890 | 0 | elapsed_secs += 60; |
4891 | 0 | warn!( |
4892 | | ?operation_id, |
4893 | | elapsed_s = elapsed_secs, |
4894 | 0 | timeout_s = download_timeout.as_secs(), |
4895 | | "prepare_action: still fetching inputs, possible stall", |
4896 | | ); |
4897 | | } |
4898 | | }; |
4899 | 72 | let res = tokio::time::timeout(download_timeout, async { |
4900 | 72 | tokio::pin!(stall_warn_fut); |
4901 | 72 | tokio::select! { |
4902 | 72 | result69 = &mut prepare_fut => result69 , |
4903 | 72 | _ = kill_rx.wait_for(|killed| *killed) => { |
4904 | 2 | warn!(%operation_id, "prepare_action: killed while fetching inputs"); |
4905 | 2 | Err(make_err!( |
4906 | 2 | Code::Aborted, |
4907 | 2 | "Operation {operation_id} was killed while fetching its inputs" |
4908 | 2 | )) |
4909 | | } |
4910 | 72 | () = &mut stall_warn_fut => unreachable!0 (), |
4911 | | } |
4912 | 71 | }) |
4913 | 72 | .await |
4914 | 72 | .map_err(|_| {1 |
4915 | 1 | warn!( |
4916 | | %operation_id, |
4917 | 1 | timeout_s = download_timeout.as_secs(), |
4918 | | "prepare_action: input fetch timed out" |
4919 | | ); |
4920 | 1 | let err = make_err!( |
4921 | 1 | Code::DeadlineExceeded, |
4922 | | "Fetching the inputs of operation {operation_id} took longer than max_download_timeout ({}s)", |
4923 | 1 | download_timeout.as_secs(), |
4924 | | ); |
4925 | 1 | manager.suggest_input_download_pool(&operation_id, &err); |
4926 | 1 | err |
4927 | 1 | }) |
4928 | 72 | .and_then(|res| res); |
4929 | 72 | if let Err(ref e9 ) = res { |
4930 | 9 | warn!(?e, "Error during prepare_action"); |
4931 | 63 | } |
4932 | 72 | res |
4933 | 72 | } |
4934 | | |
4935 | 51 | async fn execute(self: Arc<Self>) -> Result<Arc<Self>, Error> { |
4936 | 51 | let res = self |
4937 | 51 | .metrics() |
4938 | 51 | .clone() |
4939 | 51 | .execute |
4940 | 51 | .wrap(Self::inner_execute(self)) |
4941 | 51 | .await; |
4942 | 51 | if let Err(ref e3 ) = res { |
4943 | 3 | warn!(?e, "Error during prepare_action"); |
4944 | 48 | } |
4945 | 51 | res |
4946 | 51 | } |
4947 | | |
4948 | 45 | async fn upload_results(self: Arc<Self>) -> Result<Arc<Self>, Error>0 { |
4949 | 45 | let upload_timeout = self.running_actions_manager.max_upload_timeout; |
4950 | 45 | let operation_id = self.operation_id.clone(); |
4951 | 45 | info!( |
4952 | | ?operation_id, |
4953 | 45 | upload_timeout_s = upload_timeout.as_secs(), |
4954 | | "upload_results: starting with timeout", |
4955 | | ); |
4956 | 45 | let metrics = self.metrics().clone(); |
4957 | 45 | let kill_token = self.kill_token.clone(); |
4958 | 45 | let upload_fut = metrics |
4959 | 45 | .upload_results |
4960 | 45 | .wrap(Self::inner_upload_results(self)); |
4961 | | |
4962 | 45 | let stall_warn_fut = async {38 |
4963 | 38 | let mut elapsed_secs = 0u64; |
4964 | | loop { |
4965 | 38 | tokio::time::sleep(Duration::from_mins(1)).await; |
4966 | 0 | elapsed_secs += 60; |
4967 | 0 | warn!( |
4968 | | ?operation_id, |
4969 | | elapsed_s = elapsed_secs, |
4970 | 0 | timeout_s = upload_timeout.as_secs(), |
4971 | | "upload_results: still in progress — possible stall", |
4972 | | ); |
4973 | | } |
4974 | | }; |
4975 | | |
4976 | | // Only a kill that arrives during the upload ends it: a kill that |
4977 | | // already ended the command leaves a killed result that still has to |
4978 | | // reach the scheduler, so the token's current state is taken as seen |
4979 | | // and only a change from here on aborts. |
4980 | 45 | let mut kill_rx = kill_token.subscribe(); |
4981 | 45 | kill_rx.borrow_and_update(); |
4982 | 45 | let res = tokio::time::timeout(upload_timeout, async { |
4983 | 45 | tokio::pin!(upload_fut); |
4984 | 45 | tokio::pin!(stall_warn_fut); |
4985 | 45 | tokio::select! { |
4986 | 45 | result44 = &mut upload_fut => result44 , |
4987 | 45 | Ok(()) = kill_rx.changed() => { |
4988 | 1 | warn!(%operation_id, "upload_results: killed while uploading"); |
4989 | 1 | Err(make_err!( |
4990 | 1 | Code::Aborted, |
4991 | 1 | "Operation {operation_id} was killed while uploading its results" |
4992 | 1 | )) |
4993 | | } |
4994 | 45 | () = &mut stall_warn_fut => unreachable!0 (), |
4995 | | } |
4996 | 45 | }) |
4997 | 45 | .await |
4998 | 45 | .map_err(|err| {0 |
4999 | 0 | warn!(%operation_id, timeout=upload_timeout.as_secs(), "Upload results timeout"); |
5000 | 0 | Error::from_std_err(Code::DeadlineExceeded, &err).append(format!( |
5001 | | "Upload results timed out after {}s for operation {:?}", |
5002 | 0 | upload_timeout.as_secs(), |
5003 | | operation_id, |
5004 | | )) |
5005 | 0 | })?; |
5006 | 45 | if let Err(ref e1 ) = res { |
5007 | 1 | warn!(?operation_id, ?e, "Error during upload_results"); |
5008 | 44 | } |
5009 | 45 | res |
5010 | 45 | } |
5011 | | |
5012 | 274 | async fn cleanup(self: Arc<Self>) -> Result<Arc<Self>, Error> { |
5013 | 274 | let res = self |
5014 | 274 | .metrics() |
5015 | 274 | .clone() |
5016 | 274 | .cleanup |
5017 | 274 | .wrap(async move { |
5018 | 274 | let result = do_cleanup( |
5019 | 274 | &self.running_actions_manager, |
5020 | 274 | &self.operation_id, |
5021 | 274 | &self.action_directory, |
5022 | 274 | ) |
5023 | 274 | .await; |
5024 | 274 | self.has_manager_entry.store(false, Ordering::Release); |
5025 | 274 | self.did_cleanup.store(true, Ordering::Release); |
5026 | 274 | self.state.lock().input_mounts.take(); |
5027 | | // The work directory and manager entry are gone before the |
5028 | | // lease is released. If this future is cancelled while the |
5029 | | // release task is finishing, Drop still observes a completed |
5030 | | // cleanup and cannot strand the action in the manager. |
5031 | 274 | if let Some(input_lease8 ) = self.input_lease.clone() { |
5032 | 8 | input_lease.release().await; |
5033 | 266 | } |
5034 | 274 | result.map(move |()| self) |
5035 | 274 | }) |
5036 | 274 | .await; |
5037 | 274 | if let Err(ref e0 ) = res { |
5038 | 0 | warn!(?e, "Error during cleanup"); |
5039 | 274 | } |
5040 | 274 | res |
5041 | 274 | } |
5042 | | |
5043 | 44 | async fn get_finished_result(self: Arc<Self>) -> Result<ActionResult, Error>0 { |
5044 | 44 | self.metrics() |
5045 | 44 | .clone() |
5046 | 44 | .get_finished_result |
5047 | 44 | .wrap(Self::inner_get_finished_result(self)) |
5048 | 44 | .await |
5049 | 44 | } |
5050 | | |
5051 | 8 | fn resource_usage(&self) -> Option<ActionResourceUsage> { |
5052 | 8 | self.state.lock().resource_usage.clone() |
5053 | 8 | } |
5054 | | |
5055 | 20 | fn get_work_directory(&self) -> &String { |
5056 | 20 | &self.work_directory |
5057 | 20 | } |
5058 | | } |
5059 | | |
5060 | | pub trait RunningActionsManager: Sync + Send + Sized + Unpin + 'static { |
5061 | | type RunningAction: RunningAction; |
5062 | | |
5063 | | fn create_and_add_action( |
5064 | | self: &Arc<Self>, |
5065 | | worker_id: String, |
5066 | | start_execute: StartExecute, |
5067 | | ) -> impl Future<Output = Result<Arc<Self::RunningAction>, Error>> + Send; |
5068 | | |
5069 | | fn cache_action_result( |
5070 | | &self, |
5071 | | action_digest: DigestInfo, |
5072 | | action_result: &mut ActionResult, |
5073 | | hasher: DigestHasherFunc, |
5074 | | ) -> impl Future<Output = Result<(), Error>> + Send; |
5075 | | |
5076 | | fn kill_all(&self) -> impl Future<Output = ()> + Send; |
5077 | | |
5078 | | fn kill_operation( |
5079 | | &self, |
5080 | | operation_id: &OperationId, |
5081 | | ) -> impl Future<Output = Result<(), Error>> + Send; |
5082 | | |
5083 | | fn metrics(&self) -> &Arc<Metrics>; |
5084 | | |
5085 | | /// The highest memory sampled so far for each running operation that |
5086 | | /// has been sampled, as `(operation id, KiB)`, for the keepalive |
5087 | | /// (RFC-0018). Empty where nothing samples. |
5088 | 68.9k | fn memory_peaks(&self) -> Vec<(String, u64)> { |
5089 | 68.9k | Vec::new() |
5090 | 68.9k | } |
5091 | | } |
5092 | | |
5093 | | /// A function to get the current system time, used to allow mocking for tests |
5094 | | type NowFn = fn() -> SystemTime; |
5095 | | type SleepFn = fn(Duration) -> BoxFuture<'static, ()>; |
5096 | | |
5097 | | /// Functions that may be injected for testing purposes, during standard control |
5098 | | /// flows these are specified by the new function. |
5099 | | #[derive(Clone, Copy)] |
5100 | | pub struct Callbacks { |
5101 | | /// A function that gets the current time. |
5102 | | pub now_fn: NowFn, |
5103 | | /// A function that sleeps for a given Duration. |
5104 | | pub sleep_fn: SleepFn, |
5105 | | } |
5106 | | |
5107 | | impl Debug for Callbacks { |
5108 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
5109 | 0 | f.debug_struct("Callbacks").finish_non_exhaustive() |
5110 | 0 | } |
5111 | | } |
5112 | | |
5113 | | /// The set of additional information for executing an action over and above |
5114 | | /// those given in the `ActionInfo` passed to the worker. This allows |
5115 | | /// modification of the action for execution on this particular worker. This |
5116 | | /// may be used to run the action with a particular set of additional |
5117 | | /// environment variables, or perhaps configure it to execute within a |
5118 | | /// container. |
5119 | | /// What the worker holds an action to beyond the scheduler's ledger. Each |
5120 | | /// axis is on or off on its own; the value exists when at least one is on. |
5121 | | #[derive(Debug, Clone)] |
5122 | | pub struct ResourceEnforcement { |
5123 | | /// Soft memory enforcement: the worker kills an action whose process |
5124 | | /// group grows past its reservation plus headroom. Linux only; the |
5125 | | /// sampler that measures it does not exist elsewhere. An action run |
5126 | | /// through a persistent worker is not covered: that process outlives |
5127 | | /// the action and serves others, so its memory is no one action's |
5128 | | /// reservation, and only the pod's limit bounds it. |
5129 | | pub memory: Option<MemoryLimit>, |
5130 | | /// Refuse an action whose disk reservation, under this property, is |
5131 | | /// more than the free space under the work directory less what the |
5132 | | /// actions already admitted reserved, and measure what the action |
5133 | | /// writes. |
5134 | | pub disk_property_name: Option<String>, |
5135 | | /// Kill an action whose own files pass the disk reservation plus |
5136 | | /// `disk_headroom_percent`. |
5137 | | pub disk_soft: bool, |
5138 | | pub disk_headroom_percent: u64, |
5139 | | /// Kill the action holding the most memory when the worker's cgroup |
5140 | | /// stays under memory pressure; `None` leaves a thrashing worker alone. |
5141 | | pub pressure_kill: Option<PressureKill>, |
5142 | | } |
5143 | | |
5144 | | /// When sustained memory pressure on the worker's cgroup kills an action. |
5145 | | #[derive(Debug, Clone, Copy)] |
5146 | | pub struct PressureKill { |
5147 | | /// `memory.pressure` `full avg10` at or above this value (0 to 100) counts. |
5148 | | pub full_avg10_percent: u64, |
5149 | | /// For this long without a break. |
5150 | | pub after: Duration, |
5151 | | /// The largest memory reservation the worker advertises; an action |
5152 | | /// holding it is never the victim. `None` falls back to the cgroup limit. |
5153 | | pub largest_reservation_kb: Option<u64>, |
5154 | | } |
5155 | | |
5156 | | /// The reservation a memory ceiling is built from. |
5157 | | #[derive(Debug, Clone)] |
5158 | | pub struct MemoryLimit { |
5159 | | /// Platform property carrying the reservation in KiB. |
5160 | | pub property_name: String, |
5161 | | /// Percent above the reservation the action may reach. |
5162 | | pub headroom_percent: u64, |
5163 | | } |
5164 | | |
5165 | | impl ResourceEnforcement { |
5166 | | /// From the worker's configuration. `None` when neither axis is on; |
5167 | | /// `disk: guard` alone is honoured as much as `memory: soft` alone. |
5168 | 3 | pub fn from_config(config: &ResourceEnforcementConfig) -> Option<Self> { |
5169 | 3 | let memory = match config.memory { |
5170 | 2 | MemoryEnforcement::None => None, |
5171 | 1 | MemoryEnforcement::Soft => Some(MemoryLimit { |
5172 | 1 | property_name: config.memory_property_name.clone(), |
5173 | 1 | headroom_percent: config.memory_headroom_percent, |
5174 | 1 | }), |
5175 | | }; |
5176 | 3 | let disk_property_name = |
5177 | 3 | (config.disk != DiskEnforcement::None).then(|| config.disk_property_name1 .clone1 ()); |
5178 | 3 | if config.memory_pressure_kill_percent > 100 { |
5179 | 0 | warn!( |
5180 | | memory_pressure_kill_percent = config.memory_pressure_kill_percent, |
5181 | | "memory_pressure_kill_percent is over 100; using 100" |
5182 | | ); |
5183 | 3 | } |
5184 | 3 | if config.memory_pressure_kill_percent > 0 && config.memory_pressure_kill_after_s == 00 { |
5185 | 0 | warn!("memory_pressure_kill_after_s is 0; using 1"); |
5186 | 3 | } |
5187 | 3 | let pressure_kill = (config.memory_pressure_kill_percent > 0).then(|| PressureKill { |
5188 | 0 | full_avg10_percent: config.memory_pressure_kill_percent.min(100), |
5189 | 0 | after: Duration::from_secs(config.memory_pressure_kill_after_s.max(1)), |
5190 | 0 | largest_reservation_kb: None, |
5191 | 0 | }); |
5192 | 3 | if pressure_kill.is_some() && memory0 .is_none0 () { |
5193 | 0 | warn!( |
5194 | | "memory_pressure_kill_percent is set with memory: none; the pressure kill needs memory: soft and stays off" |
5195 | | ); |
5196 | 3 | } |
5197 | 3 | let pressure_kill = pressure_kill.filter(|_| memory0 .is_some0 ()); |
5198 | 3 | (memory.is_some() || disk_property_name2 .is_some2 ()).then_some(Self { |
5199 | 3 | memory, |
5200 | 3 | disk_property_name, |
5201 | 3 | disk_soft: config.disk == DiskEnforcement::Soft, |
5202 | 3 | disk_headroom_percent: config.disk_headroom_percent, |
5203 | 3 | pressure_kill, |
5204 | 3 | }) |
5205 | 3 | } |
5206 | | } |
5207 | | |
5208 | | /// Free space in KiB on the filesystem holding `path`, as an unprivileged |
5209 | | /// process may use it. |
5210 | | #[cfg(target_family = "unix")] |
5211 | 6 | pub fn free_disk_kb(path: &str) -> Option<u64> { |
5212 | 6 | let c_path = std::ffi::CString::new(path).ok()?0 ; |
5213 | 6 | let mut stat: libc::statvfs = unsafe { core::mem::zeroed() }; |
5214 | | // SAFETY: `c_path` is a valid C string and `stat` is a zeroed out |
5215 | | // parameter the call fills in. |
5216 | 6 | let rc = unsafe { libc::statvfs(c_path.as_ptr(), &raw mut stat) }; |
5217 | 6 | if rc != 0 { |
5218 | 0 | return None; |
5219 | 6 | } |
5220 | 6 | Some(widen(stat.f_bavail).saturating_mul(widen(stat.f_frsize)) / 1024) |
5221 | 6 | } |
5222 | | |
5223 | | /// `statvfs` fields are u32 or u64 depending on the platform. |
5224 | | #[cfg(target_family = "unix")] |
5225 | 12 | fn widen(value: impl Into<u64>) -> u64 { |
5226 | 12 | value.into() |
5227 | 12 | } |
5228 | | |
5229 | | #[cfg(not(target_family = "unix"))] |
5230 | | pub fn free_disk_kb(_path: &str) -> Option<u64> { |
5231 | | None |
5232 | | } |
5233 | | |
5234 | | #[derive(Debug, Default)] |
5235 | | pub struct ExecutionConfiguration { |
5236 | | /// Bytes of stdout or stderr kept in memory before the rest spills to a |
5237 | | /// file under the action directory; 0 keeps everything in memory. |
5238 | | pub max_captured_output_bytes: u64, |
5239 | | /// Kill an action that exceeds its memory reservation, before the |
5240 | | /// pod's cgroup limit takes the whole worker with it. |
5241 | | pub resource_enforcement: Option<ResourceEnforcement>, |
5242 | | /// Time a timed-out or cancelled action gets between SIGTERM and |
5243 | | /// SIGKILL to write its own cleanup; zero sends SIGKILL at once. |
5244 | | pub kill_grace: Duration, |
5245 | | /// Point `TMPDIR` at a directory of the action's own, removed with it. |
5246 | | pub set_tmpdir: bool, |
5247 | | /// How many input blobs the worker fetches into its store at once, |
5248 | | /// across every action it is staging; 0 shares no pool, and each action |
5249 | | /// fetches on its own. |
5250 | | pub max_concurrent_input_downloads: usize, |
5251 | | /// Buck2-only helper, configured for a dedicated execution container. |
5252 | | pub buck2_file_capture: Option<Buck2FileCaptureConfig>, |
5253 | | /// If set, will be executed instead of the first argument passed in the |
5254 | | /// `ActionInfo` with all of the arguments in the `ActionInfo` passed as |
5255 | | /// arguments to this command. |
5256 | | pub entrypoint: Option<String>, |
5257 | | /// The only environment variables that will be specified when the command |
5258 | | /// executes other than those in the `ActionInfo`. On Windows, `SystemRoot` |
5259 | | /// and PATH are also assigned (see `inner_execute`). |
5260 | | pub additional_environment: Option<HashMap<String, EnvironmentSource>>, |
5261 | | /// Bazel persistent workers: whether an action that supports them runs |
5262 | | /// in a pooled process, and how the pool is sized. |
5263 | | pub persistent_workers: PersistentWorkersSettings, |
5264 | | } |
5265 | | |
5266 | | /// See `ExecutionConfiguration::persistent_workers`. |
5267 | | #[derive(Debug, Clone, Copy)] |
5268 | | pub struct PersistentWorkersSettings { |
5269 | | pub enabled: bool, |
5270 | | pub pool: PoolConfig, |
5271 | | } |
5272 | | |
5273 | | impl Default for PersistentWorkersSettings { |
5274 | 0 | fn default() -> Self { |
5275 | 0 | Self { |
5276 | 0 | enabled: true, |
5277 | 0 | pool: PoolConfig::default(), |
5278 | 0 | } |
5279 | 0 | } |
5280 | | } |
5281 | | |
5282 | | #[derive(Debug)] |
5283 | | struct UploadActionResults { |
5284 | | upload_ac_results_strategy: UploadCacheResultsStrategy, |
5285 | | upload_historical_results_strategy: UploadCacheResultsStrategy, |
5286 | | ac_store: Option<Store>, |
5287 | | historical_store: Store, |
5288 | | success_message_template: Template, |
5289 | | failure_message_template: Template, |
5290 | | } |
5291 | | |
5292 | | impl UploadActionResults { |
5293 | 82 | fn new( |
5294 | 82 | config: &UploadActionResultConfig, |
5295 | 82 | ac_store: Option<Store>, |
5296 | 82 | historical_store: Store, |
5297 | 82 | ) -> Result<Self, Error> { |
5298 | 82 | let upload_historical_results_strategy = config |
5299 | 82 | .upload_historical_results_strategy |
5300 | 82 | .unwrap_or(DEFAULT_HISTORICAL_RESULTS_STRATEGY); |
5301 | 8 | if !matches!( |
5302 | 82 | config.upload_ac_results_strategy, |
5303 | | UploadCacheResultsStrategy::Never |
5304 | 8 | ) && ac_store.is_none() |
5305 | | { |
5306 | 0 | return Err(make_input_err!( |
5307 | 0 | "upload_ac_results_strategy is set, but no ac_store is configured" |
5308 | 0 | )); |
5309 | 82 | } |
5310 | | Ok(Self { |
5311 | 82 | upload_ac_results_strategy: config.upload_ac_results_strategy, |
5312 | 82 | upload_historical_results_strategy, |
5313 | 82 | ac_store, |
5314 | 82 | historical_store, |
5315 | 82 | success_message_template: Template::new(&config.success_message_template).map_err( |
5316 | 0 | |e| { |
5317 | 0 | Error::from_std_err(Code::InvalidArgument, &e).append(format!( |
5318 | | "Could not convert success_message_template to rust template: {}", |
5319 | | config.success_message_template |
5320 | | )) |
5321 | 0 | }, |
5322 | 0 | )?, |
5323 | 82 | failure_message_template: Template::new(&config.failure_message_template).map_err( |
5324 | 0 | |e| { |
5325 | 0 | Error::from_std_err(Code::InvalidArgument, &e).append(format!( |
5326 | | "Could not convert failure_message_template to rust template: {}", |
5327 | | config.success_message_template |
5328 | | )) |
5329 | 0 | }, |
5330 | 0 | )?, |
5331 | | }) |
5332 | 82 | } |
5333 | | |
5334 | 0 | const fn should_cache_result( |
5335 | 0 | strategy: UploadCacheResultsStrategy, |
5336 | 0 | action_result: &ActionResult, |
5337 | 0 | treat_infra_error_as_failure: bool, |
5338 | 0 | ) -> bool { |
5339 | 0 | let did_fail = action_result.exit_code != 0 |
5340 | 0 | || (treat_infra_error_as_failure && action_result.error.is_some()); |
5341 | 0 | match strategy { |
5342 | 0 | UploadCacheResultsStrategy::SuccessOnly => !did_fail, |
5343 | 0 | UploadCacheResultsStrategy::Never => false, |
5344 | | // Never cache internal errors or timeouts. |
5345 | | UploadCacheResultsStrategy::Everything => { |
5346 | 0 | treat_infra_error_as_failure || action_result.error.is_none() |
5347 | | } |
5348 | 0 | UploadCacheResultsStrategy::FailuresOnly => did_fail, |
5349 | | } |
5350 | 0 | } |
5351 | | |
5352 | | /// Formats the message field in `ExecuteResponse` from the `success_message_template` |
5353 | | /// or `failure_message_template` config templates. |
5354 | 5 | fn format_execute_response_message( |
5355 | 5 | mut template_str: Template, |
5356 | 5 | action_digest_info: DigestInfo, |
5357 | 5 | maybe_historical_digest_info: Option<DigestInfo>, |
5358 | 5 | hasher: DigestHasherFunc, |
5359 | 5 | ) -> Result<String, Error> { |
5360 | 5 | template_str.replace( |
5361 | | "digest_function", |
5362 | 5 | hasher.proto_digest_func().as_str_name().to_lowercase(), |
5363 | | ); |
5364 | 5 | template_str.replace( |
5365 | | "action_digest_hash", |
5366 | 5 | action_digest_info.packed_hash().to_string(), |
5367 | | ); |
5368 | 5 | template_str.replace("action_digest_size", action_digest_info.size_bytes()); |
5369 | 5 | if let Some(historical_digest_info3 ) = maybe_historical_digest_info { |
5370 | 3 | template_str.replace( |
5371 | 3 | "historical_results_hash", |
5372 | 3 | format!("{}", historical_digest_info.packed_hash()), |
5373 | 3 | ); |
5374 | 3 | template_str.replace( |
5375 | 3 | "historical_results_size", |
5376 | 3 | historical_digest_info.size_bytes(), |
5377 | 3 | ); |
5378 | 3 | } else { |
5379 | 2 | template_str.replace("historical_results_hash", ""); |
5380 | 2 | template_str.replace("historical_results_size", ""); |
5381 | 2 | } |
5382 | 5 | template_str.text().map_err(|e| {0 |
5383 | 0 | Error::from_std_err(Code::InvalidArgument, &e) |
5384 | 0 | .append("Could not convert template to text") |
5385 | 0 | }) |
5386 | 5 | } |
5387 | | |
5388 | 5 | async fn upload_ac_results( |
5389 | 5 | &self, |
5390 | 5 | action_digest: DigestInfo, |
5391 | 5 | action_result: ProtoActionResult, |
5392 | 5 | hasher: DigestHasherFunc, |
5393 | 5 | ) -> Result<(), Error> { |
5394 | 5 | let Some(ac_store) = self.ac_store.as_ref() else { |
5395 | 0 | return Ok(()); |
5396 | | }; |
5397 | | // If we are a GrpcStore we shortcut here, as this is a special store. |
5398 | 5 | if let Some(grpc_store0 ) = ac_store.downcast_ref::<GrpcStore>(Some(action_digest.into())) { |
5399 | 0 | let update_action_request = UpdateActionResultRequest { |
5400 | 0 | // This is populated by `update_action_result`. |
5401 | 0 | instance_name: String::new(), |
5402 | 0 | action_digest: Some(action_digest.into()), |
5403 | 0 | action_result: Some(action_result), |
5404 | 0 | results_cache_policy: None, |
5405 | 0 | digest_function: hasher.proto_digest_func().into(), |
5406 | 0 | }; |
5407 | 0 | return grpc_store |
5408 | 0 | .update_action_result(Request::new(update_action_request)) |
5409 | 0 | .await |
5410 | 0 | .map(|_| ()) |
5411 | 0 | .err_tip(|| "Caching ActionResult"); |
5412 | 5 | } |
5413 | | |
5414 | 5 | let mut store_data = BytesMut::with_capacity(ESTIMATED_DIGEST_SIZE); |
5415 | 5 | action_result |
5416 | 5 | .encode(&mut store_data) |
5417 | 5 | .err_tip(|| "Encoding ActionResult for caching")?0 ; |
5418 | | |
5419 | 5 | ac_store |
5420 | 5 | .update_oneshot(action_digest, store_data.split().freeze()) |
5421 | 5 | .await |
5422 | 5 | .err_tip(|| "Caching ActionResult") |
5423 | 5 | } |
5424 | | |
5425 | 3 | async fn upload_historical_results_with_message( |
5426 | 3 | &self, |
5427 | 3 | action_digest: DigestInfo, |
5428 | 3 | execute_response: ExecuteResponse, |
5429 | 3 | message_template: Template, |
5430 | 3 | hasher: DigestHasherFunc, |
5431 | 3 | ) -> Result<String, Error> { |
5432 | 3 | let historical_digest_info = serialize_and_upload_message( |
5433 | 3 | &HistoricalExecuteResponse { |
5434 | 3 | action_digest: Some(action_digest.into()), |
5435 | 3 | execute_response: Some(execute_response.clone()), |
5436 | 3 | }, |
5437 | 3 | self.historical_store.as_pin(), |
5438 | 3 | &mut hasher.hasher(), |
5439 | 3 | ) |
5440 | 3 | .await |
5441 | 3 | .err_tip(|| format!0 ("Caching HistoricalExecuteResponse for digest: {action_digest}"))?0 ; |
5442 | | |
5443 | 3 | Self::format_execute_response_message( |
5444 | 3 | message_template, |
5445 | 3 | action_digest, |
5446 | 3 | Some(historical_digest_info), |
5447 | 3 | hasher, |
5448 | | ) |
5449 | 3 | .err_tip(|| "Could not format message in upload_historical_results_with_message") |
5450 | 3 | } |
5451 | | |
5452 | 6 | async fn cache_action_result( |
5453 | 6 | &self, |
5454 | 6 | action_info: DigestInfo, |
5455 | 6 | action_result: &mut ActionResult, |
5456 | 6 | hasher: DigestHasherFunc, |
5457 | 6 | ) -> Result<(), Error> { |
5458 | 6 | let should_upload_historical_results = |
5459 | 6 | Self::should_cache_result(self.upload_historical_results_strategy, action_result, true); |
5460 | 6 | let should_upload_ac_results = |
5461 | 6 | Self::should_cache_result(self.upload_ac_results_strategy, action_result, false); |
5462 | | // Shortcut so we don't need to convert to proto if not needed. |
5463 | 6 | if !should_upload_ac_results && !should_upload_historical_results1 { |
5464 | 1 | return Ok(()); |
5465 | 5 | } |
5466 | | |
5467 | 5 | let mut execute_response = to_execute_response(action_result.clone()); |
5468 | | |
5469 | | // In theory exit code should always be != 0 if there's an error, but for safety we |
5470 | | // catch both. |
5471 | 5 | let message_template = if action_result.exit_code == 0 && action_result.error4 .is_none4 () { |
5472 | 3 | self.success_message_template.clone() |
5473 | | } else { |
5474 | 2 | self.failure_message_template.clone() |
5475 | | }; |
5476 | | |
5477 | 5 | let upload_historical_results_with_message_result = if should_upload_historical_results { |
5478 | 3 | let maybe_message = self |
5479 | 3 | .upload_historical_results_with_message( |
5480 | 3 | action_info, |
5481 | 3 | execute_response.clone(), |
5482 | 3 | message_template, |
5483 | 3 | hasher, |
5484 | 3 | ) |
5485 | 3 | .await; |
5486 | 3 | match maybe_message { |
5487 | 3 | Ok(message) => { |
5488 | 3 | action_result.message.clone_from(&message); |
5489 | 3 | execute_response.message = message; |
5490 | 3 | Ok(()) |
5491 | | } |
5492 | 0 | Err(e) => Result::<(), Error>::Err(e), |
5493 | | } |
5494 | | } else { |
5495 | 2 | match Self::format_execute_response_message(message_template, action_info, None, hasher) |
5496 | | { |
5497 | 2 | Ok(message) => { |
5498 | 2 | action_result.message.clone_from(&message); |
5499 | 2 | execute_response.message = message; |
5500 | 2 | Ok(()) |
5501 | | } |
5502 | 0 | Err(e) => Err(e).err_tip(|| "Could not format message in cache_action_result"), |
5503 | | } |
5504 | | }; |
5505 | | |
5506 | | // Note: Done in this order because we assume most results will succeed and most configs will |
5507 | | // either always upload upload historical results or only upload on failure. In which case |
5508 | | // we can avoid an extra clone of the protos by doing this last with the above assumption. |
5509 | 5 | let ac_upload_results = if should_upload_ac_results { |
5510 | 5 | self.upload_ac_results( |
5511 | 5 | action_info, |
5512 | 5 | execute_response |
5513 | 5 | .result |
5514 | 5 | .err_tip(|| "No result set in cache_action_result")?0 , |
5515 | 5 | hasher, |
5516 | | ) |
5517 | 5 | .await |
5518 | | } else { |
5519 | 0 | Ok(()) |
5520 | | }; |
5521 | 5 | upload_historical_results_with_message_result.merge(ac_upload_results) |
5522 | 6 | } |
5523 | | } |
5524 | | |
5525 | | #[cfg(target_os = "linux")] |
5526 | | #[derive(Copy, Clone, Debug)] |
5527 | | pub enum UseNamespaces { |
5528 | | No, |
5529 | | Yes, |
5530 | | /// Also unshare the mount namespace. With `isolate_tmp` each action gets |
5531 | | /// a private `/tmp`, its own tmp directory bound over `/tmp`, on top of |
5532 | | /// the masked root action directory. |
5533 | | YesAndMount { |
5534 | | isolate_tmp: bool, |
5535 | | }, |
5536 | | } |
5537 | | |
5538 | | #[derive(Debug)] |
5539 | | pub struct RunningActionsManagerArgs<'a> { |
5540 | | pub root_action_directory: String, |
5541 | | pub execution_configuration: ExecutionConfiguration, |
5542 | | pub cas_store: Arc<FastSlowStore>, |
5543 | | pub ac_store: Option<Store>, |
5544 | | pub historical_store: Store, |
5545 | | pub upload_action_result_config: &'a UploadActionResultConfig, |
5546 | | pub max_action_timeout: Duration, |
5547 | | pub max_upload_timeout: Duration, |
5548 | | pub max_download_timeout: Duration, |
5549 | | pub max_cleanup_wait: Duration, |
5550 | | pub max_cleanup_backoff: Duration, |
5551 | | pub timeout_handled_externally: bool, |
5552 | | pub directory_cache: Option<Arc<crate::directory_cache::DirectoryCache>>, |
5553 | | /// When true, every digest in an active action's input Merkle closure is |
5554 | | /// leased in the locally eviction-managed CAS tiers until the action's |
5555 | | /// cleanup completes. While leases are held, those tiers may temporarily |
5556 | | /// exceed their configured `max_bytes` / `max_count` eviction limits. |
5557 | | pub active_input_leases: bool, |
5558 | | #[cfg(target_os = "linux")] |
5559 | | pub readonly_input_mounts: Vec<String>, |
5560 | | #[cfg(target_os = "linux")] |
5561 | | pub use_namespaces: UseNamespaces, |
5562 | | } |
5563 | | |
5564 | | /// The mark that an operation's directory is being cleaned; dropping it |
5565 | | /// clears the mark and wakes whoever waits for it. |
5566 | | #[derive(Debug)] |
5567 | | pub struct CleanupGuard { |
5568 | | manager: Weak<RunningActionsManagerImpl>, |
5569 | | operation_id: OperationId, |
5570 | | } |
5571 | | |
5572 | | impl Drop for CleanupGuard { |
5573 | 563 | fn drop(&mut self) { |
5574 | 563 | let Some(manager) = self.manager.upgrade() else { |
5575 | 0 | return; |
5576 | | }; |
5577 | 563 | let mut cleaning = manager.cleaning_up_operations.lock(); |
5578 | 563 | cleaning.remove(&self.operation_id); |
5579 | 563 | manager.cleanup_complete_notify.notify_waiters(); |
5580 | 563 | } |
5581 | | } |
5582 | | |
5583 | | /// Holds state info about what is being executed and the interface for interacting |
5584 | | /// with actions while they are running. |
5585 | | #[derive(Debug)] |
5586 | | pub struct RunningActionsManagerImpl { |
5587 | | root_action_directory: String, |
5588 | | execution_configuration: ExecutionConfiguration, |
5589 | | cas_store: Arc<FastSlowStore>, |
5590 | | filesystem_store: Arc<FilesystemStore>, |
5591 | | upload_action_results: UploadActionResults, |
5592 | | max_action_timeout: Duration, |
5593 | | max_upload_timeout: Duration, |
5594 | | max_download_timeout: Duration, |
5595 | | timeout_handled_externally: bool, |
5596 | | /// The container's memory limit, the yardstick for calling a SIGKILL |
5597 | | /// nobody here sent an OOM kill. |
5598 | | pod_memory_limit_kb: Option<u64>, |
5599 | | #[cfg(target_os = "linux")] |
5600 | | use_namespaces: UseNamespaces, |
5601 | | /// The process table every action's resource-usage sampler shares. |
5602 | | process_table: Arc<ProcessTableCache>, |
5603 | | running_actions: Mutex<HashMap<OperationId, Weak<RunningActionImpl>>>, |
5604 | | /// Set once the memory pressure monitor has been started; it starts |
5605 | | /// with the first action, so a manager that never runs one never |
5606 | | /// reads the cgroup. |
5607 | | pressure_monitor_started: AtomicBool, |
5608 | | // Note: We don't use Notify because we need to support a .wait_for()-like function, which |
5609 | | // Notify does not support. |
5610 | | action_done_tx: watch::Sender<()>, |
5611 | | callbacks: Callbacks, |
5612 | | metrics: Arc<Metrics>, |
5613 | | /// Disk each admitted action reserved, by operation, so the disk guard |
5614 | | /// checks against what is left after them. An entry lives from the |
5615 | | /// guard passing to `cleanup_action`. |
5616 | | disk_reservations_kb: Mutex<HashMap<OperationId, u64>>, |
5617 | | /// Track operations being cleaned up to avoid directory collisions during action retries. |
5618 | | /// When an action fails and is retried on the same worker, we need to ensure the previous |
5619 | | /// attempt's directory is fully cleaned up before creating a new one. |
5620 | | /// See: <https://github.com/TraceMachina/nativelink/issues/1859> |
5621 | | cleaning_up_operations: Mutex<HashSet<OperationId>>, |
5622 | | max_cleanup_wait: Duration, |
5623 | | max_cleanup_backoff: Duration, |
5624 | | /// Notify waiters when a cleanup operation completes. This is used in conjunction with |
5625 | | /// `cleaning_up_operations` to coordinate directory cleanup and creation. |
5626 | | cleanup_complete_notify: Arc<Notify>, |
5627 | | /// Optional directory cache for improving performance by caching reconstructed |
5628 | | /// input directories and using hardlinks. |
5629 | | directory_cache: Option<Arc<crate::directory_cache::DirectoryCache>>, |
5630 | | /// Whether active action inputs are leased against eviction in the local |
5631 | | /// CAS tiers (opt-in via `experimental_active_input_leases`). |
5632 | | active_input_leases: bool, |
5633 | | /// The pool every input fetch draws a permit from, shared by all the |
5634 | | /// actions this worker stages; see |
5635 | | /// `ExecutionConfiguration::max_concurrent_input_downloads`. Without |
5636 | | /// one configured it is too large to bound anything. |
5637 | | input_downloads: Semaphore, |
5638 | | /// Set while no pool is configured, to suggest one when fetches show |
5639 | | /// the store behind them struggling. |
5640 | | input_pool_hint: Option<InputPoolHint>, |
5641 | | #[cfg(target_os = "linux")] |
5642 | | readonly_input_mounts: Vec<String>, |
5643 | | persistent_worker_pool: PersistentWorkerPool, |
5644 | | /// Retires idle persistent workers; ends with the manager. |
5645 | | _persistent_worker_sweeper: JoinHandleDropGuard<()>, |
5646 | | buck2_captures: Mutex<HashMap<OperationId, Buck2FileCapture>>, |
5647 | | } |
5648 | | |
5649 | | impl RunningActionsManagerImpl { |
5650 | | /// Permits free in the input fetch pool right now; with nothing staging, |
5651 | | /// the pool's size, which is `Semaphore::MAX_PERMITS` without one. |
5652 | 2 | pub fn free_input_downloads(&self) -> usize { |
5653 | 2 | self.input_downloads.available_permits() |
5654 | 2 | } |
5655 | | |
5656 | | /// Suggests `max_concurrent_input_downloads` when this worker fetches |
5657 | | /// without a shared pool and a fetch just failed or stalled the way an |
5658 | | /// overloaded store makes it. `DataLoss` is retried as transient but |
5659 | | /// means a truncated or corrupt stored copy, not a struggling store, so |
5660 | | /// it suggests nothing. |
5661 | 21 | fn suggest_input_download_pool(&self, operation_id: &OperationId, error: &Error) { |
5662 | 21 | let Some(hint19 ) = &self.input_pool_hint else { |
5663 | 2 | return; |
5664 | | }; |
5665 | 19 | if error.code != Code::DataLoss && hint14 .due14 (Instant::now14 ()) { |
5666 | 7 | warn!( |
5667 | | %operation_id, |
5668 | | %error, |
5669 | | "Input fetches are failing or stalling while max_concurrent_input_downloads is unset: every action this worker stages fetches its inputs on its own, and several cold actions at once can overload the CAS and the object store behind it. Consider setting max_concurrent_input_downloads to 64 so they share one pool of fetches (logged at most every 10 minutes)" |
5670 | | ); |
5671 | 12 | } |
5672 | 21 | } |
5673 | | |
5674 | 82 | pub fn new_with_callbacks( |
5675 | 82 | args: RunningActionsManagerArgs<'_>, |
5676 | 82 | callbacks: Callbacks, |
5677 | 82 | ) -> Result<Self, Error> { |
5678 | | #[cfg(target_os = "linux")] |
5679 | | { |
5680 | 82 | crate::input_mounts::validate_paths(&args.readonly_input_mounts)?0 ; |
5681 | 82 | if !args.readonly_input_mounts.is_empty() |
5682 | 1 | && (!matches!0 (args.use_namespaces, UseNamespaces::YesAndMount { .. }) |
5683 | 1 | || args.directory_cache.is_none()) |
5684 | | { |
5685 | 0 | return Err(make_err!( |
5686 | 0 | Code::InvalidArgument, |
5687 | 0 | "Read-only input mounts require Linux mount namespaces and a directory cache" |
5688 | 0 | )); |
5689 | 82 | } |
5690 | | } |
5691 | | // Sadly because of some limitations of how Any works we need to clone more times than optimal. |
5692 | 82 | let filesystem_store = args |
5693 | 82 | .cas_store |
5694 | 82 | .fast_store() |
5695 | 82 | .downcast_ref::<FilesystemStore>(None) |
5696 | 82 | .err_tip( |
5697 | | || "Expected FilesystemStore store for .fast_store() in RunningActionsManagerImpl", |
5698 | 0 | )? |
5699 | 82 | .get_arc() |
5700 | 82 | .err_tip(|| "FilesystemStore's internal Arc was lost")?0 ; |
5701 | 82 | let (action_done_tx, _) = watch::channel(()); |
5702 | 82 | let persistent_worker_pool = |
5703 | 82 | PersistentWorkerPool::new(args.execution_configuration.persistent_workers.pool); |
5704 | 82 | let persistent_worker_sweeper = persistent_worker_pool.spawn_sweeper(); |
5705 | 82 | let max_downloads = args.execution_configuration.max_concurrent_input_downloads; |
5706 | 82 | let input_downloads = input_download_pool(max_downloads); |
5707 | 82 | let input_pool_hint = (max_downloads == 0).then(InputPoolHint::default); |
5708 | | Ok(Self { |
5709 | 82 | root_action_directory: args.root_action_directory, |
5710 | 82 | execution_configuration: args.execution_configuration, |
5711 | 82 | input_downloads, |
5712 | 82 | input_pool_hint, |
5713 | 82 | cas_store: args.cas_store, |
5714 | 82 | filesystem_store, |
5715 | 82 | upload_action_results: UploadActionResults::new( |
5716 | 82 | args.upload_action_result_config, |
5717 | 82 | args.ac_store, |
5718 | 82 | args.historical_store, |
5719 | | ) |
5720 | 82 | .err_tip(|| "During RunningActionsManagerImpl construction")?0 , |
5721 | 82 | max_action_timeout: args.max_action_timeout, |
5722 | 82 | pod_memory_limit_kb: pod_memory_limit_kb(), |
5723 | 82 | max_upload_timeout: args.max_upload_timeout, |
5724 | 82 | max_download_timeout: args.max_download_timeout, |
5725 | 82 | timeout_handled_externally: args.timeout_handled_externally, |
5726 | 82 | running_actions: Mutex::new(HashMap::new()), |
5727 | 82 | pressure_monitor_started: AtomicBool::new(false), |
5728 | 82 | action_done_tx, |
5729 | 82 | callbacks, |
5730 | 82 | metrics: Arc::new(Metrics { |
5731 | 82 | directory_cache: args.directory_cache.as_ref().map(Arc::downgrade), |
5732 | 82 | ..Default::default() |
5733 | 82 | }), |
5734 | 82 | disk_reservations_kb: Mutex::new(HashMap::new()), |
5735 | 82 | cleaning_up_operations: Mutex::new(HashSet::new()), |
5736 | 82 | max_cleanup_wait: args.max_cleanup_wait, |
5737 | 82 | max_cleanup_backoff: args.max_cleanup_backoff, |
5738 | 82 | cleanup_complete_notify: Arc::new(Notify::new()), |
5739 | 82 | directory_cache: args.directory_cache, |
5740 | 82 | active_input_leases: args.active_input_leases, |
5741 | | #[cfg(target_os = "linux")] |
5742 | 82 | readonly_input_mounts: args.readonly_input_mounts, |
5743 | 82 | persistent_worker_pool, |
5744 | 82 | _persistent_worker_sweeper: persistent_worker_sweeper, |
5745 | 82 | buck2_captures: Mutex::new(HashMap::new()), |
5746 | | #[cfg(target_os = "linux")] |
5747 | 82 | use_namespaces: args.use_namespaces, |
5748 | 82 | process_table: Arc::new(ProcessTableCache::new(RESOURCE_USAGE_SAMPLE_INTERVAL)), |
5749 | | }) |
5750 | 82 | } |
5751 | | |
5752 | 60 | pub fn new(args: RunningActionsManagerArgs<'_>) -> Result<Self, Error> { |
5753 | 60 | Self::new_with_callbacks( |
5754 | 60 | args, |
5755 | | Callbacks { |
5756 | 60 | now_fn: SystemTime::now, |
5757 | 29 | sleep_fn: |duration| Box::pin(tokio::time::sleep(duration)), |
5758 | | }, |
5759 | | ) |
5760 | 60 | } |
5761 | | |
5762 | | /// Fixes a race condition that occurs when an action fails to execute on a worker, and the same worker |
5763 | | /// attempts to re-execute the same action before the physical cleanup (file is removed) completes. |
5764 | | /// See this issue for additional details: <https://github.com/TraceMachina/nativelink/issues/1859> |
5765 | 0 | async fn wait_for_cleanup_if_needed( |
5766 | 0 | self: &Arc<Self>, |
5767 | 0 | operation_id: &OperationId, |
5768 | 279 | ) -> Result<(), Error> { |
5769 | 279 | let start = Instant::now(); |
5770 | 279 | let mut backoff = Duration::from_millis(10); |
5771 | 279 | let mut has_waited = false; |
5772 | | |
5773 | | loop { |
5774 | | // The mark is held for the removal below, so it cannot race the |
5775 | | // orphan sweep's removal of the same tree; while someone else |
5776 | | // holds it, this waits. |
5777 | 284 | let mark = self.perform_cleanup(operation_id.clone()); |
5778 | 284 | if let Some(_cleaning_guard279 ) = mark { |
5779 | 279 | let action_is_running = self |
5780 | 279 | .running_actions |
5781 | 279 | .lock() |
5782 | 279 | .get(operation_id) |
5783 | 279 | .and_then(Weak::upgrade) |
5784 | 279 | .is_some(); |
5785 | 279 | if action_is_running { |
5786 | 1 | return Err(make_err!( |
5787 | 1 | Code::AlreadyExists, |
5788 | 1 | "Action with operation_id {} is already running", |
5789 | 1 | operation_id |
5790 | 1 | )); |
5791 | 278 | } |
5792 | | |
5793 | 278 | let dir_path = |
5794 | 278 | PathBuf::from(&self.root_action_directory).join(operation_id.to_string()); |
5795 | | |
5796 | 278 | if !dir_path.exists() { |
5797 | 276 | return Ok(()); |
5798 | 2 | } |
5799 | | |
5800 | | // Safety check: ensure we're only removing directories under root_action_directory |
5801 | 2 | let root_path = Path::new(&self.root_action_directory); |
5802 | 2 | let canonical_root = root_path.canonicalize().err_tip(|| {0 |
5803 | 0 | format!( |
5804 | | "Failed to canonicalize root directory: {}", |
5805 | 0 | self.root_action_directory |
5806 | | ) |
5807 | 0 | })?; |
5808 | 2 | let canonical_dir = dir_path.canonicalize().err_tip(|| {0 |
5809 | 0 | format!("Failed to canonicalize directory: {}", dir_path.display()) |
5810 | 0 | })?; |
5811 | | |
5812 | 2 | if !canonical_dir.starts_with(&canonical_root) { |
5813 | 0 | return Err(make_err!( |
5814 | 0 | Code::Internal, |
5815 | 0 | "Attempted to remove directory outside of root_action_directory: {}", |
5816 | 0 | dir_path.display() |
5817 | 0 | )); |
5818 | 2 | } |
5819 | | |
5820 | | // Directory exists but not being cleaned - remove it |
5821 | 2 | warn!( |
5822 | | "Removing stale directory for {}: {}", |
5823 | | operation_id, |
5824 | 2 | dir_path.display() |
5825 | | ); |
5826 | 2 | self.metrics.stale_removals.inc(); |
5827 | | |
5828 | | // Try to remove the directory, with one retry on failure |
5829 | 2 | let remove_result = fs::remove_dir_all(&dir_path).await; |
5830 | 2 | if let Err(e0 ) = remove_result { |
5831 | | // Retry once after a short delay in case the directory is temporarily locked |
5832 | 0 | tokio::time::sleep(Duration::from_millis(100)).await; |
5833 | 0 | fs::remove_dir_all(&dir_path).await.err_tip(|| { |
5834 | 0 | format!( |
5835 | | "Failed to remove stale directory {} for retry of {} after retry (original error: {})", |
5836 | 0 | dir_path.display(), |
5837 | | operation_id, |
5838 | | e |
5839 | | ) |
5840 | 0 | })?; |
5841 | 2 | } |
5842 | 2 | return Ok(()); |
5843 | 5 | } |
5844 | | |
5845 | 5 | if start.elapsed() > self.max_cleanup_wait { |
5846 | 0 | self.metrics.cleanup_wait_timeouts.inc(); |
5847 | 0 | warn!(%operation_id, waited=?start.elapsed(), "Timeout waiting for previous operation cleanup"); |
5848 | 0 | return Err(make_err!( |
5849 | 0 | Code::DeadlineExceeded, |
5850 | 0 | "Timeout waiting for previous operation cleanup: {} (waited {:?})", |
5851 | 0 | operation_id, |
5852 | 0 | start.elapsed() |
5853 | 0 | )); |
5854 | 5 | } |
5855 | | |
5856 | 5 | if !has_waited { |
5857 | 1 | self.metrics.cleanup_waits.inc(); |
5858 | 1 | has_waited = true; |
5859 | 4 | } |
5860 | | |
5861 | 5 | trace!( |
5862 | | "Waiting for cleanup of {} (elapsed: {:?}, backoff: {:?})", |
5863 | | operation_id, |
5864 | 5 | start.elapsed(), |
5865 | | backoff |
5866 | | ); |
5867 | | |
5868 | 5 | tokio::select! { |
5869 | 5 | () = self.cleanup_complete_notify.notified() => {}1 , |
5870 | 5 | () = tokio::time::sleep(backoff) => { |
5871 | 4 | // Exponential backoff |
5872 | 4 | backoff = (backoff * 2).min(self.max_cleanup_backoff); |
5873 | 4 | }, |
5874 | | } |
5875 | | } |
5876 | 279 | } |
5877 | | |
5878 | 278 | fn make_action_directory<'a>( |
5879 | 278 | &'a self, |
5880 | 278 | operation_id: &'a OperationId, |
5881 | 278 | ) -> impl Future<Output = Result<String, Error>> + 'a { |
5882 | 278 | self.metrics.make_action_directory.wrap(async move { |
5883 | 278 | let action_directory = format!("{}/{}", self.root_action_directory, operation_id); |
5884 | 278 | fs::create_dir(&action_directory) |
5885 | 278 | .await |
5886 | 278 | .err_tip(|| format!0 ("Error creating action directory {action_directory}"))?0 ; |
5887 | 278 | Ok(action_directory) |
5888 | 278 | }) |
5889 | 278 | } |
5890 | | |
5891 | | /// Reads the Action, again after a transient store failure while |
5892 | | /// `max_download_timeout` leaves room for another attempt. It is the one |
5893 | | /// store read before `prepare_action`'s own retries: a CAS pod being |
5894 | | /// replaced refused it on the first attempt, the action failed on the |
5895 | | /// spot, and the scheduler counted that against the action's retries. |
5896 | 280 | async fn fetch_action_with_retry(&self, action_digest: DigestInfo) -> Result<Action, Error>0 { |
5897 | 280 | let started = Instant::now(); |
5898 | 280 | let deadline = started.checked_add(self.max_download_timeout); |
5899 | 280 | let mut attempt: u32 = 1; |
5900 | 280 | let mut retries = PrepareRetries::new(record_prepare_outcome); |
5901 | | loop { |
5902 | 284 | let err5 = match get_and_decode_digest::<Action>( |
5903 | 284 | self.cas_store.as_ref(), |
5904 | 284 | action_digest.into(), |
5905 | | ) |
5906 | 284 | .await |
5907 | | { |
5908 | 279 | Ok(action) => { |
5909 | 279 | if attempt > 1 { |
5910 | 1 | info!( |
5911 | | %action_digest, |
5912 | | attempt, |
5913 | 1 | elapsed_s = started.elapsed().as_secs(), |
5914 | | "start_action: action read recovered" |
5915 | | ); |
5916 | 278 | } |
5917 | 279 | retries.settle("recovered"); |
5918 | 279 | return Ok(action); |
5919 | | } |
5920 | 5 | Err(err) if is_transient_fetch_error(&err) => err, |
5921 | 0 | Err(err) => { |
5922 | 0 | retries.settle("failed"); |
5923 | 0 | return Err(err); |
5924 | | } |
5925 | | }; |
5926 | 5 | let pause = prepare_retry_pause(attempt, &action_digest); |
5927 | 5 | if deadline.is_some_and(|deadline| Instant::now() + pause >= deadline) { |
5928 | 1 | retries.settle("exhausted"); |
5929 | 1 | return Err(err |
5930 | 1 | .append(format!( |
5931 | 1 | "Reading action {action_digest} failed {attempt} time(s) over {}s; the next retry would start after max_download_timeout", |
5932 | 1 | started.elapsed().as_secs() |
5933 | 1 | )) |
5934 | 1 | .append(STORE_UNREACHABLE_ERROR_TIP)); |
5935 | 4 | } |
5936 | 4 | warn!( |
5937 | | %action_digest, |
5938 | | attempt, |
5939 | 4 | wait_ms = pause.as_millis(), |
5940 | | error = %err, |
5941 | | "start_action: action read failed, retrying" |
5942 | | ); |
5943 | 4 | retries.retried(); |
5944 | 4 | tokio::time::sleep(pause).await; |
5945 | 4 | attempt += 1; |
5946 | | } |
5947 | 280 | } |
5948 | | |
5949 | 280 | fn create_action_info( |
5950 | 280 | &self, |
5951 | 280 | start_execute: StartExecute, |
5952 | 280 | queued_timestamp: SystemTime, |
5953 | 280 | ) -> impl Future<Output = Result<ActionInfo, Error>> + '_ { |
5954 | 280 | self.metrics.create_action_info.wrap(async move { |
5955 | 280 | let execute_request = start_execute |
5956 | 280 | .execute_request |
5957 | 280 | .err_tip(|| "Expected execute_request to exist in StartExecute")?0 ; |
5958 | 280 | let action_digest: DigestInfo = execute_request |
5959 | 280 | .action_digest |
5960 | 280 | .clone() |
5961 | 280 | .err_tip(|| "Expected action_digest to exist on StartExecute")?0 |
5962 | 280 | .try_into()?0 ; |
5963 | 280 | let load_start_timestamp = (self.callbacks.now_fn)(); |
5964 | 280 | let action279 = self |
5965 | 280 | .fetch_action_with_retry(action_digest) |
5966 | 280 | .await |
5967 | 280 | .err_tip(|| "During start_action") |
5968 | 280 | .map_err(tag_missing_input)?1 ; |
5969 | 279 | let mut action_info = ActionInfo::try_from_action_and_execute_request( |
5970 | 279 | execute_request, |
5971 | 279 | action, |
5972 | 279 | load_start_timestamp, |
5973 | 279 | queued_timestamp, |
5974 | | ) |
5975 | 279 | .err_tip(|| "Could not create ActionInfo in create_and_add_action()")?0 ; |
5976 | | // With enforcement on, the scheduler's view of the properties |
5977 | | // replaces the client's: a hint or cold-start reservation the |
5978 | | // scheduler placed this action by only exists there, and what |
5979 | | // is enforced (and exported to the environment) has to be the |
5980 | | // number the ledger charged. Off, the worker keeps reading the |
5981 | | // client's copy as it always did, so an upgrade changes no |
5982 | | // action's environment on its own. |
5983 | 279 | if self.execution_configuration.resource_enforcement.is_some() |
5984 | 10 | && let Some(platform) = start_execute.platform |
5985 | | { |
5986 | 10 | for property in platform.properties { |
5987 | 10 | action_info |
5988 | 10 | .platform_properties |
5989 | 10 | .insert(property.name, property.value); |
5990 | 10 | } |
5991 | 269 | } |
5992 | 279 | Ok(action_info) |
5993 | 280 | }) |
5994 | 280 | } |
5995 | | |
5996 | | /// Removes action directories under the root that no running action |
5997 | | /// owns and that have not been touched for `max_cleanup_wait`. A |
5998 | | /// cleanup that failed (the store was down, the process restarted |
5999 | | /// mid-removal) leaves its directory behind, and it counts against the |
6000 | | /// pod's disk until something removes it. A directory that will not go |
6001 | | /// is logged and counted and the sweep carries on, or one stubborn |
6002 | | /// entry would stop every sweep at the same place. Returns how many |
6003 | | /// were removed; the error is for the listing itself failing. |
6004 | | /// |
6005 | | /// A directory is named for its operation, and a retry of that |
6006 | | /// operation may be starting at any moment. The retry's |
6007 | | /// `wait_for_cleanup_if_needed` waits while the operation is marked as |
6008 | | /// cleaning, so the sweep takes that mark for the removal and reads |
6009 | | /// ownership and age under it; a mark already held is the action's own |
6010 | | /// cleanup, which finishes the job. |
6011 | 5 | pub async fn sweep_orphaned_directories(self: &Arc<Self>) -> Result<usize, Error> { |
6012 | 5 | let (_permit, dir_handle) = fs::read_dir(&self.root_action_directory) |
6013 | 5 | .await |
6014 | 5 | .err_tip(|| format!0 ("Reading {} for orphans", self.root_action_directory0 ))?0 |
6015 | 5 | .into_inner(); |
6016 | 5 | let mut dir_stream = ReadDirStream::new(dir_handle); |
6017 | 5 | let now = SystemTime::now(); |
6018 | 5 | let mut removed = 0; |
6019 | 12 | while let Some(entry7 ) = dir_stream.next().await { |
6020 | 7 | let entry = entry.err_tip(|| "Iterating action root for orphans")?0 ; |
6021 | 7 | let Some(name) = entry.file_name().to_str().map(str::to_owned) else { |
6022 | 0 | continue; |
6023 | | }; |
6024 | | // A running action's directory is not the sweep's to touch, and |
6025 | | // its cleanup waits on the mark, so the mark is taken only for |
6026 | | // an unowned directory and given back at once if the directory |
6027 | | // became owned in between: an action that starts and ends |
6028 | | // inside that window then cleans up as usual, not later. |
6029 | 13 | let is_owned7 = || { |
6030 | 13 | self.running_actions |
6031 | 13 | .lock() |
6032 | 13 | .keys() |
6033 | 13 | .any(|operation_id| operation_id.to_string()0 == name0 ) |
6034 | 13 | }; |
6035 | 7 | if is_owned() { |
6036 | 0 | continue; |
6037 | 7 | } |
6038 | 7 | let Some(_cleaning_guard6 ) = self.perform_cleanup(OperationId::from(name.as_str())) |
6039 | | else { |
6040 | 1 | continue; |
6041 | | }; |
6042 | 6 | if is_owned() { |
6043 | 0 | continue; |
6044 | 6 | } |
6045 | 6 | let path = entry.path(); |
6046 | | // Not followed: a symlink under the root is not an action |
6047 | | // directory, whatever it points at. |
6048 | 6 | let Ok(metadata) = fs::symlink_metadata(&path).await else { |
6049 | 0 | continue; |
6050 | | }; |
6051 | 6 | if !metadata.is_dir() { |
6052 | 3 | continue; |
6053 | 3 | } |
6054 | | // Young directories may belong to an action between creation and |
6055 | | // registration; only settled ones are orphans. |
6056 | 3 | let age = metadata |
6057 | 3 | .modified() |
6058 | 3 | .ok() |
6059 | 3 | .and_then(|modified| now.duration_since(modified).ok()) |
6060 | 3 | .unwrap_or(Duration::MAX); |
6061 | 3 | if age < self.max_cleanup_wait { |
6062 | 0 | continue; |
6063 | 3 | } |
6064 | 3 | warn!(path = %path.display(), age_secs = age.as_secs(), "Removing orphaned action directory"); |
6065 | 3 | match fs::remove_dir_all(&path).await { |
6066 | 3 | Ok(()) => { |
6067 | 3 | self.metrics.orphan_removals.inc(); |
6068 | 3 | removed += 1; |
6069 | 3 | } |
6070 | 0 | Err(err) => { |
6071 | 0 | self.metrics.orphan_removal_failures.inc(); |
6072 | 0 | warn!(path = %path.display(), ?err, "Could not remove orphaned action directory"); |
6073 | | } |
6074 | | } |
6075 | | } |
6076 | 5 | Ok(removed) |
6077 | 5 | } |
6078 | | |
6079 | 277 | fn cleanup_action(&self, operation_id: &OperationId) -> Result<(), Error> { |
6080 | | // The guard must be dropped before notifying: `send_modify` takes |
6081 | | // the watch channel's internal lock, and a `wait_for` closure that |
6082 | | // takes `running_actions` runs while holding that same lock, so |
6083 | | // notifying while holding `running_actions` is an ABBA deadlock |
6084 | | // (issue #2672). There is no lost wakeup in the gap: the removal |
6085 | | // happens-before the notify. |
6086 | 277 | let result = { |
6087 | 277 | let mut running_actions = self.running_actions.lock(); |
6088 | 277 | running_actions.remove(operation_id).err_tip(|| {0 |
6089 | 0 | format!( |
6090 | | "Expected operation id '{operation_id}' to exist in RunningActionsManagerImpl" |
6091 | | ) |
6092 | 0 | }) |
6093 | | }; |
6094 | 277 | self.disk_reservations_kb.lock().remove(operation_id); |
6095 | | // No need to copy anything, we just are telling the receivers an event happened. |
6096 | 277 | self.action_done_tx.send_modify(|()| {}); |
6097 | 277 | result.map(|_| ()) |
6098 | 277 | } |
6099 | | |
6100 | | // Note: We do not capture metrics on this call, only `.kill_all()`. |
6101 | | // Important: When the future returns the process may still be running. |
6102 | 210 | async fn kill_operation(action: Arc<RunningActionImpl>) { |
6103 | 210 | warn!( |
6104 | 210 | operation_id = ?action.operation_id, |
6105 | | "Sending kill to running operation", |
6106 | | ); |
6107 | | // The token first: a phase waiting on it (a fetch, an upload) ends |
6108 | | // now, whether or not the command ever started. |
6109 | 210 | action.kill_token.send_replace(true); |
6110 | 210 | let kill_channel_tx = { |
6111 | 210 | let mut action_state = action.state.lock(); |
6112 | 210 | action_state.kill_channel_tx.take() |
6113 | | }; |
6114 | 210 | if let Some(kill_channel_tx209 ) = kill_channel_tx |
6115 | 209 | && kill_channel_tx.send(()).is_err() |
6116 | | { |
6117 | 4 | error!( |
6118 | 4 | operation_id = ?action.operation_id, |
6119 | | "Error sending kill to running operation", |
6120 | | ); |
6121 | 206 | } |
6122 | 210 | } |
6123 | | |
6124 | | /// Marks the operation as being cleaned and returns the guard that |
6125 | | /// clears the mark, or `None` when another cleanup already holds it. |
6126 | | /// |
6127 | | /// The guard is built only after the lock is released: its `Drop` takes |
6128 | | /// the same lock, so a guard made under it and thrown away (as |
6129 | | /// `then_some` did, whatever the insert said) deadlocked the thread on |
6130 | | /// its own mutex and, one by one, every runtime thread that then came to |
6131 | | /// clean up or start an action, while the worker's keepalives kept it |
6132 | | /// looking alive. The orphan sweep made the double mark routine: it marks |
6133 | | /// every directory it visits, and a directory whose action is mid-cleanup |
6134 | | /// is already marked. |
6135 | 571 | pub fn perform_cleanup(self: &Arc<Self>, operation_id: OperationId) -> Option<CleanupGuard> { |
6136 | 571 | let inserted = self |
6137 | 571 | .cleaning_up_operations |
6138 | 571 | .lock() |
6139 | 571 | .insert(operation_id.clone()); |
6140 | 571 | inserted.then(|| CleanupGuard { |
6141 | 563 | manager: Arc::downgrade(self), |
6142 | 563 | operation_id, |
6143 | 563 | }) |
6144 | 571 | } |
6145 | | |
6146 | | /// Takes the cleanup mark, waiting for whoever holds it to let go. The |
6147 | | /// wait is armed before the mark is tried, so a release in between is |
6148 | | /// not missed. |
6149 | 275 | pub async fn take_cleanup_mark(self: &Arc<Self>, operation_id: OperationId) -> CleanupGuard { |
6150 | | loop { |
6151 | 276 | let notified = self.cleanup_complete_notify.notified(); |
6152 | 276 | tokio::pin!(notified); |
6153 | 276 | notified.as_mut().enable(); |
6154 | 276 | if let Some(guard275 ) = self.perform_cleanup(operation_id.clone()) { |
6155 | 275 | return guard; |
6156 | 1 | } |
6157 | 1 | notified.await; |
6158 | | } |
6159 | 275 | } |
6160 | | } |
6161 | | |
6162 | | impl RunningActionsManagerImpl { |
6163 | | /// Starts the memory pressure monitor once, when a pressure kill is |
6164 | | /// configured. It holds a weak reference, so it ends with the manager. |
6165 | 280 | fn ensure_memory_pressure_monitor(self: &Arc<Self>) { |
6166 | 280 | let Some(policy3 ) = self |
6167 | 280 | .execution_configuration |
6168 | 280 | .resource_enforcement |
6169 | 280 | .as_ref() |
6170 | 280 | .and_then(|enforcement| enforcement.pressure_kill) |
6171 | | else { |
6172 | 277 | return; |
6173 | | }; |
6174 | 3 | if self |
6175 | 3 | .pressure_monitor_started |
6176 | 3 | .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) |
6177 | 3 | .is_err() |
6178 | | { |
6179 | 0 | return; |
6180 | 3 | } |
6181 | | #[cfg(target_os = "linux")] |
6182 | | { |
6183 | 3 | let manager = Arc::downgrade(self); |
6184 | 3 | background_spawn!( |
6185 | | "memory_pressure_monitor", |
6186 | 3 | memory_pressure_monitor(manager, policy) |
6187 | | ); |
6188 | | } |
6189 | | #[cfg(not(target_os = "linux"))] |
6190 | | { |
6191 | | let _ = policy; |
6192 | | warn!( |
6193 | | "memory_pressure_kill_percent is set, but memory.pressure is a Linux cgroup v2 file; the pressure kill stays off" |
6194 | | ); |
6195 | | } |
6196 | 280 | } |
6197 | | } |
6198 | | |
6199 | | /// Reads the worker cgroup's `memory.pressure` every few seconds and, once |
6200 | | /// its `full avg10` has stayed at or above the policy's percent for the |
6201 | | /// policy's duration, asks the running action with the largest last sample |
6202 | | /// to end (see [`PressureWindow`] and [`pick_pressure_victim`]). The kill |
6203 | | /// reaches the scheduler as a memory kill, so the action comes back with a |
6204 | | /// larger reservation instead of thrashing this worker until it is evicted. |
6205 | | /// Ends when the manager is dropped, or at once when no `memory.pressure` |
6206 | | /// is readable. |
6207 | | #[cfg(target_os = "linux")] |
6208 | 3 | async fn memory_pressure_monitor(manager: Weak<RunningActionsManagerImpl>, policy: PressureKill) { |
6209 | 3 | let Some(path0 ) = crate::capacity::memory_pressure_file() else { |
6210 | 3 | warn!( |
6211 | | "no readable memory.pressure for this worker's cgroup (cgroup v2 only); the memory pressure kill stays off" |
6212 | | ); |
6213 | 3 | return; |
6214 | | }; |
6215 | 0 | info!( |
6216 | 0 | path = %path.display(), |
6217 | | full_avg10_percent = policy.full_avg10_percent, |
6218 | 0 | after_s = policy.after.as_secs(), |
6219 | | "Memory pressure kill armed" |
6220 | | ); |
6221 | 0 | let mut window = PressureWindow::new(policy.full_avg10_percent, policy.after); |
6222 | | loop { |
6223 | 0 | tokio::time::sleep(MEMORY_PRESSURE_SAMPLE_INTERVAL).await; |
6224 | 0 | let Some(manager) = manager.upgrade() else { |
6225 | 0 | return; |
6226 | | }; |
6227 | 0 | let Some(full_avg10) = std::fs::read_to_string(&path) |
6228 | 0 | .ok() |
6229 | 0 | .and_then(|text| crate::capacity::parse_pressure_full_avg10(&text)) |
6230 | | else { |
6231 | 0 | continue; |
6232 | | }; |
6233 | 0 | if !window.observe(full_avg10, Instant::now()) { |
6234 | 0 | continue; |
6235 | 0 | } |
6236 | 0 | let running: Vec<Arc<RunningActionImpl>> = manager |
6237 | 0 | .running_actions |
6238 | 0 | .lock() |
6239 | 0 | .values() |
6240 | 0 | .filter_map(Weak::upgrade) |
6241 | 0 | .collect(); |
6242 | 0 | let Some(victim) = pick_pressure_victim( |
6243 | 0 | running.iter().map(|action| PressureCandidate { |
6244 | 0 | item: action, |
6245 | 0 | last_kb: action.memory_watch.last_memory_kb(), |
6246 | 0 | reserved_kb: action.memory_watch.reserved_memory_kb(), |
6247 | 0 | kill_requested: action.memory_watch.kill_requested(), |
6248 | 0 | }), |
6249 | 0 | policy |
6250 | 0 | .largest_reservation_kb |
6251 | 0 | .or(manager.pod_memory_limit_kb), |
6252 | | ) else { |
6253 | 0 | warn!( |
6254 | | full_avg10, |
6255 | 0 | running = running.len(), |
6256 | | "Worker under sustained memory pressure with no sampled action to kill" |
6257 | | ); |
6258 | 0 | continue; |
6259 | | }; |
6260 | 0 | warn!( |
6261 | 0 | operation_id = ?victim.operation_id, |
6262 | | full_avg10, |
6263 | 0 | observed_kb = victim.memory_watch.last_memory_kb(), |
6264 | 0 | reserved_kb = victim.memory_watch.reserved_memory_kb(), |
6265 | 0 | running = running.len(), |
6266 | | "Worker under sustained memory pressure; killing the action furthest over its reservation, or holding the most memory" |
6267 | | ); |
6268 | 0 | victim.request_memory_pressure_kill(whole_percent(full_avg10)); |
6269 | | } |
6270 | 3 | } |
6271 | | |
6272 | | impl RunningActionsManager for RunningActionsManagerImpl { |
6273 | | type RunningAction = RunningActionImpl; |
6274 | | |
6275 | 0 | async fn create_and_add_action( |
6276 | 0 | self: &Arc<Self>, |
6277 | 0 | worker_id: String, |
6278 | 0 | start_execute: StartExecute, |
6279 | 280 | ) -> Result<Arc<RunningActionImpl>, Error> { |
6280 | 280 | self.ensure_memory_pressure_monitor(); |
6281 | 280 | self.metrics |
6282 | 280 | .create_and_add_action |
6283 | 280 | .wrap(async move { |
6284 | 280 | let capture_metadata = start_execute.request_metadata.clone(); |
6285 | 280 | let capture_worker = worker_id.clone(); |
6286 | 280 | let capture_digest = start_execute.execute_request.as_ref() |
6287 | 280 | .and_then(|request| request.action_digest.as_ref()) |
6288 | 280 | .map(|digest| digest.hash.clone()).unwrap_or_default(); |
6289 | 280 | let queued_timestamp = start_execute |
6290 | 280 | .queued_timestamp |
6291 | 280 | .and_then(|time| time216 .try_into216 ().ok216 ()) |
6292 | 280 | .unwrap_or(SystemTime::UNIX_EPOCH); |
6293 | 280 | let operation_id = start_execute |
6294 | 280 | .operation_id.as_str().into(); |
6295 | 280 | let action_info279 = self.create_action_info(start_execute, queued_timestamp).await?1 ; |
6296 | 279 | debug!( |
6297 | | ?action_info, |
6298 | | "Worker received action", |
6299 | | ); |
6300 | | // Wait for any previous cleanup to complete before creating directory |
6301 | 279 | self.wait_for_cleanup_if_needed(&operation_id).await?1 ; |
6302 | 278 | let action_directory = self.make_action_directory(&operation_id).await?0 ; |
6303 | 278 | let execution_metadata = ExecutionMetadata { |
6304 | 278 | worker: worker_id, |
6305 | 278 | queued_timestamp: action_info.insert_timestamp, |
6306 | 278 | worker_start_timestamp: action_info.load_timestamp, |
6307 | 278 | worker_completed_timestamp: SystemTime::UNIX_EPOCH, |
6308 | 278 | input_fetch_start_timestamp: SystemTime::UNIX_EPOCH, |
6309 | 278 | input_fetch_completed_timestamp: SystemTime::UNIX_EPOCH, |
6310 | 278 | execution_start_timestamp: SystemTime::UNIX_EPOCH, |
6311 | 278 | execution_completed_timestamp: SystemTime::UNIX_EPOCH, |
6312 | 278 | output_upload_start_timestamp: SystemTime::UNIX_EPOCH, |
6313 | 278 | output_upload_completed_timestamp: SystemTime::UNIX_EPOCH, |
6314 | 278 | }; |
6315 | 278 | let timeout = if action_info.timeout.is_zero() || self.timeout_handled_externally8 { |
6316 | 270 | self.max_action_timeout |
6317 | | } else { |
6318 | 8 | action_info.timeout |
6319 | | }; |
6320 | | // REAPI lets a server clamp a timeout over its maximum; the |
6321 | | // client asked for more time, not for a rejection. |
6322 | 278 | let timeout = if timeout > self.max_action_timeout { |
6323 | 1 | info!( |
6324 | | ?operation_id, |
6325 | 1 | requested_s = timeout.as_secs_f32(), |
6326 | 1 | max_s = self.max_action_timeout.as_secs_f32(), |
6327 | | "Action timeout clamped to the worker maximum" |
6328 | | ); |
6329 | 1 | self.max_action_timeout |
6330 | | } else { |
6331 | 277 | timeout |
6332 | | }; |
6333 | 278 | let running_action = Arc::new(RunningActionImpl::new( |
6334 | 278 | execution_metadata, |
6335 | 278 | operation_id.clone(), |
6336 | 278 | action_directory, |
6337 | 278 | action_info, |
6338 | 278 | timeout, |
6339 | 278 | self.clone(), |
6340 | | )); |
6341 | | { |
6342 | 278 | let mut running_actions = self.running_actions.lock(); |
6343 | | // Check if action already exists and is still alive |
6344 | 278 | if let Some(existing_weak0 ) = running_actions.get(&operation_id) |
6345 | 0 | && let Some(_existing_action) = existing_weak.upgrade() { |
6346 | 0 | return Err(make_err!( |
6347 | 0 | Code::AlreadyExists, |
6348 | 0 | "Action with operation_id {} is already running", |
6349 | 0 | operation_id |
6350 | 0 | )); |
6351 | 278 | } |
6352 | 278 | running_actions.insert(operation_id.clone(), Arc::downgrade(&running_action)); |
6353 | 278 | running_action |
6354 | 278 | .has_manager_entry |
6355 | 278 | .store(true, Ordering::Release); |
6356 | | } |
6357 | 278 | if let Some(config5 ) = &self.execution_configuration.buck2_file_capture |
6358 | 5 | && Buck2FileCapture::eligible(capture_metadata.as_ref()) { |
6359 | | // The directory exists already. A cancelled registration must |
6360 | | // take the same Drop cleanup path as a prepared action. |
6361 | 4 | running_action.did_cleanup.store(false, Ordering::Release); |
6362 | 4 | match Buck2FileCapture::start( |
6363 | 4 | config, |
6364 | 4 | capture_metadata.as_ref(), |
6365 | 4 | &capture_worker, |
6366 | 4 | &operation_id.to_string(), |
6367 | 4 | &capture_digest, |
6368 | 4 | ).await { |
6369 | 4 | Ok(Some(capture)) => { |
6370 | 4 | self.buck2_captures.lock().insert(operation_id, capture); |
6371 | 4 | } |
6372 | 0 | Ok(None) => {} |
6373 | 0 | Err(err) => { |
6374 | 0 | warn!(%operation_id, ?err, "Buck2 container file capture could not start"); |
6375 | | } |
6376 | | } |
6377 | 274 | } |
6378 | 278 | Ok(running_action) |
6379 | 280 | }) |
6380 | 280 | .await |
6381 | 280 | } |
6382 | | |
6383 | 6 | async fn cache_action_result( |
6384 | 6 | &self, |
6385 | 6 | action_info: DigestInfo, |
6386 | 6 | action_result: &mut ActionResult, |
6387 | 6 | hasher: DigestHasherFunc, |
6388 | 6 | ) -> Result<(), Error> { |
6389 | 6 | self.metrics |
6390 | 6 | .cache_action_result |
6391 | 6 | .wrap(self.upload_action_results.cache_action_result( |
6392 | 6 | action_info, |
6393 | 6 | action_result, |
6394 | 6 | hasher, |
6395 | 6 | )) |
6396 | 6 | .await |
6397 | 6 | } |
6398 | | |
6399 | 4 | async fn kill_operation(&self, operation_id: &OperationId) -> Result<(), Error> { |
6400 | 4 | let running_action = { |
6401 | 4 | let running_actions = self.running_actions.lock(); |
6402 | 4 | running_actions |
6403 | 4 | .get(operation_id) |
6404 | 4 | .and_then(Weak::upgrade) |
6405 | 4 | .ok_or_else(|| make_input_err!0 ("Failed to get running action {operation_id}"))?0 |
6406 | | }; |
6407 | 4 | Self::kill_operation(running_action).await; |
6408 | 4 | Ok(()) |
6409 | 4 | } |
6410 | | |
6411 | | // Note: When the future returns the process should be fully killed and cleaned up. |
6412 | 68 | async fn kill_all(&self)0 { |
6413 | 68 | self.metrics |
6414 | 68 | .kill_all |
6415 | 68 | .wrap_no_capture_result(async move { |
6416 | 68 | let kill_operations: Vec<Arc<RunningActionImpl>> = { |
6417 | 68 | let running_actions = self.running_actions.lock(); |
6418 | 68 | running_actions.values().filter_map(Weak::upgrade).collect() |
6419 | | }; |
6420 | 68 | let mut kill_futures: FuturesUnordered<_> = kill_operations |
6421 | 68 | .into_iter() |
6422 | 68 | .map(Self::kill_operation) |
6423 | 68 | .collect(); |
6424 | 274 | while kill_futures.next().await.is_some() {}206 |
6425 | 68 | }) |
6426 | 68 | .await; |
6427 | | // Wait for the running actions to drain. Deliberately NOT |
6428 | | // `wait_for(|()| ...)`: that closure runs while the watch channel's |
6429 | | // internal lock is held, so taking `running_actions` inside it |
6430 | | // deadlocks with anyone notifying the channel while holding |
6431 | | // `running_actions` (issue #2672). Checking outside the channel's |
6432 | | // lock loses no wakeup: `changed()` reports any version bump since |
6433 | | // the last check, and `cleanup_action` removes before it notifies. |
6434 | 68 | let mut action_done_rx = self.action_done_tx.subscribe(); |
6435 | | loop { |
6436 | 179 | if self.running_actions.lock().is_empty() { |
6437 | 68 | break; |
6438 | 111 | } |
6439 | | // The only sender lives on `self`, so this cannot error; if it |
6440 | | // ever did, giving up the wait is still the safe answer. |
6441 | 111 | if action_done_rx.changed().await.is_err() { |
6442 | 0 | break; |
6443 | 111 | } |
6444 | | } |
6445 | 68 | } |
6446 | | |
6447 | | #[inline] |
6448 | 2 | fn metrics(&self) -> &Arc<Metrics> { |
6449 | 2 | &self.metrics |
6450 | 2 | } |
6451 | | |
6452 | 0 | fn memory_peaks(&self) -> Vec<(String, u64)> { |
6453 | 0 | self.running_actions |
6454 | 0 | .lock() |
6455 | 0 | .iter() |
6456 | 0 | .filter_map(|(operation_id, action)| { |
6457 | 0 | let peak_kb = action.upgrade()?.memory_watch.peak_memory_kb(); |
6458 | 0 | (peak_kb > 0).then(|| (operation_id.to_string(), peak_kb)) |
6459 | 0 | }) |
6460 | 0 | .collect() |
6461 | 0 | } |
6462 | | } |
6463 | | |
6464 | | #[derive(Debug, Default, MetricsComponent)] |
6465 | | pub struct Metrics { |
6466 | | #[metric(help = "Stats about the create_and_add_action command.")] |
6467 | | create_and_add_action: AsyncCounterWrapper, |
6468 | | #[metric(help = "Stats about the cache_action_result command.")] |
6469 | | cache_action_result: AsyncCounterWrapper, |
6470 | | #[metric(help = "Stats about the kill_all command.")] |
6471 | | kill_all: AsyncCounterWrapper, |
6472 | | #[metric(help = "Stats about the create_action_info command.")] |
6473 | | create_action_info: AsyncCounterWrapper, |
6474 | | #[metric(help = "Stats about the make_work_directory command.")] |
6475 | | make_action_directory: AsyncCounterWrapper, |
6476 | | #[metric(help = "Stats about the prepare_action command.")] |
6477 | | prepare_action: AsyncCounterWrapper, |
6478 | | #[metric(help = "Stats about the execute command.")] |
6479 | | execute: AsyncCounterWrapper, |
6480 | | #[metric(help = "Stats about the upload_results command.")] |
6481 | | upload_results: AsyncCounterWrapper, |
6482 | | #[metric(help = "Stats about the cleanup command.")] |
6483 | | cleanup: AsyncCounterWrapper, |
6484 | | #[metric(help = "Stats about the get_finished_result command.")] |
6485 | | get_finished_result: AsyncCounterWrapper, |
6486 | | #[metric(help = "Number of times an action waited for cleanup to complete.")] |
6487 | | cleanup_waits: CounterWithTime, |
6488 | | #[metric(help = "Number of stale directories removed during action retries.")] |
6489 | | stale_removals: CounterWithTime, |
6490 | | #[metric(help = "Orphaned action directories removed by the periodic sweep.")] |
6491 | | orphan_removals: CounterWithTime, |
6492 | | #[metric(help = "Orphaned action directories the sweep could not remove.")] |
6493 | | orphan_removal_failures: CounterWithTime, |
6494 | | #[metric(help = "Number of timeouts while waiting for cleanup to complete.")] |
6495 | | cleanup_wait_timeouts: CounterWithTime, |
6496 | | #[metric(help = "Stats about the get_proto_command_from_store command.")] |
6497 | | get_proto_command_from_store: AsyncCounterWrapper, |
6498 | | #[metric(help = "Stats about the download_to_directory command.")] |
6499 | | download_to_directory: AsyncCounterWrapper, |
6500 | | #[metric(help = "Stats about the prepare_output_files command.")] |
6501 | | prepare_output_files: AsyncCounterWrapper, |
6502 | | #[metric(help = "Stats about the prepare_output_paths command.")] |
6503 | | prepare_output_paths: AsyncCounterWrapper, |
6504 | | #[metric(help = "Stats about the child_process command.")] |
6505 | | child_process: AsyncCounterWrapper, |
6506 | | #[metric(help = "Stats about the child_process_success_error_code command.")] |
6507 | | child_process_success_error_code: CounterWithTime, |
6508 | | #[metric(help = "Stats about the child_process_failure_error_code command.")] |
6509 | | child_process_failure_error_code: CounterWithTime, |
6510 | | #[metric(help = "Total time spent uploading stdout.")] |
6511 | | upload_stdout: AsyncCounterWrapper, |
6512 | | #[metric(help = "Total time spent uploading stderr.")] |
6513 | | upload_stderr: AsyncCounterWrapper, |
6514 | | #[metric(help = "Total number of task timeouts.")] |
6515 | | task_timeouts: CounterWithTime, |
6516 | | #[metric(help = "Actions killed for exceeding their memory reservation.")] |
6517 | | memory_reservation_kills: CounterWithTime, |
6518 | | #[metric(help = "Actions killed because the worker's cgroup stayed under memory pressure.")] |
6519 | | memory_pressure_kills: CounterWithTime, |
6520 | | #[metric(help = "Actions refused because their disk reservation exceeded the free space.")] |
6521 | | disk_guard_refusals: CounterWithTime, |
6522 | | #[metric(help = "Actions killed for writing past their disk reservation.")] |
6523 | | disk_reservation_kills: CounterWithTime, |
6524 | | #[metric(help = "Actions served by a pooled persistent worker process.")] |
6525 | | persistent_worker_dispatches: CounterWithTime, |
6526 | | #[metric( |
6527 | | help = "Actions that asked for a persistent worker and ran one-shot instead: pool at capacity, spawn failure or an unsupported protocol." |
6528 | | )] |
6529 | | persistent_worker_fallbacks: CounterWithTime, |
6530 | | #[metric( |
6531 | | help = "Stats about the input-directory cache (hits, misses, subtree reuse, evictions, size)." |
6532 | | )] |
6533 | | directory_cache: Option<Weak<crate::directory_cache::DirectoryCache>>, |
6534 | | } |
6535 | | |
6536 | | #[cfg(test)] |
6537 | | mod tests { |
6538 | | use core::sync::atomic::AtomicU32; |
6539 | | |
6540 | | use nativelink_macro::nativelink_test; |
6541 | | use pretty_assertions::assert_eq; |
6542 | | |
6543 | | use super::*; |
6544 | | |
6545 | 23 | fn test_digest(first_byte: u8) -> DigestInfo { |
6546 | 23 | let mut hash = [0u8; 32]; |
6547 | 23 | hash[0] = first_byte; |
6548 | 23 | DigestInfo::new(hash, 1) |
6549 | 23 | } |
6550 | | |
6551 | | /// Runs `fetch_and_resolve` against a resolve step that fails with |
6552 | | /// `fail_with` for the first `failures` calls and succeeds after, and |
6553 | | /// returns the result with how often each step ran. |
6554 | 3 | async fn drive(failures: u32, fail_with: Code) -> (Result<u32, Error>, u32, u32) { |
6555 | 3 | let populates = AtomicU32::new(0); |
6556 | 3 | let resolves = AtomicU32::new(0); |
6557 | 3 | let result = fetch_and_resolve( |
6558 | 3 | test_digest(0), |
6559 | 7 | || async { |
6560 | 7 | populates.fetch_add(1, Ordering::Relaxed); |
6561 | 7 | Ok(()) |
6562 | 14 | }, |
6563 | 7 | || async { |
6564 | 7 | let call = resolves.fetch_add(1, Ordering::Relaxed) + 1; |
6565 | 7 | if call <= failures { |
6566 | 6 | return Err(make_err!(fail_with, "resolve attempt {call} failed")); |
6567 | 1 | } |
6568 | 1 | Ok(call) |
6569 | 14 | }, |
6570 | | ) |
6571 | 3 | .await; |
6572 | 3 | ( |
6573 | 3 | result, |
6574 | 3 | populates.load(Ordering::Relaxed), |
6575 | 3 | resolves.load(Ordering::Relaxed), |
6576 | 3 | ) |
6577 | 3 | } |
6578 | | |
6579 | | #[nativelink_test] |
6580 | | async fn fetch_and_resolve_retries_a_missing_blob() { |
6581 | | let (result, populates, resolves) = drive(2, Code::NotFound).await; |
6582 | | assert_eq!(result.unwrap(), 3); |
6583 | | assert_eq!(populates, 3); |
6584 | | assert_eq!(resolves, 3); |
6585 | | } |
6586 | | |
6587 | | #[nativelink_test] |
6588 | | async fn fetch_and_resolve_gives_up_after_the_last_attempt() { |
6589 | | let (result, populates, resolves) = drive(3, Code::NotFound).await; |
6590 | | assert_eq!(result.unwrap_err().code, Code::NotFound); |
6591 | | assert_eq!(populates, INPUT_FETCH_ATTEMPTS); |
6592 | | assert_eq!(resolves, INPUT_FETCH_ATTEMPTS); |
6593 | | } |
6594 | | |
6595 | | #[nativelink_test] |
6596 | | async fn fetch_and_resolve_returns_other_errors_at_once() { |
6597 | | let (result, populates, resolves) = drive(1, Code::Internal).await; |
6598 | | assert_eq!(result.unwrap_err().code, Code::Internal); |
6599 | | assert_eq!(populates, 1); |
6600 | | assert_eq!(resolves, 1); |
6601 | | } |
6602 | | |
6603 | | #[nativelink_test] |
6604 | | async fn fetch_and_resolve_stops_when_the_fetch_fails() { |
6605 | | let resolves = AtomicU32::new(0); |
6606 | | let result: Result<(), Error> = fetch_and_resolve( |
6607 | | test_digest(0), |
6608 | 2 | || async {1 Err(make_err!(Code::Unavailable, "slow store is down"))1 }, |
6609 | 0 | || async { |
6610 | 0 | resolves.fetch_add(1, Ordering::Relaxed); |
6611 | 0 | Ok(()) |
6612 | 0 | }, |
6613 | | ) |
6614 | | .await; |
6615 | | assert_eq!(result.unwrap_err().code, Code::Unavailable); |
6616 | | assert_eq!(resolves.load(Ordering::Relaxed), 0); |
6617 | | } |
6618 | | |
6619 | | /// The hint is reported for the first sign of trouble and then not |
6620 | | /// again until a full interval has passed, however many signs follow. |
6621 | | #[test] |
6622 | 1 | fn input_pool_hint_is_due_once_per_interval() { |
6623 | 1 | let hint = InputPoolHint::default(); |
6624 | 1 | let start = Instant::now(); |
6625 | 1 | assert!(hint.due(start), "the first sign is reported"); |
6626 | 1 | assert!(!hint.due(start), "a second sign at once is not"); |
6627 | 1 | let almost = start + INPUT_POOL_HINT_INTERVAL - Duration::from_secs(1); |
6628 | 1 | assert!(!hint.due(almost), "nor one before the interval has passed"); |
6629 | 1 | let next = start + INPUT_POOL_HINT_INTERVAL; |
6630 | 1 | assert!(hint.due(next), "one a full interval later is reported"); |
6631 | 1 | assert!( |
6632 | 1 | !hint.due(next + Duration::from_secs(1)), |
6633 | | "and starts the next interval" |
6634 | | ); |
6635 | 1 | } |
6636 | | |
6637 | | #[test] |
6638 | 1 | fn input_download_pool_is_unbounded_only_when_unset() { |
6639 | 1 | assert_eq!( |
6640 | 1 | input_download_pool(0).available_permits(), |
6641 | | Semaphore::MAX_PERMITS |
6642 | | ); |
6643 | 1 | assert_eq!(input_download_pool(64).available_permits(), 64); |
6644 | 1 | assert_eq!( |
6645 | 1 | input_download_pool(usize::MAX).available_permits(), |
6646 | | Semaphore::MAX_PERMITS, |
6647 | | "a value past what a semaphore holds is clamped, not a panic" |
6648 | | ); |
6649 | 1 | } |
6650 | | |
6651 | | #[test] |
6652 | 1 | fn input_fetch_pause_grows_with_the_attempt_and_stays_bounded() { |
6653 | 1 | let step = Duration::from_millis(INPUT_FETCH_PAUSE_STEP_MS); |
6654 | 6 | for first_byte in [0u8, 1, 19, 20, 127, 255]1 { |
6655 | 6 | let digest = test_digest(first_byte); |
6656 | 12 | for attempt in 1..INPUT_FETCH_ATTEMPTS6 { |
6657 | 12 | let pause = input_fetch_pause(attempt, &digest); |
6658 | 12 | assert!(pause >= step * attempt, "{pause:?} for attempt {attempt}"); |
6659 | 12 | assert!( |
6660 | 12 | pause < step * (attempt + 1), |
6661 | | "{pause:?} for attempt {attempt}" |
6662 | | ); |
6663 | | } |
6664 | | } |
6665 | 1 | assert_ne!( |
6666 | 1 | input_fetch_pause(1, &test_digest(3)), |
6667 | 1 | input_fetch_pause(1, &test_digest(7)) |
6668 | | ); |
6669 | 1 | } |
6670 | | |
6671 | | #[test] |
6672 | 1 | fn prepare_retry_pause_doubles_and_stays_under_the_cap() { |
6673 | 1 | let cap = Duration::from_millis(PREPARE_RETRY_PAUSE_CAP_MS); |
6674 | 5 | for first_byte in [0u8, 1, 19, 127, 255]1 { |
6675 | 5 | let digest = test_digest(first_byte); |
6676 | 50 | for attempt in 1..=10u325 { |
6677 | 50 | let base = Duration::from_millis( |
6678 | 50 | (PREPARE_RETRY_PAUSE_MS << attempt.saturating_sub(1).min(5)) |
6679 | 50 | .min(PREPARE_RETRY_PAUSE_CAP_MS), |
6680 | | ); |
6681 | 50 | let pause = prepare_retry_pause(attempt, &digest); |
6682 | 50 | assert!(pause <= base, "{pause:?} for attempt {attempt}"); |
6683 | 50 | assert!(pause > base * 3 / 4, "{pause:?} for attempt {attempt}"); |
6684 | 50 | assert!(pause <= cap, "{pause:?} for attempt {attempt}"); |
6685 | | } |
6686 | | } |
6687 | 1 | assert_eq!( |
6688 | 1 | prepare_retry_pause(1, &test_digest(0)), |
6689 | 1 | Duration::from_secs(1) |
6690 | | ); |
6691 | 1 | assert_eq!( |
6692 | 1 | prepare_retry_pause(2, &test_digest(0)), |
6693 | 1 | Duration::from_secs(2) |
6694 | | ); |
6695 | 1 | assert_eq!( |
6696 | 1 | prepare_retry_pause(3, &test_digest(0)), |
6697 | 1 | Duration::from_secs(4) |
6698 | | ); |
6699 | 1 | assert_eq!(prepare_retry_pause(9, &test_digest(0)), cap); |
6700 | 1 | assert_ne!( |
6701 | 1 | prepare_retry_pause(1, &test_digest(3)), |
6702 | 1 | prepare_retry_pause(1, &test_digest(7)) |
6703 | | ); |
6704 | 1 | } |
6705 | | |
6706 | | #[test] |
6707 | 1 | fn transient_fetch_errors_are_the_retrier_codes() { |
6708 | 7 | for code in [ |
6709 | 1 | Code::Unavailable, |
6710 | 1 | Code::ResourceExhausted, |
6711 | 1 | Code::Aborted, |
6712 | 1 | Code::Internal, |
6713 | 1 | Code::Unknown, |
6714 | 1 | Code::DeadlineExceeded, |
6715 | 1 | Code::DataLoss, |
6716 | 1 | ] { |
6717 | 7 | let err = make_err!(code, "store answered 503 ServerBusy"); |
6718 | 7 | assert!(is_transient_fetch_error(&err), "{code:?}"); |
6719 | | } |
6720 | 9 | for code in [ |
6721 | 1 | Code::NotFound, |
6722 | 1 | Code::InvalidArgument, |
6723 | 1 | Code::FailedPrecondition, |
6724 | 1 | Code::PermissionDenied, |
6725 | 1 | Code::Unauthenticated, |
6726 | 1 | Code::Unimplemented, |
6727 | 1 | Code::OutOfRange, |
6728 | 1 | Code::AlreadyExists, |
6729 | 1 | Code::Cancelled, |
6730 | 1 | ] { |
6731 | 9 | let err = make_err!(code, "store answered"); |
6732 | 9 | assert!(!is_transient_fetch_error(&err), "{code:?}"); |
6733 | | } |
6734 | 1 | } |
6735 | | |
6736 | | /// The worker's own disk failing maps to the same codes a store |
6737 | | /// failure does, but is never retried on this worker. The error |
6738 | | /// numbers are Linux's (ENOSPC, EPIPE), so that part runs on unix. |
6739 | | #[test] |
6740 | 1 | fn an_operating_system_error_is_not_a_transient_fetch_error() { |
6741 | | #[cfg(target_family = "unix")] |
6742 | | { |
6743 | 1 | let disk_full = Error::from(std::io::Error::from_raw_os_error(28)); |
6744 | 1 | assert_eq!(disk_full.code, Code::Unknown); |
6745 | 1 | assert!(!is_transient_fetch_error(&disk_full), "{disk_full:?}"); |
6746 | 1 | let broken_pipe = |
6747 | 1 | Error::from(std::io::Error::from_raw_os_error(32)).append("Writing an input"); |
6748 | 1 | assert_eq!(broken_pipe.code, Code::Internal); |
6749 | 1 | assert!(!is_transient_fetch_error(&broken_pipe), "{broken_pipe:?}"); |
6750 | | } |
6751 | | // What the standard library prints for any OS error, whatever the |
6752 | | // platform's number for it. |
6753 | 1 | let os_error = Error::from(std::io::Error::other( |
6754 | | "No space left on device (os error 28)", |
6755 | | )); |
6756 | 1 | assert_eq!(os_error.code, Code::Internal); |
6757 | 1 | assert!(!is_transient_fetch_error(&os_error), "{os_error:?}"); |
6758 | | // A store that reset the connection is not this worker's disk. |
6759 | 1 | let reset = Error::from(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); |
6760 | 1 | assert_eq!(reset.code, Code::Unavailable); |
6761 | 1 | assert!(is_transient_fetch_error(&reset), "{reset:?}"); |
6762 | 1 | } |
6763 | | |
6764 | | /// Every retried fetch settles on one terminal outcome, dropped ones |
6765 | | /// included; a fetch never retried records nothing. |
6766 | | #[test] |
6767 | 1 | fn prepare_retries_settle_once() { |
6768 | 1 | let recorded = std::cell::RefCell::new(Vec::new()); |
6769 | 5 | let record1 = |outcome: &'static str| recorded.borrow_mut().push(outcome); |
6770 | | |
6771 | 1 | let mut retries = PrepareRetries::new(record); |
6772 | 1 | retries.retried(); |
6773 | 1 | retries.retried(); |
6774 | 1 | retries.settle("recovered"); |
6775 | 1 | assert_eq!(*recorded.borrow(), vec!["retried", "retried", "recovered"]); |
6776 | | |
6777 | 1 | recorded.borrow_mut().clear(); |
6778 | 1 | PrepareRetries::new(record).settle("failed"); |
6779 | 1 | assert!(recorded.borrow().is_empty(), "no retry, nothing to settle"); |
6780 | | |
6781 | 1 | recorded.borrow_mut().clear(); |
6782 | 1 | { |
6783 | 1 | let mut retries = PrepareRetries::new(record); |
6784 | 1 | retries.retried(); |
6785 | 1 | } |
6786 | 1 | assert_eq!(*recorded.borrow(), vec!["retried", "abandoned"]); |
6787 | 1 | } |
6788 | | } |