Skip to content

Commit dbcf7d7

Browse files
authored
Allow batches to write to a nondescendant sublevel (#81)
Closes #80. Follow-up for #45.
1 parent aecca98 commit dbcf7d7

File tree

8 files changed

+137
-47
lines changed

8 files changed

+137
-47
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ Perform multiple _put_ and/or _del_ operations in bulk. Returns a promise. The `
316316

317317
Each operation must be an object with at least a `type` property set to either `'put'` or `'del'`. If the `type` is `'put'`, the operation must have `key` and `value` properties. It may optionally have `keyEncoding` and / or `valueEncoding` properties to encode keys or values with a custom encoding for just that operation. If the `type` is `'del'`, the operation must have a `key` property and may optionally have a `keyEncoding` property.
318318

319-
An operation of either type may also have a `sublevel` property, to prefix the key of the operation with the prefix of that sublevel. This allows atomically committing data to multiple sublevels. The given `sublevel` must be a descendant of `db`. Keys and values will be encoded by the sublevel, to the same effect as a `sublevel.batch(..)` call. In the following example, the first `value` will be encoded with `'json'` rather than the default encoding of `db`:
319+
An operation of either type may also have a `sublevel` property, to prefix the key of the operation with the prefix of that sublevel. This allows atomically committing data to multiple sublevels. The given `sublevel` must have the same _root_ (i.e. top-most) database as `db`. Keys and values will be encoded by the sublevel, to the same effect as a `sublevel.batch(..)` call. In the following example, the first `value` will be encoded with `'json'` rather than the default encoding of `db`:
320320

321321
```js
322322
const people = db.sublevel('people', { valueEncoding: 'json' })
@@ -579,14 +579,14 @@ Add a `put` operation to this chained batch, not committed until `write()` is ca
579579

580580
- `keyEncoding`: custom key encoding for this operation, used to encode the `key`.
581581
- `valueEncoding`: custom value encoding for this operation, used to encode the `value`.
582-
- `sublevel` (sublevel instance): act as though the `put` operation is performed on the given sublevel, to similar effect as `sublevel.batch().put(key, value)`. This allows atomically committing data to multiple sublevels. The given `sublevel` must be a descendant of `db`. The `key` will be prefixed with the prefix of the sublevel, and the `key` and `value` will be encoded by the sublevel (using the default encodings of the sublevel unless `keyEncoding` and / or `valueEncoding` are provided).
582+
- `sublevel` (sublevel instance): act as though the `put` operation is performed on the given sublevel, to similar effect as `sublevel.batch().put(key, value)`. This allows atomically committing data to multiple sublevels. The given `sublevel` must have the same _root_ (i.e. top-most) database as `chainedBatch.db`. The `key` will be prefixed with the prefix of the sublevel, and the `key` and `value` will be encoded by the sublevel (using the default encodings of the sublevel unless `keyEncoding` and / or `valueEncoding` are provided).
583583

584584
#### `chainedBatch.del(key[, options])`
585585

586586
Add a `del` operation to this chained batch, not committed until `write()` is called. This will throw a [`LEVEL_INVALID_KEY`](#errors) error if `key` is invalid. The optional `options` object may contain:
587587

588588
- `keyEncoding`: custom key encoding for this operation, used to encode the `key`.
589-
- `sublevel` (sublevel instance): act as though the `del` operation is performed on the given sublevel, to similar effect as `sublevel.batch().del(key)`. This allows atomically committing data to multiple sublevels. The given `sublevel` must be a descendant of `db`. The `key` will be prefixed with the prefix of the sublevel, and the `key` will be encoded by the sublevel (using the default key encoding of the sublevel unless `keyEncoding` is provided).
589+
- `sublevel` (sublevel instance): act as though the `del` operation is performed on the given sublevel, to similar effect as `sublevel.batch().del(key)`. This allows atomically committing data to multiple sublevels. The given `sublevel` must have the same _root_ (i.e. top-most) database as `chainedBatch.db`. The `key` will be prefixed with the prefix of the sublevel, and the `key` will be encoded by the sublevel (using the default key encoding of the sublevel unless `keyEncoding` is provided).
590590

591591
#### `chainedBatch.clear()`
592592

UPGRADING.md

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This document describes breaking changes and how to upgrade. For a complete list
1111
- [1.1. Callbacks have been removed](#11-callbacks-have-been-removed)
1212
- [1.2. Not found](#12-not-found)
1313
- [1.3. Not ready](#13-not-ready)
14-
- [1.4. Hooks](#14-hooks)
14+
- [1.4. Slower nested sublevels](#14-slower-nested-sublevels)
1515
- [1.5. Open before creating a chained batch](#15-open-before-creating-a-chained-batch)
1616
- [2. Private API](#2-private-api)
1717
- [2.1. Promises all the way](#21-promises-all-the-way)
@@ -136,39 +136,23 @@ Or simply:
136136
await db.get('example')
137137
```
138138

139-
#### 1.4. Hooks
139+
#### 1.4. Slower nested sublevels
140140

141-
This release adds [hooks](./README.md#hooks). To achieve this feature, two low-impact breaking changes have been made to nested sublevels. Nested sublevels, no matter their depth, were previously all connected to the same parent database rather than forming a tree. In the following example, the `colorIndex` sublevel would previously forward its operations directly to `db`:
141+
The internals of nested sublevels have been refactored for the benefit of [hooks](./README.md#hooks). Nested sublevels, no matter their depth, were previously all connected to the same parent database rather than forming a tree. In the following example, the `colorIndex` sublevel would previously forward its operations directly to `db`:
142142

143143
```js
144144
const indexes = db.sublevel('idx')
145145
const colorIndex = indexes.sublevel('colors')
146146
```
147147

148-
It will now forward its operations to `indexes`, which in turn forwards them to `db`. At each step, hooks and events are available to transform and react to data from a different perspective. Which comes at a (typically small) performance cost that increases with further nested sublevels. This decreased performance is the first breaking change and mainly affects sublevels nested at a depth of more than 2.
148+
It will now forward its operations to `indexes`, which in turn forwards them to `db`. At each step, hooks and events are available to transform and react to data from a different perspective. Which comes at a (typically small) performance cost that increases with further nested sublevels.
149149

150-
To optionally negate it, a new feature has been added to `db.sublevel(name)`: it now also accepts a `name` that is an array. If the `indexes` sublevel is only used to organize keys and not directly interfaced with, operations on `colorIndex` can be made faster by skipping `indexes`:
150+
To optionally negate that cost, a new feature has been added to `db.sublevel(name)`: it now also accepts a `name` that is an array. If the `indexes` sublevel is only used to organize keys and not directly interfaced with, operations on `colorIndex` can be made faster by skipping `indexes`:
151151

152152
```js
153153
const colorIndex = db.sublevel(['idx', 'colors'])
154154
```
155155

156-
The second breaking change is that if a `sublevel` is provided as an option to `db.batch()`, that sublevel must now be a descendant of `db`:
157-
158-
```js
159-
const colorIndex = indexes.sublevel('colors')
160-
const flavorIndex = indexes.sublevel('flavors')
161-
162-
// No longer works because colorIndex isn't a descendant of flavorIndex
163-
flavorIndex.batch([{ type: 'del', key: 'blue', sublevel: colorIndex }])
164-
165-
// OK
166-
indexes.batch([{ type: 'del', key: 'blue', sublevel: colorIndex }])
167-
168-
// OK
169-
db.batch([{ type: 'del', key: 'blue', sublevel: colorIndex }])
170-
```
171-
172156
#### 1.5. Open before creating a chained batch
173157

174158
It is no longer possible to create a chained batch while the database is opening. If you previously did:

abstract-chained-batch.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const combineErrors = require('maybe-combine-errors')
44
const ModuleError = require('module-error')
55
const { getOptions, emptyOptions, noop } = require('./lib/common')
6-
const { prefixDescendantKey } = require('./lib/prefixes')
6+
const { prefixDescendantKey, isDescendant } = require('./lib/prefixes')
77
const { PrewriteBatch } = require('./lib/prewrite-batch')
88

99
const kStatus = Symbol('status')
@@ -116,15 +116,25 @@ class AbstractChainedBatch {
116116
const keyEncoding = op.keyEncoding
117117
const preencodedKey = keyEncoding.encode(op.key)
118118
const keyFormat = keyEncoding.format
119-
const encodedKey = delegated ? prefixDescendantKey(preencodedKey, keyFormat, db, this.db) : preencodedKey
119+
120+
// If the sublevel is not a descendant then forward that option to the parent db
121+
// so that we don't erroneously add our own prefix to the key of the operation.
122+
const siblings = delegated && !isDescendant(op.sublevel, this.db) && op.sublevel !== this.db
123+
const encodedKey = delegated && !siblings
124+
? prefixDescendantKey(preencodedKey, keyFormat, db, this.db)
125+
: preencodedKey
126+
120127
const valueEncoding = op.valueEncoding
121128
const encodedValue = valueEncoding.encode(op.value)
122129
const valueFormat = valueEncoding.format
123130

124-
// Prevent double prefixing
125-
if (delegated) op.sublevel = null
131+
// Only prefix once
132+
if (delegated && !siblings) {
133+
op.sublevel = null
134+
}
126135

127-
if (this[kPublicOperations] !== null) {
136+
// If the sublevel is not a descendant then we shouldn't emit events
137+
if (this[kPublicOperations] !== null && !siblings) {
128138
// Clone op before we mutate it for the private API
129139
const publicOperation = Object.assign({}, op)
130140
publicOperation.encodedKey = encodedKey
@@ -139,7 +149,7 @@ class AbstractChainedBatch {
139149
}
140150

141151
this[kPublicOperations].push(publicOperation)
142-
} else if (this[kLegacyOperations] !== null) {
152+
} else if (this[kLegacyOperations] !== null && !siblings) {
143153
const legacyOperation = Object.assign({}, original)
144154

145155
legacyOperation.type = 'put'
@@ -149,7 +159,8 @@ class AbstractChainedBatch {
149159
this[kLegacyOperations].push(legacyOperation)
150160
}
151161

152-
op.key = this.db.prefixKey(encodedKey, keyFormat, true)
162+
// If we're forwarding the sublevel option then don't prefix the key yet
163+
op.key = siblings ? encodedKey : this.db.prefixKey(encodedKey, keyFormat, true)
153164
op.value = encodedValue
154165
op.keyEncoding = keyFormat
155166
op.valueEncoding = valueFormat

abstract-level.js

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const { DatabaseHooks } = require('./lib/hooks')
1313
const { PrewriteBatch } = require('./lib/prewrite-batch')
1414
const { EventMonitor } = require('./lib/event-monitor')
1515
const { getOptions, noop, emptyOptions, resolvedPromise } = require('./lib/common')
16-
const { prefixDescendantKey } = require('./lib/prefixes')
16+
const { prefixDescendantKey, isDescendant } = require('./lib/prefixes')
1717
const { DeferredQueue } = require('./lib/deferred-queue')
1818
const rangeOptions = require('./lib/range-options')
1919

@@ -603,18 +603,26 @@ class AbstractLevel extends EventEmitter {
603603
}
604604

605605
// Encode data for private API
606-
// TODO: benchmark a try/catch around this
607606
const keyEncoding = op.keyEncoding
608607
const preencodedKey = keyEncoding.encode(op.key)
609608
const keyFormat = keyEncoding.format
610-
const encodedKey = delegated ? prefixDescendantKey(preencodedKey, keyFormat, db, this) : preencodedKey
611609

612-
// Prevent double prefixing
613-
if (delegated) op.sublevel = null
610+
// If the sublevel is not a descendant then forward that option to the parent db
611+
// so that we don't erroneously add our own prefix to the key of the operation.
612+
const siblings = delegated && !isDescendant(op.sublevel, this) && op.sublevel !== this
613+
const encodedKey = delegated && !siblings
614+
? prefixDescendantKey(preencodedKey, keyFormat, db, this)
615+
: preencodedKey
616+
617+
// Only prefix once
618+
if (delegated && !siblings) {
619+
op.sublevel = null
620+
}
614621

615622
let publicOperation = null
616623

617-
if (enableWriteEvent) {
624+
// If the sublevel is not a descendant then we shouldn't emit events
625+
if (enableWriteEvent && !siblings) {
618626
// Clone op before we mutate it for the private API
619627
// TODO (future semver-major): consider sending this shape to private API too
620628
publicOperation = Object.assign({}, op)
@@ -629,7 +637,8 @@ class AbstractLevel extends EventEmitter {
629637
publicOperations[i] = publicOperation
630638
}
631639

632-
op.key = this.prefixKey(encodedKey, keyFormat, true)
640+
// If we're forwarding the sublevel option then don't prefix the key yet
641+
op.key = siblings ? encodedKey : this.prefixKey(encodedKey, keyFormat, true)
633642
op.keyEncoding = keyFormat
634643

635644
if (isPut) {
@@ -640,7 +649,7 @@ class AbstractLevel extends EventEmitter {
640649
op.value = encodedValue
641650
op.valueEncoding = valueFormat
642651

643-
if (enableWriteEvent) {
652+
if (enableWriteEvent && !siblings) {
644653
publicOperation.encodedValue = encodedValue
645654

646655
if (delegated) {

lib/prefixes.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
'use strict'
22

33
exports.prefixDescendantKey = function (key, keyFormat, descendant, ancestor) {
4-
// TODO: optimize
5-
// TODO: throw when ancestor is not descendant's ancestor?
64
while (descendant !== null && descendant !== ancestor) {
75
key = descendant.prefixKey(key, keyFormat, true)
86
descendant = descendant.parent
97
}
108

119
return key
1210
}
11+
12+
// Check if db is a descendant of ancestor
13+
// TODO: optimize, when used alongside prefixDescendantKey
14+
// which means we visit parents twice.
15+
exports.isDescendant = function (db, ancestor) {
16+
while (true) {
17+
if (db.parent == null) return false
18+
if (db.parent === ancestor) return true
19+
db = db.parent
20+
}
21+
}

lib/prewrite-batch.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const { prefixDescendantKey } = require('./prefixes')
3+
const { prefixDescendantKey, isDescendant } = require('./prefixes')
44

55
const kDb = Symbol('db')
66
const kPrivateOperations = Symbol('privateOperations')
@@ -40,14 +40,23 @@ class PrewriteBatch {
4040
const keyEncoding = op.keyEncoding
4141
const preencodedKey = keyEncoding.encode(op.key)
4242
const keyFormat = keyEncoding.format
43-
const encodedKey = delegated ? prefixDescendantKey(preencodedKey, keyFormat, db, this[kDb]) : preencodedKey
4443

45-
// Prevent double prefixing
46-
if (delegated) op.sublevel = null
44+
// If the sublevel is not a descendant then forward that option to the parent db
45+
// so that we don't erroneously add our own prefix to the key of the operation.
46+
const siblings = delegated && !isDescendant(op.sublevel, this[kDb]) && op.sublevel !== this[kDb]
47+
const encodedKey = delegated && !siblings
48+
? prefixDescendantKey(preencodedKey, keyFormat, db, this[kDb])
49+
: preencodedKey
50+
51+
// Only prefix once
52+
if (delegated && !siblings) {
53+
op.sublevel = null
54+
}
4755

4856
let publicOperation = null
4957

50-
if (this[kPublicOperations] !== null) {
58+
// If the sublevel is not a descendant then we shouldn't emit events
59+
if (this[kPublicOperations] !== null && !siblings) {
5160
// Clone op before we mutate it for the private API
5261
publicOperation = Object.assign({}, op)
5362
publicOperation.encodedKey = encodedKey
@@ -61,7 +70,8 @@ class PrewriteBatch {
6170
this[kPublicOperations].push(publicOperation)
6271
}
6372

64-
op.key = this[kDb].prefixKey(encodedKey, keyFormat, true)
73+
// If we're forwarding the sublevel option then don't prefix the key yet
74+
op.key = siblings ? encodedKey : this[kDb].prefixKey(encodedKey, keyFormat, true)
6575
op.keyEncoding = keyFormat
6676

6777
if (isPut) {

test/hooks/prewrite.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,4 +761,48 @@ module.exports = function (test, testCommon) {
761761
await Promise.all([batchBefore.close(), batchAfter.close()])
762762
return db.close()
763763
})
764+
765+
// See https://github.com/Level/abstract-level/issues/80
766+
test('prewrite hook function can write to nondescendant sublevel', async function (t) {
767+
t.plan(2)
768+
769+
const db = testCommon.factory()
770+
await db.open()
771+
772+
const books = db.sublevel('books', { valueEncoding: 'json' })
773+
const index = db.sublevel('authors', {
774+
// Use JSON, which normally doesn't make sense for keys but
775+
// helps to assert that there's no double encoding happening.
776+
keyEncoding: 'json'
777+
})
778+
779+
db.on('write', (ops) => {
780+
// Check that data is written to correct sublevels, specifically
781+
// !authors!Hesse~12 rather than !books!!authors!Hesse~12.
782+
t.same(ops.map(x => x.key), ['!books!12', '!authors!"Hesse~12"'])
783+
})
784+
785+
books.on('write', (ops) => {
786+
// Should not include the op of the index
787+
t.same(ops.map(x => x.key), ['12'])
788+
})
789+
790+
index.on('write', (ops) => {
791+
t.fail('Did not expect an event on index')
792+
})
793+
794+
books.hooks.prewrite.add(function (op, batch) {
795+
if (op.type === 'put') {
796+
batch.add({
797+
type: 'put',
798+
// Key structure is synthetic and not relevant to the test
799+
key: op.value.author + '~' + op.key,
800+
value: '',
801+
sublevel: index
802+
})
803+
}
804+
})
805+
806+
await books.put('12', { title: 'Siddhartha', author: 'Hesse' })
807+
})
764808
}

test/sublevel-test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ exports.all = function (test, testCommon) {
5656
const b = a.sublevel('b')
5757
const c = b.sublevel('c')
5858

59+
await Promise.all([a.open(), b.open(), c.open()])
60+
5961
// Note: may return a transcoder encoding
6062
const utf8 = db.keyEncoding('utf8')
6163

@@ -120,6 +122,27 @@ exports.all = function (test, testCommon) {
120122
t.same(await db.keys().all(), [], 'db has no entries')
121123
return db.close()
122124
})
125+
126+
// See https://github.com/Level/abstract-level/issues/80
127+
test(`${method} with nondescendant sublevel option`, async function (t) {
128+
const db = testCommon.factory()
129+
await db.open()
130+
131+
const a = db.sublevel('a')
132+
const b = db.sublevel('b')
133+
134+
await Promise.all([a.open(), b.open()])
135+
136+
// The b sublevel is not a descendant of a, so the sublevel option
137+
// has to be forwarded to db so that the key gets the correct prefix.
138+
if (method === 'batch') {
139+
await a.batch([{ type: 'put', key: 'k', value: 'v', sublevel: b }])
140+
} else {
141+
await a.batch().put('k', 'v', { sublevel: b }).write()
142+
}
143+
144+
t.same(await db.keys().all(), ['!b!k'], 'written to sublevel b')
145+
})
123146
}
124147

125148
for (const deferred of [false, true]) {

0 commit comments

Comments
 (0)