@earendil-works/chord 1.2.0 → 1.4.0
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 +71 -0
- package/README.md +69 -14
- package/dist/cell.d.ts +30 -9
- package/dist/cell.d.ts.map +1 -1
- package/dist/cell.js +52 -12
- package/dist/cell.js.map +1 -1
- package/dist/cluster.d.ts +4 -3
- package/dist/cluster.d.ts.map +1 -1
- package/dist/cluster.js +58 -42
- package/dist/cluster.js.map +1 -1
- package/dist/connection.d.ts +171 -58
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +676 -217
- package/dist/connection.js.map +1 -1
- package/dist/context/index.d.ts +8 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +15 -0
- package/dist/context/index.js.map +1 -1
- package/dist/contract.d.ts +120 -45
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +104 -15
- package/dist/contract.js.map +1 -1
- package/dist/declare.d.ts +11 -48
- package/dist/declare.d.ts.map +1 -1
- package/dist/declare.js +1 -21
- package/dist/declare.js.map +1 -1
- package/dist/delta/index.d.ts +2 -1
- package/dist/delta/index.d.ts.map +1 -1
- package/dist/delta/index.js +4 -0
- package/dist/delta/index.js.map +1 -1
- package/dist/errors.d.ts +4 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -1
- package/dist/host.d.ts +214 -120
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +446 -241
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +47 -12
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +200 -14
- package/dist/link.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +7 -2
- package/dist/loader.js.map +1 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +35 -9
- package/dist/manager.js.map +1 -1
- package/dist/object.d.ts +438 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/{record.js → object.js} +614 -335
- package/dist/object.js.map +1 -0
- package/dist/output.d.ts +91 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +360 -0
- package/dist/output.js.map +1 -0
- package/dist/registry.d.ts +3 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/reload.d.ts +35 -27
- package/dist/reload.d.ts.map +1 -1
- package/dist/reload.js +256 -192
- package/dist/reload.js.map +1 -1
- package/dist/remote.d.ts +130 -35
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +493 -189
- package/dist/remote.js.map +1 -1
- package/dist/sqlite/cloudflare.d.ts +52 -0
- package/dist/sqlite/cloudflare.d.ts.map +1 -0
- package/dist/sqlite/cloudflare.js +134 -0
- package/dist/sqlite/cloudflare.js.map +1 -0
- package/dist/sqlite/database.d.ts +37 -0
- package/dist/sqlite/database.d.ts.map +1 -0
- package/dist/sqlite/database.js +2 -0
- package/dist/sqlite/database.js.map +1 -0
- package/dist/sqlite/index.d.ts +4 -0
- package/dist/sqlite/index.d.ts.map +1 -0
- package/dist/sqlite/index.js +3 -0
- package/dist/sqlite/index.js.map +1 -0
- package/dist/sqlite/migrations.d.ts +10 -0
- package/dist/sqlite/migrations.d.ts.map +1 -0
- package/dist/sqlite/migrations.js +33 -0
- package/dist/sqlite/migrations.js.map +1 -0
- package/dist/sqlite/node.d.ts +38 -0
- package/dist/sqlite/node.d.ts.map +1 -0
- package/dist/sqlite/node.js +174 -0
- package/dist/sqlite/node.js.map +1 -0
- package/dist/sqlite/schema.d.ts +3 -0
- package/dist/sqlite/schema.d.ts.map +1 -0
- package/dist/sqlite/schema.js +10 -0
- package/dist/sqlite/schema.js.map +1 -0
- package/dist/telemetry-sqlite/index.d.ts +24 -0
- package/dist/telemetry-sqlite/index.d.ts.map +1 -0
- package/dist/telemetry-sqlite/index.js +273 -0
- package/dist/telemetry-sqlite/index.js.map +1 -0
- package/dist/telemetry-sqlite/store.d.ts +9 -0
- package/dist/telemetry-sqlite/store.d.ts.map +1 -0
- package/dist/telemetry-sqlite/store.js +110 -0
- package/dist/telemetry-sqlite/store.js.map +1 -0
- package/dist/types.d.ts +39 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/websocket.d.ts +4 -5
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +3 -2
- package/dist/websocket.js.map +1 -1
- package/dist/wire.d.ts +210 -129
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +108 -55
- package/dist/wire.js.map +1 -1
- package/docs/guide.md +267 -203
- package/docs/spec.md +141 -114
- package/docs/tutorial.md +257 -235
- package/package.json +27 -1
- package/dist/record.d.ts +0 -320
- package/dist/record.d.ts.map +0 -1
- package/dist/record.js.map +0 -1
package/dist/reload.js
CHANGED
|
@@ -1,31 +1,44 @@
|
|
|
1
1
|
import { bind, coverage } from "./contract.js";
|
|
2
|
-
import { asChordError, ChordError, invalid } from "./errors.js";
|
|
2
|
+
import { asChordError, ChordError, invalid, isChordError } from "./errors.js";
|
|
3
|
+
import { hostLifetime, makeService, memberSchemas, RefImpl, } from "./object.js";
|
|
3
4
|
import { holdPublications } from "./publish.js";
|
|
4
|
-
import { HandleImpl, makeService, memberSchemas, } from "./record.js";
|
|
5
5
|
import { assertValid, deepEqual } from "./schema.js";
|
|
6
|
-
const noop = () => { };
|
|
7
6
|
/**
|
|
8
7
|
* Computes the new derivation functions over the candidate values and validates every prospective value (migrated
|
|
9
8
|
* cells and derivations) against every schema that will apply after the commit: the cell's new schema and the new
|
|
10
|
-
*
|
|
9
|
+
* implementation's remote state-member schemas. A failure fails prepare; the commit then installs without re-checking.
|
|
11
10
|
*/
|
|
12
11
|
function dryRun(candidate) {
|
|
13
|
-
const { run, migrated, service,
|
|
14
|
-
const exposed = memberSchemas(service, run.
|
|
12
|
+
const { run, migrated, service, object } = candidate;
|
|
13
|
+
const exposed = memberSchemas(service, run.implementation);
|
|
15
14
|
for (const [cell, value] of migrated) {
|
|
16
15
|
for (const schema of exposed.get(cell) ?? [])
|
|
17
|
-
assertValid("state", `${
|
|
16
|
+
assertValid("state", `${object.address}: migrated state ${cell.key}`, schema, value);
|
|
18
17
|
}
|
|
19
18
|
const values = new Map(migrated);
|
|
20
19
|
const derives = [...run.decls.derives].sort(([, a], [, b]) => a.seq - b.seq);
|
|
21
20
|
for (const [name, derived] of derives) {
|
|
22
|
-
const value = derived.compute((source) => (values.has(source) ? values.get(source) : source
|
|
21
|
+
const value = derived.compute((source) => (values.has(source) ? values.get(source) : candidateValue(source)));
|
|
23
22
|
for (const schema of exposed.get(derived) ?? [])
|
|
24
|
-
assertValid("state", `${
|
|
23
|
+
assertValid("state", `${object.address}: derivation ${name}`, schema, value);
|
|
25
24
|
values.set(derived, value);
|
|
26
25
|
}
|
|
27
26
|
candidate.derived = values;
|
|
28
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* A source's value for a dry run. A reference to a provider this reload upgrades too reads nothing before the commit
|
|
30
|
+
* (its new contract does not bind the old code): the dry run computes without it, the commit with the real value.
|
|
31
|
+
*/
|
|
32
|
+
function candidateValue(source) {
|
|
33
|
+
try {
|
|
34
|
+
return source.value;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (isChordError(error, "not_ready") || isChordError(error, "not_in_variant"))
|
|
38
|
+
return undefined;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
29
42
|
/** A reload error keeps the underlying code (`contract_mismatch`, `invalid`, ...) and says it aborted the reload. */
|
|
30
43
|
export function reloadError(error) {
|
|
31
44
|
const cause = asChordError(error, "reload");
|
|
@@ -49,30 +62,49 @@ export async function runReload(host, plugins, options) {
|
|
|
49
62
|
}
|
|
50
63
|
}
|
|
51
64
|
/** Takes the host's reload slot and loads the plugin modules. Pair with `endReload`. */
|
|
52
|
-
export async function beginReload(host, plugins, options,
|
|
65
|
+
export async function beginReload(host, plugins, options, codeVersion) {
|
|
53
66
|
if (host.reloading) {
|
|
54
67
|
throw new ChordError("unavailable", "a reload is already running", { reason: "reload_running" });
|
|
55
68
|
}
|
|
56
69
|
host.reloading = true;
|
|
57
70
|
try {
|
|
58
|
-
return new Reload(host, await host.resolve(plugins), options,
|
|
71
|
+
return new Reload(host, await host.resolve(plugins), options, codeVersion);
|
|
59
72
|
}
|
|
60
73
|
catch (error) {
|
|
61
|
-
host.
|
|
74
|
+
host.reloadEnded();
|
|
62
75
|
throw reloadError(error);
|
|
63
76
|
}
|
|
64
77
|
}
|
|
65
78
|
export function endReload(host) {
|
|
66
|
-
host.
|
|
67
|
-
|
|
79
|
+
host.reloadEnded();
|
|
80
|
+
}
|
|
81
|
+
/** Most milliseconds a forced retirement waits for aborted roots to settle before it closes what they used. */
|
|
82
|
+
const ABORTED_ROOTS_GRACE = 1000;
|
|
83
|
+
/** Resolves true once `ready()` holds (checked whenever retirement-relevant state changes), false after `ms`. */
|
|
84
|
+
function until(host, ready, ms) {
|
|
85
|
+
if (ready())
|
|
86
|
+
return Promise.resolve(true);
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
stop();
|
|
90
|
+
resolve(false);
|
|
91
|
+
}, ms);
|
|
92
|
+
const stop = host.watchRetirement(() => {
|
|
93
|
+
if (!ready())
|
|
94
|
+
return;
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
stop();
|
|
97
|
+
resolve(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
68
100
|
}
|
|
69
101
|
/** One reload on one host: `prepare` (load, prepare, gate), then `commit` or `rollback`. */
|
|
70
102
|
export class Reload {
|
|
71
103
|
host;
|
|
72
104
|
facets;
|
|
73
105
|
options;
|
|
74
|
-
/**
|
|
75
|
-
|
|
106
|
+
/** Code version id to commit (issued by the coordinating server); a local reload allocates one. */
|
|
107
|
+
codeVersion;
|
|
76
108
|
candidates = new Map();
|
|
77
109
|
abort = new AbortController();
|
|
78
110
|
closed = [];
|
|
@@ -82,13 +114,15 @@ export class Reload {
|
|
|
82
114
|
tables = new Map();
|
|
83
115
|
gated = [];
|
|
84
116
|
rolledBack = false;
|
|
85
|
-
/**
|
|
117
|
+
/** Objects of types this reload adds, with their staged listeners: their runtime starts at commit. */
|
|
86
118
|
deferred = new Map();
|
|
87
|
-
|
|
119
|
+
/** This reload's run of each facet it loads (`FacetApi.signal`): installed at commit, aborted at rollback. */
|
|
120
|
+
facetRuns = new Map();
|
|
121
|
+
constructor(host, facets, options, codeVersion) {
|
|
88
122
|
this.host = host;
|
|
89
123
|
this.facets = facets;
|
|
90
124
|
this.options = options;
|
|
91
|
-
this.
|
|
125
|
+
this.codeVersion = codeVersion;
|
|
92
126
|
}
|
|
93
127
|
/** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */
|
|
94
128
|
async prepare() {
|
|
@@ -97,11 +131,11 @@ export class Reload {
|
|
|
97
131
|
this.load();
|
|
98
132
|
this.checkProviders();
|
|
99
133
|
await this.prepareAll();
|
|
100
|
-
// From here new
|
|
134
|
+
// From here new objects of changed types are rejected (upgrade_conflict, retryable) until commit or abort,
|
|
101
135
|
// so the candidate set is final once the activations already in flight have been prepared.
|
|
102
136
|
this.host.gating = this.changed;
|
|
103
137
|
await this.withDeadline(this.prepareAll(), "activations in flight did not finish");
|
|
104
|
-
this.
|
|
138
|
+
this.checkRefs();
|
|
105
139
|
this.tables = this.host.buildTables(this.entries());
|
|
106
140
|
await this.gate();
|
|
107
141
|
this.#alive();
|
|
@@ -114,14 +148,14 @@ export class Reload {
|
|
|
114
148
|
}
|
|
115
149
|
/** What a successful prepare will commit (a child sends it with its vote). */
|
|
116
150
|
summary() {
|
|
117
|
-
const live = [...this.candidates.values()].filter(({
|
|
151
|
+
const live = [...this.candidates.values()].filter(({ object }) => object.status === "live");
|
|
118
152
|
return {
|
|
119
|
-
|
|
153
|
+
codeVersion: this.host.pending?.id ?? this.host.current.id,
|
|
120
154
|
previous: this.host.current.id,
|
|
121
155
|
changed: [...this.changed],
|
|
122
156
|
removed: [...this.removed],
|
|
123
|
-
prepared: live.map(({
|
|
124
|
-
gated: this.gated.map(({
|
|
157
|
+
prepared: live.map(({ object }) => object.address),
|
|
158
|
+
gated: this.gated.map(({ object }) => object.address),
|
|
125
159
|
};
|
|
126
160
|
}
|
|
127
161
|
/** After every await of prepare: a rollback meanwhile (abort from the coordinator) ends it. */
|
|
@@ -131,33 +165,37 @@ export class Reload {
|
|
|
131
165
|
}
|
|
132
166
|
/**
|
|
133
167
|
* Subscribes every new listener now, buffering its events, so a failing source fails prepare instead of leaving a
|
|
134
|
-
* half-installed
|
|
168
|
+
* half-installed code version. Objects running only the new code (types this reload adds) are staged too.
|
|
135
169
|
*/
|
|
136
170
|
#stage() {
|
|
137
171
|
for (const candidate of this.candidates.values()) {
|
|
138
|
-
if (candidate.
|
|
139
|
-
candidate.staged = candidate.
|
|
172
|
+
if (candidate.object.status === "live")
|
|
173
|
+
candidate.staged = candidate.object.stageListeners(candidate.run.decls);
|
|
140
174
|
}
|
|
141
|
-
for (const
|
|
142
|
-
if (
|
|
143
|
-
this.deferred.set(
|
|
175
|
+
for (const object of this.host.objects.values()) {
|
|
176
|
+
if (object.status === "live" && object.deferredRuntime)
|
|
177
|
+
this.deferred.set(object, object.stageListeners(object.decls));
|
|
144
178
|
}
|
|
145
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Retires the previous code version before loading the next one (a host keeps two). Waits until its roots ended and,
|
|
182
|
+
* on a child host, its parent retired it too (`retire` frame), at most `retireDeadline`. Past the deadline it is
|
|
183
|
+
* retired anyway: its remaining roots are aborted first and get a short grace to stop, so they see cancellation
|
|
184
|
+
* before the resources only that code version used are closed; their next call is `superseded`.
|
|
185
|
+
*/
|
|
146
186
|
async waitForRetirement() {
|
|
147
187
|
const host = this.host;
|
|
148
|
-
|
|
188
|
+
const previous = host.previous;
|
|
189
|
+
if (previous === undefined)
|
|
149
190
|
return;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
host.
|
|
158
|
-
}
|
|
159
|
-
// Roots of the dropped epoch still running get `superseded` on their next call.
|
|
160
|
-
host.retire();
|
|
191
|
+
const deadline = this.options.retireDeadline ?? 5000;
|
|
192
|
+
const gone = () => host.previous !== previous;
|
|
193
|
+
if (!(await until(host, () => gone() || host.retirable(), deadline)) && !gone()) {
|
|
194
|
+
previous.abort.abort(new ChordError("superseded", `code version ${previous.id} was retired`));
|
|
195
|
+
await until(host, () => gone() || previous.roots.size === 0, Math.min(deadline, ABORTED_ROOTS_GRACE));
|
|
196
|
+
}
|
|
197
|
+
if (!gone())
|
|
198
|
+
host.retire();
|
|
161
199
|
}
|
|
162
200
|
/** Builds the candidate service table: unchanged services plus everything the given facets serve. */
|
|
163
201
|
load() {
|
|
@@ -165,7 +203,9 @@ export class Reload {
|
|
|
165
203
|
const current = this.host.current.services;
|
|
166
204
|
this.services = new Map([...current].filter(([, service]) => !replaced.has(service.facet)));
|
|
167
205
|
for (const facet of this.facets) {
|
|
168
|
-
|
|
206
|
+
const run = new AbortController();
|
|
207
|
+
this.facetRuns.set(facet.name, run);
|
|
208
|
+
facet.setup(this.host.facetApi(((type, factory, options = {}) => this.host.serveInto(this.services, makeService(type, factory, options, facet.name))), run.signal));
|
|
169
209
|
}
|
|
170
210
|
for (const [name, service] of this.services)
|
|
171
211
|
if (current.get(name) !== service)
|
|
@@ -174,10 +214,11 @@ export class Reload {
|
|
|
174
214
|
if (!this.services.has(name))
|
|
175
215
|
this.removed.add(name);
|
|
176
216
|
this.host.pending = {
|
|
177
|
-
id: this.host.
|
|
217
|
+
id: this.host.nextCodeVersionId(this.codeVersion),
|
|
178
218
|
services: this.services,
|
|
179
219
|
tables: new Map(),
|
|
180
220
|
roots: new Map(),
|
|
221
|
+
abort: new AbortController(),
|
|
181
222
|
};
|
|
182
223
|
}
|
|
183
224
|
async withDeadline(promise, what) {
|
|
@@ -219,33 +260,33 @@ export class Reload {
|
|
|
219
260
|
}
|
|
220
261
|
}
|
|
221
262
|
/**
|
|
222
|
-
* Prepares every live
|
|
263
|
+
* Prepares every live object of a changed type, including objects that activate meanwhile, to a fixpoint. Objects
|
|
223
264
|
* already running the new code (types this reload adds) are skipped.
|
|
224
265
|
*/
|
|
225
266
|
async prepareAll() {
|
|
226
267
|
for (;;) {
|
|
227
|
-
const todo = [...this.host.
|
|
228
|
-
!this.candidates.has(
|
|
229
|
-
|
|
230
|
-
(
|
|
268
|
+
const todo = [...this.host.objects.values()].filter((object) => this.changed.has(object.typeName) &&
|
|
269
|
+
!this.candidates.has(object) &&
|
|
270
|
+
object.service !== this.services.get(object.typeName) &&
|
|
271
|
+
(object.status === "live" || object.status === "activating"));
|
|
231
272
|
if (todo.length === 0)
|
|
232
273
|
return;
|
|
233
|
-
await Promise.allSettled(todo.map((
|
|
274
|
+
await Promise.allSettled(todo.map((object) => object.ready));
|
|
234
275
|
this.#alive();
|
|
235
|
-
for (const
|
|
236
|
-
if (
|
|
237
|
-
await this.
|
|
276
|
+
for (const object of todo) {
|
|
277
|
+
if (object.status === "live" && object.service !== this.services.get(object.typeName)) {
|
|
278
|
+
await this.prepareObject(object);
|
|
238
279
|
this.#alive();
|
|
239
280
|
}
|
|
240
281
|
}
|
|
241
282
|
}
|
|
242
283
|
}
|
|
243
284
|
/** Runs the new factory in recording mode and opens what can be opened without the gate. */
|
|
244
|
-
async
|
|
245
|
-
const service = this.services.get(
|
|
246
|
-
const run =
|
|
285
|
+
async prepareObject(object) {
|
|
286
|
+
const service = this.services.get(object.typeName);
|
|
287
|
+
const run = object.run(service, object.decls);
|
|
247
288
|
const candidate = {
|
|
248
|
-
|
|
289
|
+
object,
|
|
249
290
|
service,
|
|
250
291
|
run,
|
|
251
292
|
reasons: [],
|
|
@@ -255,20 +296,19 @@ export class Reload {
|
|
|
255
296
|
derived: new Map(),
|
|
256
297
|
staged: undefined,
|
|
257
298
|
};
|
|
258
|
-
this.candidates.set(
|
|
259
|
-
|
|
260
|
-
if (bumped)
|
|
261
|
-
candidate.reasons.push(`state version ${record.stateVersion} -> ${service.stateVersion}`);
|
|
299
|
+
this.candidates.set(object, candidate);
|
|
300
|
+
// Each cell carries its own version: a raised one gates and migrates, a schema change without one is refused.
|
|
262
301
|
for (const [name, cell] of run.decls.cells) {
|
|
263
|
-
if (
|
|
302
|
+
if (object.decls.cells.get(name) !== cell)
|
|
264
303
|
continue;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
304
|
+
const from = object.decls.cellVersions.get(name) ?? 1;
|
|
305
|
+
const to = run.decls.cellVersions.get(name) ?? 1;
|
|
306
|
+
if (to < from)
|
|
307
|
+
throw invalid("state", `${object.address}: version of state ${name} lowered from ${from} to ${to}`);
|
|
308
|
+
if (to > from)
|
|
309
|
+
candidate.reasons.push(`state ${name} version ${from} -> ${to}`);
|
|
310
|
+
else if (!deepEqual(cell.schema, run.decls.cellSchemas.get(name)))
|
|
311
|
+
throw invalid("state", `${object.address}: schema of state ${name} changed without a version bump`);
|
|
272
312
|
}
|
|
273
313
|
const deferred = new Set();
|
|
274
314
|
for (const resource of run.created.resources) {
|
|
@@ -277,57 +317,62 @@ export class Reload {
|
|
|
277
317
|
}
|
|
278
318
|
for (const resource of deferred) {
|
|
279
319
|
candidate.atGate.push(resource);
|
|
280
|
-
const old =
|
|
320
|
+
const old = object.decls.resources.get(resource.key);
|
|
281
321
|
if (resource.exclusive && old?.exclusive && old.state === "open")
|
|
282
322
|
candidate.replaced.push(old);
|
|
283
323
|
}
|
|
284
324
|
if (deferred.size > 0)
|
|
285
325
|
candidate.reasons.push("exclusive resource replaced");
|
|
286
|
-
|
|
326
|
+
await object.open(run.created.resources.filter((resource) => !deferred.has(resource)), run.created.refs, this.abort.signal);
|
|
327
|
+
await object.waitHydrated(run);
|
|
328
|
+
// Cells this reload adds read their first value once the new resources are open (gated objects: at the gate);
|
|
329
|
+
// the derivations over them are checked after.
|
|
330
|
+
if (candidate.reasons.length === 0) {
|
|
331
|
+
await object.initCells(run, this.abort.signal);
|
|
287
332
|
dryRun(candidate);
|
|
288
|
-
|
|
333
|
+
}
|
|
289
334
|
}
|
|
290
335
|
/**
|
|
291
|
-
* Every
|
|
292
|
-
* after the commit they get `upgrade_required` instead.
|
|
336
|
+
* Every reference that survives into the new code version must bind to the new provider. Browser references don't block a
|
|
337
|
+
* reload: after the commit they get `upgrade_required` instead.
|
|
293
338
|
*/
|
|
294
|
-
|
|
339
|
+
checkRefs() {
|
|
295
340
|
const dropped = new Set();
|
|
296
|
-
for (const {
|
|
297
|
-
for (const
|
|
298
|
-
if (![...run.decls.
|
|
299
|
-
dropped.add(
|
|
300
|
-
}
|
|
301
|
-
for (const
|
|
302
|
-
const service = this.services.get(
|
|
303
|
-
if (service === undefined || !this.changed.has(
|
|
341
|
+
for (const { object, run } of this.candidates.values()) {
|
|
342
|
+
for (const ref of object.decls.refs.values())
|
|
343
|
+
if (![...run.decls.refs.values()].includes(ref))
|
|
344
|
+
dropped.add(ref);
|
|
345
|
+
}
|
|
346
|
+
for (const object of this.host.objects.values()) {
|
|
347
|
+
const service = this.services.get(object.typeName);
|
|
348
|
+
if (service === undefined || !this.changed.has(object.typeName))
|
|
304
349
|
continue;
|
|
305
|
-
for (const
|
|
306
|
-
// The host's bootstrap
|
|
307
|
-
if (
|
|
350
|
+
for (const ref of object.refs) {
|
|
351
|
+
// The host's bootstrap reference follows this provider at commit; it is not an old-code consumer.
|
|
352
|
+
if (ref === this.host.hostObjects.get(object.typeName))
|
|
308
353
|
continue;
|
|
309
|
-
if (!dropped.has(
|
|
310
|
-
bind(service.contract, service.options.accepts,
|
|
354
|
+
if (!dropped.has(ref) && ref.owner !== "client")
|
|
355
|
+
bind(service.contract, service.options.accepts, ref.contract);
|
|
311
356
|
}
|
|
312
357
|
}
|
|
313
358
|
}
|
|
314
359
|
entries() {
|
|
315
360
|
return this.host
|
|
316
361
|
.liveDecls(true)
|
|
317
|
-
.filter(([
|
|
318
|
-
.map(([
|
|
362
|
+
.filter(([object]) => !this.removed.has(object.typeName))
|
|
363
|
+
.map(([object, decls]) => [object, this.candidates.get(object)?.run.decls ?? decls]);
|
|
319
364
|
}
|
|
320
365
|
/**
|
|
321
366
|
* Admission cut, wait for grandfathered roots (deadline), replace exclusive resources, migrate into private
|
|
322
|
-
* copies. Old tasks of gated
|
|
367
|
+
* copies. Old tasks of gated objects are stopped at the cut; listener events are queued until commit or abort.
|
|
323
368
|
*/
|
|
324
369
|
async gate() {
|
|
325
|
-
this.gated = [...this.candidates.values()].filter((candidate) => candidate.reasons.length > 0 && candidate.
|
|
326
|
-
const drained = this.gated.map(({
|
|
327
|
-
const grandfathered = new Set(
|
|
370
|
+
this.gated = [...this.candidates.values()].filter((candidate) => candidate.reasons.length > 0 && candidate.object.status === "live");
|
|
371
|
+
const drained = this.gated.map(({ object }) => {
|
|
372
|
+
const grandfathered = new Set(object.leases.keys());
|
|
328
373
|
return new Promise((resolve) => {
|
|
329
|
-
|
|
330
|
-
|
|
374
|
+
object.gate = { grandfathered, queue: [], drained: resolve };
|
|
375
|
+
object.stopTasks();
|
|
331
376
|
if (grandfathered.size === 0)
|
|
332
377
|
resolve();
|
|
333
378
|
});
|
|
@@ -342,7 +387,7 @@ export class Reload {
|
|
|
342
387
|
]);
|
|
343
388
|
clearTimeout(timer);
|
|
344
389
|
if (timedOut) {
|
|
345
|
-
const blocking = this.gated.flatMap(({
|
|
390
|
+
const blocking = this.gated.flatMap(({ object }) => [...(object.gate?.grandfathered ?? [])]);
|
|
346
391
|
throw new ChordError("aborted", `gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`, {
|
|
347
392
|
reason: "gate_deadline",
|
|
348
393
|
details: { blocking },
|
|
@@ -350,35 +395,36 @@ export class Reload {
|
|
|
350
395
|
}
|
|
351
396
|
this.#alive();
|
|
352
397
|
for (const candidate of this.gated) {
|
|
353
|
-
const {
|
|
398
|
+
const { object } = candidate;
|
|
354
399
|
for (const old of candidate.replaced) {
|
|
355
400
|
// Owned by the rollback before closing: a failing close must still be restored.
|
|
356
|
-
this.closed.push([
|
|
357
|
-
await old.close(
|
|
401
|
+
this.closed.push([object, old]);
|
|
402
|
+
await old.close(object.openOrder);
|
|
358
403
|
}
|
|
359
|
-
await
|
|
404
|
+
await object.open(candidate.atGate, [], this.abort.signal);
|
|
405
|
+
this.#alive();
|
|
406
|
+
await object.initCells(candidate.run, this.abort.signal);
|
|
360
407
|
this.#alive();
|
|
361
408
|
}
|
|
362
409
|
for (const candidate of this.gated)
|
|
363
410
|
this.migrate(candidate);
|
|
364
411
|
}
|
|
365
412
|
migrate(candidate) {
|
|
366
|
-
const {
|
|
367
|
-
const migrate = service.options.state?.migrate ?? {};
|
|
368
|
-
const bumped = service.stateVersion !== record.stateVersion;
|
|
413
|
+
const { object, run } = candidate;
|
|
369
414
|
for (const [name, cell] of run.decls.cells) {
|
|
370
|
-
if (
|
|
415
|
+
if (object.decls.cells.get(name) !== cell)
|
|
371
416
|
continue;
|
|
372
417
|
const schema = run.decls.cellSchemas.get(name);
|
|
373
|
-
const
|
|
418
|
+
const from = object.decls.cellVersions.get(name) ?? 1;
|
|
419
|
+
const fn = run.decls.cellMigrations.get(name);
|
|
374
420
|
let value;
|
|
375
|
-
if (
|
|
376
|
-
value = fn(structuredClone(cell.value),
|
|
421
|
+
if ((run.decls.cellVersions.get(name) ?? 1) > from && fn !== undefined)
|
|
422
|
+
value = fn(structuredClone(cell.value), from);
|
|
377
423
|
else if (!deepEqual(cell.schema, schema))
|
|
378
424
|
value = cell.value;
|
|
379
425
|
else
|
|
380
426
|
continue;
|
|
381
|
-
assertValid("state", `${
|
|
427
|
+
assertValid("state", `${object.address}: migrated state ${name}`, schema, value);
|
|
382
428
|
candidate.migrated.set(cell, value);
|
|
383
429
|
}
|
|
384
430
|
dryRun(candidate);
|
|
@@ -388,54 +434,56 @@ export class Reload {
|
|
|
388
434
|
return;
|
|
389
435
|
this.rolledBack = true;
|
|
390
436
|
this.abort.abort(new ChordError("aborted", "reload aborted"));
|
|
437
|
+
for (const run of this.facetRuns.values())
|
|
438
|
+
run.abort(new ChordError("aborted", "reload aborted"));
|
|
391
439
|
const report = (error) => this.host.report(error);
|
|
392
440
|
for (const candidate of this.candidates.values())
|
|
393
441
|
candidate.staged?.discard();
|
|
394
442
|
for (const staged of this.deferred.values())
|
|
395
443
|
staged.discard();
|
|
396
|
-
for (const {
|
|
397
|
-
|
|
398
|
-
await
|
|
444
|
+
for (const { object, run } of this.candidates.values()) {
|
|
445
|
+
object.pending.delete(run.decls);
|
|
446
|
+
await object.unwind(run).catch(report);
|
|
399
447
|
}
|
|
400
448
|
// Replaced exclusive resources are restored; one that cannot be is marked unavailable, never silently closed.
|
|
401
|
-
for (const [
|
|
449
|
+
for (const [object, resource] of this.closed) {
|
|
402
450
|
if (resource.state !== "closed")
|
|
403
451
|
continue;
|
|
404
|
-
await resource.open(new AbortController().signal,
|
|
452
|
+
await resource.open(new AbortController().signal, object.openOrder).catch((error) => {
|
|
405
453
|
resource.markUnavailable();
|
|
406
|
-
report(new ChordError("unavailable", `${
|
|
454
|
+
report(new ChordError("unavailable", `${object.address}: resource ${resource.key} could not be restored`, {
|
|
407
455
|
cause: error,
|
|
408
456
|
}));
|
|
409
457
|
});
|
|
410
458
|
}
|
|
411
|
-
for (const {
|
|
412
|
-
if (
|
|
459
|
+
for (const { object } of this.gated) {
|
|
460
|
+
if (object.gate === undefined)
|
|
413
461
|
continue;
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
record.startTask(name, this.host.current.id);
|
|
462
|
+
object.openGate();
|
|
463
|
+
object.startTasks();
|
|
417
464
|
}
|
|
418
|
-
//
|
|
465
|
+
// Objects of types this reload would have added lose their code.
|
|
419
466
|
const reason = new ChordError("gone", "reload aborted; type not served");
|
|
420
|
-
for (const
|
|
421
|
-
if (!this.host.current.services.has(
|
|
422
|
-
await
|
|
467
|
+
for (const object of [...this.host.objects.values()]) {
|
|
468
|
+
if (!this.host.current.services.has(object.typeName))
|
|
469
|
+
await object.dispose(true, reason).catch(report);
|
|
423
470
|
}
|
|
424
471
|
this.host.pending = undefined;
|
|
425
472
|
this.host.gating = undefined;
|
|
426
473
|
}
|
|
427
474
|
/**
|
|
428
|
-
* Publishes the new
|
|
429
|
-
* and derivation checks against the prospective schemas, listener subscriptions), so this only installs.
|
|
430
|
-
* (state, shapes, registries, listener roots) are notified after the whole step. If an install still
|
|
431
|
-
*
|
|
475
|
+
* Publishes the new code version in one synchronous step. Everything fallible ran in prepare (factories, opens,
|
|
476
|
+
* migration and derivation checks against the prospective schemas, listener subscriptions), so this only installs.
|
|
477
|
+
* Observers (state, shapes, registries, listener roots) are notified after the whole step. If an install still
|
|
478
|
+
* throws, the code version is half-published: the host fails terminally and admits no more work.
|
|
432
479
|
*/
|
|
433
480
|
commit(beforeStart) {
|
|
434
481
|
try {
|
|
435
482
|
return holdPublications(() => {
|
|
436
483
|
const report = this.#publish();
|
|
437
|
-
// Installed, not yet started: the coordinator queues its commit frames here, so nothing this
|
|
438
|
-
// (tasks,
|
|
484
|
+
// Installed, not yet started: the coordinator queues its commit frames here, so nothing this code version
|
|
485
|
+
// starts (tasks, host object activations, then the held listener/registry/state notifications) can reach a child
|
|
486
|
+
// first.
|
|
439
487
|
beforeStart?.();
|
|
440
488
|
for (const start of this.#starts.splice(0))
|
|
441
489
|
start();
|
|
@@ -447,103 +495,119 @@ export class Reload {
|
|
|
447
495
|
throw reloadError(error);
|
|
448
496
|
}
|
|
449
497
|
}
|
|
450
|
-
/** Work that may start new-
|
|
498
|
+
/** Work that may start new-code-version roots, run after installation (and after the coordinator's commit frames). */
|
|
451
499
|
#starts = [];
|
|
452
500
|
#publish() {
|
|
453
501
|
const host = this.host;
|
|
454
502
|
const previous = host.current;
|
|
455
503
|
const next = host.pending;
|
|
456
504
|
host.pending = undefined;
|
|
457
|
-
host.
|
|
505
|
+
host.codeVersions.set(next.id, next);
|
|
458
506
|
host.previous = previous;
|
|
459
507
|
host.current = next;
|
|
460
508
|
const prepared = [];
|
|
461
|
-
for (const
|
|
462
|
-
if (this.removed.has(
|
|
509
|
+
for (const object of [...host.objects.values()]) {
|
|
510
|
+
if (this.removed.has(object.typeName))
|
|
463
511
|
continue;
|
|
464
|
-
const candidate = this.candidates.get(
|
|
512
|
+
const candidate = this.candidates.get(object);
|
|
465
513
|
if (candidate === undefined) {
|
|
466
|
-
const installed =
|
|
514
|
+
const installed = object.implementations.get(previous.id);
|
|
467
515
|
if (installed !== undefined)
|
|
468
|
-
|
|
516
|
+
object.implementations.set(next.id, { ...installed, codeVersion: next.id });
|
|
469
517
|
continue;
|
|
470
518
|
}
|
|
471
|
-
if (
|
|
519
|
+
if (object.status !== "live")
|
|
472
520
|
continue;
|
|
473
521
|
this.install(candidate, next.id);
|
|
474
|
-
prepared.push(
|
|
522
|
+
prepared.push(object.address);
|
|
475
523
|
}
|
|
476
|
-
for (const {
|
|
477
|
-
if (
|
|
524
|
+
for (const { object, run, staged } of this.candidates.values()) {
|
|
525
|
+
if (object.status === "live")
|
|
478
526
|
continue;
|
|
479
527
|
staged?.discard();
|
|
480
|
-
|
|
481
|
-
|
|
528
|
+
object.pending.delete(run.decls);
|
|
529
|
+
object.unwind(run).catch((error) => host.report(error));
|
|
482
530
|
}
|
|
483
|
-
//
|
|
484
|
-
for (const [
|
|
485
|
-
if (
|
|
531
|
+
// Objects of types this reload adds start their runtime now.
|
|
532
|
+
for (const [object, staged] of this.deferred) {
|
|
533
|
+
if (object.status !== "live") {
|
|
486
534
|
staged.discard();
|
|
487
535
|
continue;
|
|
488
536
|
}
|
|
489
|
-
|
|
537
|
+
object.deferredRuntime = false;
|
|
490
538
|
staged.activate();
|
|
491
|
-
this.#starts.push(() =>
|
|
539
|
+
this.#starts.push(() => object.startTasks());
|
|
492
540
|
}
|
|
493
541
|
host.installTables(next, this.tables, true);
|
|
494
542
|
const reason = new ChordError("gone", "type no longer served");
|
|
495
|
-
for (const
|
|
496
|
-
if (this.removed.has(
|
|
497
|
-
|
|
543
|
+
for (const object of [...host.objects.values()]) {
|
|
544
|
+
if (this.removed.has(object.typeName))
|
|
545
|
+
object.dispose(true, reason).catch((error) => host.report(error));
|
|
498
546
|
}
|
|
499
547
|
for (const name of this.removed)
|
|
500
|
-
host.
|
|
548
|
+
host.hostObjects.delete(name);
|
|
501
549
|
for (const facet of this.facets)
|
|
502
550
|
host.facets.set(facet.name, facet);
|
|
503
551
|
for (const name of this.options.remove ?? [])
|
|
504
552
|
host.facets.delete(name);
|
|
553
|
+
// The replaced and removed runs' signals abort after the install, like other observers.
|
|
554
|
+
for (const [name, run] of this.facetRuns)
|
|
555
|
+
this.#starts.push(() => host.facetRunChanged(name, run));
|
|
556
|
+
for (const name of this.options.remove ?? [])
|
|
557
|
+
this.#starts.push(() => host.facetRunChanged(name, undefined));
|
|
505
558
|
host.gating = undefined;
|
|
506
559
|
for (const [name, service] of this.services) {
|
|
507
|
-
if (service
|
|
508
|
-
this.#starts.push(() => host.
|
|
560
|
+
if (hostLifetime(service) && this.changed.has(name) && host.hostObjects.get(name)?.object?.status !== "live") {
|
|
561
|
+
this.#starts.push(() => host.startHostObject(service));
|
|
562
|
+
}
|
|
563
|
+
// A type whose lifetime became "referenced" lives on only while someone else references its object.
|
|
564
|
+
const pinned = host.hostObjects.get(name);
|
|
565
|
+
if (!hostLifetime(service) && pinned !== undefined) {
|
|
566
|
+
host.hostObjects.delete(name);
|
|
567
|
+
this.#starts.push(() => pinned.release());
|
|
509
568
|
}
|
|
510
569
|
}
|
|
511
570
|
return {
|
|
512
|
-
|
|
571
|
+
codeVersion: next.id,
|
|
513
572
|
previous: previous.id,
|
|
514
573
|
changed: [...this.changed],
|
|
515
574
|
removed: [...this.removed],
|
|
516
575
|
prepared,
|
|
517
|
-
gated: this.gated.map(({
|
|
576
|
+
gated: this.gated.map(({ object }) => object.address),
|
|
518
577
|
};
|
|
519
578
|
}
|
|
520
|
-
install(candidate,
|
|
521
|
-
const {
|
|
579
|
+
install(candidate, codeVersion) {
|
|
580
|
+
const { object, run, service } = candidate;
|
|
522
581
|
const gated = candidate.reasons.length > 0;
|
|
523
|
-
|
|
524
|
-
|
|
582
|
+
object.pending.delete(run.decls);
|
|
583
|
+
object.stopTasks();
|
|
525
584
|
if (gated) {
|
|
526
|
-
// Old derivations would compute over migrated values; the old
|
|
585
|
+
// Old derivations would compute over migrated values; the old implementation can no longer run anyway.
|
|
527
586
|
const kept = new Set(run.decls.derives.values());
|
|
528
|
-
for (const derived of
|
|
587
|
+
for (const derived of object.decls.derives.values())
|
|
529
588
|
if (!kept.has(derived))
|
|
530
589
|
derived.detach();
|
|
531
590
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
591
|
+
object.decls = run.decls;
|
|
592
|
+
object.service = service;
|
|
593
|
+
// Kept resources close with the new code's `close`; replaced ones keep their own (closed at retirement).
|
|
594
|
+
for (const [resource, declaration] of run.decls.adopted)
|
|
595
|
+
resource.adopt(declaration);
|
|
596
|
+
object.implementations.set(codeVersion, {
|
|
597
|
+
codeVersion,
|
|
598
|
+
service,
|
|
599
|
+
implementation: run.implementation,
|
|
600
|
+
decls: run.decls,
|
|
601
|
+
});
|
|
536
602
|
if (gated) {
|
|
537
|
-
// Old code can never run over migrated state: drop the previous
|
|
538
|
-
|
|
539
|
-
for (const old of [...
|
|
540
|
-
if (old !==
|
|
541
|
-
this.host.
|
|
603
|
+
// Old code can never run over migrated state: drop the previous implementation now, not at retirement.
|
|
604
|
+
object.minCodeVersion = codeVersion;
|
|
605
|
+
for (const old of [...object.implementations.keys()])
|
|
606
|
+
if (old !== codeVersion)
|
|
607
|
+
this.host.retireImplementation(object, old);
|
|
542
608
|
}
|
|
543
609
|
// Schemas first, then the values validated against them in prepare.
|
|
544
|
-
|
|
545
|
-
for (const [name, derived] of run.decls.derives)
|
|
546
|
-
derived.setFn(run.decls.deriveFns.get(name));
|
|
610
|
+
object.refreshSchemas();
|
|
547
611
|
for (const [name, cell] of run.decls.cells) {
|
|
548
612
|
const schema = run.decls.cellSchemas.get(name);
|
|
549
613
|
if (candidate.migrated.has(cell))
|
|
@@ -558,31 +622,31 @@ export class Reload {
|
|
|
558
622
|
if (!created.has(derived))
|
|
559
623
|
derived.refresh();
|
|
560
624
|
candidate.staged?.activate();
|
|
561
|
-
this.#starts.push(() =>
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
if (
|
|
565
|
-
const replacement = new
|
|
566
|
-
// Acquire first: releasing the only
|
|
567
|
-
|
|
568
|
-
this.host.
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
for (const
|
|
572
|
-
// Browser
|
|
573
|
-
if (
|
|
625
|
+
this.#starts.push(() => object.startTasks());
|
|
626
|
+
object.openGate();
|
|
627
|
+
const pinned = this.host.hostObjects.get(object.typeName);
|
|
628
|
+
if (pinned?.object === object && pinned.contract.fingerprint !== service.contract.fingerprint) {
|
|
629
|
+
const replacement = new RefImpl(object, service.contract, "host");
|
|
630
|
+
// Acquire first: releasing the only reference must not dispose/reopen the surviving host object.
|
|
631
|
+
object.addRef(replacement);
|
|
632
|
+
this.host.hostObjects.set(object.typeName, replacement);
|
|
633
|
+
pinned.release();
|
|
634
|
+
}
|
|
635
|
+
for (const ref of [...object.refs]) {
|
|
636
|
+
// Browser references whose contract no longer binds are told to reload their code (they never block a reload).
|
|
637
|
+
if (ref.owner === "client") {
|
|
574
638
|
try {
|
|
575
|
-
bind(service.contract, service.options.accepts,
|
|
639
|
+
bind(service.contract, service.options.accepts, ref.contract);
|
|
576
640
|
}
|
|
577
641
|
catch (error) {
|
|
578
|
-
|
|
642
|
+
ref.gone(new ChordError("upgrade_required", `${object.typeName} changed; reload the web facets`, {
|
|
579
643
|
cause: error,
|
|
580
644
|
}));
|
|
581
|
-
|
|
645
|
+
object.release(ref);
|
|
582
646
|
continue;
|
|
583
647
|
}
|
|
584
648
|
}
|
|
585
|
-
|
|
649
|
+
ref.shapeChanged();
|
|
586
650
|
}
|
|
587
651
|
}
|
|
588
652
|
}
|