Skip to content
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

Switch to JS implementation of indexedDB.cmp(). #1412

Merged
merged 7 commits into from Oct 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/classes/collection/collection.ts
Expand Up @@ -12,6 +12,7 @@ import { hangsOnDeleteLargeKeyRange } from "../../globals/constants";
import { ThenShortcut } from "../../public/types/then-shortcut";
import { Transaction } from '../transaction';
import { DBCoreCursor, DBCoreTransaction, DBCoreRangeType, DBCoreMutateResponse, DBCoreKeyRange } from '../../public/types/dbcore';
import { cmp } from "../../functions/cmp";

/** class Collection
*
Expand Down Expand Up @@ -485,7 +486,6 @@ export class Collection implements ICollection {
const coreTable = ctx.table.core;
const {outbound, extractKey} = coreTable.schema.primaryKey;
const limit = this.db._options.modifyChunkSize || 200;
const {cmp} = this.db.core;
const totalFailures = [];
let successCount = 0;
const failedKeys: IndexableType[] = [];
Expand Down
2 changes: 2 additions & 0 deletions src/classes/dexie/dexie-static-props.ts
Expand Up @@ -22,6 +22,7 @@ import { globalEvents } from '../../globals/global-events';
import { liveQuery } from '../../live-query/live-query';
import { extendObservabilitySet } from '../../live-query/extend-observability-set';
import { domDeps } from './dexie-dom-dependencies';
import { cmp } from '../../functions/cmp';

/* (Dexie) is an instance of DexieConstructor, as defined in public/types/dexie-constructor.d.ts
* (new Dexie()) is an instance of Dexie, as defined in public/types/dexie.d.ts
Expand Down Expand Up @@ -186,6 +187,7 @@ props(Dexie, {
shallowClone: shallowClone,
deepClone: deepClone,
getObjectDiff: getObjectDiff,
cmp,
asap: asap,
//maxKey: new Dexie('',{addons:[]})._maxKey,
minKey: minKey,
Expand Down
2 changes: 1 addition & 1 deletion src/classes/dexie/generate-middleware-stacks.ts
Expand Up @@ -18,7 +18,7 @@ function createMiddlewareStacks(
tmpTrans: IDBTransaction): {[StackName in keyof DexieStacks]?: DexieStacks[StackName]}
{
const dbcore = createMiddlewareStack<DBCore>(
createDBCore(idbdb, indexedDB, IDBKeyRange, tmpTrans),
createDBCore(idbdb, IDBKeyRange, tmpTrans),
middlewares.dbcore);

// TODO: Create other stacks the same way as above. They might be dependant on the result
Expand Down
5 changes: 0 additions & 5 deletions src/dbcore/dbcore-indexeddb.ts
Expand Up @@ -39,12 +39,9 @@ export function getKeyPathAlias(keyPath: null | string | string[]) {

export function createDBCore (
db: IDBDatabase,
indexedDB: IDBFactory,
IdbKeyRange: typeof IDBKeyRange,
tmpTrans: IDBTransaction) : DBCore
{
const cmp = indexedDB.cmp.bind(indexedDB);
Copy link

Choose a reason for hiding this comment

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

Hello, i was very excited when i first read about those performance improvements, however when taking a look at the code i got some doubts.

  • Have to tried to perform those benchmarks without the use of Function.bind (?)
  • This JS version of cmp seems only faster for small amounts of data, and specially when the data isn't equal from the first few bytes (?)

Check this out:

b1=cryptHelper.getRandomValues(473)
b2=cryptHelper.getRandomValues(473)

a1 = () => indexedDB.cmp(b1,b2)
a2 = () => compareUint8Arrays(b1,b2)
> console.time('dd');for(let i = 1e6; i--; a1());console.timeEnd('dd');
< dd: 799.961181640625 ms

> console.time('dd');for(let i = 1e6; i--; a2());console.timeEnd('dd');
< dd: 5.804931640625 ms

All good here, with 473bytes of data and not equal.

b2=b1
> console.time('dd');for(let i = 1e6; i--; a1());console.timeEnd('dd');
< dd: 822.533203125 ms

> console.time('dd');for(let i = 1e6; i--; a2());console.timeEnd('dd');
< dd: 702.003173828125 ms

Still reasonable here when the data is equal, but...

b1=cryptHelper.getRandomValues(4730)
b2=b1

> console.time('dd');for(let i = 1e6; i--; a1());console.timeEnd('dd');
< dd: 1101.98681640625 ms

> console.time('dd');for(let i = 1e6; i--; a2());console.timeEnd('dd');
< dd: 8124.588134765625 ms

Thoughts? Not sure if i did overlooked something, those tests were made under Chrome 96 on Windows.


function extractSchema(db: IDBDatabase, trans: IDBTransaction) : {schema: DBCoreSchema, hasGetAll: boolean} {
const tables = arrayify(db.objectStoreNames);
return {
Expand Down Expand Up @@ -404,8 +401,6 @@ export function createDBCore (
return tableMap[name];
},

cmp,

MIN_KEY: -Infinity,

MAX_KEY: getMaxKey(IdbKeyRange),
Expand Down
4 changes: 1 addition & 3 deletions src/dbcore/keyrange.ts
@@ -1,12 +1,10 @@
import { domDeps } from '../classes/dexie/dexie-dom-dependencies';
import { getMaxKey } from '../functions/quirks';
import { DBCoreKeyRange, DBCoreRangeType } from '../public/types/dbcore';

export const AnyRange: DBCoreKeyRange = {
type: DBCoreRangeType.Any,
lower: -Infinity,
lowerOpen: false,
get upper() { return getMaxKey(domDeps.IDBKeyRange) },
upper: [[]],
upperOpen: false
}

Expand Down
99 changes: 85 additions & 14 deletions src/functions/cmp.ts
@@ -1,18 +1,89 @@
import { domDeps } from '../classes/dexie/dexie-dom-dependencies';
import { exceptions } from '../errors';
import { IndexableType } from '../public';
// Implementation of https://www.w3.org/TR/IndexedDB-3/#compare-two-keys

let _cmp: (a: any, b: any) => number;
export function cmp(a: IndexableType, b: IndexableType): number {
if (_cmp) return _cmp(a, b);
const {indexedDB} = domDeps;
if (!indexedDB) throw new exceptions.MissingAPI();
_cmp = (a, b) => {
try {
return a == null || b == null ? NaN : indexedDB.cmp(a, b);
} catch {
return NaN;
import { toStringTag } from './utils';

// ... with the adjustment to return NaN instead of throwing.
export function cmp(a: any, b: any): number {
try {
const ta = type(a);
const tb = type(b);
if (ta !== tb) {
if (ta === 'Array') return 1;
if (tb === 'Array') return -1;
if (ta === 'binary') return 1;
if (tb === 'binary') return -1;
if (ta === 'string') return 1;
if (tb === 'string') return -1;
if (ta === 'Date') return 1;
if (tb !== 'Date') return NaN;
return -1;
}
switch (ta) {
case 'number':
case 'Date':
case 'string':
return a > b ? 1 : a < b ? -1 : 0;
case 'binary': {
return compareUint8Arrays(getUint8Array(a), getUint8Array(b));
}
case 'Array':
return compareArrays(a, b);
}
} catch {}
return NaN; // Return value if any given args are valid keys.
}

export function compareArrays(a: any[], b: any[]): number {
const al = a.length;
const bl = b.length;
const l = al < bl ? al : bl;
for (let i = 0; i < l; ++i) {
const res = cmp(a[i], b[i]);
if (res !== 0) return res;
}
return al === bl ? 0 : al < bl ? -1 : 1;
}

export function compareUint8Arrays(
a: Uint8Array,
b: Uint8Array
) {
const al = a.length;
const bl = b.length;
const l = al < bl ? al : bl;
for (let i = 0; i < l; ++i) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
Copy link

Choose a reason for hiding this comment

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

...as a follow up to my previous comment, here's an experiment with DataView

function compareUint8Arrays(a,b) {
    const dv1 = new DataView(a.buffer);
    const dv2 = new DataView(b.buffer);
    let idx = 0;
    let bytes = Math.min(dv1.byteLength, dv2.byteLength);
    if (bytes > 4) {
        let len = bytes >> 2;
        while (idx < len) {
            let b1 = dv1.getUint32(idx);
            let b2 = dv2.getUint32(idx);
            if (b1 !== b2) return b1 < b2 ? -1 : 1;
            ++idx;
        }
        bytes -= idx << 2;
    }
    if (bytes > 2) {
        let len = bytes >> 1;
        while (idx < len) {
            let b1 = dv1.getUint16(idx);
            let b2 = dv2.getUint16(idx);
            if (b1 !== b2) return b1 < b2 ? -1 : 1;
            ++idx;
        }
        bytes -= idx << 1;
    }
    while (idx < bytes) {
        let b1 = dv1.getUint8(idx);
        let b2 = dv2.getUint8(idx);
        if (b1 !== b2) return b1 < b2 ? -1 : 1;
        ++idx;
    }
    return dv1.byteLength === dv2.byteLength ? 0 : dv1.byteLength < dv2.byteLength ? -1 : 1;
}
b1=cryptHelper.getRandomValues(4730)
b2=b1

> console.time('dd');for(let i = 1e6; i--; a2());console.timeEnd('dd');
< dd: 1382.230224609375 ms

... 83% faster with 4KB of data (?) but still slower than indexedDB.cmp

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If I get it right, the conclusion of all this is that:

  • JS based comparison still beats native one by far for numbers and strings (and probably Dates and reasonably short arrays)
  • The only situation where we can see native indexedDB.cmp beat the JS based one is when comparing large binaries that have an identical start sequence in their first 500 bytes.
  • Binary keys still perform much better with the JS based algorithm than the native one as long as they are reasonably short or have reasonable randomness.

Now, when we are comparing keys, we only do it for indexed keys and properties and these shall not be large anyway (see #1392) and there is almost never any good reason to index large binaries. A typical binary index represent either a primary key of maybe 16 (random) bytes, or digests that may be between 8-32 bytes and have the same randomness. There might be other use cases, like indexing paths of tree structures that may be longer, and having identical start sequences, but probably never that large. Indexing binaries larger than say 1500 bytes would create other performance issues anyway because it bloats the BTree in the database - which is the reason that most databases limit the permitted size of BTree indexes to 1700-3500 bytes depending on DB engine.

}
return _cmp(a, b);
return al === bl ? 0 : al < bl ? -1 : 1;
}

// Implementation of https://www.w3.org/TR/IndexedDB-3/#key-type
function type(x: any) {
const t = typeof x;
if (t !== 'object') return t;
if (ArrayBuffer.isView(x)) return 'binary';
const tsTag = toStringTag(x); // Cannot use instanceof in Safari
return tsTag === 'ArrayBuffer' ? 'binary' : (tsTag as 'Array' | 'Date');
}

type BinaryType =
| ArrayBuffer
| DataView
| Uint8ClampedArray
| ArrayBufferView
| Uint8Array
| Int8Array
| Uint16Array
| Int16Array
| Uint32Array
| Int32Array
| Float32Array
| Float64Array;

function getUint8Array(a: BinaryType): Uint8Array {
if (a instanceof Uint8Array) return a;
if (ArrayBuffer.isView(a))
// TypedArray or DataView
return new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
return new Uint8Array(a); // ArrayBuffer
}
1 change: 0 additions & 1 deletion src/public/types/dbcore.d.ts
Expand Up @@ -167,7 +167,6 @@ export interface DBCore {
transaction(stores: string[], mode: 'readonly' | 'readwrite', options?: DbCoreTransactionOptions): DBCoreTransaction;

// Utility methods
cmp(a: any, b: any) : number;
readonly MIN_KEY: any;
readonly MAX_KEY: any;
readonly schema: DBCoreSchema;
Expand Down
1 change: 1 addition & 0 deletions test/tests-all.js
@@ -1,5 +1,6 @@
import Dexie from 'dexie';
Dexie.test = true; // Improve code coverage
import "./tests-cmp.js";
import "./tests-table.js";
import "./tests-chrome-transaction-durability.js";
import "./tests-collection.js";
Expand Down
150 changes: 150 additions & 0 deletions test/tests-cmp.js
@@ -0,0 +1,150 @@
import Dexie from 'dexie';
import { module, test, equal, ok } from 'QUnit';

function fillArrayBuffer(ab, val) {
const view = new Uint8Array(ab);
for (let i = 0; i < view.byteLength; ++i) {
view[i] = val;
}
}

module('cmp');

const { cmp } = Dexie;

test('it should support indexable types', () => {
// numbers
ok(cmp(1, 1) === 0, 'Equal numbers should return 0');
ok(cmp(1, 2) === -1, 'Less than numbers should return -1');
ok(cmp(-1, -2000) === 1, 'Greater than numbers should return 1');
// strings
ok(cmp('A', 'A') === 0, 'Equal strings should return 0');
ok(cmp('A', 'B') === -1, 'Less than strings should return -1');
ok(cmp('C', 'A') === 1, 'Greater than strings should return 1');
// Dates
ok(cmp(new Date(1), new Date(1)) === 0, 'Equal dates should return 0');
ok(cmp(new Date(1), new Date(2)) === -1, 'Less than dates should return -1');
ok(
cmp(new Date(1000), new Date(500)) === 1,
'Greater than dates should return 1'
);
// Arrays
ok(cmp([1, 2, '3'], [1, 2, '3']) === 0, 'Equal arrays should return 0');
ok(cmp([-1], [1]) === -1, 'Less than arrays should return -1');
ok(cmp([1], [-1]) === 1, 'Greater than arrays should return 1');
ok(cmp([1], [1, 0]) === -1, 'If second array is longer with same leading entries, return -1');
ok(cmp([1, 0], [1]) === 1, 'If first array is longer with same leading entries, return 1');
ok(cmp([1], [0,0]) === 1, 'If first array is shorter but has greater leading entries, return 1');
ok(cmp([0,0], [1]) === -1, 'If second array is shorter but has greater leading entries, return -1');

/* Binary types
| DataView
| Uint8ClampedArray
| Uint8Array
| Int8Array
| Uint16Array
| Int16Array
| Uint32Array
| Int32Array
| Float32Array
| Float64Array;
*/
const viewTypes = [
'DataView',
'Uint8ClampedArray',
'Uint8Array',
'Int8Array',
'Uint16Array',
'Uint32Array',
'Int32Array',
'Float32Array',
'Float64Array',
]
.map((typeName) => [typeName, self[typeName]])
.filter(([_, ctor]) => !!ctor); // Don't try to test types not supported by the browser

const zeroes1 = new ArrayBuffer(16);
const zeroes2 = new ArrayBuffer(16);
const ones = new ArrayBuffer(16);
fillArrayBuffer(zeroes1, 0);
fillArrayBuffer(zeroes2, 0);
fillArrayBuffer(ones, 1);

for (const [typeName, ArrayBufferView] of viewTypes) {
// Equals
let v1 = new ArrayBufferView(zeroes1);
let v2 = new ArrayBufferView(zeroes2);
ok(cmp(v1, v2) === 0, `Equal ${typeName}s should return 0`);
// Less than
v1 = new ArrayBufferView(zeroes1);
v2 = new ArrayBufferView(ones);
ok(cmp(v1, v2) === -1, `Less than ${typeName}s should return -1`);
// Less than
v1 = new ArrayBufferView(ones);
v2 = new ArrayBufferView(zeroes1);
ok(cmp(v1, v2) === 1, `Greater than ${typeName}s should return 1`);
}
});
test("it should respect IndexedDB's type order", () => {
const zoo = [
'meow',
1,
new Date(),
Infinity,
-Infinity,
new ArrayBuffer(1),
[[]],
];
const [minusInfinity, num, infinity, date, string, binary, array] =
zoo.sort(cmp);
equal(minusInfinity, -Infinity, 'Minus infinity is sorted first');
equal(num, 1, 'Numbers are sorted second');
equal(infinity, Infinity, 'Infinity is sorted third');
ok(date instanceof Date, 'Date is sorted fourth');
ok(typeof string === 'string', 'strings are sorted fifth');
ok(binary instanceof ArrayBuffer, 'binaries are sorted sixth');
ok(Array.isArray(array), 'Arrays are sorted seventh');
});

test('it should return NaN on invalid types', () => {
ok(
isNaN(cmp(1, { foo: 'bar' })),
'Comparing a number against an object returns NaN (would throw in indexedDB)'
);
ok(
isNaN(cmp({ foo: 'bar' }, 1)),
'Comparing an object against a number returns NaN also'
);
});

test('it should treat different binary types as if they were equal', () => {
const viewTypes = [
'DataView',
'Uint8ClampedArray',
'Uint8Array',
'Int8Array',
'Uint16Array',
'Uint32Array',
'Int32Array',
'Float32Array',
'Float64Array',
]
.map((typeName) => [typeName, self[typeName]])
.filter(([_, ctor]) => !!ctor); // Don't try to test types not supported by the browser

const zeroes1 = new ArrayBuffer(16);
const zeroes2 = new ArrayBuffer(16);
fillArrayBuffer(zeroes1, 0);
fillArrayBuffer(zeroes2, 0);

for (const [typeName, ArrayBufferView] of viewTypes) {
let v1 = new ArrayBufferView(zeroes1);
ok(cmp(v1, zeroes1) === 0, `Comparing ${typeName} with ArrayBuffer should return 0 if they have identical data`);
}
});

test('it should return NaN if comparing arrays where any item or sub array item includes an invalid key', ()=> {
ok(cmp([1, [[2, "3"]]], [1,[[2, "3"]]]) === 0, "It can deep compare arrays with valid keys (equals)");
ok(cmp([1, [[2, "3"]]], [1,[[2, 3]]]) === 1, "It can deep compare arrays with valid keys (greater than)");
ok(isNaN(cmp([1, [[2, 3]]], [1,[[{foo: "bar"}, 3]]])), "It returns NaN when any item in the any of the arrays are invalid keys");
});
7 changes: 6 additions & 1 deletion test/tests-misc.js
@@ -1,6 +1,6 @@
import Dexie from 'dexie';
import {module, stop, start, asyncTest, equal, deepEqual, ok} from 'QUnit';
import {resetDatabase, spawnedTest, promisedTest, supports} from './dexie-unittest-utils';
import {resetDatabase, spawnedTest, promisedTest, supports, isIE, isEdge} from './dexie-unittest-utils';

const async = Dexie.async;

Expand Down Expand Up @@ -321,6 +321,11 @@ asyncTest ("#1079 mapToClass", function(){
});

asyncTest("PR #1108", async ()=>{
if (isIE || isEdge) {
ok(true, "Disabling this test for IE and legacy Edge");
start();
return;
}
const origConsoleWarn = console.warn;
const warnings = [];
console.warn = function(msg){warnings.push(msg); return origConsoleWarn.apply(this, arguments)};
Expand Down