Skip to content

feat: framework logic-change detection for live components - #2192

Open
pyjuan91 wants to merge 6 commits into
cocoindex-io:mainfrom
pyjuan91:feat/live-logic-deps-aggregation
Open

feat: framework logic-change detection for live components#2192
pyjuan91 wants to merge 6 commits into
cocoindex-io:mainfrom
pyjuan91:feat/live-logic-deps-aggregation

Conversation

@pyjuan91

@pyjuan91 pyjuan91 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

The core change is the engine can detect if there is any logic in this live component's subtree changing by itself. This replaces the logic_version approach from #2116 (where the user had to manually bump a version string whenever their transform changed, and they might forget the bump and the scan gets silently skipped, keeping stale results.)

On the connector side (OCI, both Python and Rust SDK), the skip-scan check changes from comparing with old version to processing unchanged.

Why the engine couldn't answer this before

Live children are built in isolated contexts, and the live drain path (HandleOutcome) carries no logic_deps. So the deps of the per-item transforms only live in the sub-components, nothing aggregates upward, and the live component has nothing to read back on restart.

How the aggregation works

The subtree's deps are collected as a union set on the write path:

  • update_full replaces the set, so fingerprints of edited/deleted code drop out.
  • Incremental update unions into it, a streamed item still exists on the next restart, so its code has to stay tracked.
  • processing_unchanged() reads the set back and runs all_contained_with_env(): if any fingerprint is no longer registered, some code changed → re-scan. It also returns false when there's no stored set or it can't be decoded, so the failure direction is always "scan again," not "silently skip."

One thing this depends on: #2142 (already merged) made stored memos cover their full subtree, so a cache-hit child still contributes its whole closure. Without that, editing a mounted grandchild function would wrongly pass the skip gate.

Following George's suggestion, the aggregation set reuses the component's existing ComponentMemoizationInfo DB entry instead of user state (this is framework change detection info, not user state). A live root has no processor_fp (it's not a memoized function call) and no return_value, so those two fields (serde renames "F" / "R") become Option, and the regular memo write path now wraps them in Some(...).

I went with Option rather than dummy values because the memo reuse check compares memo_info.processor_fp == Some(processor_fp), because a live entry's None can never match it, so the two kinds of entries can't be mistaken for each other.

Durability stays user-declared (durable_stream: bool) since LiveStream exposes no cursor for the framework to detect it.

pyjuan91 added 4 commits June 22, 2026 18:25
Persist a live component's subtree logic-dependency set `S` (own fp ∪
all descendants) as it processes, so a later run can detect whether the
processing logic changed without a second pass over persisted memo
entries. Foundation for framework-level logic-change detection on live
components (cocoindex-io#2124).

- Surface a root build's rolled-up deps without touching the readiness
  path: `run_in_background` takes an optional `oneshot` outcome sink,
  used when a root has no parent readiness guard to roll up to. The
  foreground mount path keeps rolling up via the guard and passes None.
- `update_full` recomputes and replaces `S` (edited-away fingerprints
  drop out); an incremental `update` op extends it with the item's
  subtree deps, skipping the write once they are already covered.
- Store `S` under a framework-reserved Symbol key in the Live keyspace,
  encoded as a sorted fingerprint vec. Dropped sink ⇒ no persist
  (failure-safe).

Write path only; reading `S` to gate a scan skip is a follow-up.
…tion

Add the read side of framework-level logic-change detection for live
components (cocoindex-io#2124): a predicate that reports whether a component's
processing logic is unchanged since its last committed scan, so a durable
connector can gate its startup full scan on it.

`LiveComponentController::processing_unchanged()` reads the persisted
subtree dependency set `S` and checks every fingerprint is still
registered in the current logic set. Failure-safe — returns false when no
scan was ever committed, when the stored value can't be decoded, or when
any dependency's code changed (each means "re-scan").

Surfaced through PyO3 as `processing_unchanged_async`, then on
`LiveComponentOperator`/`LiveComponentSubscriber` as `processing_unchanged()`
so a connector pairs it with its own durable cursor:
`<durable cursor> and await subscriber.processing_unchanged()`.

Tests cover first-run (no S -> false), unchanged across runs (-> true),
and a simulated child-code change (-> false).
Replace the OCI connector's manual `logic_version` skip-scan opt-in
(cocoindex-io#2116) with the framework-computed signal from cocoindex-io#2124. The live view now
gates its startup-scan skip on `durable_stream and await
subscriber.processing_unchanged()` — no hand-maintained version string,
no stale-state-on-forgotten-bump footgun.

- `list_objects(..., logic_version=...)` → `list_objects(..., durable_stream=...)`:
  a bool the user sets to assert the stream durably replays its backlog.
  The logic-change check is now automatic; durability stays the user's
  responsibility (a LiveStream exposes no cursor to detect it).
- Drop `_SCAN_VERSION_KEY` and the committed-version read/write dance;
  the framework persists the subtree dependency set itself.
- Tests: mock subscriber drops the committed-state version simulation for
  a controllable `processing_unchanged()`; the four version-matching
  cases collapse to three (not-durable scans, durable+changed scans,
  durable+unchanged skips).
Bring the Rust SDK to parity with the Python SDK's live logic-change
detection (cocoindex-io#2124):

- Expose processing_unchanged() on LiveComponentOperator and the
  LiveMapSubscriber delegate, calling the core controller predicate.
- Rewire the OCI live walker from the manual logic_version string to
  durable_stream: bool — skip the startup scan on reruns only when the
  user asserts a durable stream and the framework reports the processing
  logic unchanged. Removes OCI_SCAN_VERSION_KEY and the per-scan version
  write.
@badmonster0
badmonster0 requested a review from georgeh0 June 22, 2026 15:46
Comment thread rust/core/src/engine/live_component.rs Outdated
Comment on lines +1221 to +1231
let encoded = encode_logic_deps(deps)?;
component
.app_ctx()
.app_store()
.write_user_state_standalone(
component.stable_path(),
db_schema::StateKind::Live,
&logic_deps_state_key(),
&encoded,
)
.await

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think user state may not be the right place for logic deps.

What we need to persist for change detection is pretty much the same as ComponentMemoizationInfo. And the intention is also similar to its existing usage: they keep necessary information to validate if a regular component's last execution is still valid, and our purpose is actually the same.

So I think probably we can just reuse ComponentMemoizationInfo and reuse the same entry in the DB for it.

User states for live components is intended to store states related to specific live-component logic. And live-component logic will decide if persisting the ComponentMemoizationInfo and if the last persisted ComponentMemoizationInfo is currently valid. But these don't belong to user states.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this is framework change-detection state, not user state. I'll move it onto the component's memoization entry and drop the sys/live_logic_deps user-state key.

Two things I'd confirm before wiring it up:

(1) Lifecycle. The live set accumulates rather than being a per-run snapshot, update_full replaces it, incremental update unions into it (an item processed earlier still exists on restart, so its subtree fingerprints must stay tracked). Validity check is the same all_contained_with_env, and the live controller owns the read-merge-write, which matches your "live logic decides when to persist / whether the last one is valid."

(2) Shape. Live only ever needs logic_deps. ComponentMemoizationInfo's processor_fp and return_value are required (no skip/default, and MemoizedValue has no empty variant), so reusing it as-is means storing dummy F/R. Do you prefer that, or factoring logic_deps (the shared part) into a leaner shape both paths reuse? Leaning toward the latter, but your call.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @pyjuan91, thanks for the comment, what's your thought and take on this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @badmonster0, I'll propose to reuse the single ComponentMemoizationInfo entry as @georgeh0 suggested, and make F/R Option so a live root stores just {L: [...]}.

I'd floated factoring logic_deps into a shared struct earlier. But that doesn't really fit "reuse the same entry", a leaner value would need either a different type under the ComponentMemoization key or a separate key. And it wouldn't remove the actual blocker anyway: F/R are required and a live root has neither. So I'd keep the one struct and just relax those two fields.

Option over sentinel F/R: with processor_fp: None a live entry can never match a regular skip (which always passes Some), so it can't be misread as a real memo, and sentinels would need a fake R, since MemoizedValue has no empty variant.

Lifecycle as before: update_full replaces the set, incremental update unions in, controller owns read-merge-write.

Small change either way, happy to store sentinels instead if you'd rather.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify what's "F/R"? I don't understand the term. It's never defined anywhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, "F/R" was my shorthand, I meant the two serde(rename) codes on ComponentMemoizationInfo

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Poe for the follow up. we find these summary are overly verbose with ai generated summaries. could you summarize proposal in your own words for us to easily communicate and follow through the changes? Try Surface the most important design aspect that we should discuss.

This also helps us to see how much you are digesting and learning from this PR, thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! I've rewritten the PR description to make it easy to understand.

pyjuan91 added 2 commits June 28, 2026 06:58
Address review on cocoindex-io#2192: a live component's subtree logic-dependency set
`S` was persisted under a framework-reserved user-state key, but it is
framework change-detection state — the same purpose and shape as a
regular component's `ComponentMemoizationInfo`, which validates whether a
past execution is still reusable. Reuse that entry instead of user state.

- `ComponentMemoizationInfo.processor_fp` and `return_value` become
  `Option`, so a live root stores just its `logic_deps` with both unset.
  A `None` processor_fp never matches a regular memo skip (which compares
  `Some(fp)`), so a live entry can't be mistaken for a reusable memo.
  `Some(x)` serializes identically to the old bare field, so existing
  regular memo entries still decode.
- `processing_unchanged()` reads the component's memo entry; `update_full`
  replaces `S` there via `finalize_memoization`; an incremental `update`
  extends it with a single-txn read-merge-write of the same row. Drop the
  `sys/live_logic_deps` user-state key and its encode/decode helpers.
- The extend path's single txn also removes a lost-update race the prior
  read-then-write had between concurrent per-subpath drains.

The live root is not memoized, so the memo check deletes `S` at the start
of each `update_full` and it is rewritten only on success — `S` is
present iff the last full scan completed, so a failed scan re-scans.

No behavior change: the live and OCI logic-change tests are unchanged and
still pass.
@pyjuan91
pyjuan91 requested review from badmonster0 and georgeh0 July 1, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants