Skip to content

Commit 257e4e6

Browse files
committed
attribution_in_progress_end
1 parent 7cad598 commit 257e4e6

2 files changed

Lines changed: 312 additions & 17 deletions

File tree

src/nvidia_resiliency_ext/attribution/log_analyzer/nvrx_logsage.py

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

587647
class 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

Comments
 (0)