Skip to content
Open
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
2 changes: 1 addition & 1 deletion lib/bench/tests/mooncake_trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ async fn route_approx_writes(
for (worker, tokens) in writes {
let mut tokens_with_hashes = TokensWithHashes::new(tokens, BLOCK_SIZE);
indexer
.process_routing_decision_for_request(&mut tokens_with_hashes, worker)
.process_routing_decision_for_request(&mut tokens_with_hashes, worker, None)
.await?;
}

Expand Down
1 change: 1 addition & 0 deletions lib/kv-router/src/indexer/branch_sharded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,7 @@ impl<S: AsyncShardHandle> KvIndexerInterface for BranchShardedIndexer<S> {
&self,
_tokens_with_hashes: &mut TokensWithHashes,
_worker: WorkerWithDpRank,
_ttl_override: Option<std::time::Duration>,
) -> Result<(), KvRouterError> {
Ok(())
}
Expand Down
16 changes: 12 additions & 4 deletions lib/kv-router/src/indexer/kv_indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#[cfg(feature = "bench")]
use std::time::Instant;

use std::{collections::BTreeMap, sync::Arc};
use std::{collections::BTreeMap, sync::Arc, time::Duration};

use async_trait::async_trait;
use tokio::sync::{mpsc, oneshot};
Expand Down Expand Up @@ -89,7 +89,7 @@ fn apply_routing_decision_with_prune_tracking(
seq_position: idx,
})
.collect();
pm.insert_block_entries(block_entries);
pm.insert_block_entries(block_entries, routing_req.ttl_override);
}

fn apply_prune_removes(trie: &mut RadixTree, entries: Vec<BlockEntry>, event_id_counter: &mut u64) {
Expand Down Expand Up @@ -663,12 +663,18 @@ impl KvIndexerInterface for KvIndexer {
&self,
tokens_with_hashes: &mut TokensWithHashes,
worker: WorkerWithDpRank,
ttl_override: Option<Duration>,
) -> Result<(), KvRouterError> {
let local_hashes = tokens_with_hashes.get_or_compute_block_hashes().to_vec();
let sequence_hashes = tokens_with_hashes.get_or_compute_seq_hashes().to_vec();

self.process_routing_decision_with_hashes(worker, local_hashes, sequence_hashes)
.await
self.process_routing_decision_with_hashes(
worker,
local_hashes,
sequence_hashes,
ttl_override,
)
.await
}
async fn flush(&self) -> usize {
match self.flush_and_wait().await {
Expand All @@ -688,12 +694,14 @@ impl KvIndexer {
worker: WorkerWithDpRank,
local_hashes: Vec<LocalBlockHash>,
sequence_hashes: Vec<SequenceHash>,
ttl_override: Option<Duration>,
) -> Result<(), KvRouterError> {
self.routing_tx
.send(RoutingDecisionRequest {
worker,
local_hashes,
sequence_hashes,
ttl_override,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — human evaluation requested: This carries a relative TTL through the queue/RPC, but the pruning manager samples Instant::now() only when it later processes the request. Local backpressure, a worker acknowledgement, or remote transport therefore does not consume the requested lifetime; a request with five seconds remaining after ten seconds in transit receives another five seconds. If the intended contract is event-age-bounded retention, consider computing a validated absolute internal deadline at admission and carrying that through private queues rather than restarting a relative duration at each boundary.

})
.await
.map_err(|_| KvRouterError::IndexerDroppedRequest)?;
Expand Down
7 changes: 3 additions & 4 deletions lib/kv-router/src/indexer/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, Mutex},
time::Duration,
};

use async_trait::async_trait;
Expand All @@ -21,9 +22,6 @@ use crate::protocols::*;
use super::DumpRequest;
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(test)]
use tokio::time::Duration;

// -------------------------------------------------
// Decentralized router: LocalKvIndexer for workers
// -------------------------------------------------
Expand Down Expand Up @@ -780,11 +778,12 @@ impl KvIndexerInterface for LocalKvIndexer {
&self,
tokens_with_hashes: &mut TokensWithHashes,
worker: WorkerWithDpRank,
ttl_override: Option<Duration>,
) -> Result<(), KvRouterError> {
// TODO I guess the local kvindexers have little use for this method?
// Keeping it here now to implement the trait fully
self.indexer
.process_routing_decision_for_request(tokens_with_hashes, worker)
.process_routing_decision_for_request(tokens_with_hashes, worker, ttl_override)
.await
}

Expand Down
107 changes: 69 additions & 38 deletions lib/kv-router/src/indexer/pruning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,26 +116,26 @@ impl<K: Clone + Hash + Eq + Ord> PruneManager<K> {
/// Inserts a new timer or updates an existing one for the given key.
///
/// # Arguments
/// * `key` - The unique key for the timer.
/// * `duration` - The duration from now when the timer should expire.
/// * `keys` - The unique keys for the timers.
pub fn insert(&mut self, keys: Vec<K>) {
self.insert_at(keys, Instant::now());
self.insert_at(keys, Instant::now(), None);
}

/// Inserts timers using a caller-provided timestamp.
pub fn insert_at(&mut self, keys: Vec<K>, now: Instant) {
pub fn insert_at(&mut self, keys: Vec<K>, now: Instant, ttl_override: Option<Duration>) {
let len = keys.len();
let expiry_time = if self.ttl.is_zero() {
let ttl = ttl_override.unwrap_or(self.ttl);
let expiry_time = if ttl.is_zero() {
now
} else {
self.bucket_expiry(now + self.ttl)
self.bucket_expiry(now + ttl)
};
let mut bucket_inserts: Option<Vec<K>> = None;

self.timers.reserve(len);
for key in keys {
match self.timers.entry(key.clone()) {
Entry::Occupied(entry) if *entry.get() == expiry_time => {
Entry::Occupied(entry) if *entry.get() >= expiry_time => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — human evaluation requested: Please re-evaluate the previously discussed stale-removal race against #12785's acceptance criterion. This max-expiry check only helps while the key remains in the timer map. Once queue_due removes the timer and queues a bare BlockEntry, a refresh can install a later timer, yet the stale queued Removed can still delete the refreshed tree entry, including during the worker-ack window before the later expiry is registered. If this PR intends to close that criterion, pending work likely needs to carry an expiry/generation and apply removal only when it is still current. Scanning the pending queue during insertion would not cover a removal already enqueued downstream.

continue;
}
Entry::Occupied(mut entry) => {
Expand Down Expand Up @@ -231,8 +231,13 @@ impl WorkerPruneState {
}
}

fn insert_block_entries(&mut self, entries: Vec<BlockEntry>, now: Instant) {
self.timers.insert_at(entries, now);
fn insert_block_entries(
&mut self,
entries: Vec<BlockEntry>,
now: Instant,
ttl_override: Option<Duration>,
) {
self.timers.insert_at(entries, now, ttl_override);
}

fn remove_block_entry(&mut self, entry: &BlockEntry) {
Expand Down Expand Up @@ -318,7 +323,7 @@ impl WorkerPruneManager {
}
}

pub fn insert_block_entries(&self, entries: Vec<BlockEntry>) {
pub fn insert_block_entries(&self, entries: Vec<BlockEntry>, ttl_override: Option<Duration>) {
if entries.is_empty() {
return;
}
Expand All @@ -333,20 +338,25 @@ impl WorkerPruneManager {
let mut should_bump_schedule = false;
for (worker, worker_entries) in by_worker {
should_bump_schedule |=
self.insert_worker_block_entries_at(worker, worker_entries, now);
self.insert_worker_block_entries_at(worker, worker_entries, now, ttl_override);
}

if should_bump_schedule {
self.inner.bump_schedule();
}
}

pub fn insert_worker_block_entries(&self, worker: WorkerWithDpRank, entries: Vec<BlockEntry>) {
pub fn insert_worker_block_entries(
&self,
worker: WorkerWithDpRank,
entries: Vec<BlockEntry>,
ttl_override: Option<Duration>,
) {
if entries.is_empty() {
return;
}

if self.insert_worker_block_entries_at(worker, entries, Instant::now()) {
if self.insert_worker_block_entries_at(worker, entries, Instant::now(), ttl_override) {
self.inner.bump_schedule();
}
}
Expand All @@ -356,6 +366,7 @@ impl WorkerPruneManager {
worker: WorkerWithDpRank,
entries: Vec<BlockEntry>,
now: Instant,
ttl_override: Option<Duration>,
) -> bool {
let (old_next, new_next) = {
let state =
Expand All @@ -364,28 +375,26 @@ impl WorkerPruneManager {
});
let mut state = state.lock().expect("worker prune state mutex poisoned");
let old_next = state.peek_next_valid_expiry();
state.insert_block_entries(entries, now);
state.insert_block_entries(entries, now, ttl_override);
let new_next = state.peek_next_valid_expiry();
(old_next, new_next)
};

self.update_worker_expiry(worker, old_next, new_next)
}

fn update_worker_expiry(
&self,
worker: WorkerWithDpRank,
old_next: Option<Instant>,
new_next: Option<Instant>,
) -> bool {
match (old_next, new_next) {
(None, Some(next_expiry)) => {
self.inner.push_worker_expiry(worker, next_expiry);
true
}
(Some(old_next), Some(new_next)) if new_next < old_next => {
tracing::warn!(
worker_id = worker.worker_id,
dp_rank = worker.dp_rank,
?old_next,
?new_next,
"Approximate prune expiry moved earlier during insert; rescheduling"
);
debug_assert!(
new_next >= old_next,
"approximate prune expiry moved earlier during insert"
);
self.inner.push_worker_expiry(worker, new_next);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — human evaluation requested: Please verify the interaction with concurrent heap rebuilds. A rebuild snapshots per-worker minima without serializing against this push, then replaces the heap wholesale. An insertion can publish a newly earlier minimum after its worker was snapshotted, only for the rebuild to overwrite and lose that entry; if another worker remains heap-top, stale-top validation does not rediscover the omitted worker until later. Heterogeneous TTLs make new_next < old_next routine. A mutation epoch/retry, serialization, or post-build reconciliation would make publication robust.

true
}
Expand Down Expand Up @@ -666,7 +675,7 @@ mod tests {
let mut pm: PruneManager<u32> = PruneManager::new(prune_config);

let now = pm.bucket_origin + Duration::from_millis(1);
pm.insert_at(vec![1, 2, 3], now);
pm.insert_at(vec![1, 2, 3], now, None);
let expiry = *pm.get_expiry(&1).expect("expiry missing after insert");
assert_eq!(pm.get_expiry(&2), Some(&expiry));
assert_eq!(pm.get_expiry(&3), Some(&expiry));
Expand All @@ -686,8 +695,8 @@ mod tests {
let mut pm: PruneManager<u32> = PruneManager::new(prune_config);

let now = pm.bucket_origin + Duration::from_millis(1);
pm.insert_at(vec![1], now);
pm.insert_at(vec![2], now + Duration::from_millis(20));
pm.insert_at(vec![1], now, None);
pm.insert_at(vec![2], now + Duration::from_millis(20), None);

let expiry = *pm.get_expiry(&1).expect("expiry missing for key 1");
assert_eq!(pm.get_expiry(&2), Some(&expiry));
Expand All @@ -706,10 +715,10 @@ mod tests {
let mut pm: PruneManager<u32> = PruneManager::new(prune_config);

let now = pm.bucket_origin + Duration::from_millis(1);
pm.insert_at(vec![42], now);
pm.insert_at(vec![42], now, None);
let expiry = *pm.get_expiry(&42).expect("expiry missing for key 42");

pm.insert_at(vec![42], now + Duration::from_millis(20));
pm.insert_at(vec![42], now + Duration::from_millis(20), None);

assert_eq!(pm.get_expiry(&42), Some(&expiry));
assert_eq!(pm.expirations.len(), 1);
Expand All @@ -726,7 +735,7 @@ mod tests {
let mut pm: PruneManager<u32> = PruneManager::new(prune_config);

let now = pm.bucket_origin + Duration::from_millis(1);
pm.insert_at(vec![7], now);
pm.insert_at(vec![7], now, None);

assert_eq!(pm.get_expiry(&7), Some(&now));
assert_eq!(pm.pop_expired(now), vec![7]);
Expand All @@ -741,12 +750,12 @@ mod tests {
let mut pm: PruneManager<u32> = PruneManager::new(prune_config);

let now = pm.bucket_origin + Duration::from_millis(1);
pm.insert_at(vec![42], now);
pm.insert_at(vec![42], now, None);
let first_expiry = *pm
.get_expiry(&42)
.expect("expiry missing after first insert");

pm.insert_at(vec![42], now + EXPIRY_BUCKET);
pm.insert_at(vec![42], now + EXPIRY_BUCKET, None);
let second_expiry = *pm
.get_expiry(&42)
.expect("expiry missing after reinsertion");
Expand All @@ -760,6 +769,28 @@ mod tests {
assert_eq!(expired_after, vec![42]);
}

#[test]
fn test_prune_manager_ttl_override_keeps_latest_expiry() {
let mut pm = PruneManager::new(PruneConfig {
ttl: Duration::from_secs(120),
});
let now = pm.bucket_origin + Duration::from_millis(1);

pm.insert_at(vec![1], now, None);
pm.insert_at(vec![2], now, Some(Duration::from_secs(120)));
let default_expiry = *pm.get_expiry(&1).expect("default expiry missing");
assert_eq!(pm.get_expiry(&2), Some(&default_expiry));

pm.insert_at(vec![3], now, Some(Duration::from_secs(5)));
pm.insert_at(vec![2], now, Some(Duration::from_secs(300)));
let short_expiry = *pm.get_expiry(&3).expect("short expiry missing");
let long_expiry = *pm.get_expiry(&2).expect("long expiry missing");
pm.insert_at(vec![2], now, Some(Duration::from_secs(5)));

assert!(short_expiry < long_expiry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — human evaluation requested: This checks the leaf map's computed expiries, but it does not demonstrate that the background scheduler wakes for a newly earlier deadline. The interface-level test later calls flush, which synchronously drains due work and can pass even if the wake-up path is wrong. A deterministic test could insert a long TTL followed by a shorter TTL for another block on the same worker, observe ordered background removal without relying on flush, and repeat descending deadlines past the heap-rebuild threshold.

assert_eq!(pm.get_expiry(&2), Some(&long_expiry));
}

#[test]
fn test_worker_expiry_heap_rebuilds_under_churn() {
let manager = WorkerPruneManager::new(PruneConfig {
Expand All @@ -768,7 +799,7 @@ mod tests {
let worker = WorkerWithDpRank::from_worker_id(7);

for idx in 0..=WORKER_EXPIRY_HEAP_REBUILD_THRESHOLD {
manager.insert_block_entries(vec![test_block(worker, 100 + idx as u64, idx)]);
manager.insert_block_entries(vec![test_block(worker, 100 + idx as u64, idx)], None);
}

assert_eq!(manager.worker_expiry_heap_len(), 1);
Expand All @@ -785,9 +816,9 @@ mod tests {
let first = test_block(first_worker, 101, 0);
let second = test_block(second_worker, 202, 0);

manager.insert_block_entries(vec![first]);
manager.insert_block_entries(vec![first], None);
time::advance(Duration::from_secs(5)).await;
manager.insert_block_entries(vec![second]);
manager.insert_block_entries(vec![second], None);

time::advance(Duration::from_secs(5)).await;
assert_eq!(manager.drain_due_and_pending(Instant::now()), vec![first]);
Expand All @@ -804,9 +835,9 @@ mod tests {
let worker = WorkerWithDpRank::from_worker_id(7);
let block = test_block(worker, 707, 0);

manager.insert_block_entries(vec![block]);
manager.insert_block_entries(vec![block], None);
time::advance(Duration::from_secs(5)).await;
manager.insert_block_entries(vec![block]);
manager.insert_block_entries(vec![block], None);

time::advance(Duration::from_secs(5)).await;
assert!(manager.drain_due_and_pending(Instant::now()).is_empty());
Expand Down
Loading
Loading