@laserdata/laser-sdk 0.5.0 → 0.5.1
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/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 +278 -268
- 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 +394 -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,48 @@ 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 = {}) {
|
|
135
122
|
const request = {
|
|
136
123
|
v: FILTER_OP_VERSION,
|
|
137
|
-
|
|
124
|
+
nameContains,
|
|
138
125
|
page: options.page ?? 0,
|
|
139
126
|
pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE
|
|
140
127
|
};
|
|
141
128
|
validateCatalogPage(request.page, request.pageSize);
|
|
142
|
-
const outcome = await this.catalog(
|
|
143
|
-
if (outcome.kind === "
|
|
129
|
+
const outcome = await this.catalog(ListFiltersCommand, request);
|
|
130
|
+
if (outcome.kind === "filters")
|
|
144
131
|
return outcome.page;
|
|
145
|
-
throw unexpected("
|
|
132
|
+
throw unexpected("filters");
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Drop a saved filter. The catalog releases every group bound to it.
|
|
136
|
+
*
|
|
137
|
+
* @internal
|
|
138
|
+
*/
|
|
139
|
+
async dropFilter(filterId) {
|
|
140
|
+
const applied = await this.apply({ kind: "drop", filterId });
|
|
141
|
+
if (applied.result.kind === "dropped")
|
|
142
|
+
return applied.catalogPosition;
|
|
143
|
+
throw unexpected("drop");
|
|
146
144
|
}
|
|
147
145
|
/** The binding of one consumer group. Throws with reason `not_found` when it is unbound. */
|
|
148
146
|
async binding(group) {
|
|
@@ -175,119 +173,80 @@ export class Filters {
|
|
|
175
173
|
operationId
|
|
176
174
|
});
|
|
177
175
|
if (outcome.kind === "mutation")
|
|
178
|
-
return outcome.outcome;
|
|
176
|
+
return checkedMutationOutcome(operationId, outcome.outcome);
|
|
179
177
|
throw unexpected("operation");
|
|
180
178
|
}
|
|
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
179
|
/** Add a revision. `expectedRevision` must still be the latest one. */
|
|
194
180
|
async revise(filterId, expectedRevision, filter) {
|
|
195
|
-
const
|
|
196
|
-
if (result.kind === "revised")
|
|
197
|
-
return result.revision;
|
|
181
|
+
const applied = await this.apply({ kind: "revise", filterId, expectedRevision, filter });
|
|
182
|
+
if (applied.result.kind === "revised")
|
|
183
|
+
return applied.result.revision;
|
|
198
184
|
throw unexpected("revise");
|
|
199
185
|
}
|
|
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");
|
|
186
|
+
/** Pause or resume a revision without changing its executable content or digest. */
|
|
187
|
+
async setRevisionEnabled(filterId, revision, enabled) {
|
|
188
|
+
await this.apply({ kind: "set_revision_enabled", filterId, revision, enabled });
|
|
225
189
|
}
|
|
226
|
-
/**
|
|
227
|
-
|
|
228
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Give `group` its policy in one catalog transaction: a definition saved as
|
|
192
|
+
* the group's own filter, or one of its existing revisions. A repeat with
|
|
193
|
+
* the same digest keeps the binding, another digest conflicts. The returned
|
|
194
|
+
* position is where the fold applied it, carried by the group's reads so
|
|
195
|
+
* they never see this configuration as absent.
|
|
196
|
+
*/
|
|
197
|
+
async configureGroup(group, policy, operationId = mintUlidValue(), expectedIdentity) {
|
|
198
|
+
const identity = expectedIdentity ?? (await nativeGroup(this.transport, group, group.group)).identity;
|
|
199
|
+
const applied = await this.apply({ kind: "configure_group", group, policy, expectedIdentity: identity }, operationId);
|
|
200
|
+
if (applied.result.kind === "bound")
|
|
201
|
+
return bound(applied.result.binding, applied);
|
|
202
|
+
throw unexpected("configure_group");
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Release this exact saved binding, including a group incarnation that was
|
|
206
|
+
* deleted and recreated under the same name.
|
|
207
|
+
*/
|
|
208
|
+
async releaseGroup(binding) {
|
|
209
|
+
const applied = await this.apply({
|
|
229
210
|
kind: "unbind",
|
|
230
211
|
group: binding.group,
|
|
231
212
|
expectedDigest: binding.digest,
|
|
232
213
|
expectedIdentity: binding.identity
|
|
233
214
|
});
|
|
234
|
-
if (result.kind === "unbound")
|
|
235
|
-
return result.binding;
|
|
215
|
+
if (applied.result.kind === "unbound")
|
|
216
|
+
return bound(applied.result.binding, applied);
|
|
236
217
|
throw unexpected("unbind");
|
|
237
218
|
}
|
|
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
219
|
/** Send one mutation under a caller-chosen `operationId`. The outcome may be pending. */
|
|
275
220
|
async mutate(operationId, mutation) {
|
|
276
221
|
const request = { v: FILTER_OP_VERSION, operationId, mutation };
|
|
277
222
|
validateFilterMutationRequest(request);
|
|
278
223
|
const outcome = await this.catalog(FilterMutateCommand, request);
|
|
279
224
|
if (outcome.kind === "mutation")
|
|
280
|
-
return outcome.outcome;
|
|
225
|
+
return checkedMutationOutcome(operationId, outcome.outcome);
|
|
281
226
|
throw unexpected("mutate");
|
|
282
227
|
}
|
|
283
|
-
|
|
228
|
+
// Submit a mutation and wait for its authoritative outcome. A lost reply is
|
|
229
|
+
// retried under the same id, which the catalog answers with the first
|
|
230
|
+
// attempt's outcome. A rejection throws `FilterExecutionError`. An outcome
|
|
231
|
+
// still pending after `DEFAULT_OUTCOME_WAIT_MS` throws
|
|
232
|
+
// `AmbiguousMutationError` naming the operation id.
|
|
233
|
+
async apply(mutation, operationId = mintUlidValue()) {
|
|
234
|
+
const submitted = await this.submit(operationId, mutation);
|
|
235
|
+
const outcome = submitted.status.kind === "pending"
|
|
236
|
+
? await this.waitForOutcome(operationId, DEFAULT_OUTCOME_WAIT_MS)
|
|
237
|
+
: submitted;
|
|
238
|
+
return {
|
|
239
|
+
result: settled(operationId, outcome.status),
|
|
240
|
+
...(outcome.catalogPosition !== undefined ? { catalogPosition: outcome.catalogPosition } : {})
|
|
241
|
+
};
|
|
242
|
+
}
|
|
284
243
|
async waitForOutcome(operationId, timeoutMs) {
|
|
285
244
|
const deadline = Date.now() + timeoutMs;
|
|
286
245
|
for (;;) {
|
|
287
246
|
try {
|
|
288
247
|
const outcome = await this.operation(operationId);
|
|
289
248
|
if (outcome.status.kind !== "pending")
|
|
290
|
-
return
|
|
249
|
+
return outcome;
|
|
291
250
|
}
|
|
292
251
|
catch (error) {
|
|
293
252
|
if (!isNotYetApplied(error))
|
|
@@ -327,76 +286,55 @@ export class Filters {
|
|
|
327
286
|
throw filterError(reply.error);
|
|
328
287
|
}
|
|
329
288
|
}
|
|
330
|
-
|
|
289
|
+
function checkedMutationOutcome(operationId, outcome) {
|
|
290
|
+
if (outcome.v !== FILTER_OP_VERSION ||
|
|
291
|
+
outcome.operationId !== operationId ||
|
|
292
|
+
(outcome.catalogPosition?.operationId !== undefined &&
|
|
293
|
+
outcome.catalogPosition.operationId !== operationId)) {
|
|
294
|
+
throw new ProtocolError("filter mutation outcome does not match the requested operation");
|
|
295
|
+
}
|
|
296
|
+
return outcome;
|
|
297
|
+
}
|
|
298
|
+
function bound(binding, applied) {
|
|
299
|
+
return {
|
|
300
|
+
binding,
|
|
301
|
+
...(applied.catalogPosition !== undefined ? { catalogPosition: applied.catalogPosition } : {})
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/** Builds a {@link FilteredReader} over one consumer group. Start from `ConsumerGroup.reader()`. */
|
|
331
305
|
export class FilteredReaderBuilder {
|
|
332
306
|
transport;
|
|
333
307
|
capabilities;
|
|
334
308
|
filters;
|
|
335
309
|
source;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
310
|
+
consumer;
|
|
311
|
+
filter;
|
|
312
|
+
minCatalogPosition;
|
|
339
313
|
startAt = { kind: "next" };
|
|
340
314
|
pageCount = DEFAULT_COUNT;
|
|
315
|
+
examinedBudget;
|
|
341
316
|
replyBytes = DEFAULT_MAX_REPLY_BYTES;
|
|
342
317
|
mode = "primary";
|
|
343
318
|
guarded = false;
|
|
344
319
|
idleMs = DEFAULT_IDLE_INTERVAL_MS;
|
|
345
320
|
maxUnacked = DEFAULT_MAX_UNACKED_PAGES;
|
|
346
|
-
|
|
321
|
+
selectedPartitions = new Set();
|
|
322
|
+
constructor(transport, capabilities, filters, source, consumer, filter, minCatalogPosition) {
|
|
347
323
|
this.transport = transport;
|
|
348
324
|
this.capabilities = capabilities;
|
|
349
325
|
this.filters = filters;
|
|
350
326
|
this.source = source;
|
|
327
|
+
this.consumer = consumer;
|
|
328
|
+
this.filter = filter;
|
|
329
|
+
this.minCatalogPosition = minCatalogPosition;
|
|
351
330
|
}
|
|
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 });
|
|
331
|
+
/** @internal */
|
|
332
|
+
static create(transport, capabilities, filters, source, consumer, filter, minCatalogPosition) {
|
|
333
|
+
return new FilteredReaderBuilder(transport, capabilities, filters, source, consumer, filter, minCatalogPosition);
|
|
396
334
|
}
|
|
397
335
|
/**
|
|
398
336
|
* Where each partition read at the build starts. Defaults to after the
|
|
399
|
-
* stored offset. A partition
|
|
337
|
+
* stored offset. A partition the group hands this reader later always resumes
|
|
400
338
|
* after the group's stored offset, so the previous owner's unacknowledged
|
|
401
339
|
* backlog is read, not skipped.
|
|
402
340
|
*/
|
|
@@ -404,11 +342,29 @@ export class FilteredReaderBuilder {
|
|
|
404
342
|
this.startAt = start;
|
|
405
343
|
return this;
|
|
406
344
|
}
|
|
345
|
+
/** Read this partition only while the group assigns it to this member. Repeat for several. */
|
|
346
|
+
partition(partitionId) {
|
|
347
|
+
if (!Number.isSafeInteger(partitionId) || partitionId < 0 || partitionId > 0xffff_ffff) {
|
|
348
|
+
throw new InvalidError("partition must be an unsigned 32-bit integer");
|
|
349
|
+
}
|
|
350
|
+
this.selectedPartitions.add(partitionId);
|
|
351
|
+
return this;
|
|
352
|
+
}
|
|
407
353
|
/** Most matching records per page. */
|
|
408
354
|
count(count) {
|
|
409
355
|
this.pageCount = count;
|
|
410
356
|
return this;
|
|
411
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* Most source records one page examines, independent of `count`. Without it
|
|
360
|
+
* the server's own budget bounds the scan. A page that examines its budget
|
|
361
|
+
* without a match returns empty and the reader continues from where it
|
|
362
|
+
* stopped.
|
|
363
|
+
*/
|
|
364
|
+
maxExamined(records) {
|
|
365
|
+
this.examinedBudget = records;
|
|
366
|
+
return this;
|
|
367
|
+
}
|
|
412
368
|
/** Most record bytes per page. The server may lower it. */
|
|
413
369
|
maxReplyBytes(bytes) {
|
|
414
370
|
this.replyBytes = bytes;
|
|
@@ -447,92 +403,67 @@ export class FilteredReaderBuilder {
|
|
|
447
403
|
}
|
|
448
404
|
async build() {
|
|
449
405
|
const capabilities = await this.capabilities();
|
|
450
|
-
if (!capabilities.filters.
|
|
451
|
-
throw new UnsupportedError("consumer filters
|
|
406
|
+
if (this.filter.kind === "group" && !capabilities.filters.groupPolicyReads) {
|
|
407
|
+
throw new UnsupportedError("this server serves consumer filters but not group-aware reads, upgrade it before reading groups through the Laser SDK");
|
|
452
408
|
}
|
|
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)}`);
|
|
409
|
+
if (this.filter.kind !== "group" && !capabilities.filters.native) {
|
|
410
|
+
throw new UnsupportedError("consumer filters are not served by this server");
|
|
458
411
|
}
|
|
459
412
|
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(..)");
|
|
413
|
+
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
414
|
}
|
|
415
|
+
const consumer = this.consumer;
|
|
470
416
|
const request = {
|
|
471
417
|
v: FILTER_OP_VERSION,
|
|
472
418
|
source: this.source,
|
|
473
419
|
partitionId: 0,
|
|
474
420
|
consumer,
|
|
475
|
-
filter,
|
|
421
|
+
filter: this.filter,
|
|
476
422
|
start: this.startAt,
|
|
477
423
|
count: this.pageCount,
|
|
478
424
|
maxReplyBytes: this.replyBytes,
|
|
479
|
-
readMode: this.mode
|
|
425
|
+
readMode: this.mode,
|
|
426
|
+
...(this.examinedBudget !== undefined ? { maxExamined: this.examinedBudget } : {}),
|
|
427
|
+
...(this.minCatalogPosition !== undefined
|
|
428
|
+
? { minCatalogPosition: this.minCatalogPosition }
|
|
429
|
+
: {})
|
|
480
430
|
};
|
|
481
431
|
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
432
|
const pinnedSource = consumer.kind === "group_id"
|
|
489
433
|
? await sourceIncarnation(this.transport, this.source)
|
|
490
434
|
: undefined;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
435
|
+
// A group reader owns the connection that holds its membership, so two
|
|
436
|
+
// readers are two members. An injected client has only the shared one.
|
|
437
|
+
const coordinator = this.transport.connectsNodes === true && this.transport.openCoordinator !== undefined
|
|
494
438
|
? await this.transport.openCoordinator()
|
|
495
439
|
: sharedCoordinator(this.transport);
|
|
496
440
|
let membership;
|
|
497
|
-
let partitionIds;
|
|
498
|
-
let discovers = false;
|
|
499
441
|
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
|
-
}
|
|
442
|
+
membership = await Membership.join(coordinator, this.source, consumer.kind === "group_id" ? Number(consumer.id) : consumer.name, this.transport, pinnedSource);
|
|
511
443
|
}
|
|
512
444
|
catch (error) {
|
|
513
445
|
await coordinator.close();
|
|
514
446
|
throw error;
|
|
515
447
|
}
|
|
516
|
-
return
|
|
448
|
+
return FilteredReader.create({
|
|
517
449
|
transport: this.transport,
|
|
518
450
|
...(pinnedSource === undefined ? {} : { pinnedSource }),
|
|
519
451
|
filters: this.filters,
|
|
520
452
|
coordinator,
|
|
521
453
|
request,
|
|
522
|
-
inlineDigest: filter.kind === "inline" ? consumerFilterDigest(filter.filter) : undefined,
|
|
523
454
|
start: this.startAt,
|
|
524
455
|
idleIntervalMs: this.idleMs,
|
|
525
456
|
maxUnackedPages: this.maxUnacked,
|
|
526
|
-
guard,
|
|
457
|
+
guard: undefined,
|
|
527
458
|
guardEnabled: this.guarded,
|
|
528
459
|
membership,
|
|
529
|
-
|
|
530
|
-
partitionIds
|
|
460
|
+
selectedPartitions: new Set(this.selectedPartitions),
|
|
461
|
+
partitionIds: membership.partitions.filter((partition) => this.selectedPartitions.size === 0 || this.selectedPartitions.has(partition))
|
|
531
462
|
});
|
|
532
463
|
}
|
|
533
464
|
}
|
|
534
465
|
/**
|
|
535
|
-
* Reads
|
|
466
|
+
* Reads the records a consumer group's policy selects, with their original
|
|
536
467
|
* offsets, and stores progress through fenced acknowledgments. Pages are read
|
|
537
468
|
* ahead of acknowledgments, and a page's safe offset is stored only after it
|
|
538
469
|
* and every earlier page of the partition are fully handled. Pages that
|
|
@@ -554,7 +485,6 @@ export class FilteredReader {
|
|
|
554
485
|
sequence = 0;
|
|
555
486
|
owner = {};
|
|
556
487
|
retiredOwners = new WeakSet();
|
|
557
|
-
listedAt;
|
|
558
488
|
closed = false;
|
|
559
489
|
deferredError;
|
|
560
490
|
pinnedSource;
|
|
@@ -565,11 +495,14 @@ export class FilteredReader {
|
|
|
565
495
|
this.routes = new Routes(settings.transport, settings.coordinator);
|
|
566
496
|
this.guard = settings.guard;
|
|
567
497
|
this.pinnedSource = settings.pinnedSource;
|
|
568
|
-
this.listedAt = settings.discovers ? performance.now() : undefined;
|
|
569
498
|
for (const partitionId of settings.partitionIds) {
|
|
570
499
|
this.progress.set(partitionId, new PartitionProgress(settings.start));
|
|
571
500
|
}
|
|
572
501
|
}
|
|
502
|
+
/** @internal */
|
|
503
|
+
static create(settings) {
|
|
504
|
+
return new FilteredReader(settings);
|
|
505
|
+
}
|
|
573
506
|
/** The next page with at least one match. Waits while nothing is new, up to `timeoutMs` when given. */
|
|
574
507
|
async nextPage(options = {}) {
|
|
575
508
|
const deadline = options.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs;
|
|
@@ -641,6 +574,43 @@ export class FilteredReader {
|
|
|
641
574
|
this.partitionProgress(page.partitionId).completePage(sequence);
|
|
642
575
|
await this.flush(page.partitionId);
|
|
643
576
|
}
|
|
577
|
+
/**
|
|
578
|
+
* Mark this partition's records through `record` handled without storing
|
|
579
|
+
* yet. The normal consumer delivers in order, so every delivery completes
|
|
580
|
+
* the prefix through it, and it stores that prefix on its own cadence
|
|
581
|
+
* through `flushCompleted`.
|
|
582
|
+
*
|
|
583
|
+
* @internal
|
|
584
|
+
*/
|
|
585
|
+
handled(record) {
|
|
586
|
+
const tag = record[READER_TAG];
|
|
587
|
+
if (tag?.owner !== this.owner)
|
|
588
|
+
return;
|
|
589
|
+
this.progress.get(record.partitionId)?.completeThrough(tag.sequence, record.offset);
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Store every completed prefix now.
|
|
593
|
+
*
|
|
594
|
+
* @internal
|
|
595
|
+
*/
|
|
596
|
+
async flushCompleted() {
|
|
597
|
+
if (this.settings.request.readMode !== "primary")
|
|
598
|
+
return;
|
|
599
|
+
let failure;
|
|
600
|
+
for (const partitionId of [...this.progress.keys()]) {
|
|
601
|
+
try {
|
|
602
|
+
await this.flush(partitionId);
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
failure ??= error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (failure !== undefined) {
|
|
609
|
+
throw failure instanceof Error
|
|
610
|
+
? failure
|
|
611
|
+
: new TransportError("storing group progress failed", true, { cause: failure });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
644
614
|
/** The partitions this reader reads now. */
|
|
645
615
|
partitions() {
|
|
646
616
|
return [...this.progress.keys()].filter((partitionId) => !this.revoked.has(partitionId));
|
|
@@ -725,9 +695,6 @@ export class FilteredReader {
|
|
|
725
695
|
}
|
|
726
696
|
if (this.settings.membership?.isDue() === true)
|
|
727
697
|
await this.refreshMembership();
|
|
728
|
-
if (this.listedAt !== undefined && performance.now() - this.listedAt >= ASSIGNMENT_REFRESH_MS) {
|
|
729
|
-
await this.refreshPartitions();
|
|
730
|
-
}
|
|
731
698
|
const readable = this.partitions();
|
|
732
699
|
if (readable.length === 0)
|
|
733
700
|
return { kind: "idle" };
|
|
@@ -796,6 +763,13 @@ export class FilteredReader {
|
|
|
796
763
|
return { kind: "empty", more: false };
|
|
797
764
|
}
|
|
798
765
|
this.restartAfter(partitionId, error);
|
|
766
|
+
// The group's policy changed under this read. The partition restarted
|
|
767
|
+
// from its stored offset and is read again at once under the policy the
|
|
768
|
+
// group holds now. Only an explicit acknowledgment of a record read
|
|
769
|
+
// under the old policy reports the change.
|
|
770
|
+
if (error instanceof FilterExecutionError && error.reason === "conflict") {
|
|
771
|
+
return { kind: "empty", more: true };
|
|
772
|
+
}
|
|
799
773
|
throw error;
|
|
800
774
|
}
|
|
801
775
|
this.requireOpen();
|
|
@@ -811,7 +785,7 @@ export class FilteredReader {
|
|
|
811
785
|
if (recordHead.getUint32(12, true) !== page.matched || page.matched > request.count)
|
|
812
786
|
throw new ProtocolError("filtered record body has invalid counts");
|
|
813
787
|
const messages = decodePolledBody(page.records);
|
|
814
|
-
validatePage(request,
|
|
788
|
+
validatePage(request, page, messages);
|
|
815
789
|
if (request.consumer.kind === "group_id") {
|
|
816
790
|
const pinned = this.pinnedSource;
|
|
817
791
|
const current = page.generation;
|
|
@@ -828,16 +802,19 @@ export class FilteredReader {
|
|
|
828
802
|
this.pinnedSource ??= current;
|
|
829
803
|
}
|
|
830
804
|
const unevaluated = new Set(page.unevaluated);
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
try {
|
|
836
|
-
checkGuard(this.guard, page, messages, unevaluated);
|
|
805
|
+
// An unfiltered page ran no filter, so there is nothing to check.
|
|
806
|
+
if (page.policy.mode === "filtered") {
|
|
807
|
+
if (this.settings.guardEnabled && this.guard === undefined) {
|
|
808
|
+
this.guard = await compileGuard(this.settings.transport, await boundDefinition(this.settings.filters, request.source, page));
|
|
837
809
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
810
|
+
if (this.guard !== undefined) {
|
|
811
|
+
try {
|
|
812
|
+
checkGuard(this.guard, page, messages, unevaluated);
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
this.restartAfter(partitionId, error);
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
841
818
|
}
|
|
842
819
|
}
|
|
843
820
|
this.sequence += 1;
|
|
@@ -869,7 +846,7 @@ export class FilteredReader {
|
|
|
869
846
|
partitionId,
|
|
870
847
|
offset: message.offset,
|
|
871
848
|
frontier: page.frontier,
|
|
872
|
-
evaluated: !unevaluated.has(message.offset),
|
|
849
|
+
evaluated: page.policy.mode === "filtered" && !unevaluated.has(message.offset),
|
|
873
850
|
payload: message.payload,
|
|
874
851
|
headers: message.headers,
|
|
875
852
|
headersMalformed: message.headersMalformed !== undefined,
|
|
@@ -907,8 +884,7 @@ export class FilteredReader {
|
|
|
907
884
|
}
|
|
908
885
|
if (error.reason === "source_changed")
|
|
909
886
|
this.settings.membership?.expire();
|
|
910
|
-
|
|
911
|
-
if (request.filter.kind === "bound")
|
|
887
|
+
if (this.settings.guardEnabled)
|
|
912
888
|
this.guard = undefined;
|
|
913
889
|
}
|
|
914
890
|
// Store the completed prefix of one partition. A lost reply keeps the target
|
|
@@ -927,8 +903,10 @@ export class FilteredReader {
|
|
|
927
903
|
partitionId,
|
|
928
904
|
consumer: request.consumer,
|
|
929
905
|
generation: target.generation,
|
|
930
|
-
digest: target.digest,
|
|
931
|
-
offset: target.offset
|
|
906
|
+
...(target.digest === undefined ? {} : { digest: target.digest }),
|
|
907
|
+
offset: target.offset,
|
|
908
|
+
mode: target.mode,
|
|
909
|
+
policyGeneration: target.policyGeneration
|
|
932
910
|
};
|
|
933
911
|
const encoded = encodeFilteredAck(ack);
|
|
934
912
|
for (let rerouted = false;; rerouted = true) {
|
|
@@ -949,6 +927,7 @@ export class FilteredReader {
|
|
|
949
927
|
return;
|
|
950
928
|
}
|
|
951
929
|
catch (error) {
|
|
930
|
+
this.restartAfter(partitionId, error);
|
|
952
931
|
if (rerouted || !isRouteLost(error))
|
|
953
932
|
throw error;
|
|
954
933
|
}
|
|
@@ -988,11 +967,13 @@ export class FilteredReader {
|
|
|
988
967
|
if (membership === undefined || !(await membership.sync()))
|
|
989
968
|
return;
|
|
990
969
|
this.requireOpen();
|
|
991
|
-
const
|
|
970
|
+
const selection = this.settings.selectedPartitions;
|
|
971
|
+
const assigned = new Set(membership.partitions.filter((partition) => selection === undefined || selection.size === 0 || selection.has(partition)));
|
|
992
972
|
const rejoined = membership.takeRejoined();
|
|
993
973
|
const stale = rejoined ? this.routes.detach() : [];
|
|
994
974
|
if (rejoined) {
|
|
995
|
-
|
|
975
|
+
// A rejoin can land in a recreated group with another binding.
|
|
976
|
+
if (this.settings.guardEnabled)
|
|
996
977
|
this.guard = undefined;
|
|
997
978
|
this.retiredOwners.add(this.owner);
|
|
998
979
|
this.owner = {};
|
|
@@ -1022,17 +1003,6 @@ export class FilteredReader {
|
|
|
1022
1003
|
for (const partitionId of left)
|
|
1023
1004
|
await this.routes.release(partitionId);
|
|
1024
1005
|
}
|
|
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
1006
|
// Forget a revoked partition once nothing of it is in flight.
|
|
1037
1007
|
retire(partitionId) {
|
|
1038
1008
|
const progress = this.progress.get(partitionId);
|
|
@@ -1162,7 +1132,9 @@ class PartitionProgress {
|
|
|
1162
1132
|
: {
|
|
1163
1133
|
offset: page.safeAckOffset,
|
|
1164
1134
|
generation: page.generation,
|
|
1165
|
-
digest: page.policy.digest,
|
|
1135
|
+
...(page.policy.digest === undefined ? {} : { digest: page.policy.digest }),
|
|
1136
|
+
mode: page.policy.mode,
|
|
1137
|
+
policyGeneration: page.policy.policyGeneration,
|
|
1166
1138
|
...(page.policy.groupId !== undefined ? { groupId: page.policy.groupId } : {})
|
|
1167
1139
|
};
|
|
1168
1140
|
const newest = this.pages.at(-1);
|
|
@@ -1374,7 +1346,7 @@ async function attach(open, route) {
|
|
|
1374
1346
|
return connection;
|
|
1375
1347
|
}
|
|
1376
1348
|
// The shared transport as a coordinator, for a reader that has no connection
|
|
1377
|
-
// of its own: an injected client
|
|
1349
|
+
// of its own: an injected client.
|
|
1378
1350
|
function sharedCoordinator(transport) {
|
|
1379
1351
|
return {
|
|
1380
1352
|
send: (code, payload) => transport.sendManaged(code, payload),
|
|
@@ -1387,10 +1359,6 @@ function sharedCoordinator(transport) {
|
|
|
1387
1359
|
close: () => Promise.resolve()
|
|
1388
1360
|
};
|
|
1389
1361
|
}
|
|
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
1362
|
/** This reader's membership of a consumer group, held by its coordinator connection. */
|
|
1395
1363
|
class Membership {
|
|
1396
1364
|
coordinator;
|
|
@@ -1507,6 +1475,37 @@ async function sourceIncarnation(transport, source) {
|
|
|
1507
1475
|
topicCreatedAtMicros: topic.created
|
|
1508
1476
|
};
|
|
1509
1477
|
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Read a consumer group's native id and name, and the stream and topic
|
|
1480
|
+
* incarnations that scope it. Throws with reason `not_found` when the group
|
|
1481
|
+
* does not exist.
|
|
1482
|
+
*/
|
|
1483
|
+
export async function nativeGroup(transport, source, group) {
|
|
1484
|
+
const incarnation = await sourceIncarnation(transport, source);
|
|
1485
|
+
const reply = await transport.sendManaged(GET_CONSUMER_GROUP_CODE, concat([identifier(source.stream), identifier(source.topic), identifier(group)]));
|
|
1486
|
+
// The reply begins with id:u32, partitions:u32, members:u32, then the name
|
|
1487
|
+
// as one length byte and its bytes. A missing group answers an empty body.
|
|
1488
|
+
if (reply.byteLength === 0)
|
|
1489
|
+
throw filterError({
|
|
1490
|
+
code: { kind: "known", name: "NotFound" },
|
|
1491
|
+
reason: "not_found",
|
|
1492
|
+
message: `consumer group ${String(group)} does not exist`
|
|
1493
|
+
});
|
|
1494
|
+
if (reply.byteLength < 13)
|
|
1495
|
+
throw new ProtocolError("consumer group metadata is truncated");
|
|
1496
|
+
const view = new DataView(reply.buffer, reply.byteOffset, reply.byteLength);
|
|
1497
|
+
const id = view.getUint32(0, true);
|
|
1498
|
+
if (typeof group === "number" && id !== group)
|
|
1499
|
+
throw new ProtocolError("consumer group metadata identifies another group");
|
|
1500
|
+
const length = view.getUint8(12);
|
|
1501
|
+
if (reply.byteLength < 13 + length)
|
|
1502
|
+
throw new ProtocolError("consumer group metadata is truncated");
|
|
1503
|
+
return {
|
|
1504
|
+
id,
|
|
1505
|
+
name: new TextDecoder().decode(reply.subarray(13, 13 + length)),
|
|
1506
|
+
identity: { ...incarnation, groupId: BigInt(id) }
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1510
1509
|
function identifier(name) {
|
|
1511
1510
|
if (typeof name === "number") {
|
|
1512
1511
|
const encoded = new Uint8Array(6);
|
|
@@ -1614,7 +1613,7 @@ function sameBytes(left, right) {
|
|
|
1614
1613
|
// Re-evaluate every returned record with the shared evaluator before the
|
|
1615
1614
|
// caller sees it.
|
|
1616
1615
|
function checkGuard(guard, page, messages, unevaluated) {
|
|
1617
|
-
if (!sameBytes(page.policy.digest, guard.digest)) {
|
|
1616
|
+
if (page.policy.digest === undefined || !sameBytes(page.policy.digest, guard.digest)) {
|
|
1618
1617
|
throw filterError({
|
|
1619
1618
|
code: { kind: "known", name: "Conflict" },
|
|
1620
1619
|
reason: "conflict",
|
|
@@ -1747,7 +1746,7 @@ async function boundDefinition(filters, requested, page) {
|
|
|
1747
1746
|
identity.topicId === source.topicId &&
|
|
1748
1747
|
identity.topicCreatedAtMicros === source.topicCreatedAtMicros);
|
|
1749
1748
|
if (binding !== undefined) {
|
|
1750
|
-
if (!sameBytes(binding.digest, page.policy.digest))
|
|
1749
|
+
if (page.policy.digest === undefined || !sameBytes(binding.digest, page.policy.digest))
|
|
1751
1750
|
throw new ProtocolError("served policy differs from the group's catalog binding");
|
|
1752
1751
|
return definition(filters, {
|
|
1753
1752
|
kind: "revision",
|
|
@@ -1766,8 +1765,8 @@ async function boundDefinition(filters, requested, page) {
|
|
|
1766
1765
|
async function definition(filters, filter) {
|
|
1767
1766
|
if (filter.kind === "inline")
|
|
1768
1767
|
return filter.filter;
|
|
1769
|
-
if (filter.kind === "bound")
|
|
1770
|
-
throw new ConfigError("resolve a
|
|
1768
|
+
if (filter.kind === "bound" || filter.kind === "group")
|
|
1769
|
+
throw new ConfigError("resolve a group guard from the served page");
|
|
1771
1770
|
const { filterId, revision } = filter;
|
|
1772
1771
|
for (let page = 0;; page += 1) {
|
|
1773
1772
|
const revisions = await filters.revisions(filterId, { page, pageSize: MAX_FILTER_CATALOG_PAGE });
|
|
@@ -1831,7 +1830,7 @@ function sameGeneration(left, right) {
|
|
|
1831
1830
|
left.partitionCreatedRevision === right.partitionCreatedRevision &&
|
|
1832
1831
|
left.purgeGeneration === right.purgeGeneration);
|
|
1833
1832
|
}
|
|
1834
|
-
function validatePage(request,
|
|
1833
|
+
function validatePage(request, page, messages) {
|
|
1835
1834
|
const invalid = () => {
|
|
1836
1835
|
throw new ProtocolError("filtered page does not match its request or scan boundary");
|
|
1837
1836
|
};
|
|
@@ -1844,14 +1843,20 @@ function validatePage(request, inlineDigest, page, messages) {
|
|
|
1844
1843
|
page.matched !== messages.length ||
|
|
1845
1844
|
page.matched > request.count ||
|
|
1846
1845
|
page.matched > page.examined ||
|
|
1847
|
-
|
|
1846
|
+
(request.maxExamined !== undefined && page.examined > request.maxExamined) ||
|
|
1847
|
+
(page.policy.mode === "filtered") !== (page.policy.digest !== undefined) ||
|
|
1848
|
+
(page.policy.digest !== undefined && page.policy.digest.length !== 32) ||
|
|
1849
|
+
(page.policy.mode === "unfiltered" && page.policy.groupId === undefined) ||
|
|
1850
|
+
(page.policy.mode === "unfiltered" && page.matched !== page.examined) ||
|
|
1851
|
+
(page.policy.mode === "unfiltered" &&
|
|
1852
|
+
(page.policy.filterId !== undefined || page.policy.revision !== undefined)) ||
|
|
1853
|
+
// A strict group read runs a binding and never accepts an unfiltered page.
|
|
1854
|
+
(page.policy.mode === "unfiltered" && request.filter.kind !== "group") ||
|
|
1848
1855
|
(request.consumer.kind !== "consumer") !== (page.policy.groupId !== undefined))
|
|
1849
1856
|
invalid();
|
|
1850
1857
|
const body = new DataView(page.records.buffer, page.records.byteOffset, page.records.byteLength);
|
|
1851
1858
|
if (body.getUint32(0, true) !== page.partitionId || body.getBigUint64(4, true) !== page.frontier)
|
|
1852
1859
|
invalid();
|
|
1853
|
-
if (inlineDigest !== undefined && !sameBytes(inlineDigest, page.policy.digest))
|
|
1854
|
-
invalid();
|
|
1855
1860
|
if (request.filter.kind === "revision" &&
|
|
1856
1861
|
(page.policy.filterId !== request.filter.filterId ||
|
|
1857
1862
|
page.policy.revision !== request.filter.revision))
|
|
@@ -1861,7 +1866,12 @@ function validatePage(request, inlineDigest, page, messages) {
|
|
|
1861
1866
|
const cursor = request.start.continuation;
|
|
1862
1867
|
if (!sameGeneration(cursor.generation, page.generation) ||
|
|
1863
1868
|
cursor.groupId !== page.policy.groupId ||
|
|
1864
|
-
|
|
1869
|
+
cursor.mode !== page.policy.mode ||
|
|
1870
|
+
cursor.policyGeneration !== page.policy.policyGeneration ||
|
|
1871
|
+
(cursor.digest === undefined) !== (page.policy.digest === undefined) ||
|
|
1872
|
+
(cursor.digest !== undefined &&
|
|
1873
|
+
page.policy.digest !== undefined &&
|
|
1874
|
+
!sameBytes(cursor.digest, page.policy.digest)) ||
|
|
1865
1875
|
cursor.readMode !== request.readMode)
|
|
1866
1876
|
invalid();
|
|
1867
1877
|
start = cursor.nextScanOffset;
|