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.
Files changed (29) hide show
  1. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/PKG-INFO +1 -1
  2. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/pyproject.toml +1 -1
  3. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/PKG-INFO +1 -1
  4. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/builder.py +148 -48
  5. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/context.py +1 -1
  6. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/io.py +340 -97
  7. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_aggregate.py +101 -0
  8. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_io.py +39 -5
  9. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/LICENSE +0 -0
  10. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/README.md +0 -0
  11. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/setup.cfg +0 -0
  12. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/SOURCES.txt +0 -0
  13. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/dependency_links.txt +0 -0
  14. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/requires.txt +0 -0
  15. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/duckstring_trickle.egg-info/top_level.txt +0 -0
  16. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/__init__.py +0 -0
  17. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/acc.py +0 -0
  18. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/agg.py +0 -0
  19. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/capture.py +0 -0
  20. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/lineage.py +0 -0
  21. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/src/trickle/session.py +0 -0
  22. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_builder.py +0 -0
  23. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_capture.py +0 -0
  24. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_column_lineage.py +0 -0
  25. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_join_delta.py +0 -0
  26. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_joins.py +0 -0
  27. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_offer.py +0 -0
  28. {duckstring_trickle-0.2.1 → duckstring_trickle-0.3.0}/tests/test_trickle_package.py +0 -0
  29. {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.2.1
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.2.1"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duckstring-trickle
3
- Version: 0.2.1
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.threaded_delta = threaded_delta
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", "current", "delta", "changed", "is_full")
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.current = current
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() if needs_current else None
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
- changed = ctx.apply_zset(name, rel, pk=out_pk, retain_t=retain_t, retain_n=retain_n)
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
- ref = self._root.ref
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
- self._require_pk(out_pk, [c for c in delta_rel.columns if c != D_COL])
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
- self._cols_cache[id(leaf)] = list(self.ctx.read_table(leaf.ref).columns)
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
- rel = self.ctx.read_table(node.ref)
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
- current = self._view(f"SELECT {sel} FROM {_q(base)}")
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
- _, current = self._join_view(node, ls.cols, ls.current, rs.cols, rs.current)
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), (node.right, rs, node.left, ls)):
1030
- if isinstance(child, _Join) and own.delta is not None and self._reads_twice(node, sibling, other):
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
- return _NodeState(out_cols, current, self._join_delta(node, ls, rs), True, False)
1033
-
1034
- def _reads_twice(self, node: _Join, sibling, other: _NodeState) -> bool:
1035
- """Whether ``node``'s delta terms read one side's delta more than once: always for the existence terms
1036
- (semi, anti and the outer joins' padded rows), else when the other side (``sibling``, compiled to
1037
- ``other``) changed too (``δL ⋈ δR``) or is a join the delta's keys are pushed into."""
1038
- return (node.how != "inner" or other.delta is not None
1039
- or (self._key_filter and isinstance(sibling, _Join)))
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
- pcur = self._pushed(pnode, ps, pk, flips, knames)
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
- return self._filtered_current(node, filters, keys, present)
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
- threaded = read_registry_delta(self.ctx.con, name, self.ctx.previous_f, self.ctx.f, out_pk)
1387
- nxt = TrickleBuilder(self.ctx, name, _spine_delta=threaded)
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 = first_row(self.ctx.read_table(ref).aggregate("count(*) AS n"))[0] or 0
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: ...