From 763b04c43ced04c79ff894ac7a5623c947f88e29 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Thu, 11 Jun 2026 13:47:34 +0200 Subject: [PATCH 1/8] feat(timeline): limit and paginate long-running entities Add per-entry pagination to the long-entities portion of the resource timeline endpoints. Requests gain optional long_entities_max (page size; null/0 = unlimited) and long_entities_page (0-based index); responses gain long_fsms_total so clients can compute page count, and long_fsms becomes the ranked page slice. Entities are ranked by their longest qualifying usage span (descending, id tie-break). The analyzer tracks that duration per entity; it rides on FiniteStateMachine as a serde/ts-skipped field used only for server-side ranking and never appears on the wire. Pagination runs after chunk merge in the server cache layer at a single tail point covering the fast path and all uncached fallbacks, and is excluded from the chunk cache key so pages reuse cached chunks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../analyzer/src/timeline/binned/resource.rs | 48 +++- crates/ui/src/lib.rs | 8 + crates/ui/src/timeline/request.rs | 14 ++ crates/ui/src/timeline/response.rs | 12 +- .../query_engine/server/src/timeline_cache.rs | 209 +++++++++++++++++- examples/simulator/analyzer/src/lib.rs | 20 +- examples/simulator/analyzer/src/task.rs | 3 + .../ResourceGroupTimelineRequest.ts | 9 + .../ts-bindings/ResourceTimelineBinned.ts | 10 +- .../ResourceTimelineBinnedByState.ts | 10 +- .../ts-bindings/ResourceTimelineRequest.ts | 9 + 11 files changed, 326 insertions(+), 26 deletions(-) diff --git a/crates/analyzer/src/timeline/binned/resource.rs b/crates/analyzer/src/timeline/binned/resource.rs index 7cb86d63..2283f5b2 100644 --- a/crates/analyzer/src/timeline/binned/resource.rs +++ b/crates/analyzer/src/timeline/binned/resource.rs @@ -6,7 +6,7 @@ use std::hash::Hash; use quent_time::{SpanNanoSec, TimeNanoSec, bin::BinnedSpan}; -use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use rustc_hash::FxHashMap as HashMap; use uuid::Uuid; use crate::{ @@ -29,20 +29,24 @@ fn convert_capacity( pub struct ResourceTimeline<'a> { pub config: BinnedSpan, pub data: HashMap<&'a str, Vec>, - pub long_entities: Vec, + /// Entities with a usage span exceeding the long-entities threshold, paired + /// with the longest such qualifying span duration (used for ranking). + pub long_entities: Vec<(Uuid, TimeNanoSec)>, } #[derive(Clone, Debug)] pub struct ResourceTimelineByKey<'a, K> { pub config: BinnedSpan, pub data: HashMap<(K, &'a str), Vec>, - pub long_entities: Vec, + /// Entities with a usage span exceeding the long-entities threshold, paired + /// with the longest such qualifying span duration (used for ranking). + pub long_entities: Vec<(Uuid, TimeNanoSec)>, } pub struct ResourceTimelineBuilder<'a> { resource_type: &'a ResourceTypeDecl, aggregator: KeyedAggregator<&'a str>, - long_entities: HashSet, + long_entities: HashMap, long_entities_threshold: Option, } @@ -57,7 +61,7 @@ impl<'a> ResourceTimelineBuilder<'a> { Ok(Self { resource_type, aggregator, - long_entities: HashSet::default(), + long_entities: HashMap::default(), long_entities_threshold, }) } @@ -76,7 +80,11 @@ impl<'a> ResourceTimelineBuilder<'a> { && usage.span().duration() > threshold && usage.span().intersects(&self.aggregator.config.span) { - self.long_entities.insert(usage.entity_id()); + let duration = usage.span().duration(); + self.long_entities + .entry(usage.entity_id()) + .and_modify(|d| *d = (*d).max(duration)) + .or_insert(duration); } Ok(()) } @@ -103,7 +111,7 @@ impl<'a> ResourceTimelineBuilder<'a> { pub struct ResourceTimelineByKeyBuilder<'a, K> { resource_type: &'a ResourceTypeDecl, aggregator: KeyedAggregator<(K, &'a str)>, - long_entities: HashSet, + long_entities: HashMap, long_entities_threshold: Option, } @@ -121,7 +129,7 @@ where Ok(Self { resource_type, aggregator, - long_entities: HashSet::default(), + long_entities: HashMap::default(), long_entities_threshold, }) } @@ -139,7 +147,11 @@ where && usage.span().duration() > threshold && usage.span().intersects(&self.aggregator.config.span) { - self.long_entities.insert(usage.entity_id()); + let duration = usage.span().duration(); + self.long_entities + .entry(usage.entity_id()) + .and_modify(|d| *d = (*d).max(duration)) + .or_insert(duration); } Ok(()) } @@ -167,6 +179,8 @@ where mod tests { use std::num::NonZero; + use rustc_hash::FxHashSet as HashSet; + use crate::{ fsm::{ FsmUsages, @@ -778,7 +792,13 @@ mod tests { let mut outside_builder = ResourceTimelineBuilder::try_new(resource_type, config, Some(threshold)).unwrap(); outside_builder.try_extend(outside_fsms.usages()).unwrap(); - assert!(!outside_builder.build().long_entities.contains(&resource_id)); + assert!( + !outside_builder + .build() + .long_entities + .iter() + .any(|(id, _)| *id == resource_id) + ); let mut inside_fsms = InMemoryFsms::::new(); inside_fsms.insert(make_fsm(500, 1500)); @@ -787,6 +807,12 @@ mod tests { let mut inside_builder = ResourceTimelineBuilder::try_new(resource_type, config, Some(threshold)).unwrap(); inside_builder.try_extend(inside_fsms.usages()).unwrap(); - assert!(inside_builder.build().long_entities.contains(&resource_id)); + assert!( + inside_builder + .build() + .long_entities + .iter() + .any(|(id, _)| *id == resource_id) + ); } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index bb56e8bb..07937b7e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -231,6 +231,13 @@ pub struct FiniteStateMachine { pub instance_name: String, /// The transitions of this FSM. pub transitions: Vec, + /// Longest qualifying usage span (ns) that surfaced this FSM as a long + /// entity, used only for server-side ranking. Not serialized: the per-state + /// durations are already derivable from `transitions`. Zero when this FSM + /// is not produced in a long-entities context. + #[serde(skip)] + #[ts(skip)] + pub long_duration_ns: u64, } impl FiniteStateMachine { @@ -247,6 +254,7 @@ impl FiniteStateMachine { .iter() .map(|t| FsmTransition::try_from_rt(t, epoch)) .collect::, _>>()?, + long_duration_ns: 0, }) } } diff --git a/crates/ui/src/timeline/request.rs b/crates/ui/src/timeline/request.rs index 89fe8745..94d5c15b 100644 --- a/crates/ui/src/timeline/request.rs +++ b/crates/ui/src/timeline/request.rs @@ -55,6 +55,13 @@ pub struct ResourceTimelineRequest { pub resource_id: Uuid, /// If set, fully include entities that have usages exceeding this amount of time. pub long_entities_threshold_s: Option, + /// Maximum number of long entities to return (page size). `None` or `0` + /// returns all of them. Applied after threshold filtering and ranking. + #[ts(optional)] + pub long_entities_max: Option, + /// Zero-based page index into the ranked long entities. `None` is page 0. + #[ts(optional)] + pub long_entities_page: Option, /// Entity filters. pub entity_filter: EntityFilter, /// Application-specific request parameters, e.g. for filtering. @@ -74,6 +81,13 @@ pub struct ResourceGroupTimelineRequest { /// If set, fully include entities that have usages exceeding this amount of /// time in seconds. pub long_entities_threshold_s: Option, + /// Maximum number of long entities to return (page size). `None` or `0` + /// returns all of them. Applied after threshold filtering and ranking. + #[ts(optional)] + pub long_entities_max: Option, + /// Zero-based page index into the ranked long entities. `None` is page 0. + #[ts(optional)] + pub long_entities_page: Option, /// Entity filters. pub entity_filter: EntityFilter, /// Application-specific request parameters, e.g. for filtering. diff --git a/crates/ui/src/timeline/response.rs b/crates/ui/src/timeline/response.rs index 3e5bddf5..ab156946 100644 --- a/crates/ui/src/timeline/response.rs +++ b/crates/ui/src/timeline/response.rs @@ -16,8 +16,12 @@ pub struct ResourceTimelineBinned { /// Maps a resource capacity name to a vector where each element holds an /// aggregated value of a time bin. pub capacities_values: HashMap>, - /// FSMs that have usage spans exceeding the long_entities_threshold. + /// Ranked page of FSMs whose usage spans exceed the long_entities_threshold, + /// longest first. Sliced per the request's `long_entities_max`/`_page`. pub long_fsms: Vec, + /// Total number of long FSMs matching the threshold before pagination, so + /// clients can compute the page count. + pub long_fsms_total: u32, } #[derive(TS, Debug, Clone, Serialize)] @@ -27,8 +31,12 @@ pub struct ResourceTimelineBinnedByState { /// Maps a resource capacity name to a map of a state name to a vector where /// each element holds an aggregated value of a time bin. pub capacities_states_values: HashMap>>, - /// FSMs that have usage spans exceeding the long_entities_threshold. + /// Ranked page of FSMs whose usage spans exceed the long_entities_threshold, + /// longest first. Sliced per the request's `long_entities_max`/`_page`. pub long_fsms: Vec, + /// Total number of long FSMs matching the threshold before pagination, so + /// clients can compute the page count. + pub long_fsms_total: u32, } #[derive(TS, Debug, Clone, Serialize)] diff --git a/domains/query_engine/server/src/timeline_cache.rs b/domains/query_engine/server/src/timeline_cache.rs index 259953b2..ee6ea4da 100644 --- a/domains/query_engine/server/src/timeline_cache.rs +++ b/domains/query_engine/server/src/timeline_cache.rs @@ -12,6 +12,7 @@ use moka::future::Cache; use quent_analyzer::Span; use quent_query_engine_analyzer::{QueryEngineModel, ui::UiAnalyzer}; use quent_time::{SpanNanoSec, TimeNanoSec, bin::BinnedSpan, to_nanosecs, to_secs_relative}; +use quent_ui::FiniteStateMachine; use quent_ui::timeline::{ request::{ BulkChunkedTimelineRequest, BulkTimelineRequest, SingleTimelineRequest, TimelineConfig, @@ -151,7 +152,12 @@ impl TimelineCache { } } - /// Fetch bulk timelines, serving as many chunks from cache as possible. + /// Fetch bulk timelines, paginating each entry's long FSMs after merge. + /// + /// Pagination (`long_entities_max`/`_page`, per entry) is applied here at the + /// single tail point so it covers every internal path — cache hit, chunked + /// fetch, and uncached fallback — and is deliberately excluded from the chunk + /// cache key, so different pages reuse the same cached chunks. pub(crate) async fn cached_bulk_timeline( &self, analyzer: Arc, @@ -161,6 +167,38 @@ impl TimelineCache { ::TimelineParams, >, ) -> ServerResult + where + A: UiAnalyzer + Send + Sync + 'static, + ::TimelineGlobalParams: Hash + Eq + Clone + Send + 'static, + ::TimelineParams: Hash + Eq + Clone + Send + 'static, + { + let pagination: HashMap, Option)> = request + .entries + .iter() + .map(|(k, entry)| (k.clone(), pagination_of(entry))) + .collect(); + let mut response = self + .cached_bulk_timeline_inner(analyzer, engine_id, request) + .await?; + for (key, entry) in response.entries.iter_mut() { + if let BulkTimelinesResponseEntry::Ok { data, .. } = entry { + let (max, page) = pagination.get(key).copied().unwrap_or((None, None)); + paginate_long_fsms(data, max, page); + } + } + Ok(response) + } + + /// Fetch bulk timelines, serving as many chunks from cache as possible. + async fn cached_bulk_timeline_inner( + &self, + analyzer: Arc, + engine_id: Uuid, + request: BulkTimelineRequest< + ::TimelineGlobalParams, + ::TimelineParams, + >, + ) -> ServerResult where A: UiAnalyzer + Send + Sync + 'static, ::TimelineGlobalParams: Hash + Eq + Clone + Send + 'static, @@ -386,6 +424,11 @@ impl TimelineCache { Ok(()) } + /// Fetch a single timeline, paginating its long FSMs after merge. + /// + /// Pagination (`long_entities_max`/`_page`) is applied here at the single + /// tail point so it covers the single-chunk fast path, the chunk merge, and + /// every uncached fallback, and is excluded from the chunk cache key. pub(crate) async fn cached_single_timeline( &self, analyzer: Arc, @@ -395,6 +438,28 @@ impl TimelineCache { ::TimelineParams, >, ) -> ServerResult + where + A: UiAnalyzer + Send + Sync + 'static, + ::TimelineGlobalParams: Hash + Eq + Clone + Send + 'static, + ::TimelineParams: Hash + Eq + Clone + Send + 'static, + { + let (max, page) = pagination_of(&request.entry); + let mut response = self + .cached_single_timeline_inner(analyzer, engine_id, request) + .await?; + paginate_long_fsms(&mut response.data, max, page); + Ok(response) + } + + async fn cached_single_timeline_inner( + &self, + analyzer: Arc, + engine_id: Uuid, + request: SingleTimelineRequest< + ::TimelineGlobalParams, + ::TimelineParams, + >, + ) -> ServerResult where A: UiAnalyzer + Send + Sync + 'static, ::TimelineGlobalParams: Hash + Eq + Clone + Send + 'static, @@ -638,6 +703,56 @@ fn assemble_bulk_response( }) } +/// Extract the long-entities pagination params from a timeline request entry. +fn pagination_of(entry: &TimelineRequest) -> (Option, Option) { + match entry { + TimelineRequest::Resource(r) => (r.long_entities_max, r.long_entities_page), + TimelineRequest::ResourceGroup(rg) => (rg.long_entities_max, rg.long_entities_page), + } +} + +/// Rank a timeline's long FSMs and retain the requested page. +/// +/// Sets `long_fsms_total` to the full count before slicing. Pagination params +/// are intentionally excluded from the chunk cache key, so this runs after merge +/// on every response path (cache hit, chunked fetch, and uncached fallback). +fn paginate_long_fsms(data: &mut ResourceTimeline, max: Option, page: Option) { + let (long_fsms, total) = match data { + ResourceTimeline::Binned(d) => (&mut d.long_fsms, &mut d.long_fsms_total), + ResourceTimeline::BinnedByState(d) => (&mut d.long_fsms, &mut d.long_fsms_total), + }; + *total = paginate_fsms_vec(long_fsms, max, page); +} + +/// Sort `long_fsms` by qualifying duration (longest first, `id` tie-break), +/// return the pre-slice count, then retain only the requested page. +/// +/// `max` `None` or `0` returns all entities (no slicing). `page` `None` is page +/// 0. A page past the end yields an empty slice; the returned total still +/// reflects the full count. +fn paginate_fsms_vec( + long_fsms: &mut Vec, + max: Option, + page: Option, +) -> u32 { + long_fsms.sort_by(|a, b| { + b.long_duration_ns + .cmp(&a.long_duration_ns) + .then_with(|| a.id.cmp(&b.id)) + }); + let total = long_fsms.len() as u32; + + if let Some(page_size) = max.filter(|&m| m > 0) { + let len = long_fsms.len(); + let start = ((page.unwrap_or(0) as u64) * page_size as u64).min(len as u64) as usize; + let keep = (len - start).min(page_size as usize); + long_fsms.drain(..start); + long_fsms.truncate(keep); + } + + total +} + fn combine_chunks( chunks: &[SingleTimelineResponse], req_span: SpanNanoSec, @@ -718,6 +833,7 @@ fn combine_chunks( data: ResourceTimeline::BinnedByState(ResourceTimelineBinnedByState { config, capacities_states_values: combined, + long_fsms_total: long_fsms.len() as u32, long_fsms, }), }) @@ -765,6 +881,7 @@ fn combine_chunks( data: ResourceTimeline::Binned(ResourceTimelineBinned { config, capacities_values: combined, + long_fsms_total: long_fsms.len() as u32, long_fsms, }), }) @@ -843,6 +960,90 @@ mod tests { use super::*; + fn fsm(id: u128, duration_ns: u64) -> FiniteStateMachine { + FiniteStateMachine { + id: Uuid::from_u128(id), + type_name: "task".to_string(), + instance_name: format!("t{id}"), + transitions: vec![], + long_duration_ns: duration_ns, + } + } + + fn ids(fsms: &[FiniteStateMachine]) -> Vec { + fsms.iter().map(|f| f.id.as_u128()).collect() + } + + // Ranked order for [dur 100/id 1, 300/2, 300/3, 50/4] is duration desc with + // id asc tie-break: ids [2, 3, 1, 4]. + fn unranked() -> Vec { + vec![fsm(1, 100), fsm(2, 300), fsm(3, 300), fsm(4, 50)] + } + + #[test] + fn paginate_ranks_by_duration_then_id() { + let mut v = unranked(); + let total = paginate_fsms_vec(&mut v, None, None); + assert_eq!(total, 4); + assert_eq!(ids(&v), vec![2, 3, 1, 4]); + } + + #[test] + fn paginate_pages_are_disjoint_and_cover_ranked_order() { + let mut p0 = unranked(); + let mut p1 = unranked(); + let t0 = paginate_fsms_vec(&mut p0, Some(2), Some(0)); + let t1 = paginate_fsms_vec(&mut p1, Some(2), Some(1)); + assert_eq!((t0, t1), (4, 4)); + assert_eq!(ids(&p0), vec![2, 3]); + assert_eq!(ids(&p1), vec![1, 4]); + } + + #[test] + fn paginate_page_past_end_is_empty_with_full_total() { + let mut v = unranked(); + let total = paginate_fsms_vec(&mut v, Some(2), Some(5)); + assert_eq!(total, 4); + assert!(v.is_empty()); + } + + #[test] + fn paginate_max_zero_or_none_returns_all_ranked() { + for max in [None, Some(0)] { + let mut v = unranked(); + let total = paginate_fsms_vec(&mut v, max, Some(0)); + assert_eq!(total, 4); + assert_eq!(ids(&v), vec![2, 3, 1, 4]); + } + } + + #[test] + fn paginate_long_fsms_writes_total_into_variant() { + let config = TimelineConfig { + num_bins: 1, + start: 0.0, + end: 1.0, + } + .try_into_binned_span(0) + .unwrap() + .try_to_secs_relative(0) + .unwrap(); + let mut data = ResourceTimeline::Binned(ResourceTimelineBinned { + config, + capacities_values: HashMap::new(), + long_fsms_total: 0, + long_fsms: unranked(), + }); + paginate_long_fsms(&mut data, Some(2), Some(0)); + match data { + ResourceTimeline::Binned(d) => { + assert_eq!(d.long_fsms_total, 4); + assert_eq!(ids(&d.long_fsms), vec![2, 3]); + } + _ => panic!("expected binned"), + } + } + #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct TestGlobalParams { query_id: Uuid, @@ -1031,7 +1232,7 @@ mod tests { to_secs_relative(bin.start(), 0) + params.series_offset as f64 }) .collect::>(); - let long_fsms = params + let long_fsms: Vec = params .operator_id .map(|id| FiniteStateMachine { id, @@ -1049,12 +1250,14 @@ mod tests { timestamp: config_secs.span.end(), }, ], + long_duration_ns: 0, }) .into_iter() .collect(); let data = ResourceTimeline::Binned(ResourceTimelineBinned { config: config_secs, capacities_values: HashMap::from([("capacity".to_string(), values)]), + long_fsms_total: long_fsms.len() as u32, long_fsms, }); @@ -1080,6 +1283,8 @@ mod tests { TimelineRequest::Resource(ResourceTimelineRequest { resource_id: Uuid::from_u128(2), long_entities_threshold_s: Some(0.0), + long_entities_max: None, + long_entities_page: None, entity_filter: EntityFilter { entity_type_name: None, }, diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index 35b8d39c..168dc7da 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -805,16 +805,18 @@ impl SimulatorUiAnalyzer { /// Turn a list of entity ids into UI-compatible FSM data. fn task_entities_to_ui_fsm( &self, - entity_ids: &[Uuid], + entities: &[(Uuid, TimeNanoSec)], epoch: TimeUnixNanoSec, ) -> AnalyzerResult> { - entity_ids + entities .iter() - .filter_map(|&id| { - self.model - .tasks - .get(&id) - .map(|task| task.try_to_ui_fsm(epoch)) + .filter_map(|&(id, duration)| { + self.model.tasks.get(&id).map(|task| { + task.try_to_ui_fsm(epoch).map(|mut fsm| { + fsm.long_duration_ns = duration; + fsm + }) + }) }) .collect() } @@ -832,10 +834,12 @@ impl SimulatorUiAnalyzer { .map(|(k, v)| (k.to_owned(), v)) .collect(); let long_fsms = self.task_entities_to_ui_fsm(&result.long_entities, epoch)?; + let long_fsms_total = long_fsms.len() as u32; Ok(UiResourceTimeline::Binned(ResourceTimelineBinned { config, capacities_values, long_fsms, + long_fsms_total, })) } @@ -854,11 +858,13 @@ impl SimulatorUiAnalyzer { .insert(state_name.to_owned(), values); } let long_fsms = self.task_entities_to_ui_fsm(&result.long_entities, epoch)?; + let long_fsms_total = long_fsms.len() as u32; Ok(UiResourceTimeline::BinnedByState( ResourceTimelineBinnedByState { config, capacities_states_values, long_fsms, + long_fsms_total, }, )) } diff --git a/examples/simulator/analyzer/src/task.rs b/examples/simulator/analyzer/src/task.rs index 096c9f22..074d59df 100644 --- a/examples/simulator/analyzer/src/task.rs +++ b/examples/simulator/analyzer/src/task.rs @@ -75,6 +75,9 @@ impl TaskExt for Task { type_name: self.type_name().to_string(), instance_name: self.instance_name().to_string(), transitions, + // Set by `task_entities_to_ui_fsm` from the analyzer's qualifying + // span; zero here outside the long-entities context. + long_duration_ns: 0, }) } } diff --git a/examples/simulator/server/ts-bindings/ResourceGroupTimelineRequest.ts b/examples/simulator/server/ts-bindings/ResourceGroupTimelineRequest.ts index 26960f0c..d087fff0 100644 --- a/examples/simulator/server/ts-bindings/ResourceGroupTimelineRequest.ts +++ b/examples/simulator/server/ts-bindings/ResourceGroupTimelineRequest.ts @@ -20,6 +20,15 @@ resource_type_name: string, * time in seconds. */ long_entities_threshold_s: number | null, +/** + * Maximum number of long entities to return (page size). `None` or `0` + * returns all of them. Applied after threshold filtering and ranking. + */ +long_entities_max?: number, +/** + * Zero-based page index into the ranked long entities. `None` is page 0. + */ +long_entities_page?: number, /** * Entity filters. */ diff --git a/examples/simulator/server/ts-bindings/ResourceTimelineBinned.ts b/examples/simulator/server/ts-bindings/ResourceTimelineBinned.ts index 6d37cd4d..d7e904b9 100644 --- a/examples/simulator/server/ts-bindings/ResourceTimelineBinned.ts +++ b/examples/simulator/server/ts-bindings/ResourceTimelineBinned.ts @@ -13,6 +13,12 @@ config: BinnedSpanSec, */ capacities_values: { [key in string]?: Array }, /** - * FSMs that have usage spans exceeding the long_entities_threshold. + * Ranked page of FSMs whose usage spans exceed the long_entities_threshold, + * longest first. Sliced per the request's `long_entities_max`/`_page`. */ -long_fsms: Array, }; +long_fsms: Array, +/** + * Total number of long FSMs matching the threshold before pagination, so + * clients can compute the page count. + */ +long_fsms_total: number, }; diff --git a/examples/simulator/server/ts-bindings/ResourceTimelineBinnedByState.ts b/examples/simulator/server/ts-bindings/ResourceTimelineBinnedByState.ts index e7d791c5..1c18a39f 100644 --- a/examples/simulator/server/ts-bindings/ResourceTimelineBinnedByState.ts +++ b/examples/simulator/server/ts-bindings/ResourceTimelineBinnedByState.ts @@ -13,6 +13,12 @@ config: BinnedSpanSec, */ capacities_states_values: { [key in string]?: { [key in string]?: Array } }, /** - * FSMs that have usage spans exceeding the long_entities_threshold. + * Ranked page of FSMs whose usage spans exceed the long_entities_threshold, + * longest first. Sliced per the request's `long_entities_max`/`_page`. */ -long_fsms: Array, }; +long_fsms: Array, +/** + * Total number of long FSMs matching the threshold before pagination, so + * clients can compute the page count. + */ +long_fsms_total: number, }; diff --git a/examples/simulator/server/ts-bindings/ResourceTimelineRequest.ts b/examples/simulator/server/ts-bindings/ResourceTimelineRequest.ts index abbd3a67..bf430c4c 100644 --- a/examples/simulator/server/ts-bindings/ResourceTimelineRequest.ts +++ b/examples/simulator/server/ts-bindings/ResourceTimelineRequest.ts @@ -14,6 +14,15 @@ resource_id: string, * If set, fully include entities that have usages exceeding this amount of time. */ long_entities_threshold_s: number | null, +/** + * Maximum number of long entities to return (page size). `None` or `0` + * returns all of them. Applied after threshold filtering and ranking. + */ +long_entities_max?: number, +/** + * Zero-based page index into the ranked long entities. `None` is page 0. + */ +long_entities_page?: number, /** * Entity filters. */ From cfddc1038e943c14f5e1b897dfe5f9bd5bc9f6c2 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Fri, 12 Jun 2026 12:02:18 +0200 Subject: [PATCH 2/8] Propose a better request api --- .../analyzer/src/timeline/binned/resource.rs | 7 ++- crates/ui/src/timeline/request.rs | 46 +++++++++++-------- .../ts-bindings/LongEntitiesPageParams.ts | 11 +++++ .../server/ts-bindings/LongEntitiesParams.ts | 16 +++++++ .../server/ts-bindings/PageParams.ts | 14 ++++++ .../ResourceGroupTimelineRequest.ts | 17 ++----- .../ts-bindings/ResourceTimelineRequest.ts | 16 ++----- 7 files changed, 81 insertions(+), 46 deletions(-) create mode 100644 examples/simulator/server/ts-bindings/LongEntitiesPageParams.ts create mode 100644 examples/simulator/server/ts-bindings/LongEntitiesParams.ts create mode 100644 examples/simulator/server/ts-bindings/PageParams.ts diff --git a/crates/analyzer/src/timeline/binned/resource.rs b/crates/analyzer/src/timeline/binned/resource.rs index 2283f5b2..03792053 100644 --- a/crates/analyzer/src/timeline/binned/resource.rs +++ b/crates/analyzer/src/timeline/binned/resource.rs @@ -29,8 +29,6 @@ fn convert_capacity( pub struct ResourceTimeline<'a> { pub config: BinnedSpan, pub data: HashMap<&'a str, Vec>, - /// Entities with a usage span exceeding the long-entities threshold, paired - /// with the longest such qualifying span duration (used for ranking). pub long_entities: Vec<(Uuid, TimeNanoSec)>, } @@ -38,8 +36,6 @@ pub struct ResourceTimeline<'a> { pub struct ResourceTimelineByKey<'a, K> { pub config: BinnedSpan, pub data: HashMap<(K, &'a str), Vec>, - /// Entities with a usage span exceeding the long-entities threshold, paired - /// with the longest such qualifying span duration (used for ranking). pub long_entities: Vec<(Uuid, TimeNanoSec)>, } @@ -80,6 +76,9 @@ impl<'a> ResourceTimelineBuilder<'a> { && usage.span().duration() > threshold && usage.span().intersects(&self.aggregator.config.span) { + // The entity is along entity. Also keep track of its longest usage + // duration for stable ordering by this duration later. This useful + // for pagination towards the UI. let duration = usage.span().duration(); self.long_entities .entry(usage.entity_id()) diff --git a/crates/ui/src/timeline/request.rs b/crates/ui/src/timeline/request.rs index 94d5c15b..f15f9aa9 100644 --- a/crates/ui/src/timeline/request.rs +++ b/crates/ui/src/timeline/request.rs @@ -48,20 +48,34 @@ pub struct EntityFilter { // TODO(johanpel): instance name } +/// Parameters of paginated APIs. +#[derive(TS, Debug, Clone, Serialize, Deserialize)] +pub struct PageParams { + /// Maximum number of items per page. + pub max: u32, + /// The zero-based page index requested. + pub page: u32, +} + +/// Parameters for requesting long entities in a resource timeline +#[derive(TS, Debug, Clone, Serialize, Deserialize)] +pub struct LongEntitiesParams { + /// Fully include entities that have usages exceeding this amount of time. + pub threshold_s: TimeSec, + /// Parameters of the page of long entities. If this is not set, return + /// all long entities. + pub page: Option, +} + /// Parameters for requesting a resource timeline. #[derive(TS, Debug, Clone, Serialize, Deserialize)] pub struct ResourceTimelineRequest { /// The ID of the resource pub resource_id: Uuid, - /// If set, fully include entities that have usages exceeding this amount of time. - pub long_entities_threshold_s: Option, - /// Maximum number of long entities to return (page size). `None` or `0` - /// returns all of them. Applied after threshold filtering and ranking. - #[ts(optional)] - pub long_entities_max: Option, - /// Zero-based page index into the ranked long entities. `None` is page 0. - #[ts(optional)] - pub long_entities_page: Option, + /// Parameters for dealing with long entities. + /// + /// If this is not set, then no long entities are returned. + pub long_entities: Option, /// Entity filters. pub entity_filter: EntityFilter, /// Application-specific request parameters, e.g. for filtering. @@ -78,16 +92,10 @@ pub struct ResourceGroupTimelineRequest { /// The type name of the leaf resources for which to produce the timeline /// for this group. pub resource_type_name: String, - /// If set, fully include entities that have usages exceeding this amount of - /// time in seconds. - pub long_entities_threshold_s: Option, - /// Maximum number of long entities to return (page size). `None` or `0` - /// returns all of them. Applied after threshold filtering and ranking. - #[ts(optional)] - pub long_entities_max: Option, - /// Zero-based page index into the ranked long entities. `None` is page 0. - #[ts(optional)] - pub long_entities_page: Option, + /// Parameters for dealing with long entities. + /// + /// If this is not set, then no long entities are returned. + pub long_entities: Option, /// Entity filters. pub entity_filter: EntityFilter, /// Application-specific request parameters, e.g. for filtering. diff --git a/examples/simulator/server/ts-bindings/LongEntitiesPageParams.ts b/examples/simulator/server/ts-bindings/LongEntitiesPageParams.ts new file mode 100644 index 00000000..80ac6c6f --- /dev/null +++ b/examples/simulator/server/ts-bindings/LongEntitiesPageParams.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LongEntitiesPageParams = { +/** + * Maximum number of long entities per page. + */ +max: number, +/** + * Zero-based page index into long entities. + */ +long_entities_page: number, }; diff --git a/examples/simulator/server/ts-bindings/LongEntitiesParams.ts b/examples/simulator/server/ts-bindings/LongEntitiesParams.ts new file mode 100644 index 00000000..0b93e886 --- /dev/null +++ b/examples/simulator/server/ts-bindings/LongEntitiesParams.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PageParams } from "./PageParams"; + +/** + * Parameters for requesting long entities in a resource timeline + */ +export type LongEntitiesParams = { +/** + * Fully include entities that have usages exceeding this amount of time. + */ +threshold_s: number, +/** + * Parameters of the page of long entities. If this is not set, return + * all long entities. + */ +page: PageParams | null, }; diff --git a/examples/simulator/server/ts-bindings/PageParams.ts b/examples/simulator/server/ts-bindings/PageParams.ts new file mode 100644 index 00000000..7aff16a2 --- /dev/null +++ b/examples/simulator/server/ts-bindings/PageParams.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Parameters of paginated APIs. + */ +export type PageParams = { +/** + * Maximum number of items per page. + */ +max: number, +/** + * The zero-based page index requested. + */ +page: number, }; diff --git a/examples/simulator/server/ts-bindings/ResourceGroupTimelineRequest.ts b/examples/simulator/server/ts-bindings/ResourceGroupTimelineRequest.ts index d087fff0..b68ff2a8 100644 --- a/examples/simulator/server/ts-bindings/ResourceGroupTimelineRequest.ts +++ b/examples/simulator/server/ts-bindings/ResourceGroupTimelineRequest.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { EntityFilter } from "./EntityFilter"; +import type { LongEntitiesParams } from "./LongEntitiesParams"; import type { TimelineConfig } from "./TimelineConfig"; /** @@ -16,19 +17,11 @@ resource_group_id: string, */ resource_type_name: string, /** - * If set, fully include entities that have usages exceeding this amount of - * time in seconds. + * Parameters for dealing with long entities. + * + * If this is not set, then no long entities are returned. */ -long_entities_threshold_s: number | null, -/** - * Maximum number of long entities to return (page size). `None` or `0` - * returns all of them. Applied after threshold filtering and ranking. - */ -long_entities_max?: number, -/** - * Zero-based page index into the ranked long entities. `None` is page 0. - */ -long_entities_page?: number, +long_entities: LongEntitiesParams | null, /** * Entity filters. */ diff --git a/examples/simulator/server/ts-bindings/ResourceTimelineRequest.ts b/examples/simulator/server/ts-bindings/ResourceTimelineRequest.ts index bf430c4c..2835bc06 100644 --- a/examples/simulator/server/ts-bindings/ResourceTimelineRequest.ts +++ b/examples/simulator/server/ts-bindings/ResourceTimelineRequest.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { EntityFilter } from "./EntityFilter"; +import type { LongEntitiesParams } from "./LongEntitiesParams"; import type { TimelineConfig } from "./TimelineConfig"; /** @@ -11,18 +12,11 @@ export type ResourceTimelineRequest = { */ resource_id: string, /** - * If set, fully include entities that have usages exceeding this amount of time. + * Parameters for dealing with long entities. + * + * If this is not set, then no long entities are returned. */ -long_entities_threshold_s: number | null, -/** - * Maximum number of long entities to return (page size). `None` or `0` - * returns all of them. Applied after threshold filtering and ranking. - */ -long_entities_max?: number, -/** - * Zero-based page index into the ranked long entities. `None` is page 0. - */ -long_entities_page?: number, +long_entities: LongEntitiesParams | null, /** * Entity filters. */ From 7e4ceffdecb5fee5077a2835800d498c6b851a37 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Fri, 12 Jun 2026 12:20:13 +0200 Subject: [PATCH 3/8] refactor(timeline): wire nested long-entities request API through consumers Adopt the LongEntitiesParams/PageParams request structure proposed in the previous commit across all consumers so the workspace builds again: the server cache derives the threshold from long_entities for the chunk cache key and reads PageParams for post-merge pagination (the page stays out of the cache key); the simulator analyzer reads the threshold from long_entities; and the UI request builders emit long_entities: { threshold_s, page } or null. Also removes the orphan LongEntitiesPageParams.ts binding left over from an earlier iteration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../query_engine/server/src/timeline_cache.rs | 109 ++++++++++-------- examples/simulator/analyzer/src/lib.rs | 20 +++- .../ts-bindings/LongEntitiesPageParams.ts | 11 -- .../components/src/lib/timeline.utils.test.ts | 4 +- .../components/src/lib/timeline.utils.ts | 4 +- .../src/timeline/ResourceTimeline.tsx | 4 +- .../hooks/src/timeline/timeline.utils.test.ts | 4 +- ui/src/components/QueryResourceTree.tsx | 2 +- 8 files changed, 84 insertions(+), 74 deletions(-) delete mode 100644 examples/simulator/server/ts-bindings/LongEntitiesPageParams.ts diff --git a/domains/query_engine/server/src/timeline_cache.rs b/domains/query_engine/server/src/timeline_cache.rs index ee6ea4da..1d46ea6a 100644 --- a/domains/query_engine/server/src/timeline_cache.rs +++ b/domains/query_engine/server/src/timeline_cache.rs @@ -15,8 +15,8 @@ use quent_time::{SpanNanoSec, TimeNanoSec, bin::BinnedSpan, to_nanosecs, to_secs use quent_ui::FiniteStateMachine; use quent_ui::timeline::{ request::{ - BulkChunkedTimelineRequest, BulkTimelineRequest, SingleTimelineRequest, TimelineConfig, - TimelineRequest, + BulkChunkedTimelineRequest, BulkTimelineRequest, PageParams, SingleTimelineRequest, + TimelineConfig, TimelineRequest, }, response::{ BulkTimelinesResponse, BulkTimelinesResponseEntry, ResourceTimeline, @@ -70,14 +70,20 @@ impl<'a, EntryParams> EntryParamsKey<'a, EntryParams> { match entry { TimelineRequest::Resource(r) => Self::Resource { resource_id: r.resource_id, - long_entities_threshold_s: r.long_entities_threshold_s.map(HashableF64::from), + long_entities_threshold_s: r + .long_entities + .as_ref() + .map(|le| HashableF64::from(le.threshold_s)), entity_type_name: r.entity_filter.entity_type_name.as_deref(), application: &r.application, }, TimelineRequest::ResourceGroup(rg) => Self::ResourceGroup { resource_group_id: rg.resource_group_id, resource_type_name: &rg.resource_type_name, - long_entities_threshold_s: rg.long_entities_threshold_s.map(HashableF64::from), + long_entities_threshold_s: rg + .long_entities + .as_ref() + .map(|le| HashableF64::from(le.threshold_s)), entity_type_name: rg.entity_filter.entity_type_name.as_deref(), app_params: &rg.app_params, }, @@ -154,10 +160,10 @@ impl TimelineCache { /// Fetch bulk timelines, paginating each entry's long FSMs after merge. /// - /// Pagination (`long_entities_max`/`_page`, per entry) is applied here at the - /// single tail point so it covers every internal path — cache hit, chunked - /// fetch, and uncached fallback — and is deliberately excluded from the chunk - /// cache key, so different pages reuse the same cached chunks. + /// Pagination (`long_entities.page`, per entry) is applied here at the single + /// tail point so it covers every internal path — cache hit, chunked fetch, + /// and uncached fallback — and is deliberately excluded from the chunk cache + /// key, so different pages reuse the same cached chunks. pub(crate) async fn cached_bulk_timeline( &self, analyzer: Arc, @@ -172,7 +178,7 @@ impl TimelineCache { ::TimelineGlobalParams: Hash + Eq + Clone + Send + 'static, ::TimelineParams: Hash + Eq + Clone + Send + 'static, { - let pagination: HashMap, Option)> = request + let pagination: HashMap> = request .entries .iter() .map(|(k, entry)| (k.clone(), pagination_of(entry))) @@ -182,8 +188,8 @@ impl TimelineCache { .await?; for (key, entry) in response.entries.iter_mut() { if let BulkTimelinesResponseEntry::Ok { data, .. } = entry { - let (max, page) = pagination.get(key).copied().unwrap_or((None, None)); - paginate_long_fsms(data, max, page); + let page = pagination.get(key).cloned().flatten(); + paginate_long_fsms(data, page); } } Ok(response) @@ -426,9 +432,9 @@ impl TimelineCache { /// Fetch a single timeline, paginating its long FSMs after merge. /// - /// Pagination (`long_entities_max`/`_page`) is applied here at the single - /// tail point so it covers the single-chunk fast path, the chunk merge, and - /// every uncached fallback, and is excluded from the chunk cache key. + /// Pagination (`long_entities.page`) is applied here at the single tail + /// point so it covers the single-chunk fast path, the chunk merge, and every + /// uncached fallback, and is excluded from the chunk cache key. pub(crate) async fn cached_single_timeline( &self, analyzer: Arc, @@ -443,11 +449,11 @@ impl TimelineCache { ::TimelineGlobalParams: Hash + Eq + Clone + Send + 'static, ::TimelineParams: Hash + Eq + Clone + Send + 'static, { - let (max, page) = pagination_of(&request.entry); + let page = pagination_of(&request.entry); let mut response = self .cached_single_timeline_inner(analyzer, engine_id, request) .await?; - paginate_long_fsms(&mut response.data, max, page); + paginate_long_fsms(&mut response.data, page); Ok(response) } @@ -703,38 +709,38 @@ fn assemble_bulk_response( }) } -/// Extract the long-entities pagination params from a timeline request entry. -fn pagination_of(entry: &TimelineRequest) -> (Option, Option) { - match entry { - TimelineRequest::Resource(r) => (r.long_entities_max, r.long_entities_page), - TimelineRequest::ResourceGroup(rg) => (rg.long_entities_max, rg.long_entities_page), - } +/// Extract the long-entities page request from a timeline request entry. +/// +/// `None` means return all long entities (no paging); the threshold lives in +/// `long_entities` and is handled separately as part of the cache key. +fn pagination_of(entry: &TimelineRequest) -> Option { + let long_entities = match entry { + TimelineRequest::Resource(r) => r.long_entities.as_ref(), + TimelineRequest::ResourceGroup(rg) => rg.long_entities.as_ref(), + }; + long_entities.and_then(|le| le.page.clone()) } /// Rank a timeline's long FSMs and retain the requested page. /// -/// Sets `long_fsms_total` to the full count before slicing. Pagination params -/// are intentionally excluded from the chunk cache key, so this runs after merge -/// on every response path (cache hit, chunked fetch, and uncached fallback). -fn paginate_long_fsms(data: &mut ResourceTimeline, max: Option, page: Option) { +/// Sets `long_fsms_total` to the full count before slicing. The page request is +/// intentionally excluded from the chunk cache key, so this runs after merge on +/// every response path (cache hit, chunked fetch, and uncached fallback). +fn paginate_long_fsms(data: &mut ResourceTimeline, page: Option) { let (long_fsms, total) = match data { ResourceTimeline::Binned(d) => (&mut d.long_fsms, &mut d.long_fsms_total), ResourceTimeline::BinnedByState(d) => (&mut d.long_fsms, &mut d.long_fsms_total), }; - *total = paginate_fsms_vec(long_fsms, max, page); + *total = paginate_fsms_vec(long_fsms, page); } /// Sort `long_fsms` by qualifying duration (longest first, `id` tie-break), /// return the pre-slice count, then retain only the requested page. /// -/// `max` `None` or `0` returns all entities (no slicing). `page` `None` is page -/// 0. A page past the end yields an empty slice; the returned total still -/// reflects the full count. -fn paginate_fsms_vec( - long_fsms: &mut Vec, - max: Option, - page: Option, -) -> u32 { +/// `page` `None` (or a `max` of `0`) returns all entities — no slicing. A page +/// past the end yields an empty slice; the returned total still reflects the +/// full count. +fn paginate_fsms_vec(long_fsms: &mut Vec, page: Option) -> u32 { long_fsms.sort_by(|a, b| { b.long_duration_ns .cmp(&a.long_duration_ns) @@ -742,10 +748,12 @@ fn paginate_fsms_vec( }); let total = long_fsms.len() as u32; - if let Some(page_size) = max.filter(|&m| m > 0) { + if let Some(PageParams { max, page }) = page + && max > 0 + { let len = long_fsms.len(); - let start = ((page.unwrap_or(0) as u64) * page_size as u64).min(len as u64) as usize; - let keep = (len - start).min(page_size as usize); + let start = ((page as u64) * max as u64).min(len as u64) as usize; + let keep = (len - start).min(max as usize); long_fsms.drain(..start); long_fsms.truncate(keep); } @@ -947,8 +955,8 @@ mod tests { FiniteStateMachine, FsmTransition, timeline::{ request::{ - BulkTimelineRequest, EntityFilter, ResourceTimelineRequest, TimelineConfig, - TimelineRequest, + BulkTimelineRequest, EntityFilter, LongEntitiesParams, PageParams, + ResourceTimelineRequest, TimelineConfig, TimelineRequest, }, response::{ BulkTimelinesResponse, BulkTimelinesResponseEntry, ResourceTimeline, @@ -983,7 +991,7 @@ mod tests { #[test] fn paginate_ranks_by_duration_then_id() { let mut v = unranked(); - let total = paginate_fsms_vec(&mut v, None, None); + let total = paginate_fsms_vec(&mut v, None); assert_eq!(total, 4); assert_eq!(ids(&v), vec![2, 3, 1, 4]); } @@ -992,8 +1000,8 @@ mod tests { fn paginate_pages_are_disjoint_and_cover_ranked_order() { let mut p0 = unranked(); let mut p1 = unranked(); - let t0 = paginate_fsms_vec(&mut p0, Some(2), Some(0)); - let t1 = paginate_fsms_vec(&mut p1, Some(2), Some(1)); + let t0 = paginate_fsms_vec(&mut p0, Some(PageParams { max: 2, page: 0 })); + let t1 = paginate_fsms_vec(&mut p1, Some(PageParams { max: 2, page: 1 })); assert_eq!((t0, t1), (4, 4)); assert_eq!(ids(&p0), vec![2, 3]); assert_eq!(ids(&p1), vec![1, 4]); @@ -1002,16 +1010,16 @@ mod tests { #[test] fn paginate_page_past_end_is_empty_with_full_total() { let mut v = unranked(); - let total = paginate_fsms_vec(&mut v, Some(2), Some(5)); + let total = paginate_fsms_vec(&mut v, Some(PageParams { max: 2, page: 5 })); assert_eq!(total, 4); assert!(v.is_empty()); } #[test] fn paginate_max_zero_or_none_returns_all_ranked() { - for max in [None, Some(0)] { + for page in [None, Some(PageParams { max: 0, page: 0 })] { let mut v = unranked(); - let total = paginate_fsms_vec(&mut v, max, Some(0)); + let total = paginate_fsms_vec(&mut v, page); assert_eq!(total, 4); assert_eq!(ids(&v), vec![2, 3, 1, 4]); } @@ -1034,7 +1042,7 @@ mod tests { long_fsms_total: 0, long_fsms: unranked(), }); - paginate_long_fsms(&mut data, Some(2), Some(0)); + paginate_long_fsms(&mut data, Some(PageParams { max: 2, page: 0 })); match data { ResourceTimeline::Binned(d) => { assert_eq!(d.long_fsms_total, 4); @@ -1282,9 +1290,10 @@ mod tests { key.to_string(), TimelineRequest::Resource(ResourceTimelineRequest { resource_id: Uuid::from_u128(2), - long_entities_threshold_s: Some(0.0), - long_entities_max: None, - long_entities_page: None, + long_entities: Some(LongEntitiesParams { + threshold_s: 0.0, + page: None, + }), entity_filter: EntityFilter { entity_type_name: None, }, diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index 168dc7da..d05773a9 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -261,7 +261,10 @@ impl UiAnalyzer for SimulatorUiAnalyzer { match request.entry { TimelineRequest::Resource(req) => { let resource_type = self.model.resource_type_of(req.resource_id)?; - let long_entities_threshold = req.long_entities_threshold_s.map(to_nanosecs); + let long_entities_threshold = req + .long_entities + .as_ref() + .map(|le| to_nanosecs(le.threshold_s)); let task_filter = req.application; if req.entity_filter.entity_type_name.is_some() { @@ -304,7 +307,10 @@ impl UiAnalyzer for SimulatorUiAnalyzer { } TimelineRequest::ResourceGroup(req) => { let resource_type = self.model.resource_type(&req.resource_type_name)?; - let long_entities_threshold = req.long_entities_threshold_s.map(to_nanosecs); + let long_entities_threshold = req + .long_entities + .as_ref() + .map(|le| to_nanosecs(le.threshold_s)); // Build the resource tree for this group let tree = ResourceTreeNode::try_new(&self.model, req.resource_group_id)?; @@ -758,7 +764,10 @@ impl SimulatorUiAnalyzer { resource_id_filter: [r.resource_id].into_iter().collect(), entity_filter: r.entity_filter, task_filter: r.application, - long_entities_threshold: r.long_entities_threshold_s.map(to_nanosecs), + long_entities_threshold: r + .long_entities + .as_ref() + .map(|le| to_nanosecs(le.threshold_s)), }, TimelineRequest::ResourceGroup(rg) => { let resource_type = self.model.resource_type(&rg.resource_type_name)?; @@ -779,7 +788,10 @@ impl SimulatorUiAnalyzer { resource_id_filter: resource_ids, entity_filter: rg.entity_filter, task_filter: rg.app_params, - long_entities_threshold: rg.long_entities_threshold_s.map(to_nanosecs), + long_entities_threshold: rg + .long_entities + .as_ref() + .map(|le| to_nanosecs(le.threshold_s)), } } }) diff --git a/examples/simulator/server/ts-bindings/LongEntitiesPageParams.ts b/examples/simulator/server/ts-bindings/LongEntitiesPageParams.ts deleted file mode 100644 index 80ac6c6f..00000000 --- a/examples/simulator/server/ts-bindings/LongEntitiesPageParams.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LongEntitiesPageParams = { -/** - * Maximum number of long entities per page. - */ -max: number, -/** - * Zero-based page index into long entities. - */ -long_entities_page: number, }; diff --git a/ui/packages/@quent/components/src/lib/timeline.utils.test.ts b/ui/packages/@quent/components/src/lib/timeline.utils.test.ts index 77d241db..6f263567 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.test.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.test.ts @@ -53,7 +53,7 @@ function makeResourceEntry(): TimelineRequest { return { Resource: { resource_id: 'r1', - long_entities_threshold_s: null, + long_entities: null, entity_filter: baseFilter, application: { operator_id: null }, config: baseConfig, @@ -66,7 +66,7 @@ function makeGroupEntry(): TimelineRequest { ResourceGroup: { resource_group_id: 'g1', resource_type_name: 'disk', - long_entities_threshold_s: null, + long_entities: null, entity_filter: baseFilter, app_params: { operator_id: null }, config: baseConfig, diff --git a/ui/packages/@quent/components/src/lib/timeline.utils.ts b/ui/packages/@quent/components/src/lib/timeline.utils.ts index 1507ac33..bbd66b1e 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.ts @@ -638,7 +638,7 @@ export function buildBulkParamsForItem( ResourceGroup: { resource_group_id: item.id, resource_type_name: resourceTypeName || '', - long_entities_threshold_s: null, + long_entities: null, entity_filter: { entity_type_name: fsmTypeName }, app_params: { operator_id: operatorId }, config, @@ -649,7 +649,7 @@ export function buildBulkParamsForItem( return { Resource: { resource_id: item.id, - long_entities_threshold_s: threshold, + long_entities: { threshold_s: threshold, page: null }, entity_filter: { entity_type_name: fsmTypeName }, application: { operator_id: operatorId }, config, diff --git a/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx index 5c716045..e8c8e965 100644 --- a/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx +++ b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx @@ -156,7 +156,7 @@ export function ResourceTimeline({ ResourceGroup: { resource_group_id: resourceId, resource_type_name: resourceTypeName ?? '', - long_entities_threshold_s: getLongEntitiesThreshold(windowSeconds), + long_entities: { threshold_s: getLongEntitiesThreshold(windowSeconds), page: null }, entity_filter: { entity_type_name: fsmTypeName ?? null }, app_params: { operator_id: null }, config, @@ -165,7 +165,7 @@ export function ResourceTimeline({ : { Resource: { resource_id: resourceId, - long_entities_threshold_s: getLongEntitiesThreshold(windowSeconds), + long_entities: { threshold_s: getLongEntitiesThreshold(windowSeconds), page: null }, entity_filter: { entity_type_name: fsmTypeName ?? null }, application: { operator_id: null }, config, diff --git a/ui/packages/@quent/hooks/src/timeline/timeline.utils.test.ts b/ui/packages/@quent/hooks/src/timeline/timeline.utils.test.ts index 5e48f087..5774201f 100644 --- a/ui/packages/@quent/hooks/src/timeline/timeline.utils.test.ts +++ b/ui/packages/@quent/hooks/src/timeline/timeline.utils.test.ts @@ -18,7 +18,7 @@ function makeResourceRequest( return { Resource: { resource_id: resourceId, - long_entities_threshold_s: null, + long_entities: null, entity_filter: { entity_type_name: entityTypeName }, application: { operator_id: operatorId }, config: { window_start_s: 0, window_end_s: 1, num_bins: 10 } as never, @@ -36,7 +36,7 @@ function makeGroupRequest( ResourceGroup: { resource_group_id: groupId, resource_type_name: resourceTypeName, - long_entities_threshold_s: null, + long_entities: null, entity_filter: { entity_type_name: entityTypeName }, app_params: { operator_id: operatorId }, config: { window_start_s: 0, window_end_s: 1, num_bins: 10 } as never, diff --git a/ui/src/components/QueryResourceTree.tsx b/ui/src/components/QueryResourceTree.tsx index 3638f3be..31c42ad6 100644 --- a/ui/src/components/QueryResourceTree.tsx +++ b/ui/src/components/QueryResourceTree.tsx @@ -161,7 +161,7 @@ function QueryResourceTreeContent({ queryBundle, engineId }: QueryResourceTreePr ResourceGroup: { resource_group_id: rootResourceGroupId!, resource_type_name: rootResourceType ?? '', - long_entities_threshold_s: null, + long_entities: null, entity_filter: { entity_type_name: null }, app_params: { operator_id: null }, config: { From d654b1c3eece498c0654930795d4d189050f41bf Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Fri, 12 Jun 2026 12:58:59 +0200 Subject: [PATCH 4/8] refactor(timeline): derive long-entity ranking from FSM transitions Drop the per-FSM duration field carried solely for ranking and instead derive the rank key server-side from each FSM's transitions (the largest gap from a usage-bearing transition to the next), applied at the pagination tail. Reverts the analyzer/simulator duration plumbing so the long-entities order is established entirely from data already present in transitions, with nothing extra on the FSM or the wire. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../analyzer/src/timeline/binned/resource.rs | 47 ++++------------- crates/ui/src/lib.rs | 8 --- .../query_engine/server/src/timeline_cache.rs | 51 +++++++++++++++---- examples/simulator/analyzer/src/lib.rs | 16 +++--- examples/simulator/analyzer/src/task.rs | 3 -- 5 files changed, 58 insertions(+), 67 deletions(-) diff --git a/crates/analyzer/src/timeline/binned/resource.rs b/crates/analyzer/src/timeline/binned/resource.rs index 03792053..7cb86d63 100644 --- a/crates/analyzer/src/timeline/binned/resource.rs +++ b/crates/analyzer/src/timeline/binned/resource.rs @@ -6,7 +6,7 @@ use std::hash::Hash; use quent_time::{SpanNanoSec, TimeNanoSec, bin::BinnedSpan}; -use rustc_hash::FxHashMap as HashMap; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use uuid::Uuid; use crate::{ @@ -29,20 +29,20 @@ fn convert_capacity( pub struct ResourceTimeline<'a> { pub config: BinnedSpan, pub data: HashMap<&'a str, Vec>, - pub long_entities: Vec<(Uuid, TimeNanoSec)>, + pub long_entities: Vec, } #[derive(Clone, Debug)] pub struct ResourceTimelineByKey<'a, K> { pub config: BinnedSpan, pub data: HashMap<(K, &'a str), Vec>, - pub long_entities: Vec<(Uuid, TimeNanoSec)>, + pub long_entities: Vec, } pub struct ResourceTimelineBuilder<'a> { resource_type: &'a ResourceTypeDecl, aggregator: KeyedAggregator<&'a str>, - long_entities: HashMap, + long_entities: HashSet, long_entities_threshold: Option, } @@ -57,7 +57,7 @@ impl<'a> ResourceTimelineBuilder<'a> { Ok(Self { resource_type, aggregator, - long_entities: HashMap::default(), + long_entities: HashSet::default(), long_entities_threshold, }) } @@ -76,14 +76,7 @@ impl<'a> ResourceTimelineBuilder<'a> { && usage.span().duration() > threshold && usage.span().intersects(&self.aggregator.config.span) { - // The entity is along entity. Also keep track of its longest usage - // duration for stable ordering by this duration later. This useful - // for pagination towards the UI. - let duration = usage.span().duration(); - self.long_entities - .entry(usage.entity_id()) - .and_modify(|d| *d = (*d).max(duration)) - .or_insert(duration); + self.long_entities.insert(usage.entity_id()); } Ok(()) } @@ -110,7 +103,7 @@ impl<'a> ResourceTimelineBuilder<'a> { pub struct ResourceTimelineByKeyBuilder<'a, K> { resource_type: &'a ResourceTypeDecl, aggregator: KeyedAggregator<(K, &'a str)>, - long_entities: HashMap, + long_entities: HashSet, long_entities_threshold: Option, } @@ -128,7 +121,7 @@ where Ok(Self { resource_type, aggregator, - long_entities: HashMap::default(), + long_entities: HashSet::default(), long_entities_threshold, }) } @@ -146,11 +139,7 @@ where && usage.span().duration() > threshold && usage.span().intersects(&self.aggregator.config.span) { - let duration = usage.span().duration(); - self.long_entities - .entry(usage.entity_id()) - .and_modify(|d| *d = (*d).max(duration)) - .or_insert(duration); + self.long_entities.insert(usage.entity_id()); } Ok(()) } @@ -178,8 +167,6 @@ where mod tests { use std::num::NonZero; - use rustc_hash::FxHashSet as HashSet; - use crate::{ fsm::{ FsmUsages, @@ -791,13 +778,7 @@ mod tests { let mut outside_builder = ResourceTimelineBuilder::try_new(resource_type, config, Some(threshold)).unwrap(); outside_builder.try_extend(outside_fsms.usages()).unwrap(); - assert!( - !outside_builder - .build() - .long_entities - .iter() - .any(|(id, _)| *id == resource_id) - ); + assert!(!outside_builder.build().long_entities.contains(&resource_id)); let mut inside_fsms = InMemoryFsms::::new(); inside_fsms.insert(make_fsm(500, 1500)); @@ -806,12 +787,6 @@ mod tests { let mut inside_builder = ResourceTimelineBuilder::try_new(resource_type, config, Some(threshold)).unwrap(); inside_builder.try_extend(inside_fsms.usages()).unwrap(); - assert!( - inside_builder - .build() - .long_entities - .iter() - .any(|(id, _)| *id == resource_id) - ); + assert!(inside_builder.build().long_entities.contains(&resource_id)); } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 07937b7e..bb56e8bb 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -231,13 +231,6 @@ pub struct FiniteStateMachine { pub instance_name: String, /// The transitions of this FSM. pub transitions: Vec, - /// Longest qualifying usage span (ns) that surfaced this FSM as a long - /// entity, used only for server-side ranking. Not serialized: the per-state - /// durations are already derivable from `transitions`. Zero when this FSM - /// is not produced in a long-entities context. - #[serde(skip)] - #[ts(skip)] - pub long_duration_ns: u64, } impl FiniteStateMachine { @@ -254,7 +247,6 @@ impl FiniteStateMachine { .iter() .map(|t| FsmTransition::try_from_rt(t, epoch)) .collect::, _>>()?, - long_duration_ns: 0, }) } } diff --git a/domains/query_engine/server/src/timeline_cache.rs b/domains/query_engine/server/src/timeline_cache.rs index 1d46ea6a..76ad14ec 100644 --- a/domains/query_engine/server/src/timeline_cache.rs +++ b/domains/query_engine/server/src/timeline_cache.rs @@ -741,11 +741,14 @@ fn paginate_long_fsms(data: &mut ResourceTimeline, page: Option) { /// past the end yields an empty slice; the returned total still reflects the /// full count. fn paginate_fsms_vec(long_fsms: &mut Vec, page: Option) -> u32 { - long_fsms.sort_by(|a, b| { - b.long_duration_ns - .cmp(&a.long_duration_ns) - .then_with(|| a.id.cmp(&b.id)) - }); + // Rank longest-usage-first with an id tie-break, deriving each FSM's usage + // span from its own transitions (decorate-sort-undecorate computes it once). + let mut keyed: Vec<(f64, FiniteStateMachine)> = std::mem::take(long_fsms) + .into_iter() + .map(|fsm| (longest_usage_secs(&fsm), fsm)) + .collect(); + keyed.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id))); + *long_fsms = keyed.into_iter().map(|(_, fsm)| fsm).collect(); let total = long_fsms.len() as u32; if let Some(PageParams { max, page }) = page @@ -761,6 +764,18 @@ fn paginate_fsms_vec(long_fsms: &mut Vec, page: Option f64 { + fsm.transitions + .windows(2) + .filter(|w| !w[0].usages.is_empty()) + .map(|w| w[1].timestamp - w[0].timestamp) + .fold(0.0_f64, f64::max) +} + fn combine_chunks( chunks: &[SingleTimelineResponse], req_span: SpanNanoSec, @@ -952,7 +967,7 @@ mod tests { }; use quent_query_engine_model::engine::{EngineEvent, Exit, Init}; use quent_ui::{ - FiniteStateMachine, FsmTransition, + FiniteStateMachine, FsmTransition, FsmUsage, timeline::{ request::{ BulkTimelineRequest, EntityFilter, LongEntitiesParams, PageParams, @@ -968,13 +983,28 @@ mod tests { use super::*; - fn fsm(id: u128, duration_ns: u64) -> FiniteStateMachine { + // Build an FSM whose single usage spans `usage_secs` seconds, so its + // transition-derived rank key equals `usage_secs`. + fn fsm(id: u128, usage_secs: f64) -> FiniteStateMachine { FiniteStateMachine { id: Uuid::from_u128(id), type_name: "task".to_string(), instance_name: format!("t{id}"), - transitions: vec![], - long_duration_ns: duration_ns, + transitions: vec![ + FsmTransition { + name: "use".to_string(), + usages: vec![FsmUsage { + resource: Uuid::from_u128(99), + capacities: vec![], + }], + timestamp: 0.0, + }, + FsmTransition { + name: "exit".to_string(), + usages: vec![], + timestamp: usage_secs, + }, + ], } } @@ -985,7 +1015,7 @@ mod tests { // Ranked order for [dur 100/id 1, 300/2, 300/3, 50/4] is duration desc with // id asc tie-break: ids [2, 3, 1, 4]. fn unranked() -> Vec { - vec![fsm(1, 100), fsm(2, 300), fsm(3, 300), fsm(4, 50)] + vec![fsm(1, 100.0), fsm(2, 300.0), fsm(3, 300.0), fsm(4, 50.0)] } #[test] @@ -1258,7 +1288,6 @@ mod tests { timestamp: config_secs.span.end(), }, ], - long_duration_ns: 0, }) .into_iter() .collect(); diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index d05773a9..f5acbab3 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -817,18 +817,16 @@ impl SimulatorUiAnalyzer { /// Turn a list of entity ids into UI-compatible FSM data. fn task_entities_to_ui_fsm( &self, - entities: &[(Uuid, TimeNanoSec)], + entity_ids: &[Uuid], epoch: TimeUnixNanoSec, ) -> AnalyzerResult> { - entities + entity_ids .iter() - .filter_map(|&(id, duration)| { - self.model.tasks.get(&id).map(|task| { - task.try_to_ui_fsm(epoch).map(|mut fsm| { - fsm.long_duration_ns = duration; - fsm - }) - }) + .filter_map(|&id| { + self.model + .tasks + .get(&id) + .map(|task| task.try_to_ui_fsm(epoch)) }) .collect() } diff --git a/examples/simulator/analyzer/src/task.rs b/examples/simulator/analyzer/src/task.rs index 074d59df..096c9f22 100644 --- a/examples/simulator/analyzer/src/task.rs +++ b/examples/simulator/analyzer/src/task.rs @@ -75,9 +75,6 @@ impl TaskExt for Task { type_name: self.type_name().to_string(), instance_name: self.instance_name().to_string(), transitions, - // Set by `task_entities_to_ui_fsm` from the analyzer's qualifying - // span; zero here outside the long-entities context. - long_duration_ns: 0, }) } } From 6688dacf912b6956843ad4ed11aa876cb5e1fdd8 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Fri, 12 Jun 2026 13:04:33 +0200 Subject: [PATCH 5/8] Propose a better response API --- crates/ui/src/timeline/response.rs | 28 +++++++++++-------- .../server/ts-bindings/LongEntities.ts | 18 ++++++++++++ .../ts-bindings/ResourceTimelineBinned.ts | 12 ++------ .../ResourceTimelineBinnedByState.ts | 12 ++------ 4 files changed, 40 insertions(+), 30 deletions(-) create mode 100644 examples/simulator/server/ts-bindings/LongEntities.ts diff --git a/crates/ui/src/timeline/response.rs b/crates/ui/src/timeline/response.rs index ab156946..ab6f559e 100644 --- a/crates/ui/src/timeline/response.rs +++ b/crates/ui/src/timeline/response.rs @@ -9,6 +9,18 @@ use ts_rs::TS; use crate::FiniteStateMachine; +/// Long entities in resource timelines. +#[derive(TS, Debug, Clone, Serialize)] +pub struct LongEntities { + /// FSMs that have usage spans exceeding the long entities threshold. + /// + /// This may be empty if no long entities exist or if they were not requested. + /// If pagination is requested, this will only hold one page. + pub long_fsms: Vec, + /// Total number of long FSMs, before pagination. + pub long_fsms_total: u32, +} + #[derive(TS, Debug, Clone, Serialize)] pub struct ResourceTimelineBinned { /// The configuration of the binned timeline. @@ -16,12 +28,8 @@ pub struct ResourceTimelineBinned { /// Maps a resource capacity name to a vector where each element holds an /// aggregated value of a time bin. pub capacities_values: HashMap>, - /// Ranked page of FSMs whose usage spans exceed the long_entities_threshold, - /// longest first. Sliced per the request's `long_entities_max`/`_page`. - pub long_fsms: Vec, - /// Total number of long FSMs matching the threshold before pagination, so - /// clients can compute the page count. - pub long_fsms_total: u32, + /// Entities with long usage durations of this resource, if requested. + pub long_entities: Option, } #[derive(TS, Debug, Clone, Serialize)] @@ -31,12 +39,8 @@ pub struct ResourceTimelineBinnedByState { /// Maps a resource capacity name to a map of a state name to a vector where /// each element holds an aggregated value of a time bin. pub capacities_states_values: HashMap>>, - /// Ranked page of FSMs whose usage spans exceed the long_entities_threshold, - /// longest first. Sliced per the request's `long_entities_max`/`_page`. - pub long_fsms: Vec, - /// Total number of long FSMs matching the threshold before pagination, so - /// clients can compute the page count. - pub long_fsms_total: u32, + /// Entities with long usage durations of this resource, if requested. + pub long_entities: Option, } #[derive(TS, Debug, Clone, Serialize)] diff --git a/examples/simulator/server/ts-bindings/LongEntities.ts b/examples/simulator/server/ts-bindings/LongEntities.ts new file mode 100644 index 00000000..33f33ac5 --- /dev/null +++ b/examples/simulator/server/ts-bindings/LongEntities.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FiniteStateMachine } from "./FiniteStateMachine"; + +/** + * Long entities in resource timelines. + */ +export type LongEntities = { +/** + * FSMs that have usage spans exceeding the long entities threshold. + * + * This may be empty if no long entities exist or if they were not requested. + * If pagination is requested, this will only hold one page. + */ +long_fsms: Array, +/** + * Total number of long FSMs, before pagination. + */ +long_fsms_total: number, }; diff --git a/examples/simulator/server/ts-bindings/ResourceTimelineBinned.ts b/examples/simulator/server/ts-bindings/ResourceTimelineBinned.ts index d7e904b9..172c5798 100644 --- a/examples/simulator/server/ts-bindings/ResourceTimelineBinned.ts +++ b/examples/simulator/server/ts-bindings/ResourceTimelineBinned.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { BinnedSpanSec } from "./BinnedSpanSec"; -import type { FiniteStateMachine } from "./FiniteStateMachine"; +import type { LongEntities } from "./LongEntities"; export type ResourceTimelineBinned = { /** @@ -13,12 +13,6 @@ config: BinnedSpanSec, */ capacities_values: { [key in string]?: Array }, /** - * Ranked page of FSMs whose usage spans exceed the long_entities_threshold, - * longest first. Sliced per the request's `long_entities_max`/`_page`. + * Entities with long usage durations of this resource, if requested. */ -long_fsms: Array, -/** - * Total number of long FSMs matching the threshold before pagination, so - * clients can compute the page count. - */ -long_fsms_total: number, }; +long_entities: LongEntities | null, }; diff --git a/examples/simulator/server/ts-bindings/ResourceTimelineBinnedByState.ts b/examples/simulator/server/ts-bindings/ResourceTimelineBinnedByState.ts index 1c18a39f..238b7506 100644 --- a/examples/simulator/server/ts-bindings/ResourceTimelineBinnedByState.ts +++ b/examples/simulator/server/ts-bindings/ResourceTimelineBinnedByState.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { BinnedSpanSec } from "./BinnedSpanSec"; -import type { FiniteStateMachine } from "./FiniteStateMachine"; +import type { LongEntities } from "./LongEntities"; export type ResourceTimelineBinnedByState = { /** @@ -13,12 +13,6 @@ config: BinnedSpanSec, */ capacities_states_values: { [key in string]?: { [key in string]?: Array } }, /** - * Ranked page of FSMs whose usage spans exceed the long_entities_threshold, - * longest first. Sliced per the request's `long_entities_max`/`_page`. + * Entities with long usage durations of this resource, if requested. */ -long_fsms: Array, -/** - * Total number of long FSMs matching the threshold before pagination, so - * clients can compute the page count. - */ -long_fsms_total: number, }; +long_entities: LongEntities | null, }; From 03d7dd762885a37a3fba1686a9e6c307edaf6bb4 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Fri, 12 Jun 2026 14:00:54 +0200 Subject: [PATCH 6/8] refactor(timeline): wire nested long-entities response API through consumers Adopt the Option response structure proposed in the previous commit. The analyzer's ResourceTimeline.long_entities becomes Option> (Some iff a threshold was requested), which flows to the response Option without threading a flag through the converters. The simulator maps it to Option; the server's combine_chunks merges each chunk's Option (None unless requested, otherwise the deduped union) and paginate ranks/slices the Some case; the UI reads long_entities?.long_fsms. Semantics: None = not requested, Some with empty long_fsms = requested but none found, Some with entries = ranked page plus long_fsms_total. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../analyzer/src/timeline/binned/resource.rs | 32 +++++++-- .../query_engine/server/src/timeline_cache.rs | 72 ++++++++++++------- examples/simulator/analyzer/src/lib.rs | 34 ++++++--- .../components/src/lib/timeline.utils.ts | 6 +- 4 files changed, 102 insertions(+), 42 deletions(-) diff --git a/crates/analyzer/src/timeline/binned/resource.rs b/crates/analyzer/src/timeline/binned/resource.rs index 7cb86d63..96fde68f 100644 --- a/crates/analyzer/src/timeline/binned/resource.rs +++ b/crates/analyzer/src/timeline/binned/resource.rs @@ -29,14 +29,18 @@ fn convert_capacity( pub struct ResourceTimeline<'a> { pub config: BinnedSpan, pub data: HashMap<&'a str, Vec>, - pub long_entities: Vec, + /// Entities flagged as long, or `None` when long entities were not + /// requested (no threshold). Empty `Some` means requested but none found. + pub long_entities: Option>, } #[derive(Clone, Debug)] pub struct ResourceTimelineByKey<'a, K> { pub config: BinnedSpan, pub data: HashMap<(K, &'a str), Vec>, - pub long_entities: Vec, + /// Entities flagged as long, or `None` when long entities were not + /// requested (no threshold). Empty `Some` means requested but none found. + pub long_entities: Option>, } pub struct ResourceTimelineBuilder<'a> { @@ -95,7 +99,10 @@ impl<'a> ResourceTimelineBuilder<'a> { ResourceTimeline { config: self.aggregator.config, data: self.aggregator.finish(), - long_entities: self.long_entities.into_iter().collect(), + long_entities: self + .long_entities_threshold + .is_some() + .then(|| self.long_entities.into_iter().collect()), } } } @@ -158,7 +165,10 @@ where ResourceTimelineByKey { config: self.aggregator.config, data: self.aggregator.finish(), - long_entities: self.long_entities.into_iter().collect(), + long_entities: self + .long_entities_threshold + .is_some() + .then(|| self.long_entities.into_iter().collect()), } } } @@ -778,7 +788,12 @@ mod tests { let mut outside_builder = ResourceTimelineBuilder::try_new(resource_type, config, Some(threshold)).unwrap(); outside_builder.try_extend(outside_fsms.usages()).unwrap(); - assert!(!outside_builder.build().long_entities.contains(&resource_id)); + assert!( + !outside_builder + .build() + .long_entities + .is_some_and(|e| e.contains(&resource_id)) + ); let mut inside_fsms = InMemoryFsms::::new(); inside_fsms.insert(make_fsm(500, 1500)); @@ -787,6 +802,11 @@ mod tests { let mut inside_builder = ResourceTimelineBuilder::try_new(resource_type, config, Some(threshold)).unwrap(); inside_builder.try_extend(inside_fsms.usages()).unwrap(); - assert!(inside_builder.build().long_entities.contains(&resource_id)); + assert!( + inside_builder + .build() + .long_entities + .is_some_and(|e| e.contains(&resource_id)) + ); } } diff --git a/domains/query_engine/server/src/timeline_cache.rs b/domains/query_engine/server/src/timeline_cache.rs index 76ad14ec..c1141b08 100644 --- a/domains/query_engine/server/src/timeline_cache.rs +++ b/domains/query_engine/server/src/timeline_cache.rs @@ -19,7 +19,7 @@ use quent_ui::timeline::{ TimelineConfig, TimelineRequest, }, response::{ - BulkTimelinesResponse, BulkTimelinesResponseEntry, ResourceTimeline, + BulkTimelinesResponse, BulkTimelinesResponseEntry, LongEntities, ResourceTimeline, ResourceTimelineBinned, ResourceTimelineBinnedByState, SingleTimelineResponse, }, }; @@ -727,11 +727,13 @@ fn pagination_of(entry: &TimelineRequest) -> Option { /// intentionally excluded from the chunk cache key, so this runs after merge on /// every response path (cache hit, chunked fetch, and uncached fallback). fn paginate_long_fsms(data: &mut ResourceTimeline, page: Option) { - let (long_fsms, total) = match data { - ResourceTimeline::Binned(d) => (&mut d.long_fsms, &mut d.long_fsms_total), - ResourceTimeline::BinnedByState(d) => (&mut d.long_fsms, &mut d.long_fsms_total), + let long_entities = match data { + ResourceTimeline::Binned(d) => d.long_entities.as_mut(), + ResourceTimeline::BinnedByState(d) => d.long_entities.as_mut(), }; - *total = paginate_fsms_vec(long_fsms, page); + if let Some(le) = long_entities { + le.long_fsms_total = paginate_fsms_vec(&mut le.long_fsms, page); + } } /// Sort `long_fsms` by qualifying duration (longest first, `id` tie-break), @@ -792,20 +794,34 @@ fn combine_chunks( let is_binned_by_state = matches!(&sorted[0].data, ResourceTimeline::BinnedByState(_)); - // Collect long_fsms from all chunks, deduplicated by ID. + // Merge long entities from all chunks, deduplicated by FSM id. If every + // chunk reports `None` (not requested) the result stays `None`; otherwise + // the union is returned and ranked/paginated later at the tail. let mut seen_fsm_ids = std::collections::HashSet::new(); let mut long_fsms = Vec::new(); + let mut requested = false; for chunk in &sorted { - let chunk_fsms = match &chunk.data { - ResourceTimeline::Binned(data) => &data.long_fsms, - ResourceTimeline::BinnedByState(data) => &data.long_fsms, + let chunk_le = match &chunk.data { + ResourceTimeline::Binned(data) => data.long_entities.as_ref(), + ResourceTimeline::BinnedByState(data) => data.long_entities.as_ref(), }; - for fsm in chunk_fsms { - if seen_fsm_ids.insert(fsm.id) { - long_fsms.push(fsm.clone()); + if let Some(le) = chunk_le { + requested = true; + for fsm in &le.long_fsms { + if seen_fsm_ids.insert(fsm.id) { + long_fsms.push(fsm.clone()); + } } } } + let long_entities = if requested { + Some(LongEntities { + long_fsms_total: long_fsms.len() as u32, + long_fsms, + }) + } else { + None + }; if is_binned_by_state { let mut combined: std::collections::HashMap< @@ -856,8 +872,7 @@ fn combine_chunks( data: ResourceTimeline::BinnedByState(ResourceTimelineBinnedByState { config, capacities_states_values: combined, - long_fsms_total: long_fsms.len() as u32, - long_fsms, + long_entities, }), }) } else { @@ -904,8 +919,7 @@ fn combine_chunks( data: ResourceTimeline::Binned(ResourceTimelineBinned { config, capacities_values: combined, - long_fsms_total: long_fsms.len() as u32, - long_fsms, + long_entities, }), }) } @@ -974,7 +988,7 @@ mod tests { ResourceTimelineRequest, TimelineConfig, TimelineRequest, }, response::{ - BulkTimelinesResponse, BulkTimelinesResponseEntry, ResourceTimeline, + BulkTimelinesResponse, BulkTimelinesResponseEntry, LongEntities, ResourceTimeline, ResourceTimelineBinned, SingleTimelineResponse, }, }, @@ -1069,14 +1083,17 @@ mod tests { let mut data = ResourceTimeline::Binned(ResourceTimelineBinned { config, capacities_values: HashMap::new(), - long_fsms_total: 0, - long_fsms: unranked(), + long_entities: Some(LongEntities { + long_fsms_total: 0, + long_fsms: unranked(), + }), }); paginate_long_fsms(&mut data, Some(PageParams { max: 2, page: 0 })); match data { ResourceTimeline::Binned(d) => { - assert_eq!(d.long_fsms_total, 4); - assert_eq!(ids(&d.long_fsms), vec![2, 3]); + let le = d.long_entities.unwrap(); + assert_eq!(le.long_fsms_total, 4); + assert_eq!(ids(&le.long_fsms), vec![2, 3]); } _ => panic!("expected binned"), } @@ -1294,8 +1311,10 @@ mod tests { let data = ResourceTimeline::Binned(ResourceTimelineBinned { config: config_secs, capacities_values: HashMap::from([("capacity".to_string(), values)]), - long_fsms_total: long_fsms.len() as u32, - long_fsms, + long_entities: Some(LongEntities { + long_fsms_total: long_fsms.len() as u32, + long_fsms, + }), }); Ok(( @@ -1369,7 +1388,12 @@ mod tests { BulkTimelinesResponseEntry::Ok { data: ResourceTimeline::Binned(data), .. - } => data.long_fsms.iter().map(|fsm| fsm.id).collect(), + } => data + .long_entities + .iter() + .flat_map(|le| &le.long_fsms) + .map(|fsm| fsm.id) + .collect(), _ => panic!("expected binned ok response"), } } diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index f5acbab3..d3aa134f 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -15,7 +15,7 @@ use quent_ui::{ }, response::{ BulkChunkedTimelinesResponse, BulkTimelinesResponse, BulkTimelinesResponseEntry, - ResourceTimeline as UiResourceTimeline, ResourceTimelineBinned, + LongEntities, ResourceTimeline as UiResourceTimeline, ResourceTimelineBinned, ResourceTimelineBinnedByState, SingleTimelineResponse, }, }, @@ -843,13 +843,21 @@ impl SimulatorUiAnalyzer { .into_iter() .map(|(k, v)| (k.to_owned(), v)) .collect(); - let long_fsms = self.task_entities_to_ui_fsm(&result.long_entities, epoch)?; - let long_fsms_total = long_fsms.len() as u32; + let long_entities = result + .long_entities + .map(|ids| { + let long_fsms = self.task_entities_to_ui_fsm(&ids, epoch)?; + let long_fsms_total = long_fsms.len() as u32; + Ok::<_, AnalyzerError>(LongEntities { + long_fsms, + long_fsms_total, + }) + }) + .transpose()?; Ok(UiResourceTimeline::Binned(ResourceTimelineBinned { config, capacities_values, - long_fsms, - long_fsms_total, + long_entities, })) } @@ -867,14 +875,22 @@ impl SimulatorUiAnalyzer { .or_insert_with(StdHashMap::new) .insert(state_name.to_owned(), values); } - let long_fsms = self.task_entities_to_ui_fsm(&result.long_entities, epoch)?; - let long_fsms_total = long_fsms.len() as u32; + let long_entities = result + .long_entities + .map(|ids| { + let long_fsms = self.task_entities_to_ui_fsm(&ids, epoch)?; + let long_fsms_total = long_fsms.len() as u32; + Ok::<_, AnalyzerError>(LongEntities { + long_fsms, + long_fsms_total, + }) + }) + .transpose()?; Ok(UiResourceTimeline::BinnedByState( ResourceTimelineBinnedByState { config, capacities_states_values, - long_fsms, - long_fsms_total, + long_entities, }, )) } diff --git a/ui/packages/@quent/components/src/lib/timeline.utils.ts b/ui/packages/@quent/components/src/lib/timeline.utils.ts index bbd66b1e..6ad4e708 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.ts @@ -154,10 +154,10 @@ export function getTimelineConfig(response: SingleTimelineResponse): BinnedSpanS return response.config; } -/** Extract long_fsms from a ResourceTimeline response. */ +/** Extract the long-entity FSMs (current page) from a ResourceTimeline response. */ export function getLongFsms(data: ResourceTimeline): FiniteStateMachine[] { - if ('Binned' in data) return data.Binned.long_fsms; - if ('BinnedByState' in data) return data.BinnedByState.long_fsms; + if ('Binned' in data) return data.Binned.long_entities?.long_fsms ?? []; + if ('BinnedByState' in data) return data.BinnedByState.long_entities?.long_fsms ?? []; return []; } From 946e990f0a618487b51a73f467920f4f4ebcaedf Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Mon, 15 Jun 2026 11:02:47 +0200 Subject: [PATCH 7/8] feat(analyzer): collect long-usage entities in the resource timeline builder In the same pass as binning, the resource timeline builder records entities whose usage span on the resource exceeds the long-entities threshold, keeping each entity's longest such span (LongUsageEntity). build() returns them ordered longest-usage-first (id tie-break), or None when no threshold was requested. Ranking, pagination, caching and chunking are deliberately left to higher layers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../analyzer/src/timeline/binned/resource.rs | 91 ++++++++++++++----- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/crates/analyzer/src/timeline/binned/resource.rs b/crates/analyzer/src/timeline/binned/resource.rs index 96fde68f..cd4b84dd 100644 --- a/crates/analyzer/src/timeline/binned/resource.rs +++ b/crates/analyzer/src/timeline/binned/resource.rs @@ -6,7 +6,7 @@ use std::hash::Hash; use quent_time::{SpanNanoSec, TimeNanoSec, bin::BinnedSpan}; -use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use rustc_hash::FxHashMap as HashMap; use uuid::Uuid; use crate::{ @@ -25,28 +25,34 @@ fn convert_capacity( Ok(capacity_type.reinterpret_capacity_value(capacity_value.value.unwrap_or_default(), span)) } +/// An entity that exceeded the long-entities threshold on this resource, with +/// its longest usage span there. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LongUsageEntity { + pub entity_id: Uuid, + pub longest_usage: TimeNanoSec, +} + #[derive(Clone, Debug)] pub struct ResourceTimeline<'a> { pub config: BinnedSpan, pub data: HashMap<&'a str, Vec>, - /// Entities flagged as long, or `None` when long entities were not - /// requested (no threshold). Empty `Some` means requested but none found. - pub long_entities: Option>, + /// Long entities ordered longest-usage-first, or `None` when not requested. + pub long_usage_entities: Option>, } #[derive(Clone, Debug)] pub struct ResourceTimelineByKey<'a, K> { pub config: BinnedSpan, pub data: HashMap<(K, &'a str), Vec>, - /// Entities flagged as long, or `None` when long entities were not - /// requested (no threshold). Empty `Some` means requested but none found. - pub long_entities: Option>, + /// Long entities ordered longest-usage-first, or `None` when not requested. + pub long_usage_entities: Option>, } pub struct ResourceTimelineBuilder<'a> { resource_type: &'a ResourceTypeDecl, aggregator: KeyedAggregator<&'a str>, - long_entities: HashSet, + long_entities: HashMap, long_entities_threshold: Option, } @@ -61,7 +67,7 @@ impl<'a> ResourceTimelineBuilder<'a> { Ok(Self { resource_type, aggregator, - long_entities: HashSet::default(), + long_entities: HashMap::default(), long_entities_threshold, }) } @@ -80,7 +86,11 @@ impl<'a> ResourceTimelineBuilder<'a> { && usage.span().duration() > threshold && usage.span().intersects(&self.aggregator.config.span) { - self.long_entities.insert(usage.entity_id()); + let duration = usage.span().duration(); + self.long_entities + .entry(usage.entity_id()) + .and_modify(|d| *d = (*d).max(duration)) + .or_insert(duration); } Ok(()) } @@ -99,10 +109,22 @@ impl<'a> ResourceTimelineBuilder<'a> { ResourceTimeline { config: self.aggregator.config, data: self.aggregator.finish(), - long_entities: self - .long_entities_threshold - .is_some() - .then(|| self.long_entities.into_iter().collect()), + long_usage_entities: self.long_entities_threshold.is_some().then(|| { + let mut entities: Vec = self + .long_entities + .into_iter() + .map(|(entity_id, longest_usage)| LongUsageEntity { + entity_id, + longest_usage, + }) + .collect(); + entities.sort_by(|a, b| { + b.longest_usage + .cmp(&a.longest_usage) + .then_with(|| a.entity_id.cmp(&b.entity_id)) + }); + entities + }), } } } @@ -110,7 +132,7 @@ impl<'a> ResourceTimelineBuilder<'a> { pub struct ResourceTimelineByKeyBuilder<'a, K> { resource_type: &'a ResourceTypeDecl, aggregator: KeyedAggregator<(K, &'a str)>, - long_entities: HashSet, + long_entities: HashMap, long_entities_threshold: Option, } @@ -128,7 +150,7 @@ where Ok(Self { resource_type, aggregator, - long_entities: HashSet::default(), + long_entities: HashMap::default(), long_entities_threshold, }) } @@ -146,7 +168,11 @@ where && usage.span().duration() > threshold && usage.span().intersects(&self.aggregator.config.span) { - self.long_entities.insert(usage.entity_id()); + let duration = usage.span().duration(); + self.long_entities + .entry(usage.entity_id()) + .and_modify(|d| *d = (*d).max(duration)) + .or_insert(duration); } Ok(()) } @@ -165,10 +191,23 @@ where ResourceTimelineByKey { config: self.aggregator.config, data: self.aggregator.finish(), - long_entities: self - .long_entities_threshold - .is_some() - .then(|| self.long_entities.into_iter().collect()), + long_usage_entities: self.long_entities_threshold.is_some().then(|| { + let mut entities: Vec = self + .long_entities + .into_iter() + .map(|(entity_id, longest_usage)| LongUsageEntity { + entity_id, + longest_usage, + }) + .collect(); + // Longest usage first; id tie-break for determinism. + entities.sort_by(|a, b| { + b.longest_usage + .cmp(&a.longest_usage) + .then_with(|| a.entity_id.cmp(&b.entity_id)) + }); + entities + }), } } } @@ -177,6 +216,8 @@ where mod tests { use std::num::NonZero; + use rustc_hash::FxHashSet as HashSet; + use crate::{ fsm::{ FsmUsages, @@ -791,8 +832,8 @@ mod tests { assert!( !outside_builder .build() - .long_entities - .is_some_and(|e| e.contains(&resource_id)) + .long_usage_entities + .is_some_and(|e| e.iter().any(|lu| lu.entity_id == resource_id)) ); let mut inside_fsms = InMemoryFsms::::new(); @@ -805,8 +846,8 @@ mod tests { assert!( inside_builder .build() - .long_entities - .is_some_and(|e| e.contains(&resource_id)) + .long_usage_entities + .is_some_and(|e| e.iter().any(|lu| lu.entity_id == resource_id)) ); } } From 0164d638a02e588d9c369cd7aa0efe16e131f276 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Mon, 15 Jun 2026 11:21:37 +0200 Subject: [PATCH 8/8] wip(timeline): long entities bundled in timeline response (reference) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference snapshot of the approach where long entities ride the chunked timeline: server cache (timeline_cache.rs) owns ranking, cross-chunk order recovery, and pagination; the simulator resolves entities; the public entry points are renamed paginate_*. Kept for reference only — to be superseded by a dedicated entities endpoint (see docs/agents/). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../query_engine/server/src/timeline_cache.rs | 274 +++++++++--------- domains/query_engine/server/src/ui.rs | 4 +- examples/simulator/analyzer/src/lib.rs | 46 ++- 3 files changed, 154 insertions(+), 170 deletions(-) diff --git a/domains/query_engine/server/src/timeline_cache.rs b/domains/query_engine/server/src/timeline_cache.rs index c1141b08..42b70e79 100644 --- a/domains/query_engine/server/src/timeline_cache.rs +++ b/domains/query_engine/server/src/timeline_cache.rs @@ -159,12 +159,7 @@ impl TimelineCache { } /// Fetch bulk timelines, paginating each entry's long FSMs after merge. - /// - /// Pagination (`long_entities.page`, per entry) is applied here at the single - /// tail point so it covers every internal path — cache hit, chunked fetch, - /// and uncached fallback — and is deliberately excluded from the chunk cache - /// key, so different pages reuse the same cached chunks. - pub(crate) async fn cached_bulk_timeline( + pub(crate) async fn paginate_bulk_timeline( &self, analyzer: Arc, engine_id: Uuid, @@ -184,7 +179,7 @@ impl TimelineCache { .map(|(k, entry)| (k.clone(), pagination_of(entry))) .collect(); let mut response = self - .cached_bulk_timeline_inner(analyzer, engine_id, request) + .cached_bulk_timeline(analyzer, engine_id, request) .await?; for (key, entry) in response.entries.iter_mut() { if let BulkTimelinesResponseEntry::Ok { data, .. } = entry { @@ -196,7 +191,7 @@ impl TimelineCache { } /// Fetch bulk timelines, serving as many chunks from cache as possible. - async fn cached_bulk_timeline_inner( + async fn cached_bulk_timeline( &self, analyzer: Arc, engine_id: Uuid, @@ -431,11 +426,7 @@ impl TimelineCache { } /// Fetch a single timeline, paginating its long FSMs after merge. - /// - /// Pagination (`long_entities.page`) is applied here at the single tail - /// point so it covers the single-chunk fast path, the chunk merge, and every - /// uncached fallback, and is excluded from the chunk cache key. - pub(crate) async fn cached_single_timeline( + pub(crate) async fn paginate_single_timeline( &self, analyzer: Arc, engine_id: Uuid, @@ -451,13 +442,13 @@ impl TimelineCache { { let page = pagination_of(&request.entry); let mut response = self - .cached_single_timeline_inner(analyzer, engine_id, request) + .cached_single_timeline(analyzer, engine_id, request) .await?; paginate_long_fsms(&mut response.data, page); Ok(response) } - async fn cached_single_timeline_inner( + async fn cached_single_timeline( &self, analyzer: Arc, engine_id: Uuid, @@ -573,10 +564,15 @@ impl TimelineCache { if chunk_start_ns == req_span.start() && chunk_end_ns == req_span.end() { return Ok(chunk); } - return combine_chunks(&[chunk], req_span, epoch); + return combine_chunks(&[chunk], req_span, epoch, resource_of(&request.entry)); } - combine_chunks(&chunk_responses, req_span, epoch) + combine_chunks( + &chunk_responses, + req_span, + epoch, + resource_of(&request.entry), + ) } } @@ -693,7 +689,12 @@ fn assemble_bulk_response( Err(_) => continue, }; - let combined = combine_chunks(chunks, chunk_span, geometry.epoch)?; + let combined = combine_chunks( + chunks, + chunk_span, + geometry.epoch, + resource_of(&entries[key]), + )?; result_entries.insert( key.clone(), BulkTimelinesResponseEntry::Ok { @@ -721,59 +722,57 @@ fn pagination_of(entry: &TimelineRequest) -> Option { long_entities.and_then(|le| le.page.clone()) } -/// Rank a timeline's long FSMs and retain the requested page. +/// The single resource a timeline targets, or `None` for a resource group +/// (whose leaf set isn't known to the cache layer). +fn resource_of(entry: &TimelineRequest) -> Option { + match entry { + TimelineRequest::Resource(r) => Some(r.resource_id), + TimelineRequest::ResourceGroup(_) => None, + } +} + +/// Retain only the requested page of a timeline's already-ordered long +/// entities, setting `long_fsms_total` to the full count first. /// -/// Sets `long_fsms_total` to the full count before slicing. The page request is -/// intentionally excluded from the chunk cache key, so this runs after merge on -/// every response path (cache hit, chunked fetch, and uncached fallback). +/// Ordering is established upstream — by the builder, and re-established by +/// `combine_chunks` after its merge — so this only slices. `page` `None` or a +/// `max` of `0` keeps the whole set. fn paginate_long_fsms(data: &mut ResourceTimeline, page: Option) { let long_entities = match data { ResourceTimeline::Binned(d) => d.long_entities.as_mut(), ResourceTimeline::BinnedByState(d) => d.long_entities.as_mut(), }; - if let Some(le) = long_entities { - le.long_fsms_total = paginate_fsms_vec(&mut le.long_fsms, page); - } -} + let Some(le) = long_entities else { + return; + }; -/// Sort `long_fsms` by qualifying duration (longest first, `id` tie-break), -/// return the pre-slice count, then retain only the requested page. -/// -/// `page` `None` (or a `max` of `0`) returns all entities — no slicing. A page -/// past the end yields an empty slice; the returned total still reflects the -/// full count. -fn paginate_fsms_vec(long_fsms: &mut Vec, page: Option) -> u32 { - // Rank longest-usage-first with an id tie-break, deriving each FSM's usage - // span from its own transitions (decorate-sort-undecorate computes it once). - let mut keyed: Vec<(f64, FiniteStateMachine)> = std::mem::take(long_fsms) - .into_iter() - .map(|fsm| (longest_usage_secs(&fsm), fsm)) - .collect(); - keyed.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id))); - *long_fsms = keyed.into_iter().map(|(_, fsm)| fsm).collect(); - let total = long_fsms.len() as u32; + le.long_fsms_total = le.long_fsms.len() as u32; if let Some(PageParams { max, page }) = page && max > 0 { - let len = long_fsms.len(); + let len = le.long_fsms.len(); let start = ((page as u64) * max as u64).min(len as u64) as usize; let keep = (len - start).min(max as usize); - long_fsms.drain(..start); - long_fsms.truncate(keep); + le.long_fsms.drain(..start); + le.long_fsms.truncate(keep); } - - total } -/// Longest contiguous usage span of an FSM in seconds, derived from its -/// transitions: the largest gap from a usage-bearing transition to the next. -/// Returns `0.0` when the FSM has no usages. Used to rank long entities without -/// carrying a separate duration on the wire. -fn longest_usage_secs(fsm: &FiniteStateMachine) -> f64 { +/// Longest usage span (seconds) of an FSM on the timelined resource, derived +/// from its own transitions: the largest gap from a usage-bearing transition +/// to the next. `resource` is `Some(id)` for a single-resource timeline (only +/// usages on that resource count) and `None` for a resource-group timeline, +/// where membership can't be resolved here so all usages count (best effort). +/// Used to rank long entities after the chunk merge — derived, never carried. +fn long_entity_rank_secs(fsm: &FiniteStateMachine, resource: Option) -> f64 { fsm.transitions .windows(2) - .filter(|w| !w[0].usages.is_empty()) + .filter(|w| { + w[0].usages + .iter() + .any(|u| resource.is_none_or(|r| u.resource == r)) + }) .map(|w| w[1].timestamp - w[0].timestamp) .fold(0.0_f64, f64::max) } @@ -782,6 +781,7 @@ fn combine_chunks( chunks: &[SingleTimelineResponse], req_span: SpanNanoSec, epoch: TimeNanoSec, + resource: Option, ) -> ServerResult { let mut sorted: Vec<&SingleTimelineResponse> = chunks.iter().collect(); sorted.sort_by(|a, b| { @@ -794,11 +794,10 @@ fn combine_chunks( let is_binned_by_state = matches!(&sorted[0].data, ResourceTimeline::BinnedByState(_)); - // Merge long entities from all chunks, deduplicated by FSM id. If every - // chunk reports `None` (not requested) the result stays `None`; otherwise - // the union is returned and ranked/paginated later at the tail. + // Chunking concern: merge each chunk's long entities and dedup by FSM id. + // If every chunk reports `None` (not requested) the result stays `None`. let mut seen_fsm_ids = std::collections::HashSet::new(); - let mut long_fsms = Vec::new(); + let mut long_fsms: Vec = Vec::new(); let mut requested = false; for chunk in &sorted { let chunk_le = match &chunk.data { @@ -815,9 +814,17 @@ fn combine_chunks( } } let long_entities = if requested { + // Chunking broke the per-chunk order; recover it by re-ranking the merged + // set on a rank derived from each FSM's transitions. + long_fsms.sort_by(|a, b| { + long_entity_rank_secs(b, resource) + .total_cmp(&long_entity_rank_secs(a, resource)) + .then_with(|| a.id.cmp(&b.id)) + }); + let long_fsms_total = long_fsms.len() as u32; Some(LongEntities { - long_fsms_total: long_fsms.len() as u32, long_fsms, + long_fsms_total, }) } else { None @@ -981,7 +988,7 @@ mod tests { }; use quent_query_engine_model::engine::{EngineEvent, Exit, Init}; use quent_ui::{ - FiniteStateMachine, FsmTransition, FsmUsage, + FsmTransition, timeline::{ request::{ BulkTimelineRequest, EntityFilter, LongEntitiesParams, PageParams, @@ -997,28 +1004,12 @@ mod tests { use super::*; - // Build an FSM whose single usage spans `usage_secs` seconds, so its - // transition-derived rank key equals `usage_secs`. - fn fsm(id: u128, usage_secs: f64) -> FiniteStateMachine { + fn fsm(id: u128) -> FiniteStateMachine { FiniteStateMachine { id: Uuid::from_u128(id), type_name: "task".to_string(), instance_name: format!("t{id}"), - transitions: vec![ - FsmTransition { - name: "use".to_string(), - usages: vec![FsmUsage { - resource: Uuid::from_u128(99), - capacities: vec![], - }], - timestamp: 0.0, - }, - FsmTransition { - name: "exit".to_string(), - usages: vec![], - timestamp: usage_secs, - }, - ], + transitions: vec![], } } @@ -1026,51 +1017,10 @@ mod tests { fsms.iter().map(|f| f.id.as_u128()).collect() } - // Ranked order for [dur 100/id 1, 300/2, 300/3, 50/4] is duration desc with - // id asc tie-break: ids [2, 3, 1, 4]. - fn unranked() -> Vec { - vec![fsm(1, 100.0), fsm(2, 300.0), fsm(3, 300.0), fsm(4, 50.0)] - } - - #[test] - fn paginate_ranks_by_duration_then_id() { - let mut v = unranked(); - let total = paginate_fsms_vec(&mut v, None); - assert_eq!(total, 4); - assert_eq!(ids(&v), vec![2, 3, 1, 4]); - } - - #[test] - fn paginate_pages_are_disjoint_and_cover_ranked_order() { - let mut p0 = unranked(); - let mut p1 = unranked(); - let t0 = paginate_fsms_vec(&mut p0, Some(PageParams { max: 2, page: 0 })); - let t1 = paginate_fsms_vec(&mut p1, Some(PageParams { max: 2, page: 1 })); - assert_eq!((t0, t1), (4, 4)); - assert_eq!(ids(&p0), vec![2, 3]); - assert_eq!(ids(&p1), vec![1, 4]); - } - - #[test] - fn paginate_page_past_end_is_empty_with_full_total() { - let mut v = unranked(); - let total = paginate_fsms_vec(&mut v, Some(PageParams { max: 2, page: 5 })); - assert_eq!(total, 4); - assert!(v.is_empty()); - } - - #[test] - fn paginate_max_zero_or_none_returns_all_ranked() { - for page in [None, Some(PageParams { max: 0, page: 0 })] { - let mut v = unranked(); - let total = paginate_fsms_vec(&mut v, page); - assert_eq!(total, 4); - assert_eq!(ids(&v), vec![2, 3, 1, 4]); - } - } - - #[test] - fn paginate_long_fsms_writes_total_into_variant() { + // A binned timeline whose long entities are the given FSM ids, in order. + // Paginate only slices (ordering is the builder's/merge's job), so the ids + // are supplied already ranked; ranks are placeholders here. + fn binned_with_long_entities(entity_ids: &[u128]) -> ResourceTimeline { let config = TimelineConfig { num_bins: 1, start: 0.0, @@ -1080,25 +1030,67 @@ mod tests { .unwrap() .try_to_secs_relative(0) .unwrap(); - let mut data = ResourceTimeline::Binned(ResourceTimelineBinned { + ResourceTimeline::Binned(ResourceTimelineBinned { config, capacities_values: HashMap::new(), long_entities: Some(LongEntities { + long_fsms: entity_ids.iter().map(|&id| fsm(id)).collect(), long_fsms_total: 0, - long_fsms: unranked(), }), - }); - paginate_long_fsms(&mut data, Some(PageParams { max: 2, page: 0 })); + }) + } + + fn page_ids(data: &ResourceTimeline) -> Vec { match data { - ResourceTimeline::Binned(d) => { - let le = d.long_entities.unwrap(); - assert_eq!(le.long_fsms_total, 4); - assert_eq!(ids(&le.long_fsms), vec![2, 3]); - } + ResourceTimeline::Binned(d) => ids(&d.long_entities.as_ref().unwrap().long_fsms), _ => panic!("expected binned"), } } + fn page_total(data: &ResourceTimeline) -> u32 { + match data { + ResourceTimeline::Binned(d) => d.long_entities.as_ref().unwrap().long_fsms_total, + _ => panic!("expected binned"), + } + } + + #[test] + fn paginate_slices_preserving_order() { + let mut data = binned_with_long_entities(&[2, 3, 1, 4]); + paginate_long_fsms(&mut data, None); + assert_eq!(page_total(&data), 4); + assert_eq!(page_ids(&data), vec![2, 3, 1, 4]); + } + + #[test] + fn paginate_pages_are_disjoint() { + let mut p0 = binned_with_long_entities(&[2, 3, 1, 4]); + let mut p1 = binned_with_long_entities(&[2, 3, 1, 4]); + paginate_long_fsms(&mut p0, Some(PageParams { max: 2, page: 0 })); + paginate_long_fsms(&mut p1, Some(PageParams { max: 2, page: 1 })); + assert_eq!((page_total(&p0), page_total(&p1)), (4, 4)); + assert_eq!(page_ids(&p0), vec![2, 3]); + assert_eq!(page_ids(&p1), vec![1, 4]); + } + + #[test] + fn paginate_page_past_end_is_empty_with_full_total() { + let mut data = binned_with_long_entities(&[2, 3, 1, 4]); + paginate_long_fsms(&mut data, Some(PageParams { max: 2, page: 5 })); + assert_eq!(page_total(&data), 4); + assert!(page_ids(&data).is_empty()); + } + + #[test] + fn paginate_max_zero_or_none_returns_all() { + for page in [None, Some(PageParams { max: 0, page: 0 })] { + let mut data = binned_with_long_entities(&[2, 3, 1, 4]); + paginate_long_fsms(&mut data, page); + assert_eq!(page_total(&data), 4); + assert_eq!(page_ids(&data), vec![2, 3, 1, 4]); + } + } + #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct TestGlobalParams { query_id: Uuid, @@ -1434,7 +1426,7 @@ mod tests { let cache = TimelineCache::new(); let response = cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![("a", 30.0, 80.0, 0, None)]), @@ -1469,7 +1461,7 @@ mod tests { let cache = TimelineCache::new(); cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![("a", 25.0, 75.0, 0, None)]), @@ -1477,7 +1469,7 @@ mod tests { .await .unwrap(); let response = cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![("a", 30.0, 80.0, 0, None)]), @@ -1505,7 +1497,7 @@ mod tests { let cache = TimelineCache::new(); cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![ @@ -1516,7 +1508,7 @@ mod tests { .await .unwrap(); let response = cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![ @@ -1556,7 +1548,7 @@ mod tests { let second_operator = Uuid::from_u128(7); cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![("a", 25.0, 75.0, 0, Some(first_operator))]), @@ -1564,7 +1556,7 @@ mod tests { .await .unwrap(); let response = cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![("a", 25.0, 75.0, 0, Some(second_operator))]), @@ -1602,7 +1594,7 @@ mod tests { let cache = TimelineCache::new(); let cold_response = cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![("bad", 25.0, 75.0, 999, None)]), @@ -1612,7 +1604,7 @@ mod tests { assert_error(&cold_response, "bad"); cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![("a", 25.0, 75.0, 0, None)]), @@ -1620,7 +1612,7 @@ mod tests { .await .unwrap(); let partial_response = cache - .cached_bulk_timeline( + .paginate_bulk_timeline( Arc::clone(&analyzer), analyzer.engine_id, request(vec![ diff --git a/domains/query_engine/server/src/ui.rs b/domains/query_engine/server/src/ui.rs index 4efd8027..17f77985 100644 --- a/domains/query_engine/server/src/ui.rs +++ b/domains/query_engine/server/src/ui.rs @@ -234,7 +234,7 @@ where Ok(Json( state .timelines - .cached_single_timeline(analyzer, engine_id, request) + .paginate_single_timeline(analyzer, engine_id, request) .await?, )) } @@ -273,7 +273,7 @@ where Ok(Json( state .timelines - .cached_bulk_timeline(analyzer, engine_id, request) + .paginate_bulk_timeline(analyzer, engine_id, request) .await?, )) } diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index d3aa134f..4560d7c4 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -32,7 +32,7 @@ use quent_analyzer::{ ResourceTypeDecl, Usage, Using, collection::ResourceCollection, tree::ResourceTreeNode, }, timeline::binned::resource::{ - ResourceTimeline, ResourceTimelineBuilder, ResourceTimelineByKey, + LongUsageEntity, ResourceTimeline, ResourceTimelineBuilder, ResourceTimelineByKey, ResourceTimelineByKeyBuilder, }, }; @@ -814,21 +814,27 @@ impl SimulatorUiAnalyzer { Ok(()) } - /// Turn a list of entity ids into UI-compatible FSM data. - fn task_entities_to_ui_fsm( + /// Resolve ordered long entities into UI FSMs, preserving order. Ranking + /// and pagination are the cache layer's concern, not this one. + fn long_entities_to_ui( &self, - entity_ids: &[Uuid], + entities: &[LongUsageEntity], epoch: TimeUnixNanoSec, - ) -> AnalyzerResult> { - entity_ids + ) -> AnalyzerResult { + let long_fsms = entities .iter() - .filter_map(|&id| { + .filter_map(|lu| { self.model .tasks - .get(&id) + .get(&lu.entity_id) .map(|task| task.try_to_ui_fsm(epoch)) }) - .collect() + .collect::>>()?; + let long_fsms_total = long_fsms.len() as u32; + Ok(LongEntities { + long_fsms, + long_fsms_total, + }) } /// Convert a timeline to a UI-compatible one. @@ -844,15 +850,8 @@ impl SimulatorUiAnalyzer { .map(|(k, v)| (k.to_owned(), v)) .collect(); let long_entities = result - .long_entities - .map(|ids| { - let long_fsms = self.task_entities_to_ui_fsm(&ids, epoch)?; - let long_fsms_total = long_fsms.len() as u32; - Ok::<_, AnalyzerError>(LongEntities { - long_fsms, - long_fsms_total, - }) - }) + .long_usage_entities + .map(|entities| self.long_entities_to_ui(&entities, epoch)) .transpose()?; Ok(UiResourceTimeline::Binned(ResourceTimelineBinned { config, @@ -876,15 +875,8 @@ impl SimulatorUiAnalyzer { .insert(state_name.to_owned(), values); } let long_entities = result - .long_entities - .map(|ids| { - let long_fsms = self.task_entities_to_ui_fsm(&ids, epoch)?; - let long_fsms_total = long_fsms.len() as u32; - Ok::<_, AnalyzerError>(LongEntities { - long_fsms, - long_fsms_total, - }) - }) + .long_usage_entities + .map(|entities| self.long_entities_to_ui(&entities, epoch)) .transpose()?; Ok(UiResourceTimeline::BinnedByState( ResourceTimelineBinnedByState {