@cotal-ai/core 0.40.0 → 0.41.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/dist/endpoint.d.ts +92 -19
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +197 -47
- package/dist/endpoint.js.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -26,6 +26,26 @@ export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
|
26
26
|
const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
|
|
27
27
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
28
28
|
export const DEFAULT_SPACE = "main";
|
|
29
|
+
/** How many channel filters one multi-filter consumer create may carry.
|
|
30
|
+
*
|
|
31
|
+
* A create names every requested channel in one request, so the request grows with the channel
|
|
32
|
+
* count and the CLIENT's request timeout is what gives way, not the broker: the create is never
|
|
33
|
+
* refused, it just does not answer. Measured on an isolated broker, one message per channel,
|
|
34
|
+
* `limit=5`: 70 filters answer in 43ms, 1,000 in 246ms, 5,000 in 5,345ms, and 10,000 does not
|
|
35
|
+
* answer at all, failing `timeout` after 5,023ms. Note 5,000 SUCCEEDED while taking longer than
|
|
36
|
+
* 10,000 took to fail, which is what says the ceiling is on the create request rather than on the
|
|
37
|
+
* read: past roughly 5s the create itself is what times out.
|
|
38
|
+
*
|
|
39
|
+
* 1,000 is chosen from that sweep rather than from the failure point: it is a fifth of the largest
|
|
40
|
+
* count that still answered, and it answers in a quarter second, so a batch stays far away from
|
|
41
|
+
* both the timeout and the response-deadline budget the dashboard has to share with its DM read.
|
|
42
|
+
* A space with the 69 chat channels this was built for is still ONE read, so the round-trip claim
|
|
43
|
+
* in #1210 is unchanged at that size. */
|
|
44
|
+
export const MULTI_FILTER_BATCH = 1_000;
|
|
45
|
+
/** How many filter batches may be in flight at once. Bounded because the point of #1210 was to stop
|
|
46
|
+
* issuing one read per channel: a space large enough to need batches must not get the fan-out back
|
|
47
|
+
* under another name. */
|
|
48
|
+
export const MULTI_FILTER_READ_CONCURRENCY = 4;
|
|
29
49
|
/**
|
|
30
50
|
* Events: "message" (CotalMessage), "presence" (PresenceEvent), "roster" (Presence[]), "error" (Error),
|
|
31
51
|
* "connection" ({ connected: boolean }) — true on every successful (re)bind (initial start, manual
|
|
@@ -185,6 +205,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
185
205
|
roster = new Map();
|
|
186
206
|
/** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
|
|
187
207
|
presenceSnapshot = Promise.resolve();
|
|
208
|
+
/** False from connection reset until the watch marks the last entry in its initial replay. */
|
|
209
|
+
presenceSnapshotPopulated = false;
|
|
188
210
|
/**
|
|
189
211
|
* Observer-local age of the last presence-KV delivery (any key, including DEL/PURGE). Distinct
|
|
190
212
|
* from each peer's `ts`: that is the publisher's heartbeat. Whole-bucket silence past TTL is
|
|
@@ -192,8 +214,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
192
214
|
* latter (#1045).
|
|
193
215
|
*/
|
|
194
216
|
lastPresenceWatchAt = 0;
|
|
195
|
-
/** Last emitted presence-view
|
|
196
|
-
|
|
217
|
+
/** Last emitted presence-view state. Suppresses duplicate `presence-view` events. */
|
|
218
|
+
presenceViewState = "unpopulated";
|
|
197
219
|
status = "idle";
|
|
198
220
|
activity;
|
|
199
221
|
/** Mirror of the connector's authoritative attention state, published in presence (advisory). The
|
|
@@ -830,7 +852,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
830
852
|
this.confirmingChatSubs.clear();
|
|
831
853
|
this.roster.clear();
|
|
832
854
|
this.lastPresenceWatchAt = 0;
|
|
833
|
-
this.
|
|
855
|
+
this.presenceSnapshotPopulated = false;
|
|
856
|
+
this.emitPresenceViewIfChanged();
|
|
834
857
|
this.joinSeq.clear();
|
|
835
858
|
this.channelConfigs.clear();
|
|
836
859
|
this.channelDefaults = {};
|
|
@@ -1608,29 +1631,31 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1608
1631
|
return [...this.roster.values()].sort((a, b) => a.card.name.localeCompare(b.card.name));
|
|
1609
1632
|
}
|
|
1610
1633
|
/**
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
1634
|
+
* Trust state of THIS observer's presence watch, not of any peer. `unpopulated` means the
|
|
1635
|
+
* current watch has not completed its initial snapshot, so {@link getRoster} may be partial and
|
|
1636
|
+
* cannot support an absence verdict. `stale` means the whole bucket has been silent past the
|
|
1637
|
+
* liveness window, so the roster is last-known as of `staleSince`. `fresh` is false for both
|
|
1638
|
+
* unsafe states so consumers written before `state` was added degrade in the safe direction.
|
|
1615
1639
|
*/
|
|
1616
1640
|
presenceView() {
|
|
1617
1641
|
if (!this.doWatch)
|
|
1618
|
-
return { fresh: true };
|
|
1619
|
-
if (this.
|
|
1620
|
-
return { fresh:
|
|
1642
|
+
return { state: "current", fresh: true };
|
|
1643
|
+
if (!this.presenceSnapshotPopulated)
|
|
1644
|
+
return { state: "unpopulated", fresh: false };
|
|
1621
1645
|
const staleSince = this.lastPresenceWatchAt + this.ttlMs;
|
|
1622
1646
|
if (Date.now() < staleSince)
|
|
1623
|
-
return { fresh: true };
|
|
1624
|
-
return { fresh: false, staleSince };
|
|
1647
|
+
return { state: "current", fresh: true };
|
|
1648
|
+
return { state: "stale", fresh: false, staleSince };
|
|
1625
1649
|
}
|
|
1626
1650
|
/** Wait until the current presence watch has consumed its initial KV snapshot. An empty bucket
|
|
1627
|
-
* emits no watch entry, so the timeout keeps a genuinely empty mesh bounded
|
|
1651
|
+
* emits no watch entry, so the timeout keeps a genuinely empty mesh bounded and is reported
|
|
1652
|
+
* distinctly from snapshot completion. */
|
|
1628
1653
|
async waitForPresenceSnapshot(timeoutMs = 1_000) {
|
|
1629
1654
|
let timer;
|
|
1630
1655
|
try {
|
|
1631
|
-
await Promise.race([
|
|
1632
|
-
this.presenceSnapshot,
|
|
1633
|
-
new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
|
|
1656
|
+
return await Promise.race([
|
|
1657
|
+
this.presenceSnapshot.then(() => "snapshot"),
|
|
1658
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), timeoutMs); }),
|
|
1634
1659
|
]);
|
|
1635
1660
|
}
|
|
1636
1661
|
finally {
|
|
@@ -2099,7 +2124,107 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2099
2124
|
* reclaims its ephemeral consumer before the promise rejects. */
|
|
2100
2125
|
async channelHistory(channel, opts) {
|
|
2101
2126
|
// history from any sender
|
|
2102
|
-
return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100, undefined, opts?.signal);
|
|
2127
|
+
return (await this.streamHistory(chatStream(this.space), [chatSubject(this.space, "*", "*", channel)], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* The newest `limit` chat messages across MANY channels at once, oldest-first within the page,
|
|
2131
|
+
* each tagged with the channel the BROKER delivered it on.
|
|
2132
|
+
*
|
|
2133
|
+
* **One read, not one per channel.** The CHAT stream already interleaves every channel into one
|
|
2134
|
+
* sequence space, so "the newest N across these channels" is the tail of ONE stream, and a
|
|
2135
|
+
* consumer takes a SET of filter subjects. The dashboard's activity feed used to answer this by
|
|
2136
|
+
* calling {@link channelHistory} once per channel and merging: each of those is a widening probe
|
|
2137
|
+
* loop, so the cost carried two multipliers (a probe loop per channel, and a fan-out across every
|
|
2138
|
+
* channel). Counted on the wire over a seeded corpus of 69 chat channels and 24 event channels at
|
|
2139
|
+
* limit 200: 2524 broker requests and about 8.0 MB transferred to return a 143,401-byte page,
|
|
2140
|
+
* against 143 requests and about 0.91 MB here. With no link cost the counts and the page size
|
|
2141
|
+
* repeat exactly across runs; the byte totals move by tens of bytes. `pnpm smoke:web-activity-read-cost` reproduces the
|
|
2142
|
+
* second column and a frozen copy of the fan-out shape; the first is that same suite run against
|
|
2143
|
+
* `544a974b7` (Cotal #1210).
|
|
2144
|
+
*
|
|
2145
|
+
* **The broker does the filtering, so the wire carries only what is asked for.** A channel left
|
|
2146
|
+
* out of `channels` costs nothing: its messages are never delivered, so a space whose volume is
|
|
2147
|
+
* dominated by channels the caller does not want stays cheap. That is the same "filter before the
|
|
2148
|
+
* fetch" property the per-channel fan-out had, kept rather than traded away.
|
|
2149
|
+
*
|
|
2150
|
+
* **The channel comes from the SUBJECT, never from the payload.** A message claims a `channel`
|
|
2151
|
+
* field, and this method ignores it: the tag is derived from the subject the broker routed the
|
|
2152
|
+
* message on, the same derivation {@link listChannels} uses to name a channel in the first place.
|
|
2153
|
+
*
|
|
2154
|
+
* **Concrete channels only.** Filter subjects on one consumer may not overlap, and a wildcard
|
|
2155
|
+
* channel subsumes its own subtree, so a wildcard here is refused rather than silently dropped or
|
|
2156
|
+
* silently double-counted.
|
|
2157
|
+
*
|
|
2158
|
+
* **Observer/admin credentials only, and the broker is what says so.** A multi-filter create
|
|
2159
|
+
* cannot encode its filter in the API subject, so it rides the bare
|
|
2160
|
+
* `$JS.API.CONSUMER.CREATE.<CHAT>` row that only the read-only dashboard profiles hold. An agent
|
|
2161
|
+
* credential pins the filter into the subject per channel and is denied here by the broker, which
|
|
2162
|
+
* is the correct answer: this method reads across channels, and an agent's read ACL is per
|
|
2163
|
+
* channel.
|
|
2164
|
+
*/
|
|
2165
|
+
async multiChannelHistory(channels, opts) {
|
|
2166
|
+
const subjects = [...new Set(channels.map((channel) => {
|
|
2167
|
+
if (!isConcreteChannel(channel))
|
|
2168
|
+
throw new Error(`multiChannelHistory: "${channel}" is a wildcard channel - one consumer's filter subjects may not overlap, so name the concrete channels`);
|
|
2169
|
+
// `chatSubject` builds the filter through `token()`, which REWRITES rather than refuses: it
|
|
2170
|
+
// maps a character a subject may not carry to `_`, trims each segment, and drops empty ones.
|
|
2171
|
+
// So "foo/bar" would filter on `foo_bar`, ".lead" on `lead`, and "team..b" on `team.b` - the
|
|
2172
|
+
// caller names one channel and the broker returns another, which is the exact promise this
|
|
2173
|
+
// method makes ("a channel left out of the list never crosses the link") inverted. This is the
|
|
2174
|
+
// same aliasing `assertValidChannel` was written for on the policy path; the read path needs
|
|
2175
|
+
// it too, and it fails loud rather than serving a channel nobody asked for.
|
|
2176
|
+
assertValidChannel(channel);
|
|
2177
|
+
return chatSubject(this.space, "*", "*", channel);
|
|
2178
|
+
}))];
|
|
2179
|
+
// No channels is not an empty stream, but it IS an empty answer, and asking the broker for a
|
|
2180
|
+
// consumer with no filter would read the WHOLE stream instead of none of it.
|
|
2181
|
+
if (subjects.length === 0)
|
|
2182
|
+
return [];
|
|
2183
|
+
const limit = opts?.limit ?? 100;
|
|
2184
|
+
// ONE CREATE CANNOT CARRY AN UNBOUNDED FILTER LIST. The create names every subject in one
|
|
2185
|
+
// request and the client's request timeout is what gives way, so past roughly 5,000 filters the
|
|
2186
|
+
// read does not answer at all and the route loses the whole chat source: measured on an
|
|
2187
|
+
// isolated broker, 10,000 channels failed `timeout` after 5,023ms while the fan-out this
|
|
2188
|
+
// replaced still returned 2,739 messages on the same corpus. Reading in batches keeps the
|
|
2189
|
+
// single-read cost at the sizes this was built for (69 channels is one batch, so #1210's
|
|
2190
|
+
// round-trip numbers are unchanged) and degrades to a few reads instead of none above that.
|
|
2191
|
+
// `batch` is a knob rather than a constant so the batched path can be compared against the
|
|
2192
|
+
// single-create path on ONE corpus: forcing a small batch makes a space that would otherwise be
|
|
2193
|
+
// one read take many, which is the only way to assert the two select the same messages.
|
|
2194
|
+
const size = Math.max(1, Math.trunc(opts?.batch ?? MULTI_FILTER_BATCH));
|
|
2195
|
+
const batches = [];
|
|
2196
|
+
for (let i = 0; i < subjects.length; i += size)
|
|
2197
|
+
batches.push(subjects.slice(i, i + size));
|
|
2198
|
+
const pages = new Array(batches.length);
|
|
2199
|
+
let next = 0;
|
|
2200
|
+
const readBatch = async () => {
|
|
2201
|
+
for (;;) {
|
|
2202
|
+
const i = next++;
|
|
2203
|
+
if (i >= batches.length)
|
|
2204
|
+
return;
|
|
2205
|
+
pages[i] = await this.streamHistory(chatStream(this.space), batches[i], limit, undefined, opts?.signal);
|
|
2206
|
+
}
|
|
2207
|
+
};
|
|
2208
|
+
// A BATCH THAT FAILS FAILS THE READ. Returning the batches that answered would be the newest
|
|
2209
|
+
// `limit` across SOME of the channels asked for while looking like the newest across all of
|
|
2210
|
+
// them, which is a wrong page presented as a right one. Throwing keeps the caller's existing
|
|
2211
|
+
// envelope: the dashboard marks `chat` missing and says the page is partial, which is what it
|
|
2212
|
+
// already did when this method was one read.
|
|
2213
|
+
await Promise.all(Array.from({ length: Math.min(MULTI_FILTER_READ_CONCURRENCY, batches.length) }, readBatch));
|
|
2214
|
+
// MERGE ON ARRIVAL, NOT ON `ts`. Each batch returns the newest `limit` within its own subjects,
|
|
2215
|
+
// and the newest `limit` overall is a subset of their union, so re-selecting by stream sequence
|
|
2216
|
+
// reproduces exactly what one create over the whole list would have selected. Sorting by the
|
|
2217
|
+
// payload's `ts` here instead would change which messages the page holds, which is the
|
|
2218
|
+
// selection question this pull request already had to answer once.
|
|
2219
|
+
const rows = pages.flat().sort((a, b) => a.seq - b.seq).slice(-limit);
|
|
2220
|
+
return rows.map(({ subject, msg }) => {
|
|
2221
|
+
const p = parseSubject(subject);
|
|
2222
|
+
// The filter set is built from chat subjects, so this cannot fire against a healthy broker.
|
|
2223
|
+
// It is here because the alternative to raising is tagging a message with a guess.
|
|
2224
|
+
if (p?.kind !== "chat")
|
|
2225
|
+
throw new Error(`multiChannelHistory: the broker delivered ${subject}, which is not a chat subject on this space`);
|
|
2226
|
+
return { channel: p.rest, msg };
|
|
2227
|
+
});
|
|
2103
2228
|
}
|
|
2104
2229
|
/** Read a channel's recent history THROUGH THE DELIVERY DAEMON instead of through a consumer this
|
|
2105
2230
|
* connection creates itself — the mediated read of SPEC's "Mediated reads (normative)" rule (no raw
|
|
@@ -2146,7 +2271,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2146
2271
|
* skips for them — only an `admin`-profile cred can read it. */
|
|
2147
2272
|
async dmHistory(opts) {
|
|
2148
2273
|
// every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
|
|
2149
|
-
return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst
|
|
2274
|
+
return (await this.streamHistory(dmStream(this.space), [`${spacePrefix(this.space)}.inst.>`], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
|
|
2150
2275
|
}
|
|
2151
2276
|
/**
|
|
2152
2277
|
* The `limit` MOST RECENT messages matching `subject`, oldest-first within the page.
|
|
@@ -2171,7 +2296,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2171
2296
|
*
|
|
2172
2297
|
* `before` pages toward the past: pass the `seq` of the oldest message you already have.
|
|
2173
2298
|
*/
|
|
2174
|
-
async streamHistory(stream,
|
|
2299
|
+
async streamHistory(stream, subjects, limit, before, signal) {
|
|
2175
2300
|
if (!this.nc)
|
|
2176
2301
|
throw new Error("endpoint not started");
|
|
2177
2302
|
signal?.throwIfAborted();
|
|
@@ -2210,7 +2335,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2210
2335
|
// Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
|
|
2211
2336
|
// needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
|
|
2212
2337
|
// shipped once from this function and turned every non-admin history read into an empty list.
|
|
2213
|
-
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream,
|
|
2338
|
+
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subjects, signal);
|
|
2214
2339
|
if (ceiling < 1)
|
|
2215
2340
|
return [];
|
|
2216
2341
|
// Widen from the exact ceiling until a window holds a full page, or until the window IS the
|
|
@@ -2219,6 +2344,25 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2219
2344
|
// Geometric growth keeps the number of attempts logarithmic, so total wasted transfer is a
|
|
2220
2345
|
// small multiple of a page.
|
|
2221
2346
|
//
|
|
2347
|
+
// THE FIRST WINDOW IS ONE PAGE WIDE, NOT FOUR. It used to open at `limit * 4` on the reasoning
|
|
2348
|
+
// that a filtered subject is sparse inside its stream and a wider first look would land the
|
|
2349
|
+
// page in one drain. That is true for one channel of a busy stream and false for a subject
|
|
2350
|
+
// that IS most of its stream, and the second case is the one a whole-backlog read takes: the
|
|
2351
|
+
// window succeeds on the first attempt and drains four pages to keep one, because
|
|
2352
|
+
// `drainWindow` delivers everything in the window and keeps the tail. Measured on `/api/dms`
|
|
2353
|
+
// at limit 500 against a 2500-message backlog: 1,995,854 to 1,995,859 bytes moved across
|
|
2354
|
+
// four runs to return a 346,001-byte page, and 8161ms to 8753ms on that link, so all four
|
|
2355
|
+
// missed the dashboard's 8000ms deadline with nothing else on the connection (Cotal #1210).
|
|
2356
|
+
// That cost does not keep growing with the backlog, and saying it did was wrong: the old first
|
|
2357
|
+
// span was `max(limit * 4, 64)`, so at limit 500 it is 2000 messages, and ANY backlog of 2000
|
|
2358
|
+
// or more drains the same 2000-message window. The point is that the window, not the backlog,
|
|
2359
|
+
// sets the cost, and four pages to return one is already past the deadline on this link for
|
|
2360
|
+
// every such deployment. One page wide makes the
|
|
2361
|
+
// SUCCESSFUL drain
|
|
2362
|
+
// obey the same bound the failed ones already promised: it moves at most one page. A sparse
|
|
2363
|
+
// subject pays one more widening step for that, which is four round trips against a transfer
|
|
2364
|
+
// several times the size of the answer.
|
|
2365
|
+
//
|
|
2222
2366
|
// A SHORT PAGE is either "the channel is exhausted" or "the window is still above the
|
|
2223
2367
|
// first match". Sequence 1 is the wrong floor for the first of those: three matches at
|
|
2224
2368
|
// the high end of a busy stream are exhausted as soon as the window's lower edge passes
|
|
@@ -2227,17 +2371,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2227
2371
|
// drain, full) never pays for it. The remaining unbounded-looking case is a subject
|
|
2228
2372
|
// whose FIRST match really is near sequence 1; that span is the channel's own, not the
|
|
2229
2373
|
// stream's.
|
|
2230
|
-
let span = Math.max(limit
|
|
2374
|
+
let span = Math.max(limit, 64);
|
|
2231
2375
|
let floor = 1;
|
|
2232
2376
|
let floorKnown = false;
|
|
2233
2377
|
for (;;) {
|
|
2234
2378
|
signal?.throwIfAborted();
|
|
2235
2379
|
const start = Math.max(floor, ceiling - span + 1);
|
|
2236
|
-
const page = await this.drainWindow(js, stream,
|
|
2380
|
+
const page = await this.drainWindow(js, stream, subjects, start, ceiling, limit, signal);
|
|
2237
2381
|
if (page.length >= limit)
|
|
2238
2382
|
return page.slice(-limit);
|
|
2239
2383
|
if (!floorKnown) {
|
|
2240
|
-
floor = Math.max(1, await this.firstMatchingSeq(js, stream,
|
|
2384
|
+
floor = Math.max(1, await this.firstMatchingSeq(js, stream, subjects, signal));
|
|
2241
2385
|
floorKnown = true;
|
|
2242
2386
|
}
|
|
2243
2387
|
if (start <= floor)
|
|
@@ -2268,19 +2412,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2268
2412
|
throw e;
|
|
2269
2413
|
}
|
|
2270
2414
|
}
|
|
2271
|
-
/** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
2272
|
-
*
|
|
2273
|
-
* One ordered consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
|
|
2274
|
-
* (available from the create, before anything is delivered) is 0 for an empty subject, and
|
|
2275
|
-
* otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
|
|
2276
|
-
* already uses, so no broker authority is added. */
|
|
2277
2415
|
/** The oldest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
2278
2416
|
* Mirror of {@link lastMatchingSeq}: same CREATE/INFO/NEXT/DELETE surface, `DeliverPolicy.All`
|
|
2279
2417
|
* instead of `Last`, first delivered seq instead of last. Read credentials already hold this. */
|
|
2280
|
-
async firstMatchingSeq(js, stream,
|
|
2418
|
+
async firstMatchingSeq(js, stream, subjects, signal) {
|
|
2281
2419
|
signal?.throwIfAborted();
|
|
2282
2420
|
const consumer = await js.consumers.get(stream, {
|
|
2283
|
-
filter_subjects:
|
|
2421
|
+
filter_subjects: subjects,
|
|
2284
2422
|
deliver_policy: DeliverPolicy.All,
|
|
2285
2423
|
});
|
|
2286
2424
|
try {
|
|
@@ -2299,7 +2437,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2299
2437
|
signal?.removeEventListener("abort", stop);
|
|
2300
2438
|
iter.stop();
|
|
2301
2439
|
}
|
|
2302
|
-
throw new Error(`history: the broker reported messages on ${
|
|
2440
|
+
throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
|
|
2303
2441
|
}
|
|
2304
2442
|
finally {
|
|
2305
2443
|
try {
|
|
@@ -2317,10 +2455,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2317
2455
|
* (available from the create, before anything is delivered) is 0 for an empty subject, and
|
|
2318
2456
|
* otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
|
|
2319
2457
|
* already uses, so no broker authority is added. */
|
|
2320
|
-
async lastMatchingSeq(js, stream,
|
|
2458
|
+
async lastMatchingSeq(js, stream, subjects, signal) {
|
|
2321
2459
|
signal?.throwIfAborted();
|
|
2322
2460
|
const consumer = await js.consumers.get(stream, {
|
|
2323
|
-
filter_subjects:
|
|
2461
|
+
filter_subjects: subjects,
|
|
2324
2462
|
deliver_policy: DeliverPolicy.Last,
|
|
2325
2463
|
});
|
|
2326
2464
|
try {
|
|
@@ -2345,7 +2483,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2345
2483
|
// dropped link looks like from here. Returning 0 would make the caller report an empty
|
|
2346
2484
|
// channel, which is the same "could not read means no history" lie the narrowed catch above
|
|
2347
2485
|
// exists to stop.
|
|
2348
|
-
throw new Error(`history: the broker reported messages on ${
|
|
2486
|
+
throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
|
|
2349
2487
|
}
|
|
2350
2488
|
finally {
|
|
2351
2489
|
try {
|
|
@@ -2361,10 +2499,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2361
2499
|
* One ephemeral ordered consumer, one batched pull — `AckPolicy.None`, so no per-message ack
|
|
2362
2500
|
* round trip. Fetches exactly the pending count so it returns as soon as the window is
|
|
2363
2501
|
* delivered rather than blocking for the pull's full expiry. */
|
|
2364
|
-
async drainWindow(js, stream,
|
|
2502
|
+
async drainWindow(js, stream, subjects, start, ceiling, limit, signal) {
|
|
2365
2503
|
signal?.throwIfAborted();
|
|
2366
2504
|
const out = [];
|
|
2367
|
-
const consumer = await js.consumers.get(stream, { filter_subjects:
|
|
2505
|
+
const consumer = await js.consumers.get(stream, { filter_subjects: subjects, opt_start_seq: start });
|
|
2368
2506
|
try {
|
|
2369
2507
|
// A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
|
|
2370
2508
|
// explicit uncached `info()` this used to call was a round trip for data we already had.
|
|
@@ -2393,7 +2531,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2393
2531
|
if (m.seq >= ceiling) { // reached the page's upper bound
|
|
2394
2532
|
if (m.seq === ceiling) {
|
|
2395
2533
|
try {
|
|
2396
|
-
out.push(m.json());
|
|
2534
|
+
out.push({ seq: m.seq, subject: m.subject, msg: m.json() });
|
|
2397
2535
|
}
|
|
2398
2536
|
catch { /* skip undecodable */ }
|
|
2399
2537
|
}
|
|
@@ -2401,7 +2539,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2401
2539
|
break;
|
|
2402
2540
|
}
|
|
2403
2541
|
try {
|
|
2404
|
-
out.push(m.json());
|
|
2542
|
+
out.push({ seq: m.seq, subject: m.subject, msg: m.json() });
|
|
2405
2543
|
if (out.length > limit)
|
|
2406
2544
|
out.shift();
|
|
2407
2545
|
}
|
|
@@ -2417,13 +2555,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2417
2555
|
iter.stop();
|
|
2418
2556
|
}
|
|
2419
2557
|
if (!complete)
|
|
2420
|
-
throw new Error(`history: read ${delivered} of ${pending} messages on ${
|
|
2558
|
+
throw new Error(`history: read ${delivered} of ${pending} messages on ${subjectLabel(subjects)} before the stream ended early - the window was cut short, not empty`);
|
|
2421
2559
|
return out;
|
|
2422
2560
|
}
|
|
2423
2561
|
finally {
|
|
2424
2562
|
// DELETE THE EPHEMERAL CONSUMER. The pinned client gives an ordered consumer a 5-minute
|
|
2425
2563
|
// inactive threshold, so leaving them behind is not free: the widening search below can make
|
|
2426
|
-
// up to eight per call,
|
|
2564
|
+
// up to eight per call, and a reload repeats every call the page makes. The dashboard used to
|
|
2565
|
+
// make one per channel; since #1210 its activity feed makes one for all of chat and one for
|
|
2566
|
+
// DMs, and the single-channel routes still make one each.
|
|
2427
2567
|
// Left alone that accumulates consumers on the broker until the thresholds expire, and the
|
|
2428
2568
|
// resulting resource exhaustion would land in streamHistory's catch and read as empty history.
|
|
2429
2569
|
try {
|
|
@@ -4370,7 +4510,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4370
4510
|
// @nats-io/kv marks the final initial replay entry isUpdate=true. Later updates stay true.
|
|
4371
4511
|
if (!ready && e.isUpdate) {
|
|
4372
4512
|
ready = true;
|
|
4513
|
+
this.presenceSnapshotPopulated = true;
|
|
4373
4514
|
hydrated();
|
|
4515
|
+
this.emitPresenceViewIfChanged();
|
|
4374
4516
|
}
|
|
4375
4517
|
}
|
|
4376
4518
|
hydrated();
|
|
@@ -4451,7 +4593,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4451
4593
|
this.emit("roster", this.getRoster());
|
|
4452
4594
|
return;
|
|
4453
4595
|
}
|
|
4454
|
-
this.
|
|
4596
|
+
if (this.presenceSnapshotPopulated)
|
|
4597
|
+
this.emitPresenceViewIfChanged();
|
|
4455
4598
|
// Any offline materialization (a stale snapshot OR a graceful-leave record) drops the advisory
|
|
4456
4599
|
// attention fields — an offline peer must not carry a stale `[focus]`/`locally muted` hint.
|
|
4457
4600
|
const p = stale || raw.status === "offline" ? this.toOffline(raw) : raw;
|
|
@@ -4505,11 +4648,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4505
4648
|
this.emit("presence", { type: "offline", presence: offline });
|
|
4506
4649
|
this.emit("roster", this.getRoster());
|
|
4507
4650
|
}
|
|
4508
|
-
|
|
4509
|
-
|
|
4651
|
+
emitPresenceViewIfChanged() {
|
|
4652
|
+
const view = this.presenceView();
|
|
4653
|
+
if (view.state === this.presenceViewState)
|
|
4510
4654
|
return;
|
|
4511
|
-
this.
|
|
4512
|
-
this.emit("presence-view",
|
|
4655
|
+
this.presenceViewState = view.state;
|
|
4656
|
+
this.emit("presence-view", view);
|
|
4513
4657
|
}
|
|
4514
4658
|
sweep() {
|
|
4515
4659
|
const now = Date.now();
|
|
@@ -4518,7 +4662,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4518
4662
|
// sidebar with nothing saying the window went blind (#1045). Gate the per-peer age-out on
|
|
4519
4663
|
// watch freshness; surface the view as stale instead.
|
|
4520
4664
|
if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
|
|
4521
|
-
this.
|
|
4665
|
+
this.emitPresenceViewIfChanged();
|
|
4522
4666
|
return;
|
|
4523
4667
|
}
|
|
4524
4668
|
let changed = false;
|
|
@@ -4541,6 +4685,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4541
4685
|
* never absent and never a non-string. An absent or non-string id is a malformed envelope under
|
|
4542
4686
|
* SPEC sec 5; each delivery pump handles it per its own class (durable term, live drop, history
|
|
4543
4687
|
* skip) so it never reaches the receiver's id-keyed machinery as `undefined`. */
|
|
4688
|
+
/** What a history read failure NAMES when it could not finish. One filter subject is the useful
|
|
4689
|
+
* thing to print; a set of sixty-nine of them is a wall of text in a message a human has to read,
|
|
4690
|
+
* so a set says its size and the stream it was read from instead. */
|
|
4691
|
+
function subjectLabel(subjects) {
|
|
4692
|
+
return subjects.length === 1 ? subjects[0] : `${subjects.length} filtered subjects`;
|
|
4693
|
+
}
|
|
4544
4694
|
function isUsableMessageId(id) {
|
|
4545
4695
|
return typeof id === "string";
|
|
4546
4696
|
}
|