1- # Copyright (c) 2025 , NVIDIA CORPORATION. All rights reserved.
1+ # Copyright (c) 2026 , NVIDIA CORPORATION. All rights reserved.
22#
33# Licensed under the Apache License, Version 2.0 (the "License");
44# you may not use this file except in compliance with the License.
1717from dataclasses import dataclass
1818from typing import TYPE_CHECKING , Any
1919
20+ from loguru import logger
21+
22+ from nemo_curator .backends .failed_task_markers import record_failed_tasks
23+ from nemo_curator .backends .slurm_array import (
24+ filter_slurm_array_source_tasks ,
25+ resolve_slurm_array_config ,
26+ )
2027from nemo_curator .core .utils import ignore_ray_head_node
2128from nemo_curator .tasks import Task
29+ from nemo_curator .tasks .sentinels import FailedTask , NoneTask
2230from nemo_curator .utils .performance_utils import StageTimer
31+ from nemo_curator .utils .resumability_client import (
32+ completed_resumability_sources ,
33+ flush_resumability_deltas ,
34+ is_resumability_actor_active ,
35+ )
2336
2437if TYPE_CHECKING :
2538 from nemo_curator .stages .base import ProcessingStage
2639
2740
41+ def _is_sentinel (task : Task ) -> bool :
42+ """A payload-less marker (NoneTask/FailedTask), stripped before the next stage."""
43+ return isinstance (task , (NoneTask , FailedTask ))
44+
45+
2846@dataclass
2947class NodeInfo :
3048 """Generic node information for setup_on_node calls across backends.
@@ -85,9 +103,47 @@ def process_batch(self, tasks: list[Task]) -> list[Task]:
85103 # Use the batch processing logic
86104 results = self .stage .process_batch (tasks )
87105
106+ # A returned ``None`` ("filter this slot") becomes a NoneTask so every
107+ # output is a real Task that gets a task_id. Sentinels (NoneTask /
108+ # FailedTask) carry no identity and are stripped again before this
109+ # method returns.
110+ results = [NoneTask () if r is None else r for r in results ]
111+
88112 # Guarantee every emitted task has a task_id (derived id, or uuid fallback).
89113 results = self ._post_process_task_ids (tasks , results )
90114
115+ # Failed tasks on the source stage are not supported.
116+ is_source_stage = getattr (self .stage , "is_source_stage" , False )
117+ failed_tasks = [r for r in results if isinstance (r , FailedTask )]
118+ if failed_tasks and is_source_stage :
119+ msg = (
120+ f"Source stage { self .stage .name } emitted FailedTask, which is not supported."
121+ )
122+ raise ValueError (msg )
123+
124+ # Record failed tasks for later inspection or retry bookkeeping.
125+ if failed_tasks :
126+ record_failed_tasks ()
127+
128+ # Source-stage sentinels (NoneTask only; FailedTask already raised above) are
129+ # not real partitions and must not influence shard assignment or resumability
130+ # counters. Non-source stages keep sentinels here so _apply_resumability_counters
131+ # can fire the correct -1 delta for filtered (NoneTask) slots in the 1:1 path.
132+ if is_source_stage :
133+ results = [r for r in results if not _is_sentinel (r )]
134+
135+ # Filter tasks based on the Slurm array configuration.
136+ slurm_array = resolve_slurm_array_config (is_source_stage = is_source_stage )
137+ if slurm_array is not None and is_source_stage :
138+ results = filter_slurm_array_source_tasks (results , slurm_array , self .stage .name )
139+
140+ # Opt-in resumability: fire per-source deltas (no-op when no actor registered).
141+ if is_resumability_actor_active ():
142+ results = self ._apply_resumability_counters (tasks , results )
143+
144+ # Sentinels never propagate to the next stage.
145+ results = [r for r in results if not _is_sentinel (r )]
146+
91147 # Log performance stats and add to result tasks
92148 _ , stage_perf_stats = self ._timer .log_stats ()
93149 # Consume and attach any custom metrics recorded by the stage during this call
@@ -99,62 +155,41 @@ def process_batch(self, tasks: list[Task]) -> list[Task]:
99155
100156 return results
101157
102- def _post_process_task_ids (self , input_tasks : list [Task ], output_tasks : list [Task | None ]) -> list [Task ]:
103- """Assign a deterministic ``task_id`` to every emitted task.
104-
105- This is the single place task ids are assigned — it runs for every
106- stage on every backend (all backend adapters subclass this), so it
107- makes no difference whether a stage defines ``process`` or overrides
108- ``process_batch``. ``task_id`` is the task's id path (parents + own segment); ids are
109- re-derived at each stage boundary so the same object passing through
110- N stages gets N ids.
111-
112- The input→output mapping decides each output's PARENT; whether the
113- stage is a source decides each output's SEGMENT (content id vs index)
114- — the two are independent. ``None`` outputs (Curator's "return None to
115- filter") are NOT removed before the length check — keeping them in
116- place preserves positional alignment for filter stages — and are then
117- dropped from the returned list.
118-
119- - single input → every output is its child (fan-out): ``parent_<seg>``
120- - ``len(output) == len(input)`` → positional 1:1: each ``parent_i_<seg>``;
121- a ``None`` slot just means input ``i`` was filtered.
122- - any other (ambiguous) cardinality across a batch → a random ``uuid``
123- prefixed with ``"r"`` (e.g. ``"r3f9a…"``), so ``task_id`` is never
124- empty even when a derived id is not possible. The ``"r"`` prefix flags
125- the id as non-deterministic / ancestry-not-tracked (see
126- ``Task.task_id`` docstring).
127-
128- ``seg`` is the output's content id (``Task.get_deterministic_id()``)
129- for a source stage when available, else the positional index — so a
130- source partition keeps a stable id across reorderings regardless of
131- whether the source is 1→N or N→N.
132-
133- Note: a stage that BOTH filters and fans out within a single batch
134- (returning a flat list rather than a per-input slot) cannot be mapped
135- positionally; if its length happens to equal the input length the 1:1
136- assumption may misattribute parents. That combination is unsupported
137- until per-slot sentinels (NoneTask/FailedTask) land in a later PR.
158+ def _post_process_task_ids (self , input_tasks : list [Task ], output_tasks : list [Task ]) -> list [Task ]:
159+ """Assign a deterministic ``task_id`` (parent id + own segment) to every
160+ emitted task. Runs once per stage on every backend, so ``process`` vs
161+ ``process_batch`` makes no difference; ids are re-derived at each stage
162+ boundary, so one object passing through N stages gets N ids.
163+
164+ - single input → fan-out: each output is ``parent_<seg>``
165+ - ``len(output) == len(input)`` → positional 1:1: ``parent_i_<seg>``; a
166+ ``NoneTask`` slot means input ``i`` was filtered (kept for alignment, then
167+ dropped from the result)
168+ - any other cardinality → a random ``"r"``-prefixed uuid (non-deterministic,
169+ ancestry-not-tracked; see ``Task.task_id``)
170+
171+ ``seg`` is the content id (``get_deterministic_id()``) for a source stage,
172+ else the positional index. A stage that both filters and fans out in one
173+ batch can't be mapped positionally and falls to the ``"r"`` case — return
174+ one value (or ``None``) per input to stay positional.
138175 """
139176 is_source = getattr (self .stage , "is_source_stage" , False )
140177
141178 if len (input_tasks ) == 1 :
142- # Fan-out (incl. a source reading from EmptyTask): every non-None
179+ # Fan-out (including a source reading from EmptyTask): every
143180 # output is a child of the single input.
144181 parent_id = input_tasks [0 ].task_id
145- out : list [ Task ] = [ t for t in output_tasks if t is not None ]
182+ out = list ( output_tasks )
146183 for i , task in enumerate (out ):
147184 suffix = (task .get_deterministic_id () or i ) if is_source else i
148185 task ._set_task_id (parent_id , suffix )
149186 return out
150187
151188 if len (output_tasks ) == len (input_tasks ):
152- # Positional 1:1. None is kept above so a filtered slot still lines
153- # up with its own parent; drop the None slots from the result .
189+ # Positional 1:1. A NoneTask sentinel remains aligned with the
190+ # parent whose output was filtered .
154191 out = []
155192 for parent , task in zip (input_tasks , output_tasks , strict = True ):
156- if task is None :
157- continue
158193 suffix = (task .get_deterministic_id () or 0 ) if is_source else 0
159194 task ._set_task_id (parent .task_id , suffix )
160195 out .append (task )
@@ -163,11 +198,91 @@ def _post_process_task_ids(self, input_tasks: list[Task], output_tasks: list[Tas
163198 # Ambiguous cardinality across a batch: a derived id is not possible. Use a
164199 # random "r"-prefixed uuid so task_id is non-empty but clearly flagged
165200 # non-deterministic.
166- out = [ t for t in output_tasks if t is not None ]
201+ out = list ( output_tasks )
167202 for task in out :
168203 task .task_id = "r" + uuid .uuid4 ().hex
169204 return out
170205
206+ # Resumability (opt-in): stamp _source_id, fire per-source deltas, drop
207+ # completed sources. task_ids are already assigned; sentinels stripped by caller.
208+ def _apply_resumability_counters (self , input_tasks : list [Task ], output_tasks : list [Task ]) -> list [Task ]: # noqa: C901
209+ # Dedup key is always an OUTPUT task_id, never the input's: the source
210+ # already keyed its +1 on that id, and an output id is one level deeper,
211+ # so it's unique to the (task, stage) that produced it.
212+ stage = self .stage
213+ if getattr (stage , "is_source_stage" , False ):
214+ return self ._source_counters (output_tasks )
215+
216+ # No outputs (e.g. a batch entirely filtered, or an end-of-pipeline
217+ # no-op): nothing to attribute a delta to, so skip.
218+ if not output_tasks :
219+ return output_tasks
220+
221+ # Pre-source: inputs have no _source_id yet; nothing to track.
222+ if all (not t ._source_id for t in input_tasks ):
223+ return output_tasks
224+
225+ is_sink = stage .is_sink_stage
226+ per_task : list [tuple [str , str , int ]] = []
227+
228+ if len (input_tasks ) == 1 and len (output_tasks ) > 1 :
229+ # Fan-out (1->N): parent consumed (-1); each real child continues
230+ # (+1, or 0 at a sink); each FailedTask keeps the source open (+1);
231+ # NoneTask contributes 0.
232+ parent = input_tasks [0 ]
233+ real = [t for t in output_tasks if not _is_sentinel (t )]
234+ n_failed = sum (1 for t in output_tasks if isinstance (t , FailedTask ))
235+ continuing = 0 if is_sink else len (real )
236+ delta = continuing + n_failed - 1
237+ # Key on output[0].task_id (not parent.task_id, which collides with the
238+ # source's +1). Non-source children are indexed positionally, so
239+ # output[0] is always "<parent>_0".
240+ per_task .append ((output_tasks [0 ].task_id , parent ._source_id , delta ))
241+ for c in real :
242+ if not c ._source_id :
243+ c ._source_id = parent ._source_id
244+ elif len (output_tasks ) == len (input_tasks ):
245+ # Positional 1:1; each delta keys on the output id (r.task_id).
246+ for parent , r in zip (input_tasks , output_tasks , strict = True ):
247+ sid = parent ._source_id
248+ if isinstance (r , NoneTask ): # filtered -> consumed
249+ per_task .append ((r .task_id , sid , - 1 ))
250+ continue
251+ if isinstance (r , FailedTask ): # failed -> source stays open (no sink test)
252+ per_task .append ((r .task_id , sid , 0 ))
253+ continue
254+ per_task .append ((r .task_id , sid , - 1 if is_sink else 0 )) # real: sink -1, else 0
255+ if not r ._source_id :
256+ r ._source_id = sid
257+ else :
258+ # M->K (M!=K): can't attribute parents; skip (source stays pending -> reprocessed).
259+ logger .warning (
260+ f"resumability: { type (stage ).__name__ } produced { len (output_tasks )} outputs "
261+ f"for { len (input_tasks )} inputs; can't attribute sources, skipping counter "
262+ f"update for this batch."
263+ )
264+ return output_tasks
265+
266+ flush_resumability_deltas (per_task )
267+ return output_tasks
268+
269+ def _source_counters (self , output_tasks : list [Task ]) -> list [Task ]:
270+ """Source stage: each output is a source partition; its ``_source_id`` is
271+ ``Task.get_source_id()``. Drop already-completed sources; each survivor fires ``+1``."""
272+ sources = [t for t in output_tasks if not _is_sentinel (t )]
273+ for t in sources :
274+ t ._source_id = t .get_source_id ()
275+ completed = completed_resumability_sources ([t ._source_id for t in sources ])
276+ per_task : list [tuple [str , str , int ]] = []
277+ survivors : list [Task ] = []
278+ for t in sources :
279+ if t ._source_id in completed :
280+ continue
281+ per_task .append ((t .task_id , t ._source_id , + 1 ))
282+ survivors .append (t )
283+ flush_resumability_deltas (per_task )
284+ return survivors
285+
171286 def setup_on_node (self , node_info : NodeInfo | None = None , worker_metadata : WorkerMetadata | None = None ) -> None :
172287 """Setup the stage on a node.
173288
0 commit comments