/build/source/nativelink-worker/src/persistent_worker/pool.rs
Line | Count | Source |
1 | | // Copyright 2024 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Business Source License, Version 1.1 (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may request a copy of the License by emailing contact@nativelink.com. |
6 | | // |
7 | | // Use of this module requires an enterprise license agreement, which can be |
8 | | // attained by emailing contact@nativelink.com or signing up for Nativelink |
9 | | // Cloud at app.nativelink.com. |
10 | | // |
11 | | // Unless required by applicable law or agreed to in writing, software |
12 | | // distributed under the License is distributed on an "AS IS" BASIS, |
13 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
14 | | // See the License for the specific language governing permissions and |
15 | | // limitations under the License. |
16 | | |
17 | | //! `PersistentWorkerPool`: per-`LocalWorker` pool of `LiveWorker`s keyed by |
18 | | //! `WorkerKey`. Mirrors Bazel's notion of `WorkerKey` — two actions whose |
19 | | //! executable + startup-flag prefix + wire format are identical share a worker |
20 | | //! process. |
21 | | |
22 | | use core::time::Duration; |
23 | | use core::{fmt, mem}; |
24 | | use std::collections::HashMap; |
25 | | use std::path::{Path, PathBuf}; |
26 | | use std::sync::Arc; |
27 | | |
28 | | use nativelink_error::{Code, Error, ResultExt, make_err}; |
29 | | use nativelink_util::background_spawn; |
30 | | use nativelink_util::task::JoinHandleDropGuard; |
31 | | use parking_lot::Mutex; |
32 | | use tokio::sync::Notify; |
33 | | use tracing::{debug, info, warn}; |
34 | | |
35 | | use super::live_worker::LiveWorker; |
36 | | use super::protocol::WireFormat; |
37 | | |
38 | | /// Default per-key bound on live workers. Matches Bazel's `worker_max_instances` |
39 | | /// default and keeps memory pressure bounded without operator intervention. |
40 | | pub const DEFAULT_MAX_WORKERS_PER_KEY: usize = 4; |
41 | | |
42 | | /// Default idle timeout. A worker that has not handled a request for this |
43 | | /// duration is shut down by the background sweeper. |
44 | | pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_mins(5); |
45 | | |
46 | | /// Default per-worker request cap. After this many `dispatch` calls a worker is |
47 | | /// dropped on `release` (helps recycle accumulated JVM state). |
48 | | pub const DEFAULT_MAX_REQUESTS_PER_WORKER: u64 = 200; |
49 | | |
50 | | /// Default wait for a worker to come back when a key is at its cap. |
51 | | pub const DEFAULT_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30); |
52 | | |
53 | | /// Identity by which two persistent-worker actions are considered compatible. |
54 | | /// Same `WorkerKey` => same worker can serve both. |
55 | | #[derive(Clone, PartialEq, Eq, Hash)] |
56 | | pub struct WorkerKey { |
57 | | /// Resolved tool executable. Mirrors `Action.arguments[0]`. |
58 | | pub executable: PathBuf, |
59 | | /// Leading arguments that become tool startup flags. Computed as the prefix |
60 | | /// of `Action.arguments[1..]` up to (excluding) the first arg starting |
61 | | /// with `@` (Bazel's response-file convention). |
62 | | pub startup_args: Vec<String>, |
63 | | /// Wire format from the action's `requires-worker-protocol`. |
64 | | pub wire_format: WireFormat, |
65 | | /// The action's environment, sorted by name. A worker process is |
66 | | /// started with exactly this environment, the way a one-shot action is, |
67 | | /// so two actions that differ in it do not share a process. |
68 | | pub env: Vec<(String, String)>, |
69 | | } |
70 | | |
71 | | /// The key is logged on every spawn and completion and quoted in errors the |
72 | | /// client sees, so the environment shows as its variable names only: an |
73 | | /// action's environment can carry a token. |
74 | | impl fmt::Debug for WorkerKey { |
75 | 13 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
76 | 13 | f.debug_struct("WorkerKey") |
77 | 13 | .field("executable", &self.executable) |
78 | 13 | .field("startup_args", &self.startup_args) |
79 | 13 | .field("wire_format", &self.wire_format) |
80 | 13 | .field( |
81 | 13 | "env", |
82 | 13 | &self.env.iter().map(|(name, _)| name).collect::<Vec<_>>(), |
83 | | ) |
84 | 13 | .finish() |
85 | 13 | } |
86 | | } |
87 | | |
88 | | impl WorkerKey { |
89 | | /// Derive a `WorkerKey` from a full action `argv` and the negotiated wire |
90 | | /// format. |
91 | | /// |
92 | | /// `argv[0]` is the executable. Subsequent arguments are scanned |
93 | | /// left-to-right and treated as startup flags until the first |
94 | | /// `@`-prefixed argument (Bazel's response file), at which point the rest |
95 | | /// are considered per-request inputs and excluded from the key. |
96 | 15 | pub fn from_argv(argv: &[String], wire_format: WireFormat) -> Result<Self, Error> { |
97 | 15 | let (exe14 , rest14 ) = argv.split_first().ok_or_else(|| {1 |
98 | 1 | make_err!( |
99 | 1 | Code::InvalidArgument, |
100 | | "Cannot derive WorkerKey from empty argument list" |
101 | | ) |
102 | 1 | })?; |
103 | 14 | let startup_args: Vec<String> = rest |
104 | 14 | .iter() |
105 | 25 | .take_while14 (|a| !a.starts_with('@')) |
106 | 14 | .cloned() |
107 | 14 | .collect(); |
108 | 14 | Ok(Self { |
109 | 14 | executable: PathBuf::from(exe), |
110 | 14 | startup_args, |
111 | 14 | wire_format, |
112 | 14 | env: Vec::new(), |
113 | 14 | }) |
114 | 15 | } |
115 | | |
116 | | /// The environment the worker process is started with, part of the key. |
117 | | #[must_use] |
118 | 5 | pub fn with_env(mut self, mut env: Vec<(String, String)>) -> Self { |
119 | 5 | env.sort(); |
120 | 5 | self.env = env; |
121 | 5 | self |
122 | 5 | } |
123 | | } |
124 | | |
125 | | /// Configuration knobs for a `PersistentWorkerPool`. Defaults are sensible for |
126 | | /// a JVM-heavy workload; operators can override in `nativelink-config`. |
127 | | #[derive(Clone, Copy, Debug)] |
128 | | pub struct PoolConfig { |
129 | | pub max_workers_per_key: usize, |
130 | | pub idle_timeout: Duration, |
131 | | pub max_requests_per_worker: u64, |
132 | | /// SIGKILL grace when shutting a worker down. |
133 | | pub shutdown_grace: Duration, |
134 | | /// How long `acquire` waits for a worker to come back when the key is |
135 | | /// at `max_workers_per_key` with none idle, before the action runs |
136 | | /// one-shot instead. Bazel itself queues on `worker_max_instances`; a |
137 | | /// one-shot run of a worker tool pays the tool's startup again. |
138 | | pub acquire_timeout: Duration, |
139 | | /// Start each worker process in its own user, PID, UTS, IPC and mount |
140 | | /// namespaces, with a fresh `/proc`, as one-shot actions are when the |
141 | | /// worker runs with `use_namespaces`. Never a masked root or a private |
142 | | /// `/tmp`: the process outlives any one action, so neither can apply |
143 | | /// to it. |
144 | | pub namespaced: bool, |
145 | | } |
146 | | |
147 | | impl Default for PoolConfig { |
148 | 96 | fn default() -> Self { |
149 | 96 | Self { |
150 | 96 | max_workers_per_key: DEFAULT_MAX_WORKERS_PER_KEY, |
151 | 96 | idle_timeout: DEFAULT_IDLE_TIMEOUT, |
152 | 96 | max_requests_per_worker: DEFAULT_MAX_REQUESTS_PER_WORKER, |
153 | 96 | shutdown_grace: Duration::from_secs(5), |
154 | 96 | acquire_timeout: DEFAULT_ACQUIRE_TIMEOUT, |
155 | 96 | namespaced: false, |
156 | 96 | } |
157 | 96 | } |
158 | | } |
159 | | |
160 | | /// Reservation handle returned by `acquire`. Holding it removes the worker |
161 | | /// from the pool's idle set; `release` returns it (unless the request marked |
162 | | /// it as dead). Drop-without-release is treated as "worker is dead": it is |
163 | | /// shut down rather than re-pooled. |
164 | | #[derive(Debug)] |
165 | | pub struct Lease { |
166 | | inner: Option<LeaseInner>, |
167 | | } |
168 | | |
169 | | #[derive(Debug)] |
170 | | struct LeaseInner { |
171 | | worker: LiveWorker, |
172 | | pool: Arc<PoolInner>, |
173 | | key: WorkerKey, |
174 | | } |
175 | | |
176 | | #[derive(Debug)] |
177 | | struct CountSlotGuard { |
178 | | pool: Arc<PoolInner>, |
179 | | key: WorkerKey, |
180 | | active: bool, |
181 | | } |
182 | | |
183 | | impl CountSlotGuard { |
184 | 10 | const fn new(pool: Arc<PoolInner>, key: WorkerKey) -> Self { |
185 | 10 | Self { |
186 | 10 | pool, |
187 | 10 | key, |
188 | 10 | active: true, |
189 | 10 | } |
190 | 10 | } |
191 | | |
192 | 6 | fn disarm(mut self) { |
193 | 6 | self.active = false; |
194 | 6 | } |
195 | | |
196 | 3 | fn decrement_now(mut self) { |
197 | 3 | if self.active { |
198 | 3 | self.pool.decrement_count(&self.key); |
199 | 3 | self.active = false; |
200 | 3 | }0 |
201 | 3 | } |
202 | | } |
203 | | |
204 | | impl Drop for CountSlotGuard { |
205 | 10 | fn drop(&mut self) { |
206 | 10 | if self.active { |
207 | 1 | self.pool.decrement_count(&self.key); |
208 | 9 | } |
209 | 10 | } |
210 | | } |
211 | | |
212 | | impl Lease { |
213 | 4 | pub const fn worker(&mut self) -> &mut LiveWorker { |
214 | 4 | &mut self |
215 | 4 | .inner |
216 | 4 | .as_mut() |
217 | 4 | .expect("lease used after release") |
218 | 4 | .worker |
219 | 4 | } |
220 | | |
221 | | /// Return the worker to the pool. Caller signals whether the worker is |
222 | | /// still healthy. Unhealthy workers are shut down asynchronously and not |
223 | | /// returned to the idle set. |
224 | 8 | pub async fn release(mut self, healthy: bool) { |
225 | 8 | let Some(inner) = self.inner.take() else { |
226 | 0 | return; |
227 | | }; |
228 | 8 | inner |
229 | 8 | .pool |
230 | 8 | .return_worker(inner.key, inner.worker, healthy) |
231 | 8 | .await; |
232 | 7 | } |
233 | | } |
234 | | |
235 | | impl Drop for Lease { |
236 | 8 | fn drop(&mut self) { |
237 | | // If the caller forgot to call `release`, treat the worker as |
238 | | // unhealthy and shut it down on a detached task. |
239 | 8 | if let Some(inner0 ) = self.inner.take() { |
240 | 0 | let pool = inner.pool.clone(); |
241 | 0 | let key = inner.key.clone(); |
242 | 0 | let worker = inner.worker; |
243 | 0 | let grace = pool.config.shutdown_grace; |
244 | 0 | background_spawn!("persistent_worker_lease_drop", async move { |
245 | 0 | let count_slot = CountSlotGuard::new(pool.clone(), key.clone()); |
246 | 0 | warn!( |
247 | | ?key, |
248 | | "Lease dropped without release; treating worker as unhealthy" |
249 | | ); |
250 | 0 | worker.shutdown(grace).await; |
251 | 0 | count_slot.decrement_now(); |
252 | 0 | }); |
253 | 8 | } |
254 | 8 | } |
255 | | } |
256 | | |
257 | | /// Per-key pool state. Wrapped in a `Mutex` because `acquire` and `release` |
258 | | /// run on different tokio tasks. |
259 | | #[derive(Debug)] |
260 | | struct KeyState { |
261 | | /// Idle workers ready to serve. Always size <= `total_count`. |
262 | | idle: Vec<LiveWorker>, |
263 | | /// Total live workers for this key (idle + leased). Used to enforce |
264 | | /// `max_workers_per_key`. |
265 | | total_count: usize, |
266 | | } |
267 | | |
268 | | impl KeyState { |
269 | 5 | const fn new() -> Self { |
270 | 5 | Self { |
271 | 5 | idle: Vec::new(), |
272 | 5 | total_count: 0, |
273 | 5 | } |
274 | 5 | } |
275 | | } |
276 | | |
277 | | #[derive(Debug)] |
278 | | struct PoolInner { |
279 | | config: PoolConfig, |
280 | | state: Mutex<HashMap<WorkerKey, KeyState>>, |
281 | | /// Woken whenever a worker is returned or a slot is freed, for an |
282 | | /// `acquire` waiting on a key at its cap. |
283 | | returned: Notify, |
284 | | } |
285 | | |
286 | | impl PoolInner { |
287 | | /// Helper used by Drop on a forgotten Lease. |
288 | 4 | fn decrement_count(&self, key: &WorkerKey) { |
289 | | { |
290 | 4 | let mut state = self.state.lock(); |
291 | 4 | if let Some(s) = state.get_mut(key) { |
292 | 4 | s.total_count = s.total_count.saturating_sub(1); |
293 | 4 | }0 |
294 | | } |
295 | 4 | self.returned.notify_waiters(); |
296 | 4 | } |
297 | | |
298 | 8 | async fn return_worker(self: Arc<Self>, key: WorkerKey, worker: LiveWorker, healthy: bool)6 { |
299 | 8 | let exceeded_cap = worker.request_count() >= self.config.max_requests_per_worker; |
300 | 8 | if !healthy || exceeded_cap4 { |
301 | 4 | let count_slot = CountSlotGuard::new(self.clone(), key.clone()); |
302 | 4 | debug!( |
303 | | ?key, |
304 | 4 | request_count = worker.request_count(), |
305 | | healthy, |
306 | | "Retiring persistent worker" |
307 | | ); |
308 | 4 | worker.shutdown(self.config.shutdown_grace).await; |
309 | 3 | count_slot.decrement_now(); |
310 | 3 | return; |
311 | 4 | } |
312 | 4 | { |
313 | 4 | let mut state = self.state.lock(); |
314 | 4 | let entry = state.entry(key).or_insert_with(KeyState::new); |
315 | 4 | entry.idle.push(worker); |
316 | 4 | } |
317 | 4 | self.returned.notify_waiters(); |
318 | 7 | } |
319 | | } |
320 | | |
321 | | /// The pool itself. |
322 | | #[derive(Clone, Debug)] |
323 | | pub struct PersistentWorkerPool { |
324 | | inner: Arc<PoolInner>, |
325 | | } |
326 | | |
327 | | impl Default for PersistentWorkerPool { |
328 | 0 | fn default() -> Self { |
329 | 0 | Self::new(PoolConfig::default()) |
330 | 0 | } |
331 | | } |
332 | | |
333 | | impl PersistentWorkerPool { |
334 | | /// How long `acquire` waits at the cap before giving up. |
335 | 2 | pub fn acquire_timeout(&self) -> Duration { |
336 | 2 | self.inner.config.acquire_timeout |
337 | 2 | } |
338 | | |
339 | 86 | pub fn new(config: PoolConfig) -> Self { |
340 | 86 | Self { |
341 | 86 | inner: Arc::new(PoolInner { |
342 | 86 | config, |
343 | 86 | state: Mutex::new(HashMap::new()), |
344 | 86 | returned: Notify::new(), |
345 | 86 | }), |
346 | 86 | } |
347 | 86 | } |
348 | | |
349 | | /// Acquire a worker for `key`. If an idle worker is available it is |
350 | | /// returned. Otherwise a new one is spawned, capped at |
351 | | /// `max_workers_per_key`. If the cap is reached and no idle worker exists, |
352 | | /// returns `Err(Code::ResourceExhausted)` — the caller should fall back to |
353 | | /// the one-shot subprocess path or wait and retry. |
354 | | /// |
355 | | /// `working_dir` is a stable worker process directory. Per-request action |
356 | | /// directories are passed through `WorkRequest::sandbox_dir`, because the |
357 | | /// action directory is deleted after each action completes. |
358 | 7 | pub async fn acquire( |
359 | 7 | &self, |
360 | 7 | key: WorkerKey, |
361 | 7 | executable_path: &Path, |
362 | 7 | working_dir: &Path, |
363 | 9 | ) -> Result<Lease, Error> { |
364 | 9 | let deadline = tokio::time::Instant::now() + self.inner.config.acquire_timeout; |
365 | | loop { |
366 | | // Take an idle worker, or reserve a slot to spawn one, without |
367 | | // blocking on the spawn itself. |
368 | 2 | let notified = { |
369 | 10 | let mut state = self.inner.state.lock(); |
370 | 10 | let entry = state.entry(key.clone()).or_insert_with(KeyState::new); |
371 | 10 | while let Some(mut worker2 ) = entry.idle.pop() { |
372 | 2 | if worker.is_dead() { |
373 | | // Process died while idle (e.g. JVM OOM). Drop it. |
374 | 0 | entry.total_count = entry.total_count.saturating_sub(1); |
375 | 0 | continue; |
376 | 2 | } |
377 | 2 | return Ok(Lease { |
378 | 2 | inner: Some(LeaseInner { |
379 | 2 | worker, |
380 | 2 | pool: self.inner.clone(), |
381 | 2 | key, |
382 | 2 | }), |
383 | 2 | }); |
384 | | } |
385 | 8 | if entry.total_count < self.inner.config.max_workers_per_key { |
386 | | // Reserve a slot before releasing the lock so a concurrent |
387 | | // acquire can't over-commit. |
388 | 6 | entry.total_count += 1; |
389 | 6 | break; |
390 | 2 | } |
391 | | // At the cap with nothing idle: wait for a worker to come |
392 | | // back. The waiter is registered under the lock so a return |
393 | | // between the check and the wait is not missed. |
394 | 2 | let notified = self.inner.returned.notified(); |
395 | 2 | let mut notified = Box::pin(notified); |
396 | 2 | notified.as_mut().enable(); |
397 | 2 | notified |
398 | | }; |
399 | 2 | if tokio::time::timeout_at(deadline, notified).await.is_err() { |
400 | 1 | return Err(make_err!( |
401 | 1 | Code::ResourceExhausted, |
402 | 1 | "Persistent worker pool for {key:?} is at capacity ({} workers) and none came back within {:?}", |
403 | 1 | self.inner.config.max_workers_per_key, |
404 | 1 | self.inner.config.acquire_timeout |
405 | 1 | )); |
406 | 1 | } |
407 | | } |
408 | | |
409 | | // Lock released; spawn outside the critical section. |
410 | 6 | let count_slot = CountSlotGuard::new(self.inner.clone(), key.clone()); |
411 | 6 | let spawn_result = LiveWorker::spawn( |
412 | 6 | executable_path, |
413 | 6 | &key.startup_args, |
414 | 6 | key.wire_format, |
415 | 6 | working_dir, |
416 | 6 | &key.env, |
417 | 6 | self.inner.config.namespaced, |
418 | | ); |
419 | 6 | let worker = match spawn_result { |
420 | 6 | Ok(w) => w, |
421 | 0 | Err(err) => { |
422 | 0 | return Err(err).err_tip(|| format!("Spawning persistent worker for {key:?}")); |
423 | | } |
424 | | }; |
425 | 6 | count_slot.disarm(); |
426 | 6 | info!(?key, "Spawned new persistent worker"); |
427 | 6 | Ok(Lease { |
428 | 6 | inner: Some(LeaseInner { |
429 | 6 | worker, |
430 | 6 | pool: self.inner.clone(), |
431 | 6 | key, |
432 | 6 | }), |
433 | 6 | }) |
434 | 9 | } |
435 | | |
436 | | /// Runs `sweep_idle` on a timer until the pool is dropped. Without it an |
437 | | /// idle worker process, a JVM as a rule, lived as long as the worker did. |
438 | 83 | pub fn spawn_sweeper(&self) -> JoinHandleDropGuard<()> { |
439 | 83 | let inner = Arc::downgrade(&self.inner); |
440 | 83 | let interval = (self.inner.config.idle_timeout / 4).max(Duration::from_secs(1)); |
441 | 83 | JoinHandleDropGuard::new(background_spawn!("persistent_worker_sweeper", async move {78 |
442 | | loop { |
443 | 78 | tokio::time::sleep(interval).await; |
444 | 1 | let Some(inner) = inner.upgrade() else { |
445 | 0 | return; |
446 | | }; |
447 | 1 | Self { inner }.sweep_idle().await; |
448 | | } |
449 | 0 | })) |
450 | 83 | } |
451 | | |
452 | | /// How many worker processes the pool holds, idle or leased. |
453 | 13 | pub fn live_workers(&self) -> usize { |
454 | 13 | self.inner |
455 | 13 | .state |
456 | 13 | .lock() |
457 | 13 | .values() |
458 | 13 | .map(|entry| entry.total_count) |
459 | 13 | .sum() |
460 | 13 | } |
461 | | |
462 | | /// Drop all idle workers whose `last_used` is older than `idle_timeout`. |
463 | | /// Intended to be called periodically from a background task. |
464 | 1 | pub async fn sweep_idle(&self) { |
465 | 1 | let threshold = std::time::Instant::now() |
466 | 1 | .checked_sub(self.inner.config.idle_timeout) |
467 | 1 | .unwrap_or_else(std::time::Instant::now); |
468 | 1 | let evicted: Vec<LiveWorker> = { |
469 | 1 | let mut state = self.inner.state.lock(); |
470 | 1 | let mut evicted = Vec::new(); |
471 | 1 | for entry in state.values_mut() { |
472 | 1 | let (keep, drop_): (Vec<_>, Vec<_>) = mem::take(&mut entry.idle) |
473 | 1 | .into_iter() |
474 | 1 | .partition(|w| w.last_used() >= threshold); |
475 | 1 | entry.total_count = entry.total_count.saturating_sub(drop_.len()); |
476 | 1 | entry.idle = keep; |
477 | 1 | evicted.extend(drop_); |
478 | | } |
479 | 1 | evicted |
480 | | }; |
481 | 1 | for w in evicted { |
482 | 1 | w.shutdown(self.inner.config.shutdown_grace).await; |
483 | | } |
484 | 0 | } |
485 | | } |
486 | | |
487 | | #[cfg(test)] |
488 | | mod tests { |
489 | | #[cfg(unix)] |
490 | | use std::io::Write as _; |
491 | | |
492 | | #[cfg(unix)] |
493 | | use nativelink_macro::nativelink_test; |
494 | | |
495 | | use super::*; |
496 | | |
497 | | #[cfg(unix)] |
498 | 4 | fn shell_script(working_dir: &Path, body: &str) -> PathBuf { |
499 | 4 | let path = working_dir.join("worker.sh"); |
500 | 4 | let mut file = std::fs::File::create(&path).unwrap(); |
501 | 4 | file.write_all(body.as_bytes()).unwrap(); |
502 | 4 | file.sync_all().unwrap(); |
503 | 4 | path |
504 | 4 | } |
505 | | |
506 | | /// The worker process gets only the environment in its key, so the |
507 | | /// scripts need PATH to find `sleep` wherever the tests run. |
508 | | #[cfg(unix)] |
509 | 4 | fn path_env() -> Vec<(String, String)> { |
510 | 4 | vec![( |
511 | 4 | "PATH".to_string(), |
512 | 4 | std::env::var("PATH").unwrap_or_default(), |
513 | 4 | )] |
514 | 4 | } |
515 | | |
516 | | #[cfg(unix)] |
517 | 4 | fn shell_worker_key(script: &Path) -> WorkerKey { |
518 | 4 | WorkerKey { |
519 | 4 | executable: PathBuf::from("/bin/sh"), |
520 | 4 | startup_args: vec![script.display().to_string()], |
521 | 4 | wire_format: WireFormat::Json, |
522 | 4 | env: path_env(), |
523 | 4 | } |
524 | 4 | } |
525 | | |
526 | | #[test] |
527 | 1 | fn worker_key_excludes_argfile_and_later_args() { |
528 | 1 | let argv: Vec<String> = ["javac", "-source", "21", "@args.txt", "Foo.java"] |
529 | 1 | .iter() |
530 | 5 | .map1 (|s| (*s).to_string()) |
531 | 1 | .collect(); |
532 | 1 | let key = WorkerKey::from_argv(&argv, WireFormat::Proto).unwrap(); |
533 | 1 | assert_eq!(key.executable, PathBuf::from("javac")); |
534 | 1 | assert_eq!(key.startup_args, vec!["-source".to_string(), "21".into()]); |
535 | 1 | } |
536 | | |
537 | | #[test] |
538 | 1 | fn worker_key_handles_empty_startup_args() { |
539 | 1 | let argv: Vec<String> = ["javac", "@args.txt"] |
540 | 1 | .iter() |
541 | 2 | .map1 (|s| (*s).to_string()) |
542 | 1 | .collect(); |
543 | 1 | let key = WorkerKey::from_argv(&argv, WireFormat::Json).unwrap(); |
544 | 1 | assert_eq!(key.startup_args, Vec::<String>::new()); |
545 | 1 | assert_eq!(key.wire_format, WireFormat::Json); |
546 | 1 | } |
547 | | |
548 | | #[test] |
549 | 1 | fn worker_key_handles_no_argfile() { |
550 | | // No @argfile means *all* trailing args are startup flags. Unusual but |
551 | | // legal — the action just doesn't use a response file. |
552 | 1 | let argv: Vec<String> = ["scalac", "-deprecation", "-Xfatal-warnings"] |
553 | 1 | .iter() |
554 | 3 | .map1 (|s| (*s).to_string()) |
555 | 1 | .collect(); |
556 | 1 | let key = WorkerKey::from_argv(&argv, WireFormat::Proto).unwrap(); |
557 | 1 | assert_eq!(key.startup_args.len(), 2); |
558 | 1 | } |
559 | | |
560 | | #[test] |
561 | 1 | fn worker_key_rejects_empty_argv() { |
562 | 1 | let argv: Vec<String> = Vec::new(); |
563 | 1 | assert!(WorkerKey::from_argv(&argv, WireFormat::Proto).is_err()); |
564 | 1 | } |
565 | | |
566 | | #[test] |
567 | 1 | fn worker_key_equality_collapses_compatible_actions() { |
568 | 1 | let a = WorkerKey::from_argv( |
569 | 1 | &["javac", "-source", "21", "@a"].map(String::from), |
570 | 1 | WireFormat::Proto, |
571 | | ) |
572 | 1 | .unwrap(); |
573 | 1 | let b = WorkerKey::from_argv( |
574 | 1 | &["javac", "-source", "21", "@b"].map(String::from), |
575 | 1 | WireFormat::Proto, |
576 | | ) |
577 | 1 | .unwrap(); |
578 | 1 | assert_eq!(a, b); |
579 | 1 | } |
580 | | |
581 | | #[test] |
582 | 1 | fn worker_key_distinguishes_different_startup_args() { |
583 | 1 | let a = WorkerKey::from_argv( |
584 | 1 | &["javac", "-source", "21", "@a"].map(String::from), |
585 | 1 | WireFormat::Proto, |
586 | | ) |
587 | 1 | .unwrap(); |
588 | 1 | let b = WorkerKey::from_argv( |
589 | 1 | &["javac", "-source", "17", "@a"].map(String::from), |
590 | 1 | WireFormat::Proto, |
591 | | ) |
592 | 1 | .unwrap(); |
593 | 1 | assert_ne!(a, b); |
594 | 1 | } |
595 | | |
596 | | #[test] |
597 | 1 | fn worker_key_distinguishes_wire_formats() { |
598 | 1 | let a = |
599 | 1 | WorkerKey::from_argv(&["javac", "@a"].map(String::from), WireFormat::Proto).unwrap(); |
600 | 1 | let b = WorkerKey::from_argv(&["javac", "@a"].map(String::from), WireFormat::Json).unwrap(); |
601 | 1 | assert_ne!(a, b); |
602 | 1 | } |
603 | | |
604 | | #[test] |
605 | 1 | fn worker_key_distinguishes_environments_and_ignores_their_order() { |
606 | 1 | let base = |
607 | 3 | || WorkerKey::from_argv(&["javac", "@a"].map(String::from), WireFormat::Proto).unwrap(); |
608 | 1 | let a = base().with_env(vec![ |
609 | 1 | ("PATH".into(), "/bin".into()), |
610 | 1 | ("LANG".into(), "C".into()), |
611 | | ]); |
612 | 1 | let b = base().with_env(vec![ |
613 | 1 | ("LANG".into(), "C".into()), |
614 | 1 | ("PATH".into(), "/bin".into()), |
615 | | ]); |
616 | 1 | let c = base().with_env(vec![("PATH".into(), "/usr/bin".into())]); |
617 | 1 | assert_eq!(a, b); |
618 | 1 | assert_ne!(a, c); |
619 | 1 | } |
620 | | |
621 | | #[nativelink_test] |
622 | | #[cfg(unix)] |
623 | | async fn sweeper_retires_a_worker_idle_past_the_timeout() { |
624 | | let dir = tempfile::tempdir().unwrap(); |
625 | | let script = shell_script(dir.path(), "exec sleep 60\n"); |
626 | | let key = shell_worker_key(&script); |
627 | | let pool = PersistentWorkerPool::new(PoolConfig { |
628 | | idle_timeout: Duration::from_millis(200), |
629 | | shutdown_grace: Duration::from_millis(100), |
630 | | ..PoolConfig::default() |
631 | | }); |
632 | | let _sweeper = pool.spawn_sweeper(); |
633 | | |
634 | | let lease = pool |
635 | | .acquire(key, Path::new("/bin/sh"), dir.path()) |
636 | | .await |
637 | | .unwrap(); |
638 | | lease.release(true).await; |
639 | | assert_eq!(pool.live_workers(), 1); |
640 | | |
641 | | // The sweeper runs every second at least; by then the worker has |
642 | | // been idle far longer than its timeout. |
643 | | let deadline = tokio::time::Instant::now() + Duration::from_secs(5); |
644 | | while pool.live_workers() > 0 && tokio::time::Instant::now() < deadline { |
645 | | tokio::time::sleep(Duration::from_millis(100)).await; |
646 | | } |
647 | | assert_eq!(pool.live_workers(), 0, "the idle worker was not retired"); |
648 | | } |
649 | | |
650 | | #[nativelink_test] |
651 | | #[cfg(unix)] |
652 | | async fn dropping_unhealthy_release_frees_pool_slot() { |
653 | | let dir = tempfile::tempdir().unwrap(); |
654 | | let script = shell_script(dir.path(), "exec sleep 60\n"); |
655 | | let key = shell_worker_key(&script); |
656 | | let pool = PersistentWorkerPool::new(PoolConfig { |
657 | | max_workers_per_key: 1, |
658 | | shutdown_grace: Duration::from_secs(1), |
659 | | ..PoolConfig::default() |
660 | | }); |
661 | | |
662 | | let lease = pool |
663 | | .acquire(key.clone(), Path::new("/bin/sh"), dir.path()) |
664 | | .await |
665 | | .unwrap(); |
666 | | let release_handle = |
667 | | background_spawn!("persistent_worker_release_test", lease.release(false)); |
668 | | tokio::time::sleep(Duration::from_millis(50)).await; |
669 | | release_handle.abort(); |
670 | | assert!(release_handle.await.unwrap_err().is_cancelled()); |
671 | | |
672 | | let next_lease = tokio::time::timeout( |
673 | | Duration::from_secs(2), |
674 | | pool.acquire(key, Path::new("/bin/sh"), dir.path()), |
675 | | ) |
676 | | .await |
677 | | .unwrap() |
678 | | .unwrap(); |
679 | | next_lease.release(false).await; |
680 | | } |
681 | | |
682 | | #[nativelink_test] |
683 | | #[cfg(unix)] |
684 | | async fn acquire_at_the_cap_waits_for_a_returned_worker() { |
685 | | let dir = tempfile::tempdir().unwrap(); |
686 | | let script = shell_script(dir.path(), "exec sleep 60\n"); |
687 | | let key = shell_worker_key(&script); |
688 | | let pool = PersistentWorkerPool::new(PoolConfig { |
689 | | max_workers_per_key: 1, |
690 | | shutdown_grace: Duration::from_millis(100), |
691 | | acquire_timeout: Duration::from_secs(5), |
692 | | ..PoolConfig::default() |
693 | | }); |
694 | | |
695 | | let lease = pool |
696 | | .acquire(key.clone(), Path::new("/bin/sh"), dir.path()) |
697 | | .await |
698 | | .unwrap(); |
699 | | let waiter = { |
700 | | let pool = pool.clone(); |
701 | | let key = key.clone(); |
702 | | let dir = dir.path().to_path_buf(); |
703 | 1 | background_spawn!("persistent_worker_acquire_waiter", async move { |
704 | 1 | pool.acquire(key, Path::new("/bin/sh"), &dir).await |
705 | 1 | }) |
706 | | }; |
707 | | tokio::time::sleep(Duration::from_millis(100)).await; |
708 | | assert!( |
709 | | !waiter.is_finished(), |
710 | | "the second acquire should be waiting at the cap" |
711 | | ); |
712 | | lease.release(true).await; |
713 | | let second = tokio::time::timeout(Duration::from_secs(2), waiter) |
714 | | .await |
715 | | .expect("the waiter got the returned worker") |
716 | | .unwrap() |
717 | | .unwrap(); |
718 | | second.release(false).await; |
719 | | } |
720 | | |
721 | | #[nativelink_test] |
722 | | #[cfg(unix)] |
723 | | async fn concurrent_acquire_respects_per_key_cap() { |
724 | | let dir = tempfile::tempdir().unwrap(); |
725 | | let script = shell_script(dir.path(), "exec sleep 60\n"); |
726 | | let key = shell_worker_key(&script); |
727 | | let pool = PersistentWorkerPool::new(PoolConfig { |
728 | | max_workers_per_key: 1, |
729 | | shutdown_grace: Duration::from_millis(100), |
730 | | acquire_timeout: Duration::from_millis(200), |
731 | | ..PoolConfig::default() |
732 | | }); |
733 | | |
734 | | let lease = pool |
735 | | .acquire(key.clone(), Path::new("/bin/sh"), dir.path()) |
736 | | .await |
737 | | .unwrap(); |
738 | | let err = pool |
739 | | .acquire(key, Path::new("/bin/sh"), dir.path()) |
740 | | .await |
741 | | .unwrap_err(); |
742 | | assert_eq!(err.code, Code::ResourceExhausted); |
743 | | lease.release(false).await; |
744 | | } |
745 | | } |