Skip to content

Spotlight cnid search: honor every "word" of a multi-word Finder search query, quoted phrases, folder scope, and the results limit - #3271

Merged
andylemin merged 1 commit into
mainfrom
spotlight-cnid-phrase-search
Aug 30, 2026
Merged

Spotlight cnid search: honor every "word" of a multi-word Finder search query, quoted phrases, folder scope, and the results limit#3271
andylemin merged 1 commit into
mainfrom
spotlight-cnid-phrase-search

Conversation

@andylemin

@andylemin andylemin commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

The cnid Spotlight (Finder search) backend served only a fraction of what Finder asks for. Four defects:

  1. Multi-word searches matched only the first word. Finder splits two words into one predicate pair per word joined with ||; extraction returned after the first supported predicate, so the OR the client asked for was dropped.
  2. Quoted phrase searches returned nothing. "two words" arrives as *=="\"two words\"*"cdw; the closing-quote scan used a plain strchr(…, '"'), stopped at the \" delimiter, and extracted a lone \ — below SL_CNID_MIN_TERMLEN, so zero results.
  3. Folder-scoped searches returned the whole volume. Finder sends the folder in kMDScopeArray and the RPC layer parsed it, but this backend never consulted it.
  4. The result limit was ignored by this backend, and the option it came from (sparql results limit) meant something different in each of the other two.

Fix

Extraction. sl_cnid_extract_terms() collects up to 8 terms from every supported filename predicate (kMDItemFSName, kMDItemDisplayName, _kMDItemFileName, then *== only if no named attribute matched), requiring the = to directly follow the key so a key appearing inside another predicate's value cannot bind elsewhere. sl_cnid_quoted_value() scans to the first unescaped quote, strips wildcards, drops phrase delimiters and unescapes. One search runs per term and the results are united, dropping CNIDs matched by more than one term. Phrase adjacency needs no new machinery: the CNID search is a filename substring match, so two words only matches adjacent occurrences.

Scope, enforced in the database (for the supported sqlite and mysql schemes; see the dbd note below). Post-filtering an unscoped search cannot be correct: the candidate buffer caps at 10000 volume-wide matches, so on a large volume it fills with out-of-scope entries before an in-scope one is seen. The schema stores (Id, Name, Did) — parent links, no paths — so the scope path is resolved once per query to its directory CNID with cnid_for_path(), and the new cnid_find_scoped() restricts matching to that subtree:

  • sqlite / mysql — a recursive CTE seeded with the scope DID, joined scope-set-first (CROSS JOIN / STRAIGHT_JOIN) so both the subtree walk and the name match drive off the (Did, Name) index: cost follows the subtree, not the volume. A server without recursive CTEs (MySQL < 8.0 / MariaDB < 10.2), or one refusing the recursion depth at execute time, falls back to the unscoped query.
  • dbdnot a target of this work: dbd is deprecated since the CNID default moved to sqlite. It gets a minimal implementation only, so a dbd volume is not left worse off: the daemon checks each name-index match's ancestor chain against the scope, with a per-request verdict cache, and the scope rides in the previously unused rqst.did field of the SEARCH op so the wire format is unchanged and version skew degrades to unscoped behaviour in both directions. It gets none of the optimisation the SQL schemes do — BDB has no subtree index, so the scan is unchanged — and its error handling is deliberately left incomplete (see below). The supported schemes are sqlite and mysql.

Scope paths are canonicalised at parse time: trailing slashes stripped, and the components below the volume root converted from the client's UTF8-MAC form per component — the volume path prefix is left untouched, since it already holds volume-charset bytes that this volume's casefold and precomposition rules must not rewrite. A scope that is not inside the volume, or that escapes it via .., falls back to the volume root rather than reaching cnid_for_path(), whose contract requires a path inside the volume; an over-long scope is an error, not a silent truncation to an ancestor. A path-prefix check on resolved results covers what the database-side scope cannot: daemon version skew, the mysql fallback, and renames racing the query.

The result limit — option renamed sparql results limitspotlight results limit. (It was never SPARQL-specific; the old name remains a deprecated alias that logs a warning, and the webmin module registers the rename so an existing key is shown and cleaned up). It now defaults to 10000 and means the same thing everywhere — 0 removes the limit for every backend, where previously 0 meant unlimited for localsearch, 10000 for xapian, and nothing at all for cnid, which could not exceed 10000 results by construction. A nonzero value is clamped to [100, 16000000]; negative or unparsable values fall back to the default. The end-of-life dbd scheme is excluded and keeps a fixed 10000 cap. The cnid candidate buffer is now sized from the limit, and for an unlimited search it starts at 10000 and grows until the database reports no further matches. Directory path resolution is memoized across results, cutting cnid_resolve() round-trips for every multi-result search. Zero-result replies pack CNIDs and filemeta as header-only containers, which the unmarshaller rejected as malformed — accepted now, with the empty CNID element added to its container like the empty filemeta beside it, plus guards on the three RPC handlers that read the first CNID of a client-supplied array without checking it holds one, and an empty array is treated as no filter rather than one that matches nothing.

cnid_find() keeps its public signature and behaviour; the cnid_db backend function-pointer for find gains the scope parameter (installed header — noted BREAKING in NEWS).

Coverage

Finder query (wire form) before after
single word
single predicate containing a space
quoted phrase — *=="\"two words\"*"cdw ✗ 0 results ✓ substring two words
multi-word, -joined per-word predicates ✗ first word only ✓ union of all words
name matching several words n/a ✓ counted once
under-length term among valid ones ✗ aborted extraction ✓ skipped, rest honored
key appearing inside another predicate's value ✗ could bind a foreign value ✓ ignored
scoped to a folder (kMDScopeArray) ✗ whole volume ✓ subtree only, complete to the candidate cap
scope with a trailing slash / non-ASCII name ✗ 0 results ✓ resolves
scope outside the volume or via .. n/a ✓ clamped to the volume root
result limit ✗ ignored by cnid; different meaning per backend ✓ one option, same meaning everywhere

Content queries (kMDItemTextContent alone) still return zero results gracefully — the CNID database indexes names only, by design.

Behaviour changes

  • All filename predicates are searched and united; a name matching several words appears once. With more than one term the buffer is sorted for dedup, so result order changes — Spotlight result sets are unordered; single-term queries keep the previous order.
  • An under-length term no longer aborts extraction. At most 8 terms per query (one per typed word in practice), logged when capped.
  • Folder-scoped searches return only in-scope results, complete up to the same 10000-candidate cap unscoped searches always had.
  • Option renamed: sparql results limitspotlight results limit. The old name still works and logs a deprecation warning, so existing configurations keep running; update them at your convenience.
  • spotlight results limit now defaults to 10000 and caps every backend identically; 0 is unlimited. Deployments that relied on the old unlimited-by-default behaviour should set 0 explicitly.
  • Docs updated: NEWS, the manual's Search page, afp.conf(5) and the developer indexing notes, referring to the feature as Spotlight (Finder search) so users know macOS searches network volumes through Finder search and not the menu-bar widget.

Testing

test632 — seven Finder search query shapes against a three-file fixture: plain-space term, quoted phrase, per-word OR (a fixture file matching both terms proves dedup), the universal Finder form with kMDItemTextContent predicates that must be ignored, a key embedded in another predicate's value, and short-term-alone / short-term-then-valid.

test633 — six Finder search scope phases: unscoped baseline, scoped to a subdirectory, trailing-slash scope, a scope named in the client's decomposed form against a precomposed on-disk name, a ..-escaping scope, and a scope naming another volume (plus one merely sharing a path prefix). The scope is sent through a new FPSpotlightOpenQueryScoped() helper that adds kMDScopeArray exactly as Finder does, deriving the server-side path from FPSpotlightOpen's reply.

Both were written first and recorded failing against the unmodified backend, then green after. Verified locally: full spectest 290/290; FPSpotlightRPC 8/8 on all three CNID schemes (dbd, sqlite, mysql with embedded MariaDB); 8/8 with a decomposed non-ASCII volume path; ASAN clean over the extractor across the escape, phrase, short-term and foreign-key inputs; clang -Werror, doxygen-strict and markdownlint gates clean.

Unit tests cover the limit option end to end: unset, an explicit value, an explicit 0, a value below the minimum and one above the maximum, a negative and an unparsable value, an empty canonical key falling through to the alias, and canonical-beats-alias precedence, plus the deprecation warning.

Standalone harnesses additionally cover extraction and union/dedupe, the per-term buffer reservation (a broad first term must not starve later words — verified failing before the fix and passing after), the scope boundary check (/srv/afp2 vs /srv/afp), the limit clamp, and the path memo. The scoped SQL was validated against the real cnid sqlite schema with EXPLAIN QUERY PLAN confirming both the recursion and the outer join use the (Did, Name) covering index.

The limit was also verified behaviourally rather than by unit test alone: a limit of 20 is raised to the 100 minimum and truncates a 250-file corpus to exactly 100, an unlimited search grows the candidate buffer (10000 → 80000 → …) until the database stops reporting matches, and the dbd cap fires only on dbd volumes.

Not in this PR

  • Quoted-phrase support in the localsearch and xapian backends (both still fail to parse phrase queries).
  • Date-range filters (InRange(…)) as a post-hoc filter from the per-result stat().
  • A uniform case/diacritic contract: cd insensitivity currently follows each CNID scheme's matching (SQLite LIKE is ASCII-only; MySQL follows the table collation).
  • An afp_spotlight CLI over the same testsuite client library, usable as a live smoke test.
  • Hardening the deprecated cnid_dbd scheme: a BDB engine error during the ancestor walk is still treated as out-of-scope rather than failing the search, and its scoped search cannot reach subtree cost. dbd is not a target of this work.
  • Pre-existing in the xapian backend: the limit is applied to the candidate window before scope and permission filtering, so a scoped search can under-deliver while reporting no truncation.
  • Pre-existing in sl_rpc_fetchAttributeNamesForOIDArray(): the EC_CLEANUP path dereferences the reply array whose allocation failure triggered the jump (out-of-memory only).

%%{init: {'flowchart': {'curve': 'basis'}}}%%
flowchart LR
    classDef wire    fill:#1e3a8a,stroke:#1e40af,color:#eff6ff,stroke-width:1px;
    classDef extract fill:#5b21b6,stroke:#6d28d9,color:#f5f3ff,stroke-width:1px;
    classDef db      fill:#0f766e,stroke:#0d9488,color:#f0fdfa,stroke-width:1px;
    classDef post    fill:#b45309,stroke:#d97706,color:#fffbeb,stroke-width:1px;

    Q("Finder query + kMDScopeArray<br/><i>one predicate pair per word, ‖-joined;<br/>phrases in escaped quotes</i>"):::wire
    X("extract terms<br/>named attrs → *== fallback<br/>'=' must follow the key<br/>dedupe, max 8"):::extract
    S("scope → CNID<br/>canonicalise, convert below<br/>the volume root, clamp escapes<br/>cnid_for_path(), once"):::extract
    F("cnid_find_scoped() per term<br/>sqlite/mysql: recursive CTE on (Did, Name)<br/>dbd: ancestor check in the daemon"):::db
    U("unite + dedupe<br/><i>a name matching several<br/>words counts once</i>"):::post
    L("'spotlight results limit' cap<br/><i>0 = unlimited</i>"):::post
    R("CNID → path<br/>memoized ancestor walk<br/>scope guard + stat + access(R_OK)<br/>20 results per reply page"):::post

    Q --> X --> F
    Q --> S --> F
    F --> U --> L --> R
Loading

@andylemin andylemin changed the title Spotlight cnid search: honor every word of a multi-word query, quoted phrases, and the results limit Spotlight cnid search: honor every word of a multi-word query, quoted phrases, folder scope, and the results limit Aug 25, 2026
@andylemin
andylemin force-pushed the spotlight-cnid-phrase-search branch from 8d3afcc to e2c741b Compare August 25, 2026 04:16
@andylemin
andylemin marked this pull request as ready for review August 25, 2026 04:35
@andylemin
andylemin requested a review from a team August 25, 2026 04:35
@andylemin
andylemin requested a review from rdmark as a code owner August 25, 2026 04:35
@andylemin andylemin changed the title Spotlight cnid search: honor every word of a multi-word query, quoted phrases, folder scope, and the results limit Spotlight cnid search: honor every "word" of a multi-word Finder search query, quoted phrases, folder scope, and the results limit Aug 25, 2026
@andylemin

Copy link
Copy Markdown
Contributor Author

Working well so far. Found some minor edge cases which need fixing first

@andylemin
andylemin force-pushed the spotlight-cnid-phrase-search branch 3 times, most recently from 45f4bc4 to 98df07f Compare August 26, 2026 16:24
@andylemin

andylemin commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Fixed some security issues where users could do ../othervol/word to search other vols, and some oddities when using NFD encoded vol names. Working great and is very usable now the path filter is working correctly

@andylemin
andylemin force-pushed the spotlight-cnid-phrase-search branch 4 times, most recently from 2a42334 to 37d4db6 Compare August 27, 2026 00:34
@andylemin

Copy link
Copy Markdown
Contributor Author

@rdmark This is a joy to use! It is fast, works intuitively, and works by default without user effort.

DBD still has some limitation (is missing the new features), and we will not fix.. SQLite and MySQL work beautifully.
I think we need to say in the news as a headline that DBD is officially depreciated and is no longer receiving fixes or integration with new features, and we strongly recommend removing cnid scheme = dbd from all configurations. DBD will be removed entirely in upcoming releases.

Also updated the docs to better clarify; macOS searches network volumes through Finder search only, not the Spotlight menu-bar widget, which is reserved for local volumes — macOS client behaviour, not a Netatalk limitation.

Ready for review

Comment thread doc/developer/indexing.md Outdated
Comment thread README.md Outdated

@rdmark rdmark left a comment

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.

massive quality of life improvement!

@andylemin
andylemin force-pushed the spotlight-cnid-phrase-search branch 4 times, most recently from 85389d7 to c97e555 Compare August 30, 2026 03:34
…imit

The cnid Spotlight backend served only a fraction of what Finder asks
for: multi-word searches searched only the first word (Finder sends one
predicate pair per word joined with ||, and extraction returned after
the first supported predicate); quoted exact-phrase searches returned
nothing (the closing-quote scan stopped at Finder's escaped \" phrase
delimiters, extracting a lone backslash that fell under the minimum
term length); folder-scoped searches returned matches from the entire
volume (the kMDScopeArray scope the RPC layer already parsed was never
consulted); and the result limit was ignored, while the option it came
from, 'sparql results limit', meant something different in each of the
other backends.

Extraction now honors every filename predicate, requiring the '=' to
directly follow the attribute key so a key occurring inside another
predicate's quoted value cannot bind elsewhere, stopping each scan at
the first unescaped quote and dropping phrase delimiters; one CNID
search runs per term and the results are united, dropping CNIDs
matched by more than one term. Phrase adjacency falls out of the
existing substring search.

The folder scope is enforced inside the CNID database via the new
cnid_find_scoped(), taking the CNID of the scope directory (resolved
once per query with cnid_for_path()): the sqlite and mysql schemes use
a recursive CTE seeded with the scope DID, driving both the subtree
enumeration and the name match off the (Did, Name) index; the dbd
daemon checks each name-index match's ancestor chain against the scope
with a per-request verdict cache, caching only walks that reached the
scope or the root. The scope rides in the previously unused rqst.did
field of the SEARCH op, so the dbd wire format is unchanged and
version skew degrades to the unscoped behavior; a mysql server without
CTE support, or one refusing the recursion depth at execute time,
falls back to the unscoped query.

Scope paths are canonicalised at parse time: trailing slashes are
stripped, and the components below the volume root are converted from
the client's UTF8-MAC form per component, leaving the volume path
prefix untouched because it already holds volume-charset bytes that
this volume's casefold and precomposition rules must not rewrite. A
scope that is not inside the volume, or that escapes it via '..',
falls back to the volume root rather than reaching cnid_for_path(),
whose contract requires a path inside the volume, and an over-long
scope is an error rather than a silent truncation to an ancestor. A
path-prefix guard on resolved results covers what the database-side
scope cannot: daemon version skew, the mysql fallback, and renames
racing the query. Directory path resolution is memoized across
results.

The result limit option 'sparql results limit' is renamed 'spotlight
results limit': it was never SPARQL-specific, and it now caps every
search backend identically. The old name 'sparql results limit' remains
accepted as a deprecated alias that logs a warning, and the webmin
module registers the rename so an existing key is still shown and is
cleaned up on save. The limit defaults to 10000 and 0 removes it for
all backends; previously 0 meant unlimited for localsearch, 10000 for
xapian, and nothing at all for the cnid backend, which could not return
more than 10000 results by construction. A nonzero value is clamped to
[100, 16000000] and a negative or unparsable one falls back to the
default. The end-of-life dbd CNID scheme is excluded from the option
and keeps a fixed 10000-result cap.

Zero-result fetch replies pack CNIDs and filemeta as header-only
containers, which the unmarshaller rejected as malformed, so a query
with no matches errored instead of returning an empty set; accept the
header-only encoding, add the empty CNID element to its container like
the empty filemeta beside it, and guard the three RPC handlers that
read the first CNID of a client-supplied array without checking that
it holds one.

cnid_find() keeps its public signature; the backend function-pointer
signature gains the scope parameter. Adds spectest coverage for the
query shapes, scoping (trailing-slash, non-ASCII, volume-escaping and
foreign-volume scopes), short-term scanning and zero-result draining,
a scoped-query testsuite helper, and updates NEWS, the manual's Search
page and the afp.conf man page.
@andylemin
andylemin force-pushed the spotlight-cnid-phrase-search branch from c97e555 to 2d025d5 Compare August 30, 2026 07:56
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

📊 Performance Dashboard

Commit: 2d025d5c0807415d250d81ff5bb0afe3f1798d22

🔥 Spectest (AFP 3.4) - FlameGraph

Netatalk Code-time: 2.9% · Runtime: 61s · Stacks: 1057

🔥 Click the preview to open the interactive flamegraph (zoom + search).

Flamegraph preview

🔝 Top 10 leaf functions
Function Samples
[libsqlite3.so.3.53.4] 248070375
_raw_spin_unlock_irqrestore 211686720
do_syscall_64 177508135
__cp_end 92612940
copy_folio_from_iter_atomic 67254635
x64_sys_call 61741960
srso_alias_safe_ret 42998865
afpd 42998865
generic_perform_write 30870980
__syscall_cp_c 26460840

📈 Speedtest (AFP 3.4) - PerfGraph

Speedtest throughput

Peak Read: 8624 MB/s (+20.3% vs hist avg 7167.8 MB/s; min 6309 / max 9526 over 30 PRs)
Peak Write: 311 MB/s (-76.3% vs hist avg 1313.7 MB/s; min 225 / max 1933 over 30 PRs)

🔝 Throughputs per operation (vs. historical average)
Metric Current (MB/s) Cur Avg Δ% Hist avg Hist min Hist max
Read peak mean 8624 +20.3% 7167.8 6309 9526
Read avg mean 5183 +24.9% 4149.7 3524 5393
Read avg max 5613 +22.7% 4575.6 3812 5932
Write peak mean 311 -76.3% 1313.7 225 1933
Write avg mean 168 -61.0% 430.6 91 588
Write avg max 286 -62.6% 765.2 224 1008
Copy peak mean 3333 +25.5% 2655.9 2294 3353
Copy avg mean 1942 +24.2% 1563.7 1360 1954
Copy avg max 2059 +20.6% 1706.9 1515 2087
ServerCopy peak mean 4202 +14.6% 3665.4 3055 5090
ServerCopy avg mean 2327 +21.5% 1915.2 1604 2501
ServerCopy avg max 2376 +19.5% 1987.9 1675 2658

⏱️ Lantest (AFP 3.4) - LatencyGraph

Lantest latency

Avg total runtime: 4492 ms (+7.3% vs hist avg 4186.3 ms; min 2122 / max 5208 over 30 PRs)
Avg time per AFP op: 91 µs (+8.2% vs hist avg 84.1 µs; min 43 / max 105 over 30 PRs)

🐢 All operations (avg runtime, in test order, vs. historical average)
Metric Current (ms) Cur Avg Δ% Adj Δ% Hist avg Hist min Hist max
Writing one large file 39 +5.1% 37.1 26 47
Reading one large file 15 -2.6% 15.4 12 23
Creating 2000 files 509 +2.2% -11.7% 498 211 831
Create 2000 dirs tree (20×9×10) 497 -0.5% -14.4% 499.4 289 796
Open, write 1024 bytes, close 2000 files 426 +15.5% +1.6% 368.8 195 459
Open, read 1024 bytes, close 2000 files 367 +14.0% +0.0% 322.1 177 408
Copying 1000 files client-side (R+W) 597 +9.1% -4.9% 547.3 267 677
Copying 2000 files server-side 494 -1.3% -15.2% 500.3 182 727
Stat (lookup+getparams) 2000 files 254 +15.6% +1.7% 219.7 125 282
Enumerate dir with 2000 files 12 +35.3% +21.4% 8.87 3 14
Lock then unlock 2000 open forks 186 +14.8% +0.9% 162 111 199
Deleting 2000 files 345 +3.6% -10.4% 333.1 125 447
Byte-range lock/unlock 2000 ranges in one fork 185 +11.9% -2.0% 165.3 115 196
Directory cache hits (20 dirs x 100 files) 112 +14.5% +0.6% 97.8 59 122
Mixed cache operations (create/stat/enum/delete) on 500 files 224 +5.3% -8.6% 212.7 92 265
Deep path traversal (20 levels x 100 walks) 119 +14.4% +0.5% 104 61 131
Cache validation (500 files x 4 lookups) 111 +17.5% +3.6% 94.5 58 116

Run baseline: median op-test delta +14.0%, MAD 3.6%. Adj Δ% shifts each delta by the median; standouts ≥5% in bold. A large MAD means the run did not move uniformly — read the adjusted column with caution.

Performance trend

@andylemin

Copy link
Copy Markdown
Contributor Author

massive quality of life improvement!

It is especially good if you are using modern file managers like "Forklift file manager". Finder is quite clunky, as each time you use Finder search, you have to click on the folder you are in (it always defaults to searching the local Mac).

File managers like Forklift and Pathfinder allow you to set Spotlight and "current folder" as the default. So you just work as normal, and when you search it automatically uses your current path context and Netatalk CNID, without extra clicks. It feels like a drop-in upgrade now.

@andylemin
andylemin merged commit 24e00f7 into main Aug 30, 2026
71 checks passed
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