@ccmsg/cli 0.12.0 → 0.14.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/src/mesh/mesh.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  type SettledIdentity,
19
19
  } from "../dispatch/index.ts";
20
20
  import type { TopicValue } from "../topics/index.ts";
21
- import { AUTH_TOPIC, isClusterTopic, Relay } from "./relay.ts";
21
+ import { AUTH_TOPIC, isMeshTopic, Relay } from "./relay.ts";
22
22
  import {
23
23
  ALLOWED_ALGS,
24
24
  EphemeralKey,
@@ -388,7 +388,7 @@ export class Mesh {
388
388
  * whether it can be reached right now (DESIGN §7.5).
389
389
  *
390
390
  * A peer no handshake has settled yet is listed without an id. The operator
391
- * configured that endpoint, so it is an entry of the cluster whether or not
391
+ * configured that endpoint, so it is an entry of the mesh whether or not
392
392
  * anything has answered there — and leaving it out would hide exactly the
393
393
  * peer whose link is down, which is the one a reader is looking for. */
394
394
  instances(): InstanceInfo[] {
@@ -408,7 +408,7 @@ export class Mesh {
408
408
 
409
409
  /** Whether any peer is currently out of reach.
410
410
  *
411
- * What separates "no instance in the cluster knows this session" from "an
411
+ * What separates "no instance in the mesh knows this session" from "an
412
412
  * instance that might know it cannot be asked" — the one distinction DESIGN §6.6
413
413
  * says rests on the mesh's connection state and on nothing else. */
414
414
  anyUnreachable(): boolean {
@@ -417,7 +417,7 @@ export class Mesh {
417
417
 
418
418
  /** Which instance should answer for a session this instance does not hold.
419
419
  *
420
- * A session the cluster has named belongs to the instance its `peers` row
420
+ * A session the mesh has named belongs to the instance its `peers` row
421
421
  * states. One nobody has named while a peer is out of reach is answered with
422
422
  * that peer: forwarding there fails and the caller is told
423
423
  * `instance_unreachable`, which is what DESIGN §6.6 asks for in place of deciding
@@ -532,7 +532,7 @@ export class Mesh {
532
532
  *
533
533
  * The caller the envelope names, on the link it arrived over. A request that
534
534
  * names none is dispatched as the link itself, whose role is `instance` —
535
- * which the attribute table already answers, since no instance-local op is
535
+ * which the attribute table already answers, since no owner_instance op is
536
536
  * open to an instance. */
537
537
  caller(conn: Requester, caller: CallerIdentity | undefined): Requester {
538
538
  const link = this.#linkOf.get(conn);
@@ -553,12 +553,12 @@ export class Mesh {
553
553
 
554
554
  /** The current value of a relayed topic, one entry per instance that has
555
555
  * stated one. Handed to a fresh local subscriber beside this instance's own
556
- * snapshot, so it opens on the cluster rather than on us. */
556
+ * snapshot, so it opens on the mesh rather than on us. */
557
557
  snapshot(topic: string): readonly TopicValue[] {
558
558
  return this.relay.snapshot(topic);
559
559
  }
560
560
 
561
- /** A local subscriber appeared on a cluster topic, or the last one left.
561
+ /** A local subscriber appeared on a mesh topic, or the last one left.
562
562
  *
563
563
  * The subscription travels: what a subscriber asks of this instance, this
564
564
  * instance asks of every peer, and the frames come back unchanged (DESIGN §7.4).
@@ -567,7 +567,7 @@ export class Mesh {
567
567
  // `auth.records` is never given up and never asked for on demand: every
568
568
  // instance holds the whole set whether or not anything local is watching
569
569
  // it, the way `peers` is also the routing table (DESIGN §7.4, DR-0001 §2.6).
570
- if (topic === AUTH_TOPIC || !isClusterTopic(topic)) return;
570
+ if (topic === AUTH_TOPIC || !isMeshTopic(topic)) return;
571
571
  if (wanted) {
572
572
  if (this.#demanded.has(topic)) return;
573
573
  this.#demanded.add(topic);
@@ -593,7 +593,7 @@ export class Mesh {
593
593
  topic,
594
594
  // The instance asks on behalf of whoever subscribed to it, and what they
595
595
  // have in common is that they are this deployment's people rather than
596
- // any one session: a cluster topic is the same value for all of them
596
+ // any one session: a mesh topic is the same value for all of them
597
597
  // (DESIGN §6.2), so there is nothing narrower to name.
598
598
  //
599
599
  // `auth.records` is the exception, and the one topic no person may hear:
@@ -1090,12 +1090,12 @@ export class Mesh {
1090
1090
  //
1091
1091
  // The far end has no other way to learn this instance is going: it would
1092
1092
  // keep the link, keep answering `reachable`, and keep routing
1093
- // `instance-local` ops here until its own heartbeat gave up minutes later,
1093
+ // `owner_instance` ops here until its own heartbeat gave up minutes later,
1094
1094
  // where the disconnection of DESIGN §7.5 is supposed to be immediate. Which side
1095
1095
  // dialled a link is decided by the glare rule from a comparison of
1096
1096
  // endpoint strings (mesh-peer-auth §8.1), so which of a peer's links this instance
1097
1097
  // accepted is not something either end chose — leaving those open makes a
1098
- // clean stop look like a silent one to whichever half of the cluster the
1098
+ // clean stop look like a silent one to whichever half of the mesh the
1099
1099
  // comparison put on this side.
1100
1100
  //
1101
1101
  // An accepted socket is transport's to release (DESIGN §8.5 step 5), and left to
package/src/mesh/relay.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "@ccmsg/protocol";
10
10
  import { Elements, type TopicValue } from "../topics/index.ts";
11
11
 
12
- /** The rows of sessions the whole cluster is seen through.
12
+ /** The rows of sessions the whole mesh is seen through.
13
13
  *
14
14
  * They are `element`-granular, and an element topic is relayable only when its
15
15
  * elements say whose they are: a row here names the instance that holds the
@@ -19,13 +19,13 @@ import { Elements, type TopicValue } from "../topics/index.ts";
19
19
  * such name — an `inbox` frame belongs to a session, not to an instance. */
20
20
  const ROW_TOPICS: readonly string[] = ["peers", "agents"];
21
21
 
22
- /** The topics a subscriber sees the whole cluster on.
22
+ /** The topics a subscriber sees the whole mesh on.
23
23
  *
24
24
  * A per-instance whole is relayable by construction (DESIGN §6.2): a frame replaces
25
25
  * its own instance's entries and leaves every other instance's alone, so
26
26
  * several instances can state the same topic name without colliding. The rows
27
27
  * above are relayable for the same reason read one element at a time. */
28
- export const CLUSTER_TOPICS: readonly string[] = [
28
+ export const MESH_TOPICS: readonly string[] = [
29
29
  ...PLAIN_TOPICS.filter((topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole"),
30
30
  ...ROW_TOPICS,
31
31
  ];
@@ -37,8 +37,8 @@ export const CLUSTER_TOPICS: readonly string[] = [
37
37
  * the set this instance holds rather than held here (DR-0001 §2.6). */
38
38
  export const AUTH_TOPIC = "auth.records";
39
39
 
40
- export function isClusterTopic(topic: string): boolean {
41
- return CLUSTER_TOPICS.includes(topic);
40
+ export function isMeshTopic(topic: string): boolean {
41
+ return MESH_TOPICS.includes(topic);
42
42
  }
43
43
 
44
44
  /** Whether what a frame of this topic carries is rows to be merged rather than
@@ -64,7 +64,7 @@ export interface RelayDeps {
64
64
  }
65
65
 
66
66
  /** What the peers said, held on this instance so that losing a peer does not
67
- * empty the cluster view (DESIGN §7.5).
67
+ * empty the mesh view (DESIGN §7.5).
68
68
  *
69
69
  * Two things live here and nowhere else: the last whole value each instance
70
70
  * stated per topic, and whether that instance can be reached right now. The
@@ -99,7 +99,7 @@ export class Relay {
99
99
  * unchanged — recomputing it would put the same judgement in two places
100
100
  * (DESIGN §7.4). */
101
101
  accept(instance: InstanceId, topic: string, data: unknown, snapshot = false): void {
102
- if (!isClusterTopic(topic)) return;
102
+ if (!isMeshTopic(topic)) return;
103
103
  this.#sweep();
104
104
  const held = this.#held.get(instance) ?? new Map<string, unknown>();
105
105
  this.#held.set(instance, held);
@@ -174,7 +174,7 @@ export class Relay {
174
174
  return values;
175
175
  }
176
176
 
177
- /** Which instance a session belongs to, read from the cluster values the
177
+ /** Which instance a session belongs to, read from the mesh values the
178
178
  * peers stated (DESIGN §7.3).
179
179
  *
180
180
  * `peers` names every session an instance holds, connected and lost alike,
@@ -2,6 +2,8 @@ import type {
2
2
  CallerIdentity,
3
3
  CandidateSession,
4
4
  InboxMessage,
5
+ InboxRemoved,
6
+ InboxRemovedReason,
5
7
  InstanceId,
6
8
  MessageSendArgs,
7
9
  MessageSendResult,
@@ -21,7 +23,12 @@ import {
21
23
  OpError,
22
24
  type Requester,
23
25
  } from "../dispatch/index.ts";
24
- import type { PublishOutcome, TopicValue, UpstreamResource } from "../topics/index.ts";
26
+ import {
27
+ PEOPLE,
28
+ type PublishOutcome,
29
+ type TopicValue,
30
+ type UpstreamResource,
31
+ } from "../topics/index.ts";
25
32
  import type { DirectRoute } from "./direct.ts";
26
33
  import type { Inbox } from "./inbox.ts";
27
34
 
@@ -38,15 +45,15 @@ export interface SessionLookup {
38
45
  peerRows(): PeerInfo[];
39
46
  }
40
47
 
41
- /** The rest of the cluster, for a message addressed outside this instance.
48
+ /** The rest of the mesh, for a message addressed outside this instance.
42
49
  *
43
- * `message.send` is a `cluster` op — any instance may be asked — but a message
50
+ * `message.send` is an `any_instance` op — any instance may be asked — but a message
44
51
  * reaches a session through the session's own connections, which are held by
45
52
  * the instance it greeted. So the op is answered here by carrying it there
46
- * (DESIGN §2.2 step 6 is about `instance-local` ops; this is the same forwarding for
53
+ * (DESIGN §2.2 step 6 is about `owner_instance` ops; this is the same forwarding for
47
54
  * the one op whose subject is elsewhere while its op is not). */
48
- export interface Cluster {
49
- /** Which instance holds this session, or nothing when the cluster has not
55
+ export interface MeshReach {
56
+ /** Which instance holds this session, or nothing when the mesh has not
50
57
  * named it. */
51
58
  ownerOf(sid: Sid): InstanceId | undefined;
52
59
  /** Whether an instance that might hold it cannot be asked right now. */
@@ -64,14 +71,19 @@ export interface DeliveryDeps {
64
71
  readonly sessions: SessionLookup;
65
72
  /** Absent on an instance with no mesh, where every session it can name is
66
73
  * its own. */
67
- readonly cluster?: Cluster;
74
+ readonly mesh?: MeshReach;
68
75
  readonly inbox: Inbox;
69
76
  /** Route (a). Off until it is confirmed against a running harness, which is
70
77
  * condition 0 of DESIGN §6.5 and is why this is handed in rather than built here. */
71
78
  readonly direct: DirectRoute;
72
79
  /** The one way a value reaches subscribers (DESIGN §6.1), narrowed to the session a
73
80
  * message is for. */
74
- readonly publish: (topic: string, data: unknown, instance: InstanceId, to: Sid) => PublishOutcome;
81
+ readonly publish: (
82
+ topic: string,
83
+ data: unknown,
84
+ instance: InstanceId,
85
+ to: Sid | typeof PEOPLE,
86
+ ) => PublishOutcome;
75
87
  /** How many of that session's connections are listening on `inbox`. */
76
88
  readonly listeners: (topic: string, to: Sid) => number;
77
89
  }
@@ -97,6 +109,14 @@ export class Delivery implements UpstreamResource {
97
109
 
98
110
  constructor(private readonly deps: DeliveryDeps) {
99
111
  this.#counter = deps.inbox.lastCounter(`${deps.self}/`);
112
+ // Every way out of the inbox is stated to the watchers, and the inbox is
113
+ // the one that knows about all of them — handed over, timed out, dropped
114
+ // for a newer message. Read from there rather than published beside each
115
+ // call that removes something, so a way out nobody thought of here is
116
+ // still a removal somebody watching sees (DESIGN §6.7).
117
+ deps.inbox.onRemoved((mid, reason) => {
118
+ this.#removed([mid], reason);
119
+ });
100
120
  }
101
121
 
102
122
  /** `message.send`. The op fails only for a sid nobody knows; every other
@@ -123,47 +143,87 @@ export class Delivery implements UpstreamResource {
123
143
  if (direct === "refused") {
124
144
  // Turned away for now, which is neither delivered nor undeliverable: it
125
145
  // waits in the inbox and is offered again (DESIGN §6.8).
126
- this.deps.inbox.hold(to, message);
146
+ await this.#hold(to, message);
127
147
  return { delivered: false, reason: "throttled" };
128
148
  }
129
149
 
130
150
  if (this.deps.listeners(INBOX, to) > 0) {
131
151
  if (this.deps.publish(INBOX, [message], this.deps.self, to) === "ok") {
152
+ // Route (b) is the topic itself, so the message never waits anywhere:
153
+ // the watchers are told both halves at once — it arrived, and it is
154
+ // gone because the session has it — since a view built from frames
155
+ // alone would otherwise show it waiting for good.
156
+ this.#watchers(to, [message]);
157
+ this.#removed([message.mid], "delivered");
132
158
  return { delivered: true };
133
159
  }
134
160
  // The session is listening but is behind on what it has already been
135
161
  // offered, which is the same standing as route (a) turning the message
136
162
  // away: it waits in the inbox and is offered again (DESIGN §6.8).
137
- this.deps.inbox.hold(to, message);
163
+ await this.#hold(to, message);
138
164
  return { delivered: false, reason: "throttled" };
139
165
  }
140
166
 
141
- const { evicted } = this.deps.inbox.hold(to, message);
167
+ const { evicted } = await this.#hold(to, message);
142
168
  return this.#undelivered(to, evicted ? "inbox_full" : this.#reason(state));
143
169
  };
144
170
 
171
+ /** Hold a message the session could not take, and say so to the watchers.
172
+ *
173
+ * Stated before it is held, so that a message dropped to make room for it
174
+ * reads in the order the two happened: a removal of something the watcher
175
+ * has, rather than of something it is about to be told about. */
176
+ async #hold(to: Sid, message: InboxMessage): Promise<{ evicted: boolean }> {
177
+ this.#watchers(to, [message]);
178
+ return await this.deps.inbox.hold(to, message);
179
+ }
180
+
181
+ /** What is waiting, as somebody looking at it from outside reads it.
182
+ *
183
+ * A person holds every session's inbox in one subscription, so the rows they
184
+ * are answered with name their recipient; the session's own do not, since
185
+ * its subscription is already the recipient (contract, `InboxMessage.to`). */
186
+ #watchers(to: Sid, messages: readonly InboxMessage[]): void {
187
+ if (messages.length === 0) return;
188
+ this.deps.publish(
189
+ INBOX,
190
+ messages.map((message) => ({ ...message, to })),
191
+ this.deps.self,
192
+ PEOPLE,
193
+ );
194
+ }
195
+
196
+ /** A message has left an inbox, which only a watcher has anything left to do
197
+ * with: the session it was for either has it or never will, and neither is
198
+ * something to tell it on the topic it receives messages on. */
199
+ #removed(mids: readonly Mid[], reason: InboxRemovedReason): void {
200
+ if (mids.length === 0) return;
201
+ const gone: InboxRemoved[] = mids.map((mid) => ({ mid, removed: true, reason }));
202
+ this.deps.publish(INBOX, gone, this.deps.self, PEOPLE);
203
+ }
204
+
145
205
  /** A session this instance does not hold: carried to the instance that does,
146
- * or named as one the cluster cannot answer for right now.
206
+ * or named as one the mesh cannot answer for right now.
147
207
  *
148
- * Nothing when the cluster has no such session anywhere and every instance
208
+ * Nothing when the mesh has no such session anywhere and every instance
149
209
  * could be asked — which is the only case `session_not_found` covers (DESIGN §6.6).
150
210
  * While an instance is out of reach the sid may well be its, so the sender is
151
211
  * told the reason rather than that the session does not exist. The message is
152
212
  * not held here either: the inbox that would offer it again is the one on the
153
213
  * instance that owns the session (DESIGN §6.7). */
154
214
  async #elsewhere(to: Sid, input: HandlerInput): Promise<MessageSendResult | undefined> {
155
- const cluster = this.deps.cluster;
156
- if (cluster === undefined) return undefined;
157
- const owner = cluster.ownerOf(to);
215
+ const reach = this.deps.mesh;
216
+ if (reach === undefined) return undefined;
217
+ const owner = reach.ownerOf(to);
158
218
  if (owner === undefined || owner === this.deps.self) {
159
- return cluster.anyUnreachable()
219
+ return reach.anyUnreachable()
160
220
  ? { delivered: false, reason: "instance_unreachable" }
161
221
  : undefined;
162
222
  }
163
223
  // The sender, as the owning instance will run the op as: the identity the
164
224
  // connection greeted with, which is the same thing `message.send` reads to
165
225
  // decide who a message is from (DESIGN §6.5).
166
- const answer = await cluster.forward(owner, input.args, callerOf(input));
226
+ const answer = await reach.forward(owner, input.args, callerOf(input));
167
227
  if (answer.kind === "reply") {
168
228
  const { ok: _ok, request_id: _id, ...body } = answer.response;
169
229
  return body as unknown as MessageSendResult;
@@ -187,11 +247,18 @@ export class Delivery implements UpstreamResource {
187
247
  * nothing waiting are not asked about, so the cost of a change nobody is owed
188
248
  * anything after is one map read. */
189
249
  retry = async (): Promise<void> => {
250
+ const offers: Promise<void>[] = [];
190
251
  for (const sid of this.deps.inbox.sids()) {
191
252
  const state = this.deps.sessions.classify(sid);
192
253
  if (state === undefined || state === "paused" || state === "disappeared") continue;
193
- await this.#offer(sid);
254
+ // One session at a time within its own offer, every session at once
255
+ // across them: a session that is slow to answer, or that never does
256
+ // before its deadline, is not a reason the next session waits (DR-0015).
257
+ // The order within a session is what `#offer` holds to, and it guards
258
+ // itself per sid.
259
+ offers.push(this.#offer(sid));
194
260
  }
261
+ await Promise.all(offers);
195
262
  };
196
263
 
197
264
  /** Hand a session what it is owed, oldest first, over route (a).
@@ -215,7 +282,7 @@ export class Delivery implements UpstreamResource {
215
282
  // delivered, and a daemon killed here must not offer those again.
216
283
  if (outcome !== "delivered") break;
217
284
  this.#claimed.get(to)?.delete(message.mid);
218
- this.deps.inbox.delivered(to, [message.mid]);
285
+ await this.deps.inbox.delivered(to, [message.mid]);
219
286
  }
220
287
  } finally {
221
288
  this.#claimed.delete(to);
@@ -243,22 +310,54 @@ export class Delivery implements UpstreamResource {
243
310
  *
244
311
  * A message an offer over route (a) has claimed is left out: it is on its way
245
312
  * on the other route, and the session subscribing while that runs must not
246
- * make it two messages. */
313
+ * make it two messages.
314
+ *
315
+ * **A person's subscription is a view, not a delivery.** They are answered
316
+ * with everything waiting anywhere on this instance, each row naming its
317
+ * recipient, and the inbox is left exactly as it was — a person is not who
318
+ * any of it was addressed to, and a view that consumed what it looked at
319
+ * would deliver messages to nobody by being opened. Nothing is left out of
320
+ * it either: a message an offer has claimed is still waiting until that
321
+ * offer says otherwise, and that is what somebody watching wants to see. */
247
322
  snapshot(topic: string, conn: Requester): readonly TopicValue[] {
248
323
  const identity = conn.identity;
249
- const sid = identity.state === "settled" ? identity.sid : undefined;
250
- if (topic !== INBOX || sid === undefined) return [];
324
+ if (topic !== INBOX || identity.state !== "settled") return [];
325
+ const sid = identity.sid;
326
+ // A settled connection that names no session is somebody watching: the
327
+ // topic table lets nobody but a session and a person subscribe here, so
328
+ // the sid is the whole of the difference and no role is read for it (M1).
329
+ if (sid === undefined) return [{ instance: this.deps.self, data: this.#waiting() }];
251
330
  const claimed = this.#claimed.get(sid);
252
331
  const held = this.deps.inbox
253
332
  .undelivered(sid)
254
333
  .filter((message) => claimed?.has(message.mid) !== true);
255
- this.deps.inbox.delivered(
256
- sid,
257
- held.map((message) => message.mid),
258
- );
334
+ // The messages leave the inbox here, in memory, and the lines saying so go
335
+ // behind whatever the file already owes (DR-0015): the frame carrying them
336
+ // is queued on the connection before this returns, so what the session has
337
+ // been handed is settled whether or not the line has landed yet.
338
+ void this.deps.inbox
339
+ .delivered(
340
+ sid,
341
+ held.map((message) => message.mid),
342
+ )
343
+ .catch(() => {
344
+ // A line that could not be written costs a message being offered again
345
+ // on the next run, which is what the inbox does about anything it is
346
+ // unsure of.
347
+ });
259
348
  return [{ instance: this.deps.self, data: held }];
260
349
  }
261
350
 
351
+ /** Everything still undelivered on this instance, oldest first within each
352
+ * session, each row naming who it is for. */
353
+ #waiting(): InboxMessage[] {
354
+ const rows: InboxMessage[] = [];
355
+ for (const sid of this.deps.inbox.sids()) {
356
+ for (const message of this.deps.inbox.undelivered(sid)) rows.push({ ...message, to: sid });
357
+ }
358
+ return rows;
359
+ }
360
+
262
361
  /** The reason a message is waiting, named from the classification alone
263
362
  * (DESIGN §6.6). `preparing` is the live session with nowhere to put it: it is there,
264
363
  * route (a) did not carry it, and nothing of its is listening yet.