@laserdata/laser-sdk 0.5.0 → 0.5.2
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/README.md +21 -14
- package/dist/agent/reliable-consumer.d.ts.map +1 -1
- package/dist/agent/reliable-consumer.js +4 -5
- package/dist/agent/reliable-consumer.js.map +1 -1
- package/dist/bridges/a2a.d.ts +1 -1
- package/dist/bridges/a2a.js +1 -1
- package/dist/client/capabilities.d.ts +12 -0
- package/dist/client/capabilities.d.ts.map +1 -1
- package/dist/client/capabilities.js +13 -5
- package/dist/client/capabilities.js.map +1 -1
- package/dist/client/errors.d.ts +14 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +20 -0
- package/dist/client/errors.js.map +1 -1
- package/dist/client/internals.d.ts +1 -0
- package/dist/client/internals.d.ts.map +1 -1
- package/dist/client/internals.js +1 -0
- package/dist/client/internals.js.map +1 -1
- package/dist/client/laser.d.ts +1 -6
- package/dist/client/laser.d.ts.map +1 -1
- package/dist/client/laser.js +39 -30
- package/dist/client/laser.js.map +1 -1
- package/dist/iggy/apache-iggy.d.ts +3 -0
- package/dist/iggy/apache-iggy.d.ts.map +1 -1
- package/dist/iggy/apache-iggy.js +6 -1
- package/dist/iggy/apache-iggy.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/managed/filters.d.ts +57 -128
- package/dist/managed/filters.d.ts.map +1 -1
- package/dist/managed/filters.js +292 -271
- package/dist/managed/filters.js.map +1 -1
- package/dist/stream/consumer-group.d.ts +168 -0
- package/dist/stream/consumer-group.d.ts.map +1 -0
- package/dist/stream/consumer-group.js +411 -0
- package/dist/stream/consumer-group.js.map +1 -0
- package/dist/stream/consumer.d.ts +38 -5
- package/dist/stream/consumer.d.ts.map +1 -1
- package/dist/stream/consumer.js +161 -37
- package/dist/stream/consumer.js.map +1 -1
- package/dist/stream/producer-statistics.d.ts +22 -0
- package/dist/stream/producer-statistics.d.ts.map +1 -0
- package/dist/stream/producer-statistics.js +214 -0
- package/dist/stream/producer-statistics.js.map +1 -0
- package/dist/stream/producer.d.ts +2 -0
- package/dist/stream/producer.d.ts.map +1 -1
- package/dist/stream/producer.js +16 -0
- package/dist/stream/producer.js.map +1 -1
- package/dist/stream/stream.d.ts +3 -1
- package/dist/stream/stream.d.ts.map +1 -1
- package/dist/stream/stream.js +4 -2
- package/dist/stream/stream.js.map +1 -1
- package/dist/stream/topic.d.ts +19 -3
- package/dist/stream/topic.d.ts.map +1 -1
- package/dist/stream/topic.js +29 -6
- package/dist/stream/topic.js.map +1 -1
- package/dist/wire/filter.d.ts +66 -3
- package/dist/wire/filter.d.ts.map +1 -1
- package/dist/wire/filter.js +195 -27
- package/dist/wire/filter.js.map +1 -1
- package/dist/wire/hello.d.ts +1 -0
- package/dist/wire/hello.d.ts.map +1 -1
- package/dist/wire/hello.js +2 -1
- package/dist/wire/hello.js.map +1 -1
- package/dist/wire/http.d.ts +1 -0
- package/dist/wire/http.d.ts.map +1 -1
- package/dist/wire/http.js +2 -0
- package/dist/wire/http.js.map +1 -1
- package/dist/wire/limits.d.ts +2 -0
- package/dist/wire/limits.d.ts.map +1 -1
- package/dist/wire/limits.js +2 -0
- package/dist/wire/limits.js.map +1 -1
- package/package.json +1 -1
package/dist/managed/filters.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { requireCapability } from "../client/capabilities.js";
|
|
2
1
|
import { resultCodeIsRetryable } from "../wire/result.js";
|
|
3
2
|
import { jsonCodec } from "../stream/codecs.js";
|
|
4
3
|
import { decodeBrowseReply, encodeGetSchema } from "../wire/browse.js";
|
|
@@ -9,10 +8,9 @@ import { decodePolledBody, serverErrorCode } from "../iggy/apache-iggy.js";
|
|
|
9
8
|
import { mintUlidValue } from "../runtime/ulid.js";
|
|
10
9
|
import { decodeOne, encodeNamed } from "../wire/cbor.js";
|
|
11
10
|
import { AGDX_FILTERED_ACK_CODE, AGDX_FILTERED_POLL_CODE, FILTER_OP_VERSION } from "../wire/codes.js";
|
|
12
|
-
import { FilterMutateCommand, FilterOperationCommand, FilterPreviewCommand, FilterTestCommand,
|
|
11
|
+
import { FilterMutateCommand, FilterOperationCommand, FilterPreviewCommand, FilterTestCommand, GetFilterBindingCommand, ListFilterBindingsCommand, ListFilterRevisionsCommand, ListFiltersCommand } from "../wire/commands.js";
|
|
13
12
|
import { CompiledFilter, usesRegex } from "../wire/filter-eval.js";
|
|
14
|
-
import {
|
|
15
|
-
import { filterAnnounceEvaluates } from "../wire/hello.js";
|
|
13
|
+
import { decodeFilterReply, encodeFilteredAck, encodeFilteredPollRequest, nextFilteredStart, validateCatalogPage, validateFilterGroupRef, validateFilterMutationRequest, validateFilterPreviewRequest, validateFilterTestRequest, validateFilteredPollRequest } from "../wire/filter.js";
|
|
16
14
|
import { MAX_FILTER_CATALOG_PAGE, MAX_FILTER_PARSE_DEPTH, MAX_FILTER_PREVIEW_EXAMINED, MAX_FILTERED_PAGE_BYTES } from "../wire/limits.js";
|
|
17
15
|
const DEFAULT_COUNT = 100;
|
|
18
16
|
const DEFAULT_MAX_REPLY_BYTES = 1024 * 1024;
|
|
@@ -29,6 +27,7 @@ const OUTCOME_POLL_INTERVAL_MS = 100;
|
|
|
29
27
|
export const DEFAULT_OUTCOME_WAIT_MS = 30_000;
|
|
30
28
|
const GET_STREAM_CODE = 200;
|
|
31
29
|
const GET_TOPIC_CODE = 300;
|
|
30
|
+
const GET_CONSUMER_GROUP_CODE = 600;
|
|
32
31
|
const ATTACH_CONSUMER_SESSION_CODE = 14;
|
|
33
32
|
const SYNC_CONSUMER_GROUP_CODE = 606;
|
|
34
33
|
const GET_CONSUMER_OFFSET_ROUTING_CODE = 123;
|
|
@@ -51,9 +50,9 @@ const UTF8 = new TextEncoder();
|
|
|
51
50
|
*/
|
|
52
51
|
export const READER_TAG = Symbol("laser.filters.reader");
|
|
53
52
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
53
|
+
* The catalog and native filter commands behind a consumer group's filter
|
|
54
|
+
* handle, reached through `ConsumerGroup.filter()`. Native commands need a
|
|
55
|
+
* server that serves consumer filters. The catalog also needs a managed
|
|
57
56
|
* plane. Either missing throws `UnsupportedError`.
|
|
58
57
|
*/
|
|
59
58
|
export class Filters {
|
|
@@ -63,17 +62,10 @@ export class Filters {
|
|
|
63
62
|
this.transport = transport;
|
|
64
63
|
this.capabilities = capabilities;
|
|
65
64
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
/** Validate and compile `filter` on the server without running it. */
|
|
71
|
-
async validate(filter) {
|
|
72
|
-
validateConsumerFilter(filter);
|
|
73
|
-
const outcome = await this.native(FilterValidateCommand, filter);
|
|
74
|
-
if (outcome.kind === "validated")
|
|
75
|
-
return outcome.validation;
|
|
76
|
-
throw unexpected("validate");
|
|
65
|
+
/** Throws `UnsupportedError` unless the group filter catalog is served. */
|
|
66
|
+
async requireCatalog() {
|
|
67
|
+
const capabilities = await this.capabilities();
|
|
68
|
+
requireManagedCommand(capabilities, FilterMutateCommand);
|
|
77
69
|
}
|
|
78
70
|
/** Evaluate a filter against one supplied record and explain the verdict. */
|
|
79
71
|
async test(filter, payload, headers = []) {
|
|
@@ -107,42 +99,49 @@ export class Filters {
|
|
|
107
99
|
return outcome.preview;
|
|
108
100
|
throw unexpected("preview");
|
|
109
101
|
}
|
|
110
|
-
/** One
|
|
111
|
-
async
|
|
112
|
-
const outcome = await this.catalog(GetFilterCommand, { v: FILTER_OP_VERSION, filterId });
|
|
113
|
-
if (outcome.kind === "filter")
|
|
114
|
-
return outcome.detail;
|
|
115
|
-
throw unexpected("get");
|
|
116
|
-
}
|
|
117
|
-
/** One page of saved filters, newest first. */
|
|
118
|
-
async list(options = {}) {
|
|
102
|
+
/** One page of a filter's revisions, newest first. */
|
|
103
|
+
async revisions(filterId, options = {}) {
|
|
119
104
|
const request = {
|
|
120
105
|
v: FILTER_OP_VERSION,
|
|
121
|
-
|
|
122
|
-
...(options.state !== undefined ? { state: options.state } : {}),
|
|
123
|
-
...(options.beforeId !== undefined ? { beforeId: options.beforeId } : {}),
|
|
106
|
+
filterId,
|
|
124
107
|
page: options.page ?? 0,
|
|
125
108
|
pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE
|
|
126
109
|
};
|
|
127
110
|
validateCatalogPage(request.page, request.pageSize);
|
|
128
|
-
const outcome = await this.catalog(
|
|
129
|
-
if (outcome.kind === "
|
|
111
|
+
const outcome = await this.catalog(ListFilterRevisionsCommand, request);
|
|
112
|
+
if (outcome.kind === "revisions")
|
|
130
113
|
return outcome.page;
|
|
131
|
-
throw unexpected("
|
|
114
|
+
throw unexpected("revisions");
|
|
132
115
|
}
|
|
133
|
-
/**
|
|
134
|
-
|
|
116
|
+
/**
|
|
117
|
+
* One page of saved filters whose name contains `nameContains`, newest first.
|
|
118
|
+
*
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
async list(nameContains, options = {}, beforeId) {
|
|
135
122
|
const request = {
|
|
136
123
|
v: FILTER_OP_VERSION,
|
|
137
|
-
|
|
124
|
+
nameContains,
|
|
125
|
+
...(beforeId !== undefined ? { beforeId } : {}),
|
|
138
126
|
page: options.page ?? 0,
|
|
139
127
|
pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE
|
|
140
128
|
};
|
|
141
129
|
validateCatalogPage(request.page, request.pageSize);
|
|
142
|
-
const outcome = await this.catalog(
|
|
143
|
-
if (outcome.kind === "
|
|
130
|
+
const outcome = await this.catalog(ListFiltersCommand, request);
|
|
131
|
+
if (outcome.kind === "filters")
|
|
144
132
|
return outcome.page;
|
|
145
|
-
throw unexpected("
|
|
133
|
+
throw unexpected("filters");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Drop a saved filter. The catalog releases every group bound to it.
|
|
137
|
+
*
|
|
138
|
+
* @internal
|
|
139
|
+
*/
|
|
140
|
+
async dropFilter(filterId) {
|
|
141
|
+
const applied = await this.apply({ kind: "drop", filterId });
|
|
142
|
+
if (applied.result.kind === "dropped")
|
|
143
|
+
return applied.catalogPosition;
|
|
144
|
+
throw unexpected("drop");
|
|
146
145
|
}
|
|
147
146
|
/** The binding of one consumer group. Throws with reason `not_found` when it is unbound. */
|
|
148
147
|
async binding(group) {
|
|
@@ -175,119 +174,80 @@ export class Filters {
|
|
|
175
174
|
operationId
|
|
176
175
|
});
|
|
177
176
|
if (outcome.kind === "mutation")
|
|
178
|
-
return outcome.outcome;
|
|
177
|
+
return checkedMutationOutcome(operationId, outcome.outcome);
|
|
179
178
|
throw unexpected("operation");
|
|
180
179
|
}
|
|
181
|
-
/** Save a new filter as revision 1. */
|
|
182
|
-
async register(name, filter, options = {}) {
|
|
183
|
-
const result = await this.apply({
|
|
184
|
-
kind: "register",
|
|
185
|
-
name,
|
|
186
|
-
description: options.description ?? "",
|
|
187
|
-
filter
|
|
188
|
-
});
|
|
189
|
-
if (result.kind === "registered")
|
|
190
|
-
return result.revision;
|
|
191
|
-
throw unexpected("register");
|
|
192
|
-
}
|
|
193
180
|
/** Add a revision. `expectedRevision` must still be the latest one. */
|
|
194
181
|
async revise(filterId, expectedRevision, filter) {
|
|
195
|
-
const
|
|
196
|
-
if (result.kind === "revised")
|
|
197
|
-
return result.revision;
|
|
182
|
+
const applied = await this.apply({ kind: "revise", filterId, expectedRevision, filter });
|
|
183
|
+
if (applied.result.kind === "revised")
|
|
184
|
+
return applied.result.revision;
|
|
198
185
|
throw unexpected("revise");
|
|
199
186
|
}
|
|
200
|
-
/**
|
|
201
|
-
async
|
|
202
|
-
await this.apply({ kind: "
|
|
203
|
-
}
|
|
204
|
-
/** Hide a filter from new bindings. Existing bindings keep executing. */
|
|
205
|
-
async archive(filterId) {
|
|
206
|
-
await this.apply({ kind: "archive", filterId });
|
|
207
|
-
}
|
|
208
|
-
/** Delete a filter. Throws with reason `conflict` while a consumer group is bound to it. */
|
|
209
|
-
async delete(filterId) {
|
|
210
|
-
await this.apply({ kind: "drop", filterId });
|
|
211
|
-
}
|
|
212
|
-
/** Pin an existing consumer group to one revision. */
|
|
213
|
-
async bind(group, filterId, revision) {
|
|
214
|
-
const result = await this.apply({ kind: "bind", group, filterId, revision });
|
|
215
|
-
if (result.kind === "bound")
|
|
216
|
-
return result.binding;
|
|
217
|
-
throw unexpected("bind");
|
|
218
|
-
}
|
|
219
|
-
/** Release a consumer group while it is still bound to `expectedDigest`. */
|
|
220
|
-
async unbind(group, expectedDigest) {
|
|
221
|
-
const result = await this.apply({ kind: "unbind", group, expectedDigest });
|
|
222
|
-
if (result.kind === "unbound")
|
|
223
|
-
return result.binding;
|
|
224
|
-
throw unexpected("unbind");
|
|
187
|
+
/** Pause or resume a revision without changing its executable content or digest. */
|
|
188
|
+
async setRevisionEnabled(filterId, revision, enabled) {
|
|
189
|
+
await this.apply({ kind: "set_revision_enabled", filterId, revision, enabled });
|
|
225
190
|
}
|
|
226
|
-
/**
|
|
227
|
-
|
|
228
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Give `group` its policy in one catalog transaction: a definition saved as
|
|
193
|
+
* the group's own filter, or one of its existing revisions. A repeat with
|
|
194
|
+
* the same digest keeps the binding, another digest conflicts. The returned
|
|
195
|
+
* position is where the fold applied it, carried by the group's reads so
|
|
196
|
+
* they never see this configuration as absent.
|
|
197
|
+
*/
|
|
198
|
+
async configureGroup(group, policy, operationId = mintUlidValue(), expectedIdentity) {
|
|
199
|
+
const identity = expectedIdentity ?? (await nativeGroup(this.transport, group, group.group)).identity;
|
|
200
|
+
const applied = await this.apply({ kind: "configure_group", group, policy, expectedIdentity: identity }, operationId);
|
|
201
|
+
if (applied.result.kind === "bound")
|
|
202
|
+
return bound(applied.result.binding, applied);
|
|
203
|
+
throw unexpected("configure_group");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Release this exact saved binding, including a group incarnation that was
|
|
207
|
+
* deleted and recreated under the same name.
|
|
208
|
+
*/
|
|
209
|
+
async releaseGroup(binding) {
|
|
210
|
+
const applied = await this.apply({
|
|
229
211
|
kind: "unbind",
|
|
230
212
|
group: binding.group,
|
|
231
213
|
expectedDigest: binding.digest,
|
|
232
214
|
expectedIdentity: binding.identity
|
|
233
215
|
});
|
|
234
|
-
if (result.kind === "unbound")
|
|
235
|
-
return result.binding;
|
|
216
|
+
if (applied.result.kind === "unbound")
|
|
217
|
+
return bound(applied.result.binding, applied);
|
|
236
218
|
throw unexpected("unbind");
|
|
237
219
|
}
|
|
238
|
-
/**
|
|
239
|
-
* Submit a mutation under a new operation id and wait for its authoritative
|
|
240
|
-
* outcome. A lost reply is retried under the same id, which the catalog
|
|
241
|
-
* answers with the first attempt's outcome. A rejection throws
|
|
242
|
-
* `FilterExecutionError`. An outcome still pending after
|
|
243
|
-
* {@link DEFAULT_OUTCOME_WAIT_MS} throws `AmbiguousMutationError` naming the
|
|
244
|
-
* operation id.
|
|
245
|
-
*/
|
|
246
|
-
async apply(mutation) {
|
|
247
|
-
return this.applyAs(mintUlidValue(), mutation);
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* {@link Filters.apply} under a caller-chosen `operationId`, so a caller that
|
|
251
|
-
* records the id first can resume the same mutation after a crash.
|
|
252
|
-
*/
|
|
253
|
-
async applyAs(operationId, mutation) {
|
|
254
|
-
const outcome = await this.submit(operationId, mutation);
|
|
255
|
-
if (outcome.status.kind === "pending") {
|
|
256
|
-
return this.waitForOutcome(operationId, DEFAULT_OUTCOME_WAIT_MS);
|
|
257
|
-
}
|
|
258
|
-
return settled(operationId, outcome.status);
|
|
259
|
-
}
|
|
260
|
-
/** Create the group if absent, then bind it. A failed bind can leave an unbound group. */
|
|
261
|
-
async createConsumerGroup(group, filterId, revision) {
|
|
262
|
-
const capabilities = await this.capabilities();
|
|
263
|
-
requireCapability(capabilities, "filters");
|
|
264
|
-
requireManagedCommand(capabilities, FilterMutateCommand);
|
|
265
|
-
if (this.transport.ensureConsumerGroup === undefined)
|
|
266
|
-
throw new ConfigError("this transport cannot create consumer groups");
|
|
267
|
-
await this.transport.ensureConsumerGroup(group.stream, group.topic, group.group);
|
|
268
|
-
return this.bind(group, filterId, revision);
|
|
269
|
-
}
|
|
270
|
-
/** Pause or resume a revision without changing its executable content or digest. */
|
|
271
|
-
async setRevisionEnabled(filterId, revision, enabled) {
|
|
272
|
-
await this.apply({ kind: "set_revision_enabled", filterId, revision, enabled });
|
|
273
|
-
}
|
|
274
220
|
/** Send one mutation under a caller-chosen `operationId`. The outcome may be pending. */
|
|
275
221
|
async mutate(operationId, mutation) {
|
|
276
222
|
const request = { v: FILTER_OP_VERSION, operationId, mutation };
|
|
277
223
|
validateFilterMutationRequest(request);
|
|
278
224
|
const outcome = await this.catalog(FilterMutateCommand, request);
|
|
279
225
|
if (outcome.kind === "mutation")
|
|
280
|
-
return outcome.outcome;
|
|
226
|
+
return checkedMutationOutcome(operationId, outcome.outcome);
|
|
281
227
|
throw unexpected("mutate");
|
|
282
228
|
}
|
|
283
|
-
|
|
229
|
+
// Submit a mutation and wait for its authoritative outcome. A lost reply is
|
|
230
|
+
// retried under the same id, which the catalog answers with the first
|
|
231
|
+
// attempt's outcome. A rejection throws `FilterExecutionError`. An outcome
|
|
232
|
+
// still pending after `DEFAULT_OUTCOME_WAIT_MS` throws
|
|
233
|
+
// `AmbiguousMutationError` naming the operation id.
|
|
234
|
+
async apply(mutation, operationId = mintUlidValue()) {
|
|
235
|
+
const submitted = await this.submit(operationId, mutation);
|
|
236
|
+
const outcome = submitted.status.kind === "pending"
|
|
237
|
+
? await this.waitForOutcome(operationId, DEFAULT_OUTCOME_WAIT_MS)
|
|
238
|
+
: submitted;
|
|
239
|
+
return {
|
|
240
|
+
result: settled(operationId, outcome.status),
|
|
241
|
+
...(outcome.catalogPosition !== undefined ? { catalogPosition: outcome.catalogPosition } : {})
|
|
242
|
+
};
|
|
243
|
+
}
|
|
284
244
|
async waitForOutcome(operationId, timeoutMs) {
|
|
285
245
|
const deadline = Date.now() + timeoutMs;
|
|
286
246
|
for (;;) {
|
|
287
247
|
try {
|
|
288
248
|
const outcome = await this.operation(operationId);
|
|
289
249
|
if (outcome.status.kind !== "pending")
|
|
290
|
-
return
|
|
250
|
+
return outcome;
|
|
291
251
|
}
|
|
292
252
|
catch (error) {
|
|
293
253
|
if (!isNotYetApplied(error))
|
|
@@ -327,76 +287,55 @@ export class Filters {
|
|
|
327
287
|
throw filterError(reply.error);
|
|
328
288
|
}
|
|
329
289
|
}
|
|
330
|
-
|
|
290
|
+
function checkedMutationOutcome(operationId, outcome) {
|
|
291
|
+
if (outcome.v !== FILTER_OP_VERSION ||
|
|
292
|
+
outcome.operationId !== operationId ||
|
|
293
|
+
(outcome.catalogPosition?.operationId !== undefined &&
|
|
294
|
+
outcome.catalogPosition.operationId !== operationId)) {
|
|
295
|
+
throw new ProtocolError("filter mutation outcome does not match the requested operation");
|
|
296
|
+
}
|
|
297
|
+
return outcome;
|
|
298
|
+
}
|
|
299
|
+
function bound(binding, applied) {
|
|
300
|
+
return {
|
|
301
|
+
binding,
|
|
302
|
+
...(applied.catalogPosition !== undefined ? { catalogPosition: applied.catalogPosition } : {})
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** Builds a {@link FilteredReader} over one consumer group. Start from `ConsumerGroup.reader()`. */
|
|
331
306
|
export class FilteredReaderBuilder {
|
|
332
307
|
transport;
|
|
333
308
|
capabilities;
|
|
334
309
|
filters;
|
|
335
310
|
source;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
311
|
+
consumer;
|
|
312
|
+
filter;
|
|
313
|
+
minCatalogPosition;
|
|
339
314
|
startAt = { kind: "next" };
|
|
340
315
|
pageCount = DEFAULT_COUNT;
|
|
316
|
+
examinedBudget;
|
|
341
317
|
replyBytes = DEFAULT_MAX_REPLY_BYTES;
|
|
342
318
|
mode = "primary";
|
|
343
319
|
guarded = false;
|
|
344
320
|
idleMs = DEFAULT_IDLE_INTERVAL_MS;
|
|
345
321
|
maxUnacked = DEFAULT_MAX_UNACKED_PAGES;
|
|
346
|
-
|
|
322
|
+
selectedPartitions = new Set();
|
|
323
|
+
constructor(transport, capabilities, filters, source, consumer, filter, minCatalogPosition) {
|
|
347
324
|
this.transport = transport;
|
|
348
325
|
this.capabilities = capabilities;
|
|
349
326
|
this.filters = filters;
|
|
350
327
|
this.source = source;
|
|
328
|
+
this.consumer = consumer;
|
|
329
|
+
this.filter = filter;
|
|
330
|
+
this.minCatalogPosition = minCatalogPosition;
|
|
351
331
|
}
|
|
352
|
-
/**
|
|
353
|
-
consumer
|
|
354
|
-
|
|
355
|
-
return this;
|
|
356
|
-
}
|
|
357
|
-
/**
|
|
358
|
-
* Read as a member of the existing consumer group `name`. A reader built
|
|
359
|
-
* from a connection string joins over its own connection, so every reader
|
|
360
|
-
* is its own member.
|
|
361
|
-
*/
|
|
362
|
-
group(name) {
|
|
363
|
-
this.consumerName = { kind: "group", name };
|
|
364
|
-
return this;
|
|
365
|
-
}
|
|
366
|
-
/** Read the existing group by its native numeric identity. */
|
|
367
|
-
groupId(id) {
|
|
368
|
-
if (typeof id === "number" && !Number.isSafeInteger(id))
|
|
369
|
-
throw new InvalidError("consumer group id must be an integer");
|
|
370
|
-
const value = BigInt(id);
|
|
371
|
-
if (value < 0n || value > 0xffffffffn)
|
|
372
|
-
throw new InvalidError("consumer group id exceeds 32 bits");
|
|
373
|
-
this.consumerName = { kind: "group_id", id: value };
|
|
374
|
-
return this;
|
|
375
|
-
}
|
|
376
|
-
/**
|
|
377
|
-
* Read only `partitionId`. Repeat for more. An independent consumer reads
|
|
378
|
-
* every partition when none is named, including partitions added later.
|
|
379
|
-
*/
|
|
380
|
-
partition(partitionId) {
|
|
381
|
-
this.partitionIds.push(partitionId);
|
|
382
|
-
return this;
|
|
383
|
-
}
|
|
384
|
-
/** Which filter runs. A group defaults to its bound revision. */
|
|
385
|
-
filter(filter) {
|
|
386
|
-
this.filterRef = filter;
|
|
387
|
-
return this;
|
|
388
|
-
}
|
|
389
|
-
/** Run `filter` from the request. Needs no catalog. */
|
|
390
|
-
inline(filter) {
|
|
391
|
-
return this.filter({ kind: "inline", filter });
|
|
392
|
-
}
|
|
393
|
-
/** Run a saved revision. */
|
|
394
|
-
revision(filterId, revision) {
|
|
395
|
-
return this.filter({ kind: "revision", filterId, revision });
|
|
332
|
+
/** @internal */
|
|
333
|
+
static create(transport, capabilities, filters, source, consumer, filter, minCatalogPosition) {
|
|
334
|
+
return new FilteredReaderBuilder(transport, capabilities, filters, source, consumer, filter, minCatalogPosition);
|
|
396
335
|
}
|
|
397
336
|
/**
|
|
398
337
|
* Where each partition read at the build starts. Defaults to after the
|
|
399
|
-
* stored offset. A partition
|
|
338
|
+
* stored offset. A partition the group hands this reader later always resumes
|
|
400
339
|
* after the group's stored offset, so the previous owner's unacknowledged
|
|
401
340
|
* backlog is read, not skipped.
|
|
402
341
|
*/
|
|
@@ -404,11 +343,29 @@ export class FilteredReaderBuilder {
|
|
|
404
343
|
this.startAt = start;
|
|
405
344
|
return this;
|
|
406
345
|
}
|
|
346
|
+
/** Read this partition only while the group assigns it to this member. Repeat for several. */
|
|
347
|
+
partition(partitionId) {
|
|
348
|
+
if (!Number.isSafeInteger(partitionId) || partitionId < 0 || partitionId > 0xffff_ffff) {
|
|
349
|
+
throw new InvalidError("partition must be an unsigned 32-bit integer");
|
|
350
|
+
}
|
|
351
|
+
this.selectedPartitions.add(partitionId);
|
|
352
|
+
return this;
|
|
353
|
+
}
|
|
407
354
|
/** Most matching records per page. */
|
|
408
355
|
count(count) {
|
|
409
356
|
this.pageCount = count;
|
|
410
357
|
return this;
|
|
411
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* Most source records one page examines, independent of `count`. Without it
|
|
361
|
+
* the server's own budget bounds the scan. A page that examines its budget
|
|
362
|
+
* without a match returns empty and the reader continues from where it
|
|
363
|
+
* stopped.
|
|
364
|
+
*/
|
|
365
|
+
maxExamined(records) {
|
|
366
|
+
this.examinedBudget = records;
|
|
367
|
+
return this;
|
|
368
|
+
}
|
|
412
369
|
/** Most record bytes per page. The server may lower it. */
|
|
413
370
|
maxReplyBytes(bytes) {
|
|
414
371
|
this.replyBytes = bytes;
|
|
@@ -447,92 +404,67 @@ export class FilteredReaderBuilder {
|
|
|
447
404
|
}
|
|
448
405
|
async build() {
|
|
449
406
|
const capabilities = await this.capabilities();
|
|
450
|
-
if (!capabilities.filters.
|
|
451
|
-
throw new UnsupportedError("consumer filters
|
|
407
|
+
if (this.filter.kind === "group" && !capabilities.filters.groupPolicyReads) {
|
|
408
|
+
throw new UnsupportedError("this server serves consumer filters but not group-aware reads, upgrade it before reading groups through the Laser SDK");
|
|
452
409
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
evaluation !== undefined &&
|
|
456
|
-
!filterAnnounceEvaluates(evaluation, this.filterRef.filter.evaluatorVersion, this.filterRef.filter.codec)) {
|
|
457
|
-
throw new UnsupportedError(`this server does not evaluate ${this.filterRef.filter.codec} filters of evaluator version ${String(this.filterRef.filter.evaluatorVersion)}`);
|
|
410
|
+
if (this.filter.kind !== "group" && !capabilities.filters.native) {
|
|
411
|
+
throw new UnsupportedError("consumer filters are not served by this server");
|
|
458
412
|
}
|
|
459
413
|
if (this.mode === "primary" && this.transport.connectsNodes !== true) {
|
|
460
|
-
throw new ConfigError("primary
|
|
461
|
-
}
|
|
462
|
-
const consumer = this.consumerName;
|
|
463
|
-
if (consumer === undefined) {
|
|
464
|
-
throw new ConfigError("a filtered reader needs .consumer(name), .group(name), or .groupId(id)");
|
|
465
|
-
}
|
|
466
|
-
const filter = this.filterRef ?? (consumer.kind !== "consumer" ? { kind: "bound" } : undefined);
|
|
467
|
-
if (filter === undefined) {
|
|
468
|
-
throw new ConfigError("an independent filtered reader needs .inline(..), .revision(..), or .filter(..)");
|
|
414
|
+
throw new ConfigError("primary group reads open data connections, so they need a Laser connected from a connection string. Read in local mode for an injected client");
|
|
469
415
|
}
|
|
416
|
+
const consumer = this.consumer;
|
|
470
417
|
const request = {
|
|
471
418
|
v: FILTER_OP_VERSION,
|
|
472
419
|
source: this.source,
|
|
473
420
|
partitionId: 0,
|
|
474
421
|
consumer,
|
|
475
|
-
filter,
|
|
422
|
+
filter: this.filter,
|
|
476
423
|
start: this.startAt,
|
|
477
424
|
count: this.pageCount,
|
|
478
425
|
maxReplyBytes: this.replyBytes,
|
|
479
|
-
readMode: this.mode
|
|
426
|
+
readMode: this.mode,
|
|
427
|
+
...(this.examinedBudget !== undefined ? { maxExamined: this.examinedBudget } : {}),
|
|
428
|
+
...(this.minCatalogPosition !== undefined
|
|
429
|
+
? { minCatalogPosition: this.minCatalogPosition }
|
|
430
|
+
: {})
|
|
480
431
|
};
|
|
481
432
|
validateFilteredPollRequest(request);
|
|
482
|
-
const guard = this.guarded && filter.kind !== "bound"
|
|
483
|
-
? await compileGuard(this.transport, await definition(this.filters, filter))
|
|
484
|
-
: undefined;
|
|
485
|
-
if (consumer.kind !== "consumer" && this.partitionIds.length > 0) {
|
|
486
|
-
throw new ConfigError("a group reader reads the partitions the group assigns it");
|
|
487
|
-
}
|
|
488
433
|
const pinnedSource = consumer.kind === "group_id"
|
|
489
434
|
? await sourceIncarnation(this.transport, this.source)
|
|
490
435
|
: undefined;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
436
|
+
// A group reader owns the connection that holds its membership, so two
|
|
437
|
+
// readers are two members. An injected client has only the shared one.
|
|
438
|
+
const coordinator = this.transport.connectsNodes === true && this.transport.openCoordinator !== undefined
|
|
494
439
|
? await this.transport.openCoordinator()
|
|
495
440
|
: sharedCoordinator(this.transport);
|
|
496
441
|
let membership;
|
|
497
|
-
let partitionIds;
|
|
498
|
-
let discovers = false;
|
|
499
442
|
try {
|
|
500
|
-
|
|
501
|
-
membership = await Membership.join(coordinator, this.source, consumer.kind === "group_id" ? Number(consumer.id) : consumer.name, this.transport, pinnedSource);
|
|
502
|
-
partitionIds = membership.partitions;
|
|
503
|
-
}
|
|
504
|
-
else if (this.partitionIds.length > 0) {
|
|
505
|
-
partitionIds = this.partitionIds;
|
|
506
|
-
}
|
|
507
|
-
else {
|
|
508
|
-
partitionIds = await topicPartitions(this.transport, this.source);
|
|
509
|
-
discovers = true;
|
|
510
|
-
}
|
|
443
|
+
membership = await Membership.join(coordinator, this.source, consumer.kind === "group_id" ? Number(consumer.id) : consumer.name, this.transport, pinnedSource);
|
|
511
444
|
}
|
|
512
445
|
catch (error) {
|
|
513
446
|
await coordinator.close();
|
|
514
447
|
throw error;
|
|
515
448
|
}
|
|
516
|
-
return
|
|
449
|
+
return FilteredReader.create({
|
|
517
450
|
transport: this.transport,
|
|
518
451
|
...(pinnedSource === undefined ? {} : { pinnedSource }),
|
|
519
452
|
filters: this.filters,
|
|
520
453
|
coordinator,
|
|
521
454
|
request,
|
|
522
|
-
inlineDigest: filter.kind === "inline" ? consumerFilterDigest(filter.filter) : undefined,
|
|
523
455
|
start: this.startAt,
|
|
524
456
|
idleIntervalMs: this.idleMs,
|
|
525
457
|
maxUnackedPages: this.maxUnacked,
|
|
526
|
-
guard,
|
|
458
|
+
guard: undefined,
|
|
527
459
|
guardEnabled: this.guarded,
|
|
528
460
|
membership,
|
|
529
|
-
|
|
530
|
-
partitionIds
|
|
461
|
+
selectedPartitions: new Set(this.selectedPartitions),
|
|
462
|
+
partitionIds: membership.partitions.filter((partition) => this.selectedPartitions.size === 0 || this.selectedPartitions.has(partition))
|
|
531
463
|
});
|
|
532
464
|
}
|
|
533
465
|
}
|
|
534
466
|
/**
|
|
535
|
-
* Reads
|
|
467
|
+
* Reads the records a consumer group's policy selects, with their original
|
|
536
468
|
* offsets, and stores progress through fenced acknowledgments. Pages are read
|
|
537
469
|
* ahead of acknowledgments, and a page's safe offset is stored only after it
|
|
538
470
|
* and every earlier page of the partition are fully handled. Pages that
|
|
@@ -554,7 +486,6 @@ export class FilteredReader {
|
|
|
554
486
|
sequence = 0;
|
|
555
487
|
owner = {};
|
|
556
488
|
retiredOwners = new WeakSet();
|
|
557
|
-
listedAt;
|
|
558
489
|
closed = false;
|
|
559
490
|
deferredError;
|
|
560
491
|
pinnedSource;
|
|
@@ -565,11 +496,14 @@ export class FilteredReader {
|
|
|
565
496
|
this.routes = new Routes(settings.transport, settings.coordinator);
|
|
566
497
|
this.guard = settings.guard;
|
|
567
498
|
this.pinnedSource = settings.pinnedSource;
|
|
568
|
-
this.listedAt = settings.discovers ? performance.now() : undefined;
|
|
569
499
|
for (const partitionId of settings.partitionIds) {
|
|
570
500
|
this.progress.set(partitionId, new PartitionProgress(settings.start));
|
|
571
501
|
}
|
|
572
502
|
}
|
|
503
|
+
/** @internal */
|
|
504
|
+
static create(settings) {
|
|
505
|
+
return new FilteredReader(settings);
|
|
506
|
+
}
|
|
573
507
|
/** The next page with at least one match. Waits while nothing is new, up to `timeoutMs` when given. */
|
|
574
508
|
async nextPage(options = {}) {
|
|
575
509
|
const deadline = options.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs;
|
|
@@ -641,6 +575,43 @@ export class FilteredReader {
|
|
|
641
575
|
this.partitionProgress(page.partitionId).completePage(sequence);
|
|
642
576
|
await this.flush(page.partitionId);
|
|
643
577
|
}
|
|
578
|
+
/**
|
|
579
|
+
* Mark this partition's records through `record` handled without storing
|
|
580
|
+
* yet. The normal consumer delivers in order, so every delivery completes
|
|
581
|
+
* the prefix through it, and it stores that prefix on its own cadence
|
|
582
|
+
* through `flushCompleted`.
|
|
583
|
+
*
|
|
584
|
+
* @internal
|
|
585
|
+
*/
|
|
586
|
+
handled(record) {
|
|
587
|
+
const tag = record[READER_TAG];
|
|
588
|
+
if (tag?.owner !== this.owner)
|
|
589
|
+
return;
|
|
590
|
+
this.progress.get(record.partitionId)?.completeThrough(tag.sequence, record.offset);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Store every completed prefix now.
|
|
594
|
+
*
|
|
595
|
+
* @internal
|
|
596
|
+
*/
|
|
597
|
+
async flushCompleted() {
|
|
598
|
+
if (this.settings.request.readMode !== "primary")
|
|
599
|
+
return;
|
|
600
|
+
let failure;
|
|
601
|
+
for (const partitionId of [...this.progress.keys()]) {
|
|
602
|
+
try {
|
|
603
|
+
await this.flush(partitionId);
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
failure ??= error;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (failure !== undefined) {
|
|
610
|
+
throw failure instanceof Error
|
|
611
|
+
? failure
|
|
612
|
+
: new TransportError("storing group progress failed", true, { cause: failure });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
644
615
|
/** The partitions this reader reads now. */
|
|
645
616
|
partitions() {
|
|
646
617
|
return [...this.progress.keys()].filter((partitionId) => !this.revoked.has(partitionId));
|
|
@@ -725,9 +696,6 @@ export class FilteredReader {
|
|
|
725
696
|
}
|
|
726
697
|
if (this.settings.membership?.isDue() === true)
|
|
727
698
|
await this.refreshMembership();
|
|
728
|
-
if (this.listedAt !== undefined && performance.now() - this.listedAt >= ASSIGNMENT_REFRESH_MS) {
|
|
729
|
-
await this.refreshPartitions();
|
|
730
|
-
}
|
|
731
699
|
const readable = this.partitions();
|
|
732
700
|
if (readable.length === 0)
|
|
733
701
|
return { kind: "idle" };
|
|
@@ -796,6 +764,13 @@ export class FilteredReader {
|
|
|
796
764
|
return { kind: "empty", more: false };
|
|
797
765
|
}
|
|
798
766
|
this.restartAfter(partitionId, error);
|
|
767
|
+
// The group's policy changed under this read. The partition restarted
|
|
768
|
+
// from its stored offset and is read again at once under the policy the
|
|
769
|
+
// group holds now. Only an explicit acknowledgment of a record read
|
|
770
|
+
// under the old policy reports the change.
|
|
771
|
+
if (error instanceof FilterExecutionError && error.reason === "conflict") {
|
|
772
|
+
return { kind: "empty", more: true };
|
|
773
|
+
}
|
|
799
774
|
throw error;
|
|
800
775
|
}
|
|
801
776
|
this.requireOpen();
|
|
@@ -811,7 +786,7 @@ export class FilteredReader {
|
|
|
811
786
|
if (recordHead.getUint32(12, true) !== page.matched || page.matched > request.count)
|
|
812
787
|
throw new ProtocolError("filtered record body has invalid counts");
|
|
813
788
|
const messages = decodePolledBody(page.records);
|
|
814
|
-
validatePage(request,
|
|
789
|
+
validatePage(request, page, messages);
|
|
815
790
|
if (request.consumer.kind === "group_id") {
|
|
816
791
|
const pinned = this.pinnedSource;
|
|
817
792
|
const current = page.generation;
|
|
@@ -828,16 +803,19 @@ export class FilteredReader {
|
|
|
828
803
|
this.pinnedSource ??= current;
|
|
829
804
|
}
|
|
830
805
|
const unevaluated = new Set(page.unevaluated);
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
try {
|
|
836
|
-
checkGuard(this.guard, page, messages, unevaluated);
|
|
806
|
+
// An unfiltered page ran no filter, so there is nothing to check.
|
|
807
|
+
if (page.policy.mode === "filtered") {
|
|
808
|
+
if (this.settings.guardEnabled && this.guard === undefined) {
|
|
809
|
+
this.guard = await compileGuard(this.settings.transport, await boundDefinition(this.settings.filters, request.source, page));
|
|
837
810
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
811
|
+
if (this.guard !== undefined) {
|
|
812
|
+
try {
|
|
813
|
+
checkGuard(this.guard, page, messages, unevaluated);
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
this.restartAfter(partitionId, error);
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
841
819
|
}
|
|
842
820
|
}
|
|
843
821
|
this.sequence += 1;
|
|
@@ -869,7 +847,7 @@ export class FilteredReader {
|
|
|
869
847
|
partitionId,
|
|
870
848
|
offset: message.offset,
|
|
871
849
|
frontier: page.frontier,
|
|
872
|
-
evaluated: !unevaluated.has(message.offset),
|
|
850
|
+
evaluated: page.policy.mode === "filtered" && !unevaluated.has(message.offset),
|
|
873
851
|
payload: message.payload,
|
|
874
852
|
headers: message.headers,
|
|
875
853
|
headersMalformed: message.headersMalformed !== undefined,
|
|
@@ -907,8 +885,7 @@ export class FilteredReader {
|
|
|
907
885
|
}
|
|
908
886
|
if (error.reason === "source_changed")
|
|
909
887
|
this.settings.membership?.expire();
|
|
910
|
-
|
|
911
|
-
if (request.filter.kind === "bound")
|
|
888
|
+
if (this.settings.guardEnabled)
|
|
912
889
|
this.guard = undefined;
|
|
913
890
|
}
|
|
914
891
|
// Store the completed prefix of one partition. A lost reply keeps the target
|
|
@@ -927,8 +904,10 @@ export class FilteredReader {
|
|
|
927
904
|
partitionId,
|
|
928
905
|
consumer: request.consumer,
|
|
929
906
|
generation: target.generation,
|
|
930
|
-
digest: target.digest,
|
|
931
|
-
offset: target.offset
|
|
907
|
+
...(target.digest === undefined ? {} : { digest: target.digest }),
|
|
908
|
+
offset: target.offset,
|
|
909
|
+
mode: target.mode,
|
|
910
|
+
policyGeneration: target.policyGeneration
|
|
932
911
|
};
|
|
933
912
|
const encoded = encodeFilteredAck(ack);
|
|
934
913
|
for (let rerouted = false;; rerouted = true) {
|
|
@@ -949,6 +928,7 @@ export class FilteredReader {
|
|
|
949
928
|
return;
|
|
950
929
|
}
|
|
951
930
|
catch (error) {
|
|
931
|
+
this.restartAfter(partitionId, error);
|
|
952
932
|
if (rerouted || !isRouteLost(error))
|
|
953
933
|
throw error;
|
|
954
934
|
}
|
|
@@ -988,11 +968,13 @@ export class FilteredReader {
|
|
|
988
968
|
if (membership === undefined || !(await membership.sync()))
|
|
989
969
|
return;
|
|
990
970
|
this.requireOpen();
|
|
991
|
-
const
|
|
971
|
+
const selection = this.settings.selectedPartitions;
|
|
972
|
+
const assigned = new Set(membership.partitions.filter((partition) => selection === undefined || selection.size === 0 || selection.has(partition)));
|
|
992
973
|
const rejoined = membership.takeRejoined();
|
|
993
974
|
const stale = rejoined ? this.routes.detach() : [];
|
|
994
975
|
if (rejoined) {
|
|
995
|
-
|
|
976
|
+
// A rejoin can land in a recreated group with another binding.
|
|
977
|
+
if (this.settings.guardEnabled)
|
|
996
978
|
this.guard = undefined;
|
|
997
979
|
this.retiredOwners.add(this.owner);
|
|
998
980
|
this.owner = {};
|
|
@@ -1022,17 +1004,6 @@ export class FilteredReader {
|
|
|
1022
1004
|
for (const partitionId of left)
|
|
1023
1005
|
await this.routes.release(partitionId);
|
|
1024
1006
|
}
|
|
1025
|
-
// An independent reader of every partition picks up partitions added to
|
|
1026
|
-
// the topic since the build, resuming their stored progress.
|
|
1027
|
-
async refreshPartitions() {
|
|
1028
|
-
const listed = await topicPartitions(this.settings.transport, this.settings.request.source);
|
|
1029
|
-
this.listedAt = performance.now();
|
|
1030
|
-
for (const partitionId of listed) {
|
|
1031
|
-
if (!this.progress.has(partitionId)) {
|
|
1032
|
-
this.progress.set(partitionId, new PartitionProgress({ kind: "next" }));
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
1007
|
// Forget a revoked partition once nothing of it is in flight.
|
|
1037
1008
|
retire(partitionId) {
|
|
1038
1009
|
const progress = this.progress.get(partitionId);
|
|
@@ -1162,7 +1133,9 @@ class PartitionProgress {
|
|
|
1162
1133
|
: {
|
|
1163
1134
|
offset: page.safeAckOffset,
|
|
1164
1135
|
generation: page.generation,
|
|
1165
|
-
digest: page.policy.digest,
|
|
1136
|
+
...(page.policy.digest === undefined ? {} : { digest: page.policy.digest }),
|
|
1137
|
+
mode: page.policy.mode,
|
|
1138
|
+
policyGeneration: page.policy.policyGeneration,
|
|
1166
1139
|
...(page.policy.groupId !== undefined ? { groupId: page.policy.groupId } : {})
|
|
1167
1140
|
};
|
|
1168
1141
|
const newest = this.pages.at(-1);
|
|
@@ -1233,6 +1206,11 @@ class Routes {
|
|
|
1233
1206
|
opening = new Map();
|
|
1234
1207
|
partitions = new Map();
|
|
1235
1208
|
closed = false;
|
|
1209
|
+
// Whether the deployment is one node, learned on the first route. A single
|
|
1210
|
+
// node is every partition's primary, and the address it advertises can be
|
|
1211
|
+
// one the caller cannot reach (a port mapped by a container runtime, a slot
|
|
1212
|
+
// behind a proxy), so its data connection dials what the caller dialed.
|
|
1213
|
+
singleNode;
|
|
1236
1214
|
/** Data connections these routes opened. */
|
|
1237
1215
|
opened = 0;
|
|
1238
1216
|
constructor(transport, coordinator) {
|
|
@@ -1252,7 +1230,12 @@ class Routes {
|
|
|
1252
1230
|
? encodeOffsetRouting(source, consumer, partitionId)
|
|
1253
1231
|
: encodePollRouting(source, consumer, partitionId)));
|
|
1254
1232
|
this.requireOpen();
|
|
1255
|
-
|
|
1233
|
+
if (this.singleNode === undefined) {
|
|
1234
|
+
const count = this.transport.clusterNodeCount;
|
|
1235
|
+
this.singleNode = count === undefined ? false : (await count.call(this.transport)) <= 1;
|
|
1236
|
+
}
|
|
1237
|
+
const dial = this.singleNode ? { ...route, ip: "", tcpPort: 0 } : route;
|
|
1238
|
+
const endpoint = this.singleNode ? "caller" : `${route.ip}:${String(route.tcpPort)}`;
|
|
1256
1239
|
const existing = this.connections.get(endpoint);
|
|
1257
1240
|
const reuse = existing === undefined ? undefined : sessionReuse(existing.session, route.session);
|
|
1258
1241
|
if (existing !== undefined && reuse === "raise") {
|
|
@@ -1268,7 +1251,7 @@ class Routes {
|
|
|
1268
1251
|
await this.dropEndpoint(endpoint);
|
|
1269
1252
|
}
|
|
1270
1253
|
if (!this.connections.has(endpoint)) {
|
|
1271
|
-
if (route.tcpPort === 0) {
|
|
1254
|
+
if (!this.singleNode && route.tcpPort === 0) {
|
|
1272
1255
|
throw new UnsupportedError(`partition primary ${route.name} serves no TCP endpoint`);
|
|
1273
1256
|
}
|
|
1274
1257
|
let pending = this.opening.get(endpoint);
|
|
@@ -1281,7 +1264,7 @@ class Routes {
|
|
|
1281
1264
|
}
|
|
1282
1265
|
pending = this.opening.get(endpoint);
|
|
1283
1266
|
if (pending === undefined && !this.connections.has(endpoint)) {
|
|
1284
|
-
pending = attach(open.bind(this.transport),
|
|
1267
|
+
pending = attach(open.bind(this.transport), dial).then((connection) => {
|
|
1285
1268
|
this.opened += 1;
|
|
1286
1269
|
return connection;
|
|
1287
1270
|
});
|
|
@@ -1374,7 +1357,7 @@ async function attach(open, route) {
|
|
|
1374
1357
|
return connection;
|
|
1375
1358
|
}
|
|
1376
1359
|
// The shared transport as a coordinator, for a reader that has no connection
|
|
1377
|
-
// of its own: an injected client
|
|
1360
|
+
// of its own: an injected client.
|
|
1378
1361
|
function sharedCoordinator(transport) {
|
|
1379
1362
|
return {
|
|
1380
1363
|
send: (code, payload) => transport.sendManaged(code, payload),
|
|
@@ -1387,10 +1370,6 @@ function sharedCoordinator(transport) {
|
|
|
1387
1370
|
close: () => Promise.resolve()
|
|
1388
1371
|
};
|
|
1389
1372
|
}
|
|
1390
|
-
async function topicPartitions(transport, source) {
|
|
1391
|
-
const count = await transport.getTopicPartitionCount(source.stream, source.topic);
|
|
1392
|
-
return Array.from({ length: count }, (_, index) => index);
|
|
1393
|
-
}
|
|
1394
1373
|
/** This reader's membership of a consumer group, held by its coordinator connection. */
|
|
1395
1374
|
class Membership {
|
|
1396
1375
|
coordinator;
|
|
@@ -1507,6 +1486,37 @@ async function sourceIncarnation(transport, source) {
|
|
|
1507
1486
|
topicCreatedAtMicros: topic.created
|
|
1508
1487
|
};
|
|
1509
1488
|
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Read a consumer group's native id and name, and the stream and topic
|
|
1491
|
+
* incarnations that scope it. Throws with reason `not_found` when the group
|
|
1492
|
+
* does not exist.
|
|
1493
|
+
*/
|
|
1494
|
+
export async function nativeGroup(transport, source, group) {
|
|
1495
|
+
const incarnation = await sourceIncarnation(transport, source);
|
|
1496
|
+
const reply = await transport.sendManaged(GET_CONSUMER_GROUP_CODE, concat([identifier(source.stream), identifier(source.topic), identifier(group)]));
|
|
1497
|
+
// The reply begins with id:u32, partitions:u32, members:u32, then the name
|
|
1498
|
+
// as one length byte and its bytes. A missing group answers an empty body.
|
|
1499
|
+
if (reply.byteLength === 0)
|
|
1500
|
+
throw filterError({
|
|
1501
|
+
code: { kind: "known", name: "NotFound" },
|
|
1502
|
+
reason: "not_found",
|
|
1503
|
+
message: `consumer group ${String(group)} does not exist`
|
|
1504
|
+
});
|
|
1505
|
+
if (reply.byteLength < 13)
|
|
1506
|
+
throw new ProtocolError("consumer group metadata is truncated");
|
|
1507
|
+
const view = new DataView(reply.buffer, reply.byteOffset, reply.byteLength);
|
|
1508
|
+
const id = view.getUint32(0, true);
|
|
1509
|
+
if (typeof group === "number" && id !== group)
|
|
1510
|
+
throw new ProtocolError("consumer group metadata identifies another group");
|
|
1511
|
+
const length = view.getUint8(12);
|
|
1512
|
+
if (reply.byteLength < 13 + length)
|
|
1513
|
+
throw new ProtocolError("consumer group metadata is truncated");
|
|
1514
|
+
return {
|
|
1515
|
+
id,
|
|
1516
|
+
name: new TextDecoder().decode(reply.subarray(13, 13 + length)),
|
|
1517
|
+
identity: { ...incarnation, groupId: BigInt(id) }
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1510
1520
|
function identifier(name) {
|
|
1511
1521
|
if (typeof name === "number") {
|
|
1512
1522
|
const encoded = new Uint8Array(6);
|
|
@@ -1614,7 +1624,7 @@ function sameBytes(left, right) {
|
|
|
1614
1624
|
// Re-evaluate every returned record with the shared evaluator before the
|
|
1615
1625
|
// caller sees it.
|
|
1616
1626
|
function checkGuard(guard, page, messages, unevaluated) {
|
|
1617
|
-
if (!sameBytes(page.policy.digest, guard.digest)) {
|
|
1627
|
+
if (page.policy.digest === undefined || !sameBytes(page.policy.digest, guard.digest)) {
|
|
1618
1628
|
throw filterError({
|
|
1619
1629
|
code: { kind: "known", name: "Conflict" },
|
|
1620
1630
|
reason: "conflict",
|
|
@@ -1747,7 +1757,7 @@ async function boundDefinition(filters, requested, page) {
|
|
|
1747
1757
|
identity.topicId === source.topicId &&
|
|
1748
1758
|
identity.topicCreatedAtMicros === source.topicCreatedAtMicros);
|
|
1749
1759
|
if (binding !== undefined) {
|
|
1750
|
-
if (!sameBytes(binding.digest, page.policy.digest))
|
|
1760
|
+
if (page.policy.digest === undefined || !sameBytes(binding.digest, page.policy.digest))
|
|
1751
1761
|
throw new ProtocolError("served policy differs from the group's catalog binding");
|
|
1752
1762
|
return definition(filters, {
|
|
1753
1763
|
kind: "revision",
|
|
@@ -1766,8 +1776,8 @@ async function boundDefinition(filters, requested, page) {
|
|
|
1766
1776
|
async function definition(filters, filter) {
|
|
1767
1777
|
if (filter.kind === "inline")
|
|
1768
1778
|
return filter.filter;
|
|
1769
|
-
if (filter.kind === "bound")
|
|
1770
|
-
throw new ConfigError("resolve a
|
|
1779
|
+
if (filter.kind === "bound" || filter.kind === "group")
|
|
1780
|
+
throw new ConfigError("resolve a group guard from the served page");
|
|
1771
1781
|
const { filterId, revision } = filter;
|
|
1772
1782
|
for (let page = 0;; page += 1) {
|
|
1773
1783
|
const revisions = await filters.revisions(filterId, { page, pageSize: MAX_FILTER_CATALOG_PAGE });
|
|
@@ -1831,7 +1841,7 @@ function sameGeneration(left, right) {
|
|
|
1831
1841
|
left.partitionCreatedRevision === right.partitionCreatedRevision &&
|
|
1832
1842
|
left.purgeGeneration === right.purgeGeneration);
|
|
1833
1843
|
}
|
|
1834
|
-
function validatePage(request,
|
|
1844
|
+
function validatePage(request, page, messages) {
|
|
1835
1845
|
const invalid = () => {
|
|
1836
1846
|
throw new ProtocolError("filtered page does not match its request or scan boundary");
|
|
1837
1847
|
};
|
|
@@ -1844,14 +1854,20 @@ function validatePage(request, inlineDigest, page, messages) {
|
|
|
1844
1854
|
page.matched !== messages.length ||
|
|
1845
1855
|
page.matched > request.count ||
|
|
1846
1856
|
page.matched > page.examined ||
|
|
1847
|
-
|
|
1857
|
+
(request.maxExamined !== undefined && page.examined > request.maxExamined) ||
|
|
1858
|
+
(page.policy.mode === "filtered") !== (page.policy.digest !== undefined) ||
|
|
1859
|
+
(page.policy.digest !== undefined && page.policy.digest.length !== 32) ||
|
|
1860
|
+
(page.policy.mode === "unfiltered" && page.policy.groupId === undefined) ||
|
|
1861
|
+
(page.policy.mode === "unfiltered" && page.matched !== page.examined) ||
|
|
1862
|
+
(page.policy.mode === "unfiltered" &&
|
|
1863
|
+
(page.policy.filterId !== undefined || page.policy.revision !== undefined)) ||
|
|
1864
|
+
// A strict group read runs a binding and never accepts an unfiltered page.
|
|
1865
|
+
(page.policy.mode === "unfiltered" && request.filter.kind !== "group") ||
|
|
1848
1866
|
(request.consumer.kind !== "consumer") !== (page.policy.groupId !== undefined))
|
|
1849
1867
|
invalid();
|
|
1850
1868
|
const body = new DataView(page.records.buffer, page.records.byteOffset, page.records.byteLength);
|
|
1851
1869
|
if (body.getUint32(0, true) !== page.partitionId || body.getBigUint64(4, true) !== page.frontier)
|
|
1852
1870
|
invalid();
|
|
1853
|
-
if (inlineDigest !== undefined && !sameBytes(inlineDigest, page.policy.digest))
|
|
1854
|
-
invalid();
|
|
1855
1871
|
if (request.filter.kind === "revision" &&
|
|
1856
1872
|
(page.policy.filterId !== request.filter.filterId ||
|
|
1857
1873
|
page.policy.revision !== request.filter.revision))
|
|
@@ -1861,7 +1877,12 @@ function validatePage(request, inlineDigest, page, messages) {
|
|
|
1861
1877
|
const cursor = request.start.continuation;
|
|
1862
1878
|
if (!sameGeneration(cursor.generation, page.generation) ||
|
|
1863
1879
|
cursor.groupId !== page.policy.groupId ||
|
|
1864
|
-
|
|
1880
|
+
cursor.mode !== page.policy.mode ||
|
|
1881
|
+
cursor.policyGeneration !== page.policy.policyGeneration ||
|
|
1882
|
+
(cursor.digest === undefined) !== (page.policy.digest === undefined) ||
|
|
1883
|
+
(cursor.digest !== undefined &&
|
|
1884
|
+
page.policy.digest !== undefined &&
|
|
1885
|
+
!sameBytes(cursor.digest, page.policy.digest)) ||
|
|
1865
1886
|
cursor.readMode !== request.readMode)
|
|
1866
1887
|
invalid();
|
|
1867
1888
|
start = cursor.nextScanOffset;
|