duckstring-trickle 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/PKG-INFO +1 -1
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/pyproject.toml +1 -1
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/PKG-INFO +1 -1
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/builder.py +148 -48
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/context.py +1 -1
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/io.py +340 -97
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_aggregate.py +101 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_io.py +39 -5
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/LICENSE +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/README.md +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/setup.cfg +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/SOURCES.txt +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/dependency_links.txt +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/requires.txt +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/top_level.txt +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/__init__.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/acc.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/agg.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/capture.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/lineage.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/session.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_builder.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_capture.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_column_lineage.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_join_delta.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_joins.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_offer.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_package.py +0 -0
- {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_session.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duckstring-trickle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Trickle: incremental joins and aggregates for DuckDB. Write a query once and it updates from the changes to its inputs.
|
|
5
5
|
Author-email: Duckstring <dev@duckstring.com>
|
|
6
6
|
License: Apache License
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "duckstring-trickle"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Trickle: incremental joins and aggregates for DuckDB. Write a query once and it updates from the changes to its inputs."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
{duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duckstring-trickle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Trickle: incremental joins and aggregates for DuckDB. Write a query once and it updates from the changes to its inputs.
|
|
5
5
|
Author-email: Duckstring <dev@duckstring.com>
|
|
6
6
|
License: Apache License
|
|
@@ -93,6 +93,10 @@ _JOIN_SQL = {"inner": "JOIN", "left": "LEFT JOIN", "right": "RIGHT JOIN", "full"
|
|
|
93
93
|
_LEFT_ONLY = {"semi", "anti"}
|
|
94
94
|
# Join types whose output includes the matched (inner) rows.
|
|
95
95
|
_MATCHED = {"inner", "left", "right", "full"}
|
|
96
|
+
# The joins whose delta is one pass from a changed left (right) side when the other side is unchanged: a single
|
|
97
|
+
# term reads that delta once (TrickleBuilder._one_pass, _matched_terms, _exists_delta).
|
|
98
|
+
_ONE_PASS_LEFT = {"inner", "left", "semi", "anti"}
|
|
99
|
+
_ONE_PASS_RIGHT = {"inner", "right"}
|
|
96
100
|
# A join a keys filter passes down through (``_pushed``). Whatever uses the filtered subtree joins it on the key
|
|
97
101
|
# by equality, so a row whose key side is NULL-padded never contributes. Filtering a padded side would turn rows
|
|
98
102
|
# it removes into such padded rows (harmless, but as many as the other side has rows), so the join is tightened
|
|
@@ -199,13 +203,20 @@ class _Source:
|
|
|
199
203
|
"""A leaf: a base table read as a Z-set source. ``alias`` is the explicit ``.alias()`` name (else a
|
|
200
204
|
positional ``s{i}`` is assigned at compile); ``threaded_delta`` is set when this source is a
|
|
201
205
|
just-materialised in-run Trickle (a chained ``.merge()`` result read from the registry, not the
|
|
202
|
-
not-yet-published data plane).
|
|
206
|
+
not-yet-published data plane). It may be given as a function, called the first time it's used, so a
|
|
207
|
+
terminal whose result nothing chains from never reads its own change back."""
|
|
203
208
|
|
|
204
209
|
def __init__(self, ref: str, p: float, *, alias: str | None = None, threaded_delta=None) -> None:
|
|
205
210
|
self.ref = ref
|
|
206
211
|
self.p = p
|
|
207
212
|
self.alias = alias
|
|
208
|
-
self.
|
|
213
|
+
self._threaded = threaded_delta
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def threaded_delta(self):
|
|
217
|
+
if callable(self._threaded):
|
|
218
|
+
self._threaded = self._threaded()
|
|
219
|
+
return self._threaded
|
|
209
220
|
|
|
210
221
|
def leaves(self) -> list["_Source"]:
|
|
211
222
|
return [self]
|
|
@@ -228,16 +239,27 @@ class _Join:
|
|
|
228
239
|
class _NodeState:
|
|
229
240
|
"""The compiled SQL handles for one DAG node over this run: ``cols`` (qualified output columns),
|
|
230
241
|
``current`` (view name for the full current state), ``delta`` (view name for the Z-set ΔO, unconsolidated
|
|
231
|
-
for a join, or ``None``), ``changed`` and ``is_full``.
|
|
242
|
+
for a join, or ``None``), ``changed`` and ``is_full``. ``current`` may be given as a function, built the
|
|
243
|
+
first time a term reads it: a Source's current state can be costly to read (a merge Trickle's change log
|
|
244
|
+
is consolidated), and a run whose terms only need the deltas never reads it. ``insert_only``: the delta
|
|
245
|
+
holds only added rows (see :meth:`TrickleBuilder._compile_join` for when a join's does), so it needs no
|
|
246
|
+
consolidation."""
|
|
232
247
|
|
|
233
|
-
__slots__ = ("cols", "
|
|
248
|
+
__slots__ = ("cols", "_current", "delta", "changed", "is_full", "insert_only")
|
|
234
249
|
|
|
235
|
-
def __init__(self, cols, current, delta, changed, is_full) -> None:
|
|
250
|
+
def __init__(self, cols, current, delta, changed, is_full, insert_only=False) -> None:
|
|
236
251
|
self.cols = cols
|
|
237
|
-
self.
|
|
252
|
+
self._current = current
|
|
238
253
|
self.delta = delta
|
|
239
254
|
self.changed = changed
|
|
240
255
|
self.is_full = is_full
|
|
256
|
+
self.insert_only = insert_only and delta is not None
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def current(self) -> str:
|
|
260
|
+
if callable(self._current):
|
|
261
|
+
self._current = self._current()
|
|
262
|
+
return self._current
|
|
241
263
|
|
|
242
264
|
|
|
243
265
|
class TrickleBuilder:
|
|
@@ -278,6 +300,7 @@ class TrickleBuilder:
|
|
|
278
300
|
self._cols_cache: dict[int, list[str]] = {}
|
|
279
301
|
self._states: dict[int, _NodeState] = {}
|
|
280
302
|
self._scratch: list[str] = [] # temp tables a compile made (materialised join deltas), dropped by _chain
|
|
303
|
+
self._pushed_memo: dict = {} # _pushed's views by (node, key columns, keys view): reused, then materialised
|
|
281
304
|
# While emitting a comprehensive recompute as one query (:meth:`_query_sql`), every view the compile
|
|
282
305
|
# creates is also logged here as ``(name, sql, leaf)``; ``leaf`` is set for a source's base read.
|
|
283
306
|
self._view_log: list | None = None
|
|
@@ -565,7 +588,7 @@ class TrickleBuilder:
|
|
|
565
588
|
by + tuple(c for m in metrics.values() for c in (m.col, m.col2) if c is not None)))
|
|
566
589
|
kind, rel = self._compute(required, name, ivm=ivm, key_filter=key_filter, offer="aggregate")
|
|
567
590
|
needs_current = kind == "incremental" and any(m.kind in RESCAN_KINDS for m in metrics.values())
|
|
568
|
-
current = self._full_join
|
|
591
|
+
current = self._full_join if needs_current else None # built only if a group must rescan
|
|
569
592
|
trickle.apply_aggregate(ctx.con, name, by, metrics, kind, rel, current, ctx.f,
|
|
570
593
|
retain_t=retain_t, retain_n=retain_n)
|
|
571
594
|
return self._chain(name, out_pk)
|
|
@@ -575,11 +598,12 @@ class TrickleBuilder:
|
|
|
575
598
|
if self._is_sql: # comprehensive mode (post-.sql) → diff the relation vs prior main
|
|
576
599
|
changed = ctx.merge_table(name, self._sql_result(out_pk), pk=out_pk, retain_t=retain_t, retain_n=retain_n)
|
|
577
600
|
return self._chain(name, out_pk, changed=changed)
|
|
578
|
-
kind, rel = self._compute(out_pk, name, ivm=ivm, key_filter=key_filter, offer="rows")
|
|
601
|
+
kind, rel = self._compute(out_pk, name, ivm=ivm, key_filter=key_filter, offer="rows", consolidate=False)
|
|
579
602
|
if kind == "comprehensive":
|
|
580
603
|
changed = ctx.merge_table(name, rel, pk=out_pk, retain_t=retain_t, retain_n=retain_n)
|
|
581
604
|
elif kind == "incremental":
|
|
582
|
-
|
|
605
|
+
extra = {"insert_only": True} if self._insert_only else {} # nothing to consolidate before writing
|
|
606
|
+
changed = ctx.apply_zset(name, rel, pk=out_pk, retain_t=retain_t, retain_n=retain_n, **extra)
|
|
583
607
|
else:
|
|
584
608
|
changed = False # "empty": nothing changed (and no full read) → output unchanged, no write.
|
|
585
609
|
return self._chain(name, out_pk, changed=changed)
|
|
@@ -631,7 +655,7 @@ class TrickleBuilder:
|
|
|
631
655
|
)
|
|
632
656
|
return self._chain(name, out_pk, changed=changed)
|
|
633
657
|
|
|
634
|
-
kind, rel = self._compute(out_pk, name, ivm=ivm, key_filter=key_filter, offer="rows")
|
|
658
|
+
kind, rel = self._compute(out_pk, name, ivm=ivm, key_filter=key_filter, offer="rows", consolidate=False)
|
|
635
659
|
changed = False
|
|
636
660
|
if kind != "empty":
|
|
637
661
|
zset = trickle._as_zset(rel, 1) if kind == "comprehensive" else rel
|
|
@@ -655,7 +679,7 @@ class TrickleBuilder:
|
|
|
655
679
|
|
|
656
680
|
by, metrics, along = self._acc["by"], self._acc["metrics"], self._along
|
|
657
681
|
out_pk = normalize_pk(pk)
|
|
658
|
-
kind, rel = self._compute(self._acc_required(out_pk), name, ivm=True, key_filter=True)
|
|
682
|
+
kind, rel = self._compute(self._acc_required(out_pk), name, ivm=True, key_filter=True, consolidate="always")
|
|
659
683
|
trickle.apply_accumulate(
|
|
660
684
|
self.ctx.con, name, by, along, metrics, kind, rel, self.ctx.f, pk,
|
|
661
685
|
fail_on_conflict=fail_on_conflict, log_drops=log_drops, retain_t=retain_t, retain_n=retain_n,
|
|
@@ -672,7 +696,7 @@ class TrickleBuilder:
|
|
|
672
696
|
out_pk = normalize_pk(pk)
|
|
673
697
|
if not out_pk:
|
|
674
698
|
raise BuildError(f"pond.trickle('{self.spine_ref}')...accumulate(...).merge('{name}'): pass merge(pk=...)")
|
|
675
|
-
kind, rel = self._compute(self._acc_required(out_pk), name, ivm=True, key_filter=True)
|
|
699
|
+
kind, rel = self._compute(self._acc_required(out_pk), name, ivm=True, key_filter=True, consolidate="always")
|
|
676
700
|
trickle.apply_accumulate_merge(
|
|
677
701
|
self.ctx.con, name, by, along, metrics, kind, rel, self._full_join(), self.ctx.f, pk,
|
|
678
702
|
retain_t=retain_t, retain_n=retain_n,
|
|
@@ -690,13 +714,7 @@ class TrickleBuilder:
|
|
|
690
714
|
bare = (isinstance(self._root, _Source) and not self._ops
|
|
691
715
|
and not self._is_sql and self._agg is None)
|
|
692
716
|
if bare:
|
|
693
|
-
|
|
694
|
-
if (trickle.read_meta(con).get(ref) or {}).get("mode") in ("merge", "append"):
|
|
695
|
-
return trickle.count_current(con, ref) # local registry Trickle → metadata-fast
|
|
696
|
-
counter = getattr(self.ctx, "count_table", None) # external source → host's fast path if any
|
|
697
|
-
if counter is not None:
|
|
698
|
-
return int(counter(ref))
|
|
699
|
-
return int(first_row(self.ctx.read_table(ref).aggregate("count(*)"))[0]) # correct fallback
|
|
717
|
+
return self._source_rows(self._root.ref)
|
|
700
718
|
view = trickle.unique_name("count")
|
|
701
719
|
if self._agg is not None:
|
|
702
720
|
# group count: distinct groups in the current composed state — never runs the metric aggregations.
|
|
@@ -772,14 +790,20 @@ class TrickleBuilder:
|
|
|
772
790
|
|
|
773
791
|
# ─── compute (the shared ΔO step behind .merge() and .append()) ───────────────
|
|
774
792
|
|
|
775
|
-
def _compute(self, out_pk, name: str, *, ivm: bool = True, key_filter: bool = True, offer: str | None = None
|
|
793
|
+
def _compute(self, out_pk, name: str, *, ivm: bool = True, key_filter: bool = True, offer: str | None = None,
|
|
794
|
+
consolidate: bool = True):
|
|
776
795
|
"""Compose this DAG's change once. Returns ``(kind, rel)``: ``("comprehensive", o_prime)`` (whole
|
|
777
796
|
output recomputed, clean rows — the caller diffs it against the stored main), ``("incremental",
|
|
778
797
|
delta)`` (the Z-set ΔO, user cols + ``_duckstring_d``), ``("empty", None)`` (nothing changed), or
|
|
779
798
|
``("state", state)`` when an ``offer="aggregate"`` recompute was run by the host and returned the
|
|
780
799
|
aggregate's accumulator state. Validates ``.select`` is present for a joined DAG and that it includes
|
|
781
800
|
the PK. ``offer`` (``"rows"`` or ``"aggregate"``) is what a wholesale recompute is offered to the host
|
|
782
|
-
as (see :meth:`_offer`); ``None`` never offers.
|
|
801
|
+
as (see :meth:`_offer`); ``None`` never offers. ``consolidate=False`` leaves a join's ΔO unconsolidated,
|
|
802
|
+
for a writer that consolidates what it writes anyway (``apply_zset``, ``append_zset``); otherwise it is
|
|
803
|
+
consolidated once, after the output pipeline (which is row-local, so it commutes), over the output
|
|
804
|
+
columns rather than every leaf's, unless it is insert-only (nothing can cancel, and a weight-aware
|
|
805
|
+
writer or aggregate reads repeated rows as their sum); ``"always"`` consolidates that too, for the scans,
|
|
806
|
+
which read rows rather than weights.
|
|
783
807
|
|
|
784
808
|
Two orthogonal strategy flags, both default ``True`` (the normal path), exposed on the terminals as
|
|
785
809
|
manual escapes — reach for them only when you've measured the default hurts a specific build:
|
|
@@ -799,6 +823,7 @@ class TrickleBuilder:
|
|
|
799
823
|
# onto a nonexistent log would persist just that delta as a false bootstrap. `name not in read_meta`
|
|
800
824
|
# (not raw table-existence — a young merge main has only a changelog pre-checkpoint) is the signal.
|
|
801
825
|
# See plans/deletes.md. `ivm=False` is the manual sibling escape.
|
|
826
|
+
self._insert_only = False
|
|
802
827
|
if not ivm or name not in read_meta(self.ctx.con):
|
|
803
828
|
return self._comprehensive(out_pk, offer)
|
|
804
829
|
self._key_filter = key_filter
|
|
@@ -808,10 +833,14 @@ class TrickleBuilder:
|
|
|
808
833
|
return self._comprehensive(out_pk, offer, state)
|
|
809
834
|
if not state.changed:
|
|
810
835
|
return "empty", None
|
|
811
|
-
if isinstance(self._root, _Join): # one consolidation, at the root, before the output pipeline
|
|
812
|
-
state.delta = self._consolidate(state.delta, state.cols)
|
|
813
836
|
delta_rel = self._project_delta(state)
|
|
814
|
-
|
|
837
|
+
cols = [c for c in delta_rel.columns if c != D_COL]
|
|
838
|
+
self._require_pk(out_pk, cols)
|
|
839
|
+
self._insert_only = state.insert_only # the row pipeline keeps it: it only drops or rewrites rows
|
|
840
|
+
if consolidate and isinstance(self._root, _Join) and not (state.insert_only and consolidate != "always"):
|
|
841
|
+
projected = unique_name("proj") # a source's own delta is consolidated already
|
|
842
|
+
delta_rel.create_view(projected, replace=True)
|
|
843
|
+
delta_rel = self.ctx.con.sql(f"SELECT * FROM {_q(self._consolidate(projected, cols))}")
|
|
815
844
|
return "incremental", delta_rel
|
|
816
845
|
|
|
817
846
|
def _comprehensive(self, out_pk, offer: str | None, state: _NodeState | None = None):
|
|
@@ -922,8 +951,12 @@ class TrickleBuilder:
|
|
|
922
951
|
return self._alias_of[id(leaf)]
|
|
923
952
|
|
|
924
953
|
def _bare_cols(self, leaf: _Source) -> list[str]:
|
|
954
|
+
"""A source's columns, from the host's ``source_columns`` hook where it has one (a binding, without
|
|
955
|
+
the read :meth:`read_table` does), else from the read."""
|
|
925
956
|
if id(leaf) not in self._cols_cache:
|
|
926
|
-
|
|
957
|
+
hook = getattr(self.ctx, "source_columns", None)
|
|
958
|
+
cols = hook(leaf.ref) if hook is not None else self.ctx.read_table(leaf.ref).columns
|
|
959
|
+
self._cols_cache[id(leaf)] = list(cols)
|
|
927
960
|
return self._cols_cache[id(leaf)]
|
|
928
961
|
|
|
929
962
|
@property
|
|
@@ -994,14 +1027,14 @@ class TrickleBuilder:
|
|
|
994
1027
|
|
|
995
1028
|
def _compile_source(self, node: _Source) -> _NodeState:
|
|
996
1029
|
a = self._alias_for(node)
|
|
997
|
-
|
|
998
|
-
bare = list(rel.columns)
|
|
999
|
-
self._cols_cache[id(node)] = bare
|
|
1030
|
+
bare = self._bare_cols(node)
|
|
1000
1031
|
cols = [f"{a}.{c}" for c in bare]
|
|
1001
|
-
base = unique_name("src")
|
|
1002
|
-
rel.create_view(base, replace=True)
|
|
1003
1032
|
sel = ", ".join(f'{_q(c)} AS {_q(f"{a}.{c}")}' for c in bare)
|
|
1004
|
-
|
|
1033
|
+
|
|
1034
|
+
def current() -> str:
|
|
1035
|
+
base = unique_name("src")
|
|
1036
|
+
self.ctx.read_table(node.ref).create_view(base, replace=True)
|
|
1037
|
+
return self._view(f"SELECT {sel} FROM {_q(base)}")
|
|
1005
1038
|
|
|
1006
1039
|
delta = node.threaded_delta if node.threaded_delta is not None else self.ctx.read_delta(node.ref)
|
|
1007
1040
|
if delta.is_full:
|
|
@@ -1013,7 +1046,8 @@ class TrickleBuilder:
|
|
|
1013
1046
|
dbase = unique_name("dsrc")
|
|
1014
1047
|
delta.zset.create_view(dbase, replace=True)
|
|
1015
1048
|
dsel = ", ".join(f'{_q(c)} AS {_q(f"{a}.{c}")}' for c in bare) + f", {_q(D_COL)} AS {_q(D_COL)}"
|
|
1016
|
-
return _NodeState(cols, current, self._view(f"SELECT {dsel} FROM {_q(dbase)}"), True, False
|
|
1049
|
+
return _NodeState(cols, current, self._view(f"SELECT {dsel} FROM {_q(dbase)}"), True, False,
|
|
1050
|
+
insert_only=getattr(delta, "insert_only", False))
|
|
1017
1051
|
|
|
1018
1052
|
def _compile_join(self, node: _Join) -> _NodeState:
|
|
1019
1053
|
ls = self._compile(node.left)
|
|
@@ -1021,22 +1055,35 @@ class TrickleBuilder:
|
|
|
1021
1055
|
out_cols = ls.cols if node.how in _LEFT_ONLY else ls.cols + rs.cols
|
|
1022
1056
|
is_full = ls.is_full or rs.is_full
|
|
1023
1057
|
changed = ls.changed or rs.changed
|
|
1024
|
-
|
|
1058
|
+
|
|
1059
|
+
def current() -> str:
|
|
1060
|
+
return self._join_view(node, ls.cols, ls.current, rs.cols, rs.current)[1]
|
|
1061
|
+
|
|
1025
1062
|
if is_full or not changed:
|
|
1026
1063
|
return _NodeState(out_cols, current, None, changed, is_full)
|
|
1027
1064
|
# A child join's delta is an inline view, re-evaluated at every reference. When this join reads it more
|
|
1028
1065
|
# than once, down a chain the bottom delta would run 2^depth times; a delta is small, so compute it once.
|
|
1029
|
-
for child, own, sibling, other in ((node.left, ls, node.right, rs),
|
|
1030
|
-
|
|
1066
|
+
for left, child, own, sibling, other in ((True, node.left, ls, node.right, rs),
|
|
1067
|
+
(False, node.right, rs, node.left, ls)):
|
|
1068
|
+
if isinstance(child, _Join) and own.delta is not None and self._reads_twice(node, left, sibling, other):
|
|
1031
1069
|
own.delta = self._scratch_table(own.delta)
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1070
|
+
# Insert-only when one side changed, by inserts only, and the join is one pass from it: its one term
|
|
1071
|
+
# multiplies +1 rows by the other side's current rows. (Two changed sides, or a change on the side an
|
|
1072
|
+
# outer, semi or anti join pads or tests, can retract.)
|
|
1073
|
+
insert_only = ((ls.insert_only and rs.delta is None and node.how in _ONE_PASS_LEFT)
|
|
1074
|
+
or (rs.insert_only and ls.delta is None and node.how in _ONE_PASS_RIGHT))
|
|
1075
|
+
return _NodeState(out_cols, current, self._join_delta(node, ls, rs), True, False, insert_only=insert_only)
|
|
1076
|
+
|
|
1077
|
+
def _reads_twice(self, node: _Join, left: bool, sibling, other: _NodeState) -> bool:
|
|
1078
|
+
"""Whether ``node``'s delta terms read one side's delta (the ``left`` one, or the right) more than once.
|
|
1079
|
+
Once when the other side (``sibling``, compiled to ``other``) is unchanged and the join is one pass from
|
|
1080
|
+
this side (:meth:`_one_pass`), unless the delta's keys are also pushed into the other side's subtree.
|
|
1081
|
+
Otherwise more than once: ``δL ⋈ δR``, and the existence terms of the semi, anti and outer joins."""
|
|
1082
|
+
if other.delta is not None:
|
|
1083
|
+
return True
|
|
1084
|
+
if node.how not in (_ONE_PASS_LEFT if left else _ONE_PASS_RIGHT):
|
|
1085
|
+
return True
|
|
1086
|
+
return self._key_filter and isinstance(sibling, _Join)
|
|
1040
1087
|
|
|
1041
1088
|
# ─── the join delta (plans/trickle-join-delta.md) ────────────────────────────
|
|
1042
1089
|
|
|
@@ -1047,6 +1094,9 @@ class TrickleBuilder:
|
|
|
1047
1094
|
so its terms aren't generated."""
|
|
1048
1095
|
pairs = self._resolve_pairs(node)
|
|
1049
1096
|
lk, rk = [lq for lq, _ in pairs], [rq for _, rq in pairs]
|
|
1097
|
+
one = self._one_pass(node, ls, rs, lk, rk)
|
|
1098
|
+
if one is not None:
|
|
1099
|
+
return one
|
|
1050
1100
|
terms = []
|
|
1051
1101
|
if node.how in _MATCHED:
|
|
1052
1102
|
terms += self._matched_terms(node, ls, rs, lk, rk)
|
|
@@ -1060,6 +1110,25 @@ class TrickleBuilder:
|
|
|
1060
1110
|
terms.append(self._exists_delta(node.left, ls, lk, node.right, rs, rk, semi=node.how == "semi"))
|
|
1061
1111
|
return self._view(" UNION ALL BY NAME ".join(f"SELECT * FROM {_q(t)}" for t in terms))
|
|
1062
1112
|
|
|
1113
|
+
def _one_pass(self, node: _Join, ls: _NodeState, rs: _NodeState, lk, rk) -> str | None:
|
|
1114
|
+
"""An outer join whose other side didn't change, in one pass. A left join is linear in its left side for
|
|
1115
|
+
a fixed right side, so with ``δR`` empty, ``δ(L ⟕ R) = δL ⟕ R'`` for any ``δL``, retractions included
|
|
1116
|
+
(and a right join likewise). The general terms would read ``δL`` twice, matched and padded. A full join
|
|
1117
|
+
isn't linear in either side (a new left row can take a right row's padding away), so it has none."""
|
|
1118
|
+
if node.how == "left" and ls.delta is not None and rs.delta is None:
|
|
1119
|
+
delta, dside, other, how = ls.delta, "L", self._pushed(node.right, rs, rk, ls.delta, lk), "LEFT JOIN"
|
|
1120
|
+
lview, rview = delta, other
|
|
1121
|
+
elif node.how == "right" and rs.delta is not None and ls.delta is None:
|
|
1122
|
+
delta, dside, other, how = rs.delta, "R", self._pushed(node.left, ls, lk, rs.delta, rk), "RIGHT JOIN"
|
|
1123
|
+
lview, rview = other, delta
|
|
1124
|
+
else:
|
|
1125
|
+
return None
|
|
1126
|
+
cond = " AND ".join(f"L.{_q(a)} = R.{_q(b)}" for a, b in zip(lk, rk, strict=True))
|
|
1127
|
+
sel = ", ".join(f"L.{_q(c)} AS {_q(c)}" for c in ls.cols) + ", "
|
|
1128
|
+
sel += ", ".join(f"R.{_q(c)} AS {_q(c)}" for c in rs.cols)
|
|
1129
|
+
return self._view(f"SELECT {sel}, {dside}.{_q(D_COL)} AS {_q(D_COL)} "
|
|
1130
|
+
f"FROM {_q(lview)} L {how} {_q(rview)} R ON {cond}")
|
|
1131
|
+
|
|
1063
1132
|
def _matched_terms(self, node: _Join, ls: _NodeState, rs: _NodeState, lk, rk) -> list[str]:
|
|
1064
1133
|
"""The inner join's delta terms. A row's weight is the product of its operands' weights (a current
|
|
1065
1134
|
state row weighs 1), so ``δL ⋈ δR`` enters negated: it is counted in both of the other terms."""
|
|
@@ -1096,7 +1165,9 @@ class TrickleBuilder:
|
|
|
1096
1165
|
flips = self._flips(qnode, qs, qk)
|
|
1097
1166
|
knames = [f"k{i}" for i in range(len(pk))]
|
|
1098
1167
|
cond = " AND ".join(f"P.{_q(a)} = F.{k}" for a, k in zip(pk, knames, strict=True))
|
|
1099
|
-
|
|
1168
|
+
# Restricted to δQ's keys rather than the flip set's (a superset, and the join with F narrows it), so
|
|
1169
|
+
# an outer join's matched term, which pushes the same keys into P, reads the same view (_pushed).
|
|
1170
|
+
pcur = self._pushed(pnode, ps, pk, qs.delta, qk)
|
|
1100
1171
|
sign = "" if semi else "-"
|
|
1101
1172
|
parts.append(f"SELECT {sel}, {sign}F.s AS {d} FROM {_q(pcur)} P JOIN {_q(flips)} F ON {cond}")
|
|
1102
1173
|
if ps.delta is not None:
|
|
@@ -1154,9 +1225,23 @@ class TrickleBuilder:
|
|
|
1154
1225
|
present.setdefault(jid, set()).add(side)
|
|
1155
1226
|
if not filters:
|
|
1156
1227
|
return state.current
|
|
1228
|
+
# Two terms can push the same keys into the same subtree (an outer join's matched rows and its padded
|
|
1229
|
+
# rows both read L' at δR's keys): each would re-read the subtree, for a large fact a full pass, so the
|
|
1230
|
+
# view is computed once when it's asked for a second time. Not while the views are being logged for a
|
|
1231
|
+
# comprehensive job, which wants them as SQL.
|
|
1232
|
+
memo = (id(node), tuple(cols), src, tuple(src_cols))
|
|
1233
|
+
if memo in self._pushed_memo:
|
|
1234
|
+
view, reused = self._pushed_memo[memo]
|
|
1235
|
+
if not reused and self._view_log is None:
|
|
1236
|
+
name = self._scratch_table(view)
|
|
1237
|
+
self.ctx.con.execute(f"CREATE OR REPLACE TEMP VIEW {_q(view)} AS SELECT * FROM {_q(name)}")
|
|
1238
|
+
self._pushed_memo[memo] = (view, True)
|
|
1239
|
+
return view
|
|
1157
1240
|
ksel = ", ".join(f"{_q(c)} AS k{i}" for i, c in enumerate(src_cols))
|
|
1158
1241
|
keys = self._view(f"SELECT DISTINCT {ksel} FROM {_q(src)}")
|
|
1159
|
-
|
|
1242
|
+
view = self._filtered_current(node, filters, keys, present)
|
|
1243
|
+
self._pushed_memo[memo] = (view, False)
|
|
1244
|
+
return view
|
|
1160
1245
|
|
|
1161
1246
|
def _push_path(self, node, col: str):
|
|
1162
1247
|
"""``(leaf, [(join id, side)])`` for the qualified column ``col``: the leaf that owns it and, for each
|
|
@@ -1375,6 +1460,7 @@ class TrickleBuilder:
|
|
|
1375
1460
|
for t in self._scratch: # the write is done, so nothing reads the materialised deltas any more
|
|
1376
1461
|
self.ctx.con.execute(f"DROP TABLE IF EXISTS {_q(t)}")
|
|
1377
1462
|
self._scratch = []
|
|
1463
|
+
self._pushed_memo = {}
|
|
1378
1464
|
# Observed-lineage hook (optional host capability, like ``count_table``): a terminal just wrote
|
|
1379
1465
|
# ``name`` straight to the registry, so tell the host — trickle/ stays dependency-free.
|
|
1380
1466
|
hook = getattr(self.ctx, "record_lineage_write", None)
|
|
@@ -1383,8 +1469,8 @@ class TrickleBuilder:
|
|
|
1383
1469
|
hook = getattr(self.ctx, "record_change", None) # likewise for the host's change tracking
|
|
1384
1470
|
if hook is not None:
|
|
1385
1471
|
hook(name, changed)
|
|
1386
|
-
|
|
1387
|
-
nxt = TrickleBuilder(self.ctx, name, _spine_delta=
|
|
1472
|
+
con, previous_f, f = self.ctx.con, self.ctx.previous_f, self.ctx.f
|
|
1473
|
+
nxt = TrickleBuilder(self.ctx, name, _spine_delta=lambda: read_registry_delta(con, name, previous_f, f, out_pk))
|
|
1388
1474
|
nxt._was_changed = changed
|
|
1389
1475
|
return nxt
|
|
1390
1476
|
|
|
@@ -1398,12 +1484,26 @@ class TrickleBuilder:
|
|
|
1398
1484
|
"""
|
|
1399
1485
|
return self._was_changed
|
|
1400
1486
|
|
|
1487
|
+
def _source_rows(self, ref: str) -> int:
|
|
1488
|
+
"""A source's current row count, from metadata where possible: a Trickle in this registry counts
|
|
1489
|
+
itself, and an external source uses the host's ``count_table`` if it has one. Only otherwise is the
|
|
1490
|
+
source read and counted, which for a large merge Trickle reconstructs its whole state."""
|
|
1491
|
+
from . import io as trickle
|
|
1492
|
+
|
|
1493
|
+
con = self.ctx.con
|
|
1494
|
+
if (trickle.read_meta(con).get(ref) or {}).get("mode") in ("merge", "append"):
|
|
1495
|
+
return trickle.count_current(con, ref)
|
|
1496
|
+
counter = getattr(self.ctx, "count_table", None)
|
|
1497
|
+
if counter is not None:
|
|
1498
|
+
return int(counter(ref))
|
|
1499
|
+
return int(first_row(self.ctx.read_table(ref).aggregate("count(*)"))[0])
|
|
1500
|
+
|
|
1401
1501
|
def _over_threshold(self, ref: str, delta, p: float) -> bool:
|
|
1402
1502
|
"""Whether ``delta`` touches more than fraction ``p`` of ``ref``'s current rows (``p >= 1`` never
|
|
1403
1503
|
trips and skips the count; ``p <= 0`` trips on any change)."""
|
|
1404
1504
|
if p >= 1.0 or delta.is_full:
|
|
1405
1505
|
return False
|
|
1406
|
-
total =
|
|
1506
|
+
total = self._source_rows(ref) or 0
|
|
1407
1507
|
if total == 0:
|
|
1408
1508
|
return False
|
|
1409
1509
|
return delta.keys_count() > p * total
|
|
@@ -65,4 +65,4 @@ class Context(Protocol):
|
|
|
65
65
|
|
|
66
66
|
def merge_table(self, name: str, relation, *, pk, retain_t=None, retain_n=None) -> bool: ...
|
|
67
67
|
|
|
68
|
-
def apply_zset(self, name: str, zset, *, pk, retain_t=None, retain_n=None) -> bool: ...
|
|
68
|
+
def apply_zset(self, name: str, zset, *, pk, retain_t=None, retain_n=None, insert_only=False) -> bool: ...
|