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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions nativelink-scheduler/src/memory_awaited_action_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,12 +417,18 @@ impl<I: InstantWrapper, NowFn: Fn() -> I + Clone + Send + Sync> AwaitedActionDbI
// Cleanup action_info_hash_key_to_awaited_action if it was marked cached.
match &awaited_action.action_info().unique_qualifier {
ActionUniqueQualifier::Cacheable(action_key) => {
let maybe_awaited_action = self
// Once this operation finished, a newer operation for the
// same action key may have claimed the entry; removing it
// unconditionally here would orphan that operation's
// deduplication entry.
let owned_by_this_operation = self
.action_info_hash_key_to_awaited_action
.remove(action_key);
if !awaited_action.state().stage.is_finished()
&& maybe_awaited_action.is_none()
{
.get(action_key)
.is_some_and(|id| id == &operation_id);
if owned_by_this_operation {
self.action_info_hash_key_to_awaited_action
.remove(action_key);
} else if !awaited_action.state().stage.is_finished() {
error!(
%operation_id,
?awaited_action,
Expand Down Expand Up @@ -550,27 +556,30 @@ impl<I: InstantWrapper, NowFn: Fn() -> I + Clone + Send + Sync> AwaitedActionDbI
}
match &new_awaited_action.action_info().unique_qualifier {
ActionUniqueQualifier::Cacheable(action_key) => {
let maybe_awaited_action =
action_info_hash_key_to_awaited_action.remove(action_key);
match maybe_awaited_action {
Some(removed_operation_id) => {
if &removed_operation_id != new_awaited_action.operation_id() {
error!(
?removed_operation_id,
?new_awaited_action,
?action_key,
"action_info_hash_key_to_awaited_action and operation_id_to_awaited_action are out of sync",
);
}
match action_info_hash_key_to_awaited_action.get(action_key) {
Some(owning_operation_id)
if owning_operation_id == new_awaited_action.operation_id() => {}
Some(owning_operation_id) => {
// The entry belongs to a newer operation for the same
// action key; leave it in place.
error!(
?owning_operation_id,
?new_awaited_action,
?action_key,
"action_info_hash_key_to_awaited_action and operation_id_to_awaited_action are out of sync",
);
return;
}
None => {
error!(
?new_awaited_action,
?action_key,
"action_info_hash_key_to_awaited_action out of sync, it should have had the unique_key",
);
return;
}
}
action_info_hash_key_to_awaited_action.remove(action_key);
}
ActionUniqueQualifier::Uncacheable(_action_key) => {
// If we are not cacheable, the action should not be in the
Expand Down
75 changes: 75 additions & 0 deletions nativelink-scheduler/tests/simple_scheduler_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2790,6 +2790,81 @@ async fn logs_when_no_workers_match() -> Result<(), Error> {
Ok(())
}

/// Regression test: a finished operation whose client entry is dropped late
/// (e.g. evicted after the retain window) must not remove the
/// action-key entry claimed by a newer operation for the same action,
/// otherwise later requests stop deduplicating onto the live operation.
#[nativelink_test]
async fn late_client_drop_does_not_orphan_replacement_operation() -> Result<(), Error> {
const NO_EVENT_ACTION_TIMEOUT: Duration = Duration::from_mins(1);

let task_change_notify = Arc::new(Notify::new());
let awaited_action_db = memory_awaited_action_db_factory(
0, // Use the default retain_completed_for_s (60s).
&task_change_notify,
MockInstantWrapped::default,
);
let action_info = make_base_action_info(make_system_time(0), DigestInfo::new([99u8; 32], 512));

// Client 1 creates operation A for the action key.
let client1_id = OperationId::default();
let subscriber1 = awaited_action_db
.add_action(
client1_id.clone(),
action_info.clone(),
NO_EVENT_ACTION_TIMEOUT,
)
.await?;
let mut awaited_action_a = subscriber1.borrow().await?;
let operation_id_a = awaited_action_a.operation_id().clone();

// Operation A finishes, releasing its action-key entry.
let mut completed_state = awaited_action_a.state().as_ref().clone();
completed_state.stage = ActionStage::Completed(ActionResult::default());
awaited_action_a.worker_set_state(Arc::new(completed_state), make_system_time(1));
awaited_action_db
.update_awaited_action(awaited_action_a)
.await?;

// Let the client 1 entry go stale past the retain window without dropping
// the subscriber, mimicking a client that vanished without cleanup.
MockClock::advance(Duration::from_mins(2));

// Client 2 requests the same action; the key is free, so a new operation B
// claims it. Inserting client 2 also evicts the stale client 1 entry,
// queueing the ClientDroppedOperation cleanup for operation A.
let client2_id = OperationId::default();
let subscriber2 = awaited_action_db
.add_action(
client2_id.clone(),
action_info.clone(),
NO_EVENT_ACTION_TIMEOUT,
)
.await?;
let operation_id_b = subscriber2.borrow().await?.operation_id().clone();
assert_ne!(operation_id_a, operation_id_b);

// Let the background event task process client 1's drop. The cleanup of
// finished operation A must leave operation B's action-key entry alone.
for _ in 0..10 {
tokio::task::yield_now().await;
}

// Client 3 requesting the same action must join operation B instead of
// creating a third operation.
let client3_id = OperationId::default();
let subscriber3 = awaited_action_db
.add_action(client3_id.clone(), action_info, NO_EVENT_ACTION_TIMEOUT)
.await?;
let operation_id_c = subscriber3.borrow().await?.operation_id().clone();
assert_eq!(operation_id_b, operation_id_c);

assert!(!logs_contain("out of sync"));
assert!(!logs_contain("should have had the unique_key"));

Ok(())
}

/// Wraps a real `AwaitedActionDb`, but hides queued actions from
/// `get_range_of_actions` while `suppress_queued_searches` is set. This
/// simulates an eventually consistent backend (e.g. Redis), where a
Expand Down
Loading