Skip to content

perf(search): make overlap dedupe linear in match count - #6640

Open
mzxchandra wants to merge 3 commits into
stagingfrom
perf/workflow-search-freeze
Open

perf(search): make overlap dedupe linear in match count#6640
mzxchandra wants to merge 3 commits into
stagingfrom
perf/workflow-search-freeze

Conversation

@mzxchandra

Copy link
Copy Markdown
Contributor

Summary

Typing in the workflow editor's Cmd+F froze the editor for over a second on a large workflow, with the typed characters landing late in one burst.

dedupeOverlappingWorkflowSearchMatches ran deduped.findIndex(...) over the whole accumulated list for every match, recomputing each candidate's scope key inside the predicate - O(n^2) string builds. The memo re-runs on every keystroke (the query is not debounced), and a single character is the worst case because it matches the most.

Reproduced on an 81-block workflow (4530 subblocks, real knowledge-base and OAuth references). Stage timing across one typing burst:

stage ms
searchBlocks merge 13
index 35
hydration 10
filter + dedupe 1458
resource options 6

Overlap is only ever resolved within one value of one subblock, so candidate indices are bucketed by that scope and only the bucket is scanned. A per-bucket maxEnd skips the scan entirely when a match starts at or after every kept range's end, which keeps a single long field full of disjoint hits linear too.

Dedupe alone, on that workflow:

query matches before after
email 558 6.4ms 0.56ms
r 1698 53.3ms 0.71ms
e 3373 232.9ms 1.10ms

End to end the longest main-thread task while typing went from 1534ms to 247ms, with the same 521 matches either way.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Other: ___________

Testing

Behaviour is unchanged - this is a pure restructuring of one function, and the tests are built to prove that rather than assert it.

resolvers.test.ts gains a reference implementation: a transcription of the original linear scan, checked against the bucketed one over 400 sequential seeds. The generator emits inverted, empty and non-finite ranges as well as normal ones, plus non-subblock targets and rangeless matches.

Each test was verified to fail against the corresponding defect, not merely to pass against the current code:

break tests that fail
revert to the findIndex rescan complexity pin (20k single-scope elements)
take the last overlap instead of the first 3 seeded inputs
skip the maxEnd refresh on replacement 400-seed sweep + both replacement shapes
let a non-finite end widen maxEnd 400-seed sweep
invert the kind-priority tiebreak 10 tests

Two traps the maxEnd short-circuit sets, both surfaced by adversarial review and both now pinned:

  • shouldPreferOverlappingMatch prefers the shorter range, and a shorter range can end further right than the one it evicts. maxEnd has to be refreshed on the replacement path, not only on append, or the short-circuit skips real overlaps and leaks duplicates into replace-all.
  • Widening with a non-finite end would pin maxEnd at NaN, and since every comparison against NaN is false that silently switches dedupe off for the rest of the scope. Only finite ends widen it, matching how the unbucketed scan treated such a range.

An earlier revision of this test pinned 8 hand-picked seeds and passed while 7.7% of the seed space diverged. The sweep width is the point.

Suites: full apps/sim suite green (23,611 passed, 25 skipped, 0 failed). resolvers.test.ts 13 tests; 682 across every suite that touches this module.

Reviewers should focus on: the equivalence argument in the TSDoc - specifically that a bucket holds exactly the entries the old predicate could match (scope key and range present), and that buckets keep insertion order so the first-overlap break reproduces findIndex's lowest-index semantics.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Screenshots/Videos

No visual change. The observable difference is responsiveness: on an 81-block workflow, Cmd+F then typing e (3373 matches) hangs the field for ~1.5s before this change and types through cleanly after.

Typing in workflow Cmd+F froze the editor for over a second on a large
workflow, with the typed characters landing late in one burst.

`dedupeOverlappingWorkflowSearchMatches` ran `deduped.findIndex(...)` over
the whole accumulated list for every match, recomputing each candidate's
scope key inside the predicate - O(n^2) string builds. The memo re-runs on
every keystroke (the query is not debounced), and a single character is the
worst case because it matches the most.

Reproduced on an 81-block workflow (4530 subblocks, real knowledge-base and
OAuth references). Stage timing during one typing burst:

  searchBlocks merge      13ms
  index                   35ms
  hydration               10ms
  filter + dedupe       1458ms   <-
  resource options         6ms

Overlap is only ever resolved within one value of one subblock, so bucket
candidate indices by scope key and scan the bucket. A bucket holds exactly
the entries the old predicate could match (scope key and range both
present), buckets keep insertion order, and the scan stops at the first
overlap, so the same candidate wins. A per-bucket `maxEnd` skips the scan
entirely when a match starts at or after every kept range's end, which
keeps a single long field full of disjoint hits linear too.

Measured on that workflow, dedupe alone, by query:

  query    matches    before     after
  email        558     6.4ms    0.56ms
  r           1698    53.3ms    0.71ms
  e           3373   232.9ms    1.10ms

End to end in the browser the longest task while typing went from 1534ms
to 247ms, with the same 521 matches either way.

Two traps `maxEnd` sets, both found by adversarial review and both now
pinned by tests:

- `shouldPreferOverlappingMatch` prefers the SHORTER range, and a shorter
  range can end further right than the one it evicts. `maxEnd` has to be
  refreshed on the replacement path, not only on append, or the
  short-circuit skips real overlaps and leaks duplicates into replace-all.
- Widening with a non-finite end would pin `maxEnd` at NaN, and since every
  comparison against NaN is false that silently switches dedupe off for the
  rest of the scope. Only finite ends widen it, which matches how the
  unbucketed scan treated such a range.

`resolvers.test.ts` gains a reference implementation - a transcription of
the original linear scan - checked against the bucketed one over 400
sequential seeds whose generator also emits inverted, empty and non-finite
ranges, plus the two concrete replacement shapes above and a 20k-element
single-scope case that pins the asymptotics. An earlier revision of this
test pinned 8 hand-picked seeds and passed while 7.7% of the seed space
diverged, so the sweep width is the point.
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 13, 2026 4:37am

Request Review

@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches replace/dedupe semantics on every keystroke in workflow search; extensive equivalence tests reduce regression risk, but a logic bug could still leak duplicate matches into replace-all.

Overview
Replaces the O(n²) overlap dedupe in dedupeOverlappingWorkflowSearchMatches with scope-bucketed scanning so Cmd+F on large workflows no longer freezes the search field (~1.4s filter/dedupe → sub-ms in cited benchmarks).

Instead of findIndex over the full list (rebuilding scope keys on every comparison), matches are grouped by subblock value scope; overlap checks scan only that bucket, with a maxEnd short-circuit when a new match starts past the bucket’s end bound. maxEnd is widened on append and when a shorter overlapping match replaces a longer one (stale bounds could skip real overlaps), and only finite ends widen it (non-finite ends would poison comparisons with NaN).

Exports OVERLAPPING_MATCH_KIND_PRIORITY for tests. Adds a reference transcription of the old algorithm plus 400-seed equivalence sweeps, adversarial maxEnd cases, disjoint-range correctness, and a 20k-element timing guard against quadratic regression.

Reviewed by Cursor Bugbot for commit cdeeb43. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR replaces repeated global overlap scans with scope-local buckets while preserving first-overlap and match-priority behavior.

  • Tracks candidate indices and a finite high-water mark per searchable value.
  • Adds randomized equivalence, replacement-edge-case, degenerate-range, and complexity coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/workflows/search-replace/resources/resolvers.ts Replaces the global accumulated-list scan with insertion-ordered per-scope buckets and a high-water-mark shortcut.
apps/sim/lib/workflows/search-replace/resources/resolvers.test.ts Adds a reference implementation, broad deterministic equivalence coverage, targeted replacement cases, and a complexity regression check.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Next search match] --> B{Has subblock scope and range?}
  B -- No --> G[Append match]
  B -- Yes --> C[Look up scope bucket]
  C --> D{Start below bucket maxEnd?}
  D -- No --> G
  D -- Yes --> E[Scan bucket indices for first overlap]
  E --> F{Overlap found?}
  F -- No --> G
  F -- Yes --> H{New match preferred?}
  H -- Yes --> I[Replace existing match and widen maxEnd]
  H -- No --> J[Keep existing match]
  G --> K[Add index to bucket and widen maxEnd]
Loading

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/sta..." | Re-trigger Greptile

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@waleedlatif1

Copilot AI 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.

Pull request overview

Improves workflow editor Cmd+F responsiveness by optimizing overlap deduplication for workflow search matches, reducing per-keystroke main-thread work on large workflows.

Changes:

  • Exported OVERLAPPING_MATCH_KIND_PRIORITY so tests can share the canonical tie-break priority ordering.
  • Reworked dedupeOverlappingWorkflowSearchMatches to bucket candidates by overlap scope and avoid global findIndex rescans, using a per-bucket maxEnd short-circuit.
  • Added equivalence + regression tests, including a seeded sweep against a reference implementation and an asymptotic guard.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
apps/sim/lib/workflows/search-replace/resources/resolvers.ts Buckets overlap dedupe by scope and adds maxEnd to reduce scanning; exports kind-priority map for shared test usage.
apps/sim/lib/workflows/search-replace/resources/resolvers.test.ts Adds a reference dedupe implementation and seeded/property-style tests to assert equivalence and guard performance regressions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/sim/lib/workflows/search-replace/resources/resolvers.ts
Review pointed out the comment claimed `maxEnd` is "the largest range.end
currently kept in the bucket", which stops being true the moment a
replacement swaps in a range that ends earlier - it is only ever widened.

Only the upper bound is load-bearing, so say that. A bound left too high
costs a scan that would have been skipped, never a wrong answer, and the
staleness is capped at one token length because every range spans a matched
token rather than the field.

Also records why the exact maximum is deliberately not recomputed: on the
realistic overlap shape at 10k matches, recomputing measures 45ms against
23ms as written, and 1010ms for the scan this replaced.
@mzxchandra

Copy link
Copy Markdown
Contributor Author

@greptile

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit cdeeb43. Configure here.

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.

2 participants