@toa.io/storages.mongodb 1.0.0-alpha.284 → 1.0.0-alpha.287
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 +3 -140
- package/package.json +7 -7
- package/src/client.js +29 -14
- package/src/deployment.js +1 -1
- package/src/factory.js +1 -1
- package/src/migrations.js +343 -0
- package/src/outbox.js +65 -18
- package/src/record.js +84 -4
- package/src/storage.js +171 -232
- package/src/system.js +44 -0
- package/src/translate/criteria.js +25 -12
- package/src/translate/options.js +15 -5
- package/src/translate.js +8 -11
- package/test/migrations.test.js +388 -0
- package/test/record.test.js +98 -8
- package/test/storage.test.js +71 -12
- package/test/translate.test.js +53 -2
- package/types/connection.d.ts +17 -11
- package/types/pointer.d.ts +4 -6
- package/types/record.d.ts +5 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,145 +3,8 @@
|
|
|
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.
|
|
6
|
+
# [1.0.0-alpha.287](https://github.com/toa-io/toa/compare/v1.0.0-alpha.286...v1.0.0-alpha.287) (2026-09-06)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
### Features
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# [1.0.0-alpha.283](https://github.com/toa-io/toa/compare/v1.0.0-alpha.282...v1.0.0-alpha.283) (2026-09-04)
|
|
15
|
-
|
|
16
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# [1.0.0-alpha.282](https://github.com/toa-io/toa/compare/v1.0.0-alpha.281...v1.0.0-alpha.282) (2026-09-03)
|
|
23
|
-
|
|
24
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# [1.0.0-alpha.278](https://github.com/toa-io/toa/compare/v1.0.0-alpha.277...v1.0.0-alpha.278) (2026-09-03)
|
|
31
|
-
|
|
32
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# [1.0.0-alpha.277](https://github.com/toa-io/toa/compare/v1.0.0-alpha.276...v1.0.0-alpha.277) (2026-09-03)
|
|
39
|
-
|
|
40
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# [1.0.0-alpha.274](https://github.com/toa-io/toa/compare/v1.0.0-alpha.273...v1.0.0-alpha.274) (2026-09-02)
|
|
47
|
-
|
|
48
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
# [1.0.0-alpha.273](https://github.com/toa-io/toa/compare/v1.0.0-alpha.272...v1.0.0-alpha.273) (2026-09-02)
|
|
55
|
-
|
|
56
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# [1.0.0-alpha.272](https://github.com/toa-io/toa/compare/v1.0.0-alpha.271...v1.0.0-alpha.272) (2026-09-01)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
* refactor(core)!: pump the outbox in one cycle ([bf590fe](https://github.com/toa-io/toa/commit/bf590fe8ed59c701b5fd1a91ba4874685b79242f))
|
|
66
|
-
* feat(core)!: commit events with the state that produced them ([1eb68cc](https://github.com/toa-io/toa/commit/1eb68cc435dbfa03faa16009fceb866693d22e1a)), closes [#20](https://github.com/toa-io/toa/issues/20)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
### BREAKING CHANGES
|
|
70
|
-
|
|
71
|
-
* `Storage.outbox.pending` takes a fourth argument, the id to
|
|
72
|
-
continue from.
|
|
73
|
-
|
|
74
|
-
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
75
|
-
* `event.changeset` is removed; use `origin` and `state`. `State`
|
|
76
|
-
takes an `Outbox` in place of an `Emission`. `difference` is dropped from
|
|
77
|
-
`@toa.io/generic` along with its last caller.
|
|
78
|
-
|
|
79
|
-
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# [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)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
### Performance Improvements
|
|
89
|
-
|
|
90
|
-
* **boot:** stop paying for what a composition does not need to start ([1619c27](https://github.com/toa-io/toa/commit/1619c2743072f4706939ce72f3074e615d26a91e))
|
|
91
|
-
* **storages.mongodb:** time the call instead of monitoring the command ([135f1a9](https://github.com/toa-io/toa/commit/135f1a97c753b1caed9291f85cea0521ef68b0e3))
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# [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)
|
|
98
|
-
|
|
99
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
# [1.0.0-alpha.265](https://github.com/toa-io/toa/compare/v1.0.0-alpha.264...v1.0.0-alpha.265) (2026-08-29)
|
|
106
|
-
|
|
107
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# [1.0.0-alpha.264](https://github.com/toa-io/toa/compare/v1.0.0-alpha.263...v1.0.0-alpha.264) (2026-08-29)
|
|
114
|
-
|
|
115
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
# [1.0.0-alpha.263](https://github.com/toa-io/toa/compare/v1.0.0-alpha.262...v1.0.0-alpha.263) (2026-08-29)
|
|
122
|
-
|
|
123
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# [1.0.0-alpha.262](https://github.com/toa-io/toa/compare/v1.0.0-alpha.261...v1.0.0-alpha.262) (2026-08-28)
|
|
130
|
-
|
|
131
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
# [1.0.0-alpha.259](https://github.com/toa-io/toa/compare/v1.0.0-alpha.258...v1.0.0-alpha.259) (2026-08-24)
|
|
138
|
-
|
|
139
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
# [1.0.0-alpha.257](https://github.com/toa-io/toa/compare/v1.0.0-alpha.256...v1.0.0-alpha.257) (2026-08-24)
|
|
146
|
-
|
|
147
|
-
**Note:** Version bump only for package @toa.io/storages.mongodb
|
|
10
|
+
* **mongodb:** a migration says what it is doing ([a917a81](https://github.com/toa-io/toa/commit/a917a81fdc94eb23fd73182c0edf891e6b2df843))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/storages.mongodb",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.287",
|
|
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.
|
|
24
|
-
"@toa.io/core": "1.0.0-alpha.
|
|
25
|
-
"@toa.io/generic": "1.0.0-alpha.
|
|
26
|
-
"@toa.io/pointer": "1.0.0-alpha.
|
|
23
|
+
"@toa.io/conveyor": "1.0.0-alpha.286",
|
|
24
|
+
"@toa.io/core": "1.0.0-alpha.287",
|
|
25
|
+
"@toa.io/generic": "1.0.0-alpha.286",
|
|
26
|
+
"@toa.io/pointer": "1.0.0-alpha.287",
|
|
27
27
|
"mongodb": "7.6.0",
|
|
28
|
-
"openspan": "1.0.0-alpha.
|
|
28
|
+
"openspan": "1.0.0-alpha.286",
|
|
29
29
|
"saslprep": "1.0.3"
|
|
30
30
|
},
|
|
31
|
-
"gitHead": "
|
|
31
|
+
"gitHead": "5c7944a70c16065ab6372072385cc1f02f5db77c"
|
|
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
|
|
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(
|
|
112
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
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
|
|
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,343 @@
|
|
|
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
|
+
let applied = 0
|
|
41
|
+
|
|
42
|
+
for (const migration of this.#list) if (await this.#apply(migration)) applied++
|
|
43
|
+
|
|
44
|
+
// debug, because the ordinary start has nothing to report: every migration was applied
|
|
45
|
+
// long ago by whoever started first. But a run that says nothing at all cannot be told
|
|
46
|
+
// from one that never looked
|
|
47
|
+
console.debug('Migrations checked', {
|
|
48
|
+
collection: this.#collection.collectionName,
|
|
49
|
+
declared: this.#list.length,
|
|
50
|
+
applied
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Answers whether this replica was the one that applied it.
|
|
56
|
+
*
|
|
57
|
+
* @private
|
|
58
|
+
*/
|
|
59
|
+
async #apply(migration) {
|
|
60
|
+
const id = `${this.#collection.collectionName}:${migration.id}`
|
|
61
|
+
|
|
62
|
+
/** when the wait for another replica was last reported */
|
|
63
|
+
let announced
|
|
64
|
+
|
|
65
|
+
while (true) {
|
|
66
|
+
if (await this.#claim(id)) {
|
|
67
|
+
await this.#run(id, migration)
|
|
68
|
+
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const row = await this.#state.findOne({ _id: id })
|
|
73
|
+
|
|
74
|
+
// removed between the claim and the read; whoever did that wants it applied again
|
|
75
|
+
if (row === null) continue
|
|
76
|
+
|
|
77
|
+
if (row.state === DONE) {
|
|
78
|
+
console.debug('Migration was applied already', { migration: id })
|
|
79
|
+
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const stale = Date.now() - row.heartbeat.getTime() > LEASE
|
|
84
|
+
|
|
85
|
+
if (!stale) {
|
|
86
|
+
/*
|
|
87
|
+
* The component does not serve until this returns, so a replica waiting here is a pod
|
|
88
|
+
* that is simply not up, with nothing anywhere saying why. On its own cadence rather
|
|
89
|
+
* than the poll's, which is a second.
|
|
90
|
+
*/
|
|
91
|
+
if (announced === undefined || Date.now() - announced > PROGRESS) {
|
|
92
|
+
announced = Date.now()
|
|
93
|
+
|
|
94
|
+
console.info('Waiting for another replica to apply a migration', {
|
|
95
|
+
migration: id,
|
|
96
|
+
owner: row.owner
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await sleep(POLL)
|
|
101
|
+
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.warn('Taking over an abandoned migration', {
|
|
106
|
+
migration: id,
|
|
107
|
+
owner: row.owner,
|
|
108
|
+
heartbeat: row.heartbeat
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (await this.#steal(id, row.heartbeat)) {
|
|
112
|
+
await this.#run(id, migration)
|
|
113
|
+
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Writes the row that says this replica is applying the migration. Answers whether it won.
|
|
121
|
+
*
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
async #claim(id) {
|
|
125
|
+
try {
|
|
126
|
+
await this.#state.insertOne({
|
|
127
|
+
_id: id,
|
|
128
|
+
state: RUNNING,
|
|
129
|
+
owner: OWNER,
|
|
130
|
+
started: new Date(),
|
|
131
|
+
heartbeat: new Date()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return true
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error.code === ERR_DUPLICATE_KEY) return false
|
|
137
|
+
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Takes a claim whose owner stopped saying it was alive. The heartbeat it read is part of
|
|
144
|
+
* the criteria, so only one of several waiting replicas takes it.
|
|
145
|
+
*
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
async #steal(id, heartbeat) {
|
|
149
|
+
const result = await this.#state.updateOne(
|
|
150
|
+
{ _id: id, state: RUNNING, heartbeat },
|
|
151
|
+
{ $set: { owner: OWNER, heartbeat: new Date() } }
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return result.modifiedCount === 1
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Applies the steps and marks the migration done. A step that throws leaves the row as it
|
|
159
|
+
* is: the component does not start, and the next replica to reach a stale claim runs the
|
|
160
|
+
* migration again from its first step.
|
|
161
|
+
*
|
|
162
|
+
* @private
|
|
163
|
+
*/
|
|
164
|
+
async #run(id, migration) {
|
|
165
|
+
const total = migration.steps.length
|
|
166
|
+
const started = Date.now()
|
|
167
|
+
|
|
168
|
+
console.info('Applying migration', { migration: id, steps: total })
|
|
169
|
+
|
|
170
|
+
const beat = setInterval(() => {
|
|
171
|
+
this.#state
|
|
172
|
+
.updateOne({ _id: id }, { $set: { heartbeat: new Date() } })
|
|
173
|
+
.catch((error) =>
|
|
174
|
+
console.warn('Migration heartbeat failed', { migration: id, error })
|
|
175
|
+
)
|
|
176
|
+
}, HEARTBEAT)
|
|
177
|
+
|
|
178
|
+
beat.unref?.()
|
|
179
|
+
|
|
180
|
+
let applying = 0
|
|
181
|
+
|
|
182
|
+
/*
|
|
183
|
+
* A backfill takes as long as the collection is large, and every replica of the group waits
|
|
184
|
+
* out the whole of it. On its own cadence rather than the heartbeat's, which is every five
|
|
185
|
+
* seconds and would say this a dozen times a minute.
|
|
186
|
+
*/
|
|
187
|
+
const progress = setInterval(() => {
|
|
188
|
+
console.info('Migration is still being applied', {
|
|
189
|
+
migration: id,
|
|
190
|
+
step: applying,
|
|
191
|
+
steps: total,
|
|
192
|
+
elapsed: Date.now() - started
|
|
193
|
+
})
|
|
194
|
+
}, PROGRESS)
|
|
195
|
+
|
|
196
|
+
progress.unref?.()
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
for (const step of migration.steps) {
|
|
200
|
+
applying++
|
|
201
|
+
|
|
202
|
+
await this.#step(id, step)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await this.#state.updateOne(
|
|
206
|
+
{ _id: id },
|
|
207
|
+
{ $set: { state: DONE, completed: new Date() } }
|
|
208
|
+
)
|
|
209
|
+
} finally {
|
|
210
|
+
clearInterval(beat)
|
|
211
|
+
clearInterval(progress)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.info('Migration applied', { migration: id, elapsed: Date.now() - started })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @private */
|
|
218
|
+
async #step(id, step) {
|
|
219
|
+
const verbs = Object.keys(step ?? {})
|
|
220
|
+
|
|
221
|
+
if (verbs.length !== 1 || !(verbs[0] in STEPS))
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Migration '${id}' has a step that is not one of ` +
|
|
224
|
+
`${Object.keys(STEPS).join(', ')}: ${JSON.stringify(step)}`
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
await STEPS[verbs[0]](this.#collection, step[verbs[0]], id)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Creates the index the step declares. Where the name is held by an index of a different shape,
|
|
233
|
+
* that one is dropped: the declaration is what the component is to run against, and refusing
|
|
234
|
+
* to reconcile is what left a database diverged from its manifest before migrations existed.
|
|
235
|
+
*/
|
|
236
|
+
async function index(collection, { name, keys, ...rest }, id) {
|
|
237
|
+
if (name === undefined || keys === undefined)
|
|
238
|
+
throw new Error(`Migration '${id}' declares an index without a name or keys`)
|
|
239
|
+
|
|
240
|
+
const spec = Object.fromEntries(
|
|
241
|
+
Object.entries(keys).map(([field, direction]) => [
|
|
242
|
+
field,
|
|
243
|
+
DIRECTIONS[direction] ?? direction
|
|
244
|
+
])
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
const options = { name }
|
|
248
|
+
|
|
249
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
250
|
+
if (!(key in OPTIONS))
|
|
251
|
+
throw new Error(`Migration '${id}' declares an unknown index option '${key}'`)
|
|
252
|
+
|
|
253
|
+
options[OPTIONS[key]] = value
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await collection.createIndex(spec, options)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (!CONFLICTS.includes(error.code)) throw error
|
|
260
|
+
|
|
261
|
+
console.info('Recreating an index whose declaration changed', { index: name })
|
|
262
|
+
|
|
263
|
+
await collection.dropIndex(name)
|
|
264
|
+
await collection.createIndex(spec, options)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function dropIndex(collection, { name }, id) {
|
|
269
|
+
if (name === undefined)
|
|
270
|
+
throw new Error(`Migration '${id}' drops an index without a name`)
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
await collection.dropIndex(name)
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error.code !== ERR_INDEX_NOT_FOUND) throw error
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function update(collection, { filter, update: changeset }, id) {
|
|
280
|
+
if (changeset === undefined) throw new Error(`Migration '${id}' updates with nothing`)
|
|
281
|
+
|
|
282
|
+
const result = await collection.updateMany(filter ?? {}, changeset)
|
|
283
|
+
|
|
284
|
+
console.info('Migration updated records', {
|
|
285
|
+
migration: id,
|
|
286
|
+
records: result.modifiedCount
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function remove(collection, { filter }, id) {
|
|
291
|
+
if (filter === undefined)
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Migration '${id}' deletes without a filter; pass {} to mean every record`
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const result = await collection.deleteMany(filter)
|
|
297
|
+
|
|
298
|
+
console.info('Migration deleted records', {
|
|
299
|
+
migration: id,
|
|
300
|
+
records: result.deletedCount
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const STEPS = { index, dropIndex, update, delete: remove }
|
|
305
|
+
|
|
306
|
+
const DIRECTIONS = { asc: 1, desc: -1, hash: 'hashed' }
|
|
307
|
+
|
|
308
|
+
/** what an index step may say, and what the driver calls it */
|
|
309
|
+
const OPTIONS = {
|
|
310
|
+
unique: 'unique',
|
|
311
|
+
sparse: 'sparse',
|
|
312
|
+
partial: 'partialFilterExpression',
|
|
313
|
+
ttl: 'expireAfterSeconds'
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A component's namespace may not be `system`, so this cannot be a component's own collection;
|
|
318
|
+
* and MongoDB reserves the `system.` prefix, with a dot, which this is not.
|
|
319
|
+
*/
|
|
320
|
+
export const STATE = 'system_migrations'
|
|
321
|
+
|
|
322
|
+
const RUNNING = 'running'
|
|
323
|
+
const DONE = 'done'
|
|
324
|
+
|
|
325
|
+
/** how long a claim outlives its last heartbeat before another replica may take it */
|
|
326
|
+
const LEASE = 30_000
|
|
327
|
+
const HEARTBEAT = 5_000
|
|
328
|
+
const POLL = 1_000
|
|
329
|
+
|
|
330
|
+
/** how often a run that is taking its time says it is still going, and a wait that it is waiting */
|
|
331
|
+
const PROGRESS = 30_000
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Which process holds a claim, and nothing more: it is read by whoever takes an abandoned
|
|
335
|
+
* one over, and told to whoever reads the collection. Random, because there is no identity
|
|
336
|
+
* a process is guaranteed to have — a host name says nothing about two replicas on one
|
|
337
|
+
* machine, and a pid is reused.
|
|
338
|
+
*/
|
|
339
|
+
const OWNER = randomUUID()
|
|
340
|
+
|
|
341
|
+
const ERR_DUPLICATE_KEY = 11000
|
|
342
|
+
const ERR_INDEX_NOT_FOUND = 27
|
|
343
|
+
const CONFLICTS = [85, 86] // IndexOptionsConflict, IndexKeySpecsConflict
|