|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright 2019 Google Inc. |
| 4 | + * |
| 5 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | + * you may not use this file except in compliance with the License. |
| 7 | + * You may obtain a copy of the License at |
| 8 | + * |
| 9 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | + * |
| 11 | + * Unless required by applicable law or agreed to in writing, software |
| 12 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | + * See the License for the specific language governing permissions and |
| 15 | + * limitations under the License. |
| 16 | + */ |
| 17 | + |
| 18 | +import { QueryEngine } from './query_engine'; |
| 19 | +import { LocalDocumentsView } from './local_documents_view'; |
| 20 | +import { PersistenceTransaction } from './persistence'; |
| 21 | +import { PersistencePromise } from './persistence_promise'; |
| 22 | +import { Query } from '../core/query'; |
| 23 | +import { SnapshotVersion } from '../core/snapshot_version'; |
| 24 | +import { |
| 25 | + DocumentKeySet, |
| 26 | + DocumentMap, |
| 27 | + MaybeDocumentMap |
| 28 | +} from '../model/collections'; |
| 29 | +import { Document } from '../model/document'; |
| 30 | +import { assert } from '../util/assert'; |
| 31 | +import { debug, getLogLevel, LogLevel } from '../util/log'; |
| 32 | +import { SortedSet } from '../util/sorted_set'; |
| 33 | + |
| 34 | +// TOOD(b/140938512): Drop SimpleQueryEngine and rename IndexFreeQueryEngine. |
| 35 | + |
| 36 | +/** |
| 37 | + * A query engine that takes advantage of the target document mapping in the |
| 38 | + * QueryCache. The IndexFreeQueryEngine optimizes query execution by only |
| 39 | + * reading the documents that previously matched a query plus any documents that were |
| 40 | + * edited after the query was last listened to. |
| 41 | + * |
| 42 | + * There are some cases where Index-Free queries are not guaranteed to produce |
| 43 | + * the same results as full collection scans. In these cases, the |
| 44 | + * IndexFreeQueryEngine falls back to full query processing. These cases are: |
| 45 | + * |
| 46 | + * - Limit queries where a document that matched the query previously no longer |
| 47 | + * matches the query. |
| 48 | + * |
| 49 | + * - Limit queries where a document edit may cause the document to sort below |
| 50 | + * another document that is in the local cache. |
| 51 | + * |
| 52 | + * - Queries that have never been CURRENT or free of Limbo documents. |
| 53 | + */ |
| 54 | +export class IndexFreeQueryEngine implements QueryEngine { |
| 55 | + private localDocumentsView: LocalDocumentsView | undefined; |
| 56 | + |
| 57 | + setLocalDocumentsView(localDocuments: LocalDocumentsView): void { |
| 58 | + this.localDocumentsView = localDocuments; |
| 59 | + } |
| 60 | + |
| 61 | + getDocumentsMatchingQuery( |
| 62 | + transaction: PersistenceTransaction, |
| 63 | + query: Query, |
| 64 | + lastLimboFreeSnapshotVersion: SnapshotVersion, |
| 65 | + remoteKeys: DocumentKeySet |
| 66 | + ): PersistencePromise<DocumentMap> { |
| 67 | + assert( |
| 68 | + this.localDocumentsView !== undefined, |
| 69 | + 'setLocalDocumentsView() not called' |
| 70 | + ); |
| 71 | + |
| 72 | + // Queries that match all document don't benefit from using |
| 73 | + // IndexFreeQueries. It is more efficient to scan all documents in a |
| 74 | + // collection, rather than to perform individual lookups. |
| 75 | + if (query.matchesAllDocuments()) { |
| 76 | + return this.executeFullCollectionScan(transaction, query); |
| 77 | + } |
| 78 | + |
| 79 | + // Queries that have never seen a snapshot without limbo free documents |
| 80 | + // should also be run as a full collection scan. |
| 81 | + if (lastLimboFreeSnapshotVersion.isEqual(SnapshotVersion.MIN)) { |
| 82 | + return this.executeFullCollectionScan(transaction, query); |
| 83 | + } |
| 84 | + |
| 85 | + return this.localDocumentsView!.getDocuments(transaction, remoteKeys).next( |
| 86 | + documents => { |
| 87 | + const previousResults = this.applyQuery(query, documents); |
| 88 | + |
| 89 | + if ( |
| 90 | + query.hasLimit() && |
| 91 | + this.needsRefill( |
| 92 | + previousResults, |
| 93 | + remoteKeys, |
| 94 | + lastLimboFreeSnapshotVersion |
| 95 | + ) |
| 96 | + ) { |
| 97 | + return this.executeFullCollectionScan(transaction, query); |
| 98 | + } |
| 99 | + |
| 100 | + if (getLogLevel() <= LogLevel.DEBUG) { |
| 101 | + debug( |
| 102 | + 'IndexFreeQueryEngine', |
| 103 | + 'Re-using previous result from %s to execute query: %s', |
| 104 | + lastLimboFreeSnapshotVersion.toString(), |
| 105 | + query.toString() |
| 106 | + ); |
| 107 | + } |
| 108 | + |
| 109 | + // Retrieve all results for documents that were updated since the last |
| 110 | + // limbo-document free remote snapshot. |
| 111 | + return this.localDocumentsView!.getDocumentsMatchingQuery( |
| 112 | + transaction, |
| 113 | + query, |
| 114 | + lastLimboFreeSnapshotVersion |
| 115 | + ).next(updatedResults => { |
| 116 | + // We merge `previousResults` into `updateResults`, since |
| 117 | + // `updateResults` is already a DocumentMap. If a document is |
| 118 | + // contained in both lists, then its contents are the same. |
| 119 | + previousResults.forEach(doc => { |
| 120 | + updatedResults = updatedResults.insert(doc.key, doc); |
| 121 | + }); |
| 122 | + return updatedResults; |
| 123 | + }); |
| 124 | + } |
| 125 | + ); |
| 126 | + } |
| 127 | + |
| 128 | + /** Applies the query filter and sorting to the provided documents. */ |
| 129 | + private applyQuery( |
| 130 | + query: Query, |
| 131 | + documents: MaybeDocumentMap |
| 132 | + ): SortedSet<Document> { |
| 133 | + // Sort the documents and re-apply the query filter since previously |
| 134 | + // matching documents do not necessarily still match the query. |
| 135 | + let queyrResults = new SortedSet<Document>((d1, d2) => |
| 136 | + query.docComparator(d1, d2) |
| 137 | + ); |
| 138 | + documents.forEach((_, maybeDoc) => { |
| 139 | + if (maybeDoc instanceof Document && query.matches(maybeDoc)) { |
| 140 | + queyrResults = queyrResults.add(maybeDoc); |
| 141 | + } |
| 142 | + }); |
| 143 | + return queyrResults; |
| 144 | + } |
| 145 | + |
| 146 | + /** |
| 147 | + * Determines if a limit query needs to be refilled from cache, making it |
| 148 | + * ineligible for index-free execution. |
| 149 | + * |
| 150 | + * @param sortedPreviousResults The documents that matched the query when it |
| 151 | + * was last synchronized, sorted by the query's comparator. |
| 152 | + * @param remoteKeys The document keys that matched the query at the last |
| 153 | + * snapshot. |
| 154 | + * @param limboFreeSnapshotVersion The version of the snapshot when the query |
| 155 | + * was last synchronized. |
| 156 | + */ |
| 157 | + private needsRefill( |
| 158 | + sortedPreviousResults: SortedSet<Document>, |
| 159 | + remoteKeys: DocumentKeySet, |
| 160 | + limboFreeSnapshotVersion: SnapshotVersion |
| 161 | + ): boolean { |
| 162 | + // The query needs to be refilled if a previously matching document no |
| 163 | + // longer matches. |
| 164 | + if (remoteKeys.size !== sortedPreviousResults.size) { |
| 165 | + return true; |
| 166 | + } |
| 167 | + |
| 168 | + // Limit queries are not eligible for index-free query execution if there is |
| 169 | + // a potential that an older document from cache now sorts before a document |
| 170 | + // that was previously part of the limit. This, however, can only happen if |
| 171 | + // the last document of the limit sorts lower than it did when the query was |
| 172 | + // last synchronized. If a document that is not the limit boundary sorts |
| 173 | + // differently, the boundary of the limit itself did not change and |
| 174 | + // documents from cache will continue to be "rejected" by this boundary. |
| 175 | + // Therefore, we can ignore any modifications that don't affect the last |
| 176 | + // document. |
| 177 | + const lastDocumentInLimit = sortedPreviousResults.last(); |
| 178 | + if (!lastDocumentInLimit) { |
| 179 | + // We don't need to refill the query if there were already no documents. |
| 180 | + return false; |
| 181 | + } |
| 182 | + return ( |
| 183 | + lastDocumentInLimit.hasPendingWrites || |
| 184 | + lastDocumentInLimit.version.compareTo(limboFreeSnapshotVersion) > 0 |
| 185 | + ); |
| 186 | + } |
| 187 | + |
| 188 | + private executeFullCollectionScan( |
| 189 | + transaction: PersistenceTransaction, |
| 190 | + query: Query |
| 191 | + ): PersistencePromise<DocumentMap> { |
| 192 | + if (getLogLevel() <= LogLevel.DEBUG) { |
| 193 | + debug( |
| 194 | + 'IndexFreeQueryEngine', |
| 195 | + 'Using full collection scan to execute query: %s', |
| 196 | + query.toString() |
| 197 | + ); |
| 198 | + } |
| 199 | + |
| 200 | + return this.localDocumentsView!.getDocumentsMatchingQuery( |
| 201 | + transaction, |
| 202 | + query, |
| 203 | + SnapshotVersion.MIN |
| 204 | + ); |
| 205 | + } |
| 206 | +} |
0 commit comments