From 2204b65d2181f0e3515ec630db2afcf9f7c6c6bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 07:03:25 +0000 Subject: [PATCH 1/3] fix: preserve cached retry data in Gantt chart during refresh When task instances are refreshed, rebuild_gantt() previously replaced the entire GanttData, wiping out previously fetched retry history. This caused a visible flicker as retries disappeared momentarily before being re-fetched from the API. Now, cached retry data is carried over into the new GanttData when it has more detailed history than the fresh data, and is only replaced when the new retry fetch completes. https://claude.ai/code/session_01RorM9BtWu7NUZB4yzCPFMQ --- src/airflow/model/common/gantt.rs | 2 +- src/app/model/taskinstances.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/airflow/model/common/gantt.rs b/src/airflow/model/common/gantt.rs index 13c7641e..c992a0a3 100644 --- a/src/airflow/model/common/gantt.rs +++ b/src/airflow/model/common/gantt.rs @@ -80,7 +80,7 @@ impl GanttData { } /// Recalculate `window_start` and `window_end` from all tries. - fn recompute_window(&mut self) { + pub fn recompute_window(&mut self) { let mut min_start: Option = None; let mut max_end: Option = None; let mut any_running = false; diff --git a/src/app/model/taskinstances.rs b/src/app/model/taskinstances.rs index ca62a96f..f28859eb 100644 --- a/src/app/model/taskinstances.rs +++ b/src/app/model/taskinstances.rs @@ -64,15 +64,38 @@ impl TaskInstanceModel { /// Rebuild Gantt data from the current task instance list. /// Returns task IDs that have retries (`try_number` > 1) for fetching detailed tries. + /// + /// For tasks with retries, previously cached try history is preserved so the + /// Gantt chart does not flicker while fresh retry data is being fetched. pub fn rebuild_gantt(&mut self) -> Vec { - self.gantt_data = GanttData::from_task_instances(&self.table.all); + let mut new_gantt = GanttData::from_task_instances(&self.table.all); + + // Collect retried task IDs and carry over cached retry data let mut seen = HashSet::new(); - self.table + let retried: Vec = self + .table .all .iter() .filter(|ti| ti.try_number > 1 && seen.insert(ti.task_id.clone())) .map(|ti| ti.task_id.clone()) - .collect() + .collect(); + + for task_id in &retried { + if let Some(cached_tries) = self.gantt_data.task_tries.get(task_id) { + // Only carry over if the cache has more tries than the fresh data + // (i.e. it already includes the detailed retry history) + let new_tries = new_gantt.task_tries.get(task_id); + if cached_tries.len() > new_tries.map_or(0, Vec::len) { + new_gantt + .task_tries + .insert(task_id.clone(), cached_tries.clone()); + } + } + } + + new_gantt.recompute_window(); + self.gantt_data = new_gantt; + retried } /// Mark a task instance with a new status (optimistic update) From c0f3fb7ac8c3f0add0e1166e9e4c5ee0d6be879a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 07:47:56 +0000 Subject: [PATCH 2/3] fix: reset Gantt retry cache when navigating to a different DAG run The gantt_data.task_tries cache is keyed by TaskId, which is reused across DAG runs of the same DAG. Without tracking which DAG run the cache belongs to, retry history from a previous run could leak into a newly selected run via rebuild_gantt()'s carry-over logic. Add a current_dag_run_id field to TaskInstanceModel and reset gantt_data when the DAG run changes in sync_panel. https://claude.ai/code/session_01RorM9BtWu7NUZB4yzCPFMQ --- src/app/model/taskinstances.rs | 17 ++++++++++++++++- src/app/state.rs | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/model/taskinstances.rs b/src/app/model/taskinstances.rs index f28859eb..bc128a27 100644 --- a/src/app/model/taskinstances.rs +++ b/src/app/model/taskinstances.rs @@ -11,7 +11,8 @@ use ratatui::widgets::{Block, BorderType, Borders, Row, StatefulWidget, Table, W use crate::airflow::graph::{sort_task_instances, TaskGraph}; use crate::airflow::model::common::{ - calculate_duration, format_duration, GanttData, TaskId, TaskInstance, TaskInstanceState, + calculate_duration, format_duration, DagRunId, GanttData, TaskId, TaskInstance, + TaskInstanceState, }; use crate::app::events::custom::FlowrsEvent; use crate::ui::common::{create_headers, state_to_colored_square}; @@ -32,6 +33,9 @@ pub struct TaskInstanceModel { pub popup: Popup, /// Gantt chart data computed from task instances and their tries pub gantt_data: GanttData, + /// Tracks which DAG run the cached `gantt_data` belongs to, so we can + /// invalidate it when the user navigates to a different run. + current_dag_run_id: Option, ticks: u32, event_buffer: Vec, pub task_graph: Option, @@ -43,6 +47,7 @@ impl Default for TaskInstanceModel { table: FilterableTable::new(), popup: Popup::None, gantt_data: GanttData::default(), + current_dag_run_id: None, ticks: 0, event_buffer: Vec::new(), task_graph: None, @@ -55,6 +60,16 @@ impl TaskInstanceModel { Self::default() } + /// Notify the model which DAG run is now active. + /// Resets `gantt_data` when the run changes so cached retry history from a + /// previous run cannot leak into the new one. + pub fn set_dag_run_id(&mut self, dag_run_id: &DagRunId) { + if self.current_dag_run_id.as_ref() != Some(dag_run_id) { + self.gantt_data = GanttData::default(); + self.current_dag_run_id = Some(dag_run_id.clone()); + } + } + /// Sort task instances by topological order (or timestamp fallback) pub fn sort_task_instances(&mut self) { if let Some(graph) = &self.task_graph { diff --git a/src/app/state.rs b/src/app/state.rs index 6b74d091..08656e85 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -370,6 +370,7 @@ impl App { if let (Some(dag_id), Some(dag_run_id)) = (self.nav_context.dag_id(), self.nav_context.dag_run_id()) { + self.task_instances.set_dag_run_id(dag_run_id); self.task_instances.table.all = self .environment_state .get_active_task_instances(dag_id, dag_run_id); From 90193dec122d44d867006a63b0a7468a75a54b7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 07:58:22 +0000 Subject: [PATCH 3/3] fix: key Gantt cache on (DagId, DagRunId) to prevent cross-DAG leaks DAG run IDs like "scheduled__2024-01-01T00:00:00" are commonly shared across different DAGs. The previous cache key (DagRunId only) meant navigating from DAG A's "run_1" to DAG B's "run_1" would skip the gantt_data reset since the run ID appeared unchanged. Replace current_dag_run_id with current_gantt_key: Option<(DagId, DagRunId)> so the cache is invalidated whenever either the DAG or the run changes. https://claude.ai/code/session_01RorM9BtWu7NUZB4yzCPFMQ --- src/app/model/taskinstances.rs | 23 ++++++++++++----------- src/app/state.rs | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app/model/taskinstances.rs b/src/app/model/taskinstances.rs index bc128a27..fcc41227 100644 --- a/src/app/model/taskinstances.rs +++ b/src/app/model/taskinstances.rs @@ -11,7 +11,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Row, StatefulWidget, Table, W use crate::airflow::graph::{sort_task_instances, TaskGraph}; use crate::airflow::model::common::{ - calculate_duration, format_duration, DagRunId, GanttData, TaskId, TaskInstance, + calculate_duration, format_duration, DagId, DagRunId, GanttData, TaskId, TaskInstance, TaskInstanceState, }; use crate::app::events::custom::FlowrsEvent; @@ -33,9 +33,9 @@ pub struct TaskInstanceModel { pub popup: Popup, /// Gantt chart data computed from task instances and their tries pub gantt_data: GanttData, - /// Tracks which DAG run the cached `gantt_data` belongs to, so we can - /// invalidate it when the user navigates to a different run. - current_dag_run_id: Option, + /// Tracks which DAG + run the cached `gantt_data` belongs to, so we can + /// invalidate it when the user navigates to a different DAG or run. + current_gantt_key: Option<(DagId, DagRunId)>, ticks: u32, event_buffer: Vec, pub task_graph: Option, @@ -47,7 +47,7 @@ impl Default for TaskInstanceModel { table: FilterableTable::new(), popup: Popup::None, gantt_data: GanttData::default(), - current_dag_run_id: None, + current_gantt_key: None, ticks: 0, event_buffer: Vec::new(), task_graph: None, @@ -60,13 +60,14 @@ impl TaskInstanceModel { Self::default() } - /// Notify the model which DAG run is now active. - /// Resets `gantt_data` when the run changes so cached retry history from a - /// previous run cannot leak into the new one. - pub fn set_dag_run_id(&mut self, dag_run_id: &DagRunId) { - if self.current_dag_run_id.as_ref() != Some(dag_run_id) { + /// Notify the model which DAG + run is now active. + /// Resets `gantt_data` when either the DAG or the run changes so cached + /// retry history cannot leak into a different context. + pub fn set_gantt_context(&mut self, dag_id: &DagId, dag_run_id: &DagRunId) { + let key = (dag_id.clone(), dag_run_id.clone()); + if self.current_gantt_key.as_ref() != Some(&key) { self.gantt_data = GanttData::default(); - self.current_dag_run_id = Some(dag_run_id.clone()); + self.current_gantt_key = Some(key); } } diff --git a/src/app/state.rs b/src/app/state.rs index 08656e85..69caf37e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -370,7 +370,7 @@ impl App { if let (Some(dag_id), Some(dag_run_id)) = (self.nav_context.dag_id(), self.nav_context.dag_run_id()) { - self.task_instances.set_dag_run_id(dag_run_id); + self.task_instances.set_gantt_context(dag_id, dag_run_id); self.task_instances.table.all = self .environment_state .get_active_task_instances(dag_id, dag_run_id);