mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(chord): calls during a swap never see not_ready; reloads start dependents' fates first
A call arriving while an attempt activates waits for its outcome (bounded by readyTimeout) instead of failing not_ready while its client sees live. Fates start in the reverse of the order objects went live, so a pure dependent of a swapping provider swaps instead of parking.
This commit is contained in:
parent
676af1d777
commit
0c2b568fd9
11 changed files with 578 additions and 23 deletions
|
|
@ -27,6 +27,7 @@
|
|||
- Chord v2's `host.get`, `host.hold`, `host.registry` and a root's `ctx.get` on a host whose `load` was not called throw `not_ready` at once ("call host.load first") instead of waiting for a publication; a client with nothing to load calls `load([])`. Holds arriving on the host's links before `load` still wait for its publication. The HTTP component answers a request before `load` 503 at once.
|
||||
- Chord v2's `host.load` and `host.reload` throw for a plugin name that is no npm package name, before they change anything (it was listed in the report's `failed`).
|
||||
- Chord v2 plugins import each other by relative path within a root (copied into the importer's script) and across repositories by package name through the package's `exports`, pinned in `dependencies`; slice 10's imports by plugin name (`@app/notes/contract`) are removed, and a relative path leaving the root is a build error.
|
||||
- Chord v2's `host.reload` starts the objects' fates dependents first: removals, then the other fates in the reverse of the order the objects went live (the report keeps its order). A pure dependent reloaded with its provider (a view over a store in one plugin) binds the provider while it is still live and swaps instead of parking until the provider's new instance is live: its contributions are replaced in one batch and its holds over links never show `loading`. A dependent still parks when its new code needs the provider's new version, when its provider went live after it, or when it needs something not live.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
- Chord v2's `processLock` on macOS spun until its context ended when a regular file sat at the lock's path (`ENOTSOCK`); it now fails closed naming the file, as for a socket file nobody listens on.
|
||||
- Chord v2's heartbeat closed every link after the process had slept (a laptop's sleep) or blocked its event loop, before reading what the peer had sent meanwhile. Time the process did not run no longer counts as silence: after such a pause each open link pings at once and closes only if its peer stays silent for `close - ping` (15 s by default).
|
||||
- Chord v2's workers no longer leave an address failed when a running worker loses its link (the heartbeat, a protocol error, the worker closing its own link): the worker is stopped, `worker_link_lost { address, error }` is reported, and its holders get a new worker. A second lost link within a minute of the last recovery still fails the address (public error `worker lost its link`). Crashes, `fatal`s and failed starts still stay failed until `retry` or a rollout.
|
||||
- Chord v2's objects no longer answer a call `not_ready` during a reload while the caller sees them `live`: a call over a link to an object whose swap had parked (its new code waiting for a dependency) or whose restart had drained, sent while its hold was still bound to the old instance, got `not_ready: ... not live yet`. Such a call now waits for the object's new attempt (live, failed or ended), bounded by `readyTimeout` (`unavailable/park_timeout`); a parked attempt showing `failed { waiting }` rejects it with that cause, and a hold that leaves the old instance answers it `unavailable` with `loading`.
|
||||
|
||||
## [1.5.0] - 2026-11-02
|
||||
|
||||
|
|
|
|||
|
|
@ -2503,6 +2503,29 @@ validation and copies the v2 kernel added around Delta.
|
|||
only when a removal empties the window or a bulk write hides which rows changed) is carry-over for when a case
|
||||
needs it. Rejected: a window document written beside the table (rows stored twice; app code mirroring every update
|
||||
and delete).
|
||||
- **Swaps: dependents first; calls during a swap never see `not_ready` (owner, 2026-11-04: "if swaps can be invisible
|
||||
to dependents that's cool, but I fear correctness bugs"; analysis `/tmp/swap-analysis.md`):** the problem: a reload
|
||||
swapping a provider and its pure dependent (61's `todo.store` and `todo.todos(groceries)`) parked the dependent
|
||||
every time. `#objects` keeps objects in the order they went live, providers before dependents, and fates started in
|
||||
that order: the store's swap showed `loading`, the view's new attempt bound it, parked, withdrew its contributions
|
||||
(a registry gap) and unbound its holds over links (`loading` on the client). Worse, a parked swap resolved the gate:
|
||||
a call over a link whose hold was still bound (the old instance draining) got `not_ready` while the client showed
|
||||
`live`. Decided: (1) the gate (`ChordObject.call`) holds a call while any attempt activates (a swap, parked or not,
|
||||
a restart after its drain) until the object is live, failed or ended, bounded by `readyTimeout`
|
||||
(`unavailable/park_timeout`, as a call parked on a reference); a parked attempt showing `failed { waiting }`
|
||||
rejects it with that cause. When a parked swap's hold then leaves the old instance it tells the consumer `loading`
|
||||
and answers its calls in flight `unavailable` (the rule for any object leaving `live`): an error consistent with
|
||||
what the client sees. (2) A reload starts the removals first, then the other fates dependents first (the reverse
|
||||
of `#objects`); the report keeps the objects' order. A dependent's new attempt binds its provider while that is
|
||||
still live, passes its check and reads the old instance until the new one is live (one frame): the path a
|
||||
provider-only reload already takes, so no new state. A pure dependent still parks only when its new code needs the
|
||||
provider's new version (the provider's `served` changes with its own fate, so the request waits unbound, then
|
||||
parks on the swap; it never reads that version off the old instance), when its provider went live after it (a
|
||||
provider that failed live and came back), or when it needs something that is not live (a new or missing
|
||||
dependency); so a call waiting at the gate when a swap parks is rare. Rejected: same-host references showing
|
||||
`live` during a swap by changing `ChordObject.state` (holds, keyed pins, versions and `variant` read it as "a live
|
||||
instance exists"; a per-binding view is possible later, the blip is cosmetic). Carry-over: calls surviving a
|
||||
`loading` unbind (slices.md, "Later: reload").
|
||||
- **A plugin's npm dependencies are bundled; external only by opt-in (owner, 2026-10-29: "explicit opt in + (a)"):**
|
||||
a plugin's `dependencies` are installed into its own `node_modules` (`npm install --ignore-scripts`) and bundled into
|
||||
each facet that imports them (host packages, its `peerDependencies`, stay external and are passed in). On Node a
|
||||
|
|
|
|||
|
|
@ -1952,9 +1952,27 @@ type ReloadReport = {
|
|||
|
||||
A restarted object gets a new instance id (an old activation chain never matches it); a swapped one keeps it; a
|
||||
retried one keeps it as any retry does. Cycles are checked again for the new attempts; `parked_long` is unchanged.
|
||||
- **Fates start dependents first** (decision "Swaps: dependents first; calls during a swap never see `not_ready`"):
|
||||
the removals first, then the other fates in the reverse of the order the objects went live (a dependent goes live
|
||||
after its providers). Example: `todo.store` and its view `todo.todos(groceries)`, both pure, in one plugin. Started
|
||||
provider first, the view's new attempt would bind the store while its swap shows `loading` and park (its
|
||||
contributions leaving, its holds over a link unbinding) until the store's new instance is live. Started first, it
|
||||
binds the store while that is still live, passes its dependency check and reads the store's old instance until
|
||||
the new one is live (one frame), its calls waiting at the store's gate: both are reported `swapped`, nothing parks.
|
||||
This is the path a provider-only reload under a live dependent already takes. A removed provider ends first, so a
|
||||
dependent whose new code still needs it parks (decision 4); a provider takes its new served type with its own fate,
|
||||
so a dependent needing a version only the new code serves waits unbound until then and parks on the provider's swap
|
||||
(never reading that version off the old instance). A pure dependent still parks only then, or when its provider
|
||||
went live after it (a provider that failed live and came back), or when its new code needs something that is not
|
||||
live (a new or missing dependency).
|
||||
- **The pure swap** (decision "The seal fence": not a replacement while its calls run): a new attempt of the same
|
||||
object (its documents, line and job records) activates at once; calls arriving meanwhile wait at
|
||||
the gate (bounded by `readyTimeout`, `unavailable/park_timeout`). The new attempt's requests are made and bound
|
||||
the gate (bounded by `readyTimeout`, `unavailable/park_timeout`). The gate holds a call while any attempt of the
|
||||
object activates (a swap, parked or not, a restart after its drain), until the object is live, failed or ended;
|
||||
a parked attempt showing `failed { waiting }` rejects it with that cause. A call never gets `not_ready` there: a
|
||||
hold over a link stays bound while the old instance still changes, so its consumer sees `live` and sends. When the
|
||||
hold then leaves the old instance (a parked swap's retirement), it tells the consumer `loading` and answers the
|
||||
calls still in flight `unavailable` (§22), as for any object leaving `live`. The new attempt's requests are made and bound
|
||||
before the old attempt's are released (a request equal to an old one binds to the same target with no gap, M§3.9).
|
||||
Running calls finish on the old attempt with its handles valid, except R15: an old call touching a document whose
|
||||
version rose throws once (`released/version`), the document intact, the call not resent; the same for a table whose
|
||||
|
|
|
|||
|
|
@ -195,6 +195,18 @@ deleted (the slice's spec and code are the record). Moved here from `carry-over.
|
|||
|
||||
- **`rebuild` of tables** (key and column-type changes): `evolveTable` has no edit for them; build one only when a
|
||||
real case needs it. (Slice 6a's second spec review; out of slice 7b's scope, `slice-7b.md` §12.)
|
||||
- **Calls surviving a `loading` unbind** (decision "Swaps: dependents first; calls during a swap never see
|
||||
`not_ready`", option B): today a call over a link already waiting at the gate when its object's swap parks is
|
||||
answered `unavailable` when the hold leaves the old instance (`Hold.#unbind` → `Provider.unbound` rejects every
|
||||
call in flight on the hold, with `loading`; consistent, rare after the dependents-first order). Sketch, about 5
|
||||
lines: on an unbind for `loading` (no error: the object activates), `Provider.unbound` unbinds the hold but rejects
|
||||
none of its calls: by then the old attempt is always sealed (`failLive`, `#restart` and `#retire` seal before
|
||||
`#stopFollowing`), so its running calls were rejected `released` by the seal fence, and the calls left in flight
|
||||
wait at the gate; they are answered after the fresh `bound` (or by the gate's `readyTimeout`). Checks it needs: the
|
||||
consumer keeps in-flight calls across `loading` (`Consumer.#loading` rejects only calls still waiting for a slot,
|
||||
which may name a method of the old variant; in-flight ones wait for the provider's reply), a relay forwarding
|
||||
`loading` keeps its mapped call ids, a reply on a hold that is pending or binding is accepted, and a variant change
|
||||
across the rebind (the call was decoded by the old names). (Owner, 2026-11-04: separate change, its own risk.)
|
||||
|
||||
### Later: live queries (when a case needs it)
|
||||
|
||||
|
|
|
|||
|
|
@ -1309,7 +1309,7 @@ console.log(" ada adds ' ':", refused);
|
|||
<!-- output: main -->
|
||||
```text
|
||||
9. lists changed, rolled out to: server, list, web, tui
|
||||
on list|groceries: todo.store swapped, todo.todos(groceries) parked
|
||||
on list|groceries: todo.store swapped, todo.todos(groceries) swapped
|
||||
ada adds ' tea ': [ ] tea (ada)
|
||||
ada adds ' ': { code: 'invalid', reason: 'args' }
|
||||
```
|
||||
|
|
@ -1335,8 +1335,10 @@ store was **swapped**, not restarted. The sequence on the list's worker:
|
|||
and references move to its members by name, each with one frame from its last value.
|
||||
3. The old attempt is sealed once its calls settled; its query leaves with it, so later commits run only the new one.
|
||||
|
||||
The views' new attempts need the store, which was still between steps 1 and 2 when they checked it, so they
|
||||
**parked** for that moment and went live with the new code right after. Ada's reference waited a moment, and her next add was trimmed.
|
||||
The views were swapped too, and before the store: Chord starts the fates dependents first, so each view's new
|
||||
attempt found the store still live, read its old values until step 2 and moved to its new members with one frame;
|
||||
its calls to the store waited at the gate. Had the store gone first, the views would have found it between steps 1
|
||||
and 2 and **parked** for that moment. Ada's reference never showed `loading`, and her next add was trimmed.
|
||||
Had the store kept its window as a one-time read in `self.open`, it would count as holding a native and **restart**
|
||||
at every reload of its plugin: running calls drained, natives closed and opened again, a new instance.
|
||||
|
||||
|
|
|
|||
|
|
@ -1150,7 +1150,7 @@ console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' tea ':
|
|||
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' ':"</span>, refused);</code></pre>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code>9. lists changed, rolled out to: server, list, web, tui
|
||||
on list|groceries: todo.store swapped, todo.todos(groceries) parked
|
||||
on list|groceries: todo.store swapped, todo.todos(groceries) swapped
|
||||
ada adds ' tea ': [ ] tea (ada)
|
||||
ada adds ' ': { code: 'invalid', reason: 'args' }</code></pre>
|
||||
<p>The watcher marked <code>@todo/lists</code> dirty when the file changed; <code>applyChanges</code> rebuilt it (one esbuild call per facet)
|
||||
|
|
@ -1190,8 +1190,10 @@ wait at a gate; calls already running finish on the old code.</li>
|
|||
and references move to its members by name, each with one frame from its last value.</li>
|
||||
<li>The old attempt is sealed once its calls settled; its query leaves with it, so later commits run only the new one.</li>
|
||||
</ol>
|
||||
<p>The views' new attempts need the store, which was still between steps 1 and 2 when they checked it, so they
|
||||
<strong>parked</strong> for that moment and went live with the new code right after. Ada's reference waited a moment, and her next add was trimmed.
|
||||
<p>The views were swapped too, and before the store: Chord starts the fates dependents first, so each view's new
|
||||
attempt found the store still live, read its old values until step 2 and moved to its new members with one frame;
|
||||
its calls to the store waited at the gate. Had the store gone first, the views would have found it between steps 1
|
||||
and 2 and <strong>parked</strong> for that moment. Ada's reference never showed <code>loading</code>, and her next add was trimmed.
|
||||
Had the store kept its window as a one-time read in <code>self.open</code>, it would count as holding a native and <strong>restart</strong>
|
||||
at every reload of its plugin: running calls drained, natives closed and opened again, a new instance.</p>
|
||||
<p>Ada's blank todo is now empty after trimming, so the store refused it with
|
||||
|
|
|
|||
|
|
@ -757,8 +757,8 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
* The commit (slice 7b §3), one batch: the served types become the staged ones (an unchanged plugin's stay as
|
||||
* they were), the removed plugins' registry providers go and the reloaded ones' are replaced, their facet
|
||||
* contributions replaced by plugin, every changed registry composed once; a removed type's presence list goes.
|
||||
* Then each object takes its fate, host-lifetime objects of new types start, and the publication wakes the
|
||||
* requests waiting for the names the reload serves.
|
||||
* Then each object takes its fate (`#startFates`), host-lifetime objects of new types start, and the publication
|
||||
* wakes the requests waiting for the names the reload serves.
|
||||
*/
|
||||
#commit(plan: ReloadPlan<P>, context: Context): readonly StartedFate[] {
|
||||
const { staged, changed, removed } = plan;
|
||||
|
|
@ -787,7 +787,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
const names = new Set<string>();
|
||||
for (const [name, served] of types) if (published.has(served.plugin)) names.add(name);
|
||||
for (const [name, { plugin }] of staged.registries) if (published.has(plugin)) names.add(name);
|
||||
const started = plan.fates.map((fate) => this.#startFate(fate));
|
||||
const started = this.#startFates(plan.fates);
|
||||
for (const type of types.values()) {
|
||||
if (type.kind === "local" && type.hostLifetime) this.#object(type, EMPTY_PARAMS, undefined, context);
|
||||
}
|
||||
|
|
@ -795,6 +795,23 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
return started;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts `fates` (in the order of `#objects`: providers before their dependents, `wentLive`) and returns them in
|
||||
* that order, for the report. The removals first, then the others dependents first (decision "Swaps: dependents
|
||||
* first"): a dependent's new attempt binds its provider while that is still live, so it does not park on the
|
||||
* provider's own swap or restart, and reads the old instance until the new one is live. A removed provider is gone
|
||||
* before, so a dependent whose new code still needs it parks (decision 4). A provider takes its new served type with
|
||||
* its own fate: a dependent of a version only that serves waits unbound until then (`ChordObject.hold`), then binds
|
||||
* the swapping provider and parks.
|
||||
*/
|
||||
#startFates(fates: readonly PlannedFate[]): StartedFate[] {
|
||||
const started = new Map<PlannedFate, StartedFate>();
|
||||
for (const fate of fates) if (fate.kind === "end") started.set(fate, this.#startFate(fate));
|
||||
for (const fate of fates.toReversed()) if (fate.kind !== "end") started.set(fate, this.#startFate(fate));
|
||||
// Every fate started above.
|
||||
return fates.map((fate) => started.get(fate)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one object's fate after the commit (slice 7b §4): a removed type's object ends, its holders waiting
|
||||
* `not_served`; any other swaps, restarts or retries on its new served type.
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export type ReloadFate = "swap" | "restart" | "retry";
|
|||
/**
|
||||
* A pure swap in progress (slice 7b §5): the attempt it replaces (its calls run on, then it is sealed), whether the new
|
||||
* attempt parked (holders then see what it shows, and the old one's contributions left the tables), and the end of
|
||||
* the swap (its new attempt live, parked or failed, or the object ended), which calls arriving meanwhile wait for.
|
||||
* the swap (its new attempt live, parked or failed, or the object ended), which the old attempt's retirement waits for.
|
||||
*/
|
||||
type Swap = { readonly old: Attempt; parked: boolean; readonly done: PromiseWithResolvers<void> };
|
||||
|
||||
|
|
@ -226,6 +226,8 @@ export class ChordObject implements Target {
|
|||
#retiring: Map<Attempt, Promise<void>> | undefined;
|
||||
/** Its live queries by table, across its attempts; made by the first table it declares. */
|
||||
#liveQueries: LiveQueries | undefined;
|
||||
/** Resolves at its next change of state, for calls waiting at the gate; made by the first one. */
|
||||
#changed: PromiseWithResolvers<void> | undefined;
|
||||
|
||||
constructor(
|
||||
host: AnyHost,
|
||||
|
|
@ -379,11 +381,10 @@ export class ChordObject implements Target {
|
|||
|
||||
/**
|
||||
* Calls a method of the live object for `caller`, bound at `version` (an older one maps its arguments and result,
|
||||
* Q10). While a reload's pure swap runs, the call waits for its new attempt (the gate), bounded by `readyTimeout`.
|
||||
* Q10). While an attempt activates (a reload's swap or restart), the call waits for its outcome (the gate).
|
||||
*/
|
||||
async call(method: string, args: unknown, context: Context, caller: Caller, version: number): Promise<unknown> {
|
||||
const state = this.#state;
|
||||
if (state.kind === "activating" && state.swap !== undefined) await this.#gate(state.swap, context);
|
||||
if (this.#state.kind === "activating") await this.#gate(context);
|
||||
return this.#live().call(method, args, context, caller, version);
|
||||
}
|
||||
|
||||
|
|
@ -464,11 +465,34 @@ export class ChordObject implements Target {
|
|||
return this.#live().keyMapping(member, version);
|
||||
}
|
||||
|
||||
/** Waits until `swap` ended (its new attempt live, parked, failed, or the object ended). */
|
||||
async #gate(swap: Swap, context: Context): Promise<void> {
|
||||
// No timer of its own: the swap ends when its new attempt is live, parked or failed, and `readyTimeout` bounds
|
||||
// the new attempt's own steps (Fable).
|
||||
await awaitWithContext(swap.done.promise, context);
|
||||
/**
|
||||
* Waits while an attempt activates until the object is live, failed or ended (`#live` then calls or throws);
|
||||
* rejects with the cause once the attempt parks on a failed dependency (`failed { waiting }`), and with
|
||||
* `unavailable/park_timeout` after `readyTimeout`, as a call parked on a reference. A hold over a link stays bound
|
||||
* while the old instance still changes (a swap, a restart's drain), so its consumer sees `live` and sends calls
|
||||
* that arrive here: they wait for the new attempt, never `not_ready` (decision "Swaps: dependents first").
|
||||
*/
|
||||
async #gate(context: Context): Promise<void> {
|
||||
const { ready } = this.host.timeouts;
|
||||
const due = Date.now() + ready;
|
||||
for (;;) {
|
||||
const state = this.#state;
|
||||
if (state.kind !== "activating") return;
|
||||
if (state.shown.kind === "failed") throw state.shown.cause.error;
|
||||
const left = due - Date.now();
|
||||
if (left <= 0) {
|
||||
const message = `${this.label}: a call waited ${ready} ms for its object to go live`;
|
||||
throw new ChordError({ code: "unavailable", reason: "park_timeout", message });
|
||||
}
|
||||
this.#changed ??= Promise.withResolvers();
|
||||
const timer = Promise.withResolvers<void>();
|
||||
const timeout = setTimeout(timer.resolve, left);
|
||||
try {
|
||||
await awaitWithContext(Promise.race([this.#changed.promise, timer.promise]), context);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The keyed member `member` of the live object: its ultimate slot (slice 6a §12). */
|
||||
|
|
@ -1098,6 +1122,7 @@ export class ChordObject implements Target {
|
|||
|
||||
/** Tells every holder that the state changed: references first follow what it shows now (`followed`). */
|
||||
#notify(): void {
|
||||
this.#wakeGate();
|
||||
const holders = [...this.#holders];
|
||||
// In one batch when some reference's members move (one frame each, a diamond over several sees one change); no
|
||||
// commit at all for references that used none (activation churn).
|
||||
|
|
@ -1109,6 +1134,12 @@ export class ChordObject implements Target {
|
|||
for (const holder of holders) holder.moved();
|
||||
}
|
||||
|
||||
/** Calls waiting at the gate check the state again. */
|
||||
#wakeGate(): void {
|
||||
this.#changed?.resolve();
|
||||
this.#changed = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the object ended: forgotten by the host, waits rejected, references to it ended with `error` (and gone from
|
||||
* its type's presence), its job runs ended (signals fired, waiters rejected), its transactions refused, its attempt
|
||||
|
|
@ -1125,6 +1156,7 @@ export class ChordObject implements Target {
|
|||
// Its swapped-out attempts end before its teardown completes (they share its documents).
|
||||
const previous = retiring === undefined ? endings : Promise.all([endings, retiring]).then(() => {});
|
||||
this.#state = { kind: "ended", ended: { kind: "ended", error } };
|
||||
this.#wakeGate();
|
||||
for (const records of this.#jobs.values()) records.end(error);
|
||||
if (this.#flush !== undefined) clearTimeout(this.#flush.timer);
|
||||
this.#flush = undefined;
|
||||
|
|
|
|||
|
|
@ -204,9 +204,10 @@ if (terminal !== undefined) {
|
|||
// 9. An edit applied in place: the lists plugin's worker facet trims a todo's text, so a blank one is empty and the
|
||||
// store refuses it with Chord's `invalid/args`, which reaches Ada as it would a refused argument. The
|
||||
// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and
|
||||
// the terminal. On the worker each object of the plugin takes its fate: the store opens no native, so it is swapped
|
||||
// in place (its live query runs again); the views wait for it, then go on with the new code. Ada's
|
||||
// reference follows them.
|
||||
// the terminal. On the worker each object of the plugin takes its fate, dependents first: each view swaps in place
|
||||
// while the store is still live, then the store, which opens no native, swaps in place too (its live query runs
|
||||
// again); the views read the store's old values until its new attempt is live, and their calls wait for it. Ada's
|
||||
// reference follows them and never shows `loading`.
|
||||
// The edit: `add` in the `Todos` view, before and after.
|
||||
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
|
||||
const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
type FacetSelf,
|
||||
type Host,
|
||||
type HostError,
|
||||
type PluginFacet,
|
||||
type Reference,
|
||||
type Self,
|
||||
type StoreSelf,
|
||||
untilAborted,
|
||||
|
|
@ -30,6 +32,7 @@ import {
|
|||
flushed,
|
||||
holdNextCommit,
|
||||
instancesOf,
|
||||
linked,
|
||||
loaded,
|
||||
memoryStore,
|
||||
metered,
|
||||
|
|
@ -44,6 +47,7 @@ import {
|
|||
StoreRestartable,
|
||||
statesOf,
|
||||
storeRestartable,
|
||||
type TestPrincipal,
|
||||
testCodec,
|
||||
transported,
|
||||
valuesOf,
|
||||
|
|
@ -1408,6 +1412,234 @@ describe("reload: fates (slice 7b §4)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('reload: fates start dependents first (decision "Swaps: dependents first")', () => {
|
||||
/** A provider: its name and `echo`, which answers it. */
|
||||
const Base = local("app.base", { version: 1, params: {}, members: { name: str(), echo: method(undefined, str()) } });
|
||||
/** `Base` v2: a member added, so a consumer of v2 binds only the new code. */
|
||||
const BaseV2 = evolve(Base, (e) => e.addState("extra", str()));
|
||||
/** A dependent with empty params (it contributes): its label over the provider's name, `ask` through `echo`. */
|
||||
const User = local("app.user", { version: 1, params: {}, members: { label: str(), ask: method(undefined, str()) } });
|
||||
/** A remote dependent held over a link. */
|
||||
const Viewer = remote("app.viewer", {
|
||||
version: 1,
|
||||
params: {},
|
||||
hostAddress: () => Server(),
|
||||
variants: { v: { state: { label: str() }, methods: { ask: method(undefined, str()) } } },
|
||||
});
|
||||
const Names = defineRegistry<string, string[]>("app.user-names", { version: 1 });
|
||||
|
||||
/** A host loaded with `facets`, its background errors collected. */
|
||||
async function loadedFacets(facets: readonly PluginFacet[]) {
|
||||
const errors: HostError[] = [];
|
||||
const host = createHost({ onError: (error) => errors.push(error) });
|
||||
await host.load(facets, context);
|
||||
return { host, errors };
|
||||
}
|
||||
|
||||
/** Serves `Base` as code `code`; its new code opens a native that waits for `opened` when given (a restart). */
|
||||
function serveBase(h: FacetHost, code: string, opened?: Promise<void>): void {
|
||||
h.serve(Base, (_, self) => {
|
||||
if (opened !== undefined)
|
||||
self.open(
|
||||
async () => await opened,
|
||||
() => {},
|
||||
);
|
||||
return { name: constant(code), echo: async () => code };
|
||||
});
|
||||
}
|
||||
|
||||
/** A dependent's members as code `code` over `base`: its label over the provider's name, `ask` through `echo`. */
|
||||
function over(own: Self, code: string, base: Reference<typeof Base>) {
|
||||
return {
|
||||
label: own.derive([base.name], ([name]) => `${code} ${name}`),
|
||||
ask: async (ctx: Context) => `${code}>${await base.echo(ctx)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serves `User` as code `code` over `Base`. */
|
||||
function serveUser(h: FacetHost, code: string): void {
|
||||
h.serve(User, (_, own) => over(own, code, own.get(Base, {})));
|
||||
}
|
||||
|
||||
it("a pure dependent and its provider in one plugin both swap: no parking, its contribution replaced in one batch", async () => {
|
||||
const composed: string[][] = [];
|
||||
const registry = defineFacet((h) =>
|
||||
h.serve(Names, {
|
||||
compose: (table) => {
|
||||
const names = table.map(({ value }) => value);
|
||||
composed.push(names);
|
||||
return names;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const app = (code: string) =>
|
||||
defineFacet((h) => {
|
||||
serveBase(h, code);
|
||||
h.serve(User, (_, own) => {
|
||||
(own as unknown as StoreSelf).contribute(Names, code);
|
||||
return over(own, code, own.get(Base, {}));
|
||||
});
|
||||
});
|
||||
const { host, errors } = await loadedFacets([
|
||||
{ name: "@test/app", facet: app("v1") },
|
||||
{ name: "@test/names", facet: registry },
|
||||
]);
|
||||
const user = await host.get(User, {}, context);
|
||||
const names = await host.registry(Names, context);
|
||||
expect(names.value).toEqual(["v1"]);
|
||||
const frames: string[][] = [];
|
||||
(await names.watch(context)).start(async (call) => {
|
||||
if (call.kind === "value") frames.push(call.value);
|
||||
});
|
||||
composed.length = 0;
|
||||
const report = await host.reload(
|
||||
[
|
||||
{ name: "@test/app", facet: app("v2") },
|
||||
{ name: "@test/names", facet: registry },
|
||||
],
|
||||
context,
|
||||
);
|
||||
// The report keeps the order of the objects: the provider first.
|
||||
expect(report.objects).toEqual([
|
||||
{ object: "app.base", kind: "swapped" },
|
||||
{ object: "app.user", kind: "swapped" },
|
||||
]);
|
||||
await flushed();
|
||||
// The old contribution left in the batch the new one entered: one composition, never a table without it.
|
||||
expect(names.value).toEqual(["v2"]);
|
||||
expect(composed).toEqual([["v2"]]);
|
||||
await delivered();
|
||||
expect(frames).toEqual([["v2"]]);
|
||||
expect(user.label.value).toBe("v2 v2");
|
||||
expect(await user.ask(context)).toBe("v2>v2");
|
||||
expect(failures(errors)).toEqual([]);
|
||||
await host.close(context);
|
||||
});
|
||||
|
||||
it("a remote dependent's holds never see loading; its calls reach the provider's new code", async () => {
|
||||
const serve = (h: FacetHost<TestPrincipal>, code: string) => {
|
||||
serveBase(h as FacetHost, code);
|
||||
h.serve(Viewer, (_, _principal, own) => ({ variant: "v", members: over(own, code, own.get(Base, {})) }));
|
||||
};
|
||||
const { server, client } = await linked((h) => serve(h, "v1"));
|
||||
const viewer = await client.get(Viewer, {}, context);
|
||||
const states = statesOf(await viewer.state.watch(context));
|
||||
const report = await server.reload(
|
||||
[{ name: "@test/app", facet: defineFacet<TestPrincipal>((h) => serve(h, "v2")) }],
|
||||
context,
|
||||
);
|
||||
expect(report.objects).toEqual([
|
||||
{ object: "app.base", kind: "swapped" },
|
||||
{ object: "app.viewer", kind: "swapped" },
|
||||
]);
|
||||
expect(await viewer.ask(context)).toBe("v2>v2");
|
||||
await transported();
|
||||
expect(viewer.label.value).toBe("v2 v2");
|
||||
expect(states).toEqual([]);
|
||||
await client.close(context);
|
||||
await server.close(context);
|
||||
});
|
||||
|
||||
it("a dependent whose new code needs the provider's new version parks, then goes live: never on the old instance", async () => {
|
||||
const app = (code: string, next: boolean) =>
|
||||
defineFacet((h) => {
|
||||
if (next) {
|
||||
h.serve(BaseV2, () => ({ name: constant(code), extra: constant("extra"), echo: async () => code }));
|
||||
} else serveBase(h, code);
|
||||
h.serve(User, (_, own) => {
|
||||
if (next) {
|
||||
const base = own.get(BaseV2, {});
|
||||
return {
|
||||
label: own.derive([base.name, base.extra], ([name, extra]) => `${code} ${name} ${extra}`),
|
||||
ask: async (ctx: Context) => `${code}>${await base.echo(ctx)}`,
|
||||
};
|
||||
}
|
||||
return over(own, code, own.get(Base, {}));
|
||||
});
|
||||
});
|
||||
const { host, errors } = await loadedFacets([{ name: "@test/app", facet: app("v1", false) }]);
|
||||
const user = await host.get(User, {}, context);
|
||||
const report = await host.reload([{ name: "@test/app", facet: app("v2", true) }], context);
|
||||
// Started first, the dependent's v2 request does not bind the provider still on v1 (`ChordObject.hold`): it
|
||||
// waits unbound, then binds the provider's swap and parks until its new instance is live.
|
||||
expect(report.objects).toEqual([
|
||||
{ object: "app.base", kind: "swapped" },
|
||||
{
|
||||
object: "app.user",
|
||||
kind: "parked",
|
||||
cause: {
|
||||
kind: "waiting",
|
||||
error: expect.objectContaining({ message: expect.stringContaining("app.base v2 is not served here") }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
await flushed();
|
||||
expect(user.state.value).toEqual({ kind: "live" });
|
||||
expect(user.label.value).toBe("v2 v2 extra");
|
||||
expect(await user.ask(context)).toBe("v2>v2");
|
||||
expect(errors.filter(({ kind }) => kind === "activation_failed" || kind === "object_failed")).toEqual([]);
|
||||
await host.close(context);
|
||||
});
|
||||
|
||||
it("a pure dependent of a provider that restarts swaps; its calls park until the provider's new instance is live", async () => {
|
||||
const opened = Promise.withResolvers<void>();
|
||||
const app = (code: string, natives: boolean) =>
|
||||
defineFacet((h) => {
|
||||
serveBase(h, code, natives ? opened.promise : undefined);
|
||||
serveUser(h, code);
|
||||
});
|
||||
const { host } = await loadedFacets([{ name: "@test/app", facet: app("v1", false) }]);
|
||||
const user = await host.get(User, {}, context);
|
||||
const report = await host.reload([{ name: "@test/app", facet: app("v2", true) }], context);
|
||||
expect(report.objects).toEqual([
|
||||
{ object: "app.base", kind: "restarted" },
|
||||
{ object: "app.user", kind: "swapped" },
|
||||
]);
|
||||
await flushed();
|
||||
// Live on its new code over the provider's last values while the provider restarts.
|
||||
expect(user.state.value).toEqual({ kind: "live" });
|
||||
expect(user.label.value).toBe("v2 v1");
|
||||
const asked = user.ask(context);
|
||||
await flushed();
|
||||
opened.resolve();
|
||||
expect(await asked).toBe("v2>v2");
|
||||
await flushed();
|
||||
expect(user.label.value).toBe("v2 v2");
|
||||
await host.close(context);
|
||||
});
|
||||
|
||||
it("a provider that went live after its dependent (a retry after a failure) swaps first: the dependent parks and recovers", async () => {
|
||||
const selves: Self[] = [];
|
||||
const app = (code: string) =>
|
||||
defineFacet((h) => {
|
||||
h.serve(Base, (_, own) => {
|
||||
selves.push(own);
|
||||
return { name: constant(code), echo: async () => code };
|
||||
});
|
||||
serveUser(h, code);
|
||||
});
|
||||
const { host } = await loadedFacets([{ name: "@test/app", facet: app("v1") }]);
|
||||
const user = await host.get(User, {}, context);
|
||||
const base = await host.get(Base, {}, context);
|
||||
failLive(attempted, selves.at(-1)!, new ChordError({ code: "unavailable", message: "boom" }));
|
||||
await flushed();
|
||||
base.retry();
|
||||
await flushed();
|
||||
expect(base.state.value).toEqual({ kind: "live" });
|
||||
const report = await host.reload([{ name: "@test/app", facet: app("v2") }], context);
|
||||
// The objects in the order they went live: the dependent first, so the provider's swap starts before it.
|
||||
expect(report.objects).toEqual([
|
||||
{ object: "app.user", kind: "parked", cause: expect.objectContaining({ kind: "waiting" }) },
|
||||
{ object: "app.base", kind: "swapped" },
|
||||
]);
|
||||
await flushed();
|
||||
expect(user.state.value).toEqual({ kind: "live" });
|
||||
expect(user.label.value).toBe("v2 v2");
|
||||
expect(await user.ask(context)).toBe("v2>v2");
|
||||
await host.close(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reload: the pure swap (slice 7b §5)", () => {
|
||||
it("lets a running call finish on the old attempt's handles; a new call waits at the gate for the new code", async () => {
|
||||
const gate = Promise.withResolvers<void>();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type Context, BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
||||
import { bool, evolve, hostKind, int, local, method, obj, remote, str } from "../../src/contracts/index.ts";
|
||||
import { bool, evolve, hostKind, int, keyed, local, method, obj, remote, str } from "../../src/contracts/index.ts";
|
||||
import { broken, ChordError } from "../../src/data/index.ts";
|
||||
import type * as Delta from "../../src/delta/index.ts";
|
||||
import { decoder } from "../../src/delta/index.ts";
|
||||
|
|
@ -20,8 +20,10 @@ import {
|
|||
fakeTimers,
|
||||
flushed,
|
||||
forEachCodec,
|
||||
holdNextCommit,
|
||||
instancesOf,
|
||||
linked,
|
||||
memoryStore,
|
||||
PlacedNotes,
|
||||
partValue,
|
||||
RawPeer,
|
||||
|
|
@ -1367,3 +1369,215 @@ describe("a hold across a swap whose mapping fails (final review)", () => {
|
|||
await server.close(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calls over a link while a swap parks (decision "Swaps: dependents first")', () => {
|
||||
/** A provider on the server: its name, its keys (`entries`) and `set`, which writes key `x`. */
|
||||
const Provider = local("app.gate-provider", {
|
||||
version: 1,
|
||||
params: {},
|
||||
members: { name: str(), entries: keyed(obj({ n: int() })), set: method(int()) },
|
||||
});
|
||||
const ENTRIES = { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }), keyed: true } as const;
|
||||
const Entries = defineState("entries", ENTRIES);
|
||||
const PersistedEntries = defineState("entries", { ...ENTRIES, persist: true, checkpointWhen: () => false });
|
||||
/** A local type whose native opens once `opened` resolves. */
|
||||
const Slow = local("app.gate-slow", { version: 1, params: {}, members: { name: str() } });
|
||||
/** A remote object over the provider: `bump` answers its code's number. */
|
||||
const Gated = remote("app.gated", {
|
||||
version: 1,
|
||||
params: {},
|
||||
hostAddress: () => Server(),
|
||||
variants: { v: { state: { label: str() }, methods: { bump: method(undefined, int()) } } },
|
||||
});
|
||||
|
||||
/** Serves `Slow`, whose native opens once `opened` resolves. */
|
||||
function serveSlow(h: FacetHost<TestPrincipal>, opened: Promise<void>): void {
|
||||
h.serve(Slow, (_, self) => {
|
||||
self.open(
|
||||
async () => {
|
||||
await opened;
|
||||
return null;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return { name: constant("slow") };
|
||||
});
|
||||
}
|
||||
|
||||
/** Serves the provider labelled `name`, needing `Slow` when `slow`, its keys persisted when `persisted`. */
|
||||
function serveProvider(h: FacetHost<TestPrincipal>, name: string, options: { slow?: boolean; persisted?: boolean }) {
|
||||
h.serve(Provider, (_, self) => {
|
||||
if (options.slow === true) self.get(Slow, {});
|
||||
const entries = self.state(options.persisted === true ? PersistedEntries : Entries);
|
||||
return {
|
||||
name: constant(name),
|
||||
entries,
|
||||
set: async (n: number, ctx: Context) => {
|
||||
await entries.change(
|
||||
"x",
|
||||
(draft) => {
|
||||
draft.n = n;
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves `Gated` as code `code`: its label over the provider's name (or its key `k`), `bump` first awaiting `gate`;
|
||||
* needing `Slow` too when `slow`.
|
||||
*/
|
||||
function serveGated(
|
||||
h: FacetHost<TestPrincipal>,
|
||||
code: number,
|
||||
options: { gate?: Promise<void>; pin?: boolean; slow?: boolean },
|
||||
) {
|
||||
h.serve(Gated, (_, _principal, self) => {
|
||||
const provider = self.get(Provider, {});
|
||||
if (options.slow === true) self.get(Slow, {});
|
||||
const label =
|
||||
options.pin === true
|
||||
? self.derive([provider.entries.at("k")], ([entry]) => `v${code} ${entry.n}`)
|
||||
: self.derive([provider.name], ([name]) => `v${code} ${name}`);
|
||||
return {
|
||||
variant: "v",
|
||||
members: {
|
||||
label,
|
||||
bump: async () => {
|
||||
await options.gate;
|
||||
return code;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it("a call arriving after the swap parked, while an old call drains, waits for the new attempt (never not_ready)", async () => {
|
||||
const gate = Promise.withResolvers<void>();
|
||||
const opened = Promise.withResolvers<void>();
|
||||
const { server, client } = await linked((h) => {
|
||||
serveProvider(h, "p1", {});
|
||||
serveGated(h, 1, { gate: gate.promise });
|
||||
});
|
||||
const gated = await client.get(Gated, {}, context);
|
||||
const old = gated.bump(context);
|
||||
await transported();
|
||||
const report = await server.reload(
|
||||
[
|
||||
{
|
||||
name: "@test/app",
|
||||
facet: defineFacet<TestPrincipal>((h) => {
|
||||
serveSlow(h, opened.promise);
|
||||
serveProvider(h, "p2", {});
|
||||
serveGated(h, 2, { slow: true });
|
||||
}),
|
||||
},
|
||||
],
|
||||
context,
|
||||
);
|
||||
// The new code of `Gated` waits for `Slow`, a new type still opening: its swap parks.
|
||||
expect(report.objects).toEqual([
|
||||
{ object: "app.gate-provider", kind: "swapped" },
|
||||
{ object: "app.gated", kind: "parked", cause: expect.objectContaining({ kind: "waiting" }) },
|
||||
]);
|
||||
// The old call still runs, so the hold stays bound: the client sees live and sends.
|
||||
const outcome = gated.bump(context).then(
|
||||
(value) => ({ value }),
|
||||
(error: ChordError) => ({ code: error.code }),
|
||||
);
|
||||
await transported();
|
||||
expect(gated.state.value).toEqual({ kind: "live" });
|
||||
opened.resolve();
|
||||
expect(await outcome).toEqual({ value: 2 });
|
||||
gate.resolve();
|
||||
expect(await old).toBe(1);
|
||||
await transported();
|
||||
expect(gated.label.value).toBe("v2 p2");
|
||||
await client.close(context);
|
||||
await server.close(context);
|
||||
});
|
||||
|
||||
it("a call already waiting at the gate when the swap parks is answered with its hold's loading, never not_ready", async () => {
|
||||
const store = await memoryStore();
|
||||
const server = createHost<TestPrincipal>({
|
||||
address: Server(),
|
||||
principal: { kind: "server" },
|
||||
store,
|
||||
onError: () => {},
|
||||
});
|
||||
const client = createHost<TestPrincipal>({ onError: () => {} });
|
||||
const providerV1 = defineFacet<TestPrincipal>((h) => serveProvider(h, "p1", { persisted: true }));
|
||||
await server.load(
|
||||
[
|
||||
{ name: "@test/provider", facet: providerV1 },
|
||||
{ name: "@test/app", facet: defineFacet<TestPrincipal>((h) => serveGated(h, 1, {})) },
|
||||
],
|
||||
context,
|
||||
);
|
||||
await client.load([], context);
|
||||
const [up, down] = memoryLinkPair();
|
||||
server.accept(up, { principal: { kind: "owner" }, codec: testCodec() }, context);
|
||||
client.connect(down, { principal: { kind: "server" }, codec: testCodec() }, context);
|
||||
try {
|
||||
const gated = await client.get(Gated, {}, context);
|
||||
const provider = await server.get(Provider, {}, context);
|
||||
// The provider's line is held by a commit: a key loads only after it.
|
||||
const held = holdNextCommit(store);
|
||||
const setting = provider.set(1, context);
|
||||
await held.entered;
|
||||
// `Gated`'s new code pins the provider's key `k`: its swap stays in place while the key waits for the line.
|
||||
const appV2 = defineFacet<TestPrincipal>((h) => serveGated(h, 2, { pin: true }));
|
||||
const report = await server.reload(
|
||||
[
|
||||
{ name: "@test/provider", facet: providerV1 },
|
||||
{ name: "@test/app", facet: appV2 },
|
||||
],
|
||||
context,
|
||||
);
|
||||
expect(report.objects).toEqual([{ object: "app.gated", kind: "swapped" }]);
|
||||
const states = statesOf(await gated.state.watch(context));
|
||||
const outcome = gated.bump(context).then(
|
||||
(value) => ({ value }),
|
||||
(error: ChordError) => ({ code: error.code, message: error.message }),
|
||||
);
|
||||
await transported();
|
||||
// The provider swaps too while the key waits; its new code needs \`Slow\`, still opening. Once the key loaded,
|
||||
// the swap of \`Gated\` checks again, finds the provider loading and parks, with the call waiting at the gate.
|
||||
const opened = Promise.withResolvers<void>();
|
||||
const reloading = server.reload(
|
||||
[
|
||||
{
|
||||
name: "@test/provider",
|
||||
facet: defineFacet<TestPrincipal>((h) => {
|
||||
serveSlow(h, opened.promise);
|
||||
serveProvider(h, "p2", { persisted: true, slow: true });
|
||||
}),
|
||||
},
|
||||
{ name: "@test/app", facet: appV2 },
|
||||
],
|
||||
context,
|
||||
);
|
||||
held.release();
|
||||
await setting;
|
||||
expect((await reloading).objects).toEqual([
|
||||
{ object: "app.gate-provider", kind: "parked", cause: expect.objectContaining({ kind: "waiting" }) },
|
||||
]);
|
||||
await transported();
|
||||
// Parked: its hold left the old instance and tells the client \`loading\`; the call that waited at the gate is
|
||||
// answered as every call in flight on a hold that leaves its object (§21), never \`not_ready\` while live.
|
||||
expect(states).toEqual(["loading"]);
|
||||
expect(await outcome).toEqual({ code: "unavailable", message: "hold 0: its object rebinds" });
|
||||
opened.resolve();
|
||||
await transported();
|
||||
expect(states).toEqual(["loading", "live"]);
|
||||
expect(await gated.bump(context)).toBe(2);
|
||||
expect(gated.label.value).toBe("v2 0");
|
||||
} finally {
|
||||
await client.close(context);
|
||||
await server.close(context);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue