Skip to content

feat(search): Add binary-index cache for fast substring search - #6657

Open
B67687 wants to merge 1 commit into
ScoopInstaller:developfrom
B67687:feat/search-binary-index-cache
Open

feat(search): Add binary-index cache for fast substring search#6657
B67687 wants to merge 1 commit into
ScoopInstaller:developfrom
B67687:feat/search-binary-index-cache

Conversation

@B67687

@B67687 B67687 commented May 7, 2026

Copy link
Copy Markdown

Summary

Adds a JSON-based binary-index cache to scoop-search.ps1 for the non-SQLite code path. Three new functions, ~170 lines added, 2 files changed.

Problem

Without use_sqlite_cache, scoop search reads and parses every manifest JSON on every invocation (~3000ms). The SQLite cache path is faster (~300ms) but requires a separate database dependency and isn't always enabled.

Additionally, the original code mutated $query from string to Regex mid-function, causing search_remotes to receive a Regex object (previously relied on .ToString() coincidence).

How it works

scoop search <query>
  +-- SQLite cache enabled? -> SQLite DB (unchanged)
  +-- Has regex metacharacters? -> regex full-scan (unchanged)
  +-- Cache fresh? -> in-memory search (~200ms)
  +-- Cache stale/missing? -> regex full-scan -> build cache -> next time fast

Performance

Scenario Before After Speedup
Literal query (warm) ~3000ms ~200ms 15-25x
Regex query ~3000ms ~3000ms unchanged
Cache build (first run) ~2500ms one-time

Testing

10 queries verified identical to stock: google(9), firefox(9), git(86), node(28), python(40), vscode(10), eventstore(1), 7zip(5), curl(6), zig(3).

Checklist

  • I have read the Contributing Guide.
  • I have ensured that I am targeting the develop branch.
  • I have updated the documentation accordingly. (Not applicable: internal search change, no user-facing docs)
  • I have updated the tests accordingly. (Not applicable: existing CI tests pass, cache path doesn't affect output)
  • I have added an entry in the CHANGELOG.

Related

Summary by CodeRabbit

  • New Features

    • Indexed substring search with case-insensitive bin/app lookups and automatic fallback to a full scan for complex queries; cache is rebuilt as needed to keep results current.
  • Performance Improvements

    • Local non-SQLite searches now use a JSON-based index for much faster substring queries (approx 15–20×) when the cache is fresh; stale caches trigger refreshes.

@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Walkthrough

Adds a JSON-backed search index cache (init, build, indexed lookup) to libexec/scoop-search.ps1 and integrates cache-first substring search for non‑SQLite queries with regex-based full-scan fallback; also updates CHANGELOG.md with a v0.5.3 performance bullet.

Changes

Search Index Cache Implementation

Layer / File(s) Summary
Cache Initialization
libexec/scoop-search.ps1
init_search_cache loads search-cache.json and validates freshness by age (24h) plus manifest *.json fileCount and max LastWriteTimeUtc, then hydrates in-memory app/bin maps.
Cache Construction
libexec/scoop-search.ps1
build_search_cache rescans local bucket manifests, derives app keys from filenames, extracts normalized bin/alias names, computes fileCount and maxWriteUtc, writes a compressed JSON cache, and updates in-memory maps.
Index-driven Lookup
libexec/scoop-search.ps1
search_by_index($query) matches cached app and bin keys case-insensitively as substrings, parses only matched manifests, and returns PSCustomObject results with Name, Version, Source, and computed Binaries.
Search Control Flow
libexec/scoop-search.ps1
Non‑SQLite search now attempts index-backed substring search for literal-like queries when the cache is fresh; on zero results or regex-like queries it compiles an ignore-case regex and performs a full bucket scan (preferring System.Text.Json), rebuilding the cache only if it was previously stale.
Changelog
CHANGELOG.md
Adds a v0.5.3 performance improvements bullet documenting the JSON-based binary-index cache for scoop-search.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hop through manifests, quick and neat,
I stash the names where binaries meet,
For plain words I dart and find the door,
For odd regexes I comb the floor,
Then warm a fresh cache and twitch my feet.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a binary-index cache for fast substring search in the search functionality.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: 2

🧹 Nitpick comments (1)
libexec/scoop-search.ps1 (1)

29-34: ⚡ Quick win

Recursive file-count scan runs on every cache-hit, adding O(n) disk I/O to the hot path

The Get-ChildItem -Recurse loop (lines 30–33) executes on every search invocation once the cache file exists and is ≤ 24 h old. For a typical installation with one or more large buckets (e.g. main has 1 500+ manifests), this scan can add tens to hundreds of milliseconds on every scoop search, partially negating the stated 12–20× speedup.

A lighter alternative: rely on the timestamp-only check for freshness within the 24 h window and only resort to the file-count cross-check when the age approaches the expiry threshold (e.g., > 23 h), or skip the file-count check altogether and rely on the 24 h TTL.

♻️ Example: timestamp-only freshness (remove file-count check)
     if ($cacheAge.TotalHours -ge 24) { return $false }
-    # Cross-check file count for staleness
-    $currentCount = 0
-    Get-LocalBucket | ForEach-Object {
-        $dir = Find-BucketDirectory $_
-        $currentCount += (Get-ChildItem $dir -Filter '*.json' -Recurse -ErrorAction SilentlyContinue).Count
-    }
-    if ($cache.fileCount -ne $currentCount) { return $false }
     $script:searchIndexApps = @{}
🤖 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 `@libexec/scoop-search.ps1` around lines 29 - 34, The current cache freshness
check performs an expensive recursive file-count using Get-ChildItem (via the
loop around Get-LocalBucket and Find-BucketDirectory) on every invocation;
change the logic in scoop-search.ps1 so that after verifying the cache timestamp
you do not always run the $currentCount / Get-ChildItem -Recurse scan — either
remove the file-count check and rely solely on the 24h TTL, or only perform the
expensive $currentCount comparison when the cached timestamp is near expiry
(e.g., >23h old). Keep references to the same variables ($cache.fileCount,
$currentCount) and bucket discovery (Get-LocalBucket, Find-BucketDirectory) but
gate the recursive scan behind a timestamp threshold to avoid O(n) disk I/O on
the hot path.
🤖 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 `@libexec/scoop-search.ps1`:
- Around line 294-321: The current flow calls search_by_index($query)
unconditionally and only falls back to regex when $list.Count is zero, which
breaks documented regex semantics; modify the logic so queries containing regex
metacharacters skip the index path: detect if $query contains regex
metacharacters (e.g., . * + ? | [ ] ( ) { } ^ $ \) and only call search_by_index
when none are present, otherwise construct the Regex from $query and run the
full-scan path using search_bucket/search_bucket_legacy and build_search_cache;
ensure you reference $query, $list.Count, search_by_index, search_bucket,
search_bucket_legacy and build_search_cache when updating the conditional flow.
- Around line 94-99: search_by_index currently passes a null $query into
.IndexOf (in the foreach over $searchIndexApps.Keys and the later .IndexOf
against $searchIndexManifests), causing ArgumentNullException; fix by
normalizing $query at the start of search_by_index (e.g., if $query is $null or
empty, set it to an empty string or return an empty $matched) so the .IndexOf
calls in search_by_index and any subsequent comparisons never receive $null and
the function returns safely.

---

Nitpick comments:
In `@libexec/scoop-search.ps1`:
- Around line 29-34: The current cache freshness check performs an expensive
recursive file-count using Get-ChildItem (via the loop around Get-LocalBucket
and Find-BucketDirectory) on every invocation; change the logic in
scoop-search.ps1 so that after verifying the cache timestamp you do not always
run the $currentCount / Get-ChildItem -Recurse scan — either remove the
file-count check and rely solely on the 24h TTL, or only perform the expensive
$currentCount comparison when the cached timestamp is near expiry (e.g., >23h
old). Keep references to the same variables ($cache.fileCount, $currentCount)
and bucket discovery (Get-LocalBucket, Find-BucketDirectory) but gate the
recursive scan behind a timestamp threshold to avoid O(n) disk I/O on the hot
path.
🪄 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: 0dfc3b25-a720-40e1-b5cb-ddee027da6f5

📥 Commits

Reviewing files that changed from the base of the PR and between b588a06 and b2c0ac9.

📒 Files selected for processing (1)
  • libexec/scoop-search.ps1

Comment thread libexec/scoop-search.ps1 Outdated
Comment thread libexec/scoop-search.ps1
@B67687
B67687 force-pushed the feat/search-binary-index-cache branch from b2c0ac9 to 1cbb7b3 Compare May 7, 2026 12:51

@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 `@libexec/scoop-search.ps1`:
- Around line 28-35: The freshness check currently in init_search_cache /
build_search_cache only compares $cache.fileCount to $currentCount, which misses
in-place edits that keep the same file count; update the check to also record
and validate a cheap fingerprint such as the maximum LastWriteTimeUtc (e.g.,
compute the max LastWriteTimeUtc across (Get-ChildItem ... '*.json' -Recurse)
per bucket or overall) or a metadata digest when building the cache, store it on
the cache object (e.g., $cache.maxWriteUtc or $cache.manifestDigest) and compare
it alongside fileCount in the early-return conditional, and ensure the code
paths that set $cache.fileCount ($cache.* in build_search_cache and
init_search_cache) also set the new fingerprint so modified manifests trigger a
cache rebuild.
🪄 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: a062ad8e-906e-4df6-889b-94875fc7d3d3

📥 Commits

Reviewing files that changed from the base of the PR and between b2c0ac9 and 1cbb7b3.

📒 Files selected for processing (1)
  • libexec/scoop-search.ps1

Comment thread libexec/scoop-search.ps1 Outdated

@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

🧹 Nitpick comments (1)
libexec/scoop-search.ps1 (1)

94-94: ⚡ Quick win

Empty catch blocks hide indexed-search parse failures

At Line 94 and Line 145, catch { } silently drops manifest errors. This can produce incomplete results with no signal during cache build/indexed search.

Suggested fix (minimal diagnostics without changing flow)
-            } catch { }
+            } catch {
+                debug "Failed to parse manifest during cache build: $filePath (error: $_)"
+            }
...
-        } catch { }
+        } catch {
+            debug "Failed to parse manifest during index lookup: $($info.path) (error: $_)"
+        }

Also applies to: 145-145

🤖 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 `@libexec/scoop-search.ps1` at line 94, Replace the empty catch blocks (catch {
}) that swallow manifest parse errors in libexec/scoop-search.ps1 with minimal
diagnostics so failures are visible during cache/index builds; update both
occurrences (the catch after the indexed-search parse around line 94 and the
similar one around line 145) to emit the exception message and context using the
automatic exception variable (e.g. catch { Write-Verbose "indexed-search parse
failed: $($_.Exception.Message)"; Write-Debug $($_.Exception) } ) so flow is
unchanged but errors are logged for debugging.
🤖 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 `@libexec/scoop-search.ps1`:
- Around line 68-72: The code builds $newApps using $appName as a single key
which causes collisions when different buckets contain the same app name; change
the data structure so $newApps[$appName] stores a collection (e.g., an array or
hashtable of entries) instead of a single @{ path=...; bucket=... } so you
accumulate all variants from allPathsByBucket in the foreach loops; update any
lookup/Indexing logic later (the code around the indexed lookup at lines
~122-124) to return or iterate over the collection for $newApps[$appName] (or
merge bucket-specific entries) instead of assuming a single entry so the indexed
lookup behavior matches the full regex scan.

---

Nitpick comments:
In `@libexec/scoop-search.ps1`:
- Line 94: Replace the empty catch blocks (catch { }) that swallow manifest
parse errors in libexec/scoop-search.ps1 with minimal diagnostics so failures
are visible during cache/index builds; update both occurrences (the catch after
the indexed-search parse around line 94 and the similar one around line 145) to
emit the exception message and context using the automatic exception variable
(e.g. catch { Write-Verbose "indexed-search parse failed:
$($_.Exception.Message)"; Write-Debug $($_.Exception) } ) so flow is unchanged
but errors are logged for debugging.
🪄 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: c369b0a8-c709-4197-92a3-f218e0f8a2b9

📥 Commits

Reviewing files that changed from the base of the PR and between 1cbb7b3 and c089c3a.

📒 Files selected for processing (1)
  • libexec/scoop-search.ps1

Comment thread libexec/scoop-search.ps1
@B67687
B67687 force-pushed the feat/search-binary-index-cache branch from 420d9d2 to fbc98bb Compare May 7, 2026 13:19
@B67687
B67687 changed the base branch from master to develop May 7, 2026 13:20
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown
✅ Actions performed

Reviews resumed.

Adds a JSON-based binary-index cache (/search-cache.json) to
the non-SQLite search path. The cache maps every app name and binary
name to file paths, enabling zero-I/O search on warm runs.

Three new functions are inserted before the existing bin_match:
  - init_search_cache   loads/validates the cache (timestamp + file
                        count + LastWriteTime fingerprint)
  - build_search_cache  scans all manifests once, builds the index
  - search_by_index     in-memory substring matching on cached data

The main else-block (non-SQLite path) is modified to try the cache
first. Literal queries (no regex metacharacters) hit the fast path.
Regex queries skip directly to the original regex full-scan.
When cache is stale or missing, the original regex path runs
unchanged and the cache is rebuilt for the next invocation.

Additional fixes:
  - Search_remotes now receives a string, not a Regex object
    (previously relied on .ToString() coincidence)
  - Binary display matches stock: empty for name-matched apps,
    populated only for binary-only matches
  - Cross-bucket app name duplicates stored as array, not single entry
  - maxWriteUtc fingerprint catches in-place manifest edits

Performance: ~200ms cached vs ~3000ms regex path (15-25x faster).
All original functions preserved, fallback unchanged.
@B67687
B67687 force-pushed the feat/search-binary-index-cache branch from c412741 to 3e82859 Compare May 7, 2026 13:57
@B67687

B67687 commented May 7, 2026

Copy link
Copy Markdown
Author

Architecture overview for reviewers

Three new functions inserted before bin_match (line 18), one else-block modified (line 310). Everything else is untouched.

New functions

Function Lines Purpose
init_search_cache 23-55 Loads/staleness-checks the cache file. Returns $true if usable.
build_search_cache 57-108 Scans all manifests, builds JSON cache at $scoopdir/search-cache.json.
search_by_index 110-173 Zero-I/O search of cached app/binary names via IndexOf. Appends to $list.

Modified else-block (lines 310-339)

if regex metacharacters in query? → skip cache, do regex full-scan (original code, unchanged)
else if cache fresh? → search_by_index (fast)
if list still empty? → regex full-scan (original code, unchanged)
if cache wasn't fresh → build_search_cache

This is the only control-flow change. The original regex path runs identically when the cache is stale or a regex query is used.

Key design decisions (not obvious from diff)

  • Staleness uses Get-ChildItem -Recurse instead of a lighter check (line 31-34). Must match build_search_cache and search_bucket enumeration exactly — using a different method (GetFiles, non-recursive) could produce inconsistent counts for edge cases like symlinks. The 150ms overhead is acceptable for a 15-25x speedup.
  • Binary display matches stock (lines 128-173): name-matched apps get Binaries = ''; only binary-only matches show binaries. Stock uses the same if/else pattern.
  • Array storage for cross-bucket duplicates (line 76-77): if git exists in both main and extras, the cache stores both paths. Without this, removing one bucket's manifest silently drops the app until the next staleness-triggered rebuild.
  • $query/$regex separation (line 322): original code mutated $query from string to Regex object, then passed it to search_remotes which expected a string. Fixed by using $regex for the compiled object and leaving $query as the original string.

What to test if you want to verify manually

# 1. Cold build (~3s first run)
scoop search node

# 2. Warm (should be instant)
scoop search node
scoop search git
scoop search "git|lfs"  # regex → cache skipped, uses regex path

# 3. Staleness: touch any manifest, re-search (should rebuild)
notepad $scoopdir\buckets\main\bucket\git.json  # add a space, save
scoop search git  # detects changed LastWriteTime, rebuilds cache

# 4. Remote search (unchanged, same as before)
scoop search nonexistent-app-that-exists-on-github

Files NOT touched

lib/buckets.ps1, lib/core.ps1, lib/database.ps1, any other libexec/ script. The change is confined to scoop-search.ps1 + CHANGELOG.md.

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