diff --git a/.github/scripts/shared-state/check_shared_state.py b/.github/scripts/shared-state/check_shared_state.py new file mode 100644 index 000000000..986aadf7a --- /dev/null +++ b/.github/scripts/shared-state/check_shared_state.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Gate the interpreter's no-back-channel invariant. + +`src/interpreter/CLAUDE.md`, "Core invariant: data flows between operators as +Tiles, nothing else", says every operator-to-operator handoff goes through +`subscribe`/`get`/`release`. A back channel — two operators sharing an +`Rc>` of values — breaks that *silently*: the dependency is real but +invisible to the producer graph, every test still passes, and the graph no +longer describes the dataflow. That failure mode is why the rule is checked +rather than only stated. + +The check is deliberately shallow: it flags **shared mutable state by shape** +inside `src/interpreter/`, and asks for each site to be either a known-legitimate +inner type or explicitly justified. It cannot prove the absence of a back channel +(a raw pointer, a global, a captured cell would all slip past); it makes the easy +way to build one impossible to add without writing down why. + +Legitimate shared state falls into a few named kinds, listed in `ALLOWED_INNER`: +notification handles, late-wired operator slots, a fan-out's own branch state, +and external source handles. Anything else needs a justification comment: + + // shared-state-ok: + some_field: Rc>, + +Two things keep those justifications from becoming the way past the gate rather +than an argument about the code: + +- **Test code is not scanned** (`_blank_test_items`). A spy recording what it was + handed is shared state by shape but not a back channel, so justifying it buys + nothing. +- **The justified sites are themselves listed** (`EXPECTED_EXCEPTIONS`). Adding + one means editing this file, which puts the question in a diff a reviewer + reads: is the exception necessary, or is there a tile shape that removes the + need for it? + +Run: python3 .github/scripts/shared-state/check_shared_state.py +""" + +from __future__ import annotations + +import re +import sys +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +SCANNED_DIR = Path("src/interpreter") + +# The cells whose contents can change through a shared reference. +INTERIOR = r"(?:RefCell|Cell|Mutex|RwLock|OnceLock|OnceCell)" + +# The shapes that can carry values between two owners. The inner type is taken +# by matching angle brackets rather than by regex: `Rc>>>` nests deeper than a non-greedy `.+?` can follow, and a +# truncated inner type would silently miss its `ALLOWED_INNER` entry. +SHARED_CELL_OPEN = re.compile(rf"\b(?:Rc|Arc)\s*<\s*{INTERIOR}\s*<") +# The same cell reached without a sharing wrapper of its own: a struct field +# whose *owner* supplies the sharing. `Arc` where `State` holds a `Mutex` +# is `Arc>` with the layers swapped, and exactly as much of a back +# channel, so it is checked by the same rules. +BARE_CELL_OPEN = re.compile( + rf"^[ \t]*(?:pub(?:\([^)]*\))?\s+)?[a-z_][a-z0-9_]*\s*:\s*{INTERIOR}\s*<", re.MULTILINE +) +# Ambient mutable state, which needs no sharing wrapper at all to be a back +# channel: a `static` is reachable from everywhere by name, whether its +# mutability comes from `mut` or from an interior-mutable cell. +AMBIENT = re.compile(rf"\bstatic\s+mut\b|\bthread_local!|\bstatic\s+\w+\s*:\s*{INTERIOR}\s*<") + +JUSTIFICATION = re.compile(r"//\s*shared-state-ok:\s*\S") + +# The justified exceptions the interpreter holds, as `(path, what)`. An exception is +# a permanent hole in the invariant, so adding one has to be a deliberate act rather +# than the cheapest way past a red build — editing this list is where the question +# gets asked. Listed rather than counted: a count gates only how many holes there +# are, so closing one and opening an unrelated one passes unseen, and the diff that +# adds an entry names the file it lands in. Removals fail too, so a hole that closes +# is given up rather than banked against the next one. +# +# Duplicate entries are meaningful — two sites in one file with the same shape are +# two exceptions. Test code is not listed: `_blank_test_items` skips it entirely. +EXPECTED_EXCEPTIONS = [ + ("src/interpreter/http_server.rs", "ambient mutable state"), + ("src/interpreter/http_server.rs", "cell of `HashMap`"), + ("src/interpreter/http_server.rs", "shared cell of `HashMap<(String, String), RouteSender>`"), + ("src/interpreter/mod.rs", "shared cell of `C`"), + ("src/interpreter/tile_operators/cycle_slot.rs", "shared cell of `Option>`"), + ("src/interpreter/tile_operators/fanout.rs", "cell of `bool`"), + ("src/interpreter/tile_operators/fanout.rs", "shared cell of `Box`"), + ("src/interpreter/tile_operators/fanout.rs", "shared cell of `Box`"), + ("src/interpreter/tile_operators/mod.rs", "ambient mutable state"), + ("src/interpreter/types/extent.rs", "shared cell of `Restriction`"), + ("src/interpreter/types/extent.rs", "shared cell of `Restriction`"), +] + +# Inner types that are legitimate by construction. Matched against the inner +# type with whitespace collapsed, so `Box` and `Box< dyn Consumer >` +# are the same entry. +ALLOWED_INNER = { + # Notification handles: a wakeup carries no value, only "pull me again". + "dyn Consumer": "notification handle", + "Box": "notification handle", + "Vec": "notification queue", + # Late-wired slots: a cycle cannot be built bottom-up, so the operator is + # installed after construction. These hold *operators*, never values. + # + # No entry here spells a *slot*. `Option>` and the bare + # `Box` are both deliberately absent, because either shape + # hand-rolls what `CycleSlot` already is, and `CycleSlot` carries its own + # justification — a second copy has to argue for itself. `FanOut`'s owned + # input is the one live site of the bare form and is listed by path in + # `EXPECTED_EXCEPTIONS` instead, so allowing the *kind* would blanket-excuse + # every future hand-rolled one to save a single annotation. + "Option>": "late-wired producer slot", + "Option>": "late-wired producer slot", + # A fan-out and its branches are one logical operator; the shared state is + # that operator's own bookkeeping, including the cyclic-mode tile memo. + "FanOutShared": "fan-out branch state", + # The external-world boundary: a data source's arrival state, shared with the + # scheduler that feeds it. Side effects live at the boundary by design. + "dyn DataSourceDomainExtentImpl": "external source handle", +} + + +@dataclass(frozen=True) +class Finding: + path: str + line: int + text: str + what: str + + +def _normalize(inner: str) -> str: + """Canonical spelling: one space between words, none around punctuation. + + `Box< dyn Consumer >` and `Box` are the same type and must hash + to the same `ALLOWED_INNER` key, but `dyn Consumer` must keep its space — + stripping all whitespace would fuse it into `dynConsumer`. + """ + collapsed = re.sub(r"\s+", " ", inner).strip() + collapsed = re.sub(r"\s*([<>])\s*", r"\1", collapsed) + return re.sub(r"\s*,\s*", ", ", collapsed) + + +def _inner_type(text: str, start: int) -> str | None: + """The balanced contents of the cell's type argument opening at `start`. + + `None` if the brackets do not close within `text` — a type split across + lines, which the caller reports rather than guesses at. + """ + depth = 1 + for i in range(start, len(text)): + ch = text[i] + if ch == "<": + depth += 1 + elif ch == ">": + depth -= 1 + if depth == 0: + return text[start:i] + return None + + +def _justified(lines: list[str], idx: int) -> bool: + """A justification sits on the offending line or the lines just above it. + + Doc comments often separate the annotation from the field, so scan upward + past comment and attribute lines rather than requiring adjacency. + """ + if JUSTIFICATION.search(lines[idx]): + return True + for prev in range(idx - 1, -1, -1): + stripped = lines[prev].strip() + if JUSTIFICATION.search(stripped): + return True + if stripped.startswith(("//", "#[")) or not stripped: + continue + return False + return False + + +RAW_STRING = re.compile(r"b?r(#*)\"") + + +def _code_view(text: str) -> str: + """`text` with comments and literals blanked to spaces, offsets preserved. + + Everything below reads Rust *syntax*, so a brace inside a string and a type + named in a doc comment are both noise — and both desync the brace matching + that skipping `#[cfg(test)]` items and reading a wrapped inner type depend + on. Blanked rather than removed so a finding still maps to its line, and so a + `//` comment ending a line cannot swallow the type wrapping onto the next. + """ + out = list(text) + n = len(text) + + def blank(start: int, end: int) -> None: + for k in range(start, end): + if out[k] != "\n": + out[k] = " " + + i = 0 + while i < n: + if text.startswith("//", i): + end = text.find("\n", i) + end = n if end < 0 else end + elif text.startswith("/*", i): + depth, end = 1, i + 2 + while end < n and depth: + if text.startswith("/*", end): + depth, end = depth + 1, end + 2 + elif text.startswith("*/", end): + depth, end = depth - 1, end + 2 + else: + end += 1 + elif (raw := RAW_STRING.match(text, i)) is not None: + close = '"' + raw.group(1) + found = text.find(close, raw.end()) + end = n if found < 0 else found + len(close) + elif text[i] == '"': + end = i + 1 + while end < n: + if text[end] == "\\": + end += 2 + elif text[end] == '"': + end += 1 + break + else: + end += 1 + elif text[i] == "'": + # A char literal closes; a lifetime (`'a`) does not, and skipping to + # the next quote would blank everything between two of them. + if text.startswith("'\\", i): + close = text.find("'", i + 2) + end = n if close < 0 else close + 1 + elif i + 2 < n and text[i + 2] == "'": + end = i + 3 + else: + i += 1 + continue + else: + i += 1 + continue + blank(i, end) + i = max(end, i + 1) + return "".join(out) + + +CFG_TEST = re.compile(r"#\[cfg\(test\)\]") + + +def _blank_test_items(code: str) -> str: + """`code` with every `#[cfg(test)]`-gated item blanked out. + + Test code is not the runtime. A spy that records what it was handed is shared + state by shape and a back channel by no definition, and making each one argue + for itself spends the reader's attention where there is nothing to decide — + and inflates the exception count, whose whole value is that it is small enough + to be read. + + Only `#[cfg(test)]` is skipped. `#[cfg(any(test, feature = "test-helpers"))]` + is *not*: it compiles into a real library build, so it is production code that + tests happen to also use, and it is checked like any other. + + The gated item is found by matching its braces (or its `;`, for a gated `use`), + because a `#[cfg(test)]` is not always the trailing `mod tests` — it also gates + single functions mid-file. + """ + out = list(code) + for attr in CFG_TEST.finditer(code): + end, depth, started = attr.end(), 0, False + while end < len(code): + ch = code[end] + if ch == "{": + depth, started = depth + 1, True + elif ch == "}": + depth -= 1 + if depth == 0: + end += 1 + break + elif ch == ";" and not started: + end += 1 + break + end += 1 + for k in range(attr.start(), min(end, len(code))): + if out[k] != "\n": + out[k] = " " + return "".join(out) + + +# How far past a cell's opening bracket to look for its closing one. A wrapped +# type spans a few lines at most; scanning further would let an unbalanced `<` +# elsewhere in the file pair up with something unrelated and report a nonsense +# inner type instead of the honest ``. +INNER_SCAN_CHARS = 400 + + +def check_file(path: Path, text: str) -> tuple[list[Finding], list[Finding]]: + """`(findings, exceptions)` for one file. + + An *exception* is a site that matched a shape and was excused by a + `shared-state-ok` on it. They are returned rather than merely suppressed + because the set of them is itself gated — see [`EXPECTED_EXCEPTIONS`]. + """ + findings: list[Finding] = [] + exceptions: list[Finding] = [] + lines = text.splitlines() + rel = str(path.relative_to(ROOT)) + # Scanned over the whole file rather than line by line, because rustfmt wraps + # a long type: `Rc<\n RefCell<…>>` is the same cell as the one-line + # spelling, and matching per line would not see it at all — the opposite of + # the deliberate `` report, which at least fails the gate. + code = _blank_test_items(_code_view(text)) + + def record(idx: int, what: str) -> None: + # `_justified` reads the *raw* lines: the annotation is a comment, and the + # code view has blanked every comment away. + (exceptions if _justified(lines, idx) else findings).append( + Finding(rel, idx + 1, lines[idx].strip(), what) + ) + + for pattern, what in ((SHARED_CELL_OPEN, "shared cell"), (BARE_CELL_OPEN, "cell")): + for match in pattern.finditer(code): + idx = code.count("\n", 0, match.start()) + raw = _inner_type(code[: match.end() + INNER_SCAN_CHARS], match.end()) + inner = _normalize(raw) if raw is not None else "" + if inner in ALLOWED_INNER: + continue + record(idx, f"{what} of `{inner}`") + for match in AMBIENT.finditer(code): + record(code.count("\n", 0, match.start()), "ambient mutable state") + findings.sort(key=lambda f: f.line) + exceptions.sort(key=lambda f: f.line) + return findings, exceptions + + +def main() -> int: + root = ROOT / SCANNED_DIR + if not root.is_dir(): + print(f"shared-state: {SCANNED_DIR} not found (run from the repo)", file=sys.stderr) + return 1 + findings: list[Finding] = [] + exceptions: list[Finding] = [] + scanned = 0 + for path in sorted(root.rglob("*.rs")): + scanned += 1 + found, excused = check_file(path, path.read_text(encoding="utf-8")) + findings.extend(found) + exceptions.extend(excused) + if findings: + print("shared-state: unjustified shared mutable state in the interpreter\n") + for f in findings: + print(f" {f.path}:{f.line}: {f.what}") + print(f" {f.text}") + print( + "\nData flows between operators only as tiles, pulled by `get` " + '(src/interpreter/CLAUDE.md, "Core invariant: data flows between ' + 'operators as Tiles, nothing else").\n' + "If this is genuinely not an operator-to-operator back channel, say why:\n" + " // shared-state-ok: \n" + "and if the reason is a *kind* that will recur, add it to ALLOWED_INNER " + "in this checker instead." + ) + return 1 + have = Counter((f.path, f.what) for f in exceptions) + added = sorted((have - Counter(EXPECTED_EXCEPTIONS)).elements()) + removed = sorted((Counter(EXPECTED_EXCEPTIONS) - have).elements()) + if added or removed: + print("shared-state: the justified-exception list is out of date.\n") + for path, what in added: + print(f" + {path}: {what}") + for path, what in removed: + print(f" - {path}: {what}") + if added: + print( + f"\n{len(added)} new exception(s). Before adding to EXPECTED_EXCEPTIONS " + "in this checker,\nsettle the question the list exists to force: is the " + "exception necessary,\nor is there a tile shape that removes the need for " + "it? A justification is not\na cost-free annotation — it is a permanent " + "hole in the invariant." + ) + if removed: + print( + f"\n{len(removed)} exception(s) no longer present — drop them from " + "EXPECTED_EXCEPTIONS.\nThe list only ratchets down." + ) + return 1 + print( + f"shared-state OK: {scanned} interpreter source file(s) checked, " + f"{len(exceptions)} justified exception(s)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/shared-state/test_check_shared_state.py b/.github/scripts/shared-state/test_check_shared_state.py new file mode 100644 index 000000000..9c8cf4f37 --- /dev/null +++ b/.github/scripts/shared-state/test_check_shared_state.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Unit tests for the shared-state checker. + +Run first in CI so a checker bug cannot mask a real back channel — or, worse, +manufacture a failure that sends someone editing correct code. +""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from check_shared_state import ( # noqa: E402 + _code_view, + _inner_type, + _justified, + _normalize, + check_file, +) + +FAKE = Path(__file__).resolve().parents[3] / "src" / "interpreter" / "fake.rs" + + +def findings(src: str) -> list[str]: + return [f.what for f in check_file(FAKE, src)[0]] + + +def exceptions(src: str) -> list[str]: + return [f.what for f in check_file(FAKE, src)[1]] + + +class InnerType(unittest.TestCase): + def test_takes_balanced_brackets_not_the_first_close(self) -> None: + # The bug this replaced: a non-greedy regex stopped at the first `>>`, + # yielding `Option>") + + def test_unterminated_type_is_none(self) -> None: + text = "x: Rc None: + self.assertEqual(_normalize("Box< dyn Consumer >"), "Box") + + def test_collapses_punctuation_spacing(self) -> None: + self.assertEqual( + _normalize("HashMap<(String,String) , RouteSender>"), + "HashMap<(String, String), RouteSender>", + ) + + +class Justification(unittest.TestCase): + def test_same_line(self) -> None: + self.assertEqual(findings("x: Rc>, // shared-state-ok: why"), []) + + def test_preceding_line(self) -> None: + self.assertEqual(findings("// shared-state-ok: why\nx: Rc>,"), []) + + def test_scans_up_past_doc_comments_and_attributes(self) -> None: + src = "// shared-state-ok: why\n/// docs\n#[allow(dead_code)]\nx: Rc>," + self.assertEqual(findings(src), []) + + def test_does_not_leak_across_a_code_line(self) -> None: + # An annotation two fields up must not silence an unrelated one below. + src = "// shared-state-ok: why\na: Rc>,\nb: Rc>," + self.assertEqual(findings(src), ["shared cell of `Other`"]) + + def test_bare_marker_without_a_reason_does_not_count(self) -> None: + self.assertEqual( + findings("// shared-state-ok:\nx: Rc>,"), + ["shared cell of `Rows`"], + ) + + +class Detection(unittest.TestCase): + def test_flags_the_retired_writer_buffer(self) -> None: + # The violation this checker exists for. + self.assertEqual( + findings("pub type BodyInputBuffer = Rc>;"), + ["shared cell of `WriterBuffer`"], + ) + + def test_allows_known_kinds(self) -> None: + src = ( + "pub type SharedConsumer = Rc>;\n" + "shared: Rc>,\n" + "source: Rc>,\n" + ) + self.assertEqual(findings(src), []) + + def test_flags_a_hand_rolled_cycle_slot(self) -> None: + # `CycleSlot` is the one definition of this cell (justified at its own + # declaration), so a second copy has to argue for itself rather than + # inherit the first one's reasoning. + self.assertEqual( + findings("type WriterSlot = Rc>>>;"), + ["shared cell of `Option>`"], + ) + + def test_flags_other_cell_kinds(self) -> None: + src = "a: Arc>,\nb: Rc>,\nc: Arc>," + self.assertEqual( + findings(src), + [ + "shared cell of `Rows`", + "shared cell of `usize`", + "shared cell of `Rows`", + ], + ) + + def test_flags_ambient_mutable_state(self) -> None: + self.assertEqual( + findings("static mut ROWS: usize = 0;"), ["ambient mutable state"] + ) + self.assertEqual( + findings("thread_local! { static ROWS: usize = 0; }"), + ["ambient mutable state"], + ) + + def test_flags_a_static_holding_an_interior_mutable_cell(self) -> None: + # A `static` needs no `mut` to be ambient: the cell supplies the + # mutability and the name supplies the reach. + self.assertEqual( + findings("static ROWS: Mutex> = Mutex::new(Vec::new());"), + ["ambient mutable state"], + ) + self.assertEqual( + findings("static ROWS: OnceLock> = OnceLock::new();"), + ["ambient mutable state"], + ) + + def test_flags_a_bare_cell_field(self) -> None: + # The owner supplies the sharing: `Arc` over a `State` holding a + # `Mutex` is `Arc>` with the layers swapped. + self.assertEqual(findings(" pending: Mutex,"), ["cell of `Rows`"]) + self.assertEqual(findings(" pub used: RefCell,"), ["cell of `bool`"]) + + def test_a_bare_cell_takes_a_justification_like_any_other(self) -> None: + self.assertEqual(findings("// shared-state-ok: why\n pending: Mutex,"), []) + + def test_a_local_binding_is_not_a_field(self) -> None: + self.assertEqual(findings(" let seen: RefCell = RefCell::new(rows);"), []) + + def test_ignores_commented_out_code(self) -> None: + self.assertEqual(findings("// x: Rc>,"), []) + + def test_reads_through_a_type_wrapped_across_lines(self) -> None: + self.assertEqual( + findings("x: Rc>>,"), + ["shared cell of `HashMap`"], + ) + + def test_flags_a_cell_whose_wrapper_rustfmt_split(self) -> None: + # The gap this closes: matching per line, `Rc<` and `RefCell<` land on + # different lines and the site is not seen *at all* — worse than the + # `` report below, which at least fails the gate. + self.assertEqual( + findings("x: Rc<\n RefCell,\n>,"), + ["shared cell of `Rows`"], + ) + + def test_reports_an_unbalanced_type_rather_than_guessing(self) -> None: + self.assertEqual( + findings("x: Rc`"], + ) + + + +class Exceptions(unittest.TestCase): + def test_a_justified_site_is_listed_not_dropped(self) -> None: + # The suppression and the listing are the same fact seen twice: an excused + # site produces no finding, and is exactly what `EXPECTED_EXCEPTIONS` + # enumerates. + src = "// shared-state-ok: why\nx: Rc>," + self.assertEqual(findings(src), []) + self.assertEqual(exceptions(src), ["shared cell of `Rows`"]) + + def test_an_allowlisted_kind_is_not_an_exception(self) -> None: + # A known-legitimate kind is not a hole in the invariant, so it does not + # need an entry. + src = "shared: Rc>," + self.assertEqual(findings(src), []) + self.assertEqual(exceptions(src), []) + + +class TestCode(unittest.TestCase): + def test_skips_a_cfg_test_module(self) -> None: + src = ( + "a: Rc>,\n" + "#[cfg(test)]\n" + "mod tests {\n" + " struct Spy {\n" + " log: Rc>>,\n" + " }\n" + "}\n" + ) + self.assertEqual(findings(src), ["shared cell of `Rows`"]) + + def test_skips_a_cfg_test_fn_without_swallowing_what_follows(self) -> None: + # `#[cfg(test)]` is not always the trailing `mod tests` — it also gates a + # single function mid-file, and the code after it is still production. + src = ( + "impl E {\n" + " #[cfg(test)]\n" + " fn for_test() -> Self {\n" + " let _: Rc>>;\n" + " }\n" + "}\n" + "b: Rc>,\n" + ) + self.assertEqual(findings(src), ["shared cell of `Rows`"]) + + def test_skips_a_cfg_test_use(self) -> None: + src = "#[cfg(test)]\nuse foo::Rc;\nb: Rc>,\n" + self.assertEqual(findings(src), ["shared cell of `Rows`"]) + + def test_does_not_skip_a_test_helpers_feature_gate(self) -> None: + # That configuration compiles into a real library build, so it is + # production code that tests also use. + src = '#[cfg(any(test, feature = "test-helpers"))]\npub fn h() {\n let _: Rc>;\n}\n' + self.assertEqual(findings(src), ["shared cell of `Rows`"]) + + def test_does_not_skip_cfg_not_test(self) -> None: + src = "#[cfg(not(test))]\nb: Rc>,\n" + self.assertEqual(findings(src), ["shared cell of `Rows`"]) + + +class CodeView(unittest.TestCase): + def test_blanks_a_brace_inside_a_string(self) -> None: + # The brace matching that skips a `#[cfg(test)]` item would otherwise + # desync on it and blank an arbitrary amount of the file. + src = ( + "#[cfg(test)]\n" + 'fn t() { let s = "}"; let _: Rc>; }\n' + "b: Rc>,\n" + ) + self.assertEqual(findings(src), ["shared cell of `Rows`"]) + + def test_blanks_a_raw_string(self) -> None: + self.assertEqual(findings('let s = r#"x: Rc>,"#;'), []) + + def test_blanks_a_block_comment(self) -> None: + self.assertEqual(findings("/* x: Rc>, */"), []) + + def test_a_lifetime_is_not_a_char_literal(self) -> None: + # Treating `'a` as an unterminated char literal would blank forward to + # the next quote and hide everything between. + src = "struct S<'a> { r: &'a u8 }\nb: Rc>,\n" + self.assertEqual(findings(src), ["shared cell of `Rows`"]) + + def test_preserves_line_numbers(self) -> None: + src = '// a comment\n/* block */\nx: Rc>,' + self.assertEqual(_code_view(src).count("\n"), src.count("\n")) + self.assertEqual(check_file(FAKE, src)[0][0].line, 3) + + +if __name__ == "__main__": + unittest.main(verbosity=0) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40a619e6a..ea7a5dbb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,13 @@ jobs: - name: Doc references run: ./ci.sh doc_refs + # Gate the interpreter's no-back-channel invariant (operators exchange + # tiles through `get`, never through shared mutable state). Python-only + # like the doc-ref check, so it runs here rather than behind the toolchain + # setup, and ahead of the paths filter below so it cannot be skipped. + - name: Shared state + run: ./ci.sh shared_state + # 1. Detect if changes are only in ignored paths # Skip CI for changes that don't affect code - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 diff --git a/ci.sh b/ci.sh index e63bcc69f..fe01ab970 100755 --- a/ci.sh +++ b/ci.sh @@ -69,6 +69,15 @@ ci_doc_refs() { python3 .github/scripts/doc-refs/check_doc_refs.py || return 1 } +# Gate the interpreter's no-back-channel invariant: operators exchange tiles +# through `get`, never through shared mutable state (`src/interpreter/CLAUDE.md`). +# Same ordering rule as `ci_doc_refs`: checker tests first, and `|| return 1` per +# command because `ci_all` disables errexit for this function's extent. +ci_shared_state() { + python3 .github/scripts/shared-state/test_check_shared_state.py || return 1 + python3 .github/scripts/shared-state/check_shared_state.py || return 1 +} + # Fast inner-loop gate for local iteration: format, lint (debug, lib+bins only), # and test. Deliberately skips the phases whose cost is compile-bound and rarely # relevant mid-iteration — the *release* clippy pass (~2x the debug one; only @@ -101,6 +110,9 @@ ci_all() { ci_doc_refs || failed=1 # shellcheck disable=SC2310 # intentional: || captures failure without exiting + ci_shared_state || failed=1 + # shellcheck disable=SC2310 + # intentional: || captures failure without exiting ci_fmt || failed=1 # shellcheck disable=SC2310 # intentional: || captures failure without exiting diff --git a/src/ccl/design/mutability.md b/src/ccl/design/mutability.md index ab708d01a..03fb95102 100644 --- a/src/ccl/design/mutability.md +++ b/src/ccl/design/mutability.md @@ -789,16 +789,19 @@ causal matcher (`letrec::check_letrec_causal`). | Letrec pattern | Engine | |---|---| | a binding referenced only via `get_prev_seq(𝑏, …)`, over a finite/stream induction domain | `InductionStore` — the position-driven changelog loop engine (read densely via `StoreDenseRead`) | -| commit-record bindings + `begin_` oracles + `Txn` histories read via `get_prev_txn` | the commit operator (`CommitOperator`, `TransactWriter`, cyclic `FanOut`, `StoreValueStream`) | +| commit-record bindings + `begin_` oracles + `Txn` histories read via `get_prev_txn` | the commit operator (`CommitOperator`, `TransactDriver`, `TransactWriter`, cyclic `FanOut`, `StoreValueStream`) | | a `Txn` history read out of a read-only block (any reading loop — a live request stream, a finite loop, or a standalone singleton) | the as-of read (`AsOf`), latching the store's value as of the reading transaction's position, indexed by the outer reading loop | | a non-causal cycle, or a causal shape loop planning does not know | compile error (no silent fallback) | ### The runtime engines -- **`InductionStore` (+ `StoreDenseRead`)** — the induction loop. It drives the loop *sequentially - inside the producer* — folding each position's prev-accumulator from its own commit engine, so - there is no cyclic `FanOut` — and writes a `Tile::Store` changelog (`init` at position 0; a - `commit: false` position carries the prior value forward). `StoreDenseRead` then folds that +- **`InductionDriver` + `InductionStore` (+ `StoreDenseRead`)** — the induction loop, as a cycle + through a `FanOut::new_cyclic`. The store consumes the body's decisions and writes a + `Tile::Store` changelog (`init` at position 0; a `commit: false` position carries the prior + value forward); the driver reads that changelog back to produce the body's `(prev…, item)` + input, taking the next position from the decided frontier and the prev-accumulator from the + value at it. The accumulator therefore crosses between them as a tile, like every other + operator-to-operator value, at one position per pull. `StoreDenseRead` then folds the changelog over the loop domain to the dense `𝐷 ⇀ 𝑉` stream (serving both a scalar-final `ExtractFinal` and a co-iterated `fan_in`). A single always-commit or commit-gated writer over a finite *or* async domain. @@ -1090,7 +1093,7 @@ Both a **finite** loop and an **async** (streaming) source drive an induction ac model treats a finite domain as a stream that terminates (§Liveness) — and every induction accumulator uses *one* realization: the changelog `InductionStore`. Plain, conditional, and feed-carrying loops over finite or async extents all route through it. The -drive reads its source by absolute domain position (async domains arrive unordered), reclaims the +driver reads its source by absolute domain position (async domains arrive unordered), reclaims the consumed prefix as it advances, and carries reply feeds as `__fire`-gated taps — see *Induction stores as a changelog* in `../../interpreter/design-operators.md`. diff --git a/src/interpreter/CLAUDE.md b/src/interpreter/CLAUDE.md index d935b20b2..1d308a40c 100644 --- a/src/interpreter/CLAUDE.md +++ b/src/interpreter/CLAUDE.md @@ -24,12 +24,15 @@ Concretely: reads the prev-acc directly from a `Rc>` instead of through `get`") is a violation, even if it works on the test cases at hand. Cyclic graphs go through `FanOut::new_cyclic` and the - re-entrancy machinery on `FanOutShared` — see - [`Recurse`]'s docs for the contract — but the data on the wire is - still a [`Tile`]. -- Constructor-time wiring (e.g., `Recurse::recursive_input_setter`) - passes `TileOperator` handles, not raw values. The operator graph is - static; values flow through it at `get` time. + re-entrancy machinery on `FanOutShared` — see [`FanOutReentrancy`]'s + docs for the contract — but the data on the wire is still a [`Tile`]. + A cyclic pull is served the fan's cached snapshot rather than + re-entering the inner producer, which is why such a cycle advances one + step per outer pull. +- Constructor-time wiring (a [`CycleSlot`], filled through its + `setter` once the rest of the cycle exists) passes `TileOperator` + handles, not raw values. The operator graph is static; values flow + through it at `get` time. - Side effects (I/O, sinks, notifications) live at the boundary — `compile_program` wires a `SinkConsumer` to the final operator, and `Scheduler::check_for_notifications` drives them. Operators diff --git a/src/interpreter/commit_operator.rs b/src/interpreter/commit_operator.rs index 4d6c113c7..1043eeaa6 100644 --- a/src/interpreter/commit_operator.rs +++ b/src/interpreter/commit_operator.rs @@ -42,19 +42,17 @@ //! [`CommitEngine::attempt`] is called. There is no parallelism; serialization //! semantics are validated deterministically. -use std::{ - cell::RefCell, - collections::{BTreeMap, HashMap, HashSet}, - rc::Rc, -}; +use std::collections::{BTreeMap, HashMap, HashSet}; use intervalsets::Bounding; use crate::ccl::F_WRITES; use crate::interpreter::{ BaseType, ColumnValue, Consumer, Extent, FunctionGuard, Predicate, Scheduler, SharedConsumer, - Tile, TileGuard, Tiling, Value, WakeupQueue, - tile_operators::{CyclicSequencingProducer, ProducerBase, TileOperator, TileProducer}, + Tile, TileGuard, Tiling, Value, WakeupQueue, forwarding_consumer, shared_consumer, + tile_operators::{ + CycleSlot, CyclicSequencingProducer, ProducerBase, TileOperator, TileProducer, + }, tuple_field, }; use crate::pretty_graph::VizOptions; @@ -68,10 +66,6 @@ use crate::interpreter::tile_operators::impl_producer_base; /// attempts start at `1`. pub type CommitTs = usize; -/// A writer-input slot, filled after construction via -/// [`CommitOperator::writer_input_setter`] (the cycle requires late wiring). -type WriterSlot = Rc>>>; - /// A transaction proposal, evaluated against a snapshot. /// /// A writer produces one of these by reading some keys at a decided snapshot and @@ -607,12 +601,6 @@ impl PrefixReleaseCursor { self.through.is_some_and(|r| pos <= r) } - /// The raw watermark (highest released position), for a caller doing - /// base-relative arithmetic against it. - fn through(&self) -> Option { - self.through - } - /// Advance the watermark from a released domain predicate, centralizing the /// one decision every commit-store reader shares. A fully-decided (`True`) /// release covers the whole domain — `release_all`, since no finite tick @@ -759,7 +747,7 @@ pub struct CommitOperator { /// empty). This is the op-conversion seeding path. init_ops: Vec<(Value, Box)>, output_tiling: Tiling, - writer_inputs: Vec, + writer_inputs: Vec>, } impl CommitOperator { @@ -776,9 +764,7 @@ impl CommitOperator { init, init_ops: Vec::new(), output_tiling, - writer_inputs: (0..n_writers) - .map(|_| Rc::new(RefCell::new(None))) - .collect(), + writer_inputs: (0..n_writers).map(|_| CycleSlot::new()).collect(), } } @@ -800,19 +786,14 @@ impl CommitOperator { init: HashMap::new(), init_ops, output_tiling, - writer_inputs: (0..n_writers) - .map(|_| Rc::new(RefCell::new(None))) - .collect(), + writer_inputs: (0..n_writers).map(|_| CycleSlot::new()).collect(), } } /// Wire writer `k`'s input. Call after the operator is boxed, so the writer /// can be built around a branch of the operator's store output (the cycle). pub fn writer_input_setter(&self, k: usize) -> impl FnOnce(Box) + use<> { - let slot = self.writer_inputs[k].clone(); - move |op| { - *slot.borrow_mut() = Some(op); - } + self.writer_inputs[k].setter() } } @@ -824,7 +805,7 @@ impl TileOperator for CommitOperator { fn subscribe( &mut self, _intent_guard: TileGuard, - mut consumer: Box, + consumer: Box, scheduler: &mut Scheduler, ) -> Box { // Wake this operator's consumer whenever any writer's (live) source @@ -835,7 +816,7 @@ impl TileOperator for CommitOperator { // tap off a live commit store would never be notified and would hang. // The store always has its initial value, so kick once immediately to // start the drain loop. - let consumer = Rc::new(RefCell::new(move || consumer.notify())); + let consumer = shared_consumer(consumer); consumer.borrow_mut().notify(); // Resolve the tick-0 store state: the concrete seed plus each scalar key's // init op (read once here; acyclic, so a single drain to a scalar value is @@ -862,11 +843,15 @@ impl TileOperator for CommitOperator { .iter() .enumerate() .map(|(k, slot)| { - let mut input = slot.borrow_mut().take().unwrap_or_else(|| { - panic!("CommitOperator: writer {k} not wired (call writer_input_setter)") + let mut input = slot.take().unwrap_or_else(|| { + panic!( + "CommitOperator: writer {k} is unwired at subscribe — either \ + `writer_input_setter({k})` was never called, or this operator is \ + being subscribed twice (the first subscribe takes the slot)" + ) }); let guard = input.tiling().universal_guard(); - input.subscribe(guard, Box::new(consumer.clone()), scheduler) + input.subscribe(guard, forwarding_consumer(&consumer), scheduler) }) .collect::>(); let n = writer_producers.len(); @@ -1092,7 +1077,7 @@ impl TileProducer for CommitProducer { /// domain arrives out of position order (a `HashMap` enumeration) may be processed /// in arrival order without affecting the result. /// -/// The **induction** drive must NOT use this: its recurrence `xₙ = f(xₙ₋₁, itemₙ)` +/// The **induction** driver must NOT use this: its recurrence `xₙ = f(xₙ₋₁, itemₙ)` /// is position-ordered, so it reads by absolute domain position via /// [`decode_source_positioned`], which sorts. The two look alike but carry opposite /// ordering requirements — do not swap one for the other. @@ -1120,11 +1105,11 @@ fn decode_source_items(tile: &Tile) -> Vec { /// Decode an iteration source tile into `(absolute domain position, item)` pairs, /// **sorted by position** — the ordered counterpart of [`decode_source_items`]. /// -/// The induction drive's recurrence is position-ordered, so it cannot use column +/// The induction driver's recurrence is position-ordered, so it cannot use column /// order: an **async** source's domain arrives *unordered* (it enumerates a set of /// arrived keys) and *compacts* as its consumed prefix is released, so column order /// is not position order. Pairing each item with its actual `UInt` domain position -/// and sorting makes the drive read `x₀, x₁, …` in order regardless of arrival. A +/// and sorting makes the driver read `x₀, x₁, …` in order regardless of arrival. A /// finite list is the special case (its domain is already `[0, 1, …]`). Contrast /// [`decode_source_items`], which the *transaction* writer uses because commit /// order is unordered. @@ -1169,21 +1154,16 @@ fn source_value_at(codomain: &Tile, i: usize) -> Value { /// driven by *iteration position* rather than by concurrent proposals. /// /// There is exactly one writer, visiting each iteration position once in order: -/// no proposals, no conflicts, no retries. The accumulator recurrence — position -/// `i` reads `xᵢ₋₁` and decides `xᵢ` — is driven **sequentially inside the -/// producer**: the driver holds the engine, folds the previous accumulator out of -/// it ([`CommitEngine::read_as_of`], defaulting below the earliest change to the -/// key's init), feeds the body `(prev…, item)` through a [`BodyInputBuffer`], reads -/// the body's `` {`commit{writes} | `abort} `` decision ([`body_decision_at`] decodes -/// the union tag), and [`step`](CommitEngine::step)s the engine — a `.Commit` -/// position appends a change, an `` `abort `` (a failed guard) is a **carry** (no -/// change; the value inherits). +/// no proposals, no conflicts, no retries. The store is the *consuming* half of +/// the recurrence — it reads the body's `` {`commit{writes} | `abort} `` +/// decision ([`body_decision_at`] decodes the union tag) and +/// [`step`](CommitEngine::step)s the engine, a `` `commit `` appending a change +/// and an `` `abort `` (a failed guard) **carrying** (no change; the value +/// inherits). Its cycle partner [`InductionDriver`] produces the body's +/// `(prev…, item)` input from the changelog this store emits, read back through +/// a `FanOut::new_cyclic` — so the accumulator crosses between them as a tile, +/// like every other operator-to-operator value. /// -/// The key structural difference from the retired dense `Recurse` realization: -/// the accumulator lives in the engine, not on a cyclic tile, so there is **no -/// cyclic `FanOut`** — the previous value is always available before the body -/// needs it, and a conditional write's carry positions simply produce no change -/// rather than having to synthesize a same-value "write" on a complement leg. /// A plain (unconditional) `mut` loop is the degenerate `` `commit ``-everywhere /// case (a dense changelog); a conditional write is sparse in position space /// (`` `abort `` positions append nothing) while the frontier still tracks the whole extent. @@ -1193,16 +1173,12 @@ pub struct InductionStore { /// like [`CommitOperator::with_init_ops`]). Written in `write_keys` order. init_ops: Vec<(Value, Box)>, /// The writer body `` λ (prev…, item) → {`commit{writes(, to_…)} | `abort} ``, - /// compiled around a [`BodyInputSource`] over `buffer`. - body_op: Box, - /// The iteration source `Fun(D, item)` — the loop extent's items in order. - source_op: Box, - /// The body-input buffer the driver pushes `(prev…, item)` rows onto. - buffer: BodyInputBuffer, - /// Accumulator keys the body reads a snapshot of, in body-parameter order - /// (for an induction store these are exactly the accumulators it writes). - read_keys: Vec, - /// Keys written, in decision-`writes` order: the carry keys, then + /// compiled around an [`InductionDriver`]. Filled after construction through + /// [`body_input_setter`](Self::body_input_setter): the body reads the driver, + /// which reads this store back through the cycle, so it cannot exist yet + /// when the store is built. + body_input: CycleSlot, + /// Keys written, in decision-`writes` order: the accumulator mutable variables, then /// any reply-tap (`to_`) keys. write_keys: Vec, /// Reply-tap decision fields, appended to each write set (see @@ -1212,16 +1188,11 @@ pub struct InductionStore { } impl InductionStore { - /// Assemble an induction store. `init_ops`/`read_keys`/`write_keys` follow the - /// same conventions as the commit store's writer, with `read_keys ==` the - /// accumulator keys and `write_keys` the accumulators followed by tap keys. - #[allow(clippy::too_many_arguments)] + /// Assemble an induction store. `init_ops`/`write_keys` follow the same + /// conventions as the commit store's writer, with `write_keys` the + /// accumulators followed by tap keys. pub fn new( init_ops: Vec<(Value, Box)>, - body_op: Box, - source_op: Box, - buffer: BodyInputBuffer, - read_keys: Vec, write_keys: Vec, tap_fields: Vec, key_extent: Extent, @@ -1230,15 +1201,19 @@ impl InductionStore { let output_tiling = full_store_tiling(&key_extent, &value_extent); Self { init_ops, - body_op, - source_op, - buffer, - read_keys, + body_input: CycleSlot::new(), write_keys, tap_fields, output_tiling, } } + + /// Install the decision body, which reads this store back through the cyclic + /// `FanOut` — the same late wiring [`CommitOperator::writer_input_setter`] + /// performs, and for the same reason. + pub fn body_input_setter(&self) -> impl FnOnce(Box) + use<> { + self.body_input.setter() + } } impl TileOperator for InductionStore { @@ -1249,17 +1224,16 @@ impl TileOperator for InductionStore { fn subscribe( &mut self, _intent_guard: TileGuard, - mut consumer: Box, + consumer: Box, scheduler: &mut Scheduler, ) -> Box { - // Forward source/body progress to this store's consumer: an async loop - // source (a data source arriving over scheduler notifications) delivers - // its elements incrementally, and each arrival must wake a downstream - // reader so it re-pulls and the drive loop processes the new positions. - // Without this the store stalls at whatever prefix arrived by the first - // pull (a batch/list source is complete on the first pull, so it never - // needed the wiring — but an async source does). Kick once to start. - let consumer = Rc::new(RefCell::new(move || consumer.notify())); + // Forward body progress to this store's consumer. The body's input is + // the driver, which forwards the loop source's arrivals, so an async + // source's incremental delivery reaches a downstream reader and it + // re-pulls. Without this the store stalls at whatever prefix arrived by + // the first pull (a batch/list source is complete on the first pull, so + // it never needed the wiring — an async source does). Kick once to start. + let consumer = shared_consumer(consumer); consumer.borrow_mut().notify(); // Resolve each accumulator's tick-0 fold default. The init op is acyclic // (it never reads the store), so a single drain to a scalar is sound — @@ -1280,15 +1254,14 @@ impl TileOperator for InductionStore { }); inits.insert(key, value); } - let source_producer = { - let g = self.source_op.tiling().universal_guard(); - self.source_op - .subscribe(g, Box::new(consumer.clone()), scheduler) - }; + let mut body_op = self.body_input.take().expect( + "InductionStore: the decision body is unwired at subscribe — either \ + `body_input_setter` was never called, or this store is being subscribed \ + twice (the first subscribe takes the slot)", + ); let body_producer = { - let g = self.body_op.tiling().universal_guard(); - self.body_op - .subscribe(g, Box::new(consumer.clone()), scheduler) + let g = body_op.tiling().universal_guard(); + body_op.subscribe(g, forwarding_consumer(&consumer), scheduler) }; Box::new(InductionStoreProducer { base: ProducerBase::new(InductionStoreProducer::alloc_id(), &self.output_tiling), @@ -1296,19 +1269,12 @@ impl TileOperator for InductionStore { // self-describing: `read_as_of`/`store_value_at` fold to the init below // the first *iteration* change (a leading carry) without an external // default. Iterations therefore occupy ticks 1.., a `+ 1` offset the - // drive loop and the dense read both apply. + // driver and the dense read both apply. engine: CommitEngine::new(inits), body_producer, - source_producer, - buffer: self.buffer.clone(), - read_keys: self.read_keys.clone(), write_keys: self.write_keys.clone(), tap_fields: self.tap_fields.clone(), output_tiling: self.output_tiling.clone(), - processed: 0, - source_complete: false, - released_through: None, - source_fully_released: false, }) } } @@ -1317,43 +1283,27 @@ struct InductionStoreProducer { base: ProducerBase, engine: CommitEngine, body_producer: Box, - source_producer: Box, - buffer: BodyInputBuffer, - read_keys: Vec, write_keys: Vec, tap_fields: Vec, /// The full-store output tiling — for a debug-time shape check on the rendered /// store tile. output_tiling: Tiling, - /// Iteration positions already fed to the body and stepped into the engine. - /// Monotonic; the drive resumes here each pull as the source grows. - processed: usize, - /// Whether the iteration source is complete (its most recent pull was terminal). A - /// batch source (a list — the usual loop extent) is complete on the first pull. - source_complete: bool, - /// Highest source position released back upstream (the reclaimed prefix). The - /// drive drives positions strictly forward and never re-reads a position - /// `< processed`, so that prefix is obsolete and released incrementally - /// (bounding source retention on a long async loop, the same reclamation the - /// dense `Recurse` path performed). A co-iterated reader still holding earlier - /// positions keeps them live via the source's cross-producer release - /// intersection. `None` until the first release. - released_through: Option, - /// Whether the whole source has been released (`True`) after the loop reached - /// its terminal end-state — the finite loop's `get_released_predicate() == True` - /// invariant; issued once. - /// - /// It also *ends the drive*: the release is only issued when the source is - /// complete and every arrived position has been decided, so there is nothing - /// left to read — a source honoring the release answers empty, and one that - /// re-answers would have the drive re-fold positions it already decided. - /// Later pulls serve the accumulated store, which is already the whole answer - /// (the same promise the dense `Recurse` path kept once its recurrence - /// converged). - source_fully_released: bool, } impl InductionStoreProducer { + /// Iteration positions already stepped into the engine — equivalently, the + /// next position for the driver to emit. + /// + /// Read off the engine rather than counted alongside it. [`CommitEngine::step`] + /// advances the watermark unconditionally and iteration `p` occupies tick + /// `p + 1`, so the watermark *is* this count, and it is the same number the + /// driver folds out of the rendered frontier to pick its next position. A + /// counter kept here in parallel would be a second cursor for one thing, free + /// to disagree with the one the store publishes. + fn processed(&self) -> usize { + self.engine.watermark() + } + /// The engine's accumulated store as a tile, marked `terminal` once the /// recurrence is final (the accumulator can no longer change, so a downstream /// `ExtractLast` / `final_or_default` resolves). @@ -1374,77 +1324,21 @@ impl TileProducer for InductionStoreProducer { impl_producer_base!(); fn get_impl(&mut self, _projection_guard: TileGuard) -> Tile { - // The drive is over once the source has been universally released: pulling - // it again would break that release's promise, and every position is already - // decided, so the accumulated store is the whole answer. - if self.source_fully_released { - return self.render_store(true); - } - let src = self - .source_producer - .get(self.source_producer.tiling().universal_guard()); - self.source_complete = src.is_terminal(); - // Pair each item with its absolute domain position and sort. An async - // source's domain arrives unordered, so we drive by position, not by the - // codomain's column order (see [`decode_source_positioned`]). - let by_pos: HashMap = decode_source_positioned(&src).into_iter().collect(); - // Invariant: an induction source domain has **no interior hole** — a - // finite list `[0, N)` or an async `DataSource`'s UInt domain only ever - // gaps at the *trailing* end (positions not yet arrived). The drive relies - // on this (it stops at the first missing `processed` and resumes when it - // arrives); a permanent interior hole would stall forever. The arrived set - // is *not* a prefix from 0: `get_impl` releases the consumed prefix below, - // so a later pull sees a shifted window (e.g. `{5, 6, 7}`). Assert the - // sorted positions form a contiguous run from their retained base instead. - debug_assert!( - !self.source_complete || { - let mut ks: Vec = by_pos.keys().copied().collect(); - ks.sort_unstable(); - ks.windows(2).all(|w| w[1] == w[0] + 1) - }, - "induction source domain has an interior gap: {:?}", - { - let mut ks: Vec = by_pos.keys().copied().collect(); - ks.sort_unstable(); - ks - } - ); - // Drive each not-yet-processed position **in contiguous order**. Tick 0 is - // the seeded init, so iteration `pos` occupies tick `pos + 1`: it reads the - // previous accumulator as of tick `pos` (the init at `pos == 0`, else the - // latest change ≤ that tick — a leading carry inherits the seed), feeds the - // body, and steps tick `pos + 1`. The engine must be stepped through `pos` - // before we fold, hence the sequential push-body-step loop. The body's - // decision gates commit (append the change) vs carry (`step(_, None)` — the - // value inherits from tick 0 / the latest earlier change). Stop at the first - // gap (position `processed` not yet arrived): the recurrence is sequential, - // so a later position cannot be decided before its predecessor. - while let Some(item) = by_pos.get(&self.processed) { - let pos = self.processed; - let snap_in: Vec = self - .read_keys - .iter() - .map(|k| { - self.engine - .read_as_of(pos, k) - .expect("tick 0 seeds every accumulator, so a prev read always resolves") - }) - .collect(); - self.buffer.borrow_mut().rows.push((snap_in, item.clone())); - let body_tile = self - .body_producer - .get(self.body_producer.tiling().universal_guard()); - let Some((commit, writes, tap_fired)) = - body_decision_at(&body_tile, pos, &self.tap_fields) - else { - // The decision for a freshly-pushed row of a self-contained - // induction body is always ready on the pull. A `None` means the - // body has not converged at this position — for the loop shapes - // that reach here (no cross-loop broadcast in the body) this does - // not happen, so stop the drive and re-render; the harness re-pulls - // a non-terminal store to make progress. - break; - }; + let body_tile = self + .body_producer + .get(self.body_producer.tiling().universal_guard()); + // Consume the body's decisions **in contiguous order** from `processed`, + // stopping at the first position it has not decided. Tick 0 is the + // seeded init, so iteration `pos` occupies tick `pos + 1`; the decision + // gates commit (append the change) vs carry (`step(_, None)` — the value + // inherits from tick 0 / the latest earlier change). The driver emits one + // position per pull, so this normally steps once; consuming a run costs + // nothing extra and keeps the store's rule independent of that rate. + let started_at = self.processed(); + while let Some((commit, writes, tap_fired)) = + body_decision_at(&body_tile, self.processed(), &self.tap_fields) + { + let pos = self.processed(); let write_set: Option> = if commit { debug_assert_eq!( writes.len(), @@ -1490,45 +1384,48 @@ impl TileProducer for InductionStoreProducer { None // the accumulator holds from tick 0 / the latest change }; self.engine.step(pos + 1, write_set); - self.processed = pos + 1; - } - // Incrementally reclaim the processed prefix of the source. The drive only - // ever pulls position `processed` forward and never re-reads a position - // `< processed`, so `[0, processed)` is obsolete to *this* producer; - // releasing it bounds source retention on a long async loop (matching the - // dense `Recurse` path). The source drops a row only once every producer - // releases it (cross-producer intersection), so a co-iterated reader still - // folding earlier positions keeps them live. - let unused = self.processed == 0 - || self - .released_through - .is_some_and(|r| r + 1 >= self.processed); - if !unused { - self.source_producer + debug_assert_eq!( + self.processed(), + pos + 1, + "a step at iteration {pos} advances the decided count to {}", + pos + 1 + ); + } + // Reclaim the decisions just consumed. This release travels back through + // the body to the driver, which compacts its emitted window and releases + // the loop source in turn — the whole reclamation chain, on ordinary + // edges. + if self.processed() > started_at { + self.body_producer .release(TileGuard::Function(FunctionGuard::Domain( - Predicate::LessThanEq(Value::UInt(self.processed - 1)), + Predicate::LessThanEq(Value::UInt(self.processed() - 1)), ))); - self.released_through = Some(self.processed - 1); - } - // Signal terminality once the source is complete and every arrived position - // has been decided: the accumulator is final, so the frontier *closes* - // (`terminal`) and a downstream `ExtractFinal`/`final_or_default` resolves. - // The frontier keeps its `LessThanEq(w)` watermark, which spans the whole - // extent including a trailing run of carries — so `len`/`store_frontier` - // no longer undercount to the latest change tick when the tail is all carry. - // A terminal source has a gapless domain, so having driven every contiguous - // position (`by_pos` no longer holds `processed`) means the whole extent is - // decided — robust to the incremental prefix release above shrinking - // `by_pos`. - let done = self.source_complete && !by_pos.contains_key(&self.processed); - // Final reclamation: once terminal, release the *whole* source (`True`) so a - // finite loop reaches the `get_released_predicate() == True` end-state (the - // incremental prefix release above stops one short of a `True` predicate). - if done && !self.source_fully_released { - self.source_producer - .release(TileGuard::Function(FunctionGuard::Domain(Predicate::True))); - self.source_fully_released = true; } + // Signal terminality once the body's decision stream is final and every + // position in it has been decided: the accumulator is final, so the + // frontier *closes* (`terminal`) and a downstream + // `ExtractFinal`/`final_or_default` resolves. The frontier keeps its + // `LessThanEq(w)` watermark, which spans the whole extent including a + // trailing run of carries — so `len`/`store_frontier` do not undercount + // to the latest change tick when the tail is all carry. The driver closes + // its body-input domain once a complete source has been fully emitted, + // and that terminality rides the body chain down to here; the loop above + // has consumed every decision, so a terminal body stream means the whole + // extent is decided. + let done = body_tile.is_terminal(); + // Reading a terminal body stream as "the whole extent is decided" rests on + // that stream being **gapless**. The loop above stops at the first undecided + // position, so a terminal stream with a hole at `processed` and decisions past + // it would close the frontier an iteration short and drop them without a + // sound. The driver emits contiguously (and asserts its source's gaplessness), + // so this holds by construction — asserted here because it is here that it is + // relied on. + debug_assert!( + !done || !decides_beyond(&body_tile, self.processed()), + "induction store: the body's decision stream is terminal with a hole at \ + position {} and decisions past it", + self.processed() + ); self.render_store(done) } @@ -1538,8 +1435,9 @@ impl TileProducer for InductionStoreProducer { // what every reader (dense accumulator reads, reply-tap reads) has released // — the tick prefix safe to reclaim. `gc_released_prefix` drops the // superseded entries in that prefix but **keeps each key's latest write**, - // which is exactly what the drive's own recurrence needs (`read_as_of( - // processed)` folds to the latest ≤ processed), so the GC never strands the + // which is exactly what the driver needs (it folds `store_value_at` at the + // frontier, and a change at or below the frontier is that key's latest — + // the store writes no tick above its watermark), so the GC never strands the // recurrence — bounding a never-terminating streaming loop's changelog to // O(keys) + the slowest reader's lag. (A scalar-final `ExtractFinal` reader // holds the whole stream until terminal, so it releases nothing early — but @@ -1612,7 +1510,7 @@ impl TileOperator for StoreValueStream { fn subscribe( &mut self, _intent_guard: TileGuard, - mut consumer: Box, + consumer: Box, scheduler: &mut Scheduler, ) -> Box { // Forward store progress to this stream's consumer: a new commit on the @@ -1621,12 +1519,12 @@ impl TileOperator for StoreValueStream { // value. Without this, a tap/key reader off a live commit store is only // woken once (the kick) and never again. The store starts at its tick-0 // value, so kick once to start the drain loop. - let consumer = Rc::new(RefCell::new(move || consumer.notify())); + let consumer = shared_consumer(consumer); consumer.borrow_mut().notify(); let g = self.store_op.tiling().universal_guard(); let store_producer = self .store_op - .subscribe(g, Box::new(consumer.clone()), scheduler); + .subscribe(g, forwarding_consumer(&consumer), scheduler); Box::new(StoreValueStreamProducer { base: ProducerBase::new(StoreValueStreamProducer::alloc_id(), &self.tiling), store_producer, @@ -1800,18 +1698,18 @@ impl TileOperator for StoreFinalRead { fn subscribe( &mut self, _intent_guard: TileGuard, - mut consumer: Box, + consumer: Box, scheduler: &mut Scheduler, ) -> Box { // Forward store progress downstream, as [`StoreValueStream`] does: the key is // not settled until the store says so, and the consumer has to be woken to // re-pull when that happens. Kick once to start the drain loop. - let consumer = Rc::new(RefCell::new(move || consumer.notify())); + let consumer = shared_consumer(consumer); consumer.borrow_mut().notify(); let g = self.store_op.tiling().universal_guard(); let store_producer = self .store_op - .subscribe(g, Box::new(consumer.clone()), scheduler); + .subscribe(g, forwarding_consumer(&consumer), scheduler); Box::new(StoreFinalReadProducer { base: ProducerBase::new(StoreFinalReadProducer::alloc_id(), &self.tiling), store_producer, @@ -1957,22 +1855,22 @@ impl TileOperator for StoreDenseRead { fn subscribe( &mut self, _intent_guard: TileGuard, - mut consumer: Box, + consumer: Box, scheduler: &mut Scheduler, ) -> Box { // Wake the consumer on store progress (a new decided position) and on // trigger progress. Route both through a shared notifier, and kick once. - let consumer = Rc::new(RefCell::new(move || consumer.notify())); + let consumer = shared_consumer(consumer); consumer.borrow_mut().notify(); let trigger_producer = { let g = self.trigger.tiling().universal_guard(); self.trigger - .subscribe(g, Box::new(consumer.clone()), scheduler) + .subscribe(g, forwarding_consumer(&consumer), scheduler) }; let store_producer = { let g = self.store_op.tiling().universal_guard(); self.store_op - .subscribe(g, Box::new(consumer.clone()), scheduler) + .subscribe(g, forwarding_consumer(&consumer), scheduler) }; Box::new(StoreDenseReadProducer { base: ProducerBase::new(StoreDenseReadProducer::alloc_id(), &self.tiling), @@ -2016,12 +1914,12 @@ impl TileProducer for StoreDenseReadProducer { else { return self.tiling().empty_tile(); }; - // Sample the induction store once (consumer-driven; no producer-side - // drive-to-fixpoint), then fold `key` at each position. The induction writer - // drives its whole loop per pull (every arrived position steps the engine in - // one `get_impl`), so a batch source converges in this single sample; an - // async source's later arrivals re-pull us through the store's - // source-forwarding consumer. Iterations occupy ticks 1.. (tick 0 is the + // Sample the store (consumer-driven; no producer-side drive-to-fixpoint), + // then fold `key` at each *decided* position. The cycle advances one + // position per pull, so a batch source converges over several pulls rather than in + // one sample — the read grows across pulls, and the decidedness filter below is + // what keeps each emission final. Later arrivals and later positions re-pull + // us through the store's source-forwarding consumer. Iterations occupy ticks 1.. (tick 0 is the // seeded init), so loop position `p` reads tick `p + 1` — the accumulator // *after* iteration `p`. `store_value_at` scans changes ≤ that tick, so a // carry position inherits the latest earlier write, and a leading carry folds @@ -2044,6 +1942,18 @@ impl TileProducer for StoreDenseReadProducer { }) .collect(); sorted.sort_unstable(); + // **Only decided positions may be emitted.** Position `p` reads tick + // `p + 1`, so it is decided exactly when `p + 1 <= frontier`. Folding an + // *undecided* position would resolve it to the carried earlier value and + // then contradict that value once the position really commits — a changed + // value at a known position, which the tile contract forbids. The store + // advances one position per pull, so mid-loop this filter is doing real + // work: without it a `Memo` above this read latches the seed for every + // position on the first pull, releases them so they are never re-emitted, + // and publishes that stale cache as complete when the store closes. + let decided_through = store_frontier(&store); + // `p + 1 <= w` written as `p < w`, which is the same test one `+ 1` shorter. + sorted.retain(|p| decided_through.is_some_and(|w| *p < w)); // Fold `key` at every position's tick `p + 1` in one ascending pass (the // shared [`fold_changelog_key_ascending`]): an accumulator carries the // latest write ≤ that tick (every position resolves — tick 0 seeds it); a @@ -2087,13 +1997,17 @@ impl TileProducer for StoreDenseReadProducer { }) .collect(); } - // The dense read is decided over `D` once the store is terminal (every - // position folded to its final value); until then it tracks the trigger's - // own completion but stays non-terminal so the consumer re-pulls. + // Once the store is terminal every position has folded to its final value, + // so the read is as decided as its trigger. Before that it is decided over + // exactly the positions emitted above — which are final, by the filter — so + // report those rather than `False`: a consumer may consume the prefix, and a + // `Memo` may cache it, without waiting for the loop to end. Reporting the + // emitted set rather than a `LessThanEq` bound keeps it honest when the + // trigger has not yet delivered every position below the frontier. let domain_predicate = if store.is_terminal() { trigger_pred } else { - Predicate::False + Predicate::from_column_value(&positions) }; Tile::SealedFunction { domain: positions, @@ -2313,7 +2227,7 @@ impl TileOperator for AsOf { fn subscribe( &mut self, _intent_guard: TileGuard, - mut consumer: Box, + consumer: Box, scheduler: &mut Scheduler, ) -> Box { // Both inputs wake the consumer: a new *trigger* position needs a fresh @@ -2321,15 +2235,15 @@ impl TileOperator for AsOf { // already-seen trigger position finally latch a value — and, crucially, // re-pulls until the cyclic store converges (the source's first pull may // only propose; later pulls commit and render). - let consumer = Rc::new(RefCell::new(move || consumer.notify())); + let consumer = shared_consumer(consumer); let tg = self.trigger.tiling().universal_guard(); let trigger = self .trigger - .subscribe(tg, Box::new(consumer.clone()), scheduler); + .subscribe(tg, forwarding_consumer(&consumer), scheduler); let sg = self.source.tiling().universal_guard(); let source = self .source - .subscribe(sg, Box::new(consumer.clone()), scheduler); + .subscribe(sg, forwarding_consumer(&consumer), scheduler); let b_extent = match &self.tiling { Tiling::SealedFunction { domain, .. } => domain.clone(), _ => unreachable!("AsOf tiling is SealedFunction"), @@ -2574,167 +2488,740 @@ impl TileProducer for AsOfProducer { } } -/// Shared body-input feed for the fused [`TransactWriter`]: a sliding window of -/// `(store snapshot, item)` rows the writer appends to and feeds its body -/// through [`BodyInputSource`]. The body still consumes a `Tile` via `get`; this -/// is just how the writer constructs that input incrementally (analogous to how -/// `Recurse` feeds its body). +/// One emitted body-input row: the snapshot the decision reads, the source item +/// it is for, and that item's index. /// -/// `base` is the absolute position of `rows[0]`: rows the writer has already -/// consumed (read the body decision for) are compacted away, so the body-input -/// stays bounded on a long-lived store. Positions are absolute and stable — -/// [`BodyInputSource`] emits them as the domain and [`body_decision_at`] looks a -/// row up by value, not by column position. -#[derive(Default)] -pub struct WriterBuffer { - /// Absolute position of `rows[0]` — the count of consumed rows dropped. - pub base: usize, - /// The live `(snapshot, item)` rows; `rows[i]` is absolute position `base + i`. - pub rows: Vec<(Vec, Value)>, +/// The index is what makes a release interpretable on the transaction side (it +/// says *which* item a reclaimed row belongs to). On the induction side it is +/// the emit position itself, kept for uniformity — the window is a row or two, +/// so the duplication costs nothing and having one row type is what lets both +/// drivers share the rendering below. +struct DriverRow { + snapshot: Vec, + item: Value, + item_index: usize, } -pub type BodyInputBuffer = Rc>; - -/// The writer body's input: serves the buffer as -/// `SealedFunction(UInt → {_0: snap_{k₀}, …, _{r-1}: snap_{k_{r-1}}, _r: item})` -/// — the flat `(snapshot…, item)` tuple the body's `let kᵢ = p.i … let item = -/// p.r` shape expects (read keys followed by the iteration item). Idempotent: a -/// pull returns the current buffer, so it is safe to read repeatedly within a -/// round. -pub struct BodyInputSource { - tiling: Tiling, - buffer: BodyInputBuffer, +/// The live window of emitted `(read…, item)` rows, and the body-input tile it +/// renders. +/// +/// Both drivers own one. What differs between them is *which* row to emit next — +/// the induction driver takes it from the store's decided frontier, the +/// transaction driver from its acked item cursor — not how a window of rows +/// becomes the body's input, how positions stay absolute across compaction, or +/// what a release reclaims. Those are here, once. +/// +/// Positions are **absolute**: `rows[i]` is position `base + i`, and released +/// rows compact off the front without renumbering the rest, because the body +/// looks a decision up by domain *value* ([`body_decision_at`]). +struct DriverWindow { read_extents: Vec, item_extent: Extent, + /// Absolute position of `rows[0]`; released rows are compacted away, so the + /// retained window stays bounded on a long-running loop. + base: usize, + rows: Vec, + /// Highest absolute position a consumer has released. The body fans this + /// input through a `Memo` that pulls it several times per round, so an + /// already-released position must not re-emit — that would duplicate a + /// domain position in the `Memo`'s append-merge. + release_cursor: PrefixReleaseCursor, } -impl BodyInputSource { - pub fn new(buffer: BodyInputBuffer, read_extents: Vec, item_extent: Extent) -> Self { - let tiling = Tiling::SealedFunction { - domain: Extent::Base(BaseType::UInt), - codomain: Box::new(Tiling::Record(body_input_fields( - &read_extents, - &item_extent, - Tiling::Scalar, - ))), - }; +impl DriverWindow { + fn new(read_extents: Vec, item_extent: Extent) -> Self { Self { - tiling, - buffer, read_extents, item_extent, + base: 0, + rows: Vec::new(), + release_cursor: PrefixReleaseCursor::default(), + } + } + + /// The next absolute position to emit at — one past the window's end. + fn next_position(&self) -> usize { + self.base + self.rows.len() + } + + /// Append a row, returning the absolute position it landed at. + fn push(&mut self, snapshot: Vec, item: Value, item_index: usize) -> usize { + debug_assert_eq!( + snapshot.len(), + self.read_extents.len(), + "a body-input row carries one snapshot value per read key" + ); + let pos = self.next_position(); + self.rows.push(DriverRow { + snapshot, + item, + item_index, + }); + pos + } + + /// The newest live row with its absolute position, or `None` when the window + /// is empty. + fn newest(&self) -> Option<(usize, &DriverRow)> { + self.rows.last().map(|r| (self.next_position() - 1, r)) + } + + /// Reclaim the prefix a release covers, keeping the survivors' positions + /// absolute. Returns the extent so a caller can do its own release-driven + /// work (the transaction driver's item-cursor advance) without re-deriving + /// the classification. + fn compact(&mut self, pred: &Predicate) -> ReleasedExtent { + let extent = self.release_cursor.advance_from(pred); + let drop_through = match extent { + ReleasedExtent::Nothing => return extent, + ReleasedExtent::All => self.rows.len(), + ReleasedExtent::Through(w) if w >= self.base => { + (w + 1 - self.base).min(self.rows.len()) + } + ReleasedExtent::Through(_) => return extent, + }; + self.rows.drain(..drop_through); + self.base += drop_through; + extent + } + + /// The window as the body's input tile, sealed once `done`. + /// + /// [`compact`](Self::compact) has already dropped the released prefix, so + /// every retained row is live: a re-pull within a round re-emits only what + /// the body has not merged, and an already-released position cannot come + /// back to duplicate a domain position in the body's `Memo`. + fn render(&self, done: bool) -> Tile { + // Column `i < r` is read key `i`'s snapshot; column `r` is the item. Same + // index that names the field, so the layout stays the tiling's. + let item = self.read_extents.len(); + let fields = body_input_fields(&self.read_extents, &self.item_extent, |i, ext| { + let column = self + .rows + .iter() + .map(|r| { + if i == item { + r.item.clone() + } else { + r.snapshot[i].clone() + } + }) + .collect(); + Tile::Scalar(ColumnValue::from_values(column, ext)) + }); + Tile::SealedFunction { + domain: ColumnValue::from_uints((self.base..self.next_position()).collect()), + codomain: Box::new(Tile::Record(fields)), + domain_predicate: if done { + Predicate::True + } else { + Predicate::False + }, + deleted: bit_set::BitSet::new(), } } } -/// The body-input codomain fields `{_0..._{r-1}: read, _r: item}`, built over -/// either tilings (`f = Tiling::Scalar`) or tiles. `r = read_extents.len()`. -fn body_input_fields( - read_extents: &[Extent], - item_extent: &Extent, - f: impl Fn(Extent) -> T, -) -> HashMap { - let mut fields: HashMap = HashMap::with_capacity(read_extents.len() + 1); - for (i, ext) in read_extents.iter().enumerate() { - fields.insert(tuple_field(i), f(ext.clone())); +/// The inputs every driver subscribes, wired the one way a driver's inputs are +/// wired. +/// +/// The **source** forwards its arrivals to the driver's consumer: an async loop +/// source or a live request stream delivers over scheduler notifications, and +/// each arrival has to wake the cycle so the new positions get driven. The +/// **store** does not — it is the cyclic edge, and forwarding it would loop. +struct DriverInputs { + consumer: SharedConsumer, + store_producer: Box, + source_producer: Box, +} + +fn subscribe_driver_inputs( + store_op: &mut dyn TileOperator, + source_op: &mut dyn TileOperator, + consumer: Box, + scheduler: &mut Scheduler, +) -> DriverInputs { + let consumer = shared_consumer(consumer); + let source_producer = { + let g = source_op.tiling().universal_guard(); + source_op.subscribe(g, forwarding_consumer(&consumer), scheduler) + }; + let store_producer = { + let g = store_op.tiling().universal_guard(); + store_op.subscribe(g, Box::new(|| {}), scheduler) + }; + DriverInputs { + consumer, + store_producer, + source_producer, } - fields.insert(tuple_field(read_extents.len()), f(item_extent.clone())); - fields } -impl TileOperator for BodyInputSource { +/// The induction body's input, produced from the store read back through the +/// cycle: `SealedFunction(UInt → {_0: prev_{k₀}, …, _{r-1}: prev_{k_{r-1}}, _r: +/// item})` — the flat `(prev…, item)` tuple the body's `let kᵢ = p.i … let item +/// = p.r` shape expects. +/// +/// The driver owns **no** part of the recurrence. The store's decided frontier +/// *is* the next position to iterate: [`CommitEngine::step`] advances the +/// watermark unconditionally (a carry decides its position without appending a +/// change), tick 0 is the accumulator seed and iteration `p` is tick `p + 1`, so +/// a frontier of `w` means iterations `0..w` are decided and `w` is next. The +/// previous accumulator is that key's value *at* the frontier +/// ([`store_value_at`], one fold per read key). Folding at the frontier rather +/// than taking the key's latest write ([`store_current`]) is the honest read even +/// though a contiguously driven store makes the two agree: the position being fed +/// *is* the frontier, so "as of the position" is what the recurrence means. The +/// emitted row is therefore a pure function of the store tile and the source tile, +/// with nothing cached that could drift. +/// +/// This is the [`InductionStore`]'s cycle partner: store → body → driver → +/// `FanOut::new_cyclic(store)`. Emitting only the frontier's position is what +/// makes the cycle well-founded — the body is never asked for a position whose +/// predecessor is undecided. +pub struct InductionDriver { + tiling: Tiling, + /// The store read back through the cyclic `FanOut`. + store_op: Box, + /// The iteration source `Fun(D, item)` — the loop extent's items in order. + source_op: Box, + /// Accumulator keys the body reads, in body-parameter order. + read_keys: Vec, + read_extents: Vec, + item_extent: Extent, +} + +impl InductionDriver { + pub fn new( + store_op: Box, + source_op: Box, + read_keys: Vec, + read_extents: Vec, + item_extent: Extent, + ) -> Self { + debug_assert_eq!( + read_keys.len(), + read_extents.len(), + "each read key carries its own value extent" + ); + Self { + tiling: body_input_tiling(&read_extents, &item_extent), + store_op, + source_op, + read_keys, + read_extents, + item_extent, + } + } +} + +impl TileOperator for InductionDriver { fn tiling(&self) -> &Tiling { &self.tiling } + fn subscribe( &mut self, _intent_guard: TileGuard, - _consumer: Box, - _scheduler: &mut Scheduler, + consumer: Box, + scheduler: &mut Scheduler, ) -> Box { - Box::new(BodyInputSourceProducer { - base: ProducerBase::new(BodyInputSourceProducer::alloc_id(), &self.tiling), - buffer: self.buffer.clone(), - read_extents: self.read_extents.clone(), - item_extent: self.item_extent.clone(), - release_cursor: PrefixReleaseCursor::default(), + let inputs = subscribe_driver_inputs( + &mut *self.store_op, + &mut *self.source_op, + consumer, + scheduler, + ); + Box::new(InductionDriverProducer { + base: ProducerBase::new(InductionDriverProducer::alloc_id(), &self.tiling), + store_producer: inputs.store_producer, + source_producer: inputs.source_producer, + consumer: inputs.consumer, + wakeups: scheduler.wakeup_queue(), + read_keys: self.read_keys.clone(), + window: DriverWindow::new(self.read_extents.clone(), self.item_extent.clone()), + source_released_through: None, + source_fully_released: false, }) } } -struct BodyInputSourceProducer { +struct InductionDriverProducer { base: ProducerBase, - buffer: BodyInputBuffer, + store_producer: Box, + source_producer: Box, + /// This driver's consumer, re-armed through [`wakeups`](Self::wakeups) while + /// an iteration position remains to feed. The cycle is its own trigger: a + /// position only becomes emittable once the store has decided its + /// predecessor, and nothing outside the cycle announces that. + consumer: SharedConsumer, + /// The scheduler's deferred-wakeup queue — where a pull with pending work + /// requests its own re-pull instead of looping inside `get`. + wakeups: WakeupQueue, + read_keys: Vec, + /// The emitted rows and the body-input tile they render. This is the + /// producer's own output, not recurrence state: a row is never read back to + /// compute a later one. + window: DriverWindow, + /// Highest source position released back upstream. The driver never re-reads + /// a position it has emitted, so that prefix is reclaimable; a co-iterated + /// reader keeps its own positions live through the source's cross-producer + /// release intersection. + source_released_through: Option, + /// Whether the whole source has been released (`True`) after the loop + /// finished — the finite loop's `get_released_predicate() == True` + /// end-state; issued once. + source_fully_released: bool, +} + +impl TileProducer for InductionDriverProducer { + impl_producer_base!(); + + fn get_impl(&mut self, _projection_guard: TileGuard) -> Tile { + // The driver is over once it has universally released the source: pulling it + // again would break that release's promise, and every position has already + // been emitted, so the live window is the whole remaining answer. The driver + // owns the source, so the obligation is its to keep. + if self.source_fully_released { + return self.window.render(true); + } + let src = self + .source_producer + .get(self.source_producer.tiling().universal_guard()); + let source_complete = src.is_terminal(); + // An async source's domain arrives unordered, so drive by absolute + // position rather than by the codomain's column order. + let by_pos: HashMap = decode_source_positioned(&src).into_iter().collect(); + // Invariant: an induction source domain has **no interior hole** — a finite + // list `[0, N)` or an async `DataSource`'s UInt domain only ever gaps at the + // *trailing* end (positions not yet arrived). Both of this driver's rules rest + // on it: it stops at the first missing position and resumes when that one + // arrives, so a permanent interior hole would stall forever; and `done` below + // reads "the next position is absent" as "there is no next", which a hole + // would turn into a loop that ends early and drops the rest in silence. The + // arrived set is a suffix rather than a prefix from 0 — the consumed prefix is released + // below, so a later pull sees a shifted window (e.g. `{5, 6, 7}`) — so the + // check is that the sorted positions run contiguously from whatever base is + // retained. + debug_assert!( + !source_complete || { + let mut ks: Vec = by_pos.keys().copied().collect(); + ks.sort_unstable(); + ks.windows(2).all(|w| w[1] == w[0] + 1) + }, + "induction source domain has an interior gap: {:?}", + { + let mut ks: Vec = by_pos.keys().copied().collect(); + ks.sort_unstable(); + ks + } + ); + let store = self + .store_producer + .get(self.store_producer.tiling().universal_guard()); + + // Emit the frontier's position, if the source has delivered it. At most + // one position per pull: the cyclic `FanOut` serves this store tile from + // a snapshot taken before the traversal began, so a position decided + // *during* this pull is not visible until the next one. That is the + // one-step-per-pull cycle driver every cyclic operator here runs on. + // + // The store's decided frontier *is* the next position to iterate, so it + // is also the item cursor: unlike the transaction driver, this one keeps + // no cursor of its own. + let frontier = store_frontier(&store); + let mut next = self.window.next_position(); + if let Some(frontier) = frontier { + debug_assert!( + frontier <= next, + "the store decided position {frontier} but the driver has only emitted through \ + {next} — a decision cannot precede the input it decides" + ); + if frontier == next + && let Some(item) = by_pos.get(&frontier) + { + let prev: Vec = self + .read_keys + .iter() + .map(|k| { + store_value_at(&store, frontier, k).expect( + "tick 0 seeds every accumulator, so a prev read always resolves", + ) + }) + .collect(); + self.window.push(prev, item.clone(), frontier); + next += 1; + } + } + + // Reclaim the changelog prefix this driver has consumed. It only ever + // folds at the *frontier*, and the store's keep-latest GC preserves each + // key's latest write inside a released prefix — so releasing through the + // frontier never strands the fold, and without it the store's + // `FanOut`-intersected release watermark could never advance past this + // cycle branch and the changelog would grow with the loop. + if let Some(frontier) = frontier { + self.store_producer + .release(TileGuard::Function(FunctionGuard::Domain( + Predicate::LessThanEq(Value::UInt(frontier)), + ))); + } + // Reclaim the source prefix this driver has consumed. It only ever reads + // the position it is about to emit and never re-reads an earlier one. + if next > 0 && !self.source_released_through.is_some_and(|r| r + 1 >= next) { + self.source_producer + .release(TileGuard::Function(FunctionGuard::Domain( + Predicate::LessThanEq(Value::UInt(next - 1)), + ))); + self.source_released_through = Some(next - 1); + } + // Every position of a complete source has been emitted: the body input + // is final, and that terminality propagates through the body's decision + // stream to close the store's frontier. A terminal source's domain is + // gapless, so "the next position is absent" means "there is no next". + let done = source_complete && !by_pos.contains_key(&next); + // Re-arm while the cycle still has work only it can trigger: a position + // has arrived that is not yet emitted. That is the whole condition — it + // is *not* "a row is awaiting its decision", because a row emitted this + // pull is decided later in the same pull (the store pulls the body, which + // pulls this driver), so by the next pull the frontier has already moved. + // What needs the wakeup is the position after it. When no further position + // has arrived, the pending trigger is a source notification, which this + // driver forwards — re-arming there would spin against a live source with + // nothing to deliver. + // + // The gap this leaves is a row emitted, *not* decided in its own pull, and + // no further position arrived: nothing would re-pull. An induction body is + // self-contained and always decides on the pull (unlike a transaction + // body, which can read a still-converging broadcast), so the store never + // leaves a position undecided — asserted there, where the store's + // contiguous consume-from-`processed` loop makes it checkable. + if !done && by_pos.contains_key(&next) { + self.wakeups.request(self.consumer.clone()); + } + if done && !self.source_fully_released { + self.source_producer + .release(TileGuard::Function(FunctionGuard::Domain(Predicate::True))); + self.source_fully_released = true; + } + + self.window.render(done) + } + + fn release_impl(&mut self, obsolete_guard: TileGuard) { + // Retention only: this driver's cursor is the store's frontier, so a + // release says nothing about progress — it only reclaims rows the body + // has consumed and the store has decided. + if let TileGuard::Function(FunctionGuard::Domain(pred)) = &obsolete_guard { + self.window.compact(pred); + } + } +} + +/// The transaction body's input, produced from the store read back through the +/// cycle: `SealedFunction(UInt → {_0: snap_{k₀}, …, _{r-1}: snap_{k_{r-1}}, _r: +/// item})` — the [`InductionDriver`]'s sibling, differing only in how the item +/// advances. +/// +/// An induction position is decided by the store's frontier; a transaction's is +/// not, because a commit is what *moves* the frontier. So the item cursor +/// advances on the **commit-ack**, delivered as a release — but a release from +/// the body alone would be wrong, because a body releases a row the moment it +/// consumes it, long before the attempt commits. The driver therefore sits behind +/// a `FanOut` with two branches, the body and [`TransactWriter`], and reads the +/// **intersection**: the body has consumed the row *and* the writer has finished +/// the attempt. Without that the driver would advance past an item still in +/// flight, or re-propose one that already committed — an attempt is emitted once +/// per `(item, frontier)`, and a commit is what changes the frontier. +/// +/// A row is a pure function of `(item, frontier)`: each read key's value folds +/// out of the store at its decided frontier, so a retry at a new frontier is a +/// fresh position and a re-pull at an unchanged one emits nothing. +/// +/// Both halves of that intersection are load-bearing for the window bound +/// ([`MAX_LIVE_ATTEMPTS`]), including the body's. A compiled body fans this input +/// through a `Memo`, which releases each row as it consumes it; a body chain that +/// released only when its own output was released would leave the intersection +/// standing at the writer's ack, and a superseded row could not be reclaimed +/// until its item finished — the window would grow one row per retry with the +/// writer's supersession release still in place. Measured both ways by +/// `a_contended_item_keeps_the_drive_window_flat`. +pub struct TransactDriver { + tiling: Tiling, + /// The store read back through the cyclic `FanOut`. + store_op: Box, + /// The transaction source — one item per transaction to attempt. + source_op: Box, + /// Runtime keys the body reads a snapshot of, in body-parameter order. + read_keys: Vec, read_extents: Vec, item_extent: Extent, - /// Highest absolute buffer position a consumer has released. The body op fans - /// this source through a `FanOut`/`Memo` that pulls it repeatedly within one - /// round (once per fanned use — the `{commit, writes}` decision reads it in - /// several places); re-emitting an already-released position would make the - /// `Memo`'s append-merge duplicate that domain position (an invalid tile). - /// Emitting only positions past this cursor makes the source delta-producing, - /// exactly as the induction body's `fan_in` input is — so repeated pulls - /// after a release contribute nothing. - release_cursor: PrefixReleaseCursor, } -impl TileProducer for BodyInputSourceProducer { - fn base(&self) -> &ProducerBase { - &self.base +impl TransactDriver { + pub fn new( + store_op: Box, + source_op: Box, + read_keys: Vec, + read_extents: Vec, + item_extent: Extent, + ) -> Self { + debug_assert_eq!( + read_keys.len(), + read_extents.len(), + "each read key carries its own value extent" + ); + Self { + tiling: body_input_tiling(&read_extents, &item_extent), + store_op, + source_op, + read_keys, + read_extents, + item_extent, + } } - fn base_mut(&mut self) -> &mut ProducerBase { - &mut self.base +} + +impl TileOperator for TransactDriver { + fn tiling(&self) -> &Tiling { + &self.tiling } - fn get_impl(&mut self, _projection_guard: TileGuard) -> Tile { - let buf = self.buffer.borrow(); - // Emit only unreleased rows (absolute position `base + i > released`). - let start = match self.release_cursor.through() { - Some(r) if r >= buf.base => (r + 1 - buf.base).min(buf.rows.len()), - _ => 0, - }; - let live = &buf.rows[start..]; - // `_i` (i < r) is read-key i's snapshot column; `_r` is the item column. - let mut fields: HashMap = HashMap::with_capacity(self.read_extents.len() + 1); - for (i, ext) in self.read_extents.iter().enumerate() { - fields.insert( - tuple_field(i), - Tile::Scalar(ColumnValue::from_values( - live.iter().map(|(olds, _)| olds[i].clone()).collect(), - ext, - )), - ); - } - fields.insert( - tuple_field(self.read_extents.len()), - Tile::Scalar(ColumnValue::from_values( - live.iter().map(|(_, item)| item.clone()).collect(), - &self.item_extent, - )), + + fn subscribe( + &mut self, + _intent_guard: TileGuard, + consumer: Box, + scheduler: &mut Scheduler, + ) -> Box { + let inputs = subscribe_driver_inputs( + &mut *self.store_op, + &mut *self.source_op, + consumer, + scheduler, ); - Tile::SealedFunction { - // Absolute positions: the window starts at `base` (consumed rows - // compacted away), so `body_decision_at` finds a row by value. - domain: ColumnValue::from_uints( - (buf.base + start..buf.base + buf.rows.len()).collect(), - ), - codomain: Box::new(Tile::Record(fields)), - // Never terminal: the buffer keeps growing (one attempt per round), - // so the body must re-read it each pull rather than cache a - // "complete" result. - domain_predicate: Predicate::False, - deleted: bit_set::BitSet::new(), + Box::new(TransactDriverProducer { + base: ProducerBase::new(TransactDriverProducer::alloc_id(), &self.tiling), + store_producer: inputs.store_producer, + source_producer: inputs.source_producer, + consumer: inputs.consumer, + wakeups: scheduler.wakeup_queue(), + read_keys: self.read_keys.clone(), + window: DriverWindow::new(self.read_extents.clone(), self.item_extent.clone()), + current: 0, + latest_emit: None, + }) + } +} + +struct TransactDriverProducer { + base: ProducerBase, + store_producer: Box, + source_producer: Box, + /// This driver's consumer, re-armed through [`wakeups`](Self::wakeups) while a + /// transaction remains to attempt — the one-step-per-pull cycle driver. The + /// cycle is its own trigger: an attempt becomes emittable when the store's + /// frontier moves, which nothing outside the cycle announces. + consumer: SharedConsumer, + /// The scheduler's deferred-wakeup queue — where a pull with pending work + /// requests its own re-pull instead of looping inside `get`. + wakeups: WakeupQueue, + read_keys: Vec, + /// The source item being attempted. Advanced only by `release` — the + /// writer's ack that an attempt finished. + current: usize, + /// The emitted rows — the attempts in flight, including superseded retries + /// not yet reclaimed. + /// + /// O(1), not O(retries): the writer releases everything below the position it + /// decides, so a superseded row is reclaimed on the next release rather than + /// waiting for the item to finish. The distinction matters because the body + /// re-renders this whole window each pull, so a window that grew with retries + /// would make a contended item quadratic. + window: DriverWindow, + /// `(item, frontier)` of the latest emit — the retry-suppression key. A row + /// is a pure function of that pair, so re-emitting at an unchanged pair would + /// duplicate a domain position against the body's `Memo`. + latest_emit: Option<(usize, CommitTs)>, +} + +/// The most rows this driver's live window may hold: the attempt the writer has +/// decided, plus at most one newer row emitted since it decided. +/// +/// This bound *is* the O(1) claim the writer's supersession release exists for, +/// and it holds only because of it. Rows are added at most one per pull and only +/// for `current`; they leave on the release intersection. The writer contributes +/// two releases — everything below the position it decides (supersession) and +/// `≤ attempt` when the item finishes (the ack) — and it is the first that caps +/// the window. Drop it and the window instead holds one row per retry between +/// acks: O(retries) rows retained and, because the body re-renders the whole +/// window each pull, O(retries²) body rows evaluated. +const MAX_LIVE_ATTEMPTS: usize = 2; + +impl TransactDriverProducer { + /// The two standing facts about the live window, checked on both sides of the + /// only two things that move it: an emit, and the release that advances the + /// item cursor. + /// + /// It is **all one item**: rows are emitted only for `current`, and `current` + /// advances exactly when a release drops them. That is what lets the writer + /// decide the newest position and treat every older live one as superseded — + /// if the window ever spanned two items, that rule would abandon a real + /// attempt. + /// + /// And it is **bounded** by [`MAX_LIVE_ATTEMPTS`], which no test asserts + /// directly because this does: `sustained_contention_conserves_pool` runs a + /// stuck item through many retries in a debug build, so an unbounded window + /// trips here rather than passing quietly with the right answer. + fn debug_assert_window_invariants(&self) { + debug_assert!( + self.window + .rows + .iter() + .all(|r| r.item_index == self.current), + "the driver's live window spans more than one item: {:?} with current {}", + self.window + .rows + .iter() + .map(|r| r.item_index) + .collect::>(), + self.current + ); + debug_assert!( + self.window.rows.len() <= MAX_LIVE_ATTEMPTS, + "the driver's live window holds {} attempts (bound {MAX_LIVE_ATTEMPTS}) — a \ + superseded row is not being reclaimed, so a contended item costs O(retries) \ + rows retained and O(retries²) body rows evaluated", + self.window.rows.len() + ); + } +} + +impl TileProducer for TransactDriverProducer { + impl_producer_base!(); + + fn get_impl(&mut self, _projection_guard: TileGuard) -> Tile { + // Re-read the source each pull: a live source (an HTTP request stream) + // grows over time, and this driver never releases it, so `get` returns the + // full current extent with stable append-only positions. + let src = self + .source_producer + .get(self.source_producer.tiling().universal_guard()); + // The source is *complete* only when its tile is terminal. A batch source + // (a list) is terminal on the first pull; a live source (an HTTP request + // stream) never is, so a momentarily drained one must not read as done. + let source_complete = src.is_terminal(); + let items = decode_source_items(&src); + let store = self + .store_producer + .get(self.store_producer.tiling().universal_guard()); + // The snapshot the attempt is built against: the store's decided + // frontier. A read key with no value yet (an *append* onto an empty + // collection store) folds to nothing — the empty-store bootstrap. + let frontier = store_frontier(&store); + let olds: Vec> = self + .read_keys + .iter() + .map(|k| store_current(&store, k).map(|(_, v)| v)) + .collect(); + + if self.current < items.len() + && let Some(frontier) = frontier + && self.latest_emit != Some((self.current, frontier)) + { + let item = items[self.current].clone(); + // The body reads snapshot position `i` as `p.i`. A read key with no + // value yet gets the item as a stand-in of the right extent. Load- + // bearing assumption: a body that writes an *absent* key is + // append-shaped, so it ignores the snapshot at that position and the + // stand-in is never observed. (Even if it were, the writer's read set + // omits the absent key, so the proposal cannot go stale on it.) + let snap_in: Vec = olds + .iter() + .map(|o| o.clone().unwrap_or_else(|| item.clone())) + .collect(); + self.window.push(snap_in, item, self.current); + self.latest_emit = Some((self.current, frontier)); + } + self.debug_assert_window_invariants(); + // Terminal once every item has been acked and no more can arrive. This is + // the writer's completeness signal too: it owns no source of its own, so + // "all transactions attempted" is exactly this tile closing. A live + // window that is momentarily empty over an incomplete source stays + // non-terminal — the drained-but-live case. + let done = source_complete && self.current >= items.len(); + // Re-arm while a transaction remains to attempt. It covers every + // continuation uniformly: an attempt awaiting its commit-ack, a retry + // waiting for the frontier to move, and the first pull of all — where the + // cyclic fan's snapshot is still empty, so there is no frontier to build + // an attempt against yet. A writer that is *drained but live* does not + // re-arm: a future arrival wakes it through the source, so re-arming + // would busy-poll an idle server. + if self.current < items.len() { + self.wakeups.request(self.consumer.clone()); } + self.window.render(done) } + fn release_impl(&mut self, obsolete_guard: TileGuard) { - // Advance the emit cursor past released positions so a re-pull (the - // fanned body reads this source several times per round) does not - // re-emit and duplicate a domain position through the `Memo` merge. - if let TileGuard::Function(FunctionGuard::Domain(pred)) = &obsolete_guard - && let Some(max) = max_released_tick(pred) + // The commit-ack — but only because this producer sits behind a `FanOut` + // whose branches are the body and the writer, so what arrives here is + // their **intersection**. The body releases a row as soon as it has + // *consumed* it, which is not an ack; the writer releases it when the + // attempt has *finished* (committed, or denied without proposing). The + // intersection of the two is the finish, and that is what advances the + // item cursor. Superseded retries for the same item ride the same prefix + // and give the same answer, so the rule is idempotent under any order. + let TileGuard::Function(FunctionGuard::Domain(pred)) = &obsolete_guard else { + return; + }; + // Only the **newest** live row's release is the item's finish. An older one + // is a superseded retry, which the writer releases as soon as a newer + // attempt replaces it — reclaiming that row must not advance the cursor past + // an item still in flight, which is the same mistake as taking the body's + // consume-release for an ack. The window is all one item, so the newest row + // is the last, and superseded rows are exactly the prefix compacted below. + if let Some((pos, row)) = self.window.newest() + && pred.contains(&Value::UInt(pos)) { - self.release_cursor.advance_to(max); + self.current = self.current.max(row.item_index + 1); } + self.window.compact(pred); + self.debug_assert_window_invariants(); + } +} + +/// The body-input tiling both writers' drivers produce: `UInt → {_0…_{r-1}: +/// read, _r: item}`. +fn body_input_tiling(read_extents: &[Extent], item_extent: &Extent) -> Tiling { + Tiling::SealedFunction { + domain: Extent::Base(BaseType::UInt), + codomain: Box::new(Tiling::Record(body_input_fields( + read_extents, + item_extent, + |_, ext| Tiling::Scalar(ext.clone()), + ))), + } +} + +/// The body-input codomain fields `{_0..._{r-1}: read, _r: item}`, built over +/// either tilings (`f` ignores the index and wraps the extent) or tiles (`f` +/// uses the index to pick the column). `r = read_extents.len()`. +/// +/// The layout — read fields in order, then the item — is the contract between the +/// tiling a driver declares and the tile it renders, so both go through here. Two +/// spellings of it could drift into a tile that does not match its own tiling. +fn body_input_fields( + read_extents: &[Extent], + item_extent: &Extent, + f: impl Fn(usize, &Extent) -> T, +) -> HashMap { + let mut fields: HashMap = HashMap::with_capacity(read_extents.len() + 1); + for (i, ext) in read_extents.iter().enumerate() { + fields.insert(tuple_field(i), f(i, ext)); } + let item = read_extents.len(); + fields.insert(tuple_field(item), f(item, item_extent)); + fields } /// The `commit` tag of the decision variant `` {`commit{𝑃} | `abort} ``. A union @@ -2744,7 +3231,31 @@ fn is_commit_tag(tag: &crate::ccl::FieldKey) -> bool { matches!(tag, crate::ccl::FieldKey::Name(n) if n == crate::ccl::V_COMMIT) } -/// Extract a writer body's grant/deny *decision* at buffer position `pos`. +/// The newest position present in a body-input tile — the attempt a writer is +/// currently deciding, superseding any older live one (see the caller). `None` +/// when the driver has emitted nothing live. +fn newest_body_position(tile: &Tile) -> Option { + let Tile::SealedFunction { domain, .. } = tile else { + return None; + }; + (0..domain.len()) + .filter_map(|i| match domain.index_at(i) { + Value::UInt(p) => Some(p), + _ => None, + }) + .max() +} + +/// Whether `tile` decides any position strictly after `pos` — the hole a consumer +/// that stops at the first undecided position would silently truncate on. +fn decides_beyond(tile: &Tile, pos: usize) -> bool { + let Tile::SealedFunction { domain, .. } = tile else { + return false; + }; + (0..domain.len()).any(|i| matches!(domain.index_at(i), Value::UInt(p) if p > pos)) +} + +/// Extract a writer body's grant/deny *decision* at position `pos` of its input. /// /// The body returns a **decision variant** `` {`commit{𝑃} | `abort} `` (see /// [`crate::ccl::V_COMMIT`]/[`crate::ccl::V_ABORT`]): the codomain is a @@ -2831,18 +3342,20 @@ fn body_decision_at( /// per-branch and the proposal positions would re-index out from under the /// `CommitProducer`'s cursor. /// -/// Each pull: read the cyclic store, fold to `(frontier, old)` for `key`, and — -/// once per `(item, frontier)` (idempotent retry) — push `(old, item)` to the -/// body buffer, pull the body for the new value, and append the proposal -/// `{snap: frontier, reads: {key ↦ old}, writes: {key ↦ new}}`. Advances to the -/// next item on `release` (the commit-ack). Retries (a fresh attempt at a new -/// frontier) append as new positions. +/// Each pull: take the newest live position from the [`TransactDriver`] — which built +/// that row from `(item, frontier)`, one row per pair — pull the body for its decision, +/// and append the proposal `{snap: frontier, reads: {key ↦ old}, writes: {key ↦ new}}`. +/// Releasing the driver row acks the attempt's finish, so the driver advances to the next +/// item. Retries (a fresh attempt at a new frontier) append as new positions. pub struct TransactWriter { tiling: Tiling, store_op: Box, body_op: Box, - source_op: Box, - buffer: BodyInputBuffer, + /// A second branch of the [`TransactDriver`] the body reads. The writer pulls + /// it to learn which attempt is in flight, and **releases** it to ack the + /// attempt's finish — the half of the driver's release intersection that a + /// body's consume-release cannot supply. + driver_op: Box, /// Runtime keys the body reads a snapshot of, in body-parameter order /// (snapshot position `i` ↦ `read_keys[i]`). read_keys: Vec, @@ -2864,8 +3377,7 @@ impl TransactWriter { pub fn new( store_op: Box, body_op: Box, - source_op: Box, - buffer: BodyInputBuffer, + driver_op: Box, read_keys: Vec, write_keys: Vec, tap_fields: Vec, @@ -2876,8 +3388,7 @@ impl TransactWriter { tiling: proposal_stream_tiling(&key_extent, &value_extent), store_op, body_op, - source_op, - buffer, + driver_op, read_keys, write_keys, tap_fields, @@ -2892,83 +3403,92 @@ impl TileOperator for TransactWriter { fn subscribe( &mut self, _intent_guard: TileGuard, - mut consumer: Box, + consumer: Box, scheduler: &mut Scheduler, ) -> Box { - // A new source item — a request arriving on a live source — is a new - // transaction to drive. Forward the source's notification to this writer's - // consumer (the `CommitOperator`), so a live arrival wakes the commit cycle - // and, through it, any sink reading a store key or `to_` tap. Without - // this the writer is never re-pulled on a live source, so a live - // cross-endpoint read-only transaction's reply would never fire. The store - // and body inputs need no notification: the writer pulls them on demand each - // time the source drives it (and forwarding the cyclic store would loop). - let consumer: SharedConsumer = Rc::new(RefCell::new(move || consumer.notify())); - // The deferred-wakeup queue: while a source item remains to process, the - // writer re-arms its own consumer (rather than looping inside `get`) so a - // demand-driven driver re-pulls it — the one-step-per-pull cycle drive that - // steps the store forward across pulls (a commit, a deny, or a not-ready - // broadcast input each keep the writer non-terminal). See [`WakeupQueue`] - // and the re-arm at the end of `get_impl`. - let wakeups = scheduler.wakeup_queue(); + // This writer re-arms nothing itself: the driver owns the transaction + // source and re-arms while a transaction remains to attempt, which + // subsumes every continuation this writer could want (an attempt is only + // in flight while its item is unacked, so the driver's cursor has not + // passed it). What the writer does need is for the driver's wakeups and + // live arrivals to *reach* it, and through it the commit cycle and any + // sink reading a store key or `to_` tap — that is the forwarding + // consumer on its driver branch below. The store and body inputs need no + // notification: the writer pulls them on demand, and forwarding the + // cyclic store would loop. + let consumer = shared_consumer(consumer); let sg = self.store_op.tiling().universal_guard(); let store_producer = self.store_op.subscribe(sg, Box::new(|| {}), scheduler); let bg = self.body_op.tiling().universal_guard(); let body_producer = self.body_op.subscribe(bg, Box::new(|| {}), scheduler); - let srcg = self.source_op.tiling().universal_guard(); - // Forward the source's notification to this writer's consumer via a - // fresh closure (a `Box>>` is not itself a - // `Consumer` — the blanket impl needs a sized inner type). - let src_consumer = { - let c = consumer.clone(); - Box::new(move || c.borrow_mut().notify()) - }; - let source_producer = self.source_op.subscribe(srcg, src_consumer, scheduler); + // Forward the driver's notification to this writer's consumer: the driver + // owns the transaction source, so a request arriving on a live source + // reaches this writer, and through it the commit cycle, along this edge. + let dg = self.driver_op.tiling().universal_guard(); + let driver_producer = + self.driver_op + .subscribe(dg, forwarding_consumer(&consumer), scheduler); Box::new(TransactWriterProducer { base: ProducerBase::new(TransactWriterProducer::alloc_id(), &self.tiling), store_producer, body_producer, - source_producer, - consumer, - wakeups, - buffer: self.buffer.clone(), + driver_producer, read_keys: self.read_keys.clone(), write_keys: self.write_keys.clone(), tap_fields: self.tap_fields.clone(), - items: None, - current: 0, + last_decided_pos: None, + driver_terminal: false, committed_base: 0, emitted: Vec::new(), - emitted_item: Vec::new(), - latest_emit: None, - pending: None, - source_complete: false, }) } } -/// One accumulated proposal: `(snapshot, read-set, write-set)`. -type EmittedProposal = (CommitTs, HashMap, HashMap); +/// A proposal awaiting the operator's verdict. +/// +/// The facts travel together because they are views of one attempt, and the +/// release that finishes it uses both: `snapshot`/`reads`/`writes` are what the +/// operator validates and commits, and `attempt` is the driver row to ack so the +/// driver advances past the item with it. +/// +/// There is deliberately no item index here. The driver owns the transaction +/// source and therefore the item cursor; a copy of it in the writer would be a +/// second cursor advanced by a different rule, free to disagree with the real +/// one. The writer names an attempt by the driver position it came from, which is +/// the identity the driver itself uses. +struct InFlightProposal { + /// The store frontier this proposal was built against — the read set is + /// current iff no read key was overwritten after it. + snapshot: CommitTs, + /// The multi-key read set. Omits a key with no value yet: an append onto an + /// empty store reads nothing, so it can never go stale (the empty-store + /// bootstrap). + reads: HashMap, + /// The multi-key write set, mutable variable writes then fired taps. + writes: HashMap, + /// The [`TransactDriver`] position this attempt was decided from. Acking it + /// is how the driver learns the item is finished. + attempt: usize, +} struct TransactWriterProducer { base: ProducerBase, store_producer: Box, body_producer: Box, - source_producer: Box, - /// This writer's consumer, re-armed through [`wakeups`](Self::wakeups) while an - /// item remains to process — the one-step-per-pull cycle drive (see `get_impl`). - consumer: SharedConsumer, - /// The scheduler's deferred-wakeup queue — where a pull with pending work - /// requests its own re-pull instead of looping in `get`. - wakeups: WakeupQueue, - buffer: BodyInputBuffer, + /// The driver branch this writer acks on (see [`TransactWriter::driver_op`]). + driver_producer: Box, read_keys: Vec, write_keys: Vec, /// Reply-tap decision fields, appended to each write set (see /// [`TransactWriter::tap_fields`]). tap_fields: Vec, - items: Option>, - current: usize, + /// The driver position whose decision this writer has already acted on, so a + /// re-pull re-reads a *not-ready* decision without re-deciding a settled one. + last_decided_pos: Option, + /// Whether the driver has closed — every transaction attempted and acked over + /// a source that can deliver no more. The writer owns no source; this is its + /// completeness signal. + driver_terminal: bool, /// Absolute proposal-stream position of `emitted[0]` — the number of leading /// proposals the consumer has committed-and-released, which `release_impl` /// has compacted away. The proposal stream is an **offset window**: its @@ -2976,43 +3496,9 @@ struct TransactWriterProducer { /// by value), so the released prefix is dropped without renumbering the live /// suffix. Bounds the writer's retained state on a long-lived store. committed_base: usize, - /// Accumulated proposals not yet released — append-only within the live - /// window. Each is `(snap, reads, writes)`: `reads`/`writes` are the - /// multi-key read/write sets, where `reads` omits a key with no value yet (an - /// append onto an empty store reads nothing, so it never goes stale — the - /// empty-store bootstrap). The entry at vector index `i` is absolute position - /// `committed_base + i`. - emitted: Vec, - /// Source-item index per live emitted position (for idempotent - /// release-advance), in lockstep with `emitted`. - emitted_item: Vec, - /// `(item, frontier)` of the latest emit — the retry-suppression idempotency - /// key. Sound because a proposal is a *pure function of `(item, frontier)`*: - /// the read set is `read_keys` folded against the store at `frontier`, and - /// the write set is the body applied to that snapshot — so re-pulling at an - /// unchanged `(item, frontier)` would re-derive a byte-identical proposal. - /// Suppressing it keeps the append-only proposal stream from double-emitting - /// the same transaction within one frontier (positions never shift). - latest_emit: Option<(usize, CommitTs)>, - /// `(item, frontier)` of a body-input row pushed whose decision is not yet - /// ready — the decision reads a **broadcast cross-loop accumulator final** - /// (`store := store − cnt`, `cnt` a *different*, completed loop) whose - /// `ExtractFinal` is empty until that loop's `Recurse` drains, one position per - /// body pull. While pending, the writer reuses this one row (re-pushing would - /// duplicate a buffer position against the body's `Memo`) and re-arms itself - /// via [`wakeups`](Self::wakeups) each pull; the `Memo` sees a legal monotonic - /// empty→value growth at the position. Cleared once the decision resolves. - /// Distinct from `latest_emit`, which marks a *proposal already emitted*. - pending: Option<(usize, CommitTs)>, - /// Whether the writer's source is *complete* — its most recent pull returned a - /// terminal (`Predicate::True`) tile. A batch source (a list) is complete on - /// the first pull; a live source (an HTTP request stream) never is. The writer - /// reports its proposal stream terminal only when the source is complete *and* - /// every item has been processed — never merely because it is momentarily - /// drained (0 buffered items), which over a live source would prematurely - /// declare the store (and any reply-tap stream) complete, so a later commit - /// would conflict with that completeness claim. - source_complete: bool, + /// Proposals not yet released — append-only within the live window. The + /// entry at vector index `i` is absolute position `committed_base + i`. + emitted: Vec, } impl TransactWriterProducer { @@ -3021,22 +3507,20 @@ impl TransactWriterProducer { let reads: Vec = self .emitted .iter() - .map(|(_, reads, _)| map_to_value(reads)) + .map(|p| map_to_value(&p.reads)) .collect(); let writes: Vec = self .emitted .iter() - .map(|(_, _, writes)| map_to_value(writes)) + .map(|p| map_to_value(&p.writes)) .collect(); - // Terminal only when the source is complete *and* every item has been - // processed. Gating on `source_complete` keeps a live-source writer - // non-terminal even when momentarily drained, so the store (and any reply - // tap read off it) is not prematurely declared complete. - let terminal = self.source_complete - && self - .items - .as_ref() - .is_some_and(|it| self.current >= it.len()); + // Terminal only when the driver has closed — every transaction attempted + // and acked, over a source that can deliver no more — *and* no proposal + // is still in flight. The driver owns the source, so its closing is the + // completeness signal; a live-source writer stays non-terminal when + // momentarily drained, so the store (and any reply tap read off it) is + // not prematurely declared complete. + let terminal = self.driver_terminal && self.emitted.is_empty(); Tile::SealedFunction { // Absolute positions: the live window is `[committed_base, …)`; the // released prefix has been compacted away. Positions never renumber. @@ -3047,7 +3531,7 @@ impl TransactWriterProducer { ( F_SNAP.to_string(), Tile::Scalar(ColumnValue::from_uints( - self.emitted.iter().map(|(s, _, _)| *s).collect(), + self.emitted.iter().map(|p| p.snapshot).collect(), )), ), ( @@ -3068,51 +3552,47 @@ impl TransactWriterProducer { } } - /// Drop the body-input rows consumed up to `pos` and release the body - /// producer's matching prefix, keeping the body-input window — and the body - /// sub-operator's internal caches — bounded on a long-lived store. `pos` is - /// the absolute position of the row whose decision was just read; positions - /// are absolute, so the body's view slides forward without renumbering. - fn compact_body_input(&mut self, pos: usize) { - { - let mut buf = self.buffer.borrow_mut(); - buf.rows.clear(); - buf.base = pos + 1; - } - self.body_producer - .release(TileGuard::Function(FunctionGuard::Domain( - Predicate::LessThanEq(Value::UInt(pos)), - ))); - } - - /// Drop the live window's superseded proposals for the item about to be - /// re-processed, keeping writer state O(1) under sustained contention. + /// Ack every attempt at or below `pos` — issued when an attempt finishes: a + /// deny (no proposal to commit) or a commit-ack on the proposal it produced. /// - /// When the writer re-processes an item at a *new* frontier — a retry after a - /// stale grant, or a grant→deny flip — its earlier proposal(s) for that item - /// are provably dead: the `CommitProducer` owns this writer directly (no - /// intervening fan-out), so it attempted every prior-pull proposal in the - /// pull that rendered it; a *commit* would have released and prefix-compacted - /// the item (advancing `current` past it), so a proposal still live here went - /// stale. And because `current` does not advance while an item is stuck - /// (committed items compact away, denied items advance with their orphans - /// dropped here), the entire live window at this point is superseded - /// proposals for this one item. Drop it and advance `committed_base`; the - /// fresh proposal is appended at the next absolute position, so the + /// It releases both driver branches this writer controls: its **own**, which + /// is the half of the driver's release intersection meaning "finished" (the + /// body's half only means "consumed"), and the body's decision prefix, which + /// bounds the body sub-operator's caches. Positions are absolute, so the + /// windows slide forward without renumbering. + fn ack_through(&mut self, pos: usize) { + let guard = TileGuard::Function(FunctionGuard::Domain(Predicate::LessThanEq(Value::UInt( + pos, + )))); + self.driver_producer.release(guard.clone()); + self.body_producer.release(guard); + } + + /// Drop the live window's superseded proposals, which deciding driver position + /// `attempt` makes dead — keeping writer state O(1) under sustained contention. + /// + /// The driver emits a fresh position for an item only at a *new* frontier (a + /// retry after a stale grant, or a grant→deny flip), and its whole live window + /// belongs to one item. So every proposal still here when a newer position is + /// decided is provably dead: the `CommitProducer` owns this writer directly (no + /// intervening fan-out), so it attempted every prior-pull proposal in the pull + /// that rendered it; a *commit* would have released and prefix-compacted it + /// away, so one still live went stale. Drop it and advance `committed_base`; + /// the fresh proposal is appended at the next absolute position, so the /// consumer-indexed positions never renumber. Without this, a never-winning /// writer accumulates one lingering proposal per frontier for the store's - /// lifetime (the old unbounded-`emitted` growth). - fn drop_superseded(&mut self, item: usize) { + /// lifetime, one lingering proposal per frontier it lost at. + fn drop_superseded(&mut self, attempt: usize) { debug_assert!( - self.emitted_item.iter().all(|&it| it == item), - "drop_superseded: live window holds a proposal for a non-current item \ - ({item} expected) — the stuck-item invariant (a leaving item's window \ - is cleared by commit-compaction or a deny drop) is violated" + self.emitted.iter().all(|p| p.attempt < attempt), + "drop_superseded: live window holds a proposal at or past the position being \ + decided ({attempt}) — {:?}; a superseded proposal is one from a strictly \ + earlier attempt", + self.emitted.iter().map(|p| p.attempt).collect::>() ); let drop = self.emitted.len(); if drop > 0 { self.emitted.clear(); - self.emitted_item.clear(); self.committed_base += drop; } } @@ -3120,14 +3600,17 @@ impl TransactWriterProducer { impl CyclicSequencingProducer for TransactWriterProducer { fn debug_assert_position_invariant(&self) { - // Every emitted proposal records its source item in lockstep, so a - // position denotes the same proposal in both vectors. These positions - // are append-only and consumer-indexed (`CommitProducer` reads the - // proposal stream by position), so they must never shift. - debug_assert_eq!( - self.emitted.len(), - self.emitted_item.len(), - "emitted proposals and their source-item indices grow in lockstep (append-only positions)" + // The proposal stream is append-only and consumer-indexed + // (`CommitProducer` reads it by position), so a live window's entries stay + // in emission order: each proposal is decided from a strictly later driver + // position than the one before. A window that ever went backwards would + // mean a position had shifted under the consumer's cursor. Driver positions + // are the ordering because they are what the writer names an attempt by — + // the driver's item cursor is the driver's, and the writer keeps no copy. + debug_assert!( + self.emitted.windows(2).all(|w| w[0].attempt < w[1].attempt), + "the live proposal window is out of attempt order: {:?}", + self.emitted.iter().map(|p| p.attempt).collect::>() ); } } @@ -3140,23 +3623,6 @@ impl TileProducer for TransactWriterProducer { &mut self.base } fn get_impl(&mut self, _projection_guard: TileGuard) -> Tile { - // Re-read the source each pull: a live source (an HTTP request stream) - // grows over time, so caching the item list on the first pull would miss - // requests that arrive afterward — e.g. a GET whose read-only writer is - // first pulled while *another* endpoint's commits drive the store, before - // any GET has arrived. The writer never releases its source, so `get` - // returns the full current extent; positions are append-only and stable, so - // `current` keeps indexing the same items as the list grows. - let src = self - .source_producer - .get(self.source_producer.tiling().universal_guard()); - // The source is *complete* only when its tile is terminal. A batch source - // (a list) is terminal on the first pull; a live source (an HTTP request - // stream) never is — so the writer must not report its proposal stream - // terminal merely because it is momentarily drained (see `render`). - self.source_complete = src.is_terminal(); - self.items = Some(decode_source_items(&src)); - let n_items = self.items.as_ref().unwrap().len(); let store_tile = self .store_producer .get(self.store_producer.tiling().universal_guard()); @@ -3216,49 +3682,68 @@ impl TileProducer for TransactWriterProducer { Predicate::LessThanEq(Value::UInt(through)), ))); } - // Process the current item, unless a proposal for this `(item, frontier)` - // is already emitted and awaiting commit (`latest_emit == key`) — re-running - // would double-emit it into the append-only stream. `(item, frontier)` is - // a sound key: the proposal (and the body-input row it derives from) is a - // pure function of `(read_keys, frontier)`. - if self.current < n_items + // The decisions for every live attempt. Which one to act on is settled + // below, off the driver: the driver emits a row once per `(item, frontier)`, + // so a position appearing here is a distinct attempt, and re-pulling at an + // unchanged pair adds none. + let body_tile = self + .body_producer + .get(self.body_producer.tiling().universal_guard()); + // The attempt to decide is the newest live position on this writer's own + // driver branch — the row the driver emitted for `(current, frontier)` this + // pull, or the one still in flight from an earlier one. Reading it here + // rather than counting positions independently keeps the writer and the + // driver from inventing two numberings that could drift. + let driver_tile = self + .driver_producer + .get(self.driver_producer.tiling().universal_guard()); + self.driver_terminal = driver_tile.is_terminal(); + let newest = newest_body_position(&driver_tile); + // The writer decides only the *newest* live driver position, and that + // **supersedes** every older live one. Sound because the driver's whole + // live window belongs to one item — it emits only for the item it has not + // yet acked (asserted in `TransactDriverProducer::get_impl`) — so an older + // row is either an attempt already granted and awaiting its ack, or one + // whose decision was not ready and has since been re-posed at a newer + // frontier. Both are dead: the newer attempt reads a newer snapshot, and + // the ack that finishes the item releases the whole prefix. This mirrors + // `drop_superseded` on the proposal side. + debug_assert!( + newest.is_none_or(|n| self.last_decided_pos.is_none_or(|d| n >= d)), + "the driver's newest position {newest:?} went backwards past the decided watermark {:?}", + self.last_decided_pos + ); + // Reclaim what supersession abandons, on this writer's driver branch. Every + // live position below `newest` is dead by the paragraph above, and saying so + // *here* is what keeps a contended item's cost flat: without it the driver's + // window grows one row per retry, and since the body re-renders its whole + // live window each pull, K retries cost K rows retained and K² body rows + // evaluated. The driver does not read this as the item's finish — only the + // release of its newest live row is that (see + // `TransactDriverProducer::release_impl`), which is what keeps the ack + // meaning "the attempt finished" rather than "some row of it was reclaimed". + if let Some(pos) = newest + && let Some(through) = pos.checked_sub(1) + { + self.driver_producer + .release(TileGuard::Function(FunctionGuard::Domain( + Predicate::LessThanEq(Value::UInt(through)), + ))); + } + // Decide a position once. A *new* newest position is a fresh attempt (a + // new item, or a retry of this one at a moved frontier); an unchanged one + // is either already decided — the driver suppresses re-emitting at an + // unchanged `(item, frontier)` — or a not-ready decision to re-read. + if let Some(pos) = newest + && Some(pos) != self.last_decided_pos && let Some(frontier) = snapshot - && self.latest_emit != Some((self.current, frontier)) { - let key = (self.current, frontier); - // Push the item's body-input row **once per `(item, frontier)`**; a - // not-ready retry (a broadcast input still converging, the `None` arm - // below) reuses it rather than re-pushing, which would duplicate a - // buffer position against the body's `Memo`. - if self.pending != Some(key) { - let item = self.items.as_ref().unwrap()[self.current].clone(); - // The body reads snapshot position `i` as `p.i`. For a read key - // with no value yet (the bootstrap case — an append onto an empty - // collection store) we fabricate the item as a stand-in of the - // right extent. Load-bearing assumption: a body that proposes a - // write for an *absent* key is append-shaped, so it ignores the - // snapshot at that position — the fabricated value is never - // observed. (If a body ever read a fabricated snapshot, the read - // set above would still omit the absent key, so the proposal could - // not go stale on it.) - let snap_in: Vec = olds - .iter() - .map(|o| o.clone().unwrap_or_else(|| item.clone())) - .collect(); - self.buffer.borrow_mut().rows.push((snap_in, item)); - self.pending = Some(key); - } - let pos = { - let b = self.buffer.borrow(); - b.base + b.rows.len() - 1 - }; - let body_tile = self - .body_producer - .get(self.body_producer.tiling().universal_guard()); match body_decision_at(&body_tile, pos, &self.tap_fields) { // Grant: propose the write set; the operator decides whether it - // commits (release advances `current`) or is stale (retry). The - // read set omits never-written keys (append → empty read). + // commits — its ack releases the driver row, which is what advances + // the driver past this item — or is stale, leaving the item to be + // re-attempted at the moved frontier. The read set omits + // never-written keys (append → empty read). Some((true, new, tap_fired)) => { let reads: HashMap = self .read_keys @@ -3305,12 +3790,17 @@ impl TileProducer for TransactWriterProducer { .collect(); // Re-proposing this item at a new frontier supersedes its // prior stale proposal(s); drop them so the window stays O(1). - self.drop_superseded(self.current); - self.emitted.push((frontier, reads, writes)); - self.emitted_item.push(self.current); - self.latest_emit = Some(key); - self.pending = None; - self.compact_body_input(pos); + self.drop_superseded(pos); + self.emitted.push(InFlightProposal { + snapshot: frontier, + reads, + writes, + attempt: pos, + }); + self.last_decided_pos = Some(pos); + // The body-input row stays live until the commit-ack: it is + // the attempt in flight, and releasing it now would tell the + // driver this item is finished before it has committed. } // Deny: a purely local read-only decision (the body chose not to // write at this snapshot — e.g. `if pool >= r`). No proposal, no @@ -3319,10 +3809,12 @@ impl TileProducer for TransactWriterProducer { // earlier grant-stale proposal for this item first (a grant→deny // flip on retry) so it is not orphaned in the window. Some((false, _, _)) => { - self.drop_superseded(self.current); - self.current += 1; - self.pending = None; - self.compact_body_input(pos); + self.drop_superseded(pos); + self.last_decided_pos = Some(pos); + // A deny finishes the item without proposing, so there is no + // commit-ack to carry it: ack the attempt here, which is what + // advances the driver past this item. + self.ack_through(pos); } None => { // No decision at `pos`. If the body is **terminal**, the @@ -3338,41 +3830,14 @@ impl TileProducer for TransactWriterProducer { // Otherwise the decision is **not ready**: it reads a broadcast // cross-loop accumulator final still converging — its // `ExtractFinal` is empty until the sibling loop's `Recurse` - // drains, one position per body pull. `current` is left - // unadvanced, so the pending item keeps this writer non-terminal - // and the unified re-arm below re-pulls it, each re-pull - // advancing the sibling loop one step until the decision fills in. + // drains, one position per body pull. Leaving `last_decided_pos` + // unset is the whole handling: this position stays undecided, so + // nothing acks the driver row, so the driver's item cursor does not + // move and the driver keeps re-arming. Each re-pull advances the + // sibling loop one step until the decision fills in. } } } - // One-step-per-pull convergence (the `Recurse` / #291 analog): this writer - // steps a single source item per `get`, so after processing one it must - // re-pull itself to reach the next — the notification-gated driver - // (`src/main.rs`, `tests/cli_driver_convergence.rs`) re-pulls only on a - // wakeup, never merely because a tile is non-terminal. Re-arm on the - // deferred-wakeup queue whenever an item remains to process *now* - // (`current < n_items`); this drives the cyclic store forward across pulls, - // replacing the readers' retired producer-side drive-to-fixpoint. It covers - // every non-terminal continuation uniformly: - // - a **commit** (grant): `current` advances on the commit-ack `release`, - // so the next pull processes the following item; - // - a **deny**: `current` already advanced here, with no commit — invisible - // in the store frontier, so a frontier-growth signal would miss it; - // - a **not-ready** decision (the `None` arm): `current` is unadvanced, so - // the pending item re-arms until the broadcast input converges. - // A writer that is *drained but live* (`current >= n_items`, source not yet - // complete) does **not** re-arm: a future arrival wakes it through the - // source-forwarding consumer, so re-arming would busy-poll an idle server. - // The writer's wakeup fans through the cyclic `FanOut` notify closure to - // every store branch, so it also re-pulls the `AsOf` / `StoreValueStream` - // readers that sample the store per pull. - if self - .items - .as_ref() - .is_some_and(|it| self.current < it.len()) - { - self.wakeups.request(self.consumer.clone()); - } self.debug_assert_position_invariant(); self.render() } @@ -3385,10 +3850,17 @@ impl TileProducer for TransactWriterProducer { }; // The entry at vector index `i` is absolute position `committed_base + i` // (positions are stable; the consumer releases by that absolute value). - for (i, &item) in self.emitted_item.iter().enumerate() { - if pred.contains(&Value::UInt(self.committed_base + i)) { - self.current = self.current.max(item + 1); - } + // A committed proposal finishes its item, so ack the driver row it was + // decided from — the driver is what advances the item cursor. + let ack = self + .emitted + .iter() + .enumerate() + .filter(|(i, _)| pred.contains(&Value::UInt(self.committed_base + i))) + .map(|(_, p)| p.attempt) + .max(); + if let Some(pos) = ack { + self.ack_through(pos); } // Drop the released leading prefix and advance the window base. Releases // are prefixes (`LessThanEq(step)`, accumulated across commits), so the @@ -3401,7 +3873,6 @@ impl TileProducer for TransactWriterProducer { } if drop > 0 { self.emitted.drain(0..drop); - self.emitted_item.drain(0..drop); self.committed_base += drop; } } @@ -3410,9 +3881,23 @@ impl TileProducer for TransactWriterProducer { #[cfg(test)] mod tests { use super::*; + // The consumer helpers own shared-cell construction for the operators here; the + // fixtures below build their own recording cells, hence the direct imports. use crate::ccl::{FieldKey, TagMap, V_ABORT, V_COMMIT}; - use crate::interpreter::tile_operators::{Constant, FanOut, IterateExtent}; + use crate::interpreter::tile_operators::{Constant, FanOut, IterateExtent, Memo}; use crate::interpreter::validate_tile; + use std::{cell::RefCell, rc::Rc}; + + /// A fixture producer's answer with the released region subtracted — the + /// post-condition [`TileProducer::get`] asserts. A fixture stands in for a real + /// source or body, and its consumers reclaim what they have consumed, so handing + /// back the whole tile every time would not be an honest stand-in for one. + fn honoring_release(tile: &Tile, released: &TileGuard) -> Tile { + let mut t = tile.clone(); + t.remove_guarded(released.clone()); + t.compact(); + t + } fn int(n: i64) -> Value { Value::Int(n) @@ -3566,10 +4051,15 @@ mod tests { } } - /// A single-accumulator induction-write decision body: over its `(prev, item)` - /// input (a `BodyInputSource`), emits `` `commit({writes: {_0: prev + item}}) `` - /// where `guard(item)` holds, else `` `abort `` (a carry — the accumulator holds). - /// Models the recognized body of `for i in xs: if guard(i): acc += i`. + /// A single-key decision body: over its `(read, item)` input (a driver's tile), + /// emits `` `commit({writes: {_0: read + item}}) `` where `item > threshold`, + /// else `` `abort ``. + /// + /// Serves both drivers, because the body shape is the same on both sides: for + /// induction it models `for i in xs: if guard(i): acc += i`, an `` `abort `` + /// being a carry the accumulator holds through; for a transaction it is a + /// drawdown of a negative item against the read snapshot, with `i64::MIN` + /// making every attempt a grant so contention is the only thing under test. struct AddIfBody { input: Box, tiling: Tiling, @@ -3628,7 +4118,10 @@ mod tests { fn get_impl(&mut self, _projection_guard: TileGuard) -> Tile { let in_tile = self.input.get(self.input.tiling().universal_guard()); let Tile::SealedFunction { - domain, codomain, .. + domain, + codomain, + domain_predicate, + .. } = in_tile else { panic!("AddIfBody input is a SealedFunction"); @@ -3657,38 +4150,74 @@ mod tests { rows, &decision_union_extent(commit_payload_extent()), ))), - domain_predicate: Predicate::False, + // A per-position decision map: the decision stream is final + // exactly when its input is, as a compiled body's operator chain + // propagates it. The store reads this to close its frontier. + domain_predicate, deleted: bit_set::BitSet::new(), } } - fn release_impl(&mut self, _obsolete_guard: TileGuard) {} + fn release_impl(&mut self, obsolete_guard: TileGuard) { + // Forward the store's decision release to the driver, which compacts + // its emitted window and releases the loop source in turn. + self.input.release(obsolete_guard); + } } - /// Drive an `InductionStore` for a single-accumulator loop end-to-end through - /// the tile protocol and return the converged store tile. - fn drive_induction(items: &[i64], threshold: i64, init: i64) -> Tile { - let buffer: BodyInputBuffer = Rc::new(RefCell::new(WriterBuffer::default())); - let body_input = BodyInputSource::new(buffer.clone(), vec![value_extent()], value_extent()); - let body = AddIfBody::new(Box::new(body_input), threshold); - let source = ItemSource::new(items); + /// Wire a single-accumulator induction cycle: store → body → driver → cyclic + /// fan → store. Returns the fan (its branches are the store's readers) and + /// the accumulator key. + fn induction_cycle(items: &[i64], threshold: i64, init: i64) -> (Rc, Value) { let acc = acct("acc"); - let mut op = InductionStore::new( + let store = InductionStore::new( vec![( acc.clone(), Box::new(Constant::new(int(init), value_extent())), )], - Box::new(body), - Box::new(source), - buffer, vec![acc.clone()], - vec![acc], Vec::new(), key_extent(), value_extent(), ); + let set_body = store.body_input_setter(); + let fan = Rc::new(FanOut::new_cyclic(Box::new(store))); + let driver = InductionDriver::new( + fan.branch(), + Box::new(ItemSource::new(items)), + vec![acc.clone()], + vec![value_extent()], + value_extent(), + ); + set_body(Box::new(AddIfBody::new(Box::new(driver), threshold))); + (fan, acc) + } + + /// Pull until the tile goes terminal. The cycle advances one iteration + /// position per pull, so a converging read needs one pull per position (plus + /// the closing one); the bound is generous and failing it means divergence. + fn pull_to_terminal(producer: &mut Box) -> Tile { + let mut tile = producer.get(producer.tiling().universal_guard()); + for _ in 0..MAX_CYCLE_PULLS { + if tile.is_terminal() { + return tile; + } + tile = producer.get(producer.tiling().universal_guard()); + } + panic!("induction cycle did not converge within {MAX_CYCLE_PULLS} pulls"); + } + + /// Convergence bound for the test cycles here — far above any test's + /// iteration count, so exceeding it means the cycle stalled. + const MAX_CYCLE_PULLS: usize = 64; + + /// Drive an `InductionStore` for a single-accumulator loop end-to-end through + /// the tile protocol and return the converged store tile. + fn drive_induction(items: &[i64], threshold: i64, init: i64) -> Tile { + let (fan, _acc) = induction_cycle(items, threshold, init); + let mut op = fan.branch(); let guard = op.tiling().universal_guard(); let mut producer = op.subscribe(guard, Box::new(|| {}), &mut Scheduler::new()); - producer.get(producer.tiling().universal_guard()) + pull_to_terminal(&mut producer) } /// `acc := 0; for i in [1,2,3,4]: if i > 2: acc += i` driven through the whole @@ -3745,29 +4274,12 @@ mod tests { /// and the accumulator still reads its correct final value. #[test] fn induction_store_release_bounds_changelog_keeping_latest() { - let buffer: BodyInputBuffer = Rc::new(RefCell::new(WriterBuffer::default())); - let body_input = BodyInputSource::new(buffer.clone(), vec![value_extent()], value_extent()); - let body = AddIfBody::new(Box::new(body_input), i64::MIN); // unconditional - let source = ItemSource::new(&[1, 2, 3]); - let acc = acct("acc"); - let mut op = InductionStore::new( - vec![( - acc.clone(), - Box::new(Constant::new(int(10), value_extent())), - )], - Box::new(body), - Box::new(source), - buffer, - vec![acc.clone()], - vec![acc.clone()], - Vec::new(), - key_extent(), - value_extent(), - ); + let (fan, acc) = induction_cycle(&[1, 2, 3], i64::MIN, 10); // unconditional + let mut op = fan.branch(); let guard = op.tiling().universal_guard(); let mut producer = op.subscribe(guard, Box::new(|| {}), &mut Scheduler::new()); - let full = producer.get(producer.tiling().universal_guard()); + let full = pull_to_terminal(&mut producer); let Tile::Store { changes, .. } = &full else { panic!("induction store output is a Store"); }; @@ -3803,37 +4315,16 @@ mod tests { /// Build an `InductionStore` behind a fan and read `acc` densely over the loop /// extent via `StoreDenseRead`; return the dense `Fun(D, V)` values in order. fn dense_read(items: &[i64], threshold: i64, init: i64) -> Vec { - let buffer: BodyInputBuffer = Rc::new(RefCell::new(WriterBuffer::default())); - let body_input = BodyInputSource::new(buffer.clone(), vec![value_extent()], value_extent()); - let body = AddIfBody::new(Box::new(body_input), threshold); - let source = ItemSource::new(items); - let acc = acct("acc"); - let store = InductionStore::new( - vec![( - acc.clone(), - Box::new(Constant::new(int(init), value_extent())), - )], - Box::new(body), - Box::new(source), - buffer, - vec![acc.clone()], - vec![acc.clone()], - Vec::new(), - key_extent(), - value_extent(), - ); - let fan = Rc::new(FanOut::new(Box::new(store))); + let (fan, acc) = induction_cycle(items, threshold, init); let trigger = IterateExtent::new(Extent::uint_range(items.len())); let mut reader = StoreDenseRead::new(Box::new(trigger), fan.branch(), acc, value_extent(), true); let guard = reader.tiling().universal_guard(); let mut producer = reader.subscribe(guard, Box::new(|| {}), &mut Scheduler::new()); - let tile = producer.get(producer.tiling().universal_guard()); + // The cycle advances one position per pull, so the dense read converges + // over several pulls rather than one. + let tile = pull_to_terminal(&mut producer); assert!(validate_tile(&tile)); - assert!( - tile.is_terminal(), - "a complete batch drives the dense read terminal" - ); let Tile::SealedFunction { codomain, .. } = tile else { panic!("dense read is a SealedFunction"); }; @@ -3913,24 +4404,8 @@ mod tests { /// positions 1, 2 to the tick-1 write (5), not the seed (0). #[test] fn carry_dense_reader_does_not_over_release_store() { - let buffer: BodyInputBuffer = Rc::new(RefCell::new(WriterBuffer::default())); - let body_input = BodyInputSource::new(buffer.clone(), vec![value_extent()], value_extent()); // Writes iff `item > 3`: over [5, 1, 1, 9] that fires at positions 0 and 3. - let body = AddIfBody::new(Box::new(body_input), 3); - let source = ItemSource::new(&[5, 1, 1, 9]); - let acc = acct("acc"); - let store = InductionStore::new( - vec![(acc.clone(), Box::new(Constant::new(int(0), value_extent())))], - Box::new(body), - Box::new(source), - buffer, - vec![acc.clone()], - vec![acc.clone()], - Vec::new(), - key_extent(), - value_extent(), - ); - let fan = Rc::new(FanOut::new(Box::new(store))); + let (fan, acc) = induction_cycle(&[5, 1, 1, 9], 3, 0); let trigger = IterateExtent::new(Extent::uint_range(4)); let mut reader = StoreDenseRead::new(Box::new(trigger), fan.branch(), acc, value_extent(), true); @@ -3938,7 +4413,10 @@ mod tests { let mut producer = reader.subscribe(guard, Box::new(|| {}), &mut Scheduler::new()); let read_values = |p: &mut Box| -> Vec<(usize, i64)> { - let tile = p.get(p.tiling().universal_guard()); + // The cycle advances one position per pull, so the first full read + // converges over several pulls; a later re-read is already terminal + // and returns immediately. + let tile = pull_to_terminal(p); let Tile::SealedFunction { domain, codomain, .. } = tile @@ -4034,23 +4512,7 @@ mod tests { /// forwards `≤ 3`, so keep-latest GC can reclaim the whole superseded prefix. #[test] fn carry_dense_reader_release_stops_below_carry_source() { - let buffer: BodyInputBuffer = Rc::new(RefCell::new(WriterBuffer::default())); - let body_input = BodyInputSource::new(buffer.clone(), vec![value_extent()], value_extent()); - let body = AddIfBody::new(Box::new(body_input), 3); - let source = ItemSource::new(&[5, 1, 1, 9]); - let acc = acct("acc"); - let store = InductionStore::new( - vec![(acc.clone(), Box::new(Constant::new(int(0), value_extent())))], - Box::new(body), - Box::new(source), - buffer, - vec![acc.clone()], - vec![acc.clone()], - Vec::new(), - key_extent(), - value_extent(), - ); - let fan = Rc::new(FanOut::new(Box::new(store))); + let (fan, acc) = induction_cycle(&[5, 1, 1, 9], 3, 0); let releases = Rc::new(RefCell::new(Vec::::new())); let recorder = ReleaseRecorder { inner: fan.branch(), @@ -4067,8 +4529,9 @@ mod tests { let guard = reader.tiling().universal_guard(); let mut producer = reader.subscribe(guard, Box::new(|| {}), &mut Scheduler::new()); - // Drive the fold once so the reader caches which ticks wrote `acc`. - let _ = producer.get(producer.tiling().universal_guard()); + // Drive the fold to convergence so the reader caches which ticks wrote + // `acc` — the cycle advances one position per pull. + let _ = pull_to_terminal(&mut producer); producer.release(TileGuard::Function(FunctionGuard::Domain( Predicate::LessThanEq(Value::UInt(0)), @@ -4526,7 +4989,7 @@ mod tests { &mut self.base } fn get_impl(&mut self, _projection_guard: TileGuard) -> Tile { - self.tile.clone() + honoring_release(&self.tile, self.obsolete_guard()) } fn release_impl(&mut self, _obsolete_guard: TileGuard) {} } @@ -4705,6 +5168,188 @@ mod tests { assert_eq!(store_at(&latest, &acct("n")), Some((3, 3))); } + /// What a [`TransactDriver`] emitted over a run: the largest live window it + /// ever rendered, and how many attempts it ever posted. + /// + /// Both read straight off the tile the driver hands its consumers — the live + /// window *is* that tile's domain, and an attempt is one position of it — so + /// the probe observes the invariant without standing in for any part of the + /// release discipline that establishes it. + #[derive(Default)] + struct DriverObservation { + max_window: usize, + attempts: usize, + } + + /// A pass-through in front of a driver that records each tile it emits. + struct DriverProbe { + inner: Box, + seen: Rc>, + } + + struct DriverProbeProducer { + base: ProducerBase, + inner: Box, + seen: Rc>, + } + + impl TileOperator for DriverProbe { + fn tiling(&self) -> &Tiling { + self.inner.tiling() + } + fn subscribe( + &mut self, + intent_guard: TileGuard, + consumer: Box, + scheduler: &mut Scheduler, + ) -> Box { + let inner = self.inner.subscribe(intent_guard, consumer, scheduler); + Box::new(DriverProbeProducer { + base: ProducerBase::new(DriverProbeProducer::alloc_id(), self.inner.tiling()), + inner, + seen: self.seen.clone(), + }) + } + } + + impl TileProducer for DriverProbeProducer { + impl_producer_base!(); + fn get_impl(&mut self, projection_guard: TileGuard) -> Tile { + let tile = self.inner.get(projection_guard); + if let Tile::SealedFunction { domain, .. } = &tile { + let mut seen = self.seen.borrow_mut(); + seen.max_window = seen.max_window.max(domain.len()); + // Positions are absolute and one per attempt, so the highest ever + // seen counts the attempts even after the window compacts. + if let Some(top) = newest_body_position(&tile) { + seen.attempts = seen.attempts.max(top + 1); + } + } + tile + } + fn release_impl(&mut self, obsolete_guard: TileGuard) { + self.inner.release(obsolete_guard); + } + } + + /// Wire one real [`TransactDriver`]/[`TransactWriter`] pair per entry of + /// `draws` against a shared single-key [`CommitOperator`], and return the + /// store fan with each driver's observation handle. + /// + /// Every writer reads *and* writes the one key, so no two attempts can commit + /// at the same frontier: each pull one writer wins and the rest go stale and + /// re-attempt. That is the contention this exists to produce. + fn contending_writer_cycle( + init: i64, + draws: &[&[i64]], + ) -> (Rc, Vec>>) { + let pool = acct("pool"); + let commit = CommitOperator::new( + balances(&[("pool", init)]), + key_extent(), + value_extent(), + draws.len(), + ); + let setters: Vec<_> = (0..draws.len()) + .map(|w| commit.writer_input_setter(w)) + .collect(); + let store_fan = Rc::new(FanOut::new_cyclic(Box::new(commit))); + let mut seen = Vec::with_capacity(draws.len()); + for (items, set_writer) in draws.iter().zip(setters) { + let observation = Rc::new(RefCell::new(DriverObservation::default())); + let driver = TransactDriver::new( + store_fan.branch(), + Box::new(ItemSource::new(items)), + vec![pool.clone()], + vec![value_extent()], + value_extent(), + ); + let driver_fan = Rc::new(FanOut::new(Box::new(DriverProbe { + inner: Box::new(driver), + seen: observation.clone(), + }))); + // A compiled body fans its input through a `Memo`, and that is + // load-bearing here rather than incidental: the `Memo` releases each + // row as it consumes it, which is the eager half of the driver's + // release intersection. Without it the intersection would be the + // writer's ack alone, and a superseded row could not be reclaimed + // before its item finished. + let body = AddIfBody::new(Box::new(Memo::new(driver_fan.branch())), i64::MIN); + set_writer(Box::new(TransactWriter::new( + store_fan.branch(), + Box::new(body), + driver_fan.branch(), + vec![pool.clone()], + vec![pool.clone()], + Vec::new(), + key_extent(), + value_extent(), + ))); + seen.push(observation); + } + (store_fan, seen) + } + + /// **A contended item costs a flat window, not one row per retry.** + /// + /// Six writers each draw 1 from a pool of 100, all through the same key, so + /// every attempt conflicts: one writer commits per pull and the other five go + /// stale and re-attempt at the advanced frontier. A writer therefore re-poses + /// its single item several times before winning — which is the condition the + /// end-to-end suite never reaches, because two alternating writers make the + /// loser retry exactly once. + /// + /// Under that, the driver's live window must stay at [`MAX_LIVE_ATTEMPTS`]: the + /// writer releases everything below the position it decides, so a superseded + /// row is reclaimed on the next release rather than waiting for the item to + /// finish. A window that instead grew with retries would be retained rows + /// linear in the retry count and, because the body re-renders the whole window + /// each pull, body rows quadratic in it. + /// + /// The retry assertion is not decoration. It is what stops this from passing + /// vacuously if the drain order ever stopped producing contention — a flat + /// window over zero retries proves nothing. + #[test] + fn a_contended_item_keeps_the_drive_window_flat() { + const WRITERS: usize = 6; + let draws: Vec<&[i64]> = vec![&[-1]; WRITERS]; + let (store_fan, seen) = contending_writer_cycle(100, &draws); + + let mut external = store_fan.branch(); + let guard = external.tiling().universal_guard(); + let mut producer = external.subscribe(guard, Box::new(|| {}), &mut Scheduler::new()); + let mut latest = producer.get(producer.tiling().universal_guard()); + for _ in 0..MAX_CYCLE_PULLS { + latest = producer.get(producer.tiling().universal_guard()); + } + + // Every draw committed exactly once: the pool conserves. + assert_eq!( + store_at(&latest, &acct("pool")).map(|(_, v)| v), + Some(100 - WRITERS as i64), + "each of the {WRITERS} draws commits exactly once" + ); + + let retries: Vec = seen + .iter() + .map(|o| o.borrow().attempts.saturating_sub(1)) + .collect(); + assert!( + retries.iter().any(|&r| r >= 3), + "the schedule produced no deeply contended item ({retries:?} retries per writer), \ + so the window bound below would hold vacuously" + ); + for (w, observation) in seen.iter().enumerate() { + let max_window = observation.borrow().max_window; + assert!( + max_window <= MAX_LIVE_ATTEMPTS, + "writer {w} retried {} times and its driver's window reached {max_window} \ + (bound {MAX_LIVE_ATTEMPTS}) — a superseded row is not being reclaimed", + retries[w] + ); + } + } + /// One accumulated proposal: `(snapshot, read set, write set)`. type EmittedProposal = (usize, HashMap, HashMap); @@ -5289,7 +5934,7 @@ mod tests { &mut self.base } fn get_impl(&mut self, _projection_guard: TileGuard) -> Tile { - self.tile.clone() + honoring_release(&self.tile, self.obsolete_guard()) } fn release_impl(&mut self, _obsolete_guard: TileGuard) {} } @@ -5592,7 +6237,7 @@ mod tests { // An async source's domain arrives unordered (it enumerates a set of // arrived keys), and the codomain aligns to the domain *column*, not to // position. Decoding must pair each item with its actual domain position - // and sort — otherwise the position-driven drive reads the wrong item at + // and sort — otherwise the position-driven driver reads the wrong item at // each tick, and a scalar-final `ExtractFinal` over the dense read (which // relies on the highest position being last) picks a mid-loop value. let tile = Tile::SealedFunction { @@ -5659,4 +6304,42 @@ mod tests { terminal: false, })); } + /// A `Memo` over a dense read of a *live* store must never cache a value the + /// store has not decided yet. + /// + /// The composition is the hazard: `Memo` merges each pull's tile and then + /// *releases* what it merged, and the dense read forwards that release to its + /// trigger — so a position emitted once is never offered again. If the read + /// emitted undecided positions, the very first pull would hand over every + /// position folded to the seed, the `Memo` would latch those, and the store + /// going terminal later would publish the stale cache as complete. The store + /// advances one position per pull, so nothing else prevents that. + #[test] + fn a_memo_over_a_live_dense_read_caches_only_decided_positions() { + let (fan, acc) = induction_cycle(&[1, 2, 3], i64::MIN, 0); // unconditional + let trigger = IterateExtent::new(Extent::uint_range(3)); + let reader = + StoreDenseRead::new(Box::new(trigger), fan.branch(), acc, value_extent(), true); + let mut memo = Memo::new(Box::new(reader)); + let guard = memo.tiling().universal_guard(); + let mut producer = memo.subscribe(guard, Box::new(|| {}), &mut Scheduler::new()); + let tile = pull_to_terminal(&mut producer); + let Tile::SealedFunction { codomain, .. } = &tile else { + panic!("dense read is a SealedFunction"); + }; + let Tile::Scalar(col) = codomain.as_ref() else { + panic!("dense read codomain is a scalar column"); + }; + let got: Vec = (0..col.len()) + .map(|i| match col.index_at(i) { + Value::Int(v) => v, + other => panic!("unexpected dense value {other:?}"), + }) + .collect(); + assert_eq!( + got, + vec![1, 3, 6], + "the memo cached partially-decided folds instead of the accumulator" + ); + } } diff --git a/src/interpreter/design-operators.md b/src/interpreter/design-operators.md index f98e198c7..766ac034a 100644 --- a/src/interpreter/design-operators.md +++ b/src/interpreter/design-operators.md @@ -207,11 +207,23 @@ The transaction engine that backs a `Type::Txn` [`Transact`](../ccl/design/ir.md - **`CommitEngine`** (tile-free, unit-tested) — the serialization logic. The store is `CommitTs ⇀ (Key ⇀ Value)`, held as per-tick write-set deltas with a per-key latest-write index. `attempt(proposal)` allocates the next tick and commits iff no read key was overwritten after the proposal's snapshot (else `Stale`, and the writer retries at the advanced watermark). `read_as_of(t, key)` folds the delta history. - **`CommitOperator` / `CommitProducer`** — the store's tile adapter. It owns the engine, publishes its history as one [`Tile::Store`] output, drains each writer's new proposals in writer-index order (the serialization order, rotated per pull so no writer is starved), and acknowledges a commit by `release`ing that step back to its writer. Writer inputs are wired *after* construction, so the operator sits inside a cyclic `FanOut` and every writer reads the store back before proposing — the cyclic-`FanOut` feedback idiom, one writer per key. -- **`TransactWriter` / `TransactWriterProducer`** — one *fused* writer per `with begin():` site (fused, not fanned: a stateful append-only proposal stream cannot be split across fanned branches without desyncing). Each pull folds `(frontier, snapshot)` for the site's read keys out of the cyclic store, feeds `(snap…, item)` to the decision body, and — keyed on `(item, frontier)`, so a retry is idempotent — appends a `{snap, reads, writes}` proposal when the body's decision is `` `commit ``, or advances locally when it is `` `abort ``. When the decision also reads an induction accumulator, that value arrives co-iterated in the writer *source* or broadcast as a constant — see [mutability.md](../ccl/design/mutability.md#reading-an-induction-accumulator-in-a-commit-decision), "Reading an induction accumulator in a commit decision". -- **`BodyInputSource` / `BodyInputSourceProducer`** — serves the writer body its `(snapshot…, item)` input. **Release-aware**: the body fans this source through `FanOut`/`Memo`, which pull it repeatedly per round, so it emits only positions past its released cursor — re-emitting a released position would make the `Memo`'s append-merge duplicate a domain position (an invalid tile). Delta-producing, like the induction body's `fan_in` input. +- **`TransactDriver` / `TransactDriverProducer`** — one per `with begin():` site: it owns the transaction source, folds `(frontier, snapshot)` for the site's read keys out of the cyclic store, and **produces** the decision body's `(snap…, item)` input. A row is emitted once per `(item, frontier)`, so a retry at a moved frontier is a fresh position and a re-pull at an unchanged one emits nothing. It closes (terminal) once every transaction has been attempted and acked over a source that can deliver no more — the writer's completeness signal, since the writer owns no source of its own. +- **`TransactWriter` / `TransactWriterProducer`** — one *fused* writer per site (fused, not fanned: a stateful append-only proposal stream cannot be split across fanned branches without desyncing). Each pull it decides the driver's newest live position and appends a `{snap, reads, writes}` proposal when the body's decision is `` `commit ``, or advances locally when it is `` `abort ``. When the decision also reads an induction accumulator, that value arrives co-iterated in the writer *source* or broadcast as a constant — see [mutability.md](../ccl/design/mutability.md#reading-an-induction-accumulator-in-a-commit-decision), "Reading an induction accumulator in a commit decision". + + **The ack is a release intersection.** The driver sits behind a `FanOut` with two branches — the body and the writer — and advances its item cursor on what they *both* release. A body releases a row as soon as it has consumed it, which says nothing about commitment; the writer releases it when the attempt has finished, committed or denied without proposing. Only the intersection means "this item is done", which is why the writer holds a driver branch it barely reads: that branch is the ack channel. + + Both branches of that intersection are load-bearing, including the body's. A compiled body + fans its input through a `Memo`, which releases each row as it *consumes* it — and it is + that eager half which lets a superseded row be reclaimed before its item finishes. A body + chain that released only when its own output was released would leave the intersection + standing at the writer's ack, and the window below would grow one row per retry with the + supersession release still in place. So this is an obligation on the body chain, alongside + forwarding `domain_predicate`. + + **A release is not always an ack, though — supersession reclaims too.** The writer decides only the driver's *newest* live position, so every older one is abandoned and is released immediately rather than at the item's finish. That keeps a contended item's cost flat: the body re-renders the driver's whole live window each pull, so a window that grew one row per retry would make K retries cost K rows retained and K² body rows evaluated. The bound is `MAX_LIVE_ATTEMPTS`, asserted in the driver and measured at six contending writers — a window of 2 with the supersession release, 6 without it, over an item that lost five times. It also means the driver cannot read "a row was released" as "the item finished" — only the release of its **newest live row** is the ack, exactly as a release from the body alone is not one. - **`StoreFinalRead` / `StoreFinalReadProducer`** — the **terminal read** of a commit key: `Scalar(V)`, the key's carried value at the position its own writers finish, or the store's tick-0 seed if no commit wrote it. It samples through the same `store_current` as `AsOf` and differs only in what fixes the position — a trigger's arrival there, the store's closure here — so it is neither a reduction nor a projection of the history, and needs no seed operand. Empty (and so non-terminal) until the store reports the key settled. A universal release retires it and releases the store branch; other readers hold their own guards through the fan, which the fan intersects, so the store still reclaims a version only once all of them have released it. - **`StoreValueStream` / `StoreValueStreamProducer`** — projects one key's commit-value stream `CommitTs ⇀ V` out of the store changelog, carrying the value forward across ticks that wrote other keys (the step interpolation), so its own output is a `SealedFunction` with a decided value at every tick. It backs the in-block reply tap (`carry_forward: false` — one entry per committed transaction) and the read-your-writes mutable variable carry (`carry_forward: true`). -- **`AsOf` / `AsOfProducer`** — the **as-of (temporal) join**, the cross-endpoint read. Given a `trigger` stream (the positions to sample at, e.g. an HTTP request stream) and the store, it latches the store's current value for each trigger position the first time that position is observed — indexed by the *trigger*, not the commit clock. Reading several mutable variables latches them all from one store render, so a multi-variable read is one snapshot. The dual of the changelog store's own drive: the store latches a private accumulator per *source* step, `AsOf` latches the store per *trigger* step. +- **`AsOf` / `AsOfProducer`** — the **as-of (temporal) join**, the cross-endpoint read. Given a `trigger` stream (the positions to sample at, e.g. an HTTP request stream) and the store, it latches the store's current value for each trigger position the first time that position is observed — indexed by the *trigger*, not the commit clock. Reading several mutable variables latches them all from one store render, so a multi-variable read is one snapshot. The dual of the changelog store's own driver: the store latches a private accumulator per *source* step, `AsOf` latches the store per *trigger* step. A single-writer induction store is the degenerate no-conflict case of this same contract, which is why one `Transact` carrier serves both engines. @@ -223,7 +235,7 @@ A single-writer induction store is the degenerate no-conflict case of this same A writer body returns one **decision variant** per transaction, `` {`commit{𝑃} | `abort} `` (`ccl_utils::wrap_decision_variant`). `` `commit `` carries the payload record 𝑃 = `{writes, to_*}` — the positional tuple of proposed per-key new values, plus one field per reply tap — and `` `abort `` is the nullary whole-transaction deny: carry, no proposal. Making the grant/deny the *tag* rather than a `commit` field leaves "denied yet real writes" unrepresentable. `body_decision_at` decodes the tag by name, so the two ends agree without a canonical arm position. A tap fed under one arm of cross-key *routing* carries a companion `to__k__fire : Bool` gate holding that tap's own control-flow path (see [mutability.md](../ccl/design/mutability.md#general-in-transaction-conditionals-and-conditional-writes), "General in-transaction conditionals (and conditional writes)"). The grant path omits a non-fired tap from the commit delta, so a routed reply fires only on its own route. A tap whose path *is* the commit — a single-guard or spine feed — carries no gate and fires with its transaction, keeping unconditional programs at their gate-free shape. -### Convergence: the writer drives, one step per pull +### Convergence: the writer re-arms, one step per pull A writer processes **one source item per pull** and re-arms itself on the scheduler's deferred-wakeup queue whenever an item remains, returning non-terminal — the same one-step-per-pull idiom the induction and commit stores share. That single re-arm covers every continuation uniformly: a **commit** (the commit-ack `release` advances it, so the next pull takes the next item), a **deny** (it advances locally with no commit — invisible in the store frontier, which a frontier-growth signal alone would miss), and a **not-ready** decision (it does not advance, and reuses the pending body-input row). It is the *writer's* re-arm, not any reader's, that converges the store: the wakeup fans through the cyclic `FanOut` to re-pull the `AsOf` / `StoreValueStream` readers as commits land, so no reader drives a store to fixpoint. A writer **drained but live** does not re-arm, so an idle live server does not busy-poll — a future arrival wakes it through its source-forwarding consumer. @@ -426,28 +438,60 @@ joins, aggregates and the changelog induction store all run over a literal list one thing the distinction would still buy is a memory bound on a never-terminating loop; see [*Remaining: the never-terminating bound*](#remaining-the-never-terminating-bound). -**`InductionStore` — the position-driven producer.** It owns a `CommitEngine` seeded at -tick 0 with the accumulators' inits (so the changelog is self-describing — a read below -the first *iteration* change folds to the seed), and drives the accumulator recurrence -**sequentially inside the producer**: it decodes the source into `(absolute position, item)` -pairs (`decode_source_positioned`) — an async source's domain arrives *unordered* (it -enumerates a set of arrived keys) and *compacts* as its consumed prefix is released, so the -drive keys off the actual `UInt` domain position, not the codomain's column order — and -drives positions **contiguously from `processed`**, stopping at the first gap (a -not-yet-arrived position; the recurrence is sequential, so a later position cannot be -decided before its predecessor). For each position it folds the previous accumulator out of -the engine, feeds the writer body `(prev…, item)` through a [`BodyInputSource`] buffer, reads -the `` {`commit{writes} | `abort} `` decision (`body_decision_at` decodes the union tag), and -`step`s the engine — a `` `commit `` position appends a change (tick `pos + 1`), an `` `abort `` (a -failed guard) is a **carry** (no change; the value inherits). Because the accumulator lives in the engine, not on a cyclic tile, there is -**no cyclic `FanOut`** — the previous value is always available before the body needs it. -This dissolves the cyclic-convergence desync that a restricted-source multi-leg realization -suffered: there is one writer over the *full* source, and a conditional write's carry -positions simply produce no change rather than a synthesized same-value write on a -complement leg. As the drive advances it reclaims the consumed source prefix incrementally -(`release(LessThanEq(processed - 1))`), and releases the whole source (`True`) once terminal -— the source drops a row only when *every* producer has released it (cross-producer -intersection), so a co-iterated reader still folding earlier positions keeps them live. +**`InductionDriver` / `InductionStore` — the position-driven recurrence.** The two halves +of one loop, wired as a cycle: store → body → driver → `FanOut::new_cyclic(store)`. + +The **store** owns a `CommitEngine` seeded at tick 0 with the accumulators' inits (so the +changelog is self-describing — a read below the first *iteration* change folds to the seed). +It consumes the body's `` {`commit{writes} | `abort} `` decisions (`body_decision_at` decodes +the union tag) contiguously from its decided watermark (which counts the positions already +stepped — the store keeps no separate cursor for them) and `step`s the engine: a `` `commit `` position +appends a change (tick `pos + 1`), an `` `abort `` (a failed guard) is a **carry** (no change; +the value inherits). It closes its frontier when the decision stream goes terminal. + +That last clause is an **obligation on the body chain**, and worth stating because it is +easy to violate without noticing. The driver owns the source and closes its body-input tile, +so the store learns the loop is over only if every operator between the two forwards +`domain_predicate`. An operator that +renders a decision column but hardcodes a non-terminal predicate leaves the loop running +forever with the right values in it — a hang, not a wrong answer. The store asserts the +matching gaplessness property (a terminal decision stream with a hole in it) but cannot +assert this one, since "the body never went terminal" is indistinguishable from "the body +is not done yet". + +The **driver** owns the iteration source and produces the body's `(prev…, item)` input. It +holds no part of the recurrence: the store's decided frontier already names the next position to +iterate (`step` advances the watermark unconditionally, so a carry decides its position +without appending a change), and the previous accumulator is that key's value *at* the +frontier (`store_value_at`, one fold per read key — folding *at* the position being fed, +which is what the recurrence means, rather than taking the key's latest write). So an +emitted row is a pure function of the store tile and the source tile, with nothing cached +that could drift. It decodes the source +into `(absolute position, item)` pairs (`decode_source_positioned`), since an async source's +domain arrives *unordered* and *compacts* as its consumed prefix is released; it reclaims +that prefix incrementally and releases the whole source (`True`) once the loop is done. It +also releases the changelog through the frontier — the store's keep-latest GC preserves each +key's latest write inside a released prefix, so the fold is never stranded, and without it +the store's `FanOut`-intersected watermark could never advance past the cycle branch. + +**One position advances per outer pull**, because the cyclic `FanOut` serves a snapshot +taken before the traversal began: a position decided *during* a pull is not visible until +the next. This is a property of the cycle, not of the split — the store's producer is on +the stack for the whole traversal, so no arrangement of driver, body and store can refresh +the memo mid-pull. It is the rate every cyclic operator here runs at, and the driver re-arms +on the wakeup queue while a position remains to feed. + +A pull-per-position means a long loop re-renders its changelog many times. That is a +**retention** problem, not a rate problem: the fix is a `Memo` in front of the store's +readers, caching the rendered tile and letting a reader release its consumed prefix early so +`gc_released_prefix` bounds what each render covers. Letting the store publish its +freshly-rendered tile into its own cyclic fan's memo, so the driver sees the position it just +decided, would buy a multi-position driver by inverting `get`'s direction — a change to the +model in exchange for a caching improvement, and not one to make. + +There is one writer over the *full* source: a conditional write's carry positions produce no +change rather than a synthesized same-value write on a complement leg, which is what keeps a +restricted-source multi-leg realization's cyclic-convergence desync from arising. **`StoreDenseRead` — the dense changelog read.** A `__reg.k` read folds the changelog at *every* position of the loop extent → `Fun(D, V)`: an `IterateExtent(D)` trigger supplies @@ -484,7 +528,7 @@ position it reads the tap **only if that position's delta actually wrote it** (`store_delta_at`), so the feed's per-position stream spans exactly the fired positions. A **conditional feed** (`if p: out << e`) is the same shape — the letrec phase gives it a `to___fire` gate (its guard path) and folds that path into the `commit` gate so a -feed-only position still appends a change carrying the tap. Because the drive is +feed-only position still appends a change carrying the tap. Because the driver is position-sorted, the tap stream is position-ordered even over an async source (the dense `Recurse` path scrambled it by arrival order — the bug this replaces). @@ -510,12 +554,12 @@ source — so the existing keep-global-latest GC suffices; no per-frontier reten This bounds the changelog for *any* carry consumer (co-iterated or scalar-final) without the producer knowing which it is. -The drive's own release runs the other way — outward, to the iteration source. `InductionStore` +The driver's own release runs the other way — outward, to the iteration source. `InductionStore` reclaims the consumed prefix incrementally as `processed` advances, and releases the source in -full once it is complete and every arrived position is decided. That ends the drive: a drained +full once it is complete and every arrived position is decided. That ends the driver: a drained store serves the accumulated changelog, which is already the whole answer. -Keep-latest is also what makes the drive sound despite reading the changelog it writes: +Keep-latest is also what makes the driver sound despite reading the changelog it writes: `read_as_of(processed)` folds to the latest write ≤ `processed`, which GC never drops, so the recurrence is never stranded (the delicate part — a naive GC that dropped the latest produced the `30`-then-`10` failure a probe once hit). Retention is therefore **O(keys) + the slowest diff --git a/src/interpreter/http_server.rs b/src/interpreter/http_server.rs index b06f3965b..7daf0ec5c 100644 --- a/src/interpreter/http_server.rs +++ b/src/interpreter/http_server.rs @@ -41,6 +41,8 @@ use crate::interpreter::{ type RouteSender = Sender>; /// Routing table shared between the dispatcher thread and the [`SharedHttpServer`] handle. +// shared-state-ok: the I/O boundary, not the operator graph — side effects live +// at the edge, and no operator reads this. type RouteMap = Arc>>; /// A single `tiny_http::Server` shared by all `http_serve` calls on the same port. @@ -184,6 +186,8 @@ pub fn reserve_test_port() -> u16 { /// Every lock this process holds. The reservation must outlive the call — /// the server binds later, inside `compile_program` — and a test has no /// scope to keep a guard in, so the locks live until the process exits. + // shared-state-ok: port reservations for the test harness, held open for the + // process's lifetime. Outside the operator graph entirely — no operator reads it. static RESERVED: Mutex> = Mutex::new(Vec::new()); let dir = std::env::temp_dir(); @@ -236,6 +240,10 @@ pub fn reserve_test_port() -> u16 { /// the CCL program produces a response for them. pub struct HttpServerSharedState { /// Map from request index to the still-open `tiny_http::Request`. + // shared-state-ok: the external-world boundary, where side effects live by + // design. It holds the *socket* a request arrived on, not a value the program + // computed: the request's data reaches the sink as a tile like anything else, + // and this is what the reply is finally written to. pending: Mutex>, } diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 1fe931789..ba12839e2 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -41,6 +41,8 @@ pub trait Consumer { } /// Blanket implementation: Rc> implements Consumer when C does. +// shared-state-ok: this is what makes a shared *notification* handle a consumer; +// a wakeup carries no value, so it is not a dataflow edge. impl Consumer for Rc> { fn notify(&mut self) { self.borrow_mut().notify() diff --git a/src/interpreter/operator_conversion.rs b/src/interpreter/operator_conversion.rs index dc165b726..f566f676d 100644 --- a/src/interpreter/operator_conversion.rs +++ b/src/interpreter/operator_conversion.rs @@ -22,9 +22,8 @@ use crate::{ // (aliased `CommitWriter` to avoid clashing with the CCL `TransactWriter` // node-field carrier imported from `ccl` above). commit_operator::{ - AsOf, AsOfField, BodyInputBuffer, BodyInputSource, CommitOperator, InductionStore, - StoreDenseRead, StoreFinalRead, StoreValueStream, TransactWriter as CommitWriter, - WriterBuffer, + AsOf, AsOfField, CommitOperator, InductionDriver, InductionStore, StoreDenseRead, + StoreFinalRead, StoreValueStream, TransactDriver, TransactWriter as CommitWriter, }, tile_operators::{ Aggregate, Constant, Converse, ExtractAggregate, ExtractFinal, FanOut, Filter, @@ -901,10 +900,10 @@ fn convert_impl_inner( { let upstream = expect_input(input, "filter_values")?; // `Memo` the shared upstream: `Filter` pulls it as both the value stream - // and (through the predicate) the boolean stream, and a re-entrant / - // per-proposal driver (the transaction writer) pulls the body repeatedly - // — without the memo the two fan branches desync (one sees a position - // the other has already consumed). + // and (through the predicate) the boolean stream, and the transaction + // writer re-pulls the body once per proposal — without the memo the two + // fan branches desync (one sees a position the other has already + // consumed). let fan = Rc::new(FanOut::new(Box::new(Memo::new(upstream)))); let pred_op = convert_impl(argument, Some(fan.branch()), ctx)?; Ok(Box::new(Filter::new(fan.branch(), pred_op))) @@ -1490,7 +1489,7 @@ fn body_tap_fields(body_ty: &Type) -> Vec<(String, Type)> { /// Build a [`Type::Txn`] transactional store: a multi-key [`CommitOperator`] /// wired in a cyclic [`FanOut`], one *fused* [`CommitWriter`] per writer (a /// branch of the shared store output). Each fused writer reads the cyclic store, -/// runs its body — the ``let k₀ = p.0 in … let item = p.r in {`commit{writes} | `abort}`` decision, fed via a buffer the writer owns — and either grants (appends +/// runs its body — the ``let k₀ = p.0 in … let item = p.r in {`commit{writes} | `abort}`` decision, whose input is the driver's tile — and either grants (appends /// a proposal) or denies. A single writer is the degenerate case (no conflicts → /// no retries); ≥2 writers serialize through the operator with conflict + retry. /// A *fused* writer (not fanned) is load-bearing: a stateful sequencing producer @@ -1566,7 +1565,7 @@ fn build_commit_store( })?; let item_extent = ctx.extent_of(&item_ty)?; let source_op = convert_impl(&w.source, None, ctx)?; - // The body is fed `(snap_{k₀}, …, item)` via a buffer the writer owns; the + // The body's input is the driver's tile, `(snap_{k₀}, …, item)`; the // snapshot columns carry each read key's per-commit value extent. let read_extents: Vec = w .read_keys @@ -1580,9 +1579,19 @@ fn build_commit_store( }) }) .collect::>()?; - let buffer: BodyInputBuffer = Rc::new(RefCell::new(WriterBuffer::default())); - let body_input = BodyInputSource::new(buffer.clone(), read_extents, item_extent); - let body_op = convert_impl(&w.body, Some(Box::new(body_input)), ctx)?; + let driver = TransactDriver::new( + store_fan.branch(), + source_op, + w.read_keys.iter().map(runtime_key).collect(), + read_extents, + item_extent, + ); + // Two branches of the driver: the body consumes rows, and the writer acks + // finished attempts. The driver advances its item cursor on the release + // *intersection*, so a body's consume-release cannot advance it past an + // attempt still in flight. + let driver_fan = Rc::new(FanOut::new(Box::new(driver))); + let body_op = convert_impl(&w.body, Some(driver_fan.branch()), ctx)?; // A reply (`out << e`) rides this writer body as `to_` decision // taps. Each commits as a write-only key (appended after the mutable variable write // keys), so the reply rides this transaction's commit and is read back as a @@ -1611,8 +1620,7 @@ fn build_commit_store( let writer = CommitWriter::new( store_fan.branch(), body_op, - source_op, - buffer, + driver_fan.branch(), w.read_keys.iter().map(runtime_key).collect(), write_keys, tap_fields, @@ -1652,7 +1660,7 @@ fn build_induction_store( // writer — plain, conditional, or feed-carrying — compiles to the // position-driven `InductionStore` over the `Tile::Store` changelog, read // densely via `StoreDenseRead`. Finite (list) and async (`DataSource`) extents - // share it: the drive reads the source by absolute position and tolerates + // share it: the driver reads the source by absolute position and tolerates // unordered/incremental arrival, so a finite loop is just the terminating // instance of the same changelog. let [w] = writers else { @@ -1671,10 +1679,12 @@ fn build_induction_store( } /// Build a single-writer induction store as a position-driven [`InductionStore`] -/// over a [`Tile::Store`] changelog. Mirrors [`build_commit_store`]'s writer -/// setup — the body reads `(prev…, item)` through a [`BodyInputSource`] the store -/// feeds — but the store is driven by iteration position (no cyclic `Recurse`, -/// no conflict/retry). Reads register as [`StoreReadKind::InductionChangelog`]: +/// over a [`Tile::Store`] changelog, wired as a cycle through a +/// `FanOut::new_cyclic`: the store consumes the body's decisions, and an +/// [`InductionDriver`] reads the changelog back to produce the body's +/// `(prev…, item)` input. Mirrors [`build_commit_store`]'s writer setup, but +/// driven by iteration position — one writer, no conflict, no retry. Reads +/// register as [`StoreReadKind::InductionChangelog`]: /// each `__reg.k` folds the changelog densely over the loop extent via /// [`StoreDenseRead`], serving both a scalar-final read (`ExtractFinal` over it) /// and a co-iterated read (the dense `Fun(D, V)` itself). @@ -1717,9 +1727,9 @@ fn build_induction_store_single( ); } - // The body reads each accumulator's snapshot then the loop item, fed through a - // buffer the store owns (`BodyInputSource`) — the same body shape a commit - // writer expects (`let accᵢ = p.i … let item = p.r`). + // The body reads each accumulator's snapshot then the loop item, produced by + // the driver — the same body shape a commit writer expects + // (`let accᵢ = p.i … let item = p.r`). let item_ty = w.source.ty.codomain().ok_or_else(|| { ConversionError::TypeError(format!( "induction-store writer source must have function type, got {}", @@ -1749,10 +1759,6 @@ fn build_induction_store_single( }) }) .collect::>()?; - let buffer: BodyInputBuffer = Rc::new(RefCell::new(WriterBuffer::default())); - let body_input = BodyInputSource::new(buffer.clone(), read_extents, item_extent); - let body_op = convert_impl(&w.body, Some(Box::new(body_input)), ctx)?; - // A reply (`out << e`) rides this loop body as `to_` decision taps — // the same shape a commit writer carries (see `build_commit_store`). Each tap // becomes a write-only changelog key (appended after the accumulator keys), so @@ -1784,21 +1790,20 @@ fn build_induction_store_single( _ => Extent::Union(TagMap::from_positional(value_extents)), }; - let store = InductionStore::new( - init_ops, - body_op, + let store = InductionStore::new(init_ops, write_keys, tap_fields, key_extent, value_extent); + let set_body = store.body_input_setter(); + // Cyclic: the driver reads this store's changelog back to recover each + // position's previous accumulator, so one fan branch feeds the cycle and the + // rest serve the downstream `__reg.k` dense reads. + let fan = Rc::new(FanOut::new_cyclic(Box::new(store))); + let driver = InductionDriver::new( + fan.branch(), source_op, - buffer, w.read_keys.iter().map(runtime_key).collect(), - write_keys, - tap_fields, - key_extent, - value_extent, + read_extents, + item_extent, ); - // A non-cyclic fan: unlike a commit store, no writer reads this store back - // (the driver folds the accumulator out of its own engine), so the only - // consumers are the downstream `__reg.k` dense reads. - let fan = Rc::new(FanOut::new(Box::new(store))); + set_body(convert_impl(&w.body, Some(Box::new(driver)), ctx)?); Ok(StoreReadInfo { fan, keys: keys_map, @@ -2150,7 +2155,8 @@ fn union_operand_ops( )); } // `Memo` the shared fed input so the fan's branches (one per arm) stay - // consistent under a re-entrant / per-proposal driver. + // consistent under a re-entrant pull — the transaction writer pulls the + // body once per proposal. let fan = Rc::new(FanOut::new(Box::new(Memo::new(inp)))); let ops = operands .iter() diff --git a/src/interpreter/scheduler.rs b/src/interpreter/scheduler.rs index e5144b9df..12218b33e 100644 --- a/src/interpreter/scheduler.rs +++ b/src/interpreter/scheduler.rs @@ -22,6 +22,26 @@ use crate::interpreter::{Consumer, DataSourceDomainExtentImpl}; /// (and that a producer can clone to re-arm on its next pull). pub type SharedConsumer = Rc>; +/// Share one operator's consumer between several of its inputs. +/// +/// An operator with more than one input has a single downstream consumer to wake, +/// so the handle has to be shared. This is the one way to build that handle. +pub fn shared_consumer(mut consumer: Box) -> SharedConsumer { + Rc::new(RefCell::new(move || consumer.notify())) +} + +/// A fresh `Box` forwarding to `shared` — what `subscribe` wants for +/// an input whose notifications should reach the operator's own consumer. +/// +/// The closure is not ceremony: a `Box>>` is not itself a +/// `Consumer`, because the blanket impl over `Rc>` needs a *sized* `C`, +/// and `dyn Consumer` is not. Wrapping the wake in a closure gives the blanket +/// impl something sized to bite on. +pub fn forwarding_consumer(shared: &SharedConsumer) -> Box { + let shared = shared.clone(); + Box::new(move || shared.borrow_mut().notify()) +} + #[derive(Clone, Default)] pub struct WakeupQueue(Rc>>); diff --git a/src/interpreter/tile_operators/cycle_slot.rs b/src/interpreter/tile_operators/cycle_slot.rs new file mode 100644 index 000000000..4cdeeea13 --- /dev/null +++ b/src/interpreter/tile_operators/cycle_slot.rs @@ -0,0 +1,67 @@ +//! Late wiring for cyclic operator graphs. + +use std::cell::RefCell; +use std::rc::Rc; + +/// An operator input filled once construction is done, so a cycle can be built. +/// +/// A cyclic graph cannot be assembled bottom-up: the store must exist before the +/// `FanOut` that wraps it, which must exist before the drive that reads it, which +/// must exist before the body — and the body is the store's own input. One edge +/// has to be wired last. `CycleSlot` is that edge. +/// +/// **It holds an operator, never a value**, which is what separates it from a back +/// channel: the graph stays static and complete, and data still crosses it at `get` +/// time as a tile (see `src/interpreter/CLAUDE.md`, "Core invariant: data flows +/// between operators as Tiles, nothing else"). Reach for this rather than a +/// hand-rolled `Rc>` — it is the shape `./ci.sh shared_state` recognises +/// as legitimate, so a hand-rolled one has to justify itself instead. +pub struct CycleSlot( + // shared-state-ok: the single definition of the late-wiring cell. It holds an + // operator, wired once, not values passed between operators. + Rc>>>, +); + +impl CycleSlot { + pub fn new() -> Self { + Self(Rc::new(RefCell::new(None))) + } + + /// A one-shot filler for this slot, detached from the operator that owns it + /// so the caller can build the rest of the cycle first. `FnOnce` because a + /// slot is wired exactly once: a second fill would silently replace a live + /// input. + pub fn setter(&self) -> impl FnOnce(Box) + use { + let slot = self.0.clone(); + move |op| { + debug_assert!( + slot.borrow().is_none(), + "a cycle slot is wired once; refilling it would drop a live input" + ); + *slot.borrow_mut() = Some(op); + } + } + + /// Take the wired operator, leaving the slot empty. + /// + /// `None` means the slot holds nothing *now*, which covers two construction + /// bugs the caller should name in its own panic: the cycle was never closed + /// (`setter` was not called), or it is being subscribed a second time (the + /// first `take` emptied it). A slot holds an operator, and an operator is + /// subscribed once. + pub fn take(&self) -> Option> { + self.0.borrow_mut().take() + } +} + +impl Default for CycleSlot { + fn default() -> Self { + Self::new() + } +} + +impl Clone for CycleSlot { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} diff --git a/src/interpreter/tile_operators/fanout.rs b/src/interpreter/tile_operators/fanout.rs index b3afa35e7..631f22a31 100644 --- a/src/interpreter/tile_operators/fanout.rs +++ b/src/interpreter/tile_operators/fanout.rs @@ -111,12 +111,18 @@ impl<'a> Drop for TakenProducerGuard<'a> { /// underlying operator. Call [`FanOut::branch`] to get additional handles; /// subscribing to any handle will reuse the same inner producer. pub struct FanOut { + // shared-state-ok: the fan-out's own input operator, shared with the branches + // that are views of this one operator. It holds an *operator*, not values + // passed between operators — the same reason `CycleSlot` is legitimate. input: Rc>>, tiling: Tiling, /// All mutable shared state. Created eagerly so that branches produced by /// [`FanOut::branch`] always share the same object. shared: Rc>, /// Whether any branches have been created yet. + // shared-state-ok: construction-time bookkeeping of the `FanOut` itself, not a + // channel between operators — it records that `branch` has been called, and no + // value ever passes through it. used: RefCell, } @@ -188,6 +194,8 @@ impl FanOut { } struct FanOutBranch { + // shared-state-ok: the same operator handle as [`FanOut::input`] — a branch is + // a view of one fan-out, not a second one. An operator, not a value. input: Rc>>, tiling: Tiling, /// All mutable shared state. Created eagerly so that branches produced by diff --git a/src/interpreter/tile_operators/iterate.rs b/src/interpreter/tile_operators/iterate.rs index d50e2e9b0..ef7f162e5 100644 --- a/src/interpreter/tile_operators/iterate.rs +++ b/src/interpreter/tile_operators/iterate.rs @@ -5,7 +5,8 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc}; use super::*; use crate::ccl::TagMap; use crate::interpreter::{ - BaseType, ColumnValue, Consumer, Extent, NotifyOrSubscribeResult, Scheduler, UnionArm, Value, + BaseType, ColumnValue, Consumer, Extent, NotifyOrSubscribeResult, Scheduler, SharedConsumer, + UnionArm, Value, forwarding_consumer, }; /// Produces a sealed-function tile whose domain and codomain both equal `extent`. @@ -30,16 +31,12 @@ impl IterateExtent { fn add_all_source_handles( extent: &Extent, - consumer: Rc>, + consumer: SharedConsumer, scheduler: &mut Scheduler, ) { match extent { Extent::DataSourceDomain(extent_impl, ..) => { - let c = consumer.clone(); - scheduler.add_source_handle( - extent_impl.clone(), - Box::new(move || c.borrow_mut().notify()), - ); + scheduler.add_source_handle(extent_impl.clone(), forwarding_consumer(&consumer)); } Extent::Record(fields) => { for field_extent in fields.values() { diff --git a/src/interpreter/tile_operators/mod.rs b/src/interpreter/tile_operators/mod.rs index 394873af6..4e9605d82 100644 --- a/src/interpreter/tile_operators/mod.rs +++ b/src/interpreter/tile_operators/mod.rs @@ -30,6 +30,7 @@ use crate::{ mod aggregate; mod combinators; +mod cycle_slot; mod extract_final; mod fan; mod fanout; @@ -42,6 +43,7 @@ mod union; pub use aggregate::*; pub use combinators::*; +pub use cycle_slot::*; pub use extract_final::*; pub use fan::*; pub use fanout::*; @@ -118,6 +120,9 @@ pub trait TileOperator { /// /// Each key is the display name of a producer (e.g. `"MapApply"`, `"Memo"`), /// and the value is the next ID to assign. IDs start at 1. +// shared-state-ok: an ID allocator. What crosses it is a name, never a value: no +// operator reads anything another operator computed, so the producer graph still +// describes the whole dataflow. static PRODUCER_COUNTERS: OnceLock>> = OnceLock::new(); /// Common identity and tiling state shared by every [`TileProducer`]. diff --git a/src/interpreter/types/extent.rs b/src/interpreter/types/extent.rs index b92ce9e94..62c22b663 100644 --- a/src/interpreter/types/extent.rs +++ b/src/interpreter/types/extent.rs @@ -36,6 +36,8 @@ pub enum Extent { /// A restricted extent: wraps another extent with a restriction predicate. Restricted { base: Box, + // shared-state-ok: part of the extent *value*, shared with its own clones so + // a release narrows every view of one extent. Extents are types, not operators. restriction: Rc>, }, } @@ -401,6 +403,7 @@ impl Extent { } /// Return the restriction handle if this is an [`Extent::Restricted`] extent. + // shared-state-ok: hands out the extent's own restriction handle (see the field). pub fn restriction(&mut self) -> Option>> { match self { Extent::Restricted { restriction, .. } => Some(restriction.clone()),