66import random
77import re
88import time
9+ from collections import deque
910from typing import Any , Dict , Mapping , Optional , Tuple , Union
1011
1112from langchain_core .output_parsers import StrOutputParser
@@ -521,6 +522,68 @@ def _with_exponential_backoff(llm_call, checkpoint_saved: bool) -> tuple[str, st
521522 return fallback
522523
523524
525+ class _ProgressiveLogState :
526+ """Bounded per-path state for progressive (start -> end) log attribution.
527+
528+ Replaces an append-only per-poll list of 7-tuples (which retained raw lines,
529+ ``ApplicationData``, and intermediate attribution objects for *every* poll)
530+ with only what the final history-aware attribution actually consumes:
531+
532+ * ``latest_offset`` - file offset to resume reading from
533+ * ``recent_chunks`` - rolling window of the last ``window`` polls' new lines
534+ * ``checkpoint_seen`` - OR-reduced ``checkpoint_saved`` across *all* polls
535+ * ``checkpoint_first_poll``- checkpoint observed on the very first poll
536+ * ``poll_count`` - number of polls reconciled (logging / truthiness)
537+
538+ Only the last ``window`` chunks of raw lines are retained, so memory stays
539+ bounded no matter how long the start phase polls. ``checkpoint_seen`` is
540+ tracked independently so a checkpoint that has scrolled out of the window
541+ (e.g. a boot-time checkpoint on the first poll) still flips the final
542+ attribution.
543+
544+ ``__len__`` returns ``poll_count`` so an unused state (no polls) is falsy,
545+ preserving the previous "empty list is falsy" semantics at call sites that
546+ test ``job_inline_data_dict.get(path)``.
547+ """
548+
549+ __slots__ = (
550+ "window" ,
551+ "latest_offset" ,
552+ "recent_chunks" ,
553+ "checkpoint_seen" ,
554+ "checkpoint_first_poll" ,
555+ "poll_count" ,
556+ )
557+
558+ def __init__ (self , window : int ) -> None :
559+ self .window = max (int (window ), 1 )
560+ self .latest_offset = 0
561+ self .recent_chunks : "deque[list[str]]" = deque (maxlen = self .window )
562+ self .checkpoint_seen = False
563+ self .checkpoint_first_poll = False
564+ self .poll_count = 0
565+
566+ def __len__ (self ) -> int :
567+ return self .poll_count
568+
569+ def reconcile (self , file_offset : int , new_lines : list [str ], checkpoint_saved : bool ) -> None :
570+ """Fold one poll into the bounded state."""
571+ if checkpoint_saved :
572+ if self .poll_count == 0 :
573+ self .checkpoint_first_poll = True
574+ self .checkpoint_seen = True
575+ self .latest_offset = file_offset
576+ self .recent_chunks .append (list (new_lines ))
577+ self .poll_count += 1
578+
579+ def recent_lines (self ) -> list [str ]:
580+ """Flatten the rolling window into a single list of lines."""
581+ lines : list [str ] = []
582+ for chunk in self .recent_chunks :
583+ lines .extend (chunk )
584+ return lines
585+
586+
524587class NVRxLogAnalyzer (NVRxAttribution ):
525588 def __init__ (self , args : Union [argparse .Namespace , Mapping [str , Any ]]):
526589 from nvidia_resiliency_ext .attribution .api_keys import (
@@ -562,13 +625,26 @@ def __init__(self, args: Union[argparse.Namespace, Mapping[str, Any]]):
562625 def init_config (self ) -> Dict [str , Any ]:
563626 return dict (self ._init_config )
564627
628+ def _history_window (self ) -> int :
629+ """Number of recent poll-chunks the end phase glues to the freshly read
630+ tail: ``int(logs_minutes_before_job_end / chunks_per_time)``, floored at 1.
631+
632+ Bounds :attr:`_ProgressiveLogState.recent_chunks` so progressive memory
633+ stays constant regardless of how long the start phase polls.
634+ """
635+ if self .chunks_per_time :
636+ return max (int (self .logs_minutes_before_job_end / self .chunks_per_time ), 1 )
637+ return 1
638+
565639 async def analyze_logs_rt_start (self ) -> dict [str , str | None ]:
566640 """Run the progressive-analysis start phase for the configured log path.
567641
568- This is a non-result-producing phase: it polls the log file, accumulating
569- per-poll attribution data into ``self.job_inline_data_dict[path]`` as a side
570- effect for a later (history-aware) ``analyze_logs`` call. It does not return
571- the attribution itself.
642+ This is a non-result-producing phase: it polls the log file, reconciling
643+ each poll into a bounded :class:`_ProgressiveLogState` at
644+ ``self.job_inline_data_dict[path]`` (latest offset, a rolling window of
645+ recent lines, and an accumulated checkpoint flag) as a side effect for a
646+ later (history-aware) ``analyze_logs`` call. It does not return the
647+ attribution itself.
572648
573649 Returns:
574650 A :class:`~nvidia_resiliency_ext.attribution.orchestration.progressive.ProgressiveStartResult`
@@ -588,18 +664,13 @@ async def analyze_logs_rt_start(self) -> dict[str, str | None]:
588664
589665 if path not in self .temporal_cache_dict :
590666 self .temporal_cache_dict [path ] = {}
591- self .job_inline_data_dict .setdefault (path , [])
667+ state = self .job_inline_data_dict .get (path )
668+ if not isinstance (state , _ProgressiveLogState ):
669+ state = _ProgressiveLogState (self ._history_window ())
670+ self .job_inline_data_dict [path ] = state
592671 file_offset = 0
593- log_lines : list [str ] = []
594672 empty_logs_stop = self .stop_accumulating_count
595673
596- application_log , attribution_raw_chunk , attribution_dict_chunk , hw_category_chunk = (
597- None ,
598- None ,
599- None ,
600- None ,
601- )
602-
603674 while True :
604675 try :
605676 with open (path , 'r' , encoding = 'utf-8' ) as f :
@@ -620,58 +691,34 @@ async def analyze_logs_rt_start(self) -> dict[str, str | None]:
620691 if empty_logs_stop <= 0 :
621692 break
622693
623- log_lines . extend ( new_lines )
624- attribution_list = []
625-
694+ # Only ``checkpoint_saved`` (set by error extraction) is consumed
695+ # from this poll; reconcile it plus the offset and raw lines into the
696+ # bounded state. No per-poll get_attribution: its output was never read.
626697 chunk_data = _retry_return_application_errors_rt (
627698 llm , new_lines , cache_dict , self .temporal_cache_dict [path ]
628699 )
629- app_data = chunk_data
630- if chunk_data .application_errors_list_full :
631- (
632- application_log ,
633- attribution_raw_chunk ,
634- attribution_dict_chunk ,
635- hw_category_chunk ,
636- ) = get_attribution (llm , app_data , True )
637- attribution_list .append (attribution_raw_chunk )
638-
639- self .job_inline_data_dict [path ].append (
640- (
641- file_offset ,
642- new_lines ,
643- chunk_data ,
644- application_log ,
645- attribution_raw_chunk ,
646- attribution_dict_chunk ,
647- hw_category_chunk ,
648- )
649- )
700+ checkpoint_saved = bool (getattr (chunk_data , "checkpoint_saved" , False ))
701+ state .reconcile (file_offset , new_lines , checkpoint_saved )
650702 logger .info (
651- f"[ckpt] analyze_logs_rt_start poll #"
652- f"{ len (self .job_inline_data_dict [path ])} : "
653- f"chunk_data.checkpoint_saved="
654- f"{ getattr (chunk_data , 'checkpoint_saved' , False )} " ,
703+ f"[ckpt] analyze_logs_rt_start poll #{ state .poll_count } : "
704+ f"chunk_data.checkpoint_saved={ checkpoint_saved } " ,
655705 )
656706
657707 await asyncio .sleep (self .chunks_per_time * 60 )
658708
659709 return ProgressiveStartResult (
660710 status = PROGRESSIVE_STATUS_STARTED ,
661- message = (
662- f"progressive analysis accumulated "
663- f"{ len (self .job_inline_data_dict [path ])} chunk(s) for { path } "
664- ),
711+ message = (f"progressive analysis accumulated { state .poll_count } chunk(s) for { path } " ),
665712 ).as_payload ()
666713
667714 async def analyze_logs (self ) -> list [ApplicationData ]:
668715 """
669716 Analyzes the logs and returns the application errors.
670717
671718 If progressive analysis (``analyze_logs_rt_start``) has accumulated
672- per-poll history for this log path in ``job_inline_data_dict``, read
673- only the tail beyond the last polled offset, glue it onto the last N
674- history chunks, and run a single extraction over the combined chunk
719+ bounded state for this log path in ``job_inline_data_dict``, read only
720+ the tail beyond the last polled offset, glue it onto the rolling window
721+ of recent chunks, and run a single extraction over the combined chunk
675722 — returning a one-element list.
676723
677724 Otherwise read the whole file, optionally exclude nvrx lines, chunk
@@ -680,8 +727,8 @@ async def analyze_logs(self) -> list[ApplicationData]:
680727 cfg = effective_run_or_init_config (self ._init_config )
681728 path = cfg ["log_path" ]
682729
683- history = self .job_inline_data_dict .get (path )
684- if history :
730+ state = self .job_inline_data_dict .get (path )
731+ if isinstance ( state , _ProgressiveLogState ) and state . poll_count :
685732 cycle_counter = int (cfg .get ("cycle_counter" , 0 ))
686733 cycle_counter_key = _cycle_counter_key (path )
687734 if cycle_counter == 0 :
@@ -690,7 +737,7 @@ async def analyze_logs(self) -> list[ApplicationData]:
690737 if path not in self .temporal_cache_dict :
691738 self .temporal_cache_dict [path ] = {}
692739
693- file_offset = history [ - 1 ][ 0 ]
740+ file_offset = state . latest_offset
694741 try :
695742 with open (path , 'r' , encoding = 'utf-8' ) as f :
696743 f .seek (file_offset )
@@ -700,20 +747,16 @@ async def analyze_logs(self) -> list[ApplicationData]:
700747 f .seek (file_offset )
701748 new_lines = f .readlines ()
702749
703- num_chunks = int (self .logs_minutes_before_job_end / self .chunks_per_time )
704- chunk : list [str ] = []
705- for item in history [- num_chunks :]:
706- chunk = chunk + item [1 ]
707- chunk = chunk + new_lines
750+ chunk = state .recent_lines () + new_lines
708751
709752 chunk_data = _retry_return_application_errors_rt (
710753 self .llm , chunk , self .lru_cache , self .temporal_cache_dict [path ]
711754 )
712- chunk_data .checkpoint_saved = any ( item [ 2 ]. checkpoint_saved for item in history )
755+ chunk_data .checkpoint_saved = state . checkpoint_seen
713756 logger .info (
714757 f"[ckpt] analyze_logs (history-aware): OR-reduced "
715758 f"checkpoint_saved={ chunk_data .checkpoint_saved } "
716- f"across { len ( history ) } history entries " ,
759+ f"across { state . poll_count } polls " ,
717760 )
718761 return [chunk_data ]
719762
@@ -776,6 +819,9 @@ async def llm_analyze(self, output_list: list[ApplicationData]) -> list[LogSageC
776819 path = cfg .get ("log_path" )
777820 if path and self .job_inline_data_dict .get (path ) and len (output_list ) == 1 :
778821 rt_result = self ._streaming_attribution (output_list [0 ], cfg , path )
822+ # Final attribution has consumed the progressive state; drop the
823+ # per-path entry so it does not accumulate across long-running jobs.
824+ self .job_inline_data_dict .pop (path , None )
779825 if rt_result is None :
780826 return []
781827 attribution_str = str (rt_result .attribution )
0 commit comments