Skip to content

feat(nodes): add dedup node kind (exactly-once, commit-on-success filter half) - #25

Merged
graycyrus merged 2 commits into
mainfrom
feat/dedup-node
Jul 29, 2026
Merged

feat(nodes): add dedup node kind (exactly-once, commit-on-success filter half)#25
graycyrus merged 2 commits into
mainfrom
feat/dedup-node

Conversation

@graycyrus

@graycyrus graycyrus commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Adds the 14th node kind, dedup — a first-class exactly-once filter. Config { "key": "=item.id" }. Passes through items whose key hasn't been committed in a prior run and stages them tentative; the host (OpenHuman, tinyhumansai/openhuman#5263) commits tentative→committed on run success / releases on failure (commit-on-success). Motivated by a live test proving dedup-via-memory-node can't work (semantic recall ≠ exact keyed membership).

StateStore contract (host commit half depends on this)

Per dedup node id: dedup:<id>:committed (host writes on success; node reads) and dedup:<id>:tentative (node writes on pass; host reads on completion). Each is a JSON array set (exact-match). The node NEVER mutates committed.

Behavior (test-pinned)

Fail-open on null/empty key (item passes, unrecorded — never silently drop); non-string keys canonicalized; same key twice in a run passes once; two dedup nodes keep independent sets; stale tentative preserved across runs. Single main output (engine emits one port per execute). NODE_KINDS 13→14, additive — CURRENT_SCHEMA_VERSION unchanged.

Tests

Node executor (9), validate (4), catalog (2). cargo test --lib 418 pass; clippy + fmt clean.

Summary by CodeRabbit

  • New Features
    • Added a new dedup control-flow node that performs exactly-once filtering using durable persisted state.
    • Supports per-item key expressions, suppresses within-batch duplicates, and skips items when the resolved key is null, missing, or empty (passes through without recording).
  • Documentation
    • Updated node catalog and node kind documentation to include dedup and its state-commit behavior.
  • Tests
    • Expanded serialization, validation, and behavior coverage for dedup.

Adds the 14th node kind, `dedup`: a control-flow filter that drops an
item whose per-item `key` expression is already in the host-managed
`StateStore` committed set, and otherwise passes it through while
staging the key in a tentative set for the host to commit/release
based on run outcome (PR 2, in the OpenHuman host crate).

- model/node_kind.rs: NodeKind::Dedup, wire value "dedup"
- catalog.rs: NODE_KINDS (13->14) + node_contracts() entry
- validate.rs: require non-empty `key` on dedup nodes
- nodes/control_flow/dedup.rs: the executor, StateStore key layout
  (dedup:<node_id>:{committed,tentative}), and null/duplicate-key
  handling, with unit tests
- nodes/control_flow/mod.rs, nodes/mod.rs: wire the new module and
  executor_for dispatch arm
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aa1e18e3-75f3-4134-8067-116ce81f8e4a

📥 Commits

Reviewing files that changed from the base of the PR and between c81a4cd and 0ce4fbc.

📒 Files selected for processing (1)
  • src/nodes/control_flow/dedup.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/nodes/control_flow/dedup.rs

📝 Walkthrough

Walkthrough

Adds a dedup node kind with catalog metadata, configuration validation, executor dispatch, durable committed/tentative key handling, fail-open resolution, and comprehensive unit tests.

Changes

Dedup node

Layer / File(s) Summary
Dedup kind and catalog contract
src/model/node_kind.rs, src/catalog.rs
Adds the Dedup discriminator and catalog contract with a required config.key expression, linear ports, examples, and behavior notes.
Validation and executor wiring
src/validate.rs, src/nodes/mod.rs, src/nodes/control_flow/mod.rs
Validates non-empty string keys and dispatches NodeKind::Dedup to DedupNode.
Durable dedup execution and coverage
src/nodes/control_flow/dedup.rs
Filters committed and within-batch duplicate keys, stages tentative keys in StateStore, handles fail-open keys, and tests the execution semantics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • tinyhumansai/openhuman#5263 — Directly covers the NodeKind::Dedup primitive, including execution, validation, catalog metadata, and commit-on-success semantics.

Poem

I’m a rabbit guarding each key,
Letting first hops pass free.
Committed trails stay tucked away,
Tentative tracks wait for success day.
Null little keys hop safely through—
Dedup’s neat magic is ready for you!

Sequence Diagram(s)

sequenceDiagram
  participant InputBatch
  participant DedupNode
  participant StateStore
  participant NodeOutput
  InputBatch->>DedupNode: items and config.key expression
  DedupNode->>StateStore: load committed keys
  DedupNode->>DedupNode: resolve keys and filter duplicates
  DedupNode->>StateStore: persist tentative keys once per batch
  DedupNode->>NodeOutput: emit passed items and diagnostics
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding the new dedup node kind and its exactly-once, commit-on-success behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

Comment thread src/nodes/control_flow/dedup.rs
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces dedup as the 14th node kind — a commit-on-success exactly-once filter that drops items whose per-item key has already been durably committed by a prior successful run, while staging new keys as tentative for the host to promote or release. The implementation is additive (CURRENT_SCHEMA_VERSION unchanged) and cleanly wired into the existing validate/catalog/executor pipeline.

  • dedup.rs: New DedupNode executor loads committed once per batch, tracks within-run duplicates via seen_this_run, applies fail-open on null/missing/empty resolved keys, and unions new keys into tentative via a load→merge→store; the concurrency limitation (no CAS on StateStore) is thoroughly documented.
  • validate.rs / catalog.rs: Static config validation requires a non-empty string key field and clearly separates the authoring-time error (missing key) from the run-time null-resolve fail-open path; catalog contract documents all three fail-open cases and the single-pass-per-run rule.
  • Tests: 9 executor tests and 4 validate tests cover the core spec behaviors — committed-only filtering, self-commit invariant, null/empty key pass-through, independent per-node state, within-batch dedup, stale-tentative preservation, numeric-key canonicalization, and empty-input no-op.

Confidence Score: 5/5

Safe to merge — the change is purely additive and all existing node kinds are unaffected.

The dedup node is wired in at every required layer (model, validate, catalog, executor dispatch) with no changes to existing node behavior. The StateStore contract is explicitly one-directional (node writes tentative, never committed), and the concurrency limitation is acknowledged and bounded to the safe direction (under-dedup, not over-dedup). All specified behaviors are test-pinned.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/nodes/control_flow/dedup.rs New DedupNode executor: commit-on-success exactly-once filter with clear StateStore contract, fail-open null key handling, within-run duplicate detection, and 9 test cases covering all spec behaviors
src/validate.rs Adds static config validation for dedup nodes requiring a non-empty string key field; correctly distinguishes static-config missing-key (authoring error) from run-time null-resolve (fail-open)
src/catalog.rs Adds dedup contract with required key field, fail-open documentation, and linear port spec; NODE_KINDS bumped 13 to 14 with corresponding test updates
src/model/node_kind.rs Adds Dedup variant to NodeKind enum with doc comment pointing to module; serde wire name dedup tested
src/nodes/mod.rs Adds NodeKind::Dedup dispatch to executor; all_kinds() helper updated for exhaustive coverage
src/nodes/control_flow/mod.rs Exposes dedup submodule and re-exports DedupNode; module doc updated to note the StateStore exception to the pure-control-flow rule

Reviews (2): Last reviewed commit: "docs(dedup): document concurrency contra..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/nodes/control_flow/dedup.rs`:
- Around line 131-157: Prevent lost updates in the load_key_set/store_key_set
flow used for tentative_key(node_id) by serializing per-key read-modify-write
operations or introducing a StateStore compare-and-swap/atomic update primitive.
Ensure overlapping workflow runs union disjoint keys without last-writer-wins
overwrites, while preserving the existing sorted JSON-array representation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9330f813-aea3-46f7-9725-f566f10ab256

📥 Commits

Reviewing files that changed from the base of the PR and between ff99501 and c81a4cd.

📒 Files selected for processing (6)
  • src/catalog.rs
  • src/model/node_kind.rs
  • src/nodes/control_flow/dedup.rs
  • src/nodes/control_flow/mod.rs
  • src/nodes/mod.rs
  • src/validate.rs

Comment on lines +131 to +157
async fn load_key_set(ctx: &NodeContext<'_>, key: &str) -> Result<HashSet<String>> {
let stored = ctx.caps.state.load(key).await?;
let set: HashSet<String> = stored
.as_ref()
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
tracing::debug!(key, set_len = set.len(), "{LOG_PREFIX} loaded key set");
Ok(set)
}

/// Persists `set` under `key` as a JSON array of strings, sorted for a stable,
/// diffable on-disk representation (membership is exact-match either way, so
/// sort order carries no semantic meaning — it only makes manual inspection
/// and test assertions predictable).
async fn store_key_set(ctx: &NodeContext<'_>, key: &str, set: &HashSet<String>) -> Result<()> {
let mut keys: Vec<String> = set.iter().cloned().collect();
keys.sort_unstable();
let value = Value::Array(keys.into_iter().map(Value::String).collect());
tracing::debug!(key, set_len = set.len(), "{LOG_PREFIX} storing key set");
ctx.caps.state.store(key, value).await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the StateStore trait definition to check for atomic update/CAS primitives
# and any documentation about concurrent-execution guarantees for the same node/flow.
rg -n -B2 -A20 'trait StateStore' --type=rs
rg -n -B2 -A5 'fn (load|store|update|compare_and_swap|cas)\(' --type=rs -g '*caps*'
rg -n -i 'concurren|serial(ize|ized)|same (node|flow) instance' --type=rs -g '*engine*' -g '*caps*'

Repository: tinyhumansai/tinyflows

Length of output: 253


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the repository and find the relevant files.
git ls-files | rg '^(src/|.*caps.*|.*engine.*|.*state.*|.*workflow.*)' | head -n 200

printf '\n--- StateStore candidates ---\n'
rg -n 'trait StateStore|struct .*StateStore|enum .*StateStore|fn (load|store|update|compare_and_swap|cas)\(' src

printf '\n--- concurrency keywords ---\n'
rg -n -i 'concurren|serial(ize|ized)|same (node|flow) instance|overlap|race|atomic' src

Repository: tinyhumansai/tinyflows

Length of output: 16116


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the relevant source files first.
fd -a 'dedup.rs|caps|state' src

# Narrow in on the state-store trait and implementation surface.
rg -n 'trait StateStore|fn (load|store|update|compare_and_swap|cas)\(' src

# Search for any documented execution/concurrency guarantees.
rg -n -i 'concurren|serial(ize|ized)|same (node|flow) instance|overlap|race|atomic' src

Repository: tinyhumansai/tinyflows

Length of output: 14917


🏁 Script executed:

#!/bin/bash
set -euo pipefail
printf 'PWD: '; pwd
printf '\nTop-level files:\n'
git ls-files | head -n 200

Repository: tinyhumansai/tinyflows

Length of output: 3832


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Inspect the files most likely to define state storage and execution semantics.
fd -a 'dedup.rs|caps.rs|state.rs|engine.rs|mod.rs' src

printf '\n--- trait/impl surface ---\n'
rg -n 'trait StateStore|struct .*StateStore|impl .*StateStore|fn (load|store|update|compare_and_swap|cas)\(' src

printf '\n--- execution/concurrency docs ---\n'
rg -n -i 'concurren|serial(ize|ized)|same (node|flow) instance|overlap|race|atomic|lock' src

Repository: tinyhumansai/tinyflows

Length of output: 21819


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- src/nodes/control_flow/dedup.rs ---'
sed -n '1,280p' src/nodes/control_flow/dedup.rs

printf '\n%s\n' '--- src/caps/mod.rs (StateStore section) ---'
sed -n '180,240p' src/caps/mod.rs

printf '\n%s\n' '--- src/engine.rs concurrency notes ---'
sed -n '1,220p' src/engine.rs
printf '\n%s\n' '--- src/engine.rs relevant concurrency excerpts ---'
sed -n '620,760p' src/engine.rs
printf '\n%s\n' '--- src/engine.rs branch race notes ---'
sed -n '1230,1270p' src/engine.rs

Repository: tinyhumansai/tinyflows

Length of output: 35387


Serialize the tentative merge or add CAS support The load_key_set → union → store_key_set path on tentative_key(node_id) can lose disjoint keys when two runs of the same workflow overlap; StateStore only exposes load/store, so this is a last-writer-wins race. Consider a host-side per-key serialization guarantee or an atomic update primitive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/nodes/control_flow/dedup.rs` around lines 131 - 157, Prevent lost updates
in the load_key_set/store_key_set flow used for tentative_key(node_id) by
serializing per-key read-modify-write operations or introducing a StateStore
compare-and-swap/atomic update primitive. Ensure overlapping workflow runs union
disjoint keys without last-writer-wins overwrites, while preserving the existing
sorted JSON-array representation.

…key canonicalization

Review fixes (tinyflows#25):
- CodeRabbit (Major): document that the load→merge→store on tentative/committed
  is not atomic, so overlapping runs of the SAME flow can lose disjoint keys —
  bounded + safe-direction (under-dedup / occasional duplicate, never a wrong
  drop). Exactly-once holds under one-active-run-per-flow; full CAS deferred.
- Greptile (P2): note non-scalar keys canonicalize via JSON text deterministically
  but a scalar id is recommended.
@graycyrus

Copy link
Copy Markdown
Collaborator Author

Review comments addressed — pushed in 0ce4fbc (+ host-side mitigation in openhuman#5265)

@coderabbitai (Major) — non-atomic load→union→store can lose keys when runs of the same flow overlap. Correct. The full fix is a StateStore CAS/atomic-update primitive, which is a deliberate deferred engine change (bigger than this node). What I did instead:

  • Documented the exact contract in the module doc: exactly-once holds under the normal one-active-run-per-flow assumption; when two runs of the same flow truly overlap, the failure is bounded and in the safe direction — a lost tentative/committed key can only cause an item to be processed again on a later run (occasional duplicate / under-dedup), never a wrong drop (over-dedup). No item is ever silently lost.
  • Closed the durable half of the race host-side: the OpenHuman DedupCommitSubscriber (openhuman#5265) now serializes its post-run committed update per flow id, so overlapping FlowRunFinished events for the same flow can't clobber each other's committed set — directly your "host-side per-key serialization" suggestion. The residual node-side tentative race stays documented + deferred to a CAS primitive.

@greptileai (P2) — the fallback Some(other) => other.to_string() accepts arrays/objects as keys. Deterministic for all types, so not a correctness bug, but a structured key is almost always an authoring mistake. Added a Key canonicalization doc section: scalars canonicalize via JSON text and dedupe deterministically; arrays/objects also resolve deterministically but authors should treat a structured key as a smell and use a scalar id. Left it non-rejecting (a hard error would be more surprising than the deterministic behavior).

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- commit summary ---'
git show --stat --oneline --decorate 0ce4fbc

echo
echo '--- changed dedup module documentation/context ---'
git show --format=fuller --find-renames 0ce4fbc -- src/nodes/control_flow/dedup.rs

echo
echo '--- current dedup module outline ---'
ast-grep outline src/nodes/control_flow/dedup.rs --items all

echo
echo '--- linked host PR metadata and changed files (if public/available) ---'
gh pr view 5265 --repo tinyhumansai/openhuman --json number,title,state,url,body,files 2>&1 || true

Length of output: 8765


@graycyrus Thanks—0ce4fbc does clearly document both the non-atomic StateStore limitation and deterministic structured-key canonicalization. The host-side serialization described in openhuman#5265 is also consistent with the proposed durable-state mitigation, though that PR remains open.

One accuracy issue remains in the concurrency documentation: the residual tentative race can cause the same key to pass and be processed by two overlapping runs themselves, not solely “again on a later run.” The outcome is still safe-direction under-dedup (duplicate processing, never an incorrect drop), but the wording should cover both concurrent and subsequent duplicate processing.

The original finding therefore remains open pending that small clarification. 🐇🔍


✏️ Learnings added
Learnt from: graycyrus
URL: https://github.com/tinyhumansai/tinyflows/pull/25

Timestamp: 2026-07-29T10:37:19.141Z
Learning: For `src/nodes/control_flow/dedup.rs`, exactly-once semantics require at most one active run per saved flow because `StateStore` currently offers non-atomic load/store operations. With overlapping runs, non-atomic tentative or committed set updates can cause under-dedup: the same item may be processed by a concurrent overlapping run or a later run, but the race must not cause over-dedup or silently drop an item. The OpenHuman host-side `DedupCommitSubscriber` proposed in openhuman#5265 serializes post-run committed-set updates per flow but does not eliminate the node-side tentative-set race.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Both addressed cleanly.

Concurrency (major): The module doc is now the right place for this — the failure-mode analysis (lost tentative → re-process, never wrong-drop) is precise, and documenting the one-active-run-per-flow assumption makes the guarantee explicit rather than implicit. Serializing the host-side committed union per flow id in openhuman#5265 is the correct partial mitigation given the current StateStore surface. Deferring to a CAS primitive is fine; the documentation makes the residual risk visible to whoever implements it.

Structured keys (P2): Agreed that deterministic-but-smelly is better than a surprising hard error here. The "Key canonicalization" doc section is sufficient — authors who end up here while debugging will find the explanation.

Looks good to me.

@graycyrus
graycyrus merged commit ead2615 into main Jul 29, 2026
6 checks passed
@graycyrus
graycyrus deleted the feat/dedup-node branch July 29, 2026 15:25
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.

1 participant