-
Notifications
You must be signed in to change notification settings - Fork 10
Use an options object for searchByIndex and the custom-index search() contract #2187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -961,12 +961,17 @@ export class HierarchicalNavigableSmallWorld { | |
| * This the main entry from Harper's query functionality, where we actually search for an ordered list of nearest | ||
| * neighbors, using the provided sort/order definition object and performing the multi-layer skip-list search. | ||
| * This returns an iterable of the nearest neighbors to the provided target vector, with nearest ordered first. | ||
| * | ||
| * This is also the contract an index implemented outside this repo has to satisfy, so everything | ||
| * optional is named: a future capability (a paging cursor, a deadline, a recall target) is a new | ||
| * field on `options` rather than a positional argument that breaks every existing implementation. | ||
| * @param target | ||
| * @param value | ||
| * @param descending | ||
| * @param distance | ||
| * @param comparator | ||
| * @param context | ||
| * @param options | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: This new Nothing breaks today, because the third parameter is destructured inline and never bound to a name. The cost is on the next edit: giving that parameter a name — which is exactly what adding a runtime type guard would require — collides with the local. Renaming the local to —
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All of the |
||
| */ | ||
| search( | ||
| { | ||
|
|
@@ -987,16 +992,21 @@ export class HierarchicalNavigableSmallWorld { | |
| filterExpansion?: number; | ||
| }, | ||
| context: any, | ||
| // Predicate-aware traversal (#1241). When provided, only nodes for which `filter(primaryKey)` | ||
| // returns true are admitted to the result list at layer 0; routing is unaffected. Composed by | ||
| // search.ts from companion AND conditions and caller-supplied vector/row filters. Must be | ||
| // synchronous and side-effect free. JS-API only (never from a REST query string). | ||
| filter?: (primaryKey: Id) => boolean, | ||
| // offset + limit for a bounded query. A layer-0 search returns at most `ef` candidates, so a | ||
| // query asking for more rows than that used to come back short with no error — capped at 512 | ||
| // (AUTO_EF_MAX) however large the limit was. Raising ef to cover the request keeps `limit` | ||
| // meaningful; the caller pays for what it asked for. | ||
| minResults?: number | ||
| { | ||
| filter, | ||
| minResults, | ||
| }: { | ||
| // Predicate-aware traversal (#1241). When provided, only nodes for which `filter(primaryKey)` | ||
| // returns true are admitted to the result list at layer 0; routing is unaffected. Composed by | ||
| // search.ts from companion AND conditions and caller-supplied vector/row filters. Must be | ||
| // synchronous and side-effect free. JS-API only (never from a REST query string). | ||
| filter?: (primaryKey: Id) => boolean; | ||
| // offset + limit for a bounded query. A layer-0 search returns at most `ef` candidates, so a | ||
| // query asking for more rows than that used to come back short with no error — capped at 512 | ||
| // (AUTO_EF_MAX) however large the limit was. Raising ef to cover the request keeps `limit` | ||
| // meaningful; the caller pays for what it asked for. | ||
| minResults?: number; | ||
| } = {} | ||
| ) { | ||
| let limit: number | undefined; // only set for threshold comparators; 0 is a valid threshold (e.g. dotProduct) | ||
| let limitInclusive = false; // true for `le`, false for `lt` | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -140,16 +140,12 @@ export function executeConditions( | |
| filtered | ||
| // recordAccess intentionally omitted: guards run once, at the top level (see above). | ||
| ); | ||
| return searchByIndex( | ||
| condition, | ||
| txn, | ||
| condition.descending || request.reverse === true, | ||
| table, | ||
| request.allowFullScan, | ||
| return searchByIndex(condition, txn, condition.descending || request.reverse === true, table, { | ||
| allowFullScan: request.allowFullScan, | ||
| filtered, | ||
| context, | ||
| request.limit !== undefined ? (request.offset || 0) + request.limit : undefined | ||
| ); | ||
| minResults: request.limit !== undefined ? (request.offset || 0) + request.limit : undefined, | ||
| }); | ||
| } | ||
| function mapConditionsToFilters(conditions, intersection, estimatedIncomingCount) { | ||
| return conditions | ||
|
|
@@ -233,26 +229,34 @@ function composeRecordFilter(recordFilters, table, context): (primaryKey: Id) => | |
| } | ||
|
|
||
| /** | ||
| * Search for records or keys, based on the search condition, using an index if available | ||
| * Search for records or keys, based on the search condition, using an index if available. The four | ||
| * leading parameters are required at every call site; everything optional is named, so a new | ||
| * capability can be added without shifting positions at callers that don't use it. | ||
| * @param searchCondition | ||
| * @param transaction | ||
| * @param reverse | ||
| * @param Table | ||
| * @param allowFullScan | ||
| * @param filtered | ||
| * @param options | ||
| */ | ||
| export function searchByIndex( | ||
| searchCondition: DirectCondition, | ||
| transaction: any, | ||
| reverse: boolean, | ||
| Table: any, | ||
| allowFullScan?: boolean, | ||
| filtered?: any, | ||
| context?: any, | ||
| // How many rows the query will ultimately consume (offset + limit), when it is bounded. An | ||
| // approximate index returns a fixed-size candidate list, so without this a query asking for more | ||
| // rows than that list holds silently gets a short result set. Only custom indexes read it. | ||
| minResults?: number | ||
| { | ||
| allowFullScan, | ||
| filtered, | ||
| context, | ||
| minResults, | ||
| }: { | ||
| allowFullScan?: boolean; | ||
| filtered?: any; | ||
| context?: any; | ||
| // How many rows the query will ultimately consume (offset + limit), when it is bounded. An | ||
| // approximate index returns a fixed-size candidate list, so without this a query asking for more | ||
| // rows than that list holds silently gets a short result set. Only custom indexes read it. | ||
| minResults?: number; | ||
| } = {} | ||
|
Comment on lines
+246
to
+259
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low: a stale positional call silently permits a full scan — the one hazard the PR flags has no guard
Suggested fix — name the parameter, reject a non-object, then destructure (structural, so not a one-click suggestion): Table: any,
options: {
allowFullScan?: boolean;
filtered?: any;
context?: any;
minResults?: number;
} = {}
): AsyncIterable<Id | { key: Id; value: any }> {
if (typeof options !== 'object' || options === null)
throw new TypeError('searchByIndex: the 5th argument is an options object (#2165), not a positional value');
const { allowFullScan, filtered, context, minResults } = options;That also lets — |
||
| ): AsyncIterable<Id | { key: Id; value: any }> { | ||
| let attribute_name = searchCondition[0] ?? searchCondition.attribute; | ||
| let value = searchCondition[1] ?? searchCondition.value; | ||
|
|
@@ -284,8 +288,7 @@ export function searchByIndex( | |
| transaction, | ||
| reverse, | ||
| relatedTable, | ||
| allowFullScan, | ||
| joined | ||
| { allowFullScan, filtered: joined } | ||
|
maurice-harper marked this conversation as resolved.
|
||
| ); | ||
| if (attribute.relationship.to) { | ||
| // this is one-to-many or many-to-many, so we need to track the filtering of related entries that match | ||
|
|
@@ -302,8 +305,7 @@ export function searchByIndex( | |
| transaction, | ||
| reverse, | ||
| Table, | ||
| allowFullScan, | ||
| joined | ||
| { allowFullScan, filtered: joined } | ||
|
maurice-harper marked this conversation as resolved.
|
||
| ); | ||
| }; | ||
| if (attribute.elements) { | ||
|
|
@@ -510,21 +512,23 @@ export function searchByIndex( | |
| // exploring until it has enough MATCHING results, rather than post-filtering an under-filled | ||
| // candidate set. Only indexes that opt in (filteredSearch) receive it; others post-filter as before. | ||
| const recordFilter = index.customIndex.filteredSearch ? searchCondition.recordFilter : undefined; | ||
| const loaded = index.customIndex.search(searchCondition, context, recordFilter, minResults).map((entry) => { | ||
| // if the custom index returns an entry with metadata, merge it with the loaded entry | ||
| if (typeof entry === 'object' && entry) { | ||
| const { key, ...otherProps } = entry; | ||
| if (key == null) return SKIP; // primaryKey missing from HNSW node — skip rather than crash | ||
| const loadedEntry = Table.primaryStore.getEntry(key, { | ||
| transaction: context && Table._readTxnForContext(context), | ||
| }); | ||
| if (!loadedEntry) return SKIP; // record was deleted/expired or not yet visible | ||
| freezeRecord(loadedEntry?.value); | ||
| recordRead(loadedEntry); | ||
| return { ...otherProps, ...loadedEntry }; | ||
| } | ||
| return entry; | ||
| }); | ||
| const loaded = index.customIndex | ||
| .search(searchCondition, context, { filter: recordFilter, minResults }) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a breaking change to the external custom-index contract. An existing — KrAIs (Codex) |
||
| .map((entry) => { | ||
| // if the custom index returns an entry with metadata, merge it with the loaded entry | ||
| if (typeof entry === 'object' && entry) { | ||
| const { key, ...otherProps } = entry; | ||
| if (key == null) return SKIP; // primaryKey missing from HNSW node — skip rather than crash | ||
| const loadedEntry = Table.primaryStore.getEntry(key, { | ||
| transaction: context && Table._readTxnForContext(context), | ||
| }); | ||
| if (!loadedEntry) return SKIP; // record was deleted/expired or not yet visible | ||
| freezeRecord(loadedEntry?.value); | ||
| recordRead(loadedEntry); | ||
| return { ...otherProps, ...loadedEntry }; | ||
| } | ||
| return entry; | ||
| }); | ||
| if (index.customIndex.rescoreResults) { | ||
| const rescored = index.customIndex.rescoreResults(loaded, searchCondition, comparator, attribute_name); | ||
| if (rescored != null) return rescored as any; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| const assert = require('node:assert'); | ||
| const { setupTestDBPath } = require('../testUtils'); | ||
| const { table } = require('#src/resources/databases'); | ||
| const { searchByIndex } = require('#src/resources/search'); | ||
| const { setMainIsWorker } = require('#js/server/threads/manageThreads'); | ||
|
|
||
| // searchByIndex takes its optional tail as a named options object (#2165). These assert the options | ||
| // are actually read off that object: a caller that reverts to positional arguments would hand | ||
| // `false` in where the options belong, and destructuring it yields `allowFullScan === undefined` — | ||
| // silently turning a rejected full scan into a permitted one. | ||
| describe('searchByIndex options object (#2165)', () => { | ||
| if (process.env.HARPER_STORAGE_ENGINE === 'lmdb') return; | ||
| let T; | ||
|
|
||
| before(async () => { | ||
| setupTestDBPath(); | ||
| setMainIsWorker(true); | ||
| T = table({ | ||
| table: 'SearchByIndexOptionsTest', | ||
| database: 'test', | ||
| attributes: [ | ||
| { name: 'id', isPrimaryKey: true }, | ||
| { name: 'indexedName', indexed: true }, | ||
| { name: 'unindexedName' }, | ||
| ], | ||
| }); | ||
| await T.put(1, { indexedName: 'a', unindexedName: 'x' }); | ||
| await T.put(2, { indexedName: 'b', unindexedName: 'y' }); | ||
| }); | ||
|
|
||
| after(() => { | ||
| T.dropTable(); | ||
| }); | ||
|
|
||
| it('rejects an unindexed attribute when allowFullScan is false in the options object', () => { | ||
| assert.throws( | ||
| () => | ||
| searchByIndex({ attribute: 'unindexedName', value: 'x' }, undefined, false, T, { | ||
| allowFullScan: false, | ||
| }), | ||
| /not indexed/, | ||
| 'allowFullScan must be read from the named option' | ||
| ); | ||
| }); | ||
|
|
||
| it('keeps the permissive defaults when the options object is omitted entirely', async () => { | ||
| const ids = []; | ||
| for await (const entry of searchByIndex({ attribute: 'unindexedName', value: 'x' }, undefined, false, T)) { | ||
| ids.push(entry?.key ?? entry); | ||
| } | ||
| assert.deepStrictEqual(ids, [1], 'omitting options must full-scan, not inherit allowFullScan: false'); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.