hojichar.core.parallel

  1from __future__ import annotations
  2
  3import functools
  4import logging
  5import os
  6import signal
  7import threading
  8from copy import copy
  9from multiprocessing.pool import Pool
 10from typing import Iterator, List
 11
 12import hojichar
 13from hojichar.core import inspection
 14from hojichar.core.models import Statistics
 15
 16logger = logging.getLogger(__name__)
 17
 18
 19PARALLEL_BASE_FILTER: hojichar.Compose
 20WORKER_PARAM_IGNORE_ERRORS: bool
 21
 22
 23def _init_worker(filter: hojichar.Compose, ignore_errors: bool) -> None:
 24    signal.signal(signal.SIGINT, signal.SIG_IGN)
 25    global PARALLEL_BASE_FILTER, WORKER_PARAM_IGNORE_ERRORS
 26    PARALLEL_BASE_FILTER = hojichar.Compose(copy(filter.filters))  # TODO random state treatment
 27    WORKER_PARAM_IGNORE_ERRORS = ignore_errors
 28
 29
 30def _worker(
 31    doc: hojichar.Document,
 32) -> tuple[hojichar.Document, int, List[Statistics], str | None]:
 33    global PARALLEL_BASE_FILTER, WORKER_PARAM_IGNORE_ERRORS
 34    ignore_errors = WORKER_PARAM_IGNORE_ERRORS
 35    error_message = None
 36    try:
 37        result = PARALLEL_BASE_FILTER.apply(doc)
 38    except Exception as e:
 39        if ignore_errors:
 40            logger.error(e)
 41            error_message = str(e)
 42            result = hojichar.Document("", is_rejected=True)
 43        else:
 44            raise e  # If we're not ignoring errors, let this one propagate
 45    return result, os.getpid(), PARALLEL_BASE_FILTER.get_total_statistics(), error_message
 46
 47
 48class _InFlightGate:
 49    """Bounds documents drawn from the input but not yet returned to the caller.
 50
 51    ``feed`` wraps the input iterator and acquires one permit *before* each
 52    document is drawn (acquiring afterwards would hold one extra pre-fetched
 53    document while waiting). ``imap_apply`` releases the permit only after the
 54    corresponding result has been handed back to the caller, so at most
 55    ``max_in_flight`` documents exist anywhere between the input iterator and
 56    the caller at any moment.
 57
 58    ``feed`` runs inside Pool's task-handler thread. The polling acquire
 59    observes :meth:`stop` before and after each successful acquire (handing
 60    the permit back if stopped), and abnormal exits stop the gate before
 61    returning their permit, so the feeder never draws from the source after
 62    consumption ended and pool shutdown never blocks on the gate.
 63    """
 64
 65    def __init__(self, max_in_flight: int) -> None:
 66        self._semaphore = threading.Semaphore(max_in_flight)
 67        self._stop_feeding = threading.Event()
 68
 69    def feed(self, docs: Iterator[hojichar.Document]) -> Iterator[hojichar.Document]:
 70        iterator = iter(docs)
 71        while self._acquire():
 72            try:
 73                doc = next(iterator)
 74            except StopIteration:
 75                self.release()
 76                return
 77            yield doc
 78
 79    def release(self) -> None:
 80        self._semaphore.release()
 81
 82    def stop(self) -> None:
 83        """Unblock the feeder; called when consumption ends and at shutdown."""
 84        self._stop_feeding.set()
 85
 86    def _acquire(self) -> bool:
 87        while not self._stop_feeding.is_set():
 88            if self._semaphore.acquire(timeout=0.1):
 89                if self._stop_feeding.is_set():
 90                    # Stopped while blocked on (or right after) the acquire:
 91                    # hand the permit back instead of drawing one more
 92                    # document from a possibly blocking source.
 93                    self._semaphore.release()
 94                    return False
 95                return True
 96        return False
 97
 98
 99class Parallel:
100    """
101    The Parallel class provides a way to apply a hojichar.Compose filter
102    to an iterator of documents in a parallel manner using a specified
103    number of worker processes. This class should be used as a context
104    manager with a 'with' statement.
105
106    Example:
107
108    doc_iter = (hojichar.Document(d) for d in open("my_text.txt"))
109    with Parallel(my_filter, num_jobs=8) as pfilter:
110        for doc in pfilter.imap_apply(doc_iter):
111            pass  # Process the filtered document as needed.
112    """
113
114    def __init__(
115        self,
116        filter: hojichar.Compose,
117        num_jobs: int | None = None,
118        ignore_errors: bool = False,
119        ordered: bool = False,
120        max_in_flight: int | None = None,
121    ):
122        """
123        Initializes a new instance of the Parallel class.
124
125        Args:
126            filter (hojichar.Compose): A composed filter object that specifies the
127                processing operations to apply to each document in parallel.
128                A copy of the filter is made within a 'with' statement. When the 'with'
129                block terminates,the statistical information obtained through `filter.statistics`
130                or`filter.statistics_obj` is replaced with the total value of the statistical
131                information processed within the 'with' block.
132
133            num_jobs (int | None, optional): The number of worker processes to use.
134                If None, then the number returned by os.cpu_count() is used. Defaults to None.
135            ignore_errors (bool, optional): If set to True, any exceptions thrown during
136                the processing of a document will be caught and logged, but will not
137                stop the processing of further documents. If set to False, the first
138                exception thrown will terminate the entire parallel processing operation.
139                Defaults to False.
140            ordered (bool, optional): If set to True, processed documents are yielded in
141                the same order as the input documents. If set to False, documents are
142                yielded as soon as their processing completes. Defaults to False.
143            max_in_flight (int | None, optional): Strict upper bound on the number of
144                documents drawn from the input iterator whose results have not yet been
145                handed back to the caller. A yielded document keeps its permit until the
146                caller requests the next one, so with `max_in_flight=1` the pool holds a
147                single document end to end (no pipelining). Without a bound, Pool's
148                task-handler thread drains the input as fast as the worker pipe accepts,
149                so a producer that outruns the filters can buffer a large number of
150                documents. If None, no explicit bound is applied. Defaults to None.
151        """
152        if max_in_flight is not None and max_in_flight < 1:
153            raise ValueError("max_in_flight must be at least 1")
154        self.filter = filter
155        self.num_jobs = num_jobs
156        self.ignore_errors = ignore_errors
157        self.ordered = ordered
158        self.max_in_flight = max_in_flight
159
160        self._pool: Pool | None = None
161        self._pid_stats: dict[int, List[Statistics]] | None = None
162        self._gates: list[_InFlightGate] = []
163
164    def __enter__(self) -> Parallel:
165        self._pool = Pool(
166            processes=self.num_jobs,
167            initializer=_init_worker,
168            initargs=(self.filter, self.ignore_errors),
169        )
170        self._pid_stats = dict()
171        self._gates = []
172        return self
173
174    def imap_apply(self, docs: Iterator[hojichar.Document]) -> Iterator[hojichar.Document]:
175        """
176        Takes an iterator of Documents and applies the Compose filter to
177        each Document in a parallel manner. This is a generator method
178        that yields processed Documents.
179
180        Args:
181            docs (Iterator[hojichar.Document]): An iterator of Documents to be processed.
182
183        Raises:
184            RuntimeError: If the Parallel instance is not properly initialized. This
185                generally happens when the method is called outside of a 'with' statement.
186            Exception: If any exceptions are raised within the worker processes.
187
188        Yields:
189            Iterator[hojichar.Document]: An iterator that yields processed Documents.
190        """
191        if self._pool is None or self._pid_stats is None:
192            raise RuntimeError(
193                "Parallel instance not properly initialized. Use within a 'with' statement."
194            )
195        gate: _InFlightGate | None = None
196        if self.max_in_flight is not None:
197            gate = _InFlightGate(self.max_in_flight)
198            self._gates.append(gate)
199            docs = gate.feed(docs)
200        try:
201            results = (
202                self._pool.imap(_worker, docs)
203                if self.ordered
204                else self._pool.imap_unordered(_worker, docs)
205            )
206            for doc, pid, stat, err_msg in results:
207                self._pid_stats[pid] = stat
208                if err_msg is not None:
209                    logger.error(f"Error in worker {pid}: {err_msg}")
210                # The permit is returned only once the caller has taken the
211                # document: releasing before the yield would let the feeder
212                # momentarily draw max_in_flight + 1 documents. On an abnormal
213                # exit (close/throw at the yield point) the gate is stopped
214                # *before* the permit is returned, so a feeder woken by the
215                # release always observes the stop and cannot draw again.
216                try:
217                    yield doc
218                except BaseException:
219                    if gate is not None:
220                        gate.stop()
221                        gate.release()
222                    raise
223                else:
224                    if gate is not None:
225                        gate.release()
226        except Exception:
227            self.__exit__(None, None, None)
228            raise
229        finally:
230            if gate is not None:
231                gate.stop()
232
233    def __exit__(self, exc_type, exc_value, traceback) -> None:  # type: ignore
234        # Feeders blocked on a max_in_flight gate must exit before the pool is
235        # joined, or shutdown would wait on them forever.
236        for gate in self._gates:
237            gate.stop()
238        if self._pool:
239            self._pool.terminate()
240            self._pool.join()
241        if self._pid_stats:
242            total_stats = functools.reduce(
243                lambda x, y: Statistics.add_list_of_stats(x, y), self._pid_stats.values()
244            )
245            self.filter._statistics.update(Statistics.get_filter("Total", total_stats))
246            for stat in total_stats:
247                for filt in self.filter.filters:
248                    if stat.name == filt.name:
249                        filt._statistics.update(stat)
250                        break
251
252    def get_total_statistics(self) -> List[Statistics]:
253        """
254        Returns a statistics object of the total statistical
255        values processed within the Parallel block.
256
257        Returns:
258            StatsContainer: Statistics object
259        """
260        if self._pid_stats:
261            total_stats = functools.reduce(
262                lambda x, y: Statistics.add_list_of_stats(x, y), self._pid_stats.values()
263            )
264            return total_stats
265        else:
266            return []
267
268    def get_total_statistics_map(self) -> List[dict]:
269        return [stat.to_dict() for stat in self.get_total_statistics()]
270
271    @property
272    def statistics_obj(self) -> inspection.StatsContainer:
273        """
274        Returns the statistics object of the Parallel instance.
275        This is a StatsContainer object which contains the statistics
276        of the Parallel instance and sub filters.
277
278        Returns:
279            StatsContainer: Statistics object
280        """
281        return inspection.statistics_obj_adapter(self.get_total_statistics())  # type: ignore
class Parallel:
100class Parallel:
101    """
102    The Parallel class provides a way to apply a hojichar.Compose filter
103    to an iterator of documents in a parallel manner using a specified
104    number of worker processes. This class should be used as a context
105    manager with a 'with' statement.
106
107    Example:
108
109    doc_iter = (hojichar.Document(d) for d in open("my_text.txt"))
110    with Parallel(my_filter, num_jobs=8) as pfilter:
111        for doc in pfilter.imap_apply(doc_iter):
112            pass  # Process the filtered document as needed.
113    """
114
115    def __init__(
116        self,
117        filter: hojichar.Compose,
118        num_jobs: int | None = None,
119        ignore_errors: bool = False,
120        ordered: bool = False,
121        max_in_flight: int | None = None,
122    ):
123        """
124        Initializes a new instance of the Parallel class.
125
126        Args:
127            filter (hojichar.Compose): A composed filter object that specifies the
128                processing operations to apply to each document in parallel.
129                A copy of the filter is made within a 'with' statement. When the 'with'
130                block terminates,the statistical information obtained through `filter.statistics`
131                or`filter.statistics_obj` is replaced with the total value of the statistical
132                information processed within the 'with' block.
133
134            num_jobs (int | None, optional): The number of worker processes to use.
135                If None, then the number returned by os.cpu_count() is used. Defaults to None.
136            ignore_errors (bool, optional): If set to True, any exceptions thrown during
137                the processing of a document will be caught and logged, but will not
138                stop the processing of further documents. If set to False, the first
139                exception thrown will terminate the entire parallel processing operation.
140                Defaults to False.
141            ordered (bool, optional): If set to True, processed documents are yielded in
142                the same order as the input documents. If set to False, documents are
143                yielded as soon as their processing completes. Defaults to False.
144            max_in_flight (int | None, optional): Strict upper bound on the number of
145                documents drawn from the input iterator whose results have not yet been
146                handed back to the caller. A yielded document keeps its permit until the
147                caller requests the next one, so with `max_in_flight=1` the pool holds a
148                single document end to end (no pipelining). Without a bound, Pool's
149                task-handler thread drains the input as fast as the worker pipe accepts,
150                so a producer that outruns the filters can buffer a large number of
151                documents. If None, no explicit bound is applied. Defaults to None.
152        """
153        if max_in_flight is not None and max_in_flight < 1:
154            raise ValueError("max_in_flight must be at least 1")
155        self.filter = filter
156        self.num_jobs = num_jobs
157        self.ignore_errors = ignore_errors
158        self.ordered = ordered
159        self.max_in_flight = max_in_flight
160
161        self._pool: Pool | None = None
162        self._pid_stats: dict[int, List[Statistics]] | None = None
163        self._gates: list[_InFlightGate] = []
164
165    def __enter__(self) -> Parallel:
166        self._pool = Pool(
167            processes=self.num_jobs,
168            initializer=_init_worker,
169            initargs=(self.filter, self.ignore_errors),
170        )
171        self._pid_stats = dict()
172        self._gates = []
173        return self
174
175    def imap_apply(self, docs: Iterator[hojichar.Document]) -> Iterator[hojichar.Document]:
176        """
177        Takes an iterator of Documents and applies the Compose filter to
178        each Document in a parallel manner. This is a generator method
179        that yields processed Documents.
180
181        Args:
182            docs (Iterator[hojichar.Document]): An iterator of Documents to be processed.
183
184        Raises:
185            RuntimeError: If the Parallel instance is not properly initialized. This
186                generally happens when the method is called outside of a 'with' statement.
187            Exception: If any exceptions are raised within the worker processes.
188
189        Yields:
190            Iterator[hojichar.Document]: An iterator that yields processed Documents.
191        """
192        if self._pool is None or self._pid_stats is None:
193            raise RuntimeError(
194                "Parallel instance not properly initialized. Use within a 'with' statement."
195            )
196        gate: _InFlightGate | None = None
197        if self.max_in_flight is not None:
198            gate = _InFlightGate(self.max_in_flight)
199            self._gates.append(gate)
200            docs = gate.feed(docs)
201        try:
202            results = (
203                self._pool.imap(_worker, docs)
204                if self.ordered
205                else self._pool.imap_unordered(_worker, docs)
206            )
207            for doc, pid, stat, err_msg in results:
208                self._pid_stats[pid] = stat
209                if err_msg is not None:
210                    logger.error(f"Error in worker {pid}: {err_msg}")
211                # The permit is returned only once the caller has taken the
212                # document: releasing before the yield would let the feeder
213                # momentarily draw max_in_flight + 1 documents. On an abnormal
214                # exit (close/throw at the yield point) the gate is stopped
215                # *before* the permit is returned, so a feeder woken by the
216                # release always observes the stop and cannot draw again.
217                try:
218                    yield doc
219                except BaseException:
220                    if gate is not None:
221                        gate.stop()
222                        gate.release()
223                    raise
224                else:
225                    if gate is not None:
226                        gate.release()
227        except Exception:
228            self.__exit__(None, None, None)
229            raise
230        finally:
231            if gate is not None:
232                gate.stop()
233
234    def __exit__(self, exc_type, exc_value, traceback) -> None:  # type: ignore
235        # Feeders blocked on a max_in_flight gate must exit before the pool is
236        # joined, or shutdown would wait on them forever.
237        for gate in self._gates:
238            gate.stop()
239        if self._pool:
240            self._pool.terminate()
241            self._pool.join()
242        if self._pid_stats:
243            total_stats = functools.reduce(
244                lambda x, y: Statistics.add_list_of_stats(x, y), self._pid_stats.values()
245            )
246            self.filter._statistics.update(Statistics.get_filter("Total", total_stats))
247            for stat in total_stats:
248                for filt in self.filter.filters:
249                    if stat.name == filt.name:
250                        filt._statistics.update(stat)
251                        break
252
253    def get_total_statistics(self) -> List[Statistics]:
254        """
255        Returns a statistics object of the total statistical
256        values processed within the Parallel block.
257
258        Returns:
259            StatsContainer: Statistics object
260        """
261        if self._pid_stats:
262            total_stats = functools.reduce(
263                lambda x, y: Statistics.add_list_of_stats(x, y), self._pid_stats.values()
264            )
265            return total_stats
266        else:
267            return []
268
269    def get_total_statistics_map(self) -> List[dict]:
270        return [stat.to_dict() for stat in self.get_total_statistics()]
271
272    @property
273    def statistics_obj(self) -> inspection.StatsContainer:
274        """
275        Returns the statistics object of the Parallel instance.
276        This is a StatsContainer object which contains the statistics
277        of the Parallel instance and sub filters.
278
279        Returns:
280            StatsContainer: Statistics object
281        """
282        return inspection.statistics_obj_adapter(self.get_total_statistics())  # type: ignore

The Parallel class provides a way to apply a hojichar.Compose filter to an iterator of documents in a parallel manner using a specified number of worker processes. This class should be used as a context manager with a 'with' statement.

Example:

doc_iter = (hojichar.Document(d) for d in open("my_text.txt")) with Parallel(my_filter, num_jobs=8) as pfilter: for doc in pfilter.imap_apply(doc_iter): pass # Process the filtered document as needed.

Parallel( filter: hojichar.core.composition.Compose, num_jobs: int | None = None, ignore_errors: bool = False, ordered: bool = False, max_in_flight: int | None = None)
115    def __init__(
116        self,
117        filter: hojichar.Compose,
118        num_jobs: int | None = None,
119        ignore_errors: bool = False,
120        ordered: bool = False,
121        max_in_flight: int | None = None,
122    ):
123        """
124        Initializes a new instance of the Parallel class.
125
126        Args:
127            filter (hojichar.Compose): A composed filter object that specifies the
128                processing operations to apply to each document in parallel.
129                A copy of the filter is made within a 'with' statement. When the 'with'
130                block terminates,the statistical information obtained through `filter.statistics`
131                or`filter.statistics_obj` is replaced with the total value of the statistical
132                information processed within the 'with' block.
133
134            num_jobs (int | None, optional): The number of worker processes to use.
135                If None, then the number returned by os.cpu_count() is used. Defaults to None.
136            ignore_errors (bool, optional): If set to True, any exceptions thrown during
137                the processing of a document will be caught and logged, but will not
138                stop the processing of further documents. If set to False, the first
139                exception thrown will terminate the entire parallel processing operation.
140                Defaults to False.
141            ordered (bool, optional): If set to True, processed documents are yielded in
142                the same order as the input documents. If set to False, documents are
143                yielded as soon as their processing completes. Defaults to False.
144            max_in_flight (int | None, optional): Strict upper bound on the number of
145                documents drawn from the input iterator whose results have not yet been
146                handed back to the caller. A yielded document keeps its permit until the
147                caller requests the next one, so with `max_in_flight=1` the pool holds a
148                single document end to end (no pipelining). Without a bound, Pool's
149                task-handler thread drains the input as fast as the worker pipe accepts,
150                so a producer that outruns the filters can buffer a large number of
151                documents. If None, no explicit bound is applied. Defaults to None.
152        """
153        if max_in_flight is not None and max_in_flight < 1:
154            raise ValueError("max_in_flight must be at least 1")
155        self.filter = filter
156        self.num_jobs = num_jobs
157        self.ignore_errors = ignore_errors
158        self.ordered = ordered
159        self.max_in_flight = max_in_flight
160
161        self._pool: Pool | None = None
162        self._pid_stats: dict[int, List[Statistics]] | None = None
163        self._gates: list[_InFlightGate] = []

Initializes a new instance of the Parallel class.

Args: filter (hojichar.Compose): A composed filter object that specifies the processing operations to apply to each document in parallel. A copy of the filter is made within a 'with' statement. When the 'with' block terminates,the statistical information obtained through filter.statistics orfilter.statistics_obj is replaced with the total value of the statistical information processed within the 'with' block.

num_jobs (int | None, optional): The number of worker processes to use.
    If None, then the number returned by os.cpu_count() is used. Defaults to None.
ignore_errors (bool, optional): If set to True, any exceptions thrown during
    the processing of a document will be caught and logged, but will not
    stop the processing of further documents. If set to False, the first
    exception thrown will terminate the entire parallel processing operation.
    Defaults to False.
ordered (bool, optional): If set to True, processed documents are yielded in
    the same order as the input documents. If set to False, documents are
    yielded as soon as their processing completes. Defaults to False.
max_in_flight (int | None, optional): Strict upper bound on the number of
    documents drawn from the input iterator whose results have not yet been
    handed back to the caller. A yielded document keeps its permit until the
    caller requests the next one, so with `max_in_flight=1` the pool holds a
    single document end to end (no pipelining). Without a bound, Pool's
    task-handler thread drains the input as fast as the worker pipe accepts,
    so a producer that outruns the filters can buffer a large number of
    documents. If None, no explicit bound is applied. Defaults to None.
def imap_apply( self, docs: Iterator[hojichar.core.models.Document]) -> Iterator[hojichar.core.models.Document]:
175    def imap_apply(self, docs: Iterator[hojichar.Document]) -> Iterator[hojichar.Document]:
176        """
177        Takes an iterator of Documents and applies the Compose filter to
178        each Document in a parallel manner. This is a generator method
179        that yields processed Documents.
180
181        Args:
182            docs (Iterator[hojichar.Document]): An iterator of Documents to be processed.
183
184        Raises:
185            RuntimeError: If the Parallel instance is not properly initialized. This
186                generally happens when the method is called outside of a 'with' statement.
187            Exception: If any exceptions are raised within the worker processes.
188
189        Yields:
190            Iterator[hojichar.Document]: An iterator that yields processed Documents.
191        """
192        if self._pool is None or self._pid_stats is None:
193            raise RuntimeError(
194                "Parallel instance not properly initialized. Use within a 'with' statement."
195            )
196        gate: _InFlightGate | None = None
197        if self.max_in_flight is not None:
198            gate = _InFlightGate(self.max_in_flight)
199            self._gates.append(gate)
200            docs = gate.feed(docs)
201        try:
202            results = (
203                self._pool.imap(_worker, docs)
204                if self.ordered
205                else self._pool.imap_unordered(_worker, docs)
206            )
207            for doc, pid, stat, err_msg in results:
208                self._pid_stats[pid] = stat
209                if err_msg is not None:
210                    logger.error(f"Error in worker {pid}: {err_msg}")
211                # The permit is returned only once the caller has taken the
212                # document: releasing before the yield would let the feeder
213                # momentarily draw max_in_flight + 1 documents. On an abnormal
214                # exit (close/throw at the yield point) the gate is stopped
215                # *before* the permit is returned, so a feeder woken by the
216                # release always observes the stop and cannot draw again.
217                try:
218                    yield doc
219                except BaseException:
220                    if gate is not None:
221                        gate.stop()
222                        gate.release()
223                    raise
224                else:
225                    if gate is not None:
226                        gate.release()
227        except Exception:
228            self.__exit__(None, None, None)
229            raise
230        finally:
231            if gate is not None:
232                gate.stop()

Takes an iterator of Documents and applies the Compose filter to each Document in a parallel manner. This is a generator method that yields processed Documents.

Args: docs (Iterator[hojichar.Document]): An iterator of Documents to be processed.

Raises: RuntimeError: If the Parallel instance is not properly initialized. This generally happens when the method is called outside of a 'with' statement. Exception: If any exceptions are raised within the worker processes.

Yields: Iterator[hojichar.Document]: An iterator that yields processed Documents.

def get_total_statistics(self) -> List[hojichar.core.models.Statistics]:
253    def get_total_statistics(self) -> List[Statistics]:
254        """
255        Returns a statistics object of the total statistical
256        values processed within the Parallel block.
257
258        Returns:
259            StatsContainer: Statistics object
260        """
261        if self._pid_stats:
262            total_stats = functools.reduce(
263                lambda x, y: Statistics.add_list_of_stats(x, y), self._pid_stats.values()
264            )
265            return total_stats
266        else:
267            return []

Returns a statistics object of the total statistical values processed within the Parallel block.

Returns: StatsContainer: Statistics object

def get_total_statistics_map(self) -> List[dict]:
269    def get_total_statistics_map(self) -> List[dict]:
270        return [stat.to_dict() for stat in self.get_total_statistics()]

Returns the statistics object of the Parallel instance. This is a StatsContainer object which contains the statistics of the Parallel instance and sub filters.

Returns: StatsContainer: Statistics object