Skip to content

Fix the ordering of the index-based lookup in getAll(keys) #6128

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 6 commits into from
Apr 12, 2022
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .changeset/nasty-hairs-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@firebase/firestore": patch
---

Fixes an issue during multi-document lookup that resulted in the IndexedDB error "The parameter is less than or equal to this cursor's".
27 changes: 22 additions & 5 deletions packages/firestore/src/local/indexeddb_remote_document_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,12 +611,29 @@ function dbCollectionGroupKey(
/* document id */ path.length > 0 ? path[path.length - 1] : ''
];
}

/**
* Comparator that compares document keys according to the primary key sorting
* used by the `DbRemoteDocumentDocument` store (by collection path and then
* document ID).
* used by the `DbRemoteDocumentDocument` store (by prefix path, collection id
* and then document ID).
*
* Visible for testing.
*/
function dbKeyComparator(l: DocumentKey, r: DocumentKey): number {
const cmp = l.path.length - r.path.length;
return cmp !== 0 ? cmp : DocumentKey.comparator(l, r);
export function dbKeyComparator(l: DocumentKey, r: DocumentKey): number {
const left = l.path.toArray();
const right = r.path.toArray();

let cmp = 0;
for (let i = 0; i < left.length - 2 && i < right.length - 2; ++i) {
cmp = primitiveComparator(left[i], right[i]);
if (cmp) {
return cmp;
}
}

return (
Copy link
Contributor

Choose a reason for hiding this comment

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

primitiveComparator returns number, not boolean..how this works is not obvious to me..

Can we make it more explicit?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

JS compares by "thruhiness" and any non-null number is considered true. We could have something like:

let cmp = compare()
if (cmp) return cmp;

cmp = compare()
if (cmp) return cmp;

cmp = compare()
if (cmp) return cmp;

That was my original code but it is pretty bloated. Let me know what you prefer.

Copy link
Contributor

Choose a reason for hiding this comment

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

I prefer the bloated way..

primitiveComparator(left.length, right.length) ||
primitiveComparator(left[left.length - 2], right[right.length - 2]) ||
primitiveComparator(left[left.length - 1], right[right.length - 1])
);
}
6 changes: 3 additions & 3 deletions packages/firestore/src/local/simple_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,8 @@ export class SimpleDbStore<
loadAll(): PersistencePromise<ValueType[]>;
/** Loads all elements for the index range from the object store. */
loadAll(range: IDBKeyRange): PersistencePromise<ValueType[]>;
/** Loads all elements ordered by the given index. */
loadAll(index: string): PersistencePromise<ValueType[]>;
/**
* Loads all elements from the object store that fall into the provided in the
* index range for the given index.
Expand Down Expand Up @@ -845,9 +847,7 @@ export class SimpleDbStore<
cursor.continue(controller.skipToKey);
}
};
}).next(() => {
return PersistencePromise.waitFor(results);
});
}).next(() => PersistencePromise.waitFor(results));
}

private options(
Expand Down
13 changes: 13 additions & 0 deletions packages/firestore/test/unit/local/remote_document_cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,19 @@ function genericRemoteDocumentCacheTests(
});
});

it('can set and read several documents with deeply nested keys', () => {
// This test verifies that the sorting works correctly in IndexedDB,
// which sorts by prefix path first.
// Repro of https://github.com/firebase/firebase-js-sdk/issues/6110
const keys = ['a/a/a/a/a/a/a/a', 'b/b/b/b/a/a', 'c/c/a/a', 'd/d'];
return cache
.addEntries(keys.map(k => doc(k, VERSION, DOC_DATA)))
.then(() => cache.getEntries(documentKeySet(...keys.map(k => key(k)))))
.then(read => {
expect(read.size).to.equal(keys.length);
});
});

it('can set and read several documents including missing document', () => {
const docs = [
doc(DOC_PATH, VERSION, DOC_DATA),
Expand Down
73 changes: 70 additions & 3 deletions packages/firestore/test/unit/local/simple_db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import { expect, use } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { Context } from 'mocha';

import { dbKeyComparator } from '../../../src/local/indexeddb_remote_document_cache';
import { PersistencePromise } from '../../../src/local/persistence_promise';
import {
SimpleDb,
SimpleDbSchemaConverter,
SimpleDbStore,
SimpleDbTransaction
} from '../../../src/local/simple_db';
import { DocumentKey } from '../../../src/model/document_key';
import { fail } from '../../../src/util/assert';
import { Code, FirestoreError } from '../../../src/util/error';

Expand Down Expand Up @@ -66,13 +68,16 @@ class TestSchemaConverter implements SimpleDbSchemaConverter {
fromVersion: number,
toVersion: number
): PersistencePromise<void> {
const objectStore = db.createObjectStore('users', { keyPath: 'id' });
objectStore.createIndex('age-name', ['age', 'name'], {
const userStore = db.createObjectStore('users', { keyPath: 'id' });
userStore.createIndex('age-name', ['age', 'name'], {
unique: false
});

// A store that uses arrays as keys.
db.createObjectStore('docs');
const docStore = db.createObjectStore('docs');
docStore.createIndex('path', ['prefixPath', 'collectionId', 'documentId'], {
unique: false
});
return PersistencePromise.resolve();
}
}
Expand Down Expand Up @@ -526,6 +531,68 @@ describe('SimpleDb', () => {
);
});

it('correctly sorts keys with nested arrays', async function (this: Context) {
// This test verifies that the sorting in IndexedDb matches
// `dbKeyComparator()`

const keys = [
'a/a/a/a/a/a/a/a/a/a',
'a/b/a/a/a/a/a/a/a/b',
'b/a/a/a/a/a/a/a/a/a',
'b/b/a/a/a/a/a/a/a/b',
'b/b/a/a/a/a/a/a',
'b/b/b/a/a/a/a/b',
'c/c/a/a/a/a',
'd/d/a/a',
'e/e'
].map(k => DocumentKey.fromPath(k));

interface ValueType {
prefixPath: string[];
collectionId: string;
documentId: string;
}

const expectedOrder = [...keys];
expectedOrder.sort(dbKeyComparator);

const actualOrder = await db.runTransaction(
this.test!.fullTitle(),
'readwrite',
['docs'],
txn => {
const store = txn.store<string[], ValueType>('docs');

const writes = keys.map(k => {
const path = k.path.toArray();
return store.put(k.path.toArray(), {
prefixPath: path.slice(0, path.length - 2),
collectionId: path[path.length - 2],
documentId: path[path.length - 1]
});
});

return PersistencePromise.waitFor(writes).next(() =>
store
.loadAll('path')
.next(keys =>
keys.map(k =>
DocumentKey.fromSegments([
...k.prefixPath,
k.collectionId,
k.documentId
])
)
)
);
}
);

expect(actualOrder.map(k => k.toString())).to.deep.equal(
expectedOrder.map(k => k.toString())
);
});

it('retries transactions', async function (this: Context) {
let attemptCount = 0;

Expand Down
18 changes: 0 additions & 18 deletions packages/firestore/test/unit/local/test_remote_document_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

import { SnapshotVersion } from '../../../src/core/snapshot_version';
import { IndexManager } from '../../../src/local/index_manager';
import { remoteDocumentCacheGetNewDocumentChanges } from '../../../src/local/indexeddb_remote_document_cache';
import { Persistence } from '../../../src/local/persistence';
import { PersistencePromise } from '../../../src/local/persistence_promise';
import { RemoteDocumentCache } from '../../../src/local/remote_document_cache';
Expand Down Expand Up @@ -139,23 +138,6 @@ export class TestRemoteDocumentCache {
);
}

getNewDocumentChanges(sinceReadTime: SnapshotVersion): Promise<{
changedDocs: MutableDocumentMap;
readTime: SnapshotVersion;
}> {
return this.persistence.runTransaction(
'getNewDocumentChanges',
'readonly',
txn => {
return remoteDocumentCacheGetNewDocumentChanges(
this.cache,
txn,
sinceReadTime
);
}
);
}

getSize(): Promise<number> {
return this.persistence.runTransaction('get size', 'readonly', txn =>
this.cache.getSize(txn)
Expand Down