Skip to content

Commit 12c677e

Browse files
committed
perf(search): make overlap dedupe linear in match count
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.
1 parent 9aa16e3 commit 12c677e

2 files changed

Lines changed: 302 additions & 11 deletions

File tree

apps/sim/lib/workflows/search-replace/resources/resolvers.test.ts

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
import { describe, expect, it } from 'vitest'
55
import {
66
dedupeOverlappingWorkflowSearchMatches,
7+
OVERLAPPING_MATCH_KIND_PRIORITY,
78
workflowSearchMatchMatchesQuery,
89
} from '@/lib/workflows/search-replace/resources/resolvers'
9-
import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types'
10+
import type {
11+
WorkflowSearchMatch,
12+
WorkflowSearchMatchKind,
13+
} from '@/lib/workflows/search-replace/types'
1014

1115
function createMatch(overrides: Partial<WorkflowSearchMatch>): WorkflowSearchMatch {
1216
return {
@@ -139,4 +143,231 @@ describe('workflowSearchMatchMatchesQuery', () => {
139143
workflowSearchMatchMatchesQuery({ ...selectorMatch, displayLabel: 'Gucci Case' }, 'Gucci')
140144
).toBe(true)
141145
})
146+
147+
/**
148+
* The bucketed dedupe replaced an O(n^2) linear rescan. This pins it to a
149+
* transcription of the original algorithm over randomized inputs, so any
150+
* divergence in which overlapping match wins shows up as a diff rather than
151+
* as a subtly wrong result the fixed examples above would miss.
152+
*/
153+
describe('bucketed dedupe matches the original linear scan', () => {
154+
function scopeKey(match: WorkflowSearchMatch): string | null {
155+
if (!match.range) return null
156+
if (match.target.kind !== 'subblock') return null
157+
const path = match.valuePath.map((s) => `${typeof s}:${String(s)}`).join('/')
158+
return [match.blockId, match.subBlockId, path].join(':')
159+
}
160+
161+
function rangeLength(match: WorkflowSearchMatch): number {
162+
return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY
163+
}
164+
165+
function prefers(candidate: WorkflowSearchMatch, current: WorkflowSearchMatch): boolean {
166+
const a = rangeLength(candidate)
167+
const b = rangeLength(current)
168+
if (a !== b) return a < b
169+
const pa = OVERLAPPING_MATCH_KIND_PRIORITY[candidate.kind]
170+
const pb = OVERLAPPING_MATCH_KIND_PRIORITY[current.kind]
171+
if (pa !== pb) return pa > pb
172+
return false
173+
}
174+
175+
/** Straight transcription of the pre-optimization implementation. */
176+
function referenceDedupe(matches: WorkflowSearchMatch[]): WorkflowSearchMatch[] {
177+
const deduped: WorkflowSearchMatch[] = []
178+
for (const match of matches) {
179+
const key = scopeKey(match)
180+
const range = match.range
181+
const existingIndex =
182+
key && range
183+
? deduped.findIndex(
184+
(candidate) =>
185+
scopeKey(candidate) === key &&
186+
candidate.range &&
187+
candidate.range.start < range.end &&
188+
range.start < candidate.range.end
189+
)
190+
: -1
191+
if (existingIndex === -1) {
192+
deduped.push(match)
193+
continue
194+
}
195+
if (prefers(match, deduped[existingIndex])) deduped[existingIndex] = match
196+
}
197+
return deduped
198+
}
199+
200+
/** Deterministic PRNG so a failure is reproducible from the seed alone. */
201+
function makeRandom(seed: number) {
202+
let state = seed
203+
return () => {
204+
state = (state * 1103515245 + 12345) & 0x7fffffff
205+
return state / 0x7fffffff
206+
}
207+
}
208+
209+
const KINDS = Object.keys(OVERLAPPING_MATCH_KIND_PRIORITY) as WorkflowSearchMatchKind[]
210+
211+
function randomMatches(seed: number, count: number): WorkflowSearchMatch[] {
212+
const random = makeRandom(seed)
213+
const pick = <T>(xs: T[]) => xs[Math.floor(random() * xs.length)]
214+
return Array.from({ length: count }, (_, index) => {
215+
const start = Math.floor(random() * 30)
216+
const hasRange = random() > 0.15
217+
// Degenerate spans too: the bucketed scan keys off range arithmetic, so
218+
// the oracle has to defend inverted, empty and non-finite ends as well.
219+
const degenerate = random()
220+
const end =
221+
degenerate > 0.97
222+
? Number.NaN
223+
: degenerate > 0.94
224+
? start - 1 - Math.floor(random() * 3)
225+
: degenerate > 0.91
226+
? start
227+
: start + 1 + Math.floor(random() * 8)
228+
const isSubBlockTarget = random() > 0.15
229+
return createMatch({
230+
id: `m-${index}`,
231+
blockId: pick(['b1', 'b2', 'b3']),
232+
subBlockId: pick(['s1', 's2']),
233+
valuePath: pick([[], ['content'], [0], ['rows', 1]]),
234+
kind: pick(KINDS),
235+
target: isSubBlockTarget ? { kind: 'subblock' } : { kind: 'block-name' },
236+
range: hasRange ? { start, end } : undefined,
237+
})
238+
})
239+
}
240+
241+
/**
242+
* A sequential sweep, not a handful of hand-picked seeds. An earlier version
243+
* pinned 8 seeds that happened to be clean while 7.7% of the space diverged,
244+
* so the count is what gives this test its power - keep it wide.
245+
*/
246+
it('agrees with the original scan across 400 seeded inputs', () => {
247+
const diverged: number[] = []
248+
249+
for (let seed = 1; seed <= 400; seed++) {
250+
const matches = randomMatches(seed, 120)
251+
const actual = dedupeOverlappingWorkflowSearchMatches(matches).map((m) => m.id)
252+
const expected = referenceDedupe(matches).map((m) => m.id)
253+
if (actual.join('|') !== expected.join('|')) diverged.push(seed)
254+
}
255+
256+
expect(diverged).toEqual([])
257+
})
258+
259+
/**
260+
* The exact shape that broke the `maxEnd` short-circuit: a shorter range
261+
* evicts a longer one but ends further right, so a stale `maxEnd` let the
262+
* next match skip the overlap scan and leak through as a duplicate.
263+
*/
264+
it.each([
265+
{
266+
name: 'shorter replacement ends further right',
267+
spans: [
268+
{ kind: 'workflow-reference' as const, start: 0, end: 13 },
269+
{ kind: 'environment' as const, start: 10, end: 17 },
270+
{ kind: 'text' as const, start: 13, end: 16 },
271+
],
272+
},
273+
{
274+
name: 'replacement extends past the evicted range',
275+
spans: [
276+
{ kind: 'text' as const, start: 0, end: 10 },
277+
{ kind: 'environment' as const, start: 5, end: 15 },
278+
{ kind: 'text' as const, start: 10, end: 14 },
279+
],
280+
},
281+
])('collapses overlaps when a $name', ({ spans }) => {
282+
const matches = spans.map((span, index) =>
283+
createMatch({
284+
id: `span-${index}`,
285+
blockId: 'b1',
286+
subBlockId: 's1',
287+
valuePath: [],
288+
kind: span.kind,
289+
range: { start: span.start, end: span.end },
290+
})
291+
)
292+
293+
expect(dedupeOverlappingWorkflowSearchMatches(matches).map((m) => m.id)).toEqual(
294+
referenceDedupe(matches).map((m) => m.id)
295+
)
296+
expect(dedupeOverlappingWorkflowSearchMatches(matches)).toHaveLength(1)
297+
})
298+
299+
it.each([0, 1])('agrees on a %i-element input', (count) => {
300+
const matches = randomMatches(5, count)
301+
302+
expect(dedupeOverlappingWorkflowSearchMatches(matches).map((m) => m.id)).toEqual(
303+
referenceDedupe(matches).map((m) => m.id)
304+
)
305+
})
306+
307+
/**
308+
* The bucketed scan is still linear *within* one scope, so a single field
309+
* holding many non-overlapping hits is the residual worst case. It stays
310+
* cheap because the inner loop is two integer comparisons - the old code
311+
* rebuilt a scope-key string per candidate, which is where the 100x went.
312+
*/
313+
it('agrees when one scope holds many non-overlapping ranges', () => {
314+
const matches = Array.from({ length: 300 }, (_, index) =>
315+
createMatch({
316+
id: `disjoint-${index}`,
317+
blockId: 'b1',
318+
subBlockId: 'code',
319+
valuePath: [],
320+
kind: 'text',
321+
range: { start: index * 4, end: index * 4 + 1 },
322+
})
323+
)
324+
325+
expect(dedupeOverlappingWorkflowSearchMatches(matches)).toHaveLength(300)
326+
expect(dedupeOverlappingWorkflowSearchMatches(matches).map((m) => m.id)).toEqual(
327+
referenceDedupe(matches).map((m) => m.id)
328+
)
329+
})
330+
331+
/**
332+
* Pins the asymptotics, not a stopwatch. 20k disjoint hits in one scope run
333+
* in single-digit ms bucketed; the O(n^2) rescan this replaced took ~30s on
334+
* the same input, so the bound has roughly three orders of magnitude of
335+
* headroom and only trips on a genuine complexity regression.
336+
*/
337+
it('stays sub-quadratic on a single scope full of disjoint ranges', () => {
338+
const matches = Array.from({ length: 20_000 }, (_, index) =>
339+
createMatch({
340+
id: `wide-${index}`,
341+
blockId: 'b1',
342+
subBlockId: 'code',
343+
valuePath: [],
344+
kind: 'text',
345+
range: { start: index * 4, end: index * 4 + 1 },
346+
})
347+
)
348+
349+
const startedAt = performance.now()
350+
const deduped = dedupeOverlappingWorkflowSearchMatches(matches)
351+
352+
expect(deduped).toHaveLength(20_000)
353+
expect(performance.now() - startedAt).toBeLessThan(2_000)
354+
})
355+
356+
it('agrees when every match shares one scope and range', () => {
357+
const matches = Array.from({ length: 40 }, (_, index) =>
358+
createMatch({
359+
id: `same-${index}`,
360+
blockId: 'b1',
361+
subBlockId: 's1',
362+
valuePath: [],
363+
kind: index % 2 === 0 ? 'text' : 'table',
364+
range: { start: 0, end: 5 },
365+
})
366+
)
367+
368+
expect(dedupeOverlappingWorkflowSearchMatches(matches).map((m) => m.id)).toEqual(
369+
referenceDedupe(matches).map((m) => m.id)
370+
)
371+
})
372+
})
142373
})

apps/sim/lib/workflows/search-replace/resources/resolvers.ts

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import type {
77
} from '@/lib/workflows/search-replace/types'
88
import type { SelectorContext } from '@/hooks/selectors/types'
99

10-
const OVERLAPPING_MATCH_KIND_PRIORITY: Record<WorkflowSearchMatchKind, number> = {
10+
/**
11+
* Which kind wins when two matches cover the same span. Exported so the
12+
* equivalence tests can share it instead of hand-copying the values, which
13+
* silently drifted once already.
14+
*/
15+
export const OVERLAPPING_MATCH_KIND_PRIORITY: Record<WorkflowSearchMatchKind, number> = {
1116
text: 0,
1217
environment: 1,
1318
'workflow-reference': 2,
@@ -141,31 +146,86 @@ function shouldPreferOverlappingMatch(
141146
return false
142147
}
143148

149+
/** Kept indices for one overlap scope, plus the highest `range.end` among them. */
150+
interface RangeMatchScopeBucket {
151+
indices: number[]
152+
maxEnd: number
153+
}
154+
155+
function widenScopeBucket(bucket: RangeMatchScopeBucket, end: number): void {
156+
if (Number.isFinite(end)) bucket.maxEnd = Math.max(bucket.maxEnd, end)
157+
}
158+
159+
/**
160+
* Overlap resolution is scoped to one value inside one subblock, so candidates
161+
* are bucketed by that scope rather than rescanned. The previous `findIndex`
162+
* over the whole accumulated list recomputed every candidate's scope key on
163+
* every iteration - O(n^2) string builds, which cost ~1.4s on a workflow
164+
* producing ~500 matches and froze the search field while typing.
165+
*
166+
* A bucket only ever holds entries that have both a scope key and a range, and
167+
* those are exactly the entries the old predicate could match. Buckets keep
168+
* insertion order and the scan stops at the first overlap, so this picks the
169+
* same candidate the linear scan did.
170+
*
171+
* `maxEnd` is the largest `range.end` currently kept in the bucket. A match
172+
* starting at or after it cannot overlap anything in that bucket, so the scan
173+
* is skipped. That keeps a single long field full of disjoint hits linear
174+
* instead of quadratic within its own bucket, to the extent its matches arrive
175+
* in ascending offset order; out-of-order producers just fall back to scanning.
176+
*
177+
* It must be refreshed on the replacement path too, not only on append:
178+
* `shouldPreferOverlappingMatch` prefers the SHORTER range, and a shorter range
179+
* can still end further right than the one it evicts. Leaving `maxEnd` stale
180+
* there let the short-circuit skip genuine overlaps and leak duplicates.
181+
*
182+
* Only finite ends widen it. `Math.max` with a non-finite end would pin
183+
* `maxEnd` at `NaN`, and since every comparison against `NaN` is false that
184+
* would silently switch dedupe off for the rest of the scope. A non-finite
185+
* range cannot overlap anything anyway - `rangesOverlap` is false for it - so
186+
* skipping the widening matches what the unbucketed scan did.
187+
*/
144188
export function dedupeOverlappingWorkflowSearchMatches<T extends WorkflowSearchMatch>(
145189
matches: T[]
146190
): T[] {
147191
const deduped: T[] = []
192+
const bucketsByScopeKey = new Map<string, RangeMatchScopeBucket>()
148193

149194
for (const match of matches) {
150195
const scopeKey = getRangeMatchScopeKey(match)
151196
const matchRange = match.range
152-
const existingIndex =
153-
scopeKey && matchRange
154-
? deduped.findIndex(
155-
(candidate) =>
156-
getRangeMatchScopeKey(candidate) === scopeKey &&
157-
candidate.range &&
158-
rangesOverlap(candidate.range, matchRange)
159-
)
160-
: -1
197+
const bucket = scopeKey && matchRange ? bucketsByScopeKey.get(scopeKey) : undefined
198+
199+
let existingIndex = -1
200+
if (bucket && matchRange && matchRange.start < bucket.maxEnd) {
201+
for (const index of bucket.indices) {
202+
const candidate = deduped[index]
203+
if (candidate.range && rangesOverlap(candidate.range, matchRange)) {
204+
existingIndex = index
205+
break
206+
}
207+
}
208+
}
161209

162210
if (existingIndex === -1) {
211+
if (scopeKey && matchRange) {
212+
if (bucket) {
213+
bucket.indices.push(deduped.length)
214+
widenScopeBucket(bucket, matchRange.end)
215+
} else {
216+
bucketsByScopeKey.set(scopeKey, {
217+
indices: [deduped.length],
218+
maxEnd: Number.isFinite(matchRange.end) ? matchRange.end : Number.NEGATIVE_INFINITY,
219+
})
220+
}
221+
}
163222
deduped.push(match)
164223
continue
165224
}
166225

167226
if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) {
168227
deduped[existingIndex] = match
228+
if (bucket && matchRange) widenScopeBucket(bucket, matchRange.end)
169229
}
170230
}
171231

0 commit comments

Comments
 (0)