@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 +1 -1
- package/src/instance/instance.ts +2 -6
- package/src/messaging/delivery.ts +10 -4
- package/src/messaging/notify.ts +12 -3
- package/src/topics/egress.ts +162 -0
- package/src/topics/index.ts +1 -0
- package/src/topics/topics.ts +0 -0
package/package.json
CHANGED
package/src/instance/instance.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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);
|
package/src/messaging/notify.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
+
}
|
package/src/topics/index.ts
CHANGED
package/src/topics/topics.ts
CHANGED
|
Binary file
|