Skip to content

Implement IndexedDB LRU Reference Delegate, add LRU tests #1224

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 24 commits into from
Sep 19, 2018
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
eab8740
Implement IndexedDB LRU Reference Delegate, add LRU tests
Sep 12, 2018
6818502
Lint cleanup
Sep 12, 2018
d37c2c4
[AUTOMATED]: Prettier Code Styling
Sep 12, 2018
11cad0d
Add garbagecollector property, reorder indexeddb query cache construc…
Sep 17, 2018
3114ce7
LiveTargets -> ActiveTargets, fix comments on ReferenceDelegate
Sep 17, 2018
5718c24
[AUTOMATED]: Prettier Code Styling
Sep 17, 2018
baa9fa8
Methods reordered to match android
Sep 17, 2018
ed3c84d
Remove forEachOrphanedDocument from query cache, move to reference de…
Sep 17, 2018
9d68610
Fix comment typo and reflow, move mutationQueuesContainKey to indexed…
Sep 17, 2018
cba7bf7
[AUTOMATED]: Prettier Code Styling
Sep 17, 2018
f88817a
Only pass reference delegate to indexeddb query cache, not whole pers…
Sep 17, 2018
a79a941
[AUTOMATED]: Prettier Code Styling
Sep 17, 2018
be943c5
iterateAsync -> iterateSerial
Sep 17, 2018
16eaaca
onLimboDocumentUpdated -> updateLimboDocument
Sep 17, 2018
e25162c
Reorder lru delegate methods to match android
Sep 17, 2018
279e14a
Regroup tests a little
Sep 17, 2018
34f7967
A little test cleanup
Sep 17, 2018
801694c
additionalReferences -> inMemoryPins
Sep 18, 2018
34f62fb
Using SortedSet instead of a PriorityQueue (#1233)
Sep 19, 2018
3b748b1
Drop sentinel row and sentinel key store. Add sequence number to targ…
Sep 19, 2018
11b0baf
Merge
Sep 19, 2018
366418e
Lint
Sep 19, 2018
fcbb366
Comments and flip conditional
Sep 19, 2018
c922dce
Compare to undefined, not typeof undefined
Sep 19, 2018
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
37 changes: 24 additions & 13 deletions packages/firestore/src/local/indexeddb_mutation_queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,19 +525,7 @@ export class IndexedDbMutationQueue implements MutationQueue {
txn: PersistenceTransaction,
key: DocumentKey
): PersistencePromise<boolean> {
const indexKey = DbDocumentMutation.prefixForPath(this.userId, key.path);
const encodedPath = indexKey[1];
const startRange = IDBKeyRange.lowerBound(indexKey);
let containsKey = false;
return documentMutationsStore(txn)
.iterate({ range: startRange, keysOnly: true }, (key, value, control) => {
const [userID, keyPath, /*batchID*/ _] = key;
if (userID === this.userId && keyPath === encodedPath) {
containsKey = true;
}
control.done();
})
.next(() => containsKey);
return mutationQueueContainsKey(txn, this.userId, key);
}

// PORTING NOTE: Multi-tab only (state is held in memory in other clients).
Expand All @@ -560,6 +548,29 @@ export class IndexedDbMutationQueue implements MutationQueue {
}
}

/**
* @return true if the mutation queue for the given user contains a pending mutation for the given key.
*/
export function mutationQueueContainsKey(
txn: PersistenceTransaction,
userId: string,
key: DocumentKey
): PersistencePromise<boolean> {
const indexKey = DbDocumentMutation.prefixForPath(userId, key.path);
const encodedPath = indexKey[1];
const startRange = IDBKeyRange.lowerBound(indexKey);
let containsKey = false;
return documentMutationsStore(txn)
.iterate({ range: startRange, keysOnly: true }, (key, value, control) => {
const [userID, keyPath, /*batchID*/ _] = key;
if (userID === userId && keyPath === encodedPath) {
containsKey = true;
}
control.done();
})
.next(() => containsKey);
}

/**
* Delete a mutation batch and the associated document mutations.
* @return A PersistencePromise of the document mutations that were removed.
Expand Down
236 changes: 230 additions & 6 deletions packages/firestore/src/local/indexeddb_persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import { assert, fail } from '../util/assert';
import { Code, FirestoreError } from '../util/error';
import * as log from '../util/log';

import { IndexedDbMutationQueue } from './indexeddb_mutation_queue';
import {
IndexedDbMutationQueue,
mutationQueueContainsKey
} from './indexeddb_mutation_queue';
import {
IndexedDbQueryCache,
getHighestListenSequenceNumber
Expand All @@ -35,24 +38,34 @@ import {
DbPrimaryClientKey,
SCHEMA_VERSION,
DbTargetGlobal,
SchemaConverter
SchemaConverter,
DbTargetDocument,
DbTargetDocumentKey,
DbMutationQueueKey,
DbMutationQueue
} from './indexeddb_schema';
import { LocalSerializer } from './local_serializer';
import { MutationQueue } from './mutation_queue';
import {
Persistence,
PersistenceTransaction,
PrimaryStateListener
PrimaryStateListener,
ReferenceDelegate
} from './persistence';
import { PersistencePromise } from './persistence_promise';
import { QueryCache } from './query_cache';
import { RemoteDocumentCache } from './remote_document_cache';
import { SimpleDb, SimpleDbStore, SimpleDbTransaction } from './simple_db';
import { Platform } from '../platform/platform';
import { AsyncQueue, TimerId } from '../util/async_queue';
import { ClientId } from './shared_client_state';
import { CancelablePromise } from '../util/promise';
import { ListenSequence, SequenceNumberSyncer } from '../core/listen_sequence';
import { ReferenceSet } from './reference_set';
import { QueryData } from './query_data';
import { DocumentKey } from '../model/document_key';
import { encode, EncodedResourcePath } from './encoded_resource_path';
import { LiveTargets, LruDelegate } from './lru_garbage_collector';
import { ListenSequenceNumber, TargetId } from '../core/types';

const LOG_TAG = 'IndexedDbPersistence';

Expand Down Expand Up @@ -247,6 +260,7 @@ export class IndexedDbPersistence implements Persistence {
private remoteDocumentCache: IndexedDbRemoteDocumentCache;
private readonly webStorage: Storage;
private listenSequence: ListenSequence;
readonly referenceDelegate: ReferenceDelegate;

// Note that `multiClientParams` must be present to enable multi-client support while multi-tab
// is still experimental. When multi-client is switched to always on, `multiClientParams` will
Expand All @@ -265,11 +279,12 @@ export class IndexedDbPersistence implements Persistence {
UNSUPPORTED_PLATFORM_ERROR_MSG
);
}
this.referenceDelegate = new IndexedDbLruDelegate(this);
this.dbName = persistenceKey + IndexedDbPersistence.MAIN_DATABASE;
this.serializer = new LocalSerializer(serializer);
this.document = platform.document;
this.allowTabSynchronization = multiClientParams !== undefined;
this.queryCache = new IndexedDbQueryCache(this.serializer);
this.queryCache = new IndexedDbQueryCache(this.serializer, this);
this.remoteDocumentCache = new IndexedDbRemoteDocumentCache(
this.serializer,
/*keepDocumentChangeLog=*/ this.allowTabSynchronization
Expand Down Expand Up @@ -694,7 +709,7 @@ export class IndexedDbPersistence implements Persistence {
return IndexedDbMutationQueue.forUser(user, this.serializer);
}

getQueryCache(): QueryCache {
getQueryCache(): IndexedDbQueryCache {
assert(
this.started,
'Cannot initialize QueryCache before persistence is started.'
Expand Down Expand Up @@ -1026,3 +1041,212 @@ function clientMetadataStore(
DbClientMetadata.store
);
}

/** Provides LRU functionality for IndexedDB persistence. */
class IndexedDbLruDelegate implements ReferenceDelegate, LruDelegate {
private additionalReferences: ReferenceSet | null;

constructor(private readonly db: IndexedDbPersistence) {}

setInMemoryPins(inMemoryPins: ReferenceSet): void {
this.additionalReferences = inMemoryPins;
}

removeTarget(
txn: PersistenceTransaction,
queryData: QueryData
): PersistencePromise<void> {
const updated = queryData.copy({
sequenceNumber: txn.currentSequenceNumber
});
return this.db.getQueryCache().updateQueryData(txn, updated);
}

addReference(
txn: PersistenceTransaction,
key: DocumentKey
): PersistencePromise<void> {
return this.writeSentinelKey(txn, key);
}

removeReference(
txn: PersistenceTransaction,
key: DocumentKey
): PersistencePromise<void> {
return this.writeSentinelKey(txn, key);
}

removeMutationReference(
txn: PersistenceTransaction,
key: DocumentKey
): PersistencePromise<void> {
return this.writeSentinelKey(txn, key);
}

onLimboDocumentUpdated(
txn: PersistenceTransaction,
key: DocumentKey
): PersistencePromise<void> {
return this.writeSentinelKey(txn, key);
}

forEachTarget(
txn: PersistenceTransaction,
f: (q: QueryData) => void
): PersistencePromise<void> {
return this.db.getQueryCache().forEachTarget(txn, f);
}

forEachOrphanedDocumentSequenceNumber(
txn: PersistenceTransaction,
f: (sequenceNumber: ListenSequenceNumber) => void
): PersistencePromise<void> {
return this.db
.getQueryCache()
.forEachOrphanedDocument(txn, (docKey, sequenceNumber) =>
f(sequenceNumber)
);
}

getTargetCount(txn: PersistenceTransaction): PersistencePromise<number> {
return this.db.getQueryCache().getQueryCount(txn);
}

removeTargets(
txn: PersistenceTransaction,
upperBound: ListenSequenceNumber,
liveTargets: LiveTargets
): PersistencePromise<number> {
return this.db.getQueryCache().removeTargets(txn, upperBound, liveTargets);
}

removeOrphanedDocuments(
txn: PersistenceTransaction,
upperBound: ListenSequenceNumber
): PersistencePromise<number> {
let count = 0;
let p = PersistencePromise.resolve();
const iteration = this.db
.getQueryCache()
.forEachOrphanedDocument(txn, (docKey, sequenceNumber) => {
if (sequenceNumber <= upperBound) {
p = p.next(() => this.isPinned(txn, docKey)).next(isPinned => {
if (!isPinned) {
count++;
return this.removeOrphanedDocument(txn, docKey);
}
});
}
});
return iteration.next(() => p).next(() => count);
}

/**
* Clears a document from the cache. The document is assumed to be orphaned, so target-document
* associations are not queried. We remove it from the remote document cache, as well as remove
* its sentinel row.
*/
private removeOrphanedDocument(
txn: PersistenceTransaction,
docKey: DocumentKey
): PersistencePromise<void> {
return PersistencePromise.waitFor([
sentinelKeyStore(txn).delete(sentinelKey(docKey)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These sentinel keys are target_document entries, which are normally managed by the QueryCache, right? Can we preserve that (have a method on QueryCache to delete them)? I'm not excited about having multiple classes interacting with the same underlying IndexedDb stores. Seems like breaking that encapsulation increases the opportunity for bugs.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this a bit, I think I would actually go the other way. Currently, I have forEachOrphanedDocument implemented in IndexedDbQueryCache, which is the only place the query cache interacts with instances of SentinelRow. I think it would be better to confine access to the SentinelRow in the ReferenceDelegate, and let the query cache only worry about actual targets.

That way, the query cache almost* doesn't have to know anything about the sentinel rows, and only the reference delegate has to know about them being stored alongside normal target-document rows.

*: the query cache currently has a containsKey implementation that needs to disregard sentinel rows, although it doesn't need to know about the type, just that the targetId is 0. Once the lru stuff is fully ported, this method is only used in tests, and could be moved to a helper in test code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should also note that the ports already diverge a bit especially around this area, since each platform has a different way of interacting with on-disk data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still have mixed feelings about this. I liked having individual object stores owned by a specific component. That way it's easy for me to consider all of the interactions within that store together. I'm curious why we didn't just have a separate store for the sentinel rows, but I assume there was a good reason and that ship has probably sailed anyway.

So I'm fine with this as-is, except per my other comments I think we should avoid sentinelKeyStore() since it suggests there is a separate store, which is not the case.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason sentinel rows are interleaved with target-document rows is efficiency. The current algorithm for identifying orphaned documents is a scan. Using a separate store would require scanning that store, and then for each row, doing a lookup in the target-document store. FWIW, we tried this in sqlite on Android by adding a sequence number column to each remote document. It was faster to add a nullable column to the target-document many-to-many table that only had values where targetId == 0.

this.db.getRemoteDocumentCache().removeEntry(txn, docKey)
]);
}

/**
* Returns true if anything would prevent this document from being garbage collected, given that
* the document in question is not present in any targets and has a sequence number less than or
* equal to the upper bound for the collection run.
*/
private isPinned(
txn: PersistenceTransaction,
docKey: DocumentKey
): PersistencePromise<boolean> {
return this.additionalReferences!.containsKey(txn, docKey).next(
isPinned => {
if (isPinned) {
return PersistencePromise.resolve(true);
} else {
return this.mutationQueuesContainKey(txn, docKey);
}
}
);
}

/** Returns true if any mutation queue contains the given document. */
private mutationQueuesContainKey(
txn: PersistenceTransaction,
docKey: DocumentKey
): PersistencePromise<boolean> {
let found = false;
return IndexedDbPersistence.getStore<DbMutationQueueKey, DbMutationQueue>(
txn,
DbMutationQueue.store
)
.iterateAsync(userId => {
return mutationQueueContainsKey(txn, userId, docKey).next(
containsKey => {
if (containsKey) {
found = true;
}
return PersistencePromise.resolve(!containsKey);
}
);
})
.next(() => found);
}

private writeSentinelKey(
txn: PersistenceTransaction,
key: DocumentKey
): PersistencePromise<void> {
return sentinelKeyStore(txn).put(
sentinelRow(key, txn.currentSequenceNumber)
);
}
}

/**
* `SentinelRow` describes the schema of rows in the DbTargetDocument store that
* have TargetId === 0. The sequence number is an approximation of a last-used value
* for the document identified by the path portion of the key.
*/
export type SentinelRow = {
targetId: TargetId;
path: EncodedResourcePath;
sequenceNumber: ListenSequenceNumber;
};

/**
* The sentinel key store is the same as the DbTargetDocument store, but allows for
* reading and writing sequence numbers as part of the value stored.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems confusing to have two different names for the same store depending on how it's used. I'm also concerned that we seem to be describing schema outside of indexeddb_schema.ts. That should be the authoritative description of our entire persistence schema. This also seems to be a deviation from Android.

Is there another way to go about this?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the only reference the query cache had to sequence numbers being available on target-document entries. I think it would be nice to keep this knowledge separate: the query cache can deal in target-document associations, and the reference delegate can deal with the sentinel rows. This is now accomplished via SentinelRow being confined to the reference delegate. We could potentially have two different views of the schema defined in indexeddb_schema.ts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel pretty strongly that indexeddb_schema.ts should continue to be a complete and authoritative description of our schema. So sequenceNumber should be included there.

Having sentinelKey() and sentinelRow() helpers here seems useful and good (and consistent with Android), but sentinelKeyStore() strongly communicates to me that there's a separate object store for these sentinel rows, which is not what we've actually implemented. I'd be fine with moving them to a separate store, but if they're in the "targetDocuments" object store, calling it something else is confusing to me.

So can we drop SentinelRow and sentinelKeyStore() from here, and just define "sequenceNumber" as part of the DbTargetDocument schema?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main concern is that I don't want the query cache to have access to the sequence number. It's not present for any rows that the query cache would be dealing with. I've removed SentinelRow and sentinelKeyStore() though. I've added an assert to the constructor for DbTargetDocument.

function sentinelKeyStore(
txn: PersistenceTransaction
): SimpleDbStore<DbTargetDocumentKey, SentinelRow> {
return IndexedDbPersistence.getStore<DbTargetDocumentKey, SentinelRow>(
txn,
DbTargetDocument.store
);
}

function sentinelKey(key: DocumentKey): [TargetId, EncodedResourcePath] {
return [0, encode(key.path)];
}

/**
* @return A value suitable for writing in the sentinel key store.
*/
function sentinelRow(
key: DocumentKey,
sequenceNumber: ListenSequenceNumber
): SentinelRow {
return {
targetId: 0,
path: encode(key.path),
sequenceNumber
};
}
Loading