Skip to content

Commit a4df369

Browse files
committed
Guarantee cache column alignment via required order_by
The lazy column cache caches each column independently and recombines them positionally, so a source that does not emit a deterministic row order across collects could silently misalign cached columns. Previously this was only a warn-on-every-call plus a docstring caveat. Require an order_by key (a unique row identity). Every cached block is collected sorted by order_by, so independently cached columns share one canonical order and recombine in alignment. Uniqueness is validated on the already-collected fill (no extra source pass) and can be skipped with validate=False. order_by and partition_cols are folded into the cache key so caches built with different layouts never collide. The runtime ordering warning is removed. Tests cover alignment under a shuffling source, uniqueness rejection, validate=False, and cross-partition-layout cache reuse. Docs document the new required argument. Signed-off-by: Pascal Tomecek <pascal.tomecek@cubistsystematic.com>
1 parent ac18319 commit a4df369

7 files changed

Lines changed: 158 additions & 78 deletions

File tree

docs/wiki/API-Reference.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,17 @@ The signatures below show the namespace form where one exists.
1616
### `cache`
1717

1818
```python
19-
lf.piot.cache(cache=None, *, partition_cols=(), cache_mode="cache", log_explain=False, **kwargs)
19+
lf.piot.cache(cache=None, *, order_by, partition_cols=(), cache_mode="cache", validate=True, log_explain=False, **kwargs)
2020
```
2121

2222
Maintain an intermediate, per-column cache of the LazyFrame, optionally partitioned by
2323
`partition_cols`. Predicates on partition columns restrict which partitions are cached.
2424
`cache` defaults to a global in-memory dict; pass a custom mapping (such as
2525
`diskcache.Cache`) to persist across sessions. `cache_mode` is `"cache"`, `"rebuild"`, or
26-
`"ignore"`. Relies on the source producing consistent row ordering across collects.
26+
`"ignore"`. `order_by` is required and must uniquely identify each row (within each
27+
partition when `partition_cols` is used): columns are cached sorted by it so that
28+
independently cached columns stay aligned regardless of source ordering. Uniqueness is
29+
verified unless `validate=False`.
2730

2831
### `cache_parquet`
2932

docs/wiki/Getting-Started.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ columns (optionally per partition) so later collects reuse them instead of recom
131131
df = (
132132
pl.LazyFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
133133
.with_columns((pl.col("x") * 2).alias("slow"))
134-
.piot.cache()
134+
.piot.cache(order_by="x")
135135
)
136136

137137
# First collect computes "slow" and stores it in the cache.
@@ -142,7 +142,8 @@ df.select(pl.col("slow").min()).collect()
142142
```
143143

144144
By default the cache is an in-memory dictionary; pass your own mapping (for example a
145-
`diskcache.Cache`) to persist results across sessions.
145+
`diskcache.Cache`) to persist results across sessions. `order_by` is a column (or columns)
146+
that uniquely identifies each row, so columns cached in separate collects stay aligned.
146147

147148
## What you built
148149

docs/wiki/Query-Optimization.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ collects reuse already-computed columns:
143143
df = (
144144
pl.LazyFrame({"x": [1, 2, 3]})
145145
.with_columns((pl.col("x") * 2).alias("slow"))
146-
.piot.cache()
146+
.piot.cache(order_by="x")
147147
)
148148

149149
df.select(pl.col("slow").max()).collect() # computes and caches "slow"
@@ -152,7 +152,8 @@ df.select(pl.col("slow").min()).collect() # reuses the cached column
152152

153153
Pass a custom mapping (for example `diskcache.Cache(...)`) to persist across sessions,
154154
and `partition_cols=` so that filters on partition columns restrict which partitions are
155-
cached. `cache` relies on the source producing consistent row ordering across collects.
155+
cached. `order_by` must uniquely identify each row (within each partition when partitioning)
156+
so that columns cached in separate collects stay aligned.
156157

157158
## Cache to partitioned Parquet on disk or S3
158159

polars_io_tools/io_sources/lazy_cache.py

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import hashlib
22
import logging
3-
import warnings
43
from collections.abc import MutableMapping
5-
from typing import Any, Dict, Hashable, Iterator, List, Literal, NamedTuple, Optional, Tuple
4+
from typing import Any, Dict, Hashable, Iterator, List, Literal, NamedTuple, Optional, Sequence, Tuple, Union
65

76
import polars as pl
87

@@ -30,9 +29,10 @@ class _CacheKey(NamedTuple):
3029
_CACHE: Dict[_CacheKey, pl.Series] = {}
3130

3231

33-
def _df_key(df: pl.LazyFrame) -> str:
34-
"""Return a unique key for the given dataframe."""
35-
return hashlib.md5(df.serialize()).hexdigest()
32+
def _df_key(df: pl.LazyFrame, order_by: Tuple[str, ...] = (), partition_cols: Tuple[str, ...] = ()) -> str:
33+
"""Return a unique key for the given dataframe, ordering key and partition layout."""
34+
payload = df.serialize() + repr((tuple(order_by), tuple(partition_cols))).encode()
35+
return hashlib.md5(payload).hexdigest()
3636

3737

3838
def _partition_key(partition_values: Dict[str, Hashable]) -> _PartitionKey:
@@ -114,12 +114,30 @@ def _extract_filter_from_df(df: pl.DataFrame) -> Optional[pl.Expr]:
114114
return pl.Expr.and_(*row_exprs)
115115

116116

117+
def _validate_order_by_unique(frame: pl.DataFrame, order_by: Tuple[str, ...]) -> None:
118+
"""Raise if ``order_by`` does not uniquely identify the rows of ``frame``.
119+
120+
Operates on a frame already collected for the cache fill, so it adds no extra pass
121+
over the source.
122+
"""
123+
if frame.height and frame.select(list(order_by)).is_duplicated().any():
124+
raise ValueError(
125+
f"order_by={order_by} does not uniquely identify rows (duplicate keys found). "
126+
"A unique total order is required so that independently cached columns remain "
127+
"aligned. When partition_cols is used, order_by must be unique within each "
128+
"partition. Pass validate=False to skip this check if you are certain the "
129+
"ordering is unique."
130+
)
131+
132+
117133
def cache(
118134
self: pl.LazyFrame,
119135
cache: Optional[MutableMapping[_CacheKey, pl.Series]] = None,
120136
*,
137+
order_by: Union[str, Sequence[str]],
121138
partition_cols: Tuple[str, ...] = (),
122139
cache_mode: Literal["cache", "ignore", "rebuild"] = "cache",
140+
validate: bool = True,
123141
log_explain: bool = False,
124142
**kwargs,
125143
) -> pl.LazyFrame:
@@ -141,9 +159,21 @@ def cache(
141159
Args:
142160
self: The input data frame to cache columns of.
143161
cache: An optional implementation of a cache backend. Defaults to a global in-memory cache.
162+
order_by: One or more columns that uniquely identify each row (a unique total order) —
163+
typically the frame's natural row identity, such as a primary key or ``date`` + ``symbol``.
164+
Every cached column is collected and sorted by these columns so that independently cached
165+
columns share one row order and stay aligned when recombined. ``partition_cols`` is not a
166+
substitute: a partition normally holds many rows, so ``order_by`` must be unique *within*
167+
each partition. Uniqueness is verified (see ``validate``). Output rows are returned
168+
sorted by ``order_by`` (within each partition when ``partition_cols`` is set).
144169
partition_cols: An optional set of columns to partition the cache by. It is recommended that queries to the underlying frame for the partition cols are fast,
145-
i.e. they correspond to the parquet partition columns. Ordering of the result is not guaranteed when using partition columns.
170+
i.e. they correspond to the parquet partition columns.
146171
cache_mode: The caching mode; use "cache" for regular caching, "rebuild" to overwrite existing elements of the cache (i.e. to force a refresh), or "ignore" to not use the cache at all.
172+
validate: If True (default), verify that ``order_by`` uniquely identifies rows of each
173+
collected block, raising at ``collect`` time otherwise (Polars surfaces this as a
174+
``ComputeError`` wrapping the validation ``ValueError``). The check runs on data already
175+
collected for the cache fill, so it adds no extra pass over the source. Set to False to
176+
skip it on hot paths where uniqueness is already guaranteed.
147177
log_explain: If True, logs the query plan when defining the function.
148178
**kwargs: Arguments to pass to the collect() method of the input data frame (i.e. to use a different engine)
149179
@@ -152,26 +182,19 @@ def cache(
152182
It means that if a persistent cache implementation is provided, the cache can remain valid between sessions.
153183
- The cache will be invalidated if the input LazyFrame is changed in any way (i.e. by adding a new column, or changing the underlying data source).
154184
It also means that the act of generating the column_cache will change the key for downstream caches,
155-
i.e. `df.piot.cache().select(expr_1).piot.cache()` will have a different cache from `df.select(expr_1).piot.cache()`,
185+
i.e. `df.piot.cache(order_by="id").select(expr_1).piot.cache(order_by="id")` will have a different cache from `df.select(expr_1).piot.cache(order_by="id")`,
156186
even though they return the same result.
157187
- Turn on debug level logging for more info about the cache hits and misses.
158188
159-
.. warning::
160-
161-
**Ordering Requirement**: This function relies on the source LazyFrame producing
162-
consistent row ordering across multiple collects. Since columns are cached independently,
163-
if the source LazyFrame does not guarantee deterministic ordering, different columns
164-
may be cached with different row orderings, leading to misaligned data when combined.
165-
166189
Examples:
167190
Simple usage example:
168191
>>> import polars_io_tools.io_sources # registers .piot namespace
169192
>>> df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}).lazy()
170193
>>> cache = {} # or use a persistent cache like diskcache.Cache("./polars_cache")
171-
>>> _ = df.piot.cache(cache).select("x").head(1).collect()
194+
>>> _ = df.piot.cache(cache, order_by="x").select("x").head(1).collect()
172195
>>> len(cache) > 0
173196
True
174-
>>> _ = df.piot.cache(cache).select(["x", "y"]).collect() # x will be pulled from the cache
197+
>>> _ = df.piot.cache(cache, order_by="x").select(["x", "y"]).collect() # x will be pulled from the cache
175198
>>> len(cache) > 1 # y was added to the cache
176199
True
177200
@@ -181,7 +204,7 @@ def cache(
181204
... (pl.col("x") * 2).alias("slow"),
182205
... (pl.col("x") * 3).alias("very_slow"),
183206
... ])
184-
>>> df = df.piot.cache()
207+
>>> df = df.piot.cache(order_by="x")
185208
186209
This first call evaluates the "slow" column (and stores it in the cache):
187210
>>> result = df.select(pl.col("slow").max()).collect()
@@ -197,15 +220,14 @@ def cache(
197220
"""
198221
if cache_mode not in ("cache", "ignore", "rebuild"):
199222
raise ValueError(f"Invalid cache mode: {cache_mode}")
223+
224+
order_by = (order_by,) if isinstance(order_by, str) else tuple(order_by)
225+
if not order_by:
226+
raise ValueError("order_by must specify at least one column")
227+
200228
if cache_mode == "ignore":
201229
return self
202230

203-
warnings.warn(
204-
"cacherelies on the source LazyFrame having consistent row ordering. "
205-
"If the source uses non-deterministic operations (e.g., joins without maintain_order), "
206-
"cached columns may have misaligned rows."
207-
)
208-
209231
if cache is None:
210232
cache = _CACHE
211233

@@ -214,11 +236,17 @@ def cache(
214236
# Note: This may be slow. Also, make sure to call this *before* generating _df_key.
215237
schema = self.collect_schema()
216238

239+
missing = [col for col in order_by if col not in schema]
240+
if missing:
241+
raise ValueError(f"order_by columns not found in frame schema: {missing}")
242+
217243
# Generate the schema of the partition columns, as we'll need to apply the partition predicate to a frame with this schema
218244
partition_schema = {p_col: schema[p_col] for p_col in partition_cols}
219245

220-
# Create a key for the dataframe as part of the cache keys
221-
df_key = _df_key(self)
246+
# Create a key for the dataframe as part of the cache keys. The ordering key and partition
247+
# layout are folded in so that caches built with a different order_by or different
248+
# partition_cols never collide.
249+
df_key = _df_key(self, order_by, partition_cols)
222250
if log_explain:
223251
log.debug(str(self.explain()))
224252

@@ -305,8 +333,10 @@ def source_generator(
305333
filtered_df = self.filter(selected_predicate)
306334
else:
307335
filtered_df = self
336+
# Include the ordering key so the block can be sorted into the canonical order.
337+
cols_with_order = cols_to_collect + [c for c in order_by if c not in cols_to_collect]
308338
# This frame corresponds to more columns needed for a known partition key
309-
frames_to_collect[partition_key] = filtered_df.select(cols_to_collect)
339+
frames_to_collect[partition_key] = filtered_df.select(cols_with_order).sort(list(order_by))
310340

311341
# Lastly, query all columns for all partitions that are not in the cache.
312342
# We don't know the partition key, so need to include the partition columns in the query,
@@ -317,6 +347,10 @@ def source_generator(
317347
# Make sure to include the partition columns in the query
318348
for p in set(partition_cols).difference(cols_to_collect):
319349
cols_to_collect.append(p)
350+
# Make sure to include the ordering key so the block can be sorted canonically.
351+
for c in order_by:
352+
if c not in cols_to_collect:
353+
cols_to_collect.append(c)
320354
can_skip_query = False
321355
if query_predicate is not None and filtered_partition_dfs:
322356
# We already filtered the frame containing our partition information with our predicate.
@@ -346,7 +380,9 @@ def source_generator(
346380
if not can_skip_query:
347381
log.debug("Querying for data without a fixed partition key... %s", query_predicate)
348382
frames_to_collect[None] = (
349-
self.filter(query_predicate).select(cols_to_collect) if query_predicate is not None else self.select(cols_to_collect)
383+
self.filter(query_predicate).select(cols_to_collect).sort(list(order_by))
384+
if query_predicate is not None
385+
else self.select(cols_to_collect).sort(list(order_by))
350386
)
351387

352388
# Collect all the frames together in a single call for efficiency
@@ -369,13 +405,17 @@ def source_generator(
369405
frame_iter = [((), frame)]
370406

371407
for partition_values, partition_frame in frame_iter:
408+
if validate and all(c in partition_frame.columns for c in order_by):
409+
_validate_order_by_unique(partition_frame, order_by)
372410
partition_key = _partition_key(dict(zip(partition_cols, partition_values)))
373411
for col in partition_frame.columns:
374412
cache_key = _CacheKey(col=col, df_key=df_key, partition_key=partition_key)
375413
log.debug("Caching new partition: %s", cache_key)
376414
cache[cache_key] = partition_frame[col]
377415
data.setdefault(partition_key, {})[col] = partition_frame[col]
378416
else:
417+
if validate and all(c in frame.columns for c in order_by):
418+
_validate_order_by_unique(frame, order_by)
379419
for col in frame.columns:
380420
cache_key = _CacheKey(col=col, df_key=df_key, partition_key=partition_key)
381421
log.debug("Caching new partition: %s", cache_key)

0 commit comments

Comments
 (0)