/build/source/nativelink-service/src/execution_server.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::convert::Into; |
16 | | use core::pin::Pin; |
17 | | use core::time::Duration; |
18 | | use std::collections::HashMap; |
19 | | use std::fmt; |
20 | | use std::sync::Arc; |
21 | | use std::time::{SystemTime, UNIX_EPOCH}; |
22 | | |
23 | | use bytes::Bytes; |
24 | | use futures::stream::unfold; |
25 | | use futures::{Stream, StreamExt}; |
26 | | use nativelink_config::cas_server::{ExecutionConfig, InstanceName, WithInstanceName}; |
27 | | use nativelink_error::{Error, ErrorContext, ResultExt, make_input_err}; |
28 | | use nativelink_proto::build::bazel::remote::execution::v2::execution_server::{ |
29 | | Execution, ExecutionServer as Server, |
30 | | }; |
31 | | use nativelink_proto::build::bazel::remote::execution::v2::{ |
32 | | Action, Command, ExecuteRequest, WaitExecutionRequest, |
33 | | }; |
34 | | use nativelink_proto::google::longrunning::operations_server::{Operations, OperationsServer}; |
35 | | use nativelink_proto::google::longrunning::{ |
36 | | CancelOperationRequest, DeleteOperationRequest, GetOperationRequest, ListOperationsRequest, |
37 | | ListOperationsResponse, Operation, WaitOperationRequest, |
38 | | }; |
39 | | use nativelink_proto::google::rpc::{ |
40 | | PreconditionFailure, Status as GrpcStatusProto, precondition_failure, |
41 | | }; |
42 | | use nativelink_scheduler::known_platform_property_provider::KnownPlatformPropertyProvider; |
43 | | use nativelink_store::ac_utils::get_and_decode_digest; |
44 | | use nativelink_store::existence_cache_store::ExistenceCacheStore; |
45 | | use nativelink_store::store_manager::StoreManager; |
46 | | use nativelink_util::action_messages::{ |
47 | | ActionInfo, ActionStage, ActionState, ActionUniqueKey, ActionUniqueQualifier, |
48 | | DEFAULT_EXECUTION_PRIORITY, OperationId, TypeUrl, |
49 | | }; |
50 | | use nativelink_util::common::{self, DigestInfo}; |
51 | | use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc, make_ctx_for_hash_func}; |
52 | | use nativelink_util::metrics::{record_execute_stream_drop, record_execute_stream_heartbeat}; |
53 | | use nativelink_util::operation_state_manager::{ActionStateResult, OperationFilter}; |
54 | | use nativelink_util::origin_event::{BAZEL_METADATA_KEY, request_metadata_from_baggage}; |
55 | | use nativelink_util::spawn; |
56 | | use nativelink_util::store_trait::{Store, StoreLike}; |
57 | | use nativelink_util::task::JoinHandleDropGuard; |
58 | | use opentelemetry::Context; |
59 | | use opentelemetry::baggage::BaggageExt; |
60 | | use opentelemetry::context::FutureExt; |
61 | | use opentelemetry_semantic_conventions::attribute::ENDUSER_ID; |
62 | | use prost::Message as _; |
63 | | use tonic::{Code, Request, Response, Status}; |
64 | | use tracing::{Instrument, Level, debug, error, error_span, info, instrument, warn}; |
65 | | |
66 | | /// Result of a synchronous `Execute` decision before the async |
67 | | /// scheduling stream begins. Stream is the happy path; Reject is a |
68 | | /// client-facing gRPC `Status` returned without going through NL's |
69 | | /// internal Error/instrumentation pipeline. |
70 | | /// When an action fails because one of its blobs is missing, stop this |
71 | | /// instance's existence cache vouching for that blob before the client |
72 | | /// hears about it. Bazel answers the failure by asking `FindMissingBlobs` |
73 | | /// again and uploading what it is told is missing; if the cache still |
74 | | /// says present (the blob left the object store, which sends no removal |
75 | | /// event, after the cache learned it), nothing is uploaded and the retry |
76 | | /// fails the same way until the cache entry expires. |
77 | 40.9k | async fn forget_missing_input(cas_store: &Store, stage: &ActionStage) { |
78 | 40.9k | let ActionStage::Completed(result9.97k ) = stage else { |
79 | 31.0k | return; |
80 | | }; |
81 | 1 | let Some(ErrorContext::MissingDigest { hash, size }) = |
82 | 9.97k | result.error.as_ref().map(|err| &err.context) |
83 | | else { |
84 | 9.96k | return; |
85 | | }; |
86 | 1 | let Ok(digest) = DigestInfo::try_new(hash, *size) else { |
87 | 0 | return; |
88 | | }; |
89 | 1 | if let Some(cache) = cas_store.downcast_ref::<ExistenceCacheStore<SystemTime>>(None) { |
90 | 1 | cache.remove_from_cache(&digest).await; |
91 | 1 | debug!(%digest, "Action input missing; dropped it from the existence cache"); |
92 | 0 | } |
93 | 40.9k | } |
94 | | |
95 | | enum ExecuteOutcome<S> { |
96 | | Stream(S), |
97 | | Reject(Status), |
98 | | } |
99 | | |
100 | | /// Converts `Action.timeout` to a [`Duration`]. An absent timeout means "as |
101 | | /// long as the server allows", which the worker maps to its maximum. |
102 | | /// |
103 | | /// A `google.protobuf.Duration` with a negative field, or `nanos` outside |
104 | | /// `0..1_000_000_000`, is not a usable timeout. Casting it straight to |
105 | | /// unsigned integers turned a negative timeout into a near-infinite one, and |
106 | | /// `seconds: -1, nanos: -1` panicked in `Duration::new` when the nanosecond |
107 | | /// carry overflowed. Such a timeout is now rejected with `INVALID_ARGUMENT`. |
108 | 16.2k | fn action_timeout(timeout: Option<prost_types::Duration>) -> Result<Duration, Error> { |
109 | 16.2k | let Some(timeout4 ) = timeout else { |
110 | 16.2k | return Ok(Duration::MAX); |
111 | | }; |
112 | 4 | let (Ok(seconds1 ), Ok(nanos1 )) = (u64::try_from(timeout.seconds), u32::try_from(timeout.nanos)) |
113 | | else { |
114 | 3 | return Err(make_input_err!( |
115 | 3 | "Action.timeout must not be negative, got {}s {}ns", |
116 | 3 | timeout.seconds, |
117 | 3 | timeout.nanos |
118 | 3 | )); |
119 | | }; |
120 | 1 | if nanos >= 1_000_000_000 { |
121 | 1 | return Err(make_input_err!( |
122 | 1 | "Action.timeout nanos must be less than 1000000000, got {nanos}" |
123 | 1 | )); |
124 | 0 | } |
125 | 0 | Ok(Duration::new(seconds, nanos)) |
126 | 16.2k | } |
127 | | |
128 | | /// Build a tonic [`Status`] of code `FAILED_PRECONDITION` whose details |
129 | | /// carry a `google.rpc.PreconditionFailure` listing the missing CAS |
130 | | /// blobs. |
131 | | /// |
132 | | /// The pre-check that calls this is intentionally shallow: it only |
133 | | /// validates the top-level Action proto, `command_digest`, and |
134 | | /// `input_root_digest`. Nested Directory protos and file contents |
135 | | /// under the input root are not walked here — the worker path fetches |
136 | | /// those lazily and reports them via the same mechanism (see |
137 | | /// `action_messages::to_execute_response`, which dispatches on |
138 | | /// `Error::context`). |
139 | | /// |
140 | | /// Race note: the pre-check uses `has_many` then the action is |
141 | | /// scheduled; a blob present at check time may be evicted before the |
142 | | /// worker fetches it. That case is intentionally not addressed here — |
143 | | /// the worker path covers it with the same `FAILED_PRECONDITION` |
144 | | /// surfacing, so Bazel retries either way. |
145 | 4 | fn missing_blobs_failed_precondition( |
146 | 4 | missing: &[(DigestInfo, &'static str)], |
147 | 4 | summary: &str, |
148 | 4 | ) -> Status { |
149 | 4 | let pf = PreconditionFailure { |
150 | 4 | violations: missing |
151 | 4 | .iter() |
152 | 4 | .map(|(d, ctx)| precondition_failure::Violation { |
153 | 5 | r#type: common::VIOLATION_TYPE_MISSING.to_string(), |
154 | | // Per REv2, the subject for a missing-blob violation is |
155 | | // `blobs/<hash>/<size>` so the client knows exactly |
156 | | // which digest to re-upload. |
157 | 5 | subject: format!("blobs/{}/{}", d.packed_hash(), d.size_bytes()), |
158 | 5 | description: (*ctx).to_string(), |
159 | 5 | }) |
160 | 4 | .collect(), |
161 | | }; |
162 | | |
163 | | // Wrap PreconditionFailure into a google.protobuf.Any. |
164 | 4 | let mut pf_buf: Vec<u8> = Vec::with_capacity(pf.encoded_len()); |
165 | 4 | pf.encode(&mut pf_buf) |
166 | 4 | .expect("encoding prost message into Vec<u8> cannot fail"); |
167 | 4 | let any = prost_types::Any { |
168 | 4 | type_url: PreconditionFailure::TYPE_URL.to_string(), |
169 | 4 | value: pf_buf, |
170 | 4 | }; |
171 | | |
172 | 4 | let status_proto = GrpcStatusProto { |
173 | 4 | code: Code::FailedPrecondition as i32, |
174 | 4 | message: summary.to_string(), |
175 | 4 | details: vec![any], |
176 | 4 | }; |
177 | 4 | let mut status_buf: Vec<u8> = Vec::with_capacity(status_proto.encoded_len()); |
178 | 4 | status_proto |
179 | 4 | .encode(&mut status_buf) |
180 | 4 | .expect("encoding prost message into Vec<u8> cannot fail"); |
181 | | |
182 | 4 | Status::with_details( |
183 | 4 | Code::FailedPrecondition, |
184 | 4 | summary.to_string(), |
185 | 4 | Bytes::from(status_buf), |
186 | | ) |
187 | 4 | } |
188 | | |
189 | | type InstanceInfoName = String; |
190 | | |
191 | | struct NativelinkOperationId { |
192 | | instance_name: InstanceInfoName, |
193 | | client_operation_id: OperationId, |
194 | | } |
195 | | |
196 | | impl NativelinkOperationId { |
197 | 16.6k | const fn new(instance_name: InstanceInfoName, client_operation_id: OperationId) -> Self { |
198 | 16.6k | Self { |
199 | 16.6k | instance_name, |
200 | 16.6k | client_operation_id, |
201 | 16.6k | } |
202 | 16.6k | } |
203 | | } |
204 | | |
205 | | /// Splits an operation name into the instance that serves it and the id the |
206 | | /// scheduler knows, trying the configured instance names longest first. A |
207 | | /// client operation id may itself carry a `/`: a grpc scheduler hands back the |
208 | | /// upstream's `<instance>/<uuid>` as the id, so the name a client holds reads |
209 | | /// `main/main/<uuid>`, and splitting at the last `/` would look for an |
210 | | /// instance called `main/main`. The instance is the longest configured name |
211 | | /// the operation name starts with, and the id is whatever follows it. |
212 | 424 | fn resolve_operation_name<'a>( |
213 | 424 | instances: impl IntoIterator<Item = &'a str>, |
214 | 424 | name: &str, |
215 | 424 | ) -> Option<(&'a str, OperationId)> { |
216 | 424 | let mut instances: Vec<&str> = instances.into_iter().collect(); |
217 | 424 | instances.sort_by_key(|instance| core::cmp::Reverse(instance42 .len42 ())); |
218 | 434 | instances.into_iter()424 .find_map424 (|instance| { |
219 | 434 | name.strip_prefix(instance) |
220 | 434 | .and_then(|rest| rest426 .strip_prefix426 ('/')) |
221 | 434 | .filter(|id| !id422 .is_empty422 ()) |
222 | 434 | .map(|id| (instance421 , OperationId::from421 (id421 ))) |
223 | 434 | }) |
224 | 424 | } |
225 | | |
226 | | impl fmt::Display for NativelinkOperationId { |
227 | 32.8k | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
228 | 32.8k | write!(f, "{}/{}", self.instance_name, self.client_operation_id) |
229 | 32.8k | } |
230 | | } |
231 | | |
232 | | #[derive(Clone)] |
233 | | struct InstanceInfo { |
234 | | scheduler: Arc<dyn KnownPlatformPropertyProvider>, |
235 | | cas_store: Store, |
236 | | buck2_invocation_isolation: bool, |
237 | | /// Re-send the current operation state on an otherwise idle `Execute` |
238 | | /// or `WaitExecution` stream at this interval. `None` sends no |
239 | | /// heartbeats. |
240 | | execute_stream_heartbeat: Option<Duration>, |
241 | | /// The time an action is queued at. |
242 | | now_fn: NowFn, |
243 | | } |
244 | | |
245 | | /// The clock queued actions are stamped from. |
246 | | pub type NowFn = Arc<dyn Fn() -> SystemTime + Send + Sync>; |
247 | | |
248 | | impl fmt::Debug for InstanceInfo { |
249 | 0 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
250 | 0 | f.debug_struct("InstanceInfo") |
251 | 0 | .field("cas_store", &self.cas_store) |
252 | 0 | .finish_non_exhaustive() |
253 | 0 | } |
254 | | } |
255 | | |
256 | | impl InstanceInfo { |
257 | 16.2k | fn execution_scope(&self) -> Option<String> { |
258 | 16.2k | if !self.buck2_invocation_isolation { |
259 | 16.1k | return None; |
260 | 7 | } |
261 | 7 | let context = Context::current(); |
262 | 7 | let baggage = context.baggage(); |
263 | 6 | let metadata = |
264 | 7 | request_metadata_from_baggage6 (baggage.get(BAZEL_METADATA_KEY)?1 .as_str6 ()).ok6 ()?0 ; |
265 | 6 | if metadata.tool_invocation_id.is_empty() |
266 | 5 | || !metadata |
267 | 5 | .tool_details |
268 | 5 | .as_ref() |
269 | 5 | .is_some_and(|tool| tool.tool_name.eq_ignore_ascii_case("buck2")) |
270 | | { |
271 | 2 | return None; |
272 | 4 | } |
273 | 4 | let identity = baggage |
274 | 4 | .get(ENDUSER_ID) |
275 | 4 | .map(opentelemetry::StringValue::as_str) |
276 | 4 | .unwrap_or_default(); |
277 | 4 | let mut hash = DigestHasherFunc::Sha256.hasher(); |
278 | | // Length-prefix the identity so the two fields cannot alias. Use a |
279 | | // fixed width so all scheduler architectures compute the same scope. |
280 | 4 | hash.update(&(identity.len() as u64).to_be_bytes()); |
281 | 4 | hash.update(identity.as_bytes()); |
282 | 4 | hash.update(metadata.tool_invocation_id.as_bytes()); |
283 | 4 | Some(hash.finalize_digest().packed_hash().to_string()) |
284 | 16.2k | } |
285 | | |
286 | 16.2k | async fn build_action_info( |
287 | 16.2k | &self, |
288 | 16.2k | instance_name: String, |
289 | 16.2k | action_digest: DigestInfo, |
290 | 16.2k | action: Action, |
291 | 16.2k | priority: i32, |
292 | 16.2k | skip_cache_lookup: bool, |
293 | 16.2k | digest_function: DigestHasherFunc, |
294 | 16.2k | ) -> Result<ActionInfo, Error> { |
295 | 16.2k | let command_digest = DigestInfo::try_from( |
296 | 16.2k | action |
297 | 16.2k | .command_digest |
298 | 16.2k | .clone() |
299 | 16.2k | .ok_or_else(|| make_input_err!0 ("Expected command_digest to exist"))?0 , |
300 | | ) |
301 | 16.2k | .err_tip(|| "Could not decode command digest")?0 ; |
302 | | |
303 | 16.2k | let input_root_digest = DigestInfo::try_from( |
304 | 16.2k | action |
305 | 16.2k | .clone() |
306 | 16.2k | .input_root_digest |
307 | 16.2k | .ok_or_else(|| make_input_err!0 ("Expected input_digest_root"))?0 , |
308 | 0 | )?; |
309 | 16.2k | let timeout16.2k = action_timeout(action.timeout)?4 ; |
310 | | |
311 | 16.2k | let mut platform_properties = HashMap::new(); |
312 | 16.2k | if let Some(platform2 ) = action.platform { |
313 | 2 | for property in platform.properties { |
314 | 2 | platform_properties.insert(property.name, property.value); |
315 | 2 | } |
316 | 16.2k | } |
317 | | |
318 | | // Goma puts the properties in the Command. |
319 | 16.2k | if platform_properties.is_empty() { |
320 | 16.2k | let command = |
321 | 16.2k | get_and_decode_digest::<Command>(&self.cas_store, command_digest.into()).await?0 ; |
322 | 16.2k | if let Some(platform0 ) = command.platform { |
323 | 0 | for property in platform.properties { |
324 | 0 | platform_properties.insert(property.name, property.value); |
325 | 0 | } |
326 | 16.2k | } |
327 | 2 | } |
328 | | |
329 | 16.2k | let action_key = ActionUniqueKey { |
330 | 16.2k | execution_scope: self.execution_scope(), |
331 | 16.2k | instance_name, |
332 | 16.2k | digest_function, |
333 | 16.2k | digest: action_digest, |
334 | 16.2k | }; |
335 | | // REv2 `Action.do_not_cache`: "the Action's result cannot be cached, |
336 | | // and in-flight requests for the same Action may not be merged". |
337 | | // `Uncacheable` skips the cache lookup and the in-flight merge. |
338 | 16.2k | let unique_qualifier = if skip_cache_lookup || action.do_not_cache15.8k { |
339 | 339 | ActionUniqueQualifier::Uncacheable(action_key) |
340 | | } else { |
341 | 15.8k | ActionUniqueQualifier::Cacheable(action_key) |
342 | | }; |
343 | | |
344 | 16.2k | Ok(ActionInfo { |
345 | 16.2k | command_digest, |
346 | 16.2k | input_root_digest, |
347 | 16.2k | timeout, |
348 | 16.2k | platform_properties, |
349 | 16.2k | priority, |
350 | 16.2k | load_timestamp: UNIX_EPOCH, |
351 | 16.2k | insert_timestamp: (self.now_fn)(), |
352 | 16.2k | unique_qualifier, |
353 | 16.2k | }) |
354 | 16.2k | } |
355 | | } |
356 | | |
357 | | #[derive(Debug, Clone)] |
358 | | pub struct ExecutionServer { |
359 | | instance_infos: HashMap<InstanceName, InstanceInfo>, |
360 | | } |
361 | | |
362 | | type ExecuteStream = Pin<Box<dyn Stream<Item = Result<Operation, Status>> + Send>>; |
363 | | |
364 | | /// Watches an `Execute`/`WaitExecution` response stream so a stream torn |
365 | | /// down before its operation finished is recorded — usually a client that |
366 | | /// walked away, but a server shutdown counts too. tonic drops the stream on |
367 | | /// disconnect without running any handler code, so the `Drop` impl is the |
368 | | /// only place the server can see it; the operation's fate after this is the |
369 | | /// scheduler's client keepalive timeout, which the dropped subscriber (or |
370 | | /// the aborted drive task) stops refreshing. |
371 | | struct ExecuteStreamMonitor { |
372 | | inner: ExecuteStream, |
373 | | operation_id: String, |
374 | | finished: bool, |
375 | | } |
376 | | |
377 | | impl Stream for ExecuteStreamMonitor { |
378 | | type Item = Result<Operation, Status>; |
379 | | |
380 | 626k | fn poll_next( |
381 | 626k | mut self: Pin<&mut Self>, |
382 | 626k | cx: &mut core::task::Context<'_>, |
383 | 626k | ) -> core::task::Poll<Option<Self::Item>> { |
384 | 626k | let this = self.as_mut().get_mut(); |
385 | 626k | let poll = this.inner.as_mut().poll_next(cx); |
386 | 626k | if let core::task::Poll::Ready(item40.9k ) = &poll { |
387 | 40.9k | match item { |
388 | 40.9k | Some(Ok(operation)) if operation.done9.97k => this.finished = true9.97k , |
389 | 7 | Some(Err(_)) | None => this.finished = true, |
390 | 31.0k | Some(Ok(_)) => {} |
391 | | } |
392 | 585k | } |
393 | 626k | poll |
394 | 626k | } |
395 | | } |
396 | | |
397 | | impl Drop for ExecuteStreamMonitor { |
398 | 16.4k | fn drop(&mut self) { |
399 | 16.4k | if !self.finished { |
400 | 6.43k | info!( |
401 | | operation_id = %self.operation_id, |
402 | | "Execute stream dropped before the operation finished" |
403 | | ); |
404 | 6.43k | record_execute_stream_drop(); |
405 | 9.97k | } |
406 | 16.4k | } |
407 | | } |
408 | | |
409 | | impl ExecutionServer { |
410 | 215 | pub fn new( |
411 | 215 | configs: &[WithInstanceName<ExecutionConfig>], |
412 | 215 | scheduler_map: &HashMap<String, Arc<dyn KnownPlatformPropertyProvider>>, |
413 | 215 | store_manager: &StoreManager, |
414 | 215 | ) -> Result<Self, Error> { |
415 | 215 | let mut instance_infos = HashMap::with_capacity(configs.len()); |
416 | 215 | for config in configs { |
417 | 215 | let cas_store = store_manager.get_store(&config.cas_store).ok_or_else(|| {0 |
418 | 0 | make_input_err!("'cas_store': '{}' does not exist", config.cas_store) |
419 | 0 | })?; |
420 | 215 | let scheduler = scheduler_map |
421 | 215 | .get(&config.scheduler) |
422 | 215 | .err_tip(|| {0 |
423 | 0 | format!( |
424 | | "Scheduler needs config for '{}' because it exists in execution", |
425 | 0 | config.scheduler |
426 | | ) |
427 | 0 | })? |
428 | 215 | .clone(); |
429 | | |
430 | 215 | instance_infos.insert( |
431 | 215 | config.instance_name.clone(), |
432 | | InstanceInfo { |
433 | 215 | scheduler, |
434 | 215 | cas_store, |
435 | 215 | buck2_invocation_isolation: config.experimental_buck2_invocation_isolation, |
436 | 215 | execute_stream_heartbeat: (config.execute_stream_heartbeat_s > 0) |
437 | 215 | .then(|| Duration::from_secs1 (config.execute_stream_heartbeat_s as u641 )), |
438 | 215 | now_fn: Arc::new(SystemTime::now), |
439 | | }, |
440 | | ); |
441 | | } |
442 | 215 | Ok(Self { instance_infos }) |
443 | 215 | } |
444 | | |
445 | | /// Stamps queued actions from `now_fn` instead of the wall clock, so |
446 | | /// they share the scheduler's clock in a test that injects one. |
447 | | #[must_use] |
448 | 188 | pub fn with_now_fn(mut self, now_fn: impl Fn() -> SystemTime + Send + Sync + 'static) -> Self { |
449 | 188 | let now_fn: NowFn = Arc::new(now_fn); |
450 | 188 | for info in self.instance_infos.values_mut() { |
451 | 188 | info.now_fn = now_fn.clone(); |
452 | 188 | } |
453 | 188 | self |
454 | 188 | } |
455 | | |
456 | 1 | pub fn into_service(self) -> Server<Self> { |
457 | 1 | Server::new(self) |
458 | 1 | } |
459 | | |
460 | 0 | pub fn into_operations_service(self) -> OperationsServer<Self> { |
461 | 0 | OperationsServer::new(self) |
462 | 0 | } |
463 | | |
464 | | /// Converts one action state into the client's `Operation`, with the |
465 | | /// side effects every sent update carries. The bool says whether the |
466 | | /// operation is finished. |
467 | 40.9k | async fn operation_from_update( |
468 | 40.9k | action_update: Arc<ActionState>, |
469 | 40.9k | client_operation_id: &OperationId, |
470 | 40.9k | cas_store: &Store, |
471 | 40.9k | ) -> (Operation, bool) { |
472 | 40.9k | debug!(?action_update, "Execute Resp Stream"); |
473 | 40.9k | forget_missing_input(cas_store, &action_update.stage).await; |
474 | 40.9k | let finished = action_update.stage.is_finished(); |
475 | 40.9k | ( |
476 | 40.9k | action_update.as_operation(client_operation_id.clone()), |
477 | 40.9k | finished, |
478 | 40.9k | ) |
479 | 40.9k | } |
480 | | |
481 | | /// Waits for the next update and converts it for the client. The bool |
482 | | /// says whether the stream is over (a finished stage or an error). |
483 | 47.3k | async fn next_operation( |
484 | 47.3k | action_listener: &mut Box<dyn ActionStateResult>, |
485 | 47.3k | client_operation_id: &OperationId, |
486 | 47.3k | cas_store: &Store, |
487 | 47.3k | ) -> (Result<Operation, Status>, bool) { |
488 | 47.3k | match action_listener.changed().await { |
489 | 40.9k | Ok((action_update, _maybe_origin_metadata)) => { |
490 | 40.9k | let (operation, finished) = |
491 | 40.9k | Self::operation_from_update(action_update, client_operation_id, cas_store) |
492 | 40.9k | .await; |
493 | 40.9k | (Ok(operation), finished) |
494 | | } |
495 | 6 | Err(err) => { |
496 | 6 | error!(?err, "Error in action_listener stream"); |
497 | 6 | (Err(err.into()), true) |
498 | | } |
499 | | } |
500 | 40.9k | } |
501 | | |
502 | 16.4k | fn to_execute_stream( |
503 | 16.4k | nl_client_operation_id: &NativelinkOperationId, |
504 | 16.4k | action_listener: Box<dyn ActionStateResult>, |
505 | 16.4k | cas_store: Store, |
506 | 16.4k | heartbeat_interval: Option<Duration>, |
507 | 16.4k | ) -> impl Stream<Item = Result<Operation, Status>> + Send + use<> { |
508 | | struct HeartbeatState { |
509 | | rx: tokio::sync::mpsc::Receiver<Result<Operation, Status>>, |
510 | | /// The last update sent, re-sent as the heartbeat. |
511 | | last_operation: Option<Operation>, |
512 | | /// Aborts the driving task when the stream is dropped. |
513 | | _drive_guard: JoinHandleDropGuard<()>, |
514 | | } |
515 | 16.4k | let client_operation_id = OperationId::from(nl_client_operation_id.to_string()); |
516 | 16.4k | let inner: ExecuteStream = if let Some(interval1 ) = heartbeat_interval { |
517 | | // `changed()` must be awaited without interruption: subscriber |
518 | | // implementations keep their no-progress watchdog (the |
519 | | // `no_event_action_timeout` select in the state manager) inside |
520 | | // one `changed()` call, so cancelling it on a timer would reset |
521 | | // that watchdog forever. A task drives the listener and feeds a |
522 | | // channel, whose `recv()` is cancel-safe, and the heartbeat |
523 | | // timer races the channel instead. |
524 | 1 | let (tx, rx) = tokio::sync::mpsc::channel::<Result<Operation, Status>>(1); |
525 | 1 | let task_client_operation_id = client_operation_id.clone(); |
526 | 1 | let mut action_listener = action_listener; |
527 | | // The guard aborts the task when the stream is dropped, so a |
528 | | // client disconnect still drops the subscriber and lets its |
529 | | // keepalive lapse, exactly as without the heartbeat. |
530 | 1 | let drive_guard = spawn!("execute_stream_updates", async move { |
531 | | // Send the current state first: it seeds the heartbeat, so |
532 | | // the stream is never silent while waiting for the first |
533 | | // transition. Subscribers typically re-yield the current |
534 | | // state as their first update too; duplicates are permitted. |
535 | 1 | match action_listener.as_state().await { |
536 | 1 | Ok((state, _maybe_origin_metadata)) => { |
537 | 1 | let (operation, finished) = Self::operation_from_update( |
538 | 1 | state, |
539 | 1 | &task_client_operation_id, |
540 | 1 | &cas_store, |
541 | | ) |
542 | 1 | .await; |
543 | 1 | if tx.send(Ok(operation)).await.is_err() || finished { |
544 | 0 | return; |
545 | 1 | } |
546 | | } |
547 | 0 | Err(err) => { |
548 | 0 | drop(tx.send(Err(err.into())).await); |
549 | 0 | return; |
550 | | } |
551 | | } |
552 | | loop { |
553 | 2 | let (item1 , finished1 ) = Self::next_operation( |
554 | 2 | &mut action_listener, |
555 | 2 | &task_client_operation_id, |
556 | 2 | &cas_store, |
557 | | ) |
558 | 2 | .await; |
559 | 1 | if tx.send(item).await.is_err() || finished { |
560 | 0 | return; |
561 | 1 | } |
562 | | } |
563 | 0 | }); |
564 | 1 | Box::pin(unfold( |
565 | 1 | Some(HeartbeatState { |
566 | 1 | rx, |
567 | 1 | last_operation: None, |
568 | 1 | _drive_guard: drive_guard, |
569 | 1 | }), |
570 | 4 | move |maybe_state| async move { |
571 | 4 | let mut state = maybe_state?0 ; |
572 | 4 | let received2 = if state.last_operation.is_some() { |
573 | 3 | match tokio::time::timeout(interval, state.rx.recv()).await { |
574 | 1 | Ok(received) => received, |
575 | | // No update within the interval: re-send the last |
576 | | // state. `recv()` is cancel-safe, so no update is |
577 | | // lost, and the listener keeps running in its task. |
578 | 2 | Err(_elapsed) => { |
579 | 2 | let heartbeat = |
580 | 2 | state.last_operation.clone().expect("checked is_some above"); |
581 | 2 | record_execute_stream_heartbeat(); |
582 | 2 | return Some((Ok(heartbeat), Some(state))); |
583 | | } |
584 | | } |
585 | | } else { |
586 | 1 | state.rx.recv().await |
587 | | }; |
588 | 2 | match received { |
589 | 2 | Some(Ok(operation)) => { |
590 | 2 | let finished = operation.done; |
591 | 2 | if !finished { |
592 | 2 | state.last_operation = Some(operation.clone()); |
593 | 2 | }0 |
594 | 2 | Some((Ok(operation), (!finished).then_some(state))) |
595 | | } |
596 | 0 | Some(Err(err)) => Some((Err(err), None)), |
597 | | // The producer only returns after a terminal item or |
598 | | // when this side is gone, so an empty channel here |
599 | | // means the drive task died (a panic). Surface an |
600 | | // error rather than a clean, successful-looking end. |
601 | 0 | None => Some(( |
602 | 0 | Err(Status::internal( |
603 | 0 | "Execute stream update task ended before the operation finished", |
604 | 0 | )), |
605 | 0 | None, |
606 | 0 | )), |
607 | | } |
608 | 8 | }, |
609 | | )) |
610 | | } else { |
611 | 16.4k | Box::pin(unfold( |
612 | 16.4k | Some(action_listener), |
613 | 47.3k | move |maybe_action_listener| { |
614 | 47.3k | let client_operation_id = client_operation_id.clone(); |
615 | 47.3k | let cas_store = cas_store.clone(); |
616 | 47.3k | async move { |
617 | 47.3k | let mut action_listener47.3k = maybe_action_listener?1 ; |
618 | 47.3k | let (item40.9k , finished40.9k ) = Self::next_operation( |
619 | 47.3k | &mut action_listener, |
620 | 47.3k | &client_operation_id, |
621 | 47.3k | &cas_store, |
622 | | ) |
623 | 47.3k | .await; |
624 | 40.9k | Some((item, (!finished).then_some(action_listener))) |
625 | 40.9k | } |
626 | 47.3k | }, |
627 | | )) |
628 | | }; |
629 | 16.4k | ExecuteStreamMonitor { |
630 | 16.4k | inner, |
631 | 16.4k | operation_id: nl_client_operation_id.to_string(), |
632 | 16.4k | finished: false, |
633 | 16.4k | } |
634 | 16.4k | } |
635 | | |
636 | 16.2k | async fn inner_execute( |
637 | 16.2k | &self, |
638 | 16.2k | request: ExecuteRequest, |
639 | 16.2k | ) -> Result<ExecuteOutcome<impl Stream<Item = Result<Operation, Status>> + Send + use<>>, Error> |
640 | 16.2k | { |
641 | 16.2k | let instance_name = request.instance_name; |
642 | | |
643 | 16.2k | let instance_info16.2k = self.instance_infos.get(&instance_name).ok_or_else(|| {1 |
644 | 1 | make_input_err!("'instance_name' not configured for '{instance_name}'") |
645 | 1 | })?; |
646 | | |
647 | 16.2k | let digest = DigestInfo::try_from( |
648 | 16.2k | request |
649 | 16.2k | .action_digest |
650 | 16.2k | .ok_or_else(|| make_input_err!0 ("Expected action_digest to exist"))?0 , |
651 | | ) |
652 | 16.2k | .err_tip(|| "Failed to unwrap action cache")?0 ; |
653 | | |
654 | 16.2k | let priority = request |
655 | 16.2k | .execution_policy |
656 | 16.2k | .map_or(DEFAULT_EXECUTION_PRIORITY, |p| p.priority); |
657 | | |
658 | 16.2k | let action16.2k = match get_and_decode_digest::<Action>(&instance_info.cas_store, digest.into()) |
659 | 16.2k | .await |
660 | | { |
661 | 16.2k | Ok(a) => a, |
662 | 1 | Err(e) if e.code == Code::NotFound => { |
663 | 1 | warn!( |
664 | | %digest, |
665 | | %e, |
666 | | "Execute: Action proto missing from CAS; returning FAILED_PRECONDITION with PreconditionFailure detail so Bazel can re-upload" |
667 | | ); |
668 | 1 | let summary = format!( |
669 | | "Action {digest} is missing from CAS; client should re-upload it and retry" |
670 | | ); |
671 | 1 | return Ok(ExecuteOutcome::Reject(missing_blobs_failed_precondition( |
672 | 1 | &[(digest, "Action")], |
673 | 1 | &summary, |
674 | 1 | ))); |
675 | | } |
676 | 0 | Err(e) => return Err(e).err_tip(|| "Decoding Action proto in Execute")?, |
677 | | }; |
678 | | |
679 | 16.2k | let action_command_digest = action |
680 | 16.2k | .command_digest |
681 | 16.2k | .as_ref() |
682 | 16.2k | .map(|d| DigestInfo::try_from(d.clone())) |
683 | 16.2k | .transpose() |
684 | 16.2k | .err_tip(|| "Failed to parse command_digest from Action")?0 ; |
685 | 16.2k | let action_input_root_digest = action |
686 | 16.2k | .input_root_digest |
687 | 16.2k | .as_ref() |
688 | 16.2k | .map(|d| DigestInfo::try_from(d.clone())) |
689 | 16.2k | .transpose() |
690 | 16.2k | .err_tip(|| "Failed to parse input_root_digest from Action")?0 ; |
691 | 16.2k | let mut blobs_to_check: Vec<DigestInfo> = Vec::with_capacity(2); |
692 | 16.2k | if let Some(d) = action_command_digest { |
693 | 16.2k | blobs_to_check.push(d); |
694 | 16.2k | }0 |
695 | 16.2k | if let Some(d) = action_input_root_digest { |
696 | 16.2k | blobs_to_check.push(d); |
697 | 16.2k | }0 |
698 | 16.2k | if !blobs_to_check.is_empty() { |
699 | 32.4k | let store_keys16.2k : Vec<_>16.2k = blobs_to_check.iter()16.2k .map16.2k (|d| (*d).into()).collect16.2k (); |
700 | 16.2k | let sizes = instance_info |
701 | 16.2k | .cas_store |
702 | 16.2k | .has_many(&store_keys) |
703 | 16.2k | .await |
704 | 16.2k | .err_tip(|| "Validating Action input blobs in CAS")?0 ; |
705 | 16.2k | let mut missing: Vec<(DigestInfo, &'static str)> = Vec::new(); |
706 | 32.4k | for ((digest, present), label) in blobs_to_check |
707 | 16.2k | .iter() |
708 | 16.2k | .zip(sizes.iter()) |
709 | 16.2k | .zip(["Action.command_digest", "Action.input_root_digest"].iter()) |
710 | | { |
711 | 32.4k | if present.is_none() { |
712 | 4 | missing.push((*digest, label)); |
713 | 32.4k | } |
714 | | } |
715 | 16.2k | if !missing.is_empty() { |
716 | 3 | warn!( |
717 | | ?missing, |
718 | | %digest, |
719 | | "Execute pre-check found missing CAS blobs; returning FAILED_PRECONDITION with PreconditionFailure detail so Bazel can re-upload" |
720 | | ); |
721 | 3 | let summary = format!( |
722 | | "{} CAS blob(s) referenced by action {} are missing; client should re-upload them and retry", |
723 | 3 | missing.len(), |
724 | | digest, |
725 | | ); |
726 | 3 | return Ok(ExecuteOutcome::Reject(missing_blobs_failed_precondition( |
727 | 3 | &missing, &summary, |
728 | 3 | ))); |
729 | 16.2k | } |
730 | 0 | } |
731 | | |
732 | 16.2k | let action_info16.2k = instance_info |
733 | 16.2k | .build_action_info( |
734 | 16.2k | instance_name.clone(), |
735 | 16.2k | digest, |
736 | 16.2k | action, |
737 | 16.2k | priority, |
738 | 16.2k | request.skip_cache_lookup, |
739 | 16.2k | request |
740 | 16.2k | .digest_function |
741 | 16.2k | .try_into() |
742 | 16.2k | .err_tip(|| "Could not convert digest function in inner_execute()")?0 , |
743 | | ) |
744 | 16.2k | .await?4 ; |
745 | | |
746 | 16.2k | let action_listener = instance_info |
747 | 16.2k | .scheduler |
748 | 16.2k | .add_action(OperationId::default(), Arc::new(action_info)) |
749 | 16.2k | .await |
750 | 16.2k | .err_tip(|| "Failed to schedule task")?0 ; |
751 | | |
752 | 16.2k | Ok(ExecuteOutcome::Stream(Self::to_execute_stream( |
753 | 16.2k | &NativelinkOperationId::new( |
754 | 16.2k | instance_name, |
755 | 16.2k | action_listener |
756 | 16.2k | .as_state() |
757 | 16.2k | .await |
758 | 16.2k | .err_tip(|| "In ExecutionServer::inner_execute")?0 |
759 | | .0 |
760 | | .client_operation_id |
761 | 16.2k | .clone(), |
762 | | ), |
763 | 16.2k | action_listener, |
764 | 16.2k | instance_info.cas_store.clone(), |
765 | 16.2k | instance_info.execute_stream_heartbeat, |
766 | | ))) |
767 | 16.2k | } |
768 | | |
769 | 417 | async fn inner_wait_execution( |
770 | 417 | &self, |
771 | 417 | request: WaitExecutionRequest, |
772 | 417 | ) -> Result<impl Stream<Item = Result<Operation, Status>> + Send + use<>, Status> { |
773 | 417 | let Some((instance_name, client_operation_id)) = resolve_operation_name( |
774 | 417 | self.instance_infos.keys().map(String::as_str), |
775 | 417 | &request.name, |
776 | 417 | ) else { |
777 | 0 | return Err(Status::not_found(format!( |
778 | 0 | "No scheduler serves the instance of operation {}", |
779 | 0 | request.name, |
780 | 0 | ))); |
781 | | }; |
782 | 417 | let nl_operation_id = |
783 | 417 | NativelinkOperationId::new(instance_name.to_string(), client_operation_id); |
784 | 417 | let instance_info = self |
785 | 417 | .instance_infos |
786 | 417 | .get(instance_name) |
787 | 417 | .ok_or_else(|| Status::internal0 ("Instance resolved from its own map is missing"))?0 ; |
788 | 417 | let Some(rx210 ) = instance_info |
789 | 417 | .scheduler |
790 | 417 | .filter_operations(OperationFilter { |
791 | 417 | client_operation_id: Some(nl_operation_id.client_operation_id.clone()), |
792 | 417 | ..Default::default() |
793 | 417 | }) |
794 | 417 | .await |
795 | 417 | .err_tip(|| "Error running find_existing_action in ExecutionServer::wait_execution")?0 |
796 | 417 | .next() |
797 | 417 | .await |
798 | | else { |
799 | 207 | return Err(Status::not_found("Failed to find existing task")); |
800 | | }; |
801 | 210 | Ok(Self::to_execute_stream( |
802 | 210 | &nl_operation_id, |
803 | 210 | rx, |
804 | 210 | instance_info.cas_store.clone(), |
805 | 210 | instance_info.execute_stream_heartbeat, |
806 | 210 | )) |
807 | 417 | } |
808 | | } |
809 | | |
810 | | #[tonic::async_trait] |
811 | | impl Execution for ExecutionServer { |
812 | | type ExecuteStream = ExecuteStream; |
813 | | type WaitExecutionStream = ExecuteStream; |
814 | | |
815 | | #[instrument( |
816 | | err, |
817 | | level = Level::ERROR, |
818 | | skip_all, |
819 | | fields(request = ?grpc_request.get_ref()) |
820 | | )] |
821 | | async fn execute( |
822 | | &self, |
823 | | grpc_request: Request<ExecuteRequest>, |
824 | | ) -> Result<Response<ExecuteStream>, Status> { |
825 | | let request = grpc_request.into_inner(); |
826 | | |
827 | | let digest_function = request.digest_function; |
828 | | let result = self |
829 | | .inner_execute(request) |
830 | | .instrument(error_span!("execution_server_execute")) |
831 | | .with_context( |
832 | | make_ctx_for_hash_func(digest_function) |
833 | | .err_tip(|| "In ExecutionServer::execute")?, |
834 | | ) |
835 | | .await |
836 | | .err_tip(|| "Failed on execute() command")?; |
837 | | |
838 | | match result { |
839 | | ExecuteOutcome::Stream(stream) => Ok(Response::new(Box::pin(stream))), |
840 | | ExecuteOutcome::Reject(status) => Err(status), |
841 | | } |
842 | | } |
843 | | |
844 | | #[instrument( |
845 | | err, |
846 | | level = Level::ERROR, |
847 | | skip_all, |
848 | | fields(request = ?grpc_request.get_ref()) |
849 | | )] |
850 | | async fn wait_execution( |
851 | | &self, |
852 | | grpc_request: Request<WaitExecutionRequest>, |
853 | | ) -> Result<Response<ExecuteStream>, Status> { |
854 | | let request = grpc_request.into_inner(); |
855 | | |
856 | | let stream_result = self |
857 | | .inner_wait_execution(request) |
858 | | .await |
859 | | .err_tip(|| "Failed on wait_execution() command") |
860 | | .map_err(Into::into); |
861 | | let stream = match stream_result { |
862 | | Ok(stream) => stream, |
863 | | Err(e) => return Err(e), |
864 | | }; |
865 | | debug!(return = "Ok(<stream>)"); |
866 | | Ok(Response::new(Box::pin(stream))) |
867 | | } |
868 | | } |
869 | | |
870 | | #[tonic::async_trait] |
871 | | impl Operations for ExecutionServer { |
872 | | async fn list_operations( |
873 | | &self, |
874 | | _request: Request<ListOperationsRequest>, |
875 | 1 | ) -> Result<Response<ListOperationsResponse>, Status> { |
876 | | Err(Status::unimplemented("list_operations not implemented")) |
877 | 1 | } |
878 | | |
879 | | async fn delete_operation( |
880 | | &self, |
881 | | _request: Request<DeleteOperationRequest>, |
882 | 1 | ) -> Result<Response<()>, Status> { |
883 | | Err(Status::unimplemented("delete_operation not implemented")) |
884 | 1 | } |
885 | | |
886 | | async fn cancel_operation( |
887 | | &self, |
888 | | _request: Request<CancelOperationRequest>, |
889 | 1 | ) -> Result<Response<()>, Status> { |
890 | | Err(Status::unimplemented("cancel_operation not implemented")) |
891 | 1 | } |
892 | | |
893 | | async fn get_operation( |
894 | | &self, |
895 | | request: Request<GetOperationRequest>, |
896 | 2 | ) -> Result<Response<Operation>, Status> { |
897 | | let inner_request = request.into_inner(); |
898 | | |
899 | | let mut stream = Box::pin( |
900 | | self.inner_wait_execution(WaitExecutionRequest { |
901 | | name: inner_request.name, |
902 | | }) |
903 | | .await?, |
904 | | ); |
905 | | |
906 | | let operation = stream |
907 | | .next() |
908 | | .await |
909 | 0 | .ok_or_else(|| Status::not_found("Operation not found"))??; |
910 | | |
911 | | Ok(Response::new(operation)) |
912 | 2 | } |
913 | | |
914 | | async fn wait_operation( |
915 | | &self, |
916 | | request: Request<WaitOperationRequest>, |
917 | 2 | ) -> Result<Response<Operation>, Status> { |
918 | | let inner_request = request.into_inner(); |
919 | 1 | let timeout_opt = inner_request.timeout.map(|d| { |
920 | 1 | let secs = u64::try_from(d.seconds).unwrap_or(0); |
921 | 1 | let nanos = u32::try_from(d.nanos).unwrap_or(0); |
922 | 1 | Duration::new(secs, nanos) |
923 | 1 | }); |
924 | | |
925 | | let mut stream = Box::pin( |
926 | | self.inner_wait_execution(WaitExecutionRequest { |
927 | | name: inner_request.name, |
928 | | }) |
929 | | .await?, |
930 | | ); |
931 | | |
932 | | let mut last_operation = stream |
933 | | .next() |
934 | | .await |
935 | 0 | .ok_or_else(|| Status::not_found("Operation not found"))??; |
936 | | |
937 | | if last_operation.done { |
938 | | return Ok(Response::new(last_operation)); |
939 | | } |
940 | | |
941 | 1 | let end_time = timeout_opt.map(|t| tokio::time::Instant::now() + t); |
942 | | |
943 | | loop { |
944 | | let next_fut = stream.next(); |
945 | | let next_res = if let Some(end) = end_time { |
946 | | match tokio::time::timeout_at(end, next_fut).await { |
947 | | Ok(res) => res, |
948 | | Err(_) => break, |
949 | | } |
950 | | } else { |
951 | | next_fut.await |
952 | | }; |
953 | | |
954 | | match next_res { |
955 | | Some(Ok(operation)) => { |
956 | | let is_done = operation.done; |
957 | | last_operation = operation; |
958 | | if is_done { |
959 | | break; |
960 | | } |
961 | | } |
962 | | Some(Err(e)) => return Err(e), |
963 | | None => break, |
964 | | } |
965 | | } |
966 | | |
967 | | Ok(Response::new(last_operation)) |
968 | 2 | } |
969 | | } |
970 | | |
971 | | #[cfg(test)] |
972 | | #[test] |
973 | 1 | fn test_resolve_operation_name() { |
974 | 1 | let instances = ["main", "", "a/b/c"]; |
975 | 1 | let examples = [ |
976 | 1 | ("main/bar", Some(("main", "bar"))), |
977 | 1 | // The id a grpc scheduler hands back carries the upstream's instance. |
978 | 1 | ("main/main/bar", Some(("main", "main/bar"))), |
979 | 1 | ("/bar", Some(("", "bar"))), |
980 | 1 | ("a/b/c/d", Some(("a/b/c", "d"))), |
981 | 1 | // Not an instance this server has, and not the default one either. |
982 | 1 | ("other/bar", None), |
983 | 1 | // A prefix that is not a whole instance name. |
984 | 1 | ("mainline/bar", None), |
985 | 1 | ("main/", None), |
986 | 1 | ]; |
987 | | |
988 | 7 | for (input, expected) in examples1 { |
989 | 7 | let resolved = resolve_operation_name(instances, input) |
990 | 7 | .map(|(instance, id)| (instance4 , id4 .to_string4 ())); |
991 | 7 | assert_eq!( |
992 | 7 | resolved.as_ref().map(|(i, id)| (*i4 , id4 .as_str4 ())), |
993 | | expected, |
994 | | "{input}" |
995 | | ); |
996 | | } |
997 | 1 | } |