LCOV - code coverage report
Current view: top level - dashql-core-profile/src - tests_progress.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 100.0 % 240 240
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! Unit tests of the execution state: input events, rollups, output events, snapshots and resets.
       2              : 
       3              : use super::{EntityState, ProfileInputEvent, ProfileUpdate, QueryProfile, SlotRole, SlotSpec, Stats, Status};
       4              : use dashql_buffers::profile as fb;
       5              : 
       6              : /// A join in two pipelines below an execution target:
       7              : /// operators 0 (scan build), 1 (scan probe), 2 (join), 3 (execution target),
       8              : /// pipeline 0 = slots 0 (scan build, source), 1 (join, sink),
       9              : /// pipeline 1 = slots 2 (scan probe, source), 3 (join, through), 4 (execution target, sink)
      10              : const JOIN_PLAN: &str = r#"{
      11              :     "operator":"executiontarget","operatorId":1,
      12              :     "input":{"operator":"join","operatorId":2,
      13              :         "left":{"operator":"tablescan","operatorId":3},
      14              :         "right":{"operator":"tablescan","operatorId":4}},
      15              :     "pipelines":[{"pipelineId":1,"operators":[3,2]},{"pipelineId":2,"operators":[4,2,1]}]
      16              : }"#;
      17              : 
      18           12 : fn load(plan: &str) -> QueryProfile {
      19           12 :     let mut model = QueryProfile::new();
      20           12 :     model.parse_hyper_plan(plan.as_bytes()).expect("plan must parse");
      21           12 :     model
      22           12 : }
      23              : 
      24           15 : fn slot(time: u64, slot: u32, status: Status, stats: Stats) -> ProfileInputEvent {
      25           15 :     ProfileInputEvent::UpdateSlot { time, slot, status: Some(status), stats }
      26           15 : }
      27              : 
      28           10 : fn tuples(tuples_in: u64, tuples_out: u64) -> Stats {
      29           10 :     Stats { tuples_in: Some(tuples_in), tuples_out: Some(tuples_out), ..Default::default() }
      30           10 : }
      31              : 
      32              : /// The state of an entity in a list of updates
      33           29 : fn state_of(updates: &[ProfileUpdate], entity: fb::ProfileEntity, index: u32) -> Option<EntityState> {
      34           89 :     updates.iter().find(|u| u.entity == entity && u.index == index).map(|u| u.state)
      35           29 : }
      36              : 
      37           11 : fn snapshot_state(model: &QueryProfile, entity: fb::ProfileEntity, index: u32) -> EntityState {
      38           11 :     state_of(&model.snapshot_events(), entity, index).unwrap_or_default()
      39           11 : }
      40              : 
      41              : #[test]
      42            1 : fn an_operator_in_two_pipelines_rolls_up_its_slots() {
      43            1 :     let mut model = load(JOIN_PLAN);
      44            1 :     let updates = model.apply_events(&[
      45            1 :         slot(10, 1, Status::Succeeded, tuples(100, 0)),
      46            1 :         slot(20, 3, Status::Running, Stats { cpu_time_us: Some(7), ..tuples(40, 30) }),
      47            1 :     ]);
      48              :     // The join consumed both inputs and produced the output of its probe side, not of its build side
      49            1 :     let join = state_of(&updates, fb::ProfileEntity::OPERATOR, 2).unwrap();
      50            1 :     assert_eq!(join.stats.tuples_in, Some(140));
      51            1 :     assert_eq!(join.stats.tuples_out, Some(30));
      52            1 :     assert_eq!(join.stats.cpu_time_us, Some(7));
      53            1 :     assert_eq!(join.status, Status::Running);
      54              :     // Every update of a batch carries the time of the batch
      55            7 :     assert!(updates.iter().all(|u| u.time == 20));
      56              :     // The build pipeline ends at the join, its output is what reached the sink
      57            1 :     let build = state_of(&updates, fb::ProfileEntity::PIPELINE, 0).unwrap();
      58            1 :     assert_eq!((build.status, build.stats.tuples_out), (Status::Succeeded, Some(0)));
      59            1 :     let probe = state_of(&updates, fb::ProfileEntity::PIPELINE, 1).unwrap();
      60            1 :     assert_eq!((probe.status, probe.stats.tuples_in, probe.stats.cpu_time_us), (Status::Running, None, Some(7)));
      61              :     // The fragment and the query see both pipelines
      62            1 :     let fragment = state_of(&updates, fb::ProfileEntity::FRAGMENT, 0).unwrap();
      63            1 :     assert_eq!((fragment.status, fragment.stats.cpu_time_us), (Status::Running, Some(7)));
      64            1 :     let query = state_of(&updates, fb::ProfileEntity::QUERY, 0).unwrap();
      65            1 :     assert_eq!(query.status, Status::Running);
      66            1 : }
      67              : 
      68              : #[test]
      69            1 : fn reported_values_win_over_rollups() {
      70            1 :     let mut model = load(JOIN_PLAN);
      71            1 :     model.apply_events(&[
      72            1 :         slot(1, 3, Status::Running, tuples(40, 30)),
      73            1 :         ProfileInputEvent::UpdateOperator {
      74            1 :             time: 2,
      75            1 :             operator: 2,
      76            1 :             status: Some(Status::Succeeded),
      77            1 :             stats: Stats { tuples_out: Some(99), ..Default::default() },
      78            1 :         },
      79            1 :     ]);
      80            1 :     let join = snapshot_state(&model, fb::ProfileEntity::OPERATOR, 2);
      81            1 :     assert_eq!(join.status, Status::Succeeded);
      82            1 :     assert_eq!(join.stats.tuples_out, Some(99));
      83              :     // Fields the operator did not report still come from its slots
      84            1 :     assert_eq!(join.stats.tuples_in, Some(40));
      85            1 : }
      86              : 
      87              : #[test]
      88            1 : fn unknown_stays_unknown() {
      89            1 :     let mut model = load(JOIN_PLAN);
      90            1 :     model.apply_events(&[slot(1, 0, Status::Running, Stats { tuples_out: Some(5), ..Default::default() })]);
      91            1 :     let scan = snapshot_state(&model, fb::ProfileEntity::OPERATOR, 0);
      92            1 :     assert_eq!(scan.stats.tuples_in, None);
      93            1 :     assert_eq!(scan.stats.reported(), fb::ProfileStatsField::TUPLES_OUT.0);
      94              :     // Entities without any state have no snapshot event
      95            1 :     assert!(state_of(&model.snapshot_events(), fb::ProfileEntity::OPERATOR, 1).is_none());
      96            1 : }
      97              : 
      98              : #[test]
      99            1 : fn finished_when_all_children_finished() {
     100            1 :     let mut model = load(JOIN_PLAN);
     101            3 :     let at = |started, finished| Stats { started_at: Some(started), finished_at: finished, ..Default::default() };
     102            1 :     model.apply_events(&[slot(1, 1, Status::Succeeded, at(5, Some(9))), slot(1, 3, Status::Running, at(3, None))]);
     103            1 :     let join = snapshot_state(&model, fb::ProfileEntity::OPERATOR, 2);
     104            1 :     assert_eq!((join.stats.started_at, join.stats.finished_at), (Some(3), None));
     105            1 :     model.apply_events(&[slot(2, 3, Status::Succeeded, at(3, Some(12)))]);
     106            1 :     let join = snapshot_state(&model, fb::ProfileEntity::OPERATOR, 2);
     107            1 :     assert_eq!((join.status, join.stats.finished_at), (Status::Succeeded, Some(12)));
     108            1 : }
     109              : 
     110              : #[test]
     111            1 : fn applying_events_twice_changes_nothing() {
     112            1 :     let mut model = load(JOIN_PLAN);
     113            1 :     let events = [slot(1, 1, Status::Running, tuples(10, 0)), slot(1, 3, Status::Running, tuples(4, 3))];
     114            1 :     assert!(!model.apply_events(&events).is_empty());
     115            1 :     assert!(model.apply_events(&events).is_empty());
     116            1 : }
     117              : 
     118              : #[test]
     119            1 : fn a_partial_report_keeps_the_other_fields() {
     120            1 :     let mut model = load(JOIN_PLAN);
     121            1 :     model.apply_events(&[slot(1, 0, Status::Running, tuples(10, 8))]);
     122            1 :     let updates = model.apply_events(&[ProfileInputEvent::UpdateSlot {
     123            1 :         time: 2,
     124            1 :         slot: 0,
     125            1 :         status: None,
     126            1 :         stats: Stats { tuples_out: Some(9), ..Default::default() },
     127            1 :     }]);
     128            1 :     let scan = state_of(&updates, fb::ProfileEntity::SLOT, 0).unwrap();
     129            1 :     assert_eq!((scan.status, scan.stats.tuples_in, scan.stats.tuples_out), (Status::Running, Some(10), Some(9)));
     130            1 : }
     131              : 
     132              : #[test]
     133            1 : fn nested_fragments_count_operators_once() {
     134            1 :     let mut model = load(
     135            1 :         r#"{"operator":"federate","inputs":[{"operator":"map","input":
     136            1 :             {"operator":"federate","inputs":[{"operator":"scan"}]}}]}"#,
     137              :     );
     138            1 :     let memory = |operator, bytes| ProfileInputEvent::UpdateOperator {
     139              :         time: 1,
     140            4 :         operator,
     141            4 :         status: Some(Status::Succeeded),
     142            4 :         stats: Stats { memory_bytes: Some(bytes), ..Default::default() },
     143            4 :     };
     144              :     // Operators 0 (scan) and 1 (inner federate) belong to the inner fragment, 2 (map) and 3 to the outer one.
     145              :     // A fragment is its own work, the query all of it.
     146            1 :     model.apply_events(&[memory(0, 1), memory(1, 2), memory(2, 4), memory(3, 8)]);
     147            1 :     assert_eq!(snapshot_state(&model, fb::ProfileEntity::FRAGMENT, 1).stats.memory_bytes, Some(3));
     148            1 :     assert_eq!(snapshot_state(&model, fb::ProfileEntity::FRAGMENT, 0).stats.memory_bytes, Some(12));
     149            1 :     assert_eq!(snapshot_state(&model, fb::ProfileEntity::QUERY, 0).stats.memory_bytes, Some(15));
     150            1 :     assert_eq!(snapshot_state(&model, fb::ProfileEntity::QUERY, 0).status, Status::Succeeded);
     151            1 : }
     152              : 
     153              : #[test]
     154            1 : fn analyzed_plans_are_an_event_batch() {
     155            1 :     let model = load(
     156            1 :         r#"{"operator":"executiontarget","statistics":{"output-rows":3},
     157            1 :             "input":{"operator":"tablescan","statistics":{"processed-rows":10,"output-rows":3}}}"#,
     158              :     );
     159            1 :     let scan = snapshot_state(&model, fb::ProfileEntity::OPERATOR, 0);
     160            1 :     assert_eq!((scan.stats.tuples_in, scan.stats.tuples_out), (Some(10), Some(3)));
     161              :     // The hidden root fragment takes its output from its root operator
     162            1 :     assert_eq!(snapshot_state(&model, fb::ProfileEntity::FRAGMENT, 0).stats.tuples_out, Some(3));
     163            1 : }
     164              : 
     165              : #[test]
     166            1 : fn pipelines_and_fragments_register_at_runtime() {
     167            1 :     let mut model = load(r#"{"operator":"output","input":{"operator":"filter","input":{"operator":"scan"}}}"#);
     168            1 :     let version = model.structure_version();
     169            1 :     let updates = model.apply_events(&[
     170            1 :         ProfileInputEvent::RegisterFragment { time: 1, engine_id: Some("1".into()), parent_fragment: None },
     171            1 :         ProfileInputEvent::RegisterPipeline {
     172            1 :             time: 1,
     173            1 :             engine_id: Some("0".into()),
     174            1 :             fragment: Some(0),
     175            1 :             slots: vec![
     176            1 :                 SlotSpec { operator: 0, role: SlotRole::Source },
     177            1 :                 SlotSpec { operator: 1, role: SlotRole::Through },
     178            1 :                 SlotSpec { operator: 2, role: SlotRole::Sink },
     179            1 :             ],
     180            1 :         },
     181            1 :         slot(2, 1, Status::Running, tuples(10, 4)),
     182            1 :     ]);
     183            1 :     assert_eq!(model.structure_version(), version + 2);
     184              :     // Registered entities without state are no update, the slot and what rolls up from it are
     185            1 :     assert!(state_of(&updates, fb::ProfileEntity::SLOT, 0).is_none());
     186            1 :     assert_eq!(state_of(&updates, fb::ProfileEntity::OPERATOR, 1).unwrap().stats.tuples_out, Some(4));
     187            1 :     assert_eq!(state_of(&updates, fb::ProfileEntity::PIPELINE, 0).unwrap().status, Status::Running);
     188            1 :     assert_eq!(state_of(&updates, fb::ProfileEntity::FRAGMENT, 0).unwrap().status, Status::Running);
     189              : 
     190              :     // The edges inside the pipeline are carried by the slots of their children
     191            1 :     let buffer = model.pack_buffer();
     192            1 :     let plan = flatbuffers::root::<fb::QueryProfile>(&buffer).unwrap();
     193            1 :     assert_eq!(plan.structure_version(), version + 2);
     194            2 :     let edge_slots: Vec<u32> = plan.operator_edges().unwrap().iter().map(|e| e.slot()).collect();
     195            1 :     assert_eq!(edge_slots, vec![0, 1]);
     196              : 
     197              :     // Events that reference unknown entities are ignored
     198            1 :     let ignored = model.apply_events(&[
     199            1 :         ProfileInputEvent::RegisterFragment { time: 3, engine_id: None, parent_fragment: Some(7) },
     200            1 :         ProfileInputEvent::RegisterPipeline {
     201            1 :             time: 3,
     202            1 :             engine_id: None,
     203            1 :             fragment: None,
     204            1 :             slots: vec![SlotSpec { operator: 9, role: SlotRole::Source }],
     205            1 :         },
     206            1 :         slot(3, 42, Status::Failed, Stats::default()),
     207            1 :     ]);
     208            1 :     assert!(ignored.is_empty());
     209            1 :     assert_eq!(model.structure_version(), version + 2);
     210            1 : }
     211              : 
     212              : #[test]
     213            1 : fn reset_and_replay_seek_backwards() {
     214            1 :     let mut model = load(JOIN_PLAN);
     215            1 :     let version = model.structure_version();
     216            1 :     let log = vec![
     217            1 :         ProfileInputEvent::UpdateQuery { time: 1, status: Some(Status::Running), stats: Stats::default() },
     218            1 :         ProfileInputEvent::RegisterPipeline {
     219            1 :             time: 2,
     220            1 :             engine_id: None,
     221            1 :             fragment: Some(0),
     222            1 :             slots: vec![SlotSpec { operator: 0, role: SlotRole::Source }],
     223            1 :         },
     224            1 :         slot(3, 5, Status::Running, tuples(1, 1)),
     225            1 :         slot(4, 1, Status::Succeeded, tuples(100, 0)),
     226              :     ];
     227              :     // The states, the change times depend on the batches
     228            2 :     let states = |model: &QueryProfile| -> Vec<_> {
     229           16 :         model.snapshot_events().into_iter().map(|u| (u.entity, u.index, u.state)).collect()
     230            2 :     };
     231            1 :     model.apply_events(&log);
     232            1 :     let at_end = states(&model);
     233              : 
     234            1 :     model.reset_execution();
     235            1 :     assert!(model.snapshot_events().is_empty());
     236            1 :     assert_eq!(model.structure_version(), version + 2);
     237            1 :     assert_eq!(flatbuffers::root::<fb::QueryProfile>(&model.pack_buffer()).unwrap().slots().unwrap().len(), 5);
     238              : 
     239              :     // Replaying the log up to a point shows the state of that point, replaying all of it the end state
     240            1 :     model.apply_events(&log[..1]);
     241            1 :     assert_eq!(snapshot_state(&model, fb::ProfileEntity::QUERY, 0).status, Status::Running);
     242            1 :     assert!(state_of(&model.snapshot_events(), fb::ProfileEntity::OPERATOR, 2).is_none());
     243            1 :     model.apply_events(&log[1..]);
     244            1 :     assert_eq!(states(&model), at_end);
     245            1 : }
     246              : 
     247              : #[test]
     248            1 : fn input_events_have_a_json_form() {
     249            1 :     let json = r#"[
     250            1 :         {"type":"updateQuery","time":1,"status":"running","stats":{"workDone":3,"workTotal":10}},
     251            1 :         {"type":"registerPipeline","time":2,"engineId":"7","fragment":0,
     252            1 :          "slots":[{"operator":0,"role":"source"},{"operator":2,"role":"sink"}]},
     253            1 :         {"type":"updateSlot","time":3,"slot":5,"stats":{"tuplesOut":4,"cpuTimeUs":12}}
     254            1 :     ]"#;
     255            1 :     let events: Vec<ProfileInputEvent> = serde_json::from_str(json).unwrap();
     256            1 :     assert_eq!(events.len(), 3);
     257            1 :     let reserialized = serde_json::to_string(&events).unwrap();
     258            1 :     assert_eq!(serde_json::from_str::<Vec<ProfileInputEvent>>(&reserialized).unwrap(), events);
     259              : 
     260            1 :     let mut model = load(JOIN_PLAN);
     261            1 :     let updates = model.apply_events_json(json.as_bytes()).unwrap();
     262            1 :     let query = state_of(&updates, fb::ProfileEntity::QUERY, 0).unwrap();
     263            1 :     assert_eq!((query.status, query.stats.work_done, query.stats.work_total), (Status::Running, Some(3), Some(10)));
     264            1 :     assert_eq!(state_of(&updates, fb::ProfileEntity::SLOT, 5).unwrap().stats.cpu_time_us, Some(12));
     265              : 
     266              :     // Unknown fields are rejected, a typo must not silently drop a counter
     267            1 :     let typo = r#"[{"type":"updateSlot","time":1,"slot":0,"stats":{"tuples":1}}]"#;
     268            1 :     assert!(serde_json::from_str::<Vec<ProfileInputEvent>>(typo).is_err());
     269            1 : }
     270              : 
     271              : #[test]
     272            1 : fn packed_events_carry_the_reported_fields() {
     273            1 :     let mut model = load(JOIN_PLAN);
     274            1 :     let updates = model.apply_events(&[slot(5, 0, Status::Running, tuples(10, 8))]);
     275            1 :     let buffer = model.pack_events(&updates);
     276            1 :     let events = flatbuffers::root::<fb::QueryProfileEvents>(&buffer).unwrap();
     277            1 :     assert_eq!(events.structure_version(), model.structure_version());
     278            4 :     let event = events.events().unwrap().iter().find(|e| e.entity() == fb::ProfileEntity::SLOT).unwrap();
     279            1 :     assert_eq!((event.index(), event.event_time(), event.status()), (0, 5, fb::ProfileExecutionStatus::RUNNING));
     280            1 :     let stats = event.stats();
     281            1 :     assert_eq!(stats.reported(), fb::ProfileStatsField::TUPLES_IN.0 | fb::ProfileStatsField::TUPLES_OUT.0);
     282            1 :     assert_eq!((stats.tuples_in(), stats.tuples_out()), (10, 8));
     283            1 : }
     284              : 
     285              : #[test]
     286            1 : fn trino_stages_register_once_and_tolerate_missing_fields() {
     287            1 :     let mut model = QueryProfile::new();
     288            1 :     model.start_query(fb::ProfileEngine::TRINO);
     289            2 :     let poll = |state: &str, completed: u64| {
     290            2 :         format!(
     291              :             r#"{{"state":"RUNNING","completedSplits":{completed},"totalSplits":4,
     292              :                 "rootStage":{{"stageId":"0","state":"{state}",
     293              :                     "subStages":[{{"stageId":"1","state":"FINISHED","cpuTimeMillis":2}}]}}}}"#
     294              :         )
     295            2 :     };
     296            1 :     let version = model.structure_version();
     297            1 :     let updates = model.apply_trino_statement_stats(poll("RUNNING", 1).as_bytes(), 10).unwrap();
     298            1 :     assert_eq!(model.structure_version(), version + 2);
     299            1 :     let query = state_of(&updates, fb::ProfileEntity::QUERY, 0).unwrap();
     300            1 :     assert_eq!((query.status, query.stats.work_done, query.stats.work_total), (Status::Running, Some(1), Some(4)));
     301              :     // Fields the payload lacks stay unknown
     302            1 :     assert_eq!(query.stats.memory_bytes, None);
     303            1 :     let stage = state_of(&updates, fb::ProfileEntity::FRAGMENT, 1).unwrap();
     304            1 :     assert_eq!((stage.status, stage.stats.cpu_time_us), (Status::Succeeded, Some(2000)));
     305              : 
     306              :     // The next poll knows the stages already
     307            1 :     let updates = model.apply_trino_statement_stats(poll("FINISHED", 4).as_bytes(), 20).unwrap();
     308            1 :     assert_eq!(model.structure_version(), version + 2);
     309            1 :     assert_eq!(state_of(&updates, fb::ProfileEntity::FRAGMENT, 0).unwrap().status, Status::Succeeded);
     310            1 :     assert!(state_of(&updates, fb::ProfileEntity::FRAGMENT, 1).is_none());
     311              : 
     312              :     // A poll without stage tree updates the query only, an unknown state keeps the status
     313            1 :     let updates = model.apply_trino_statement_stats(br#"{"state":"SOMETHING_NEW","completedSplits":4}"#, 30).unwrap();
     314            1 :     assert!(updates.is_empty());
     315            1 :     assert!(model.apply_trino_statement_stats(b"not json", 40).is_err());
     316              : 
     317            1 :     let buffer = model.pack_buffer();
     318            1 :     let plan = flatbuffers::root::<fb::QueryProfile>(&buffer).unwrap();
     319            1 :     assert_eq!(plan.engine(), fb::ProfileEngine::TRINO);
     320            1 :     let child = plan.fragments().unwrap().get(1);
     321            1 :     let engine_id = plan.string_dictionary().unwrap().get(child.engine_id() as usize);
     322            1 :     assert_eq!((engine_id, child.parent_fragment()), ("1", 0));
     323            1 : }
        

Generated by: LCOV version 2.0-1