@toa.io/storages.mongodb 1.0.0-alpha.31 → 1.0.0-alpha.310

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/storage.js CHANGED
@@ -1,210 +1,577 @@
1
- 'use strict'
1
+ import { Connector, exceptions } from '@toa.io/core'
2
+ import { console } from 'openspan'
3
+ import { translate } from './translate.js'
4
+ import { codec } from './record.js'
5
+ import { Inbox } from './inbox.js'
6
+ import { Outbox } from './outbox.js'
7
+ import { Migrations } from './migrations.js'
8
+ import { conflicted, query } from './measurements.js'
9
+ import { ReturnDocument } from 'mongodb'
10
+
11
+ export class Storage extends Connector {
12
+ #client
13
+
14
+ /** @type {import('mongodb').Collection} */
15
+ #collection
16
+ #entity
2
17
 
3
- const {
4
- Connector,
5
- exceptions
6
- } = require('@toa.io/core')
18
+ /**
19
+ * @type {Outbox | undefined} absent when nothing consumes this component's events, or when
20
+ * the deployment cannot run transactions
21
+ */
22
+ #outbox
7
23
 
8
- const { translate } = require('./translate')
9
- const {
10
- to,
11
- from
12
- } = require('./record')
24
+ /**
25
+ * @type {Inbox | undefined} absent unless the component declares `once` somewhere; a
26
+ * deployment that cannot commit one with the entity does not boot, so this is present
27
+ * wherever it was asked for
28
+ */
29
+ #inbox
13
30
 
14
- class Storage extends Connector {
15
- #connection
16
- #entity
31
+ /** @type {Migrations | undefined} absent where the component declares no migrations */
32
+ #migrations
33
+
34
+ /** @type {Map<string, object>} span options per driver method */
35
+ #spans = new Map()
36
+
37
+ /** how a record is written and read back, which depends on what the entity declares */
38
+ #to
39
+ #from
17
40
 
18
- constructor (connection, entity) {
41
+ /** properties held as BSON dates, so that a criterion against one is one too */
42
+ #dates
43
+
44
+ constructor(client, entity) {
19
45
  super()
20
46
 
21
- this.#connection = connection
47
+ this.#client = client
22
48
  this.#entity = entity
23
49
 
24
- this.depends(connection)
50
+ const { to, from, dates } = codec(entity?.properties)
51
+
52
+ this.#to = to
53
+ this.#from = from
54
+ this.#dates = dates
55
+
56
+ this.depends(client)
57
+ }
58
+
59
+ get raw() {
60
+ return this.#collection
61
+ }
62
+
63
+ /**
64
+ * The outbox is offered only where a row can be committed atomically with the entity.
65
+ * Without that it would be a second write with a window in front of it — worse than the
66
+ * inline emission it replaces — so the storage simply does not advertise it.
67
+ */
68
+ get outbox() {
69
+ return this.#outbox
70
+ }
71
+
72
+ get inbox() {
73
+ return this.#inbox
74
+ }
75
+
76
+ /** This storage can record a call, given a deployment that can commit one. */
77
+ get claims() {
78
+ return true
79
+ }
80
+
81
+ /** This storage applies what a component's `migrations` directory declares. */
82
+ get migrates() {
83
+ return true
84
+ }
85
+
86
+ /** This storage converges. */
87
+ get converges() {
88
+ return true
89
+ }
90
+
91
+ async open() {
92
+ this.#collection = this.#client.collection
93
+
94
+ if (this.#client.outbox !== undefined) this.#outbox = new Outbox(this.#client.outbox)
95
+ if (this.#client.inbox !== undefined) this.#inbox = new Inbox(this.#client.inbox)
96
+
97
+ this.#spans.clear()
98
+
99
+ if (this.#entity.migrations?.length > 0) {
100
+ this.#migrations = new Migrations(
101
+ this.#client.db,
102
+ this.#collection,
103
+ this.#entity.migrations
104
+ )
105
+
106
+ await this.#migrations.run()
107
+ }
108
+
109
+ await this.#outbox?.index()
110
+ await this.#inbox?.index()
25
111
  }
26
112
 
27
- async open () {
28
- await this.index()
113
+ async get(query) {
114
+ const { criteria, options } = translate(query, this.#dates)
115
+
116
+ // identity lookups must return deleted records, so that callers
117
+ // can tell a deleted entity from a missing one
118
+ if (query?.id === undefined && query?.options?.deleted !== true)
119
+ criteria.DELETED = null
120
+
121
+ const record = await this.command('findOne', { criteria, options }, () =>
122
+ this.#collection.findOne(criteria, options)
123
+ )
124
+
125
+ return this.#from(record)
29
126
  }
30
127
 
31
- async get (query) {
32
- const {
33
- criteria,
34
- options
35
- } = translate(query)
128
+ async find(query) {
129
+ const { criteria, options, sample } = translate(query, this.#dates)
130
+
131
+ if (query?.options?.deleted !== true) criteria.DELETED = null
36
132
 
37
- const record = await this.#connection.get(criteria, options)
133
+ const recordset =
134
+ sample === undefined
135
+ ? await this.command(
136
+ 'find',
137
+ { criteria, options },
138
+ async () => await this.#collection.find(criteria, options).toArray()
139
+ )
140
+ : await this.aggregate(criteria, options, sample)
38
141
 
39
- return from(record)
142
+ return recordset.map((item) => this.#from(item))
40
143
  }
41
144
 
42
- async find (query) {
43
- const {
44
- criteria,
45
- options
46
- } = translate(query)
145
+ /** @private */
146
+ async aggregate(criteria, options, sample) {
147
+ const pipeline = toPipeline(criteria, options, sample)
47
148
 
48
- const recordset = await this.#connection.find(criteria, options)
149
+ return await this.command(
150
+ 'aggregate',
151
+ { pipeline },
152
+ async () => await this.#collection.aggregate(pipeline).toArray()
153
+ )
154
+ }
49
155
 
50
- return recordset.map((item) => from(item))
156
+ async stream(query = undefined) {
157
+ const { criteria, options } = translate(query, this.#dates)
158
+
159
+ if (query?.options?.deleted !== true) criteria.DELETED = null
160
+
161
+ this.debug('find (stream)', { criteria, options })
162
+
163
+ return this.#collection.find(criteria, options).stream({ transform: this.#from })
51
164
  }
52
165
 
53
- async add (entity) {
54
- const record = to(entity)
55
- const result = await this.#connection.add(record)
166
+ async add(entity, session = undefined) {
167
+ const record = this.#to(entity)
168
+
169
+ const result = await this.command('insertOne', { record }, () =>
170
+ this.#collection.insertOne(record, { session })
171
+ )
56
172
 
57
173
  return result.acknowledged
58
174
  }
59
175
 
60
- async set (entity) {
176
+ async set(entity, session = undefined) {
61
177
  const criteria = {
62
178
  _id: entity.id,
63
- _version: entity._version - 1
179
+ VERSION: entity.VERSION - 1
64
180
  }
65
- const result = await this.#connection.replace(criteria, to(entity))
181
+
182
+ const record = this.#to(entity)
183
+
184
+ const result = await this.command('findOneAndReplace', { criteria, record }, () =>
185
+ this.#collection.findOneAndReplace(criteria, record, { session })
186
+ )
187
+
188
+ // the version moved under this write: whoever asked either retries it or raises
189
+ if (result === null) conflicted(this.#collection.collectionName)
66
190
 
67
191
  return result !== null
68
192
  }
69
193
 
70
- async store (entity) {
194
+ async store(entity, row = undefined, call = undefined, attempt = 0) {
195
+ const writes = row !== undefined && this.#outbox !== undefined
196
+ const records = call !== undefined && this.#inbox !== undefined
197
+
198
+ let committed
199
+
71
200
  try {
72
- if (entity._version === 1) {
73
- return await this.add(entity)
74
- } else {
75
- return await this.set(entity)
201
+ if (!writes && !records) {
202
+ if (entity.VERSION === 1) return await this.add(entity)
203
+ else return await this.set(entity)
76
204
  }
77
- } catch (error) {
78
- if (error.code === ERR_DUPLICATE_KEY) {
79
205
 
80
- const id = error.keyPattern === undefined
81
- ? error.message.includes(' index: _id_ ') // AWS DocumentDB
82
- : error.keyPattern._id === 1
206
+ committed = await this.#client.transaction(async (session) => {
207
+ /*
208
+ * First, so that a call already made pays for nothing else: the duplicate key is the
209
+ * whole mechanism, and it takes the entity write down with it. Caught here rather than
210
+ * around the transaction, because here is where which key refused it is known — the
211
+ * driver's message is the only other thing that says, and it is worded differently by
212
+ * DocumentDB.
213
+ */
214
+ if (records)
215
+ try {
216
+ await this.#inbox.insert(call, session)
217
+ } catch (error) {
218
+ if (error?.code !== ERR_DUPLICATE_KEY) throw error
219
+
220
+ await session.abortTransaction()
221
+
222
+ return MADE
223
+ }
224
+
225
+ const ok =
226
+ entity.VERSION === 1
227
+ ? await this.add(entity, session)
228
+ : await this.set(entity, session)
229
+
230
+ // a lost compare-and-swap must take the row down with it, or a retried transition
231
+ // leaves a row for a write that never happened
232
+ if (!ok) {
233
+ await session.abortTransaction()
83
234
 
84
- if (id) {
85
235
  return false
86
- } else {
87
- throw new exceptions.DuplicateException()
88
236
  }
89
- } else {
90
- throw error
91
- }
237
+
238
+ if (writes) await this.#outbox.insert(row, session)
239
+
240
+ return true
241
+ })
242
+ } catch (error) {
243
+ console.error('MongoDB error', error)
244
+
245
+ const retry = await retriable(error, attempt)
246
+
247
+ if (retry) return await this.store(entity, row, call, attempt + 1)
248
+ else return false
92
249
  }
250
+
251
+ /*
252
+ * Outside the catch: the call has been made and what it changed is changed, which is not a
253
+ * driver failure and not worth another attempt. Raised rather than answered `false`, which
254
+ * means a lost compare-and-swap and *is* worth one.
255
+ */
256
+ if (committed === MADE) throw new exceptions.DuplicateCallException(call.id)
257
+
258
+ return committed === true
93
259
  }
94
260
 
95
- async upsert (query, changeset) {
96
- const {
97
- criteria,
98
- options
99
- } = translate(query)
261
+ async massStore(entities, rows = undefined, attempt = 0) {
262
+ if (entities.length === 0) return true
263
+
264
+ const operations = entities.map((entity) => {
265
+ const record = this.#to(entity)
266
+
267
+ if (entity.VERSION === 1) {
268
+ const { VERSION, ...rest } = record
269
+
270
+ return {
271
+ // upsert in required when document is deleted
272
+ updateOne: {
273
+ filter: { _id: entity.id },
274
+ update: {
275
+ $set: {
276
+ ...rest,
277
+ DELETED: null
278
+ },
279
+ $inc: { VERSION: 1 }
280
+ },
281
+ upsert: true
282
+ }
283
+ }
284
+ } else
285
+ return {
286
+ replaceOne: {
287
+ filter: { _id: entity.id, VERSION: entity.VERSION - 1 },
288
+ replacement: record
289
+ }
290
+ }
291
+ })
100
292
 
101
- if (!('_deleted' in changeset) || changeset._deleted === null) {
102
- delete criteria._deleted
103
- changeset._deleted = null
293
+ try {
294
+ await this.#client.transaction(async (session) => {
295
+ await this.command(
296
+ 'bulkWrite',
297
+ { operations: operations.length },
298
+ async () => await this.#collection.bulkWrite(operations, { session })
299
+ )
300
+
301
+ if (rows !== undefined && this.#outbox !== undefined)
302
+ await this.#outbox.insertMany(rows, session)
303
+ })
304
+
305
+ return true
306
+ } catch (error) {
307
+ console.error('MongoDB error', error)
308
+
309
+ const retry = await retriable(error, attempt)
310
+
311
+ if (retry) return await this.massStore(entities, rows, attempt + 1)
312
+ else return false
104
313
  }
314
+ }
105
315
 
106
- const update = {
107
- $set: { ...changeset },
108
- $inc: { _version: 1 }
316
+ /**
317
+ * The filter selects by `_id` alone, so it always matches what is there and upserts what is
318
+ * not, and the rule is the pipeline: either the incoming document replaces the stored one, or
319
+ * `$$ROOT` stays where it is and nothing changes. That keeps the three answers apart — a
320
+ * record never seen is an upsert, a superseded one is a modification, and a stale one is
321
+ * neither — where putting the rule in the filter would make absence and staleness one and the
322
+ * same miss, and then have the upsert collide on `_id` to say so.
323
+ *
324
+ * `$literal` because a value in a pipeline is an expression, so a property holding a string
325
+ * that begins with `$` would otherwise be read as a field path.
326
+ *
327
+ * `$ifNull` twice, and for two reasons. On the upsert path the pipeline runs over the base
328
+ * document the filter builds, which is `{ _id }`, so a missing `VERSION` reads as `0` — the
329
+ * version an entity holds before its first write. And a record that somehow reached here
330
+ * without a `REGION` reads as the first region, which is what the prototype's migration
331
+ * writes into one: without that, such a record would lose no tie and would beat every
332
+ * equal-version write from anywhere, silently and for good.
333
+ */
334
+ async converge(record) {
335
+ const document = this.#to(record)
336
+
337
+ const supersedes = {
338
+ $or: [
339
+ { $lt: [{ $ifNull: ['$VERSION', 0] }, document.VERSION] },
340
+ {
341
+ $and: [
342
+ { $eq: ['$VERSION', document.VERSION] },
343
+ { $gt: [{ $ifNull: ['$REGION', FIRST] }, document.REGION] }
344
+ ]
345
+ }
346
+ ]
109
347
  }
110
348
 
111
- options.returnDocument = 'after'
349
+ const pipeline = [
350
+ { $replaceWith: { $cond: [supersedes, { $literal: document }, '$$ROOT'] } }
351
+ ]
112
352
 
113
- const result = await this.#connection.update(criteria, update, options)
353
+ const result = await this.command(
354
+ 'updateOne',
355
+ { criteria: { _id: document._id }, pipeline },
356
+ () => this.#collection.updateOne({ _id: document._id }, pipeline, { upsert: true })
357
+ )
114
358
 
115
- return from(result)
359
+ return result.upsertedCount === 1 || result.modifiedCount === 1
116
360
  }
117
361
 
118
- async index () {
119
- const indexes = []
120
-
121
- if (this.#entity.unique !== undefined) {
122
- for (const [name, fields] of Object.entries(this.#entity.unique)) {
123
- const sparse = this.checkFields(fields)
124
- const unique = await this.uniqueIndex(name, fields, sparse)
362
+ async upsert(query, changeset, row = undefined, call = undefined) {
363
+ const records = call !== undefined && this.#inbox !== undefined
364
+ const { criteria, options } = translate(query, this.#dates)
125
365
 
126
- indexes.push(unique)
127
- }
366
+ if (!('DELETED' in changeset) || changeset.DELETED === null) {
367
+ delete criteria.DELETED
368
+ changeset.DELETED = null
128
369
  }
129
370
 
130
- if (this.#entity.index !== undefined) {
131
- for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
132
- const name = 'index_' + suffix
133
- const fields = Object.fromEntries(Object.entries(declaration)
134
- .map(([name, type]) => [name, INDEX_TYPES[type]]))
371
+ const update = {
372
+ $set: { ...changeset },
373
+ $inc: { VERSION: 1 }
374
+ }
135
375
 
136
- const sparse = this.checkFields(Object.keys(fields))
376
+ // BEFORE, so that the filter is applied once and atomically and the pre-image comes back
377
+ // with it — an assignment is the one event whose images are the write's own
378
+ options.returnDocument = ReturnDocument.BEFORE
379
+
380
+ const apply = async (session) => {
381
+ const found = await this.command(
382
+ 'findOneAndUpdate',
383
+ { criteria, update, options },
384
+ () => this.#collection.findOneAndUpdate(criteria, update, { ...options, session })
385
+ )
386
+
387
+ if (found === null) return null
388
+
389
+ const origin = this.#from(found)
390
+
391
+ /*
392
+ * The post-image is `update` applied to the pre-image, computed rather than read back.
393
+ * That is exact, not approximate: `$set` on top-level keys is a spread (entity property
394
+ * names cannot contain dots, so a changeset never carries a path), and `VERSION` is
395
+ * incremented by one. It is also a coupling — an operator added to `update` and not
396
+ * mirrored here diverges silently — which `features/events/outbox.feature` guards.
397
+ */
398
+ const state = { ...origin, ...changeset, VERSION: origin.VERSION + 1 }
399
+
400
+ // an assignment's event is the write's own images, so they are filled in here whether
401
+ // or not the row is going to be committed
402
+ if (row !== undefined) row.event = { origin, state, ...row.event }
403
+
404
+ /*
405
+ * The reply is what a duplicate is answered with, and an assignment answers the post-image
406
+ * where its algorithm named nothing — which is this, computed here and nowhere else. Held
407
+ * to a copy: the driver may run this again, and the call is the caller's object.
408
+ */
409
+ if (records) {
410
+ const reply =
411
+ call.reply?.output === undefined ? { ...call.reply, output: state } : call.reply
412
+
413
+ try {
414
+ await this.#inbox.insert({ id: call.id, reply }, session)
415
+ } catch (error) {
416
+ if (error?.code !== ERR_DUPLICATE_KEY) throw error
417
+
418
+ await session.abortTransaction()
419
+
420
+ return MADE
421
+ }
422
+ }
137
423
 
138
- await this.#connection.index(fields, {
139
- name,
140
- sparse
141
- })
424
+ if (row !== undefined && this.#outbox !== undefined)
425
+ await this.#outbox.insert(row, session)
142
426
 
143
- indexes.push(name)
144
- }
427
+ return state
145
428
  }
146
429
 
147
- await this.removeObsolete(indexes)
148
- }
149
-
150
- async uniqueIndex (name, properties, sparse = false) {
151
- const fields = properties.reduce((acc, property) => {
152
- acc[property] = 1
153
- return acc
154
- }, {})
430
+ if ((row === undefined || this.#outbox === undefined) && !records)
431
+ return apply(undefined)
155
432
 
156
- name = 'unique_' + name
433
+ const result = await this.#client.transaction(apply)
157
434
 
158
- await this.#connection.index(fields, {
159
- name,
160
- unique: true,
161
- sparse
162
- })
435
+ if (result === MADE) throw new exceptions.DuplicateCallException(call.id)
163
436
 
164
- return name
437
+ return result
165
438
  }
166
439
 
167
- async removeObsolete (desired) {
168
- const current = await this.#connection.indexes()
169
- const obsolete = current.filter((name) => !desired.includes(name))
440
+ async ensure(query, properties, state, row = undefined) {
441
+ let { criteria, options } = translate(query, this.#dates)
170
442
 
171
- if (obsolete.length > 0) {
172
- console.info(`Remove obsolete indexes: [${obsolete.join(', ')}]`)
443
+ if (query === undefined) criteria = properties
173
444
 
174
- await this.#connection.dropIndexes(obsolete)
445
+ const update = { $setOnInsert: this.#to(state) }
446
+
447
+ options.upsert = true
448
+ options.returnDocument = ReturnDocument.AFTER
449
+
450
+ try {
451
+ const result =
452
+ row === undefined || this.#outbox === undefined
453
+ ? await this.command('findOneAndUpdate', { criteria, update, options }, () =>
454
+ this.#collection.findOneAndUpdate(criteria, update, options)
455
+ )
456
+ : await this.#client.transaction(async (session) => {
457
+ const found = await this.command(
458
+ 'findOneAndUpdate',
459
+ { criteria, update, options },
460
+ () =>
461
+ this.#collection.findOneAndUpdate(criteria, update, {
462
+ ...options,
463
+ session
464
+ })
465
+ )
466
+
467
+ // only an insert is an event; finding an existing record is not
468
+ if (found !== null && found._id === state.id)
469
+ await this.#outbox.insert(row, session)
470
+
471
+ return found
472
+ })
473
+
474
+ if (result.DELETED !== undefined && result.DELETED !== null) return null
475
+ else return this.#from(result)
476
+ } catch (error) {
477
+ if (error.code === ERR_DUPLICATE_KEY)
478
+ throw new exceptions.DuplicateException(this.#client.name)
479
+ else throw error
175
480
  }
176
481
  }
177
482
 
178
- checkFields (fields) {
179
- const optional = []
483
+ /**
484
+ * Names, logs and times a call into the driver. The driver's own command monitoring is
485
+ * off (see `client.js`), so this is where a query becomes a span.
486
+ *
487
+ * @private
488
+ */
489
+ async command(method, attributes, task) {
490
+ this.debug(method, attributes)
180
491
 
181
- for (const field of fields) {
182
- if (!(field in this.#entity.schema.properties)) {
183
- throw new Error(`Index field '${field}' is not defined.`)
184
- }
492
+ return console.span(this.span(method), task)
493
+ }
185
494
 
186
- if (!this.#entity.schema.required?.includes(field)) {
187
- optional.push(field)
495
+ /**
496
+ * The span of a driver method is the same object every time: the collection is fixed
497
+ * for a storage, and nothing downstream writes to what it is given.
498
+ *
499
+ * @private
500
+ */
501
+ span(method) {
502
+ let options = this.#spans.get(method)
503
+
504
+ if (options === undefined) {
505
+ const collection = this.#collection.collectionName
506
+
507
+ options = {
508
+ name: `${method} ${collection}`,
509
+ kind: 'client',
510
+ // https://opentelemetry.io/docs/specs/semconv/database/mongodb/
511
+ attributes: {
512
+ 'db.system': 'mongodb',
513
+ 'db.namespace': this.#collection.dbName,
514
+ 'db.operation.name': method,
515
+ 'db.collection.name': collection
516
+ },
517
+ measure: query(collection, method)
188
518
  }
189
- }
190
519
 
191
- if (optional.length > 0) {
192
- console.info(`Index fields [${optional.join(', ')}] are optional, creating sparse index.`)
193
-
194
- return true
195
- } else {
196
- return false
520
+ this.#spans.set(method, options)
197
521
  }
522
+
523
+ return options
198
524
  }
199
525
 
526
+ debug(method, attributes) {
527
+ console.debug('MongoDB query', {
528
+ collection: this.#collection.collectionName,
529
+ method,
530
+ ...attributes
531
+ })
532
+ }
200
533
  }
201
534
 
202
- const INDEX_TYPES = {
203
- 'asc': 1,
204
- 'desc': -1,
205
- 'hash': 'hashed'
535
+ function toPipeline(criteria, options, sample) {
536
+ const pipeline = []
537
+
538
+ if (criteria !== undefined) pipeline.push({ $match: criteria })
539
+
540
+ if (sample !== undefined) pipeline.push({ $sample: { size: sample } })
541
+
542
+ if (options?.sort !== undefined) pipeline.push({ $sort: options.sort })
543
+
544
+ if (options?.projection !== undefined) pipeline.push({ $project: options.projection })
545
+
546
+ return pipeline
206
547
  }
207
548
 
549
+ /** the rank of the first region, which is what a record written before regions reads as */
550
+ const FIRST = 0
551
+
208
552
  const ERR_DUPLICATE_KEY = 11000
209
553
 
210
- exports.Storage = Storage
554
+ /** what the transaction answers where the call it carries has already been made */
555
+ const MADE = Symbol('made')
556
+
557
+ async function retriable(error, attempt) {
558
+ if (error.code === ERR_DUPLICATE_KEY) {
559
+ const id =
560
+ error.keyPattern === undefined
561
+ ? error.message.includes(' index: _id_ ') // AWS DocumentDB
562
+ : error.keyPattern._id === 1
563
+
564
+ if (id) return false
565
+ else throw new exceptions.DuplicateException()
566
+ } else if (error.cause?.code === 'ECONNREFUSED') {
567
+ if (attempt === LAST_ATTEMPT) throw error
568
+
569
+ const timeout = 1000 + 500 * attempt
570
+
571
+ await new Promise((resolve) => setTimeout(resolve, timeout))
572
+
573
+ return true
574
+ } else throw error
575
+ }
576
+
577
+ const LAST_ATTEMPT = 9