Source code for zephon.ops.accumulators.base

# Copyright 2025 DatologyAI
# SPDX-License-Identifier: Apache-2.0

"""Base accumulator abstraction for deterministic parallel operator execution."""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Generic, Sequence, TypeVar

T = TypeVar("T")

# Type alias for a batch ready for worker invocation.
# Tuple of (batch elements, wait_ns for metrics).
ReadyBatch = tuple[list[T], int]


[docs] @dataclass(frozen=True) class SourceExhaustion: """Immutable source facts observed by one operator in one lane. ``exhausted`` contains all components whose end-of-data (EOD) sentinel reached the operator. ``continuing`` is the subset listed by :meth:`~zephon.work.WorkSource.continues_after_exhaustion`, whose policy permits continuing without them. The runner updates this snapshot before calling :meth:`Accumulator.on_source_exhausted`. """ exhausted: frozenset[int] = frozenset() continuing: frozenset[int] = frozenset()
[docs] class Accumulator(ABC, Generic[T]): """Base class for accumulators that run on the pump thread. Accumulators consume inputs in deterministic order, maintain any cross-invocation state (buffers/bins), and emit ready invocation batches. Contract for deterministic parallelism: - Operator-owned cross-invocation state must live in the accumulator - Worker `process_many` must be deterministic given its input batch - Accumulators are invoked only on the pump thread (no concurrent calls) """
[docs] @abstractmethod def push_many(self, elems: Sequence[T]) -> list[ReadyBatch[T]]: """Consume elements in stream order and return 0..N ready invocation batches. Args: elems: Input elements to accumulate. Returns: List of ready batches to dispatch to workers. May be empty if more elements are needed before a batch is ready. """
[docs] @abstractmethod def flush( self, *, reset: bool = False, lane_id: int | None = None ) -> list[ReadyBatch[T]]: """Emit any remaining ready batches. Called both at upstream close (default, ``reset=False``) and mid-stream by flush sentinels (``reset=True``). Mid-stream flushes must fully reset internal state so the accumulator is indistinguishable from a freshly constructed instance. Flush sentinels are injected **per lane**, so a mid-stream flush is scoped to ``lane_id``: only that lane's state is emitted and reset, leaving other lanes' epochs untouched. Flushing every lane at one lane's epoch boundary corrupts the others and breaks deterministic replay across checkpoint/restore. ``lane_id=None`` flushes all lanes (used at upstream close, when every lane is done). Args: reset: True when triggered by a flush sentinel mid-stream (the accumulator must reset to fresh state), False when called at upstream close. lane_id: When set, flush only this lane. When None, flush all lanes. Returns: List of remaining batches. These are typically partial batches that were waiting for more elements. """
@property def reads_payload(self) -> bool: """Whether ``push_many()`` may access element payloads. When True, the runner resolves lazy SHM payloads before calling ``push_many()``. Default False — most accumulators only read metadata (e.g. ``lane_id``). """ return False
[docs] def has_pending_data(self, lane_id: int | None = None) -> bool: """Return True if buffered data would be emitted on flush(). Default implementation returns False. Subclasses with internal buffers should override this to return True when they have pending data. Args: lane_id: When set, report only this lane's pending state. When None, report whether any lane has pending data. """ return False
[docs] def on_source_exhausted( self, lane_id: int, component_id: int ) -> list[ReadyBatch[T]]: """Handle an end-of-data (EOD) sentinel for a component in this lane. The component's source has permanently run out, but buffered records may still arrive, even through order-preserving operators. The default leaves buffers untouched. Called at most once per component per lane stream; some lanes close without an EOD sentinel. Reactions must be lane-scoped and deterministic for a given input order. Restore reconstructs runner state from replayed sentinels. Obtain the accumulator-only callback with ``ctx.get("get_source_exhaustion")``. It may be ``None`` during validation, which uses an empty context; use ``SourceExhaustion()`` (no observed exhaustion) in that case. Otherwise, calling it with ``lane_id`` returns a :class:`SourceExhaustion` snapshot of what this operator has seen: ``exhausted`` contains every component whose EOD sentinel reached it, and ``continuing`` is the subset the source permits continuing without, per ``WorkSource.continues_after_exhaustion()``. At runtime, the runner updates it before this hook and retains it across epoch resets. It is a cheap local call, available during any accumulator call, including after ``flush(reset=True)``. EOD sentinels are emitted regardless of continuation policy. See :doc:`/pipelines/user_defined_operators`. Return ready batches as in :meth:`push_many`. If dropping records, emit their tombstones with :func:`~zephon.ops.tombstones_for_record` in separate batches from data. """ return []
[docs] def try_epoch_reset( self, boundary_chunk_id: int, lane_id: int | None = None ) -> bool: """Attempt a delayed epoch reset for stalled flush sentinels. Called when a flush sentinel was stalled (held behind buffered data) and the runner wants to check whether the accumulator can now be reset. Returns True if and only if future outputs are equivalent to a fresh accumulator on the retained suffix — i.e., the accumulator's behavior going forward is indistinguishable from one that was freshly constructed and fed only the records still in the buffer. When returning True, the method must also perform any necessary state reset (e.g., clearing SWRR emission history). The default implementation is conservative: it returns True only when there is no pending data at all. Accumulators that buffer ``SampleRecord`` elements can override this to check ``chunk_id`` directly and return True as soon as no pre-boundary records remain, even if post-boundary records are still buffered. Args: boundary_chunk_id: The ``_boundary_cid`` from the stalled flush sentinel. Records with ``chunk_id < boundary_chunk_id`` are pre-boundary (old epoch). lane_id: The stalled sentinel's lane. When set, only this lane's buffered records gate the reset; other lanes are independent. Returns: True if the reset was performed and the sentinel can be released. False if pre-boundary records still exist in the buffer. """ return not self.has_pending_data(lane_id)
__all__ = ["Accumulator", "ReadyBatch", "SourceExhaustion"]