Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/api/autocomplete/bench/prefix-bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* eslint-disable no-console -- CLI benchmark script; console output is the deliverable */
/**
* Benchmark: prefix autocomplete via Fuse.js scan vs PrefixIndex trie.
*
* Run: npm run bench:prefix (tsx bench/prefix-bench.ts)
*
* Compares three ways of answering a short prefix query over N items:
* 1. Fuse.js as shipped - `^q` WITHOUT useExtendedSearch (broken: the
* caret is fuzzy-matched as a literal character)
* 2. Fuse.js extended - `^q` WITH useExtendedSearch: true (correct,
* but still scans every indexed string per query)
* 3. PrefixIndex trie - O(|prefix| + k) lookup
*/

import Fuse from "fuse.js";
import { PrefixIndex } from "../src/prefix-index";
import { AutocompleteItem } from "../src/types";

const N = Number(process.env.BENCH_N || 100_000);
const QUERY_ROUNDS = Number(process.env.BENCH_ROUNDS || 200);
const LIMIT = 10;

const ADJ = ["fast", "smart", "cloud", "micro", "hyper", "quantum", "neural", "atomic", "green", "solid"];
const NOUN = ["widget", "gadget", "service", "engine", "parser", "router", "cache", "queue", "stream", "index"];
const SUF = ["pro", "lite", "max", "core", "kit", "hub", "lab", "box", "net", "base"];

function makeItems(n: number): AutocompleteItem[] {
const items: AutocompleteItem[] = [];
for (let i = 0; i < n; i++) {
const a = ADJ[i % ADJ.length];
const b = NOUN[Math.floor(i / ADJ.length) % NOUN.length];
const c = SUF[Math.floor(i / (ADJ.length * NOUN.length)) % SUF.length];
items.push({
id: `item-${i}`,
title: `${a} ${b} ${c} ${i}`,
description: `The ${a} ${b} for serious ${c} users`,
category: NOUN[i % NOUN.length],
tags: [a, b],
createdAt: new Date(),
updatedAt: new Date(),
});
}
return items;
}

// Same 1-2 char prefixes the engine's prefix strategy handles
const QUERIES = ["fa", "sm", "cl", "mi", "hy", "qu", "ne", "at", "gr", "so", "w", "g", "s", "e", "p"];

function bench(label: string, fn: () => void, rounds: number): number {
// warmup
for (let i = 0; i < Math.min(10, rounds); i++) fn();
const t0 = process.hrtime.bigint();
for (let i = 0; i < rounds; i++) fn();
const t1 = process.hrtime.bigint();
const perOpMs = Number(t1 - t0) / 1e6 / rounds;
console.log(`${label.padEnd(34)} ${perOpMs.toFixed(4)} ms/query`);
return perOpMs;
}

function main(): void {
console.log(`\n=== Prefix autocomplete benchmark: N=${N} items, ${QUERY_ROUNDS} query rounds, limit=${LIMIT} ===\n`);
const items = makeItems(N);
const fuseKeys = [
{ name: "title", weight: 0.7 },
{ name: "description", weight: 0.3 },
{ name: "tags", weight: 0.2 },
];

let t = Date.now();
const fusePlain = new Fuse(items, { keys: fuseKeys, threshold: 0.3, includeScore: true });
console.log(`Fuse (default) index build: ${Date.now() - t} ms`);

t = Date.now();
const fuseExt = new Fuse(items, { keys: fuseKeys, threshold: 0.3, includeScore: true, useExtendedSearch: true });
console.log(`Fuse (extended) index build: ${Date.now() - t} ms`);

t = Date.now();
const trie = new PrefixIndex();
trie.build(items);
console.log(`PrefixIndex trie build: ${Date.now() - t} ms (${trie.tokens} tokens)\n`);

let qi = 0;
const nextQ = (): string => QUERIES[qi++ % QUERIES.length];

const plainMs = bench("Fuse as shipped (`^q`, no ext):", () => {
fusePlain.search(`^${nextQ()}`, { limit: LIMIT });
}, QUERY_ROUNDS);

qi = 0;
const extMs = bench("Fuse extended (`^q`, correct):", () => {
fuseExt.search(`^${nextQ()}`, { limit: LIMIT });
}, QUERY_ROUNDS);

qi = 0;
const trieMs = bench("PrefixIndex trie:", () => {
trie.search(nextQ(), LIMIT);
}, QUERY_ROUNDS);

console.log(`\nSpeedup vs Fuse-as-shipped: ${(plainMs / trieMs).toFixed(1)}x`);
console.log(`Speedup vs Fuse-extended: ${(extMs / trieMs).toFixed(1)}x\n`);

// Correctness spot check: trie results actually start with the prefix
const sample = trie.search("qu", LIMIT);
const ok = sample.length > 0 && sample.every((r) =>
r.title.split(/\s+/).some((w) => w.startsWith("qu")) || r.tags.some((tg) => tg.startsWith("qu"))
);
console.log(`Trie correctness spot check ("qu" -> ${sample.length} results, all prefixed): ${ok ? "PASS" : "FAIL"}`);
if (!ok) process.exit(1);
}

main();
7 changes: 3 additions & 4 deletions src/api/autocomplete/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,20 @@
"lint:fix": "eslint 'src/**/*.ts' --fix && npm run format",
"format": "prettier --write 'src/**/*.{ts,js,json}' '*.md'",
"format:check": "prettier --check 'src/**/*.{ts,js,json}' '*.md'",
"openapi": "tsx src/generate-openapi.ts"
"openapi": "tsx src/generate-openapi.ts",
"bench:prefix": "tsx bench/prefix-bench.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@fastify/swagger": "^9.7.0",
"fastify": "^5.7.4",
"fuse.js": "^7.0.0",
"lodash": "^4.17.21",
"lodash.debounce": "^4.0.8",
"redis": "^4.6.10",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^20.8.0",
"concurrently": "^8.2.2",
"jest": "^29.7.0",
Expand Down
62 changes: 34 additions & 28 deletions src/api/autocomplete/src/autocomplete-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import { SearchEngine } from "./search-engine";
import { CacheManager, createCacheProvider } from "./cache-manager";
import { DataSourceManager, createDataSource } from "./data-source";
import debounce from "lodash.debounce";
import {
AutocompleteItem,
AutocompleteRequest,
Expand All @@ -35,8 +34,21 @@ export class AutocompleteService {
private lastIndexRebuild = new Date();
private indexRebuildTimer?: NodeJS.Timeout;

// Debounced search function for performance
private debouncedSearch: (request: AutocompleteRequest) => Promise<AutocompleteResponse>;
/**
* Single-flight map: identical concurrent requests share one in-flight
* search instead of each hitting the engine (classic cache-stampede guard).
*
* This replaces the previous lodash.debounce wrapper, which was a genuine
* correctness bug on a server: per lodash's documented contract,
* "subsequent calls to the debounced function return the result of the
* LAST func invocation" - and undefined before the first invocation ever
* runs. Debouncing the shared request path therefore (a) returned
* `undefined` responses during the wait window and (b) leaked one user's
* search response to a different user whose request arrived in the same
* window. Debounce belongs on the client keystroke, never on the server
* request path.
*/
private inflight = new Map<string, Promise<AutocompleteResponse>>();

constructor(config: AutocompleteConfig) {
this.config = config;
Expand All @@ -53,15 +65,6 @@ export class AutocompleteService {
// Initialize data source manager
this.dataSourceManager = new DataSourceManager();

// Create debounced search function
const debouncedFn = debounce(this.performSearch.bind(this), config.api.debounceMs || 300);

// Wrapper to ensure we always return a Promise
this.debouncedSearch = async (request: AutocompleteRequest): Promise<AutocompleteResponse> => {
const result = await debouncedFn(request);
return result as AutocompleteResponse;
};

console.warn("🚀 AutocompleteService initialized");
}

Expand Down Expand Up @@ -105,12 +108,21 @@ export class AutocompleteService {
// Validate request
this.validateRequest(request);

// Use debounced search for better performance
if (this.config.api.debounceMs > 0) {
return this.debouncedSearch(request);
} else {
return this.performSearch(request);
// Single-flight: coalesce identical concurrent requests onto one search.
// Distinct requests always run independently and each caller always
// receives the response for ITS OWN request. (config.api.debounceMs is
// intentionally ignored on the server path - see the `inflight` docs.)
const key = this.cacheManager.generateCacheKey(request);
const existing = this.inflight.get(key);
if (existing) {
return existing;
}

const pending = this.performSearch(request).finally(() => {
this.inflight.delete(key);
});
this.inflight.set(key, pending);
return pending;
}

/**
Expand Down Expand Up @@ -324,6 +336,9 @@ export class AutocompleteService {
}
}, this.config.index.rebuildInterval);

// Background maintenance must not keep the Node.js process alive
this.indexRebuildTimer.unref?.();

console.warn(`⏰ Scheduled index rebuild every ${this.config.index.rebuildInterval}ms`);
}

Expand Down Expand Up @@ -435,17 +450,8 @@ export class AutocompleteService {
this.searchEngine.updateConfig(newConfig.search);
}

// Recreate debounced search if debounce time changed
if (newConfig.api?.debounceMs !== undefined) {
const debouncedFn = debounce(this.performSearch.bind(this), newConfig.api.debounceMs);

this.debouncedSearch = async (
request: AutocompleteRequest
): Promise<AutocompleteResponse> => {
const result = await debouncedFn(request);
return result as AutocompleteResponse;
};
}
// Note: api.debounceMs is accepted for backward compatibility but is not
// applied on the server request path (see `inflight` docs above).

console.warn("⚙️ Service configuration updated");
}
Expand Down
Loading