@ccmsg/cli 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -522,18 +522,14 @@ export class Instance {
522
522
  ...(this.#mesh === undefined ? {} : { cluster: this.#mesh }),
523
523
  inbox,
524
524
  direct: this.#direct,
525
- publish: (topic, data, instance, to) => {
526
- this.#topics.publish(topic, data, instance, to);
527
- },
525
+ publish: (topic, data, instance, to) => this.#topics.publish(topic, data, instance, to),
528
526
  listeners: (topic, to) => this.#topics.subscriberCount(topic, to),
529
527
  });
530
528
 
531
529
  this.#notify = new Notify({
532
530
  self: this.self,
533
531
  label: (sid) => sessionLabel(this.#sessions, sid),
534
- publish: (topic, data, instance) => {
535
- this.#topics.publish(topic, data, instance);
536
- },
532
+ publish: (topic, data, instance) => this.#topics.publish(topic, data, instance),
537
533
  });
538
534
 
539
535
  this.#topics.attach("peers", this.#sessions);
@@ -21,7 +21,7 @@ import {
21
21
  OpError,
22
22
  type Requester,
23
23
  } from "../dispatch/index.ts";
24
- import type { TopicValue, UpstreamResource } from "../topics/index.ts";
24
+ import type { PublishOutcome, TopicValue, UpstreamResource } from "../topics/index.ts";
25
25
  import type { DirectRoute } from "./direct.ts";
26
26
  import type { Inbox } from "./inbox.ts";
27
27
 
@@ -71,7 +71,7 @@ export interface DeliveryDeps {
71
71
  readonly direct: DirectRoute;
72
72
  /** The one way a value reaches subscribers (§6.1), narrowed to the session a
73
73
  * message is for. */
74
- readonly publish: (topic: string, data: unknown, instance: InstanceId, to: Sid) => void;
74
+ readonly publish: (topic: string, data: unknown, instance: InstanceId, to: Sid) => PublishOutcome;
75
75
  /** How many of that session's connections are listening on `inbox`. */
76
76
  readonly listeners: (topic: string, to: Sid) => number;
77
77
  }
@@ -128,8 +128,14 @@ export class Delivery implements UpstreamResource {
128
128
  }
129
129
 
130
130
  if (this.deps.listeners(INBOX, to) > 0) {
131
- this.deps.publish(INBOX, [message], this.deps.self, to);
132
- return { delivered: true };
131
+ if (this.deps.publish(INBOX, [message], this.deps.self, to) === "ok") {
132
+ return { delivered: true };
133
+ }
134
+ // The session is listening but is behind on what it has already been
135
+ // offered, which is the same standing as route (a) turning the message
136
+ // away: it waits in the inbox and is offered again (§4.4).
137
+ this.deps.inbox.hold(to, message);
138
+ return { delivered: false, reason: "throttled" };
133
139
  }
134
140
 
135
141
  const { evicted } = this.deps.inbox.hold(to, message);
@@ -11,7 +11,7 @@ import type {
11
11
  Timestamp,
12
12
  } from "@ccmsg/protocol";
13
13
  import { type HandlerInput, OpError } from "../dispatch/index.ts";
14
- import type { TopicValue, UpstreamResource } from "../topics/index.ts";
14
+ import type { PublishOutcome, TopicValue, UpstreamResource } from "../topics/index.ts";
15
15
 
16
16
  /** The one topic a notification reaches a watcher on. */
17
17
  const NOTIFY = "notify";
@@ -23,7 +23,7 @@ export interface NotifyDeps {
23
23
  readonly label: (sid: Sid) => string;
24
24
  /** The one way a value reaches subscribers (§6.1). No `to`: a notification is
25
25
  * for whoever is watching, not for one session. */
26
- readonly publish: (topic: string, data: unknown, instance: InstanceId) => void;
26
+ readonly publish: (topic: string, data: unknown, instance: InstanceId) => PublishOutcome;
27
27
  }
28
28
 
29
29
  /** The `notify` topic and the three ops that speak on it.
@@ -99,7 +99,16 @@ export class Notify implements UpstreamResource {
99
99
  text,
100
100
  sent_at: now,
101
101
  };
102
- this.deps.publish(NOTIFY, notification, this.deps.self);
102
+ // A notification is an occurrence, so nothing folds it away and a watcher
103
+ // that cannot keep up is what stops it. The caller hears that rather than
104
+ // the notification going nowhere: it is the one that decides whether to
105
+ // raise another (§6.4).
106
+ if (this.deps.publish(NOTIFY, notification, this.deps.self) === "rate_limited") {
107
+ throw new OpError(
108
+ "internal_error",
109
+ "a watcher is behind on this topic; the notification was not taken",
110
+ );
111
+ }
103
112
  return now;
104
113
  }
105
114
 
@@ -0,0 +1,162 @@
1
+ import type { Requester } from "../dispatch/index.ts";
2
+
3
+ /** How long one terminal's frames are gathered before they go out.
4
+ *
5
+ * The value bounds two things at once. Towards the reader: a person watching a
6
+ * list cannot see a change arrive sooner than the display draws it, so frames
7
+ * closer together than a few display frames are spent on nothing, while a wait
8
+ * long enough to be read as lag starts around a quarter of a second. Towards
9
+ * the cluster: a relayed frame waits once per hop, so the delay a subscriber
10
+ * sees is this value times the hops between it and the instance that produced
11
+ * the value — at 100 ms a two-hop cluster still answers inside the window a
12
+ * person reads as immediate, which a longer period would leave.
13
+ *
14
+ * It is not a poll. Nothing is looked at when the period elapses: the timer is
15
+ * armed only by a frame that has to wait, and an idle terminal has none. */
16
+ export const FLUSH_PERIOD_MS = 100;
17
+
18
+ /** How many frames that cannot be folded one terminal may hold at once.
19
+ *
20
+ * Folded frames need no bound — a topic that replaces its value keeps one entry
21
+ * however often it is stated — so this bounds the occurrences and the deltas,
22
+ * the frames that mean something twice if they arrive twice. At one flush every
23
+ * `FLUSH_PERIOD_MS` a terminal that keeps up drains this many every period, so
24
+ * reaching the limit means the producer has been outrunning the reader by more
25
+ * than 2500 frames a second for as long as the queue has stood: past anything a
26
+ * person, a session or a peer produces, and into the storm this layer exists
27
+ * for. What is over the limit is refused rather than dropped quietly, so the op
28
+ * that raised it is the one that hears about it. */
29
+ export const QUEUE_LIMIT = 256;
30
+
31
+ /** The two things the queue asks of time, so a test can hold both still.
32
+ *
33
+ * `schedule` answers with the way to cancel what it armed, because a terminal
34
+ * that goes away while a flush is pending has to leave nothing behind. */
35
+ export interface EgressClock {
36
+ now(): number;
37
+ schedule(afterMs: number, run: () => void): () => void;
38
+ }
39
+
40
+ const REAL_CLOCK: EgressClock = {
41
+ now: () => Date.now(),
42
+ schedule: (afterMs, run) => {
43
+ const timer = setTimeout(run, afterMs);
44
+ return () => {
45
+ clearTimeout(timer);
46
+ };
47
+ },
48
+ };
49
+
50
+ /** What a caller may move: the period, the limit, and the clock both are read
51
+ * against. */
52
+ export interface EgressOptions {
53
+ readonly periodMs?: number;
54
+ readonly limit?: number;
55
+ readonly clock?: EgressClock;
56
+ }
57
+
58
+ interface Pending {
59
+ /** The key this frame folds on, absent for one that does not fold. */
60
+ readonly fold?: string;
61
+ frame: object;
62
+ }
63
+
64
+ /** One terminal's outgoing frames, gathered and let go on a period.
65
+ *
66
+ * A terminal is a person's connection, a mesh peer or a CLI subscriber, and
67
+ * this is the same layer for all three: what differs between them is the socket
68
+ * underneath, not how fast a subscriber can be written to.
69
+ *
70
+ * Three things happen here, and the topic's own granularity decides which. A
71
+ * frame that replaces the value it carries folds onto the one already waiting
72
+ * under the same key, so a value stated a thousand times between two flushes
73
+ * leaves one frame and it is the latest — the reader is never handed a value
74
+ * that has already been superseded, and never misses the last one. A frame that
75
+ * is an occurrence or a delta cannot fold, so it queues in the order it was
76
+ * raised and the queue is bounded: past the bound the frame is refused, which is
77
+ * how the pressure reaches whoever is producing it instead of accumulating
78
+ * here. Both leave together on the flush, in the order they were queued.
79
+ *
80
+ * The first frame after a quiet spell goes out at once: the period bounds how
81
+ * often a flush happens, not how long a lone change waits. */
82
+ export class Egress {
83
+ #queue: Pending[] = [];
84
+ /** The waiting frame per fold key, so a restatement finds its own entry
85
+ * rather than being appended behind it. */
86
+ readonly #folded = new Map<string, Pending>();
87
+ /** How many waiting frames do not fold, which is what the limit counts. */
88
+ #kept = 0;
89
+ #lastFlush = Number.NEGATIVE_INFINITY;
90
+ #cancel: (() => void) | undefined;
91
+
92
+ readonly #periodMs: number;
93
+ readonly #limit: number;
94
+ readonly #clock: EgressClock;
95
+
96
+ constructor(
97
+ private readonly conn: Requester,
98
+ options: EgressOptions = {},
99
+ ) {
100
+ this.#periodMs = options.periodMs ?? FLUSH_PERIOD_MS;
101
+ this.#limit = options.limit ?? QUEUE_LIMIT;
102
+ this.#clock = options.clock ?? REAL_CLOCK;
103
+ }
104
+
105
+ /** Take one frame for this terminal. `fold` is the key it replaces itself
106
+ * under, absent for a frame that has to be sent as often as it is raised.
107
+ *
108
+ * `false` is the queue refusing the frame: it is full of frames that cannot
109
+ * be folded, and the caller is the one that can answer for it. */
110
+ push(frame: object, fold?: string): boolean {
111
+ if (fold === undefined) {
112
+ if (this.#kept >= this.#limit) return false;
113
+ this.#kept += 1;
114
+ this.#queue.push({ frame });
115
+ } else {
116
+ const held = this.#folded.get(fold);
117
+ if (held !== undefined) {
118
+ // In place: the value moves, its position among the occurrences around
119
+ // it does not.
120
+ held.frame = frame;
121
+ this.#arm();
122
+ return true;
123
+ }
124
+ const entry: Pending = { fold, frame };
125
+ this.#queue.push(entry);
126
+ this.#folded.set(fold, entry);
127
+ }
128
+ this.#arm();
129
+ return true;
130
+ }
131
+
132
+ /** Send everything waiting, in the order it was queued. */
133
+ flush(): void {
134
+ this.#cancel?.();
135
+ this.#cancel = undefined;
136
+ this.#lastFlush = this.#clock.now();
137
+ const queue = this.#queue;
138
+ this.#queue = [];
139
+ this.#folded.clear();
140
+ this.#kept = 0;
141
+ for (const entry of queue) this.conn.send(entry.frame);
142
+ }
143
+
144
+ /** The terminal is done with: what was queued goes out, and nothing armed
145
+ * outlives it. */
146
+ release(): void {
147
+ this.flush();
148
+ }
149
+
150
+ #arm(): void {
151
+ if (this.#cancel !== undefined) return;
152
+ const wait = this.#periodMs - (this.#clock.now() - this.#lastFlush);
153
+ if (wait <= 0) {
154
+ this.flush();
155
+ return;
156
+ }
157
+ this.#cancel = this.#clock.schedule(wait, () => {
158
+ this.#cancel = undefined;
159
+ this.flush();
160
+ });
161
+ }
162
+ }
@@ -1,2 +1,3 @@
1
+ export * from "./egress.ts";
1
2
  export * from "./handlers.ts";
2
3
  export * from "./topics.ts";
Binary file