@@ -530,7 +530,7 @@ class _ProgressiveLogState:
530530 with only what the final history-aware attribution actually consumes:
531531
532532 * ``latest_offset`` - file offset to resume reading from
533- * ``recent_chunks`` - rolling window of the last ``window`` polls' new lines
533+ * ``recent_chunks`` - rolling window of the last ``window`` *non-empty* polls' lines
534534 * ``checkpoint_seen`` - OR-reduced ``checkpoint_saved`` across *all* polls
535535 * ``checkpoint_first_poll``- checkpoint observed on the very first poll
536536 * ``poll_count`` - number of polls reconciled (logging / truthiness)
@@ -544,6 +544,17 @@ class _ProgressiveLogState:
544544 ``__len__`` returns ``poll_count`` so an unused state (no polls) is falsy,
545545 preserving the previous "empty list is falsy" semantics at call sites that
546546 test ``job_inline_data_dict.get(path)``.
547+
548+ **Concurrency / lifecycle.** When the start phase polls as a background task,
549+ its reconcile interleaves (at ``await`` points) with a final ``analyze_logs``
550+ that reads the same state. ``lock()`` is a *narrow, per-path* async lock that
551+ guards the reconcile/snapshot critical sections so final attribution never
552+ consumes a half-mutated state. It must be acquired only around those sync
553+ sections — never held across the poller's ``await asyncio.sleep`` — and is
554+ deliberately *not* the broad ``LogSageRunner`` lock (which would serialize all
555+ analysis for a whole poll interval). ``closed`` is the cooperative cancel flag
556+ the poller checks each iteration; ``poller_task`` (set by the owner when the
557+ poll runs as a background task) lets :meth:`request_close` hard-cancel it.
547558 """
548559
549560 __slots__ = (
@@ -553,6 +564,10 @@ class _ProgressiveLogState:
553564 "checkpoint_seen" ,
554565 "checkpoint_first_poll" ,
555566 "poll_count" ,
567+ "closed" ,
568+ "poller_task" ,
569+ "_lock" ,
570+ "_lock_loop" ,
556571 )
557572
558573 def __init__ (self , window : int ) -> None :
@@ -562,26 +577,71 @@ def __init__(self, window: int) -> None:
562577 self .checkpoint_seen = False
563578 self .checkpoint_first_poll = False
564579 self .poll_count = 0
580+ # Cooperative cancel flag + optional background-poller handle. The owner
581+ # that launches polling as a task assigns ``poller_task``; cooperative
582+ # close via ``closed`` works even when no task handle is registered.
583+ self .closed = False
584+ self .poller_task : "asyncio.Task[Any] | None" = None
585+ # Lazily bound to the running loop on first use (see ``lock``).
586+ self ._lock : "asyncio.Lock | None" = None
587+ self ._lock_loop : "asyncio.AbstractEventLoop | None" = None
565588
566589 def __len__ (self ) -> int :
567590 return self .poll_count
568591
592+ def lock (self ) -> asyncio .Lock :
593+ """Per-path async lock, bound lazily to the running event loop.
594+
595+ Recreated if the running loop changes, so a state object that outlives a
596+ single ``asyncio.run`` (e.g. tests driving the start and end phases in
597+ separate loops, where the phases are sequential and need no cross-loop
598+ guarding) does not reuse a lock bound to a defunct loop.
599+ """
600+ loop = asyncio .get_running_loop ()
601+ if self ._lock is None or self ._lock_loop is not loop :
602+ self ._lock = asyncio .Lock ()
603+ self ._lock_loop = loop
604+ return self ._lock
605+
569606 def reconcile (self , file_offset : int , new_lines : list [str ], checkpoint_saved : bool ) -> None :
570- """Fold one poll into the bounded state."""
607+ """Fold one poll into the bounded state. Call under :meth:`lock`.
608+
609+ Only *non-empty* polls enter ``recent_chunks``: empty reads (the writer
610+ has gone idle, but the start phase keeps polling until its deadline) must
611+ not evict real content from the bounded window — otherwise the last
612+ log chunk (which carries the terminal error) can scroll out before final
613+ attribution reads it. ``latest_offset`` / ``poll_count`` still advance on
614+ every poll.
615+ """
571616 if checkpoint_saved :
572617 if self .poll_count == 0 :
573618 self .checkpoint_first_poll = True
574619 self .checkpoint_seen = True
575620 self .latest_offset = file_offset
576- self .recent_chunks .append (list (new_lines ))
621+ if new_lines :
622+ self .recent_chunks .append (list (new_lines ))
577623 self .poll_count += 1
578624
579- def recent_lines (self ) -> list [str ]:
580- """Flatten the rolling window into a single list of lines."""
625+ def snapshot (self ) -> "tuple[int, list[str], bool]" :
626+ """Return ``(latest_offset, recent_lines, checkpoint_seen)`` atomically.
627+
628+ Flattens the rolling window into a single list. Call under :meth:`lock`
629+ so a concurrent :meth:`reconcile` cannot observe a partial read.
630+ """
581631 lines : list [str ] = []
582632 for chunk in self .recent_chunks :
583633 lines .extend (chunk )
584- return lines
634+ return self .latest_offset , lines , self .checkpoint_seen
635+
636+ def request_close (self ) -> None :
637+ """Signal the poller to stop; cancel its task if one is registered.
638+
639+ Call under :meth:`lock` so the poller cannot reconcile after close.
640+ """
641+ self .closed = True
642+ task = self .poller_task
643+ if task is not None and not task .done ():
644+ task .cancel ()
585645
586646
587647class NVRxLogAnalyzer (NVRxAttribution ):
@@ -672,6 +732,11 @@ async def analyze_logs_rt_start(self) -> dict[str, str | None]:
672732 empty_logs_stop = self .stop_accumulating_count
673733
674734 while True :
735+ # Final attribution sets ``closed`` (under the per-path lock) to stop
736+ # the poller; honor it before doing more work.
737+ if state .closed :
738+ break
739+
675740 try :
676741 with open (path , 'r' , encoding = 'utf-8' ) as f :
677742 f .seek (file_offset )
@@ -691,19 +756,29 @@ async def analyze_logs_rt_start(self) -> dict[str, str | None]:
691756 if empty_logs_stop <= 0 :
692757 break
693758
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.
759+ # Error extraction (the only consumer of this poll is its
760+ # ``checkpoint_saved`` flag) runs *outside* the per-path lock — it is
761+ # synchronous LLM/CPU work, not state mutation. No per-poll
762+ # get_attribution: its output was never read.
697763 chunk_data = _retry_return_application_errors_rt (
698764 llm , new_lines , cache_dict , self .temporal_cache_dict [path ]
699765 )
700766 checkpoint_saved = bool (getattr (chunk_data , "checkpoint_saved" , False ))
701- state .reconcile (file_offset , new_lines , checkpoint_saved )
767+ # Narrow critical section: fold this poll into the shared state under
768+ # the per-path lock so a concurrent final ``analyze_logs`` cannot read
769+ # a half-mutated state. Bail if final attribution closed us meanwhile.
770+ async with state .lock ():
771+ if state .closed :
772+ break
773+ state .reconcile (file_offset , new_lines , checkpoint_saved )
774+ poll_no = state .poll_count
702775 logger .info (
703- f"[ckpt] analyze_logs_rt_start poll #{ state . poll_count } : "
776+ f"[ckpt] analyze_logs_rt_start poll #{ poll_no } : "
704777 f"chunk_data.checkpoint_saved={ checkpoint_saved } " ,
705778 )
706779
780+ # Sleep is deliberately outside the lock so the poll interval never
781+ # blocks final attribution (or anything else) on the per-path lock.
707782 await asyncio .sleep (self .chunks_per_time * 60 )
708783
709784 return ProgressiveStartResult (
@@ -737,7 +812,16 @@ async def analyze_logs(self) -> list[ApplicationData]:
737812 if path not in self .temporal_cache_dict :
738813 self .temporal_cache_dict [path ] = {}
739814
740- file_offset = state .latest_offset
815+ # Under the per-path lock: close the poller (so it stops reconciling)
816+ # and snapshot the state atomically. A concurrent poll either ran its
817+ # reconcile fully before this, or sees ``closed`` and bails — it can
818+ # never interleave with this read.
819+ async with state .lock ():
820+ state .request_close ()
821+ file_offset , recent_lines , checkpoint_seen = state .snapshot ()
822+ poll_count = state .poll_count
823+
824+ # File I/O stays outside the lock; ``file_offset`` was snapshotted.
741825 try :
742826 with open (path , 'r' , encoding = 'utf-8' ) as f :
743827 f .seek (file_offset )
@@ -747,16 +831,16 @@ async def analyze_logs(self) -> list[ApplicationData]:
747831 f .seek (file_offset )
748832 new_lines = f .readlines ()
749833
750- chunk = state . recent_lines () + new_lines
834+ chunk = recent_lines + new_lines
751835
752836 chunk_data = _retry_return_application_errors_rt (
753837 self .llm , chunk , self .lru_cache , self .temporal_cache_dict [path ]
754838 )
755- chunk_data .checkpoint_saved = state . checkpoint_seen
839+ chunk_data .checkpoint_saved = checkpoint_seen
756840 logger .info (
757841 f"[ckpt] analyze_logs (history-aware): OR-reduced "
758842 f"checkpoint_saved={ chunk_data .checkpoint_saved } "
759- f"across { state . poll_count } polls" ,
843+ f"across { poll_count } polls" ,
760844 )
761845 return [chunk_data ]
762846
@@ -819,8 +903,11 @@ async def llm_analyze(self, output_list: list[ApplicationData]) -> list[LogSageC
819903 path = cfg .get ("log_path" )
820904 if path and self .job_inline_data_dict .get (path ) and len (output_list ) == 1 :
821905 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.
906+ # Final attribution has consumed the progressive state (and already
907+ # closed the poller while snapshotting in ``analyze_logs``); remove
908+ # the per-path entry so it does not accumulate across long-running
909+ # jobs. The poller holds its own reference and exits on ``closed``,
910+ # so it will not resurrect the entry after this pop.
824911 self .job_inline_data_dict .pop (path , None )
825912 if rt_result is None :
826913 return []
0 commit comments