Skip to content

Commit 2d12199

Browse files
committed
Breaking: prefer backpressure over snapshot guarantees
Previously (in `level-js`) an iterator would keep reading in the background so as to keep the IndexedDB transaction alive and thus not see the data of simultaneous writes. I.e. it was reading from a snapshot in time. The downsides of that approach: - Memory usage on large databases - IndexedDB doesn't actually use a snapshot (Chrome used to) but rather a blocking transaction. Meaning you can't write while an iterator is reading. So instead, an iterator now reads a few entries ahead and then opens a new transaction on the next read. A "few" means all entries for `iterator.all()`, `size` amount of entries for `iterator.nextv(size)` and a hardcoded 100 entries for `iterator.next()`. Individual calls to those methods still have snapshot guarantees, but repeated calls do not. Reading should now be faster too, because it uses the `getAll()` and `getAllKeys()` methods of IndexedDB, instead of a cursor. This means multiple entries are transferred from IndexedDB to JS in a single turn of the JS event loop, rather than one turn per entry. Reverse iterators do still use a cursor and are therefor slower. To reflect the new behavior, `db.supports.snapshots` is now false. Ref Level/level-js#86
1 parent 7649dcd commit 2d12199

File tree

3 files changed

+155
-101
lines changed

3 files changed

+155
-101
lines changed

index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class BrowserLevel extends AbstractLevel {
3535

3636
super({
3737
encodings: { view: true },
38+
snapshots: false,
3839
createIfMissing: false,
3940
errorIfExists: false,
4041
seek: false
@@ -188,7 +189,7 @@ class BrowserLevel extends AbstractLevel {
188189
this[kOnComplete](req, callback)
189190
}
190191

191-
// TODO: implement key and value iterators, and nextv()
192+
// TODO: implement key and value iterators
192193
_iterator (options) {
193194
return new Iterator(this, this[kLocation], options)
194195
}

iterator.js

Lines changed: 146 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -4,137 +4,192 @@ const { AbstractIterator } = require('abstract-level')
44
const createKeyRange = require('./util/key-range')
55
const deserialize = require('./util/deserialize')
66

7-
const noop = function () {}
8-
const kCount = Symbol('count')
9-
const kCallback = Symbol('callback')
107
const kCache = Symbol('cache')
11-
const kCompleted = Symbol('completed')
12-
const kAborted = Symbol('aborted')
13-
const kError = Symbol('error')
14-
const kKeys = Symbol('keys')
15-
const kValues = Symbol('values')
16-
const kOnItem = Symbol('onItem')
17-
const kOnAbort = Symbol('onAbort')
18-
const kOnComplete = Symbol('onComplete')
19-
const kMaybeNext = Symbol('maybeNext')
8+
const kFinished = Symbol('finished')
9+
const kOptions = Symbol('options')
10+
const kPosition = Symbol('position')
11+
const kLocation = Symbol('location')
12+
const emptyOptions = {}
2013

2114
class Iterator extends AbstractIterator {
2215
constructor (db, location, options) {
2316
super(db, options)
2417

25-
this[kCount] = 0
26-
this[kCallback] = null
2718
this[kCache] = []
28-
this[kCompleted] = false
29-
this[kAborted] = false
30-
this[kError] = null
31-
this[kKeys] = options.keys
32-
this[kValues] = options.values
33-
34-
if (this.limit === 0) {
35-
this[kCompleted] = true
36-
return
19+
this[kFinished] = this.limit === 0
20+
this[kOptions] = options
21+
this[kPosition] = undefined
22+
this[kLocation] = location
23+
}
24+
25+
// Note: if called by _all() then size can be Infinity. This is an internal
26+
// detail; by design AbstractIterator.nextv() does not support Infinity.
27+
_nextv (size, options, callback) {
28+
if (this[kFinished]) {
29+
return this.nextTick(callback, null, [])
30+
} else if (this[kCache].length > 0) {
31+
// TODO: mixing next and nextv is not covered by test suite
32+
size = Math.min(size, this[kCache].length)
33+
return this.nextTick(callback, null, this[kCache].splice(0, size))
34+
}
35+
36+
// Adjust range by what we already visited
37+
if (this[kPosition] !== undefined) {
38+
if (this[kOptions].reverse) {
39+
this[kOptions].lt = this[kPosition]
40+
this[kOptions].lte = undefined
41+
} else {
42+
this[kOptions].gt = this[kPosition]
43+
this[kOptions].gte = undefined
44+
}
3745
}
3846

3947
let keyRange
4048

4149
try {
42-
keyRange = createKeyRange(options)
43-
} catch (e) {
50+
keyRange = createKeyRange(this[kOptions])
51+
} catch (_) {
4452
// The lower key is greater than the upper key.
4553
// IndexedDB throws an error, but we'll just return 0 results.
46-
this[kCompleted] = true
47-
return
54+
this[kFinished] = true
55+
return this.nextTick(callback, null, [])
4856
}
4957

50-
const transaction = db.db.transaction([location], 'readonly')
51-
const store = transaction.objectStore(location)
52-
const req = store.openCursor(keyRange, options.reverse ? 'prev' : 'next')
58+
const transaction = this.db.db.transaction([this[kLocation]], 'readonly')
59+
const store = transaction.objectStore(this[kLocation])
60+
const entries = []
5361

54-
req.onsuccess = (ev) => {
55-
const cursor = ev.target.result
56-
if (cursor) this[kOnItem](cursor)
57-
}
62+
if (!this[kOptions].reverse) {
63+
let keys
64+
let values
5865

59-
// If an error occurs (on the request), the transaction will abort.
60-
transaction.onabort = () => {
61-
this[kOnAbort](transaction.error || new Error('aborted by user'))
62-
}
66+
const complete = () => {
67+
// Wait for both requests to complete
68+
if (keys === undefined || values === undefined) return
6369

64-
transaction.oncomplete = () => {
65-
this[kOnComplete]()
66-
}
67-
}
70+
const length = Math.max(keys.length, values.length)
6871

69-
[kOnItem] (cursor) {
70-
this[kCache].push(cursor.key, cursor.value)
71-
72-
if (++this[kCount] < this.limit) {
73-
cursor.continue()
74-
}
72+
if (length === 0 || size === Infinity) {
73+
this[kFinished] = true
74+
} else {
75+
this[kPosition] = keys[length - 1]
76+
}
7577

76-
this[kMaybeNext]()
77-
}
78+
// Resize
79+
entries.length = length
7880

79-
[kOnAbort] (err) {
80-
this[kAborted] = true
81-
this[kError] = err
82-
this[kMaybeNext]()
83-
}
81+
// Merge keys and values
82+
for (let i = 0; i < length; i++) {
83+
const key = keys[i]
84+
const value = values[i]
8485

85-
[kOnComplete] () {
86-
this[kCompleted] = true
87-
this[kMaybeNext]()
88-
}
86+
entries[i] = [
87+
this[kOptions].keys && key !== undefined ? deserialize(key) : undefined,
88+
this[kOptions].values && value !== undefined ? deserialize(value) : undefined
89+
]
90+
}
8991

90-
[kMaybeNext] () {
91-
if (this[kCallback]) {
92-
this._next(this[kCallback])
93-
this[kCallback] = null
94-
}
95-
}
96-
97-
_next (callback) {
98-
if (this[kAborted]) {
99-
const err = this[kError]
100-
this[kError] = null
101-
this.nextTick(callback, err)
102-
} else if (this[kCache].length > 0) {
103-
let key = this[kCache].shift()
104-
let value = this[kCache].shift()
92+
maybeCommit(transaction)
93+
}
10594

106-
if (this[kKeys] && key !== undefined) {
107-
key = deserialize(key)
95+
// If keys were not requested and size is Infinity, we don't have to keep
96+
// track of position and can thus skip getting keys.
97+
if (this[kOptions].keys || size < Infinity) {
98+
store.getAllKeys(keyRange, size < Infinity ? size : undefined).onsuccess = (ev) => {
99+
keys = ev.target.result
100+
complete()
101+
}
108102
} else {
109-
key = undefined
103+
keys = []
104+
this.nextTick(complete)
110105
}
111106

112-
if (this[kValues] && value !== undefined) {
113-
value = deserialize(value)
107+
if (this[kOptions].values) {
108+
store.getAll(keyRange, size < Infinity ? size : undefined).onsuccess = (ev) => {
109+
values = ev.target.result
110+
complete()
111+
}
114112
} else {
115-
value = undefined
113+
values = []
114+
this.nextTick(complete)
115+
}
116+
} else {
117+
// Can't use getAll() in reverse, so use a slower cursor that yields one item at a time
118+
store.openCursor(keyRange, 'prev').onsuccess = (ev) => {
119+
const cursor = ev.target.result
120+
121+
if (cursor) {
122+
const { key, value } = cursor
123+
this[kPosition] = key
124+
125+
entries.push([
126+
this[kOptions].keys && key !== undefined ? deserialize(key) : undefined,
127+
this[kOptions].values && value !== undefined ? deserialize(value) : undefined
128+
])
129+
130+
if (entries.length < size) {
131+
cursor.continue()
132+
} else {
133+
maybeCommit(transaction)
134+
}
135+
} else {
136+
this[kFinished] = true
137+
}
116138
}
139+
}
140+
141+
// If an error occurs (on the request), the transaction will abort.
142+
transaction.onabort = () => {
143+
callback(transaction.error || new Error('aborted by user'))
144+
callback = null
145+
}
146+
147+
transaction.oncomplete = () => {
148+
callback(null, entries)
149+
callback = null
150+
}
151+
}
117152

153+
_next (callback) {
154+
if (this[kCache].length > 0) {
155+
const [key, value] = this[kCache].shift()
118156
this.nextTick(callback, null, key, value)
119-
} else if (this[kCompleted]) {
157+
} else if (this[kFinished]) {
120158
this.nextTick(callback)
121159
} else {
122-
this[kCallback] = callback
160+
// TODO: use 1 if this is the first _next() call (see classic-level)
161+
const size = Math.min(100, this.limit - this.count)
162+
163+
this._nextv(size, emptyOptions, (err, entries) => {
164+
if (err) return callback(err)
165+
this[kCache] = entries
166+
this._next(callback)
167+
})
123168
}
124169
}
125170

126-
_close (callback) {
127-
if (this[kAborted] || this[kCompleted]) {
128-
return this.nextTick(callback)
171+
_all (options, callback) {
172+
// TODO: mixing next and all is not covered by test suite
173+
const cache = this[kCache].splice(0, this[kCache].length)
174+
const size = this.limit - this.count - cache.length
175+
176+
if (size <= 0) {
177+
return this.nextTick(callback, null, cache)
129178
}
130179

131-
// Don't advance the cursor anymore, and the transaction will complete
132-
// on its own in the next tick. This approach is much cleaner than calling
133-
// transaction.abort() with its unpredictable event order.
134-
this[kOnItem] = noop
135-
this[kOnAbort] = callback
136-
this[kOnComplete] = callback
180+
this._nextv(size, emptyOptions, (err, entries) => {
181+
if (err) return callback(err)
182+
if (cache.length > 0) entries = cache.concat(entries)
183+
callback(null, entries)
184+
})
137185
}
138186
}
139187

140188
exports.Iterator = Iterator
189+
190+
function maybeCommit (transaction) {
191+
// Commit (meaning close) now instead of waiting for auto-commit
192+
if (typeof transaction.commit === 'function') {
193+
transaction.commit()
194+
}
195+
}

util/key-range.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,17 @@
22

33
'use strict'
44

5-
const kNone = Symbol('none')
6-
75
module.exports = function createKeyRange (options) {
8-
const lower = 'gte' in options ? options.gte : 'gt' in options ? options.gt : kNone
9-
const upper = 'lte' in options ? options.lte : 'lt' in options ? options.lt : kNone
10-
const lowerExclusive = !('gte' in options)
11-
const upperExclusive = !('lte' in options)
6+
const lower = options.gte !== undefined ? options.gte : options.gt !== undefined ? options.gt : undefined
7+
const upper = options.lte !== undefined ? options.lte : options.lt !== undefined ? options.lt : undefined
8+
const lowerExclusive = options.gte === undefined
9+
const upperExclusive = options.lte === undefined
1210

13-
if (lower !== kNone && upper !== kNone) {
11+
if (lower !== undefined && upper !== undefined) {
1412
return IDBKeyRange.bound(lower, upper, lowerExclusive, upperExclusive)
15-
} else if (lower !== kNone) {
13+
} else if (lower !== undefined) {
1614
return IDBKeyRange.lowerBound(lower, lowerExclusive)
17-
} else if (upper !== kNone) {
15+
} else if (upper !== undefined) {
1816
return IDBKeyRange.upperBound(upper, upperExclusive)
1917
} else {
2018
return null

0 commit comments

Comments
 (0)