@toa.io/storages.mongodb 1.0.0-alpha.266 → 1.0.0-alpha.270

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/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [1.0.0-alpha.270](https://github.com/toa-io/toa/compare/v1.0.0-alpha.269...v1.0.0-alpha.270) (2026-08-31)
7
+
8
+
9
+ ### Performance Improvements
10
+
11
+ * **boot:** stop paying for what a composition does not need to start ([1619c27](https://github.com/toa-io/toa/commit/1619c2743072f4706939ce72f3074e615d26a91e))
12
+ * **storages.mongodb:** time the call instead of monitoring the command ([135f1a9](https://github.com/toa-io/toa/commit/135f1a97c753b1caed9291f85cea0521ef68b0e3))
13
+
14
+
15
+
16
+
17
+
6
18
  # [1.0.0-alpha.266](https://github.com/toa-io/toa/compare/v1.0.0-alpha.265...v1.0.0-alpha.266) (2026-08-29)
7
19
 
8
20
  **Note:** Version bump only for package @toa.io/storages.mongodb
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.266",
3
+ "version": "1.0.0-alpha.270",
4
4
  "description": "Toa MongoDB Storage Connector",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -20,12 +20,12 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@toa.io/conveyor": "1.0.0-alpha.254",
23
- "@toa.io/core": "1.0.0-alpha.266",
23
+ "@toa.io/core": "1.0.0-alpha.270",
24
24
  "@toa.io/generic": "1.0.0-alpha.254",
25
- "@toa.io/pointer": "1.0.0-alpha.263",
25
+ "@toa.io/pointer": "1.0.0-alpha.270",
26
26
  "mongodb": "7.2.0",
27
- "openspan": "1.0.0-alpha.264",
27
+ "openspan": "1.0.0-alpha.270",
28
28
  "saslprep": "1.0.3"
29
29
  },
30
- "gitHead": "0d2b83ff9ed6bf6d9fee7bf00f979b9ee38e9713"
30
+ "gitHead": "94400bdd411b4c25074ffcf58184580ddd060268"
31
31
  }
package/src/client.js CHANGED
@@ -11,7 +11,6 @@ const { Connector } = require('@toa.io/core')
11
11
  const { resolve } = require('@toa.io/pointer')
12
12
  const { ID } = require('./deployment')
13
13
  const { MongoClient } = require('mongodb')
14
- const { monitor } = require('./monitoring')
15
14
 
16
15
  /**
17
16
  * @type {Record<string, Promise<Instance>>}
@@ -113,8 +112,6 @@ class Client extends Connector {
113
112
  const client = new MongoClient(urls.join(','), OPTIONS)
114
113
  const hosts = urls.map((str) => new URL(str).host)
115
114
 
116
- monitor(client)
117
-
118
115
  console.info('Connecting to MongoDB', { address: hosts.join(', ') })
119
116
 
120
117
  await client.connect()
@@ -158,9 +155,14 @@ function getKey (db, urls) {
158
155
  return db + ':' + urls.sort().join(' ')
159
156
  }
160
157
 
158
+ /**
159
+ * `monitorCommands` is deliberately absent. It makes the driver materialize every reply
160
+ * eagerly to populate the monitoring event (`CommandSucceededEvent`), which defeats the
161
+ * lazy per-document deserialization a cursor exists for — a 100-document batch is then
162
+ * deserialized twice. `Storage` times its own calls instead.
163
+ */
161
164
  const OPTIONS = {
162
- ignoreUndefined: true,
163
- monitorCommands: true
165
+ ignoreUndefined: true
164
166
  }
165
167
 
166
168
  const ALREADY_EXISTS = 48
package/src/storage.js CHANGED
@@ -13,6 +13,9 @@ class Storage extends Connector {
13
13
  #collection
14
14
  #entity
15
15
 
16
+ /** @type {Map<string, object>} span options per driver method */
17
+ #spans = new Map()
18
+
16
19
  constructor (client, entity) {
17
20
  super()
18
21
 
@@ -29,6 +32,8 @@ class Storage extends Connector {
29
32
  async open () {
30
33
  this.#collection = this.#client.collection
31
34
 
35
+ this.#spans.clear()
36
+
32
37
  await this.index()
33
38
  }
34
39
 
@@ -40,9 +45,8 @@ class Storage extends Connector {
40
45
  if (query?.id === undefined && query?.options?.deleted !== true)
41
46
  criteria._deleted = null
42
47
 
43
- this.debug('findOne', { criteria, options })
44
-
45
- const record = await this.#collection.findOne(criteria, options)
48
+ const record = await this.command('findOne', { criteria, options },
49
+ () => this.#collection.findOne(criteria, options))
46
50
 
47
51
  return from(record)
48
52
  }
@@ -53,23 +57,20 @@ class Storage extends Connector {
53
57
  if (query?.options?.deleted !== true)
54
58
  criteria._deleted = null
55
59
 
56
- let cursor
57
-
58
- if (sample === undefined) {
59
- this.debug('find', { criteria, options })
60
+ const recordset = sample === undefined
61
+ ? await this.command('find', { criteria, options },
62
+ async () => await this.#collection.find(criteria, options).toArray())
63
+ : await this.aggregate(criteria, options, sample)
60
64
 
61
- cursor = this.#collection.find(criteria, options)
62
- } else {
63
- const pipeline = toPipeline(criteria, options, sample)
64
-
65
- this.debug('aggregate', { pipeline })
66
-
67
- cursor = this.#collection.aggregate(pipeline)
68
- }
65
+ return recordset.map((item) => from(item))
66
+ }
69
67
 
70
- const recordset = await cursor.toArray()
68
+ /** @private */
69
+ async aggregate (criteria, options, sample) {
70
+ const pipeline = toPipeline(criteria, options, sample)
71
71
 
72
- return recordset.map((item) => from(item))
72
+ return await this.command('aggregate', { pipeline },
73
+ async () => await this.#collection.aggregate(pipeline).toArray())
73
74
  }
74
75
 
75
76
  async stream (query = undefined) {
@@ -86,9 +87,8 @@ class Storage extends Connector {
86
87
  async add (entity) {
87
88
  const record = to(entity)
88
89
 
89
- this.debug('insertOne', { record })
90
-
91
- const result = await this.#collection.insertOne(record)
90
+ const result = await this.command('insertOne', { record },
91
+ () => this.#collection.insertOne(record))
92
92
 
93
93
  return result.acknowledged
94
94
  }
@@ -101,9 +101,8 @@ class Storage extends Connector {
101
101
 
102
102
  const record = to(entity)
103
103
 
104
- this.debug('findOneAndReplace', { criteria, record })
105
-
106
- const result = await this.#collection.findOneAndReplace(criteria, record)
104
+ const result = await this.command('findOneAndReplace', { criteria, record },
105
+ () => this.#collection.findOneAndReplace(criteria, record))
107
106
 
108
107
  return result !== null
109
108
  }
@@ -162,11 +161,9 @@ class Storage extends Connector {
162
161
 
163
162
  try {
164
163
  await client.withSession(async (session) => {
165
- await session.withTransaction(async () => {
166
- this.debug('bulkWrite', { operations: operations.length })
167
-
168
- await this.#collection.bulkWrite(operations, { session })
169
- })
164
+ await session.withTransaction(async () =>
165
+ await this.command('bulkWrite', { operations: operations.length },
166
+ async () => await this.#collection.bulkWrite(operations, { session })))
170
167
  })
171
168
 
172
169
  return true
@@ -197,9 +194,8 @@ class Storage extends Connector {
197
194
 
198
195
  options.returnDocument = ReturnDocument.AFTER
199
196
 
200
- this.debug('findOneAndUpdate', { criteria, update, options })
201
-
202
- const result = await this.#collection.findOneAndUpdate(criteria, update, options)
197
+ const result = await this.command('findOneAndUpdate', { criteria, update, options },
198
+ () => this.#collection.findOneAndUpdate(criteria, update, options))
203
199
 
204
200
  return from(result)
205
201
  }
@@ -215,10 +211,9 @@ class Storage extends Connector {
215
211
  options.upsert = true
216
212
  options.returnDocument = ReturnDocument.AFTER
217
213
 
218
- console.debug('Database query', { collection: this.#collection.collectionName, method: 'findOneAndUpdate', criteria, update, options })
219
-
220
214
  try {
221
- const result = await this.#collection.findOneAndUpdate(criteria, update, options)
215
+ const result = await this.command('findOneAndUpdate', { criteria, update, options },
216
+ () => this.#collection.findOneAndUpdate(criteria, update, options))
222
217
 
223
218
  if (result._deleted !== undefined && result._deleted !== null)
224
219
  return null
@@ -232,19 +227,18 @@ class Storage extends Connector {
232
227
  }
233
228
  }
234
229
 
230
+ /** A component does not start before this returns, so the indexes are created at once. */
235
231
  async index () {
236
- const indexes = []
232
+ const pending = []
237
233
 
238
- if (this.#entity.unique !== undefined) {
234
+ if (this.#entity.unique !== undefined)
239
235
  for (const [name, fields] of Object.entries(this.#entity.unique)) {
240
236
  const optional = this.getOptional(fields)
241
- const unique = await this.uniqueIndex(name, fields, optional)
242
237
 
243
- indexes.push(unique)
238
+ pending.push(this.uniqueIndex(name, fields, optional))
244
239
  }
245
- }
246
240
 
247
- if (this.#entity.index !== undefined) {
241
+ if (this.#entity.index !== undefined)
248
242
  for (const [suffix, declaration] of Object.entries(this.#entity.index)) {
249
243
  const name = 'index_' + suffix
250
244
  const fields = Object.fromEntries(Object.entries(declaration)
@@ -255,12 +249,12 @@ class Storage extends Connector {
255
249
 
256
250
  console.info('Creating index', { fields, options })
257
251
 
258
- await this.#collection.createIndex(fields, options)
252
+ pending.push(this.#collection.createIndex(fields, options)
259
253
  .catch((e) => console.warn('MongoDB index creation failed', { collection: this.#collection.collectionName, name, fields, error: e }))
260
-
261
- indexes.push(name)
254
+ .then(() => name))
262
255
  }
263
- }
256
+
257
+ const indexes = await Promise.all(pending)
264
258
 
265
259
  await this.removeObsoleteIndexes(indexes)
266
260
  }
@@ -322,6 +316,48 @@ class Storage extends Connector {
322
316
  return optional
323
317
  }
324
318
 
319
+ /**
320
+ * Names, logs and times a call into the driver. The driver's own command monitoring is
321
+ * off (see `client.js`), so this is where a query becomes a span.
322
+ *
323
+ * @private
324
+ */
325
+ async command (method, attributes, task) {
326
+ this.debug(method, attributes)
327
+
328
+ return console.span(this.span(method), task)
329
+ }
330
+
331
+ /**
332
+ * The span of a driver method is the same object every time: the collection is fixed
333
+ * for a storage, and nothing downstream writes to what it is given.
334
+ *
335
+ * @private
336
+ */
337
+ span (method) {
338
+ let options = this.#spans.get(method)
339
+
340
+ if (options === undefined) {
341
+ const collection = this.#collection.collectionName
342
+
343
+ options = {
344
+ name: `${method} ${collection}`,
345
+ kind: 'client',
346
+ // https://opentelemetry.io/docs/specs/semconv/database/mongodb/
347
+ attributes: {
348
+ 'db.system': 'mongodb',
349
+ 'db.namespace': this.#collection.dbName,
350
+ 'db.operation.name': method,
351
+ 'db.collection.name': collection
352
+ }
353
+ }
354
+
355
+ this.#spans.set(method, options)
356
+ }
357
+
358
+ return options
359
+ }
360
+
325
361
  debug (method, attributes) {
326
362
  console.debug('MongoDB query', {
327
363
  collection: this.#collection.collectionName,
package/src/monitoring.js DELETED
@@ -1,87 +0,0 @@
1
- 'use strict'
2
-
3
- const { create, current, record } = require('openspan')
4
-
5
- /**
6
- * Records MongoDB commands as client spans using the driver command monitoring events.
7
- * Requires the client to be created with `monitorCommands: true`.
8
- *
9
- * Commands executed outside of a sampled trace context are not recorded.
10
- *
11
- * @param {import('mongodb').MongoClient} client
12
- */
13
- function monitor (client) {
14
- /** @type {Map<number, { context: object, time: number, attributes: object }>} */
15
- const pending = new Map()
16
-
17
- client.on('commandStarted', (event) => {
18
- if (IGNORE.has(event.commandName))
19
- return
20
-
21
- const parent = current()
22
-
23
- if (parent === undefined || !parent.sampled)
24
- return
25
-
26
- if (pending.size >= CAPACITY) // should never happen
27
- pending.delete(pending.keys().next().value)
28
-
29
- // https://opentelemetry.io/docs/specs/semconv/database/mongodb/
30
- const attributes = {
31
- 'db.system': 'mongodb',
32
- 'db.namespace': event.databaseName,
33
- 'db.operation.name': event.commandName
34
- }
35
-
36
- const collection = event.command?.[event.commandName]
37
-
38
- if (typeof collection === 'string')
39
- attributes['db.collection.name'] = collection
40
-
41
- pending.set(event.requestId, {
42
- context: create(parent),
43
- time: Date.now(),
44
- attributes
45
- })
46
- })
47
-
48
- client.on('commandSucceeded', (event) => complete(event))
49
- client.on('commandFailed', (event) => complete(event, 'error'))
50
-
51
- function complete (event, status) {
52
- const started = pending.get(event.requestId)
53
-
54
- if (started === undefined)
55
- return
56
-
57
- pending.delete(event.requestId)
58
-
59
- const collection = started.attributes['db.collection.name']
60
-
61
- const span = {
62
- name: collection === undefined
63
- ? event.commandName
64
- : `${event.commandName} ${collection}`,
65
- traceId: started.context.traceId,
66
- spanId: started.context.spanId,
67
- parentId: started.context.parentId,
68
- kind: 'client',
69
- time: started.time,
70
- duration: event.duration,
71
- attributes: started.attributes,
72
- service: started.context.service
73
- }
74
-
75
- if (status !== undefined)
76
- span.status = status
77
-
78
- record(span)
79
- }
80
- }
81
-
82
- // internal commands, including authentication and connection management
83
- const IGNORE = new Set(['hello', 'ismaster', 'ping', 'endSessions', 'saslStart', 'saslContinue'])
84
-
85
- const CAPACITY = 1024
86
-
87
- exports.monitor = monitor
@@ -1,99 +0,0 @@
1
- 'use strict'
2
-
3
- const { EventEmitter } = require('node:events')
4
- const { create, exporting, run, traces } = require('openspan')
5
- const { monitor } = require('../src/monitoring')
6
-
7
- let client
8
- let spans
9
-
10
- beforeEach(() => {
11
- client = new EventEmitter()
12
- spans = []
13
-
14
- exporting([{ export: (span) => spans.push(span) }])
15
- monitor(client)
16
- })
17
-
18
- afterEach(() => {
19
- traces()
20
- })
21
-
22
- const started = {
23
- requestId: 1,
24
- commandName: 'find',
25
- databaseName: 'toa-dev',
26
- command: { find: 'pots', filter: {} }
27
- }
28
-
29
- const succeeded = { requestId: 1, commandName: 'find', duration: 1.5 }
30
- const failed = { requestId: 1, commandName: 'find', duration: 0.5, failure: 'boom' }
31
-
32
- it('should record a command as a client span', () => {
33
- const context = create()
34
-
35
- run(context, () => client.emit('commandStarted', started))
36
- client.emit('commandSucceeded', succeeded)
37
-
38
- expect(spans).toHaveLength(1)
39
-
40
- expect(spans[0]).toMatchObject({
41
- name: 'find pots',
42
- traceId: context.traceId,
43
- parentId: context.spanId,
44
- kind: 'client',
45
- duration: 1.5,
46
- attributes: {
47
- 'db.system': 'mongodb',
48
- 'db.namespace': 'toa-dev',
49
- 'db.operation.name': 'find',
50
- 'db.collection.name': 'pots'
51
- }
52
- })
53
-
54
- expect(spans[0].status).toBeUndefined()
55
- })
56
-
57
- it('should record failed commands with error status', () => {
58
- run(create(), () => client.emit('commandStarted', started))
59
- client.emit('commandFailed', failed)
60
-
61
- expect(spans).toHaveLength(1)
62
- expect(spans[0].status).toBe('error')
63
- })
64
-
65
- it('should not record commands outside of a trace context', () => {
66
- client.emit('commandStarted', started)
67
- client.emit('commandSucceeded', succeeded)
68
-
69
- expect(spans).toHaveLength(0)
70
- })
71
-
72
- it('should not record commands of unsampled traces', () => {
73
- const context = { ...create(), sampled: false }
74
-
75
- run(context, () => client.emit('commandStarted', started))
76
- client.emit('commandSucceeded', succeeded)
77
-
78
- expect(spans).toHaveLength(0)
79
- })
80
-
81
- it('should ignore internal commands', () => {
82
- run(create(), () => {
83
- client.emit('commandStarted', { ...started, commandName: 'hello', command: { hello: 1 } })
84
- })
85
-
86
- client.emit('commandSucceeded', { ...succeeded, commandName: 'hello' })
87
-
88
- expect(spans).toHaveLength(0)
89
- })
90
-
91
- it('should name spans without a collection by the command', () => {
92
- const start = { ...started, commandName: 'listIndexes', command: { listIndexes: 1 } }
93
-
94
- run(create(), () => client.emit('commandStarted', start))
95
- client.emit('commandSucceeded', { ...succeeded, commandName: 'listIndexes' })
96
-
97
- expect(spans).toHaveLength(1)
98
- expect(spans[0].name).toBe('listIndexes')
99
- })