Skip to content

Commit 61ecc4e

Browse files
authored
Merge pull request #34 from Point72/feat/probe-source
feat: add `.piot.probe()` pass-through telemetry source
2 parents 363a4f8 + 2f63d5f commit 61ecc4e

5 files changed

Lines changed: 176 additions & 1 deletion

File tree

polars_io_tools/io_sources/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .lazy_debug import debug, debug as _lazy_debug
1919
from .lazy_iter_rows import *
2020
from .lazy_narwhals_reader import *
21+
from .lazy_probe import probe, probe as _lazy_probe
2122
from .lazy_sql_reader import *
2223
from .pushdown_combine import *
2324
from .pushdown_pivot import *
@@ -69,6 +70,10 @@ def __init__(self, lf: pl.LazyFrame) -> None:
6970
def debug(self, *args, **kwargs) -> pl.LazyFrame:
7071
return _lazy_debug(self._lf, *args, **kwargs)
7172

73+
@functools.wraps(_lazy_probe)
74+
def probe(self, *args, **kwargs) -> pl.LazyFrame:
75+
return _lazy_probe(self._lf, *args, **kwargs)
76+
7277
@functools.wraps(_lazy_cache)
7378
def cache(self, *args, **kwargs) -> pl.LazyFrame:
7479
if self._DISABLE_OPTIMIZATIONS:

polars_io_tools/io_sources/lazy_debug.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ def debug(
1515
self: pl.LazyFrame,
1616
log_level: int | None = None,
1717
description: str | None = None,
18+
*,
19+
is_pure: bool = False,
1820
) -> pl.LazyFrame:
1921
"""
2022
A very simple pass-through lazy frame source to help with debugging experimentation of polars io sources and lazy frame behavior.
@@ -23,6 +25,7 @@ def debug(
2325
self: The input data frame to cache columns of.
2426
log_level: If provided, will log at the given level. If None, will print. Defaults to None.
2527
description: Optional free-form description of this source instance, attached to its OpenTelemetry span (``explain_detail``).
28+
is_pure: Whether the wrapped input is pure (deterministic and side-effect-free). Defaults to ``False`` so repeated uses are not deduplicated -- ``debug`` logs on every execution, so collapsing executions would drop output you asked for. Set ``True`` only for a known-pure input where deduplication is wanted.
2629
"""
2730
schema = self.collect_schema()
2831

@@ -55,4 +58,4 @@ def source_generator(
5558
raise RuntimeError(err_msg) from e
5659

5760
# TODO: Turn on validate_schema when this is solved: https://github.com/pola-rs/polars/issues/22110
58-
return register_io_source_with_is_pure(source_generator, schema=schema, validate_schema=False, explain_detail=description)
61+
return register_io_source_with_is_pure(source_generator, schema=schema, is_pure=is_pure, validate_schema=False, explain_detail=description)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from collections.abc import Iterator
2+
3+
import polars as pl
4+
5+
from .util import collect_lf_in_io_source, register_io_source_with_is_pure
6+
7+
__all__ = ("probe",)
8+
9+
10+
def probe(self: pl.LazyFrame, description: str | None = None, *, is_pure: bool = False) -> pl.LazyFrame:
11+
"""A result-preserving pass-through source that emits one OpenTelemetry span per execution.
12+
13+
Inserting ``.piot.probe()`` at a point in a lazy pipeline turns that point into a measured
14+
``io_source.execute[probe]`` span: ``next_elapsed_total_ms`` is the time spent pulling the sub-plan
15+
below the probe and ``total_rows`` is the number of rows that flow through it. Predicate and
16+
projection pushdown are forwarded to the input, so the probe is not an optimization barrier for
17+
them. It does add a ``PythonScan`` node that re-enters Polars via ``collect_lf_in_io_source``, so it
18+
is a measurement point rather than a zero-cost tap.
19+
20+
Unlike ``debug``, it performs no logging and does not compute ``explain()``, so it is cheap enough to
21+
leave in a pipeline. The span is a no-op unless an OpenTelemetry SDK is configured (see
22+
``polars_io_tools.io_sources.profiling``).
23+
24+
Overhead is on the order of milliseconds and grows with the number of rows passing through the probe
25+
(each batch makes a Rust-Python round trip); it is negligible relative to any non-trivial read but can
26+
dominate a small, fully-cached one. The overhead is the io-source boundary itself, not the telemetry.
27+
Under a single-worker Polars pool the input is materialized in full before the first batch (see
28+
``collect_lf_in_io_source``), so memory and latency can be higher than the streaming path.
29+
``next_elapsed_total_ms`` is caller-observed pull latency, so it can undercount a reader that decodes
30+
on background threads.
31+
32+
Args:
33+
self: The input LazyFrame to pass through unchanged.
34+
description: Optional free-form description of this probe instance, attached to its OpenTelemetry span (``explain_detail``) -- e.g. the pipeline stage being measured.
35+
is_pure: Whether the wrapped input is pure (deterministic and side-effect-free). Defaults to ``False``: a pass-through cannot infer its input's purity, and ``False`` preserves results for an impure input and measures every occurrence. Set ``True`` when the input is known pure to let Polars deduplicate repeated uses of the same probe (one execution, one span) -- i.e. to propagate a pure input's dedup-ability through the probe.
36+
37+
Returns:
38+
pl.LazyFrame: A LazyFrame equivalent to ``self`` whose execution emits one telemetry span.
39+
"""
40+
41+
def source_generator(
42+
with_columns: list[str] | None,
43+
predicate: pl.Expr | None,
44+
n_rows: int | None,
45+
batch_size: int | None,
46+
) -> Iterator[pl.DataFrame]:
47+
df = self
48+
if predicate is not None:
49+
df = df.filter(predicate)
50+
if with_columns is not None:
51+
df = df.select(with_columns)
52+
if n_rows is not None:
53+
df = df.head(n_rows)
54+
yield from collect_lf_in_io_source(df, batch_size)
55+
56+
# The probe's purity is its input's purity, which it cannot infer, so ``is_pure`` defaults to ``False``:
57+
# claiming purity for an impure input would let Polars deduplicate repeated uses and change results. The
58+
# schema is passed as a callable so it is resolved lazily rather than forced at construction.
59+
return register_io_source_with_is_pure(
60+
source_generator, schema=self.collect_schema, is_pure=is_pure, validate_schema=False, explain_detail=description
61+
)

polars_io_tools/tests/io_sources/test_lazy_debug.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,12 @@ def test_debug_filter(caplog):
4343
assert "debug called with" in caplog.text
4444
assert "`with_columns=None`" in caplog.text
4545
assert '`predicate=[(col("a")) > (1)]`' in caplog.text
46+
47+
48+
def test_debug_logs_per_use_by_default(caplog):
49+
# is_pure defaults to False, so reusing the same debug is not deduplicated: its log-side-effect runs per occurrence.
50+
df = pl.DataFrame({"a": [1]}).lazy()
51+
debug = df.piot.debug(log_level=logging.INFO)
52+
caplog.set_level(logging.INFO)
53+
pl.concat([debug, debug]).collect(engine="streaming")
54+
assert caplog.text.count("debug called with") == 2
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import polars as pl
2+
import pytest
3+
from opentelemetry import trace
4+
from opentelemetry.sdk.trace import TracerProvider
5+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
6+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
7+
from polars.testing import assert_frame_equal
8+
9+
import polars_io_tools # noqa: F401 -- registers the `.piot` namespace
10+
from polars_io_tools.io_sources.util import register_io_source_with_is_pure
11+
12+
_EXPORTER = InMemorySpanExporter()
13+
14+
15+
@pytest.fixture(scope="module", autouse=True)
16+
def _otel_provider():
17+
provider = trace.get_tracer_provider()
18+
if not hasattr(provider, "add_span_processor"):
19+
trace.set_tracer_provider(TracerProvider())
20+
provider = trace.get_tracer_provider()
21+
if hasattr(provider, "add_span_processor"):
22+
provider.add_span_processor(SimpleSpanProcessor(_EXPORTER))
23+
yield
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def _reset():
28+
_EXPORTER.clear()
29+
yield
30+
31+
32+
def _probe_spans():
33+
return [s for s in _EXPORTER.get_finished_spans() if s.name.startswith("io_source.execute[probe]")]
34+
35+
36+
def test_probe_is_result_preserving_passthrough():
37+
df = pl.DataFrame({"id": [1, 2, 3], "v": [10, 20, 30]})
38+
out = df.lazy().piot.probe().collect(engine="streaming")
39+
assert_frame_equal(out, df)
40+
41+
42+
def test_probe_forwards_n_rows_pushdown():
43+
out = pl.LazyFrame({"id": list(range(100))}).piot.probe().head(5).collect(engine="streaming")
44+
assert out["id"].to_list() == [0, 1, 2, 3, 4]
45+
assert _probe_spans()[-1].attributes["polars_io_tools.total_rows"] == 5
46+
47+
48+
def test_probe_does_not_dedupe_impure_input():
49+
# A pass-through cannot claim purity: reusing the same probe over an impure input must not collapse executions.
50+
state = {"n": 0}
51+
52+
def impure(with_columns, predicate, n_rows, batch_size):
53+
state["n"] += 1
54+
yield pl.DataFrame({"run": [state["n"]]})
55+
56+
probe = register_io_source_with_is_pure(impure, schema=pl.Schema({"run": pl.Int64}), is_pure=False).piot.probe()
57+
out = pl.concat([probe, probe]).collect(engine="streaming")
58+
assert sorted(out["run"].to_list()) == [1, 2]
59+
60+
61+
def test_probe_emits_one_span_with_description():
62+
df = pl.DataFrame({"id": [1, 2, 3]})
63+
df.lazy().piot.probe(description="stage1").collect(engine="streaming")
64+
65+
spans = _probe_spans()
66+
assert len(spans) == 1
67+
span = spans[0]
68+
assert span.attributes["polars_io_tools.explain_name"] == "probe"
69+
assert span.attributes["polars_io_tools.explain_detail"] == "stage1"
70+
assert span.attributes["polars_io_tools.total_rows"] == 3
71+
72+
73+
def test_probe_forwards_predicate_pushdown():
74+
# A pushed predicate reaches the probe, so only matching rows flow through it (and are counted).
75+
df = pl.DataFrame({"id": [1, 2, 3, 4], "grp": [0, 1, 0, 1]})
76+
out = df.lazy().piot.probe().filter(pl.col("grp") == 1).collect(engine="streaming")
77+
78+
assert out.sort("id")["id"].to_list() == [2, 4]
79+
assert _probe_spans()[-1].attributes["polars_io_tools.total_rows"] == 2
80+
81+
82+
def test_probe_forwards_is_pure(monkeypatch):
83+
# is_pure defaults to False (safe for an input of unknown purity) and can be opted into for a known-pure input.
84+
captured = {}
85+
86+
def fake_register(io_source, *, schema, **kwargs):
87+
captured.update(kwargs)
88+
return object()
89+
90+
monkeypatch.setattr("polars.io.plugins.register_io_source", fake_register)
91+
92+
pl.LazyFrame({"a": [1]}).piot.probe()
93+
assert captured["is_pure"] is False
94+
95+
captured.clear()
96+
pl.LazyFrame({"a": [1]}).piot.probe(is_pure=True)
97+
assert captured["is_pure"] is True

0 commit comments

Comments
 (0)