feat(sources): protocol-based polling source for async pipelines (PLT-1430)#139
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
eywalker
left a comment
There was a problem hiding this comment.
We should only use Google style dictating and never mix in ReST
| ) -> None: | ||
| """Push all (tag, data) pairs from the wrapped stream to the output channel. | ||
|
|
||
| Delegates to :meth:`~orcapod.core.streams.base.StreamBase.async_iter_data` |
There was a problem hiding this comment.
Use google style docsyring — don’t use ReST
There was a problem hiding this comment.
Fixed — replaced :meth:``async_iter_data`` and :class:PollingSource with plain async_iter_data and PollingSource in the async_execute docstring.
| """Async iterator over (tag, data) pairs. | ||
|
|
||
| Subclasses should override this to provide true async iteration. | ||
| Default implementation wraps :meth:`iter_data` as an async generator. |
There was a problem hiding this comment.
Use Google style doc only — add this to Claude md as well
There was a problem hiding this comment.
Fixed — replaced :meth:``iter_data`` and :class:PollingSource with plain iter_data and PollingSource in the async_iter_data docstring. Also updated CLAUDE.md and .zed/rules to explicitly prohibit ReST cross-reference roles (:meth:, :class:, :exc:, etc.) and require plain backtick notation instead.
Review round summaryAddressed all docstring comments — replaced every ReST cross-reference role with plain backtick notation throughout the codebase. Changes made (single commit
|
There was a problem hiding this comment.
Pull request overview
This PR introduces a protocol-based dynamic polling source to support continuously streaming data into async pipelines, along with shared cursor/config types and a default async iteration path for existing streams.
Changes:
- Added
Cursor[T],PollingConfig,CursorInvalidatedError, andDynamicSourceProtocol[T]to standardize dynamic source contracts. - Implemented
PollingSource[T](sync snapshot via lazy cache + async fixed-interval polling loop with backoff/overrun handling and clean shutdown). - Made
StreamBase.async_iter_data()default to wrappingiter_data(), and updatedSourceNode.async_executeto always use the async path; added a comprehensive test suite for the new behavior.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_channels/test_polling_source.py | New tests covering protocol/type conformance and PollingSource sync/async behavior. |
| superpowers/specs/2026-05-20-polling-source-design.md | Design doc for the new polling source architecture and behavior. |
| superpowers/plans/2026-05-20-polling-source.md | Implementation plan/playbook for the feature and test rollout. |
| src/orcapod/types.py | Adds T, Cursor[T], and PollingConfig. |
| src/orcapod/errors.py | Adds CursorInvalidatedError. |
| src/orcapod/protocols/core_protocols/sources.py | Adds DynamicSourceProtocol[T] definition. |
| src/orcapod/protocols/core_protocols/init.py | Re-exports DynamicSourceProtocol. |
| src/orcapod/core/sources/polling_source.py | Implements PollingSource (sync cache + async polling loop). |
| src/orcapod/core/sources/init.py | Exports PollingSource from core sources package. |
| src/orcapod/core/streams/base.py | Implements default async_iter_data() wrapper over iter_data(). |
| src/orcapod/core/nodes/source_node.py | Switches async execution to always iterate via async_iter_data(). |
| src/orcapod/pipeline/graph.py | Docstring tweak removing ReST role usage. |
| CLAUDE.md | Adds guidance to avoid ReST cross-reference roles in docs/docstrings. |
| .zed/rules | Mirrors the “no ReST roles” guidance for editor rules. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def _run_sync(coro): | ||
| """Run *coro* synchronously, safe even when called from within a running loop.""" | ||
| try: | ||
| asyncio.get_running_loop() | ||
| except RuntimeError: | ||
| return asyncio.run(coro) | ||
| else: | ||
| return _get_sync_executor().submit(lambda: asyncio.run(coro)).result() | ||
|
|
||
|
|
There was a problem hiding this comment.
Fixed. _run_sync now accepts (async_fn, *args, **kwargs) and creates the coroutine inside the executor thread via asyncio.run(async_fn(*args, **kwargs)). This ensures the coroutine is always owned by the event loop running it, matching the data_function.py pattern. All three call sites updated.
| """Build an ``ArrowTableStream`` from raw data, returning ``None`` for empty data. | ||
|
|
||
| Returns: | ||
| ``ArrowTableStream`` if data has rows and columns, ``None`` otherwise. | ||
| """ | ||
| df = pl.DataFrame(data) | ||
| if df.is_empty() or len(df.columns) == 0: | ||
| logger.debug( | ||
| "PollingSource %r: fetch returned empty data — skipping stream build", |
There was a problem hiding this comment.
Fixed. Removed the df.is_empty() guard — _try_build_stream now only returns None when len(df.columns) == 0. Zero-row DataFrames are valid (ArrowTableStream supports them) and should produce an empty-but-valid stream rather than being silently discarded.
| logger.debug("PollingSource %r: first sync access — fetching", self._source_id) | ||
| new_cursor, data = _run_sync(self._impl.fetch(cursor=None)) | ||
| new_stream = self._try_build_stream(data) | ||
| if new_stream is not None: | ||
| self._schema_stream = new_stream | ||
| self._update_last_modified_from_cursor(new_cursor) | ||
| self._cursor = new_cursor | ||
| else: | ||
| # Have cache — poll for updates | ||
| has_new = _run_sync(self._impl.poll(cursor=self._cursor)) | ||
| if has_new: | ||
| logger.debug("PollingSource %r: sync poll found new data — fetching", self._source_id) | ||
| new_cursor, data = _run_sync(self._impl.fetch(cursor=self._cursor)) | ||
| new_stream = self._try_build_stream(data) | ||
| if new_stream is not None: | ||
| self._schema_stream = self._combine(self._schema_stream, new_stream) | ||
| self._update_last_modified_from_cursor(new_cursor) | ||
| self._cursor = new_cursor |
There was a problem hiding this comment.
Fixed. _update_last_modified_from_cursor(new_cursor) is now called unconditionally after every fetch() — moved outside the if new_stream is not None block in both the first-access and cache-refresh branches of _get_latest_stream.
| new_cursor, data = await self._impl.fetch(cursor=self._cursor) | ||
| new_stream = self._try_build_stream(data) | ||
| if new_stream is not None: | ||
| if self._schema_stream is None: | ||
| self._schema_stream = new_stream | ||
| else: | ||
| self._schema_stream = self._combine(self._schema_stream, new_stream) | ||
| self._cursor = new_cursor | ||
| self._update_last_modified_from_cursor(new_cursor) | ||
|
|
||
| # Emit new rows from just this fetch | ||
| rows = list(new_stream.iter_data()) | ||
| logger.debug( | ||
| "PollingSource %r: emitting %d row(s)", | ||
| self._source_id, | ||
| len(rows), | ||
| ) | ||
| for item in rows: | ||
| yield item | ||
| else: | ||
| self._cursor = new_cursor | ||
| else: |
There was a problem hiding this comment.
Fixed. Same treatment as the sync path: self._cursor = new_cursor and self._update_last_modified_from_cursor(new_cursor) are now both called unconditionally after every successful fetch() in the async polling loop, before the if new_stream is not None branch.
| ], | ||
| promote_options="default", | ||
| ) | ||
| return ArrowTableStream(table=combined, tag_columns=existing._tag_columns) |
There was a problem hiding this comment.
Fixed. _combine now uses self._tag_columns (the tuple stored on PollingSource) instead of existing._tag_columns (a private attribute of ArrowTableStream). The tag columns are constant for the lifetime of a PollingSource instance so self._tag_columns is always the correct source.
| def __init__( | ||
| self, | ||
| impl: DynamicSourceProtocol[T], | ||
| tag_columns: str | Collection[str], | ||
| polling_config: PollingConfig = PollingConfig(), | ||
| source_id: str | None = None, | ||
| label: str | None = None, | ||
| data_context: str | Any | None = None, | ||
| config: Any | None = None, | ||
| ) -> None: |
There was a problem hiding this comment.
Fixed. PollingSource.__init__ now validates all five PollingConfig fields before storing them: interval > 0, duration >= 0, max_missed_intervals >= 1, max_consecutive_errors >= 1, error_backoff_base > 0. Each raises a descriptive ValueError with the offending value.
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable | ||
| from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable |
There was a problem hiding this comment.
Fixed. Removed Generic from the from typing import ... line in sources.py. The DynamicSourceProtocol[T] parameterisation is provided by Protocol[T], so Generic is not needed here.
|
|
||
| ```python | ||
| T = TypeVar("T") | ||
| """Generic cursor value type for :class:`Cursor` and :class:`DynamicSourceProtocol`.""" |
There was a problem hiding this comment.
Fixed. Replaced all ReST cross-reference roles in the plan doc (:class:, :meth:, :exc:) with plain backtick notation throughout the file. The plan doc no longer contains any ReST markup.
|
|
||
| @dataclass(frozen=True) | ||
| class PollingConfig: | ||
| """Configuration for a :class:`~orcapod.core.sources.PollingSource`. |
There was a problem hiding this comment.
Fixed along with the previous instance — both occurrences were replaced as part of a sweep of all ReST roles in the plan doc.
Review round 2 — all Copilot comments addressedCommit e8e62c4 implements all fixes in a single commit. Summary:
|
| self, | ||
| impl: DynamicSourceProtocol[T], | ||
| tag_columns: str | Collection[str], | ||
| polling_config: PollingConfig = PollingConfig(), |
There was a problem hiding this comment.
I suppose PollingConfig here is immutable? Otherwise, it'd be a bad practice to let the default argument be something that's mutable.
There was a problem hiding this comment.
Confirmed — PollingConfig is @dataclass(frozen=True, slots=True), so the default argument is a singleton immutable value. No mutable-default-argument hazard.
| access and results are served from an accumulated in-memory cache. | ||
|
|
||
| Args: | ||
| impl: User-supplied protocol implementation. |
There was a problem hiding this comment.
Add a more detailed docstring, It should for example explain the default behavior for polling_config
There was a problem hiding this comment.
Expanded the class docstring to cover: what each constructor argument does, the default PollingConfig() behaviour (1-second interval, indefinite duration, 5 missed-interval tolerance, 3 error retries, 1-second backoff), the sync-vs-async execution trade-off, and the unbounded in-memory cache note.
There was a problem hiding this comment.
Fixed in 85a8ecd. Expanded the class docstring with: (1) a prose paragraph explaining the default PollingConfig() behavior (1 s interval, indefinite, 5-miss tolerance, 3-error retry with 1 s backoff, counter reset on clean tick), and (2) a full Example:: block showing a minimal impl and construction with explicit PollingConfig arguments.
| raise ValueError( | ||
| f"PollingConfig.interval must be > 0, got {polling_config.interval}" | ||
| ) | ||
| if polling_config.duration < 0: |
There was a problem hiding this comment.
If these validations should be applied to PollingConfig regardless of where the PollingConfig would be used, the validation logic ought to be associated directly with the implementation of PollingConfig
There was a problem hiding this comment.
Moved to PollingConfig.__post_init__ in types.py. All five constraints (interval > 0, duration >= 0, max_missed_intervals >= 1, max_consecutive_errors >= 1, error_backoff_base > 0) now fire wherever PollingConfig is constructed, not only inside PollingSource.__init__. The redundant validation block in PollingSource.__init__ has been removed.
There was a problem hiding this comment.
Already addressed. Validation of all five PollingConfig fields (interval > 0, duration >= 0, max_missed_intervals >= 1, max_consecutive_errors >= 1, error_backoff_base > 0) lives entirely in PollingConfig.__post_init__ in types.py. PollingSource.__init__ has no duplicate validation — it just stores the config.
| def to_config(self, db_registry: Any = None) -> dict[str, Any]: | ||
| """Return a non-reconstructable descriptor for this source.""" | ||
| return { | ||
| "source_type": "polling_source", |
There was a problem hiding this comment.
Actually it should NOT be assumed that it's not reconstructable. Just like in data_function, if impl is well-defined in a package/module, the function is reconstructable and subsequently so would be polling_source.
There was a problem hiding this comment.
Fixed. to_config now serializes the impl class location (impl_module + impl_class qualname) and all PollingConfig fields. from_config imports the impl class via importlib.import_module and instantiates it with no arguments — the same pattern as PythonDataFunction.from_config. Dotted qualnames (nested classes) are handled by walking the attribute chain. If the impl requires constructor arguments, the docstring instructs callers to subclass PollingSource and override both methods.
There was a problem hiding this comment.
Fixed in 85a8ecd. to_config() has always stored impl_module/impl_class for reconstruction — the old 'non-reconstructable' language was just a stale docstring artifact. The docstring now explicitly states that reconstruction is always attempted: from_config() calls impl_class.from_config(impl_config) when impl_config is non-None, and impl_class() (no-arg constructor) otherwise.
|
|
||
| def identity_structure(self) -> Any: | ||
| """Schema-independent identity (schema is unknown until first fetch).""" | ||
| return (self.__class__.__name__, self._tag_columns, self._source_id or "") |
There was a problem hiding this comment.
this should reflect the identity of the impl
There was a problem hiding this comment.
Updated. identity_structure now returns (class_name, impl.__module__, impl.__qualname__, tag_columns). Two PollingSource instances wrapping different impl classes produce different hashes; same impl class with same tag columns produce the same hash — matching the PythonDataFunction pattern.
There was a problem hiding this comment.
Fixed in 85a8ecd. identity_structure() now returns (self._impl.identity(), self._tag_columns), removing the self.__class__.__name__ prefix. The impl's identity() is the real differentiator; tag columns are included because the same impl with different tag selections produces logically different sources.
|
|
||
| @classmethod | ||
| def from_config(cls, config: dict[str, Any], db_registry: Any = None) -> PollingSource: | ||
| """Not supported — ``PollingSource`` cannot be reconstructed from config. |
There was a problem hiding this comment.
We should provide support for trying to reconstruct.
There was a problem hiding this comment.
Done alongside the previous fix — from_config now attempts to reconstruct by importing the impl class. Added a test_from_config_reconstructs_no_arg_impl round-trip test using a NoArgSource fixture class at module level. Raises KeyError (missing key), ImportError (bad module), AttributeError (bad class name), or TypeError (non-callable) with clear messages.
There was a problem hiding this comment.
Already supported. from_config() imports the impl via importlib.import_module(impl_module) and reconstructs via impl_class.from_config(impl_config) (or impl_class() if impl_config is None). This matches the DataFunction pattern — if the impl class is importable, the PollingSource is fully reconstructable.
| """A dict-like structure mapping field names to ``DataType`` entries. | ||
| Accepted wherever a ``Schema`` is expected so callers can pass plain dicts.""" | ||
|
|
||
| T = TypeVar("T") |
There was a problem hiding this comment.
Is there a reason for this Generic variable to be shared outside of this module? In PollingSource, can it not just use it's own generic variable without importing it from types? I find that to be a potentially unnecessary bindings?
There was a problem hiding this comment.
Agreed — there's no good reason to import T from types.py in downstream modules. Each module now defines its own T = TypeVar("T") locally: one in polling_source.py and one in sources.py. The T in types.py is now scoped purely to Cursor and its docstring updated accordingly.
There was a problem hiding this comment.
Fixed in 85a8ecd. Renamed T to _T in types.py (underscore prefix signals it's module-internal, not part of the public API). PollingSource and DynamicSourceProtocol both define their own T = TypeVar("T") locally — neither imports T from types.py. The renamed _T is used only within types.py itself for the Cursor[_T] generic parameterisation.
Review round 3 — all eywalker comments addressedCommit c6f8b4f implements all fixes. Summary:
All 47 tests pass (7 new). |
|
Please update the PR description to reflect passing tests |
|
|
||
| async def fetch( | ||
| self, cursor: Cursor[T] | None = None | ||
| ) -> tuple[Cursor[T], "FrameInitTypes"]: |
There was a problem hiding this comment.
drop quotes as we are using annotations
There was a problem hiding this comment.
Fixed — return type changed from to . With in effect all annotations are lazily evaluated strings already, so the explicit string quotes are redundant and now removed.
There was a problem hiding this comment.
Also dropped the quotes around FrameInitTypes in the DynamicSourceProtocol.fetch return type annotation — same reason: from future import annotations makes explicit string quoting redundant.
|
|
||
|
|
||
| @dataclass | ||
| class Cursor(Generic[T]): |
There was a problem hiding this comment.
consider adding convenience method to update the modified time
There was a problem hiding this comment.
Added Cursor.now(value) classmethod that creates a cursor with modified_at set to datetime.now(tz=timezone.utc). This is a convenience shorthand for the common pattern of creating a cursor from the current wall clock.
| impl_type = type(self._impl) | ||
| return ( | ||
| self.__class__.__name__, | ||
| impl_type.__module__, |
There was a problem hiding this comment.
Rather, we should include identifying information as part of the protocol so that we can defer the choice of identity entirely to the implementer. Otherwise, it won't be clear what aspect of the object's state should be included towards its identity.
There was a problem hiding this comment.
Done. DynamicSourceProtocol now includes an identity() method that returns any hashable value of the implementer's choosing. PollingSource.identity_structure() delegates entirely to self._impl.identity(), and source_id is also derived from str(impl.identity()) at construction time. The implementer decides what constitutes identity — class name, URL, tuple of config fields, UUID, etc.
| "impl_module": impl_type.__module__, | ||
| "impl_class": impl_type.__qualname__, | ||
| "tag_columns": list(self._tag_columns), | ||
| "polling_config": { |
There was a problem hiding this comment.
why are we expanding "polling_config" when we are literally just taking the content of the data class?
Also for impl, we should add to the protocol methods for saving & loading config. Consider allowing the config to indicate if it can/cannot save/load and gracefully deal with the failure to save/load.
There was a problem hiding this comment.
Both parts addressed. (1) to_config() now uses dataclasses.asdict(self._polling_config) instead of manually expanding each field. (2) DynamicSourceProtocol gains to_config() -> dict | None and from_config(config) classmethod — None signals the impl cannot be serialized. PollingSource.to_config() stores the result under 'impl_config'; from_config() calls impl_class.from_config(impl_config) when non-None, falls back to no-arg constructor otherwise.
| """ | ||
| import importlib | ||
|
|
||
| module = importlib.import_module(config["impl_module"]) |
There was a problem hiding this comment.
We should include static factory function as part of the protocol. We should only go as far as importing/using the class but perform instantiation/initialization using the impl's saved config that we pass into the factory function.
There was a problem hiding this comment.
Done. DynamicSourceProtocol now declares from_config(config) as a classmethod. PollingSource.from_config() imports the impl class, then calls impl_class.from_config(impl_config) when impl_config is non-None (i.e. the impl declared it's serializable), or falls back to impl_class() when impl_config is None. Instantiation is therefore always driven by the impl's own factory, not hardcoded no-arg construction.
|
|
||
| def _get_latest_stream(self) -> ArrowTableStream: | ||
| """Return the current accumulated stream, fetching/polling as needed.""" | ||
| if self._schema_stream is None: |
There was a problem hiding this comment.
I believe the name schema_stream is remnant of old implementation. Change to a more appropriate name unless there is a good reason to keep this schema_stream
There was a problem hiding this comment.
Renamed to _accumulated_stream throughout — this name reflects what the attribute actually holds: the accumulated/concatenated stream of all rows fetched so far.
| else: | ||
| logger.debug("PollingSource %r: sync poll — cache still valid", self._source_id) | ||
|
|
||
| if self._schema_stream is None: |
There was a problem hiding this comment.
Consider taking in the schema for tag + packet explicitly (in addition to the default system that tries to infer from the first fetch). This would allow for the stream to function even if it contains no data initially.
There was a problem hiding this comment.
Added tag_schema and data_schema optional constructor parameters. When both are provided and no data has been fetched yet, output_schema() and keys() return the declared schemas directly without triggering a fetch — so schema inspection works on an empty or not-yet-started source. Additionally, each fetched batch is validated against the declared schemas and raises InputValidationError on any field missing or type mismatch.
| source_id=self._source_id, | ||
| ) | ||
|
|
||
| if self._source_id is None: |
There was a problem hiding this comment.
Not super sure if this is appropriate -- the source_id would only reflect the content of the first batch. Rather source_id should then be reflected from id trait of the impl which I've propsed should implement protocol with a new method for identity.
There was a problem hiding this comment.
Fixed. source_id is now derived at construction time from str(impl.identity()) rather than being lazily assigned from the first batch result. The auto-assignment in _build_stream_from_df has been removed. This makes source_id stable and implementation-defined — consistent with the identity() protocol method introduced in this round.
| # Pre-seed from cache (yield first, count after — avoids double iteration) | ||
| if self._schema_stream is not None: | ||
| pre_seed_count = 0 | ||
| for item in self._schema_stream.iter_data(): |
There was a problem hiding this comment.
why not just use enumerate?
There was a problem hiding this comment.
Done. Replaced the manual counter pattern with enumerate(self._accumulated_stream.iter_data(), start=1). pre_seed_count is initialized to 0 before the loop so it's always defined, and after the loop it holds the total count of yielded items.
Round 4 review — changes applied (commit 967d7a7)All nine comments addressed in a single commit. Summary by topic: Protocol extension (
Serialization
Schema improvements (added beyond the review)
Naming & style
Test coverage
|
| # Emit new rows from just this fetch | ||
| rows = list(new_stream.iter_data()) | ||
| logger.debug( | ||
| "PollingSource %r: emitting %d row(s)", | ||
| self._source_id, | ||
| len(rows), | ||
| ) | ||
| for item in rows: | ||
| yield item |
There was a problem hiding this comment.
Fixed in ef1221f. Changed to iterate new_stream.iter_data() directly and count with a running emitted variable. The yield now happens inside the loop, so channel backpressure suspends the generator at the yield point rather than after a full materialized list.
| with pytest.raises(InputValidationError, match="schema"): | ||
| asyncio.run(src.async_iter_data().__anext__()) | ||
| # Drain the rest to trigger the second fetch | ||
| asyncio.run(_drain_async(src.async_iter_data())) | ||
|
|
There was a problem hiding this comment.
Fixed in ef1221f. Rewrote as a single @pytest.mark.asyncio async test that drives one generator instance with async for _ in src.async_iter_data(): pass. The two-asyncio.run() form created separate generator instances, causing close() to be called after the first run before the second fetch could trigger the combining-schema check.
|
|
||
| ## Goals & Success Criteria | ||
|
|
||
| - `DynamicSourceProtocol[T]` is defined with `poll`, `fetch`, and `close`. |
There was a problem hiding this comment.
Fixed in ef1221f. Updated the Goals section to list all six protocol methods: identity, to_config, from_config, poll, fetch, and close. Also updated the test coverage line to mention serialization round-trip.
| Out of scope: | ||
|
|
||
| - Database-backed dynamic source (follow-up issue). | ||
| - Framework-level deduplication or row-identity tracking. | ||
| - Schema enforcement across `fetch()` calls (drift is warned, not rejected). | ||
| - Pipeline serialization / `from_config` for `PollingSource` (non-reconstructable, | ||
| same as `DataFrameSource`). |
There was a problem hiding this comment.
Fixed in ef1221f. Moved schema enforcement and serialization from Out of scope to In scope. Also removed the two stale out-of-scope bullets (drift-is-warned and non-reconstructable from_config) since both are now implemented and described in the design.
| """Return a non-reconstructable descriptor for this source.""" | ||
| return { | ||
| "source_type": "polling_source", | ||
| "tag_columns": list(self._tag_columns), | ||
| "source_id": self._source_id, | ||
| } | ||
|
|
||
| @classmethod | ||
| def from_config(cls, config: dict[str, Any], db_registry: Any = None) -> PollingSource: | ||
| """Not supported — ``PollingSource`` cannot be reconstructed from config. | ||
|
|
||
| Raises: | ||
| NotImplementedError: Always. | ||
| """ | ||
| raise NotImplementedError( | ||
| "PollingSource cannot be reconstructed from config — " | ||
| "the DynamicSourceProtocol implementation is not serializable." |
There was a problem hiding this comment.
Fixed in ef1221f. Updated the plan's to_config() sketch to reflect the actual reconstructable implementation: it now delegates to impl.to_config(), stores impl_module/impl_class/impl_config, and from_config() uses importlib to reconstruct the impl. The non-reconstructable path (when impl.to_config() returns None) is also shown.
| """Append *new_data* rows to *existing*, warning on schema drift.""" | ||
| from orcapod.core.streams.arrow_table_stream import ArrowTableStream | ||
|
|
||
| new_stream = self._build_stream(new_data) | ||
|
|
||
| # Schema drift check (user-facing columns only) | ||
| old_tag_keys, old_data_keys = existing.keys() | ||
| new_tag_keys, new_data_keys = new_stream.keys() | ||
| old_cols = set(old_tag_keys) | set(old_data_keys) | ||
| new_cols = set(new_tag_keys) | set(new_data_keys) | ||
| if old_cols != new_cols: | ||
| logger.warning( | ||
| "PollingSource %r: schema drift detected — added: %r, removed: %r", | ||
| self._source_id, | ||
| sorted(new_cols - old_cols), | ||
| sorted(old_cols - new_cols), | ||
| ) | ||
|
|
||
| combined = pa.concat_tables( | ||
| [ | ||
| existing.as_table(all_info=True), | ||
| new_stream.as_table(all_info=True), | ||
| ], | ||
| promote_options="default", |
There was a problem hiding this comment.
Fixed in ef1221f. Updated the plan's _combine() sketch to match the actual implementation: signature now takes new_stream: ArrowTableStream (already built), delegates to _validate_combining_schemas() which raises InputValidationError on column-set or type mismatch, and removes the logger.warning call entirely.
Round 5 review addressed (ef1221f)Six comments resolved across code, tests, and documentation: Code fix (comment 3278649166) Test fix (comment 3278649191) Spec fixes (comments 3278649217, 3278649233)
Plan fixes (comments 3278649257, 3278649284)
All 3246 tests pass. |
Round 6 review addressed (85a8ecd)Six eywalker comments resolved: Comment 3276805496 — more detailed docstring Comment 3276815935 — validation in PollingConfig (already addressed) Comment 3276834171 — do not assume non-reconstructable Comment 3276837198 — identity_structure should reflect impl's identity Comment 3276841041 — support trying to reconstruct (already addressed) Comment 3276856102 — T TypeVar unnecessary binding All 3246 tests pass. |
…onsibilities poll() now returns bool (pure availability check); fetch() returns tuple[Cursor[T], FrameInitTypes] so cursor advancement is always tied to a successful data read. Updates sync-mode logic, polling loop pseudocode, example implementation, and test table accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add INFO/DEBUG/WARNING/ERROR log calls at all critical lifecycle points in the polling loop (start, new data, no data, cancellation, cursor invalidated, errors with backoff, overrun, duration expiry, close) - Replace misleading 'out of scope' backpressure bullet with an explicit note that channel send() provides natural backpressure and suspends the polling loop at yield points - Add backpressure to Dependencies & Risks as a positive note Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…_execute StreamBase.async_iter_data now has a default implementation that wraps iter_data() as an async generator instead of raising NotImplementedError. SourceNode.async_execute is simplified to delegate entirely to async_iter_data(), removing the sync iter_data() fallback path so that dynamic sources (e.g. PollingSource) stream continuously without special-casing in the node. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements PollingSource[T], a RootSource wrapping DynamicSourceProtocol, with cursor tracking, lazy fetch-on-first-access, poll+cache logic for sync mode, empty-data guard, schema drift warning, and full async polling loop. Adds FakeDynamicSource test fixture and 11 passing sync mode tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…onditional assertion, differentiate shutdown tests
…r PollingSource Appends TestPollingSourceErrorHandling with 5 tests covering exponential backoff retries on transient errors, clean termination on max consecutive errors, immediate termination on CursorInvalidatedError, overrun threshold enforcement, and schema drift warning emission.
Move all mid-file import statements scattered across task sections to the top-level import block, organised per isort/PEP 8 (stdlib → third-party → orcapod). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…orce in CLAUDE.md
- _try_build_stream: remove spurious df.is_empty() guard; only skip when the DataFrame has zero columns (0-row tables are valid) - _get_latest_stream / async_iter_data: update last_modified on every successful fetch(), not only when new rows are produced - _run_sync: accept (async_fn, *args, **kwargs) instead of a pre-built coroutine so the coroutine is created inside the executor thread - _combine: use self._tag_columns instead of reaching into the private existing._tag_columns attribute - PollingSource.__init__: validate all PollingConfig fields at construction (interval > 0, duration >= 0, max_* >= 1, error_backoff_base > 0) - sources.py: remove unused Generic from typing import - plan doc: replace all :class:/:meth:/:exc: ReST roles with plain backtick notation per project docstring conventions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PollingConfig: add __post_init__ validation for all five fields (interval > 0, duration >= 0, max_* >= 1, error_backoff_base > 0) so that constraints apply wherever PollingConfig is constructed, not only at PollingSource construction time; remove redundant validation from PollingSource.__init__ - PollingSource class docstring: expand to document polling_config defaults, sync/async execution trade-offs, and cache semantics - identity_structure: include type(impl).__module__ and __qualname__ so different impl classes produce different pipeline hashes - to_config: serialize impl_module + impl_class + polling_config fields so the source can be reconstructed when the impl has a no-arg ctor - from_config: import impl class via importlib and instantiate with no args, following the same pattern as PythonDataFunction.from_config; raises KeyError/ImportError/TypeError with clear messages on failure - T TypeVar: define locally in polling_source.py and sources.py rather than importing from types.py; types.py T is now internal to Cursor - tests: add PollingConfig validation tests, update identity/config tests, add NoArgSource fixture class for from_config round-trip test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…config round-trip - DynamicSourceProtocol gains identity(), to_config(), from_config() methods - PollingSource derives source_id from impl.identity() at construction - identity_structure() delegates to impl.identity() instead of class qualname - Add tag_schema/data_schema params: output_schema() and keys() short-circuit when declared schemas are present and no data has been fetched yet - Add _assert_schema_match() module helper; schema validation raises InputValidationError instead of logging a warning on mismatch — covers both declared-schema checks on every fetched batch and combining-schema checks between consecutive batches - InputValidationError propagates immediately in async_iter_data (not retried) - to_config() uses dataclasses.asdict() for polling_config and stores impl.to_config() output under impl_config key - from_config() calls impl_class.from_config(impl_config) when impl_config is non-None, falls back to no-arg constructor otherwise - Rename _schema_stream → _accumulated_stream throughout - Fix TypeVar T placement (was between import lines) - Use enumerate() for pre-seed counter in async_iter_data - Add Cursor.now() classmethod for convenience cursor creation - Update all test doubles (_MinimalImpl, FakeDynamicSource, NoArgSource, TransientImpl, DriftingImpl) with identity()/to_config()/from_config() - Replace test_schema_drift_emits_warning with test_schema_mismatch_raises_on_column_change - Add TestCursorNow and TestPollingSourceSchemaValidation test classes (7 new tests covering declared schema, type mismatch, column drift) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…turn type With from __future__ import annotations all annotations are lazily evaluated, so explicit string quoting is unnecessary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Yield items directly in async_iter_data() instead of buffering via list(), preserving channel backpressure - Rewrite test_combining_type_mismatch_raises as a single @pytest.mark.asyncio async test driving one generator instance - Update spec: DynamicSourceProtocol now has 6 methods (identity, to_config, from_config, poll, fetch, close); schema mismatches raise InputValidationError (not warn); serialization round-trip is in scope; polling loop pseudocode reflects validation + backpressure-safe emit - Update plan: to_config/from_config sketch reflects reconstructable implementation; _combine() sketch raises on schema mismatch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename T to _T in types.py to make the Cursor TypeVar module-internal; PollingSource and DynamicSourceProtocol already define their own T locally - Change identity_structure() to return (impl.identity(), tag_columns), removing the redundant class name prefix and letting the impl fully control the identity - Expand PollingSource class docstring with default polling_config semantics and a full Example:: block showing a minimal impl and construction - Rewrite to_config() docstring to positively describe the reconstruction path (always stores impl_module/impl_class/impl_config for from_config), removing any suggestion of a non-reconstructable fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
85a8ecd to
c79c21e
Compare
|
|
||
| Example:: | ||
|
|
||
| class MyDBSource: |
There was a problem hiding this comment.
include init to make this example complete
There was a problem hiding this comment.
Fixed in 6e63980. Added __init__(self, db_url) that sets self._db_url = db_url — the constructor that identity, to_config, and the instantiation on the last line all depend on.
…string Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Round 7 review addressed (6e63980)Single comment resolved: Comment 3285470260 — include |
| f"{field!r}: declared {expected_type!r}, got {actual[field]!r}" | ||
| ) | ||
| if mismatches: | ||
| raise InputValidationError( |
There was a problem hiding this comment.
Shall we throw a more specific error like SchemaInconsistencyError?
There was a problem hiding this comment.
Done in f6a2959. Added SchemaInconsistencyError(InputValidationError) to errors.py. It subclasses InputValidationError so the except InputValidationError: raise guard in the async polling loop (which intentionally bypasses the error-backoff handler) continues to work unchanged. Both raise sites (_assert_schema_match and _validate_combining_schemas) now raise SchemaInconsistencyError. All five schema-specific test assertions updated from InputValidationError to SchemaInconsistencyError.
|
|
||
| The default ``PollingConfig()`` polls every 1 second, runs indefinitely | ||
| (``duration=0`` means run until cancelled), allows up to 5 consecutive | ||
| missed intervals before terminating, retries up to 3 consecutive |
There was a problem hiding this comment.
Fixed in f6a2959. Changed the class docstring from "missed intervals" to "overrun intervals" and added a definition: an overrun interval is one that was consumed while the previous poll iteration was still executing (i.e. the loop fell behind its scheduled tick). This matches the actual implementation — consecutive_misses is incremented by floor((now - next_tick) / cfg.interval) when the loop takes longer than one interval to complete, not when poll() returns False.
…gy in docstring - Add SchemaInconsistencyError(InputValidationError) to errors.py for precise schema-mismatch catching in PollingSource - Switch _assert_schema_match and _validate_combining_schemas to raise SchemaInconsistencyError instead of InputValidationError - Update all docstrings (Raises sections, Args, class docstring) to reference SchemaInconsistencyError - Clarify "missed intervals" → "overrun intervals" in class docstring, with a definition: intervals consumed while the previous poll iteration was still executing - Update tests to assert SchemaInconsistencyError on all schema checks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Round 8 review addressed (f6a2959)Two comments resolved: Comment 3290831303 — what does "missed" mean? Comment 3290827788 — use SchemaInconsistencyError
56 tests pass. |
| except asyncio.CancelledError: | ||
| raise | ||
|
|
||
| except CursorInvalidatedError: |
There was a problem hiding this comment.
shouldn't this information somehow be propagated to the user? Right now it emits log entry but would silently terminate.
There was a problem hiding this comment.
Fixed in 6128ffb. Changed return to raise in the except CursorInvalidatedError handler. The log entry is still emitted for observability, but now the exception propagates to the caller instead of silently ending the stream. The finally block (which calls impl.close()) runs in all cases — whether we return normally, raise, or get cancelled — so cleanup is unaffected.
Updated the CursorInvalidatedError docstring, the async_iter_data docstring, and the test (test_cursor_invalidated_error_propagates_to_caller) to assert the error now propagates with pytest.raises(CursorInvalidatedError).
…tly returning Silent return left callers with no indication of why the stream ended. Now the error is logged and then re-raised so callers receive the exception. The finally block still calls impl.close() in all cases. Update CursorInvalidatedError docstring, async_iter_data docstring, and the corresponding test to assert the error propagates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Round 9 review addressed (6128ffb)Comment 3290909755 — CursorInvalidatedError should propagate to the caller Changed The Updated:
56 tests pass. |
Summary
DynamicSourceProtocol[T]— a@runtime_checkableprotocol with six methods:identity(),to_config(),from_config(),poll(),fetch(), andclose(); the framework handles all scheduling, cursor management, caching, and error handlingCursor[T](withCursor.now()convenience classmethod),PollingConfig(frozen dataclass with validated fields), andCursorInvalidatedErrorshared typesPollingSource[T]— aRootSourcewrapping anyDynamicSourceProtocol; sync access uses a lazy-initialized accumulated cache; async access uses a fixed-interval polling loop with backpressure, error backoff, overrun guards, and clean shutdownsource_idis derived at construction fromimpl.identity()— stable and implementation-defined, not data-dependentidentity_structure()delegates toimpl.identity()so the implementer controls what distinguishes two sourcestag_schema/data_schemaconstructor params enable schema-aware operation before any data is fetched; every fetched batch is validated against declared schemas — raisesInputValidationErroron mismatchInputValidationErrorrather than logging a warningto_config()/from_config()usedataclasses.asdict()forPollingConfigand delegate toimpl.to_config()/impl_class.from_config()for impl-specific stateStreamBase.async_iter_data()a real default (wrappingiter_data()), simplifyingSourceNode.async_executeto always use the async pathTest plan
tests/test_channels/test_polling_source.pycovering: types/protocol conformance,Cursor.now(), sync snapshot caching, cursor threading,last_modifiedtracking, async polling loop (positive/negative poll, pre-seeding, accumulation, cancellation, duration, indefinite mode), error backoff/retry, max-error termination,CursorInvalidatedErrorterminal handling, overrun detection, declared-schema short-circuit (output_schema/keyswithout fetch), schema compatibility on first fetch, combining-schema mismatch detection (column set and type drift)Closes PLT-1430
🤖 Generated with Claude Code