Skip to content

Port QueryEngine #6153

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

Merged
merged 7 commits into from
Apr 19, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions packages/firestore/src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function asCollectionQueryAtPath(
* Returns true if this query does not specify any query constraints that
* could remove results.
*/
export function matchesAllDocuments(query: Query): boolean {
export function queryMatchesAllDocuments(query: Query): boolean {
return (
query.filters.length === 0 &&
query.limit === null &&
Expand Down Expand Up @@ -393,7 +393,7 @@ export function queryWithAddedOrderBy(query: Query, orderBy: OrderBy): Query {

export function queryWithLimit(
query: Query,
limit: number,
limit: number | null,
limitType: LimitType
): Query {
return new QueryImpl(
Expand Down
9 changes: 9 additions & 0 deletions packages/firestore/src/local/index_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,13 @@ export interface IndexManager {
transaction: PersistenceTransaction,
documents: DocumentMap
): PersistencePromise<void>;

/**
* Iterates over all field indexes that are used to serve the given target,
* and returns the minimum offset of them all.
*/
getMinOffset(
transaction: PersistenceTransaction,
target: Target
): PersistencePromise<IndexOffset>;
}
10 changes: 10 additions & 0 deletions packages/firestore/src/local/indexeddb_index_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,16 @@ export class IndexedDbIndexManager implements IndexManager {
}
return ranges;
}

getMinOffset(
transaction: PersistenceTransaction,
target: Target
): PersistencePromise<IndexOffset> {
// TODO(orqueries): Get the minimum offset for all subqueries
return this.getFieldIndex(transaction, target).next(index =>
index ? index.indexState.offset : IndexOffset.min()
);
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/firestore/src/local/indexeddb_schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { EncodedResourcePath } from './encoded_resource_path';
import { DbTimestampKey } from './indexeddb_sentinels';

// TODO(indexing): Remove this constant
const INDEXING_ENABLED = false;
export const INDEXING_ENABLED = false;

export const INDEXING_SCHEMA_VERSION = 14;

Expand Down
2 changes: 1 addition & 1 deletion packages/firestore/src/local/local_documents_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class LocalDocumentsView {
constructor(
readonly remoteDocumentCache: RemoteDocumentCache,
readonly mutationQueue: MutationQueue,
readonly indexManager: IndexManager
private readonly indexManager: IndexManager
) {}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/firestore/src/local/local_store_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ class LocalStoreImpl implements LocalStore {
this.indexManager
);
this.remoteDocuments.setIndexManager(this.indexManager);
this.queryEngine.setLocalDocumentsView(this.localDocuments);
this.queryEngine.initialize(this.localDocuments, this.indexManager);
}

collectGarbage(garbageCollector: LruGarbageCollector): Promise<LruResults> {
Expand Down
7 changes: 7 additions & 0 deletions packages/firestore/src/local/memory_index_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ export class MemoryIndexManager implements IndexManager {
return PersistencePromise.resolve<string | null>(null);
}

getMinOffset(
transaction: PersistenceTransaction,
target: Target
): PersistencePromise<IndexOffset> {
return PersistencePromise.resolve(IndexOffset.min());
}

updateCollectionGroup(
transaction: PersistenceTransaction,
collectionGroup: string,
Expand Down
207 changes: 183 additions & 24 deletions packages/firestore/src/local/query_engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,62 @@ import {
hasLimitToFirst,
hasLimitToLast,
LimitType,
matchesAllDocuments,
newQueryComparator,
Query,
queryMatches,
queryMatchesAllDocuments,
queryToTarget,
queryWithLimit,
stringifyQuery
} from '../core/query';
import { SnapshotVersion } from '../core/snapshot_version';
import { DocumentKeySet, DocumentMap } from '../model/collections';
import {
documentKeySet,
DocumentKeySet,
DocumentMap
} from '../model/collections';
import { Document } from '../model/document';
import {
IndexOffset,
INITIAL_LARGEST_BATCH_ID,
newIndexOffsetSuccessorFromReadTime
} from '../model/field_index';
import { debugAssert } from '../util/assert';
import { getLogLevel, LogLevel, logDebug } from '../util/log';
import { getLogLevel, logDebug, LogLevel } from '../util/log';
import { ValueIterable, values } from '../util/misc';
import { SortedSet } from '../util/sorted_set';

import { IndexManager, IndexType } from './index_manager';
import { INDEXING_ENABLED } from './indexeddb_schema';
import { LocalDocumentsView } from './local_documents_view';
import { PersistencePromise } from './persistence_promise';
import { PersistenceTransaction } from './persistence_transaction';

/**
* A query engine that takes advantage of the target document mapping in the
* QueryCache. Query execution is optimized by only reading the documents that
* previously matched a query plus any documents that were edited after the
* query was last listened to.
* The Firestore query engine.
*
* Firestore queries can be executed in three modes. The Query Engine determines
* what mode to use based on what data is persisted. The mode only determines
* the runtime complexity of the query - the result set is equivalent across all
* implementations.
*
* The Query engine will use indexed-based execution if a user has configured
* any index that can be used to execute query (via `setIndexConfiguration()`).
* Otherwise, the engine will try to optimize the query by re-using a previously
* persisted query result. If that is not possible, the query will be executed
* via a full collection scan.
*
* Index-based execution is the default when available. The query engine
* supports partial indexed execution and merges the result from the index
* lookup with documents that have not yet been indexed. The index evaluation
* matches the backend's format and as such, the SDK can use indexing for all
* queries that the backend supports.
*
* If no index exists, the query engine tries to take advantage of the target
* document mapping in the TargetCache. These mappings exists for all queries
* that have been synced with the backend at least once and allow the query
* engine to only read documents that previously matched a query plus any
* documents that were edited after the query was last listened to.
*
* There are some cases when this optimization is not guaranteed to produce
* the same results as full collection scans. In these cases, query
Expand All @@ -60,11 +89,18 @@ import { PersistenceTransaction } from './persistence_transaction';
* - Queries that have never been CURRENT or free of limbo documents.
*/
export class QueryEngine {
private localDocumentsView: LocalDocumentsView | undefined;
private localDocumentsView!: LocalDocumentsView;
private indexManager!: IndexManager;
private initialized = false;

/** Sets the document view to query against. */
setLocalDocumentsView(localDocuments: LocalDocumentsView): void {
initialize(
localDocuments: LocalDocumentsView,
indexManager: IndexManager
): void {
this.localDocumentsView = localDocuments;
this.indexManager = indexManager;
this.initialized = true;
}

/** Returns all local documents matching the specified query. */
Expand All @@ -74,15 +110,122 @@ export class QueryEngine {
lastLimboFreeSnapshotVersion: SnapshotVersion,
remoteKeys: DocumentKeySet
): PersistencePromise<DocumentMap> {
debugAssert(
this.localDocumentsView !== undefined,
'setLocalDocumentsView() not called'
);
debugAssert(this.initialized, 'initialize() not called');

return this.performQueryUsingIndex(transaction, query)
.next(result =>
result
? result
: this.performQueryUsingRemoteKeys(
transaction,
query,
remoteKeys,
lastLimboFreeSnapshotVersion
)
)
.next(result =>
result ? result : this.executeFullCollectionScan(transaction, query)
);
}

/**
* Performs an indexed query that evaluates the query based on a collection's
* persisted index values. Returns `null` if an index is not available.
*/
private performQueryUsingIndex(
transaction: PersistenceTransaction,
query: Query
): PersistencePromise<DocumentMap | null> {
if (!INDEXING_ENABLED) {
return PersistencePromise.resolve<DocumentMap | null>(null);
}

if (queryMatchesAllDocuments(query)) {
// Don't use indexes for queries that can be executed by scanning the
// collection.
return PersistencePromise.resolve<DocumentMap | null>(null);
}

let target = queryToTarget(query);
return this.indexManager
.getIndexType(transaction, target)
.next(indexType => {
if (indexType === IndexType.NONE) {
// The target cannot be served from any index.
return null;
}

if (indexType === IndexType.PARTIAL) {
// We cannot apply a limit for targets that are served using a partial
// index. If a partial index will be used to serve the target, the
// query may return a superset of documents that match the target
// (e.g. if the index doesn't include all the target's filters), or
// may return the correct set of documents in the wrong order (e.g. if
// the index doesn't include a segment for one of the orderBys).
// Therefore, a limit should not be applied in such cases.
query = queryWithLimit(query, null, LimitType.First);
target = queryToTarget(query);
}

return this.indexManager
.getDocumentsMatchingTarget(transaction, target)
.next(keys => {
debugAssert(
!!keys,
'Index manager must return results for partial and full indexes.'
);
const sortedKeys = documentKeySet(...keys);
return this.localDocumentsView
.getDocuments(transaction, sortedKeys)
.next(indexedDocuments => {
return this.indexManager
.getMinOffset(transaction, target)
.next(offset => {
const previousResults = this.applyQuery(
query,
indexedDocuments
);

if (
(hasLimitToFirst(query) || hasLimitToLast(query)) &&
this.needsRefill(
query.limitType,
previousResults,
sortedKeys,
offset.readTime
)
) {
return PersistencePromise.resolve<DocumentMap | null>(
null
);
}

return this.appendRemainingResults(
transaction,
values(indexedDocuments),
query,
offset
) as PersistencePromise<DocumentMap | null>;
});
});
});
});
}

/**
* Performs a query based on the target's persisted query mapping. Returns
* `null` if the mapping is not available or cannot be used.
*/
private performQueryUsingRemoteKeys(
transaction: PersistenceTransaction,
query: Query,
remoteKeys: DocumentKeySet,
lastLimboFreeSnapshotVersion: SnapshotVersion
): PersistencePromise<DocumentMap> {
// Queries that match all documents don't benefit from using
// key-based lookups. It is more efficient to scan all documents in a
// collection, rather than to perform individual lookups.
if (matchesAllDocuments(query)) {
if (queryMatchesAllDocuments(query)) {
return this.executeFullCollectionScan(transaction, query);
}

Expand Down Expand Up @@ -119,22 +262,15 @@ export class QueryEngine {

// Retrieve all results for documents that were updated since the last
// limbo-document free remote snapshot.
return this.localDocumentsView!.getDocumentsMatchingQuery(
return this.appendRemainingResults(
transaction,
previousResults,
query,
newIndexOffsetSuccessorFromReadTime(
lastLimboFreeSnapshotVersion,
INITIAL_LARGEST_BATCH_ID
)
).next(updatedResults => {
// We merge `previousResults` into `updateResults`, since
// `updateResults` is already a DocumentMap. If a document is
// contained in both lists, then its contents are the same.
previousResults.forEach(doc => {
updatedResults = updatedResults.insert(doc.key, doc);
});
return updatedResults;
});
);
}
);
}
Expand All @@ -159,6 +295,7 @@ export class QueryEngine {
* Determines if a limit query needs to be refilled from cache, making it
* ineligible for index-free execution.
*
* @param limitType The limit type used by the query.
* @param sortedPreviousResults - The documents that matched the query when it
* was last synchronized, sorted by the query's comparator.
* @param remoteKeys - The document keys that matched the query at the last
Expand Down Expand Up @@ -218,4 +355,26 @@ export class QueryEngine {
IndexOffset.min()
);
}

/**
* Combines the results from an indexed execution with the remaining documents
* that have not yet been indexed.
*/
private appendRemainingResults(
transaction: PersistenceTransaction,
indexedResults: ValueIterable<Document>,
query: Query,
offset: IndexOffset
): PersistencePromise<DocumentMap> {
// Retrieve all results for documents that were updated since the offset.
return this.localDocumentsView
.getDocumentsMatchingQuery(transaction, query, offset)
.next(remainingResults => {
// Merge with existing results
indexedResults.forEach(d => {
remainingResults = remainingResults.insert(d.key, d);
});
return remainingResults;
});
}
}
8 changes: 6 additions & 2 deletions packages/firestore/src/model/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ export type DocumentMap = SortedMap<DocumentKey, Document>;
const EMPTY_DOCUMENT_MAP = new SortedMap<DocumentKey, Document>(
DocumentKey.comparator
);
export function documentMap(): DocumentMap {
return EMPTY_DOCUMENT_MAP;
export function documentMap(...docs: Document[]): DocumentMap {
let map = EMPTY_DOCUMENT_MAP;
for (const doc of docs) {
map = map.insert(doc.key, doc);
}
return map;
}

export type OverlayMap = ObjectMap<DocumentKey, Overlay>;
Expand Down
Loading