@toa.io/storages.mongodb 1.0.0-alpha.284 → 1.0.0-alpha.286

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/storages.mongodb",
3
- "version": "1.0.0-alpha.284",
3
+ "version": "1.0.0-alpha.286",
4
4
  "type": "module",
5
5
  "description": "Toa MongoDB Storage Connector",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -20,13 +20,13 @@
20
20
  "test": "echo \"Error: run tests from root\" && exit 1"
21
21
  },
22
22
  "dependencies": {
23
- "@toa.io/conveyor": "1.0.0-alpha.283",
24
- "@toa.io/core": "1.0.0-alpha.284",
25
- "@toa.io/generic": "1.0.0-alpha.283",
26
- "@toa.io/pointer": "1.0.0-alpha.283",
23
+ "@toa.io/conveyor": "1.0.0-alpha.286",
24
+ "@toa.io/core": "1.0.0-alpha.286",
25
+ "@toa.io/generic": "1.0.0-alpha.286",
26
+ "@toa.io/pointer": "1.0.0-alpha.286",
27
27
  "mongodb": "7.6.0",
28
- "openspan": "1.0.0-alpha.277",
28
+ "openspan": "1.0.0-alpha.286",
29
29
  "saslprep": "1.0.3"
30
30
  },
31
- "gitHead": "006755556879e91e8d68fa86753aa3b475bdf4bf"
31
+ "gitHead": "81f3d0688f17668b902be336d1749cc715edcdb6"
32
32
  }
package/src/client.js CHANGED
@@ -43,6 +43,15 @@ export class Client extends Connector {
43
43
  */
44
44
  transactional = false
45
45
 
46
+ /**
47
+ * The database this component's collections live in, which is where the migration state
48
+ * is kept as well.
49
+ *
50
+ * @public
51
+ * @type {import('mongodb').Db}
52
+ */
53
+ db
54
+
46
55
  /**
47
56
  * @private
48
57
  * @type {Locator}
@@ -71,7 +80,7 @@ export class Client extends Connector {
71
80
  * @param {Locator} locator
72
81
  * @param {boolean} [publishes] whether this component publishes anything
73
82
  */
74
- constructor (locator, publishes = false) {
83
+ constructor(locator, publishes = false) {
75
84
  super()
76
85
 
77
86
  this.locator = locator
@@ -84,7 +93,7 @@ export class Client extends Connector {
84
93
  * @override
85
94
  * @return {Promise<void>}
86
95
  */
87
- async open () {
96
+ async open() {
88
97
  const urls = await this.resolveURLs()
89
98
  const dbname = this.resolveDB()
90
99
 
@@ -101,6 +110,7 @@ export class Client extends Connector {
101
110
 
102
111
  const db = this.instance.client.db(dbname)
103
112
 
113
+ this.db = db
104
114
  this.collection = await collection(db, this.name)
105
115
  this.transactional = await transactional(db)
106
116
 
@@ -108,8 +118,10 @@ export class Client extends Connector {
108
118
 
109
119
  if (this.transactional) this.outbox = await collection(db, this.name + OUTBOX)
110
120
  else
111
- console.warn('MongoDB is not a replica set; events are emitted inline, without an outbox',
112
- { collection: this.name })
121
+ console.warn(
122
+ 'MongoDB is not a replica set; events are emitted inline, without an outbox',
123
+ { collection: this.name }
124
+ )
113
125
  }
114
126
 
115
127
  /**
@@ -122,9 +134,10 @@ export class Client extends Connector {
122
134
  * @param {(session: import('mongodb').ClientSession) => Promise<T>} fn
123
135
  * @return {Promise<T>}
124
136
  */
125
- async transaction (fn) {
137
+ async transaction(fn) {
126
138
  return this.instance.client.withSession(async (session) =>
127
- session.withTransaction(async () => fn(session)))
139
+ session.withTransaction(async () => fn(session))
140
+ )
128
141
  }
129
142
 
130
143
  /**
@@ -132,7 +145,7 @@ export class Client extends Connector {
132
145
  * @override
133
146
  * @return {Promise<void>}
134
147
  */
135
- async close () {
148
+ async close() {
136
149
  const instance = await INSTANCES[this.key]
137
150
 
138
151
  instance.count--
@@ -148,7 +161,7 @@ export class Client extends Connector {
148
161
  * @param {string[]} urls
149
162
  * @return {Promise<Instance>}
150
163
  */
151
- async createInstance (urls) {
164
+ async createInstance(urls) {
152
165
  const client = new MongoClient(urls.join(','), OPTIONS)
153
166
  const hosts = urls.map((str) => new URL(str).host)
154
167
 
@@ -166,9 +179,11 @@ export class Client extends Connector {
166
179
  * @private
167
180
  * @return {Promise<string[]>}
168
181
  */
169
- async resolveURLs () {
182
+ async resolveURLs() {
183
+ // Toa's own development stack is not on the conventional ports: the applications built on
184
+ // Toa are, and they share the machine. See CONTRIBUTING.md.
170
185
  if (process.env.TOA_DEV === '1') {
171
- return ['mongodb://developer:secret@localhost']
186
+ return ['mongodb://developer:secret@localhost:31020']
172
187
  } else {
173
188
  return await resolve(ID, this.locator.id)
174
189
  }
@@ -178,7 +193,7 @@ export class Client extends Connector {
178
193
  * @private
179
194
  * @return {string}
180
195
  */
181
- resolveDB () {
196
+ resolveDB() {
182
197
  if (process.env.TOA_CONTEXT !== undefined) {
183
198
  return process.env.TOA_CONTEXT
184
199
  }
@@ -191,14 +206,14 @@ export class Client extends Connector {
191
206
  }
192
207
  }
193
208
 
194
- function getKey (db, urls) {
209
+ function getKey(db, urls) {
195
210
  return db + ':' + urls.sort().join(' ')
196
211
  }
197
212
 
198
213
  /**
199
214
  * Concurrent pods race to create the same collection, and losing that race is not an error.
200
215
  */
201
- async function collection (db, name) {
216
+ async function collection(db, name) {
202
217
  try {
203
218
  return await db.createCollection(name)
204
219
  } catch (e) {
@@ -208,7 +223,7 @@ async function collection (db, name) {
208
223
  }
209
224
  }
210
225
 
211
- async function transactional (db) {
226
+ async function transactional(db) {
212
227
  try {
213
228
  const hello = await db.admin().command({ hello: 1 })
214
229
 
package/src/deployment.js CHANGED
@@ -7,7 +7,7 @@ export const deployment = (instances, annotation) => {
7
7
  return { variables }
8
8
  }
9
9
 
10
- function createRequest (instance) {
10
+ function createRequest(instance) {
11
11
  return {
12
12
  group: instance.locator.label,
13
13
  selectors: [instance.locator.id]
package/src/factory.js CHANGED
@@ -2,7 +2,7 @@ import { Client } from './client.js'
2
2
  import { Storage } from './storage.js'
3
3
 
4
4
  export class Factory {
5
- storage (locator, entity, options = {}) {
5
+ storage(locator, entity, options = {}) {
6
6
  const client = new Client(locator, options.outbox === true)
7
7
 
8
8
  return new Storage(client, entity)
@@ -0,0 +1,270 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { setTimeout as sleep } from 'node:timers/promises'
3
+
4
+ import { console } from 'openspan'
5
+
6
+ /**
7
+ * Applies a component's migrations to its collection, in the order they were declared, and
8
+ * records each one so that it is applied once for the database rather than once per replica.
9
+ *
10
+ * The record is also the lock: the row that says a migration ran is inserted before it runs,
11
+ * and MongoDB refuses the second insert of the same `_id`. Nothing else is needed to make the
12
+ * group agree, which is why this works where there is no Redis.
13
+ */
14
+ export class Migrations {
15
+ /** @type {import('mongodb').Collection} */
16
+ #collection
17
+
18
+ /** @type {import('mongodb').Collection} */
19
+ #state
20
+
21
+ /** @type {Array<{ id: string, steps: object[] }>} */
22
+ #list
23
+
24
+ /**
25
+ * @param {import('mongodb').Db} db
26
+ * @param {import('mongodb').Collection} collection the entity's own
27
+ * @param {Array<{ id: string, steps: object[] }>} list
28
+ */
29
+ constructor(db, collection, list) {
30
+ this.#collection = collection
31
+ this.#state = db.collection(STATE)
32
+ this.#list = list
33
+ }
34
+
35
+ /**
36
+ * A migration is applied only after the one before it, because a later one is written
37
+ * against what an earlier one leaves behind.
38
+ */
39
+ async run() {
40
+ for (const migration of this.#list) await this.#apply(migration)
41
+ }
42
+
43
+ /** @private */
44
+ async #apply(migration) {
45
+ const id = `${this.#collection.collectionName}:${migration.id}`
46
+
47
+ while (true) {
48
+ if (await this.#claim(id)) return await this.#run(id, migration)
49
+
50
+ const row = await this.#state.findOne({ _id: id })
51
+
52
+ // removed between the claim and the read; whoever did that wants it applied again
53
+ if (row === null) continue
54
+
55
+ if (row.state === DONE) return
56
+
57
+ const stale = Date.now() - row.heartbeat.getTime() > LEASE
58
+
59
+ if (!stale) {
60
+ await sleep(POLL)
61
+
62
+ continue
63
+ }
64
+
65
+ console.warn('Taking over an abandoned migration', {
66
+ migration: id,
67
+ owner: row.owner,
68
+ heartbeat: row.heartbeat
69
+ })
70
+
71
+ if (await this.#steal(id, row.heartbeat)) return await this.#run(id, migration)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Writes the row that says this replica is applying the migration. Answers whether it won.
77
+ *
78
+ * @private
79
+ */
80
+ async #claim(id) {
81
+ try {
82
+ await this.#state.insertOne({
83
+ _id: id,
84
+ state: RUNNING,
85
+ owner: OWNER,
86
+ started: new Date(),
87
+ heartbeat: new Date()
88
+ })
89
+
90
+ return true
91
+ } catch (error) {
92
+ if (error.code === ERR_DUPLICATE_KEY) return false
93
+
94
+ throw error
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Takes a claim whose owner stopped saying it was alive. The heartbeat it read is part of
100
+ * the criteria, so only one of several waiting replicas takes it.
101
+ *
102
+ * @private
103
+ */
104
+ async #steal(id, heartbeat) {
105
+ const result = await this.#state.updateOne(
106
+ { _id: id, state: RUNNING, heartbeat },
107
+ { $set: { owner: OWNER, heartbeat: new Date() } }
108
+ )
109
+
110
+ return result.modifiedCount === 1
111
+ }
112
+
113
+ /**
114
+ * Applies the steps and marks the migration done. A step that throws leaves the row as it
115
+ * is: the component does not start, and the next replica to reach a stale claim runs the
116
+ * migration again from its first step.
117
+ *
118
+ * @private
119
+ */
120
+ async #run(id, migration) {
121
+ console.info('Applying migration', { migration: id, steps: migration.steps.length })
122
+
123
+ const beat = setInterval(() => {
124
+ this.#state
125
+ .updateOne({ _id: id }, { $set: { heartbeat: new Date() } })
126
+ .catch((error) =>
127
+ console.warn('Migration heartbeat failed', { migration: id, error })
128
+ )
129
+ }, HEARTBEAT)
130
+
131
+ beat.unref?.()
132
+
133
+ try {
134
+ for (const step of migration.steps) await this.#step(id, step)
135
+
136
+ await this.#state.updateOne(
137
+ { _id: id },
138
+ { $set: { state: DONE, completed: new Date() } }
139
+ )
140
+ } finally {
141
+ clearInterval(beat)
142
+ }
143
+
144
+ console.info('Migration applied', { migration: id })
145
+ }
146
+
147
+ /** @private */
148
+ async #step(id, step) {
149
+ const verbs = Object.keys(step ?? {})
150
+
151
+ if (verbs.length !== 1 || !(verbs[0] in STEPS))
152
+ throw new Error(
153
+ `Migration '${id}' has a step that is not one of ` +
154
+ `${Object.keys(STEPS).join(', ')}: ${JSON.stringify(step)}`
155
+ )
156
+
157
+ await STEPS[verbs[0]](this.#collection, step[verbs[0]], id)
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Creates the index the step declares. Where the name is held by an index of a different shape,
163
+ * that one is dropped: the declaration is what the component is to run against, and refusing
164
+ * to reconcile is what left a database diverged from its manifest before migrations existed.
165
+ */
166
+ async function index(collection, { name, keys, ...rest }, id) {
167
+ if (name === undefined || keys === undefined)
168
+ throw new Error(`Migration '${id}' declares an index without a name or keys`)
169
+
170
+ const spec = Object.fromEntries(
171
+ Object.entries(keys).map(([field, direction]) => [
172
+ field,
173
+ DIRECTIONS[direction] ?? direction
174
+ ])
175
+ )
176
+
177
+ const options = { name }
178
+
179
+ for (const [key, value] of Object.entries(rest)) {
180
+ if (!(key in OPTIONS))
181
+ throw new Error(`Migration '${id}' declares an unknown index option '${key}'`)
182
+
183
+ options[OPTIONS[key]] = value
184
+ }
185
+
186
+ try {
187
+ await collection.createIndex(spec, options)
188
+ } catch (error) {
189
+ if (!CONFLICTS.includes(error.code)) throw error
190
+
191
+ console.info('Recreating an index whose declaration changed', { index: name })
192
+
193
+ await collection.dropIndex(name)
194
+ await collection.createIndex(spec, options)
195
+ }
196
+ }
197
+
198
+ async function dropIndex(collection, { name }, id) {
199
+ if (name === undefined)
200
+ throw new Error(`Migration '${id}' drops an index without a name`)
201
+
202
+ try {
203
+ await collection.dropIndex(name)
204
+ } catch (error) {
205
+ if (error.code !== ERR_INDEX_NOT_FOUND) throw error
206
+ }
207
+ }
208
+
209
+ async function update(collection, { filter, update: changeset }, id) {
210
+ if (changeset === undefined) throw new Error(`Migration '${id}' updates with nothing`)
211
+
212
+ const result = await collection.updateMany(filter ?? {}, changeset)
213
+
214
+ console.info('Migration updated records', {
215
+ migration: id,
216
+ records: result.modifiedCount
217
+ })
218
+ }
219
+
220
+ async function remove(collection, { filter }, id) {
221
+ if (filter === undefined)
222
+ throw new Error(
223
+ `Migration '${id}' deletes without a filter; pass {} to mean every record`
224
+ )
225
+
226
+ const result = await collection.deleteMany(filter)
227
+
228
+ console.info('Migration deleted records', {
229
+ migration: id,
230
+ records: result.deletedCount
231
+ })
232
+ }
233
+
234
+ const STEPS = { index, dropIndex, update, delete: remove }
235
+
236
+ const DIRECTIONS = { asc: 1, desc: -1, hash: 'hashed' }
237
+
238
+ /** what an index step may say, and what the driver calls it */
239
+ const OPTIONS = {
240
+ unique: 'unique',
241
+ sparse: 'sparse',
242
+ partial: 'partialFilterExpression',
243
+ ttl: 'expireAfterSeconds'
244
+ }
245
+
246
+ /**
247
+ * A component's namespace may not be `system`, so this cannot be a component's own collection;
248
+ * and MongoDB reserves the `system.` prefix, with a dot, which this is not.
249
+ */
250
+ export const STATE = 'system_migrations'
251
+
252
+ const RUNNING = 'running'
253
+ const DONE = 'done'
254
+
255
+ /** how long a claim outlives its last heartbeat before another replica may take it */
256
+ const LEASE = 30_000
257
+ const HEARTBEAT = 5_000
258
+ const POLL = 1_000
259
+
260
+ /**
261
+ * Which process holds a claim, and nothing more: it is read by whoever takes an abandoned
262
+ * one over, and told to whoever reads the collection. Random, because there is no identity
263
+ * a process is guaranteed to have — a host name says nothing about two replicas on one
264
+ * machine, and a pid is reused.
265
+ */
266
+ const OWNER = randomUUID()
267
+
268
+ const ERR_DUPLICATE_KEY = 11000
269
+ const ERR_INDEX_NOT_FOUND = 27
270
+ const CONFLICTS = [85, 86] // IndexOptionsConflict, IndexKeySpecsConflict
package/src/outbox.js CHANGED
@@ -12,18 +12,18 @@ export class Outbox {
12
12
 
13
13
  #retention
14
14
 
15
- constructor (collection) {
15
+ constructor(collection) {
16
16
  this.#collection = collection
17
17
  this.#retention = retention()
18
18
  }
19
19
 
20
20
  /** @param {import('mongodb').ClientSession} session */
21
- async insert (row, session) {
21
+ async insert(row, session) {
22
22
  await this.#collection.insertOne(to(row), { session })
23
23
  }
24
24
 
25
25
  /** @param {import('mongodb').ClientSession} session */
26
- async insertMany (rows, session) {
26
+ async insertMany(rows, session) {
27
27
  if (rows.length === 0) return
28
28
 
29
29
  await this.#collection.insertMany(rows.map(to), { session })
@@ -37,7 +37,7 @@ export class Outbox {
37
37
  * the order rows were written and a page is never read twice within a cycle — which matters
38
38
  * because a row stays unpublished in the database until the cycle that sent it marks it.
39
39
  */
40
- async pending (lanes, now, limit, after = undefined) {
40
+ async pending(lanes, now, limit, after = undefined) {
41
41
  const criteria = { lane: { $in: lanes }, published: false, pending: { $lte: now } }
42
42
 
43
43
  if (after !== undefined) criteria._id = { $gt: after }
@@ -55,18 +55,21 @@ export class Outbox {
55
55
  * One batched write for many events, which is why the ids are held in memory until the
56
56
  * tick rather than updated one by one.
57
57
  */
58
- async settle (ids) {
58
+ async settle(ids) {
59
59
  if (ids.length === 0) return
60
60
 
61
- await this.#collection.updateMany({ _id: { $in: ids } },
62
- { $set: { published: true, publishedAt: new Date() } })
61
+ await this.#collection.updateMany(
62
+ { _id: { $in: ids } },
63
+ { $set: { published: true, publishedAt: new Date() } }
64
+ )
63
65
  }
64
66
 
65
67
  /**
66
- * The entity collection's index management does not reach here, so this collection keeps
67
- * its own — including pruning, or a later change leaves the old index behind forever.
68
+ * These are the runtime's indexes, not the component's, so they are declared here rather
69
+ * than in a migration a component would have to write — including pruning, or a later
70
+ * change leaves the old index behind forever.
68
71
  */
69
- async index () {
72
+ async index() {
70
73
  const desired = {
71
74
  // holds only what is not published yet, so it stays at in-flight size
72
75
  outbox_pending: {
@@ -82,15 +85,54 @@ export class Outbox {
82
85
  }
83
86
 
84
87
  for (const { fields, options } of Object.values(desired))
85
- await this.#collection.createIndex(fields, options)
86
- .catch((e) => console.warn('MongoDB outbox index creation failed',
87
- { collection: this.#collection.collectionName, name: options.name, error: e }))
88
+ await this.#index(fields, options)
88
89
 
89
90
  await this.#prune(Object.keys(desired))
90
91
  }
91
92
 
93
+ /**
94
+ * The name of an index here is fixed, so changing what it is made of — a retention that
95
+ * changes `expireAfterSeconds`, say — leaves that name held by an index of the old shape,
96
+ * which MongoDB refuses to overwrite. The old one is dropped and the declared one made.
97
+ *
98
+ * @private
99
+ */
100
+ async #index(fields, options) {
101
+ try {
102
+ await this.#collection.createIndex(fields, options)
103
+ } catch (e) {
104
+ if (!CONFLICTS.includes(e.code))
105
+ return console.warn('MongoDB outbox index creation failed', {
106
+ collection: this.#collection.collectionName,
107
+ name: options.name,
108
+ error: e
109
+ })
110
+
111
+ console.info('Recreating an outbox index whose declaration changed', {
112
+ collection: this.#collection.collectionName,
113
+ name: options.name
114
+ })
115
+
116
+ await this.#drop(options.name)
117
+ await this.#collection.createIndex(fields, options)
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Concurrent replicas prune the same index, and losing that race is not an error.
123
+ *
124
+ * @private
125
+ */
126
+ async #drop(name) {
127
+ try {
128
+ await this.#collection.dropIndex(name)
129
+ } catch (e) {
130
+ if (e.code !== ERR_INDEX_NOT_FOUND) throw e
131
+ }
132
+ }
133
+
92
134
  /** @private */
93
- async #prune (desired) {
135
+ async #prune(desired) {
94
136
  let current
95
137
 
96
138
  try {
@@ -105,17 +147,19 @@ export class Outbox {
105
147
 
106
148
  if (obsolete.length === 0) return
107
149
 
108
- console.info('Removing obsolete outbox indexes',
109
- { collection: this.#collection.collectionName, indexes: obsolete.join(', ') })
150
+ console.info('Removing obsolete outbox indexes', {
151
+ collection: this.#collection.collectionName,
152
+ indexes: obsolete.join(', ')
153
+ })
110
154
 
111
- await Promise.all(obsolete.map((name) => this.#collection.dropIndex(name)))
155
+ await Promise.all(obsolete.map((name) => this.#drop(name)))
112
156
  }
113
157
  }
114
158
 
115
159
  const to = ({ id, ...rest }) => ({ _id: id, ...rest })
116
160
  const from = ({ _id, ...rest }) => ({ id: _id, ...rest })
117
161
 
118
- function retention () {
162
+ function retention() {
119
163
  const value = Number(process.env.TOA_OUTBOX_RETENTION)
120
164
 
121
165
  return Number.isNaN(value) || value < 0 ? RETENTION : value
@@ -123,3 +167,6 @@ function retention () {
123
167
 
124
168
  /** seconds a published row is kept as a change log before the TTL monitor reaps it */
125
169
  const RETENTION = 86400
170
+
171
+ const ERR_INDEX_NOT_FOUND = 27
172
+ const CONFLICTS = [85, 86] // IndexOptionsConflict, IndexKeySpecsConflict
package/src/record.js CHANGED
@@ -1,12 +1,11 @@
1
- export function to (entity) {
1
+ export function to(entity) {
2
2
  const { id, ...rest } = entity
3
3
 
4
4
  return /** @type {toa.mongodb.Record} */ { _id: id, ...rest }
5
5
  }
6
6
 
7
- export function from (record) {
8
- if (record === undefined || record === null)
9
- return null
7
+ export function from(record) {
8
+ if (record === undefined || record === null) return null
10
9
 
11
10
  const { _id, ...rest } = record
12
11