apache-iggy 0.10.1-edge.2 → 0.10.1-edge.3
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 +65 -2
- package/dist/client/client.socket.js +2 -2
- package/dist/client/client.socket.test.js +30 -0
- package/dist/e2e/metadata-changes.e2e.d.ts +2 -0
- package/dist/e2e/metadata-changes.e2e.js +26 -0
- package/dist/e2e/metadata-changes.suite.d.ts +17 -0
- package/dist/e2e/metadata-changes.suite.js +328 -0
- package/dist/e2e/resource-options.e2e.js +4 -0
- package/dist/e2e/tls.system.e2e.js +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/wire/command-set.d.ts +1 -0
- package/dist/wire/command-set.js +3 -1
- package/dist/wire/command.code.d.ts +1 -0
- package/dist/wire/command.code.js +1 -0
- package/dist/wire/system/get-metadata-changes.command.d.ts +97 -0
- package/dist/wire/system/get-metadata-changes.command.js +170 -0
- package/dist/wire/system/get-metadata-changes.command.test.d.ts +2 -0
- package/dist/wire/system/get-metadata-changes.command.test.js +467 -0
- package/dist/wire/system/index.d.ts +2 -0
- package/dist/wire/system/index.js +2 -0
- package/dist/wire/system/metadata-changes.utils.d.ts +482 -0
- package/dist/wire/system/metadata-changes.utils.js +816 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ note: previous works on node.js http client has been moved to [iggy-node-http-cl
|
|
|
22
22
|
npm i --save apache-iggy@edge
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Use matching SDK and server builds from the coordinated VSR release. Older builds are incompatible. This release requires fresh server storage (`IGGY-
|
|
25
|
+
Use matching SDK and server builds from the coordinated VSR release. Older builds are incompatible. This release requires fresh server storage (`IGGY-METADATA-CHANGE-LOG-1`). Existing incompatible data is refused unchanged. For a local server with the example credentials, follow the [example setup](../../examples/node/README.md#running-examples).
|
|
26
26
|
|
|
27
27
|
## basic usage
|
|
28
28
|
|
|
@@ -362,6 +362,69 @@ await client.topic.update({
|
|
|
362
362
|
|
|
363
363
|
Reads return every setting in `options` (keys the client set) and `derivedOptions` (keys the server filled). `u64` values stay `bigint`, so values above 2^53 keep their precision. `system.describeOptions({ scope: OptionsScope.Stream })` lists the keys a server accepts per scope, with `updatable`, `resettable`, `secret`, `readOnly` and `critical`.
|
|
364
364
|
|
|
365
|
+
### metadata changes
|
|
366
|
+
|
|
367
|
+
`system.getMetadataChanges` reads the node's change log: one record per public result of a committed metadata operation, such as a stream created, a topic renamed, a user's permissions changed or a session closed. A mirror keeps a cursor, asks for the records after it, and stores the returned `next` cursor. The call needs root or the global `manage_servers` permission.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
371
|
+
import {
|
|
372
|
+
Client, MetadataChangeStart, MetadataChangeStatus,
|
|
373
|
+
type MetadataChangeGroup, type MetadataCurrentPageToken,
|
|
374
|
+
} from "apache-iggy";
|
|
375
|
+
|
|
376
|
+
const follow = async (client: Client, groups: MetadataChangeGroup[]) => {
|
|
377
|
+
let start: MetadataChangeStart = MetadataChangeStart.Earliest;
|
|
378
|
+
for (;;) {
|
|
379
|
+
const page = await client.system.getMetadataChanges({ start, groups });
|
|
380
|
+
switch (page.status) {
|
|
381
|
+
case MetadataChangeStatus.Ok:
|
|
382
|
+
for (const record of page.records)
|
|
383
|
+
console.log(record.op, record.kind, record.actorUserId, record.body);
|
|
384
|
+
if (page.next) start = MetadataChangeStart.After(page.next);
|
|
385
|
+
if (!page.hasMore) await sleep(1000);
|
|
386
|
+
break;
|
|
387
|
+
case MetadataChangeStatus.Behind:
|
|
388
|
+
await sleep(100);
|
|
389
|
+
break;
|
|
390
|
+
default:
|
|
391
|
+
start = await rebuild(client, groups);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/** Pages the current state, then returns the position to follow from. */
|
|
397
|
+
const rebuild = async (client: Client, groups: MetadataChangeGroup[]) => {
|
|
398
|
+
let pageToken: MetadataCurrentPageToken | undefined;
|
|
399
|
+
for (;;) {
|
|
400
|
+
const page = await client.system.getMetadataChanges({
|
|
401
|
+
start: MetadataChangeStart.Current(pageToken), groups,
|
|
402
|
+
});
|
|
403
|
+
if (page.status === MetadataChangeStatus.Behind) {
|
|
404
|
+
await sleep(100);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (page.status !== MetadataChangeStatus.Ok) {
|
|
408
|
+
pageToken = undefined;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
for (const item of page.items)
|
|
412
|
+
console.log(item.group, item.body);
|
|
413
|
+
if (page.currentPageToken) pageToken = page.currentPageToken;
|
|
414
|
+
else if (page.next) return MetadataChangeStart.After(page.next);
|
|
415
|
+
else pageToken = undefined;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
`expired` means the log no longer holds the history after the cursor, and `reset` means the cursor belongs to another history, for example after the data directory was rebuilt. Either way the mirror rebuilds from current state pages and then follows the changes after the anchor they return.
|
|
421
|
+
|
|
422
|
+
Current pages need the parent groups of every selected group, for example `topics` with `streams`. Keep a rebuilt state private until a change page answers `ok` with `hasMore` false and a `headOp` at or above the last current page's.
|
|
423
|
+
|
|
424
|
+
Records narrow by `kind` and items by `group`, so `record.kind === "stream_created"` types `record.body` as a stream. A group or kind newer than this build arrives as `"unknown"` with the raw body and the wire codes in `groupCode` and `kindCode`, while a record of a newer kind keeps its known `group`, so a mirror that cannot apply it can stop without advancing its cursor. Ops, timestamps and 128-bit ids are `bigint`.
|
|
425
|
+
|
|
426
|
+
A query that breaks the request rules, such as a limit outside 1 to 1000 or a time bound with a latest or current start, is refused with error 3 before anything is sent. `MetadataChangeCursor` and `MetadataCurrentPageToken` print and parse as the `mc1_` and `cs1_` text forms the HTTP API and the CLI use, and `JSON.stringify` writes them as that text.
|
|
427
|
+
|
|
365
428
|
## use sources
|
|
366
429
|
|
|
367
430
|
Cluster polling uses command 15 (`BindSession`), route discovery 105 and poll
|
|
@@ -398,7 +461,7 @@ npm run test:unit
|
|
|
398
461
|
|
|
399
462
|
e2e test expect an iggy-server at tcp://127.0.0.1:8090
|
|
400
463
|
|
|
401
|
-
The resource options tests also expect the server to run with encryption enabled and a config key (`IGGY_ENCRYPTION_ENABLED=true` and `IGGY_ENCRYPTION_KEY` set to the base64 of 32 bytes), so a stream created without a key reports a server-generated one.
|
|
464
|
+
The resource options tests also expect the server to run with encryption enabled and a config key (`IGGY_ENCRYPTION_ENABLED=true` and `IGGY_ENCRYPTION_KEY` set to the base64 of 32 bytes), so a stream created without a key reports a server-generated one. The keyed metadata changes case registered from the same file needs the config key too.
|
|
402
465
|
|
|
403
466
|
```bash
|
|
404
467
|
npm run test:e2e
|
|
@@ -67,8 +67,8 @@ const TOPIC_DISCOVERY_RESETS = new Set([
|
|
|
67
67
|
]);
|
|
68
68
|
const READ_COMMANDS = new Set([
|
|
69
69
|
COMMAND_CODE.Ping, COMMAND_CODE.GetStats, COMMAND_CODE.GetClusterMetadata,
|
|
70
|
-
COMMAND_CODE.DescribeOptions, COMMAND_CODE.
|
|
71
|
-
COMMAND_CODE.GetClients, COMMAND_CODE.GetUser, COMMAND_CODE.GetUsers,
|
|
70
|
+
COMMAND_CODE.DescribeOptions, COMMAND_CODE.GetMetadataChanges, COMMAND_CODE.GetMe,
|
|
71
|
+
COMMAND_CODE.GetClient, COMMAND_CODE.GetClients, COMMAND_CODE.GetUser, COMMAND_CODE.GetUsers,
|
|
72
72
|
COMMAND_CODE.GetAccessTokens, COMMAND_CODE.GetPartitionRoute, COMMAND_CODE.GetOffset,
|
|
73
73
|
COMMAND_CODE.GetStream, COMMAND_CODE.GetStreams, COMMAND_CODE.GetTopic,
|
|
74
74
|
COMMAND_CODE.GetTopics, COMMAND_CODE.GetGroup, COMMAND_CODE.GetGroups, COMMAND_CODE.SyncGroup
|
|
@@ -29,6 +29,7 @@ import { UPDATE_USER } from '../wire/user/update-user.command.js';
|
|
|
29
29
|
import { UPDATE_TOPIC } from '../wire/topic/update-topic.command.js';
|
|
30
30
|
import { UPDATE_STREAM } from '../wire/stream/update-stream.command.js';
|
|
31
31
|
import { OptionsScope } from '../wire/system/describe-options.command.js';
|
|
32
|
+
import { MetadataChangeStart, getMetadataChanges } from '../wire/system/get-metadata-changes.command.js';
|
|
32
33
|
import { CHANGE_PASSWORD } from '../wire/user/change-password.command.js';
|
|
33
34
|
import { POLL_MESSAGES, pollMessages } from '../wire/message/poll-messages.command.js';
|
|
34
35
|
import { SEND_MESSAGES, sendMessages } from '../wire/message/send-messages.command.js';
|
|
@@ -1251,6 +1252,35 @@ describe('VSR client socket', () => {
|
|
|
1251
1252
|
await server.close();
|
|
1252
1253
|
}
|
|
1253
1254
|
});
|
|
1255
|
+
it('resends a metadata changes read after an uncommitted response', async () => {
|
|
1256
|
+
// A behind answer before op 1: status 1, phase 0, no log id, zero ops, no cursor.
|
|
1257
|
+
const behind = Buffer.concat([Buffer.from([1, 0, 0]), Buffer.alloc(24), Buffer.alloc(2 + 4 + 4 + 2)]);
|
|
1258
|
+
let attempts = 0;
|
|
1259
|
+
const server = await startVsrServer((frame, socket) => {
|
|
1260
|
+
const operation = frame.readUInt8(REQUEST_OFFSET.operation);
|
|
1261
|
+
if (operation !== Operation.NonReplicated ||
|
|
1262
|
+
frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.GetMetadataChanges)
|
|
1263
|
+
return singleNodeHandler(server.port)(frame, socket);
|
|
1264
|
+
attempts += 1;
|
|
1265
|
+
socket.write(attempts === 1
|
|
1266
|
+
? replyFrame(frame, operation, Buffer.alloc(0), 57)
|
|
1267
|
+
: replyFrame(frame, operation, behind));
|
|
1268
|
+
});
|
|
1269
|
+
const client = new CommandResponseStream(vsrConfig(server.port));
|
|
1270
|
+
try {
|
|
1271
|
+
const changes = await getMetadataChanges(async () => client)({ start: MetadataChangeStart.Latest });
|
|
1272
|
+
assert.equal(attempts, 2);
|
|
1273
|
+
assert.equal(changes.status, 'behind');
|
|
1274
|
+
assert.equal(changes.next, null);
|
|
1275
|
+
const sent = server.frames.filter((frame) => frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.GetMetadataChanges);
|
|
1276
|
+
assert.equal(sent.length, 2);
|
|
1277
|
+
assert.deepEqual(sent[0], sent[1]);
|
|
1278
|
+
}
|
|
1279
|
+
finally {
|
|
1280
|
+
client.destroy();
|
|
1281
|
+
await server.close();
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1254
1284
|
it('routes eviction out of band without desynchronizing replies', async () => {
|
|
1255
1285
|
const server = await startVsrServer((frame, socket) => {
|
|
1256
1286
|
if (frame.readUInt32LE(REQUEST_OFFSET.reserved) === 60_003) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Licensed to the Apache Software Foundation (ASF) under one
|
|
2
|
+
// or more contributor license agreements. See the NOTICE file
|
|
3
|
+
// distributed with this work for additional information
|
|
4
|
+
// regarding copyright ownership. The ASF licenses this file
|
|
5
|
+
// to you under the Apache License, Version 2.0 (the
|
|
6
|
+
// "License"); you may not use this file except in compliance
|
|
7
|
+
// with the License. You may obtain a copy of the License at
|
|
8
|
+
//
|
|
9
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
//
|
|
11
|
+
// Unless required by applicable law or agreed to in writing,
|
|
12
|
+
// software distributed under the License is distributed on an
|
|
13
|
+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
// KIND, either express or implied. See the License for the
|
|
15
|
+
// specific language governing permissions and limitations
|
|
16
|
+
// under the License.
|
|
17
|
+
// The keyed stream case needs a server key, so resource-options.e2e.ts and
|
|
18
|
+
// tls.system.e2e.ts register it, and the shipped-default lane skips both.
|
|
19
|
+
import { Client } from '../client/client.js';
|
|
20
|
+
import { getIggyAddress } from '../tcp.sm.utils.js';
|
|
21
|
+
import { describeMetadataChanges } from './metadata-changes.suite.js';
|
|
22
|
+
const [host, port] = getIggyAddress();
|
|
23
|
+
describeMetadataChanges('e2e -> metadata changes', 'e2e-mc', (credentials) => new Client({
|
|
24
|
+
transport: 'TCP', options: { host, port }, credentials
|
|
25
|
+
}));
|
|
26
|
+
//# sourceMappingURL=metadata-changes.e2e.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Client } from '../client/client.js';
|
|
2
|
+
type Credentials = {
|
|
3
|
+
username: string;
|
|
4
|
+
password: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Registers the metadata changes cases for one transport. `connect` builds a
|
|
8
|
+
* client of that transport signed in with the given credentials.
|
|
9
|
+
*/
|
|
10
|
+
export declare const describeMetadataChanges: (label: string, prefix: string, connect: (credentials: Credentials) => Client, skip?: string | false) => Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Registers the keyed stream case, which needs a server with a config key:
|
|
13
|
+
* a client key is refused without one.
|
|
14
|
+
*/
|
|
15
|
+
export declare const describeKeyedMetadataChanges: (label: string, prefix: string, connect: (credentials: Credentials) => Client, skip?: string | false) => Promise<void>;
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=metadata-changes.suite.d.ts.map
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Licensed to the Apache Software Foundation (ASF) under one
|
|
2
|
+
// or more contributor license agreements. See the NOTICE file
|
|
3
|
+
// distributed with this work for additional information
|
|
4
|
+
// regarding copyright ownership. The ASF licenses this file
|
|
5
|
+
// to you under the Apache License, Version 2.0 (the
|
|
6
|
+
// "License"); you may not use this file except in compliance
|
|
7
|
+
// with the License. You may obtain a copy of the License at
|
|
8
|
+
//
|
|
9
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
//
|
|
11
|
+
// Unless required by applicable law or agreed to in writing,
|
|
12
|
+
// software distributed under the License is distributed on an
|
|
13
|
+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
// KIND, either express or implied. See the License for the
|
|
15
|
+
// specific language governing permissions and limitations
|
|
16
|
+
// under the License.
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
19
|
+
import { after, describe, it } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { ResponseError } from '../wire/error.utils.js';
|
|
22
|
+
import { EncryptionKeySource, MAX_METADATA_CHANGES_LIMIT, MetadataChangeCursor, MetadataChangeKind, MetadataChangeStart, MetadataChangeStatus, StreamEncryptionKey, UserStatus, encryptionKeyFingerprint } from '../wire/index.js';
|
|
23
|
+
const NEVER_RETURNED = ['encryption_key', 'encryption_key_wrapped', 'critical_keys'];
|
|
24
|
+
const FINGERPRINT = /^[0-9a-f]{16}$/;
|
|
25
|
+
const INVALID_COMMAND = 3;
|
|
26
|
+
const UNAUTHORIZED = 41;
|
|
27
|
+
// A two phase delete commits its record after the call returns.
|
|
28
|
+
const FOLLOW_BUDGET_MS = 15_000;
|
|
29
|
+
const FOLLOW_INTERVAL_MS = 100;
|
|
30
|
+
const latestCursor = async (c, groups) => {
|
|
31
|
+
const changes = await c.system.getMetadataChanges({ start: MetadataChangeStart.Latest, groups });
|
|
32
|
+
assert.equal(changes.status, MetadataChangeStatus.Ok);
|
|
33
|
+
assert.deepEqual(changes.records, []);
|
|
34
|
+
assert.ok(changes.next);
|
|
35
|
+
assert.equal(changes.next.logId, changes.logId);
|
|
36
|
+
return changes.next;
|
|
37
|
+
};
|
|
38
|
+
/** The stream a stream, topic or partitions record names, null for any other record. */
|
|
39
|
+
const recordStream = (record) => {
|
|
40
|
+
switch (record.kind) {
|
|
41
|
+
case 'stream_created':
|
|
42
|
+
case 'stream_updated':
|
|
43
|
+
case 'stream_deleted':
|
|
44
|
+
case 'topic_created':
|
|
45
|
+
case 'topic_updated':
|
|
46
|
+
case 'topic_deleted':
|
|
47
|
+
case 'partitions_changed':
|
|
48
|
+
return { streamId: record.body.streamId, streamCreatedOp: record.body.streamCreatedOp };
|
|
49
|
+
default:
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Follows the change log after `cursor` until `want` records name the stream
|
|
55
|
+
* the first record `introduces` accepts. Other e2e files run concurrently and
|
|
56
|
+
* stream ids are reused, so the stream key identifies the stream, not its id.
|
|
57
|
+
*/
|
|
58
|
+
const followStream = async (c, cursor, groups, introduces, want) => {
|
|
59
|
+
const records = [];
|
|
60
|
+
let identity;
|
|
61
|
+
const deadline = Date.now() + FOLLOW_BUDGET_MS;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const changes = await c.system.getMetadataChanges({
|
|
64
|
+
start: MetadataChangeStart.After(cursor), groups, limit: MAX_METADATA_CHANGES_LIMIT
|
|
65
|
+
});
|
|
66
|
+
assert.equal(changes.status, MetadataChangeStatus.Ok);
|
|
67
|
+
assert.equal(changes.phase, 'changes');
|
|
68
|
+
assert.deepEqual(changes.items, []);
|
|
69
|
+
assert.equal(changes.currentPageToken, null);
|
|
70
|
+
assert.ok(changes.next);
|
|
71
|
+
cursor = changes.next;
|
|
72
|
+
for (const record of changes.records) {
|
|
73
|
+
assert.ok(groups.includes(record.group), `${record.group} was not requested`);
|
|
74
|
+
const stream = recordStream(record);
|
|
75
|
+
if (stream === null)
|
|
76
|
+
continue;
|
|
77
|
+
if (identity === undefined && introduces(record))
|
|
78
|
+
identity = stream;
|
|
79
|
+
if (identity !== undefined && stream.streamId === identity.streamId &&
|
|
80
|
+
stream.streamCreatedOp === identity.streamCreatedOp)
|
|
81
|
+
records.push(record);
|
|
82
|
+
}
|
|
83
|
+
if (changes.hasMore)
|
|
84
|
+
continue;
|
|
85
|
+
if (records.length >= want || Date.now() > deadline)
|
|
86
|
+
break;
|
|
87
|
+
await sleep(FOLLOW_INTERVAL_MS);
|
|
88
|
+
}
|
|
89
|
+
assert.equal(records.length, want);
|
|
90
|
+
return records;
|
|
91
|
+
};
|
|
92
|
+
/** Accepts the stream_created record of a stream by its id and name. */
|
|
93
|
+
const createdStream = (streamId, name) => (record) => record.kind === 'stream_created' && record.body.streamId === streamId && record.body.name === name;
|
|
94
|
+
/** Pages the streams state one item at a time, returning the names by id. */
|
|
95
|
+
const currentStreams = async (c) => {
|
|
96
|
+
const streams = new Map();
|
|
97
|
+
let pageToken;
|
|
98
|
+
for (;;) {
|
|
99
|
+
const changes = await c.system.getMetadataChanges({
|
|
100
|
+
start: MetadataChangeStart.Current(pageToken), groups: ['streams'], limit: 1
|
|
101
|
+
});
|
|
102
|
+
assert.equal(changes.status, MetadataChangeStatus.Ok);
|
|
103
|
+
assert.equal(changes.phase, 'current');
|
|
104
|
+
assert.deepEqual(changes.records, []);
|
|
105
|
+
assert.ok(changes.items.length <= 1);
|
|
106
|
+
for (const item of changes.items) {
|
|
107
|
+
assert.equal(item.group, 'streams');
|
|
108
|
+
if (item.group === 'streams')
|
|
109
|
+
streams.set(item.body.streamId, item.body.name);
|
|
110
|
+
}
|
|
111
|
+
if (changes.currentPageToken === null) {
|
|
112
|
+
assert.ok(changes.next, 'the last page returns the anchor');
|
|
113
|
+
return streams;
|
|
114
|
+
}
|
|
115
|
+
assert.equal(changes.hasMore, true);
|
|
116
|
+
pageToken = changes.currentPageToken;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const isRefusedLocally = (code) => (error) => error instanceof ResponseError && error.errorCode === code && !error.fromServer;
|
|
120
|
+
/**
|
|
121
|
+
* Registers the metadata changes cases for one transport. `connect` builds a
|
|
122
|
+
* client of that transport signed in with the given credentials.
|
|
123
|
+
*/
|
|
124
|
+
export const describeMetadataChanges = (label, prefix, connect, skip = false) => describe(label, { skip }, async () => {
|
|
125
|
+
const c = connect({ username: 'iggy', password: 'iggy' });
|
|
126
|
+
it('given latest cursor when creating renaming and deleting a stream should follow its records', async () => {
|
|
127
|
+
const cursor = await latestCursor(c, ['streams']);
|
|
128
|
+
const created = await c.stream.create({ name: `${prefix}-follow` });
|
|
129
|
+
assert.ok(await c.stream.update({ streamId: created.id, name: `${prefix}-follow-renamed` }));
|
|
130
|
+
assert.ok(await c.stream.delete({ streamId: created.id }));
|
|
131
|
+
const records = await followStream(c, cursor, ['streams'], createdStream(created.id, created.name), 3);
|
|
132
|
+
assert.deepEqual(records.map((record) => record.kind), [
|
|
133
|
+
MetadataChangeKind.StreamCreated, MetadataChangeKind.StreamUpdated, MetadataChangeKind.StreamDeleted
|
|
134
|
+
]);
|
|
135
|
+
for (const record of records) {
|
|
136
|
+
assert.equal(record.actorUserId, 0);
|
|
137
|
+
assert.equal(record.group, 'streams');
|
|
138
|
+
assert.ok(record.checksum > 0n);
|
|
139
|
+
assert.ok(record.timestamp > 0n);
|
|
140
|
+
}
|
|
141
|
+
const [createdRecord, updatedRecord, deletedRecord] = records;
|
|
142
|
+
assert.ok(createdRecord.kind === 'stream_created' && updatedRecord.kind === 'stream_updated' &&
|
|
143
|
+
deletedRecord.kind === 'stream_deleted');
|
|
144
|
+
assert.equal(createdRecord.body.name, `${prefix}-follow`);
|
|
145
|
+
assert.equal(updatedRecord.body.name, `${prefix}-follow-renamed`);
|
|
146
|
+
assert.equal(deletedRecord.body.name, `${prefix}-follow-renamed`);
|
|
147
|
+
assert.equal(createdRecord.body.streamCreatedOp, createdRecord.op);
|
|
148
|
+
assert.equal(createdRecord.body.options.unreadable, false);
|
|
149
|
+
assert.ok(createdRecord.op < updatedRecord.op && updatedRecord.op < deletedRecord.op);
|
|
150
|
+
});
|
|
151
|
+
it('given topics group when creating a stream and topic should return only the topic record', async () => {
|
|
152
|
+
const cursor = await latestCursor(c, ['topics']);
|
|
153
|
+
const stream = await c.stream.create({ name: `${prefix}-filter` });
|
|
154
|
+
try {
|
|
155
|
+
const topic = await c.topic.create({ streamId: stream.id, name: 'events', partitionCount: 2 });
|
|
156
|
+
const introduces = (record) => record.kind === 'topic_created' && record.body.streamId === stream.id && record.body.topicId === topic.id;
|
|
157
|
+
const records = await followStream(c, cursor, ['topics'], introduces, 1);
|
|
158
|
+
const [record] = records;
|
|
159
|
+
assert.equal(record.kind, MetadataChangeKind.TopicCreated);
|
|
160
|
+
assert.ok(record.kind === 'topic_created');
|
|
161
|
+
assert.equal(record.body.topicId, topic.id);
|
|
162
|
+
assert.equal(record.body.name, 'events');
|
|
163
|
+
assert.equal(record.body.partitionsCount, 2);
|
|
164
|
+
assert.equal(record.body.topicCreatedOp, record.op);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await c.stream.delete({ streamId: stream.id });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
it('given limit of one when following three streams should page one record at a time', async () => {
|
|
171
|
+
let cursor = await latestCursor(c, ['streams']);
|
|
172
|
+
const created = new Map();
|
|
173
|
+
try {
|
|
174
|
+
for (const index of [1, 2, 3]) {
|
|
175
|
+
const stream = await c.stream.create({ name: `${prefix}-page-${index}` });
|
|
176
|
+
created.set(stream.id, stream.name);
|
|
177
|
+
}
|
|
178
|
+
const seen = new Map();
|
|
179
|
+
const deadline = Date.now() + FOLLOW_BUDGET_MS;
|
|
180
|
+
while (seen.size < created.size) {
|
|
181
|
+
assert.ok(Date.now() < deadline, `paged ${[...seen.values()]} of ${[...created.values()]}`);
|
|
182
|
+
const changes = await c.system.getMetadataChanges({
|
|
183
|
+
start: MetadataChangeStart.After(cursor), groups: ['streams'], limit: 1
|
|
184
|
+
});
|
|
185
|
+
assert.equal(changes.status, MetadataChangeStatus.Ok);
|
|
186
|
+
assert.ok(changes.records.length <= 1);
|
|
187
|
+
assert.ok(changes.next);
|
|
188
|
+
for (const record of changes.records) {
|
|
189
|
+
if (record.kind === 'stream_created' && created.get(record.body.streamId) === record.body.name)
|
|
190
|
+
seen.set(record.body.streamId, record.body.name);
|
|
191
|
+
assert.equal(record.op, changes.next.op);
|
|
192
|
+
assert.equal(record.ordinal, changes.next.ordinal);
|
|
193
|
+
}
|
|
194
|
+
assert.ok(changes.next.op >= cursor.op);
|
|
195
|
+
cursor = changes.next;
|
|
196
|
+
if (!changes.hasMore && seen.size < created.size)
|
|
197
|
+
await sleep(FOLLOW_INTERVAL_MS);
|
|
198
|
+
}
|
|
199
|
+
assert.deepEqual(seen, created);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
for (const streamId of created.keys())
|
|
203
|
+
await c.stream.delete({ streamId });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
it('given streams when paging current state one item at a time should list the streams the list returns', async () => {
|
|
207
|
+
const created = [
|
|
208
|
+
await c.stream.create({ name: `${prefix}-current-1` }),
|
|
209
|
+
await c.stream.create({ name: `${prefix}-current-2` })
|
|
210
|
+
];
|
|
211
|
+
try {
|
|
212
|
+
const before = await c.stream.list();
|
|
213
|
+
const projected = await currentStreams(c);
|
|
214
|
+
const afterwards = await c.stream.list();
|
|
215
|
+
for (const stream of created)
|
|
216
|
+
assert.equal(projected.get(stream.id), stream.name);
|
|
217
|
+
// Other e2e files change streams meanwhile: a stream listed unchanged
|
|
218
|
+
// before and after the scan must be in it.
|
|
219
|
+
for (const stream of before)
|
|
220
|
+
if (afterwards.some((later) => later.id === stream.id && later.name === stream.name))
|
|
221
|
+
assert.equal(projected.get(stream.id), stream.name, `stream ${stream.id}`);
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
for (const stream of created)
|
|
225
|
+
await c.stream.delete({ streamId: stream.id });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
it('given queries breaking the request rules when reading should refuse before sending', async () => {
|
|
229
|
+
const created = [
|
|
230
|
+
await c.stream.create({ name: `${prefix}-token-1` }),
|
|
231
|
+
await c.stream.create({ name: `${prefix}-token-2` })
|
|
232
|
+
];
|
|
233
|
+
try {
|
|
234
|
+
const first = await c.system.getMetadataChanges({
|
|
235
|
+
start: MetadataChangeStart.Current(), groups: ['streams'], limit: 1
|
|
236
|
+
});
|
|
237
|
+
assert.ok(first.currentPageToken);
|
|
238
|
+
const refused = [
|
|
239
|
+
{ limit: 0 },
|
|
240
|
+
{ limit: MAX_METADATA_CHANGES_LIMIT + 1 },
|
|
241
|
+
{ since: 2n, until: 1n },
|
|
242
|
+
{ start: MetadataChangeStart.Latest, since: 1n },
|
|
243
|
+
{ start: MetadataChangeStart.Current(), until: 1n },
|
|
244
|
+
{ start: MetadataChangeStart.Current(), groups: ['topics'] },
|
|
245
|
+
{ start: MetadataChangeStart.Current(first.currentPageToken), groups: ['streams', 'users'] }
|
|
246
|
+
];
|
|
247
|
+
for (const query of refused)
|
|
248
|
+
await assert.rejects(c.system.getMetadataChanges(query), isRefusedLocally(INVALID_COMMAND));
|
|
249
|
+
assert.throws(() => MetadataChangeCursor.parse('mc1_x'), isRefusedLocally(INVALID_COMMAND));
|
|
250
|
+
const latest = await c.system.getMetadataChanges({ start: MetadataChangeStart.Latest });
|
|
251
|
+
assert.equal(latest.status, MetadataChangeStatus.Ok, 'the connection stays usable');
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
for (const stream of created)
|
|
255
|
+
await c.stream.delete({ streamId: stream.id });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
it('given user without manage servers when reading changes should be refused as unauthorized', async () => {
|
|
259
|
+
const password = 'changes-secret';
|
|
260
|
+
const user = await c.user.create({
|
|
261
|
+
username: `${prefix}-reader`, password, status: UserStatus.Active,
|
|
262
|
+
permissions: {
|
|
263
|
+
global: {
|
|
264
|
+
ManageServers: false, ReadServers: true, ManageUsers: false, ReadUsers: true,
|
|
265
|
+
ManageStreams: false, ReadStreams: true, ManageTopics: false, ReadTopics: true,
|
|
266
|
+
PollMessages: false, SendMessages: false
|
|
267
|
+
},
|
|
268
|
+
streams: []
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
const reader = connect({ username: `${prefix}-reader`, password });
|
|
272
|
+
try {
|
|
273
|
+
await assert.rejects(reader.system.getMetadataChanges({}), (error) => error instanceof ResponseError && error.errorCode === UNAUTHORIZED && error.fromServer);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await reader.destroy();
|
|
277
|
+
await c.user.delete({ userId: user.id });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
after(() => {
|
|
281
|
+
c.destroy();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
/**
|
|
285
|
+
* Registers the keyed stream case, which needs a server with a config key:
|
|
286
|
+
* a client key is refused without one.
|
|
287
|
+
*/
|
|
288
|
+
export const describeKeyedMetadataChanges = (label, prefix, connect, skip = false) => describe(label, { skip }, async () => {
|
|
289
|
+
const c = connect({ username: 'iggy', password: 'iggy' });
|
|
290
|
+
it('given keyed stream when following its creation should expose fingerprints and no key', async () => {
|
|
291
|
+
const cursor = await latestCursor(c, ['streams']);
|
|
292
|
+
const key = randomBytes(32);
|
|
293
|
+
const created = await c.stream.create({
|
|
294
|
+
name: `${prefix}-keyed`, encryptionKey: StreamEncryptionKey.from(key)
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
const [record] = await followStream(c, cursor, ['streams'], createdStream(created.id, created.name), 1);
|
|
298
|
+
assert.equal(record.kind, MetadataChangeKind.StreamCreated);
|
|
299
|
+
assert.ok(record.kind === 'stream_created');
|
|
300
|
+
const { options } = record.body;
|
|
301
|
+
assert.equal(options.unreadable, false);
|
|
302
|
+
assert.ok(!options.unreadable);
|
|
303
|
+
const text = (name) => options.derivedOptions[name] ?? options.options[name];
|
|
304
|
+
assert.equal(text('encryption_key_source'), EncryptionKeySource.Stream);
|
|
305
|
+
assert.equal(text('encryption_key_fingerprint'), encryptionKeyFingerprint(key));
|
|
306
|
+
const keks = String(text('encryption_kek_fingerprint')).split(',');
|
|
307
|
+
assert.deepEqual(keks, created.encryptionKekFingerprints);
|
|
308
|
+
for (const kek of keks)
|
|
309
|
+
assert.match(kek, FINGERPRINT);
|
|
310
|
+
for (const block of [options.options, options.derivedOptions]) {
|
|
311
|
+
for (const name of NEVER_RETURNED)
|
|
312
|
+
assert.equal(name in block, false, `${name} was returned`);
|
|
313
|
+
for (const value of Object.values(block)) {
|
|
314
|
+
assert.ok(!(Buffer.isBuffer(value) && value.includes(key)), 'an option holds the key');
|
|
315
|
+
for (const encoded of [key.toString('base64'), key.toString('base64url'), key.toString('hex')])
|
|
316
|
+
assert.ok(!String(value).includes(encoded), 'an option holds the encoded key');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
await c.stream.delete({ streamId: created.id });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
after(() => {
|
|
325
|
+
c.destroy();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
//# sourceMappingURL=metadata-changes.suite.js.map
|
|
@@ -20,9 +20,13 @@
|
|
|
20
20
|
import { Client } from '../client/client.js';
|
|
21
21
|
import { getIggyAddress } from '../tcp.sm.utils.js';
|
|
22
22
|
import { EncryptionKeySource } from '../wire/index.js';
|
|
23
|
+
import { describeKeyedMetadataChanges } from './metadata-changes.suite.js';
|
|
23
24
|
import { describeResourceOptions } from './resource-options.suite.js';
|
|
24
25
|
const [host, port] = getIggyAddress();
|
|
25
26
|
describeResourceOptions('e2e -> resource options', 'e2e-ro', (credentials) => new Client({
|
|
26
27
|
transport: 'TCP', options: { host, port }, credentials
|
|
27
28
|
}), EncryptionKeySource.Server);
|
|
29
|
+
describeKeyedMetadataChanges('e2e -> keyed metadata changes', 'e2e-mc', (credentials) => new Client({
|
|
30
|
+
transport: 'TCP', options: { host, port }, credentials
|
|
31
|
+
}));
|
|
28
32
|
//# sourceMappingURL=resource-options.e2e.js.map
|
|
@@ -39,6 +39,7 @@ import assert from 'node:assert/strict';
|
|
|
39
39
|
import { Client } from '../client/client.js';
|
|
40
40
|
import { Partitioning, Consumer, EncryptionKeySource, PollingStrategy } from '../wire/index.js';
|
|
41
41
|
import { getIggyAddress } from '../tcp.sm.utils.js';
|
|
42
|
+
import { describeKeyedMetadataChanges, describeMetadataChanges } from './metadata-changes.suite.js';
|
|
42
43
|
import { describeResourceOptions } from './resource-options.suite.js';
|
|
43
44
|
const tlsEnabled = process.env.IGGY_TCP_TLS_ENABLED === 'true';
|
|
44
45
|
// Path to the CA certificate. Override with E2E_ROOT_CA_CERT env var,
|
|
@@ -63,6 +64,8 @@ const getTlsClient = (credentials = { username: 'iggy', password: 'iggy' }) => {
|
|
|
63
64
|
});
|
|
64
65
|
};
|
|
65
66
|
describeResourceOptions('e2e -> tls resource options', 'tls-e2e-ro', getTlsClient, EncryptionKeySource.None, !tlsEnabled && 'IGGY_TCP_TLS_ENABLED is not set');
|
|
67
|
+
describeMetadataChanges('e2e -> tls metadata changes', 'tls-e2e-mc', getTlsClient, !tlsEnabled && 'IGGY_TCP_TLS_ENABLED is not set');
|
|
68
|
+
describeKeyedMetadataChanges('e2e -> tls keyed metadata changes', 'tls-e2e-mc', getTlsClient, !tlsEnabled && 'IGGY_TCP_TLS_ENABLED is not set');
|
|
66
69
|
describe('e2e -> tls', { skip: !tlsEnabled && 'IGGY_TCP_TLS_ENABLED is not set' }, async () => {
|
|
67
70
|
const c = getTlsClient();
|
|
68
71
|
const credentials = { username: 'iggy', password: 'iggy' };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type Id, PollingStrategy, Consumer, Partitioning, HeaderValue, HeaderKeyFactory, type SendMessagesConfirmation, type SendMessagesResponse, CompressionAlgorithm, Durability, PartitionResizePolicy, EncryptionKeySource, OptionsScope, StreamOptionKey, TopicOptionKey, UserOptionKey, UserStatus, StreamEncryptionKey, encryptionKeyFingerprint, type OptionEntry, type OptionSpec, type ParsedOptions, type StreamOptions, type TopicOptions, type UserOptions, } from "./wire/index.js";
|
|
1
|
+
export { type Id, PollingStrategy, Consumer, Partitioning, HeaderValue, HeaderKeyFactory, type SendMessagesConfirmation, type SendMessagesResponse, CompressionAlgorithm, Durability, PartitionResizePolicy, EncryptionKeySource, OptionsScope, StreamOptionKey, TopicOptionKey, UserOptionKey, UserStatus, StreamEncryptionKey, encryptionKeyFingerprint, type OptionEntry, type OptionSpec, type ParsedOptions, type StreamOptions, type TopicOptions, type UserOptions, MetadataChangeGroup, MetadataChangeKind, MetadataChangeStatus, MetadataChangePhase, MetadataMemberLeftReason, MetadataTokenDeletedReason, MetadataSessionClosedReason, MetadataPermissionsUpdatedReason, MetadataChangeStart, MetadataChangeCursor, MetadataCurrentPageToken, DEFAULT_METADATA_CHANGES_LIMIT, MAX_METADATA_CHANGES_LIMIT, METADATA_CHANGE_CURSOR_PREFIX, METADATA_CURRENT_PAGE_TOKEN_PREFIX, metadataChangeGroupName, metadataChangeKindName, metadataChangeKindGroup, type GetMetadataChanges, type MetadataChanges, type MetadataChange, type MetadataChangeRecord, type MetadataCurrentItem, type MetadataOptions, type MetadataStreamKey, type MetadataTopicKey, type MetadataUserKey, type MetadataStreamChange, type MetadataStreamDeleted, type MetadataTopicChange, type MetadataTopicItem, type MetadataTopicDeleted, type MetadataPartitionsChanged, type MetadataPartitionsItem, type MetadataConsumerGroupCreated, type MetadataConsumerGroupDeleted, type MetadataConsumerGroupItem, type MetadataMemberJoined, type MetadataMemberLeft, type MetadataPartitionHandedOver, type MetadataMemberItem, type MetadataUserChange, type MetadataUserIdentity, type MetadataUserPermissionsUpdated, type MetadataTokenChange, type MetadataTokenDeleted, type MetadataSession, type MetadataSessionClosed, } from "./wire/index.js";
|
|
2
2
|
export * from "./client/index.js";
|
|
3
3
|
export * from "./stream/index.js";
|
|
4
4
|
export { DeserializeError, ResponseError } from './wire/error.utils.js';
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// KIND, either express or implied. See the License for the
|
|
15
15
|
// specific language governing permissions and limitations
|
|
16
16
|
// under the License.
|
|
17
|
-
export { PollingStrategy, Consumer, Partitioning, HeaderValue, HeaderKeyFactory, CompressionAlgorithm, Durability, PartitionResizePolicy, EncryptionKeySource, OptionsScope, StreamOptionKey, TopicOptionKey, UserOptionKey, UserStatus, StreamEncryptionKey, encryptionKeyFingerprint, } from "./wire/index.js";
|
|
17
|
+
export { PollingStrategy, Consumer, Partitioning, HeaderValue, HeaderKeyFactory, CompressionAlgorithm, Durability, PartitionResizePolicy, EncryptionKeySource, OptionsScope, StreamOptionKey, TopicOptionKey, UserOptionKey, UserStatus, StreamEncryptionKey, encryptionKeyFingerprint, MetadataChangeGroup, MetadataChangeKind, MetadataChangeStatus, MetadataChangePhase, MetadataMemberLeftReason, MetadataTokenDeletedReason, MetadataSessionClosedReason, MetadataPermissionsUpdatedReason, MetadataChangeStart, MetadataChangeCursor, MetadataCurrentPageToken, DEFAULT_METADATA_CHANGES_LIMIT, MAX_METADATA_CHANGES_LIMIT, METADATA_CHANGE_CURSOR_PREFIX, METADATA_CURRENT_PAGE_TOKEN_PREFIX, metadataChangeGroupName, metadataChangeKindName, metadataChangeKindGroup, } from "./wire/index.js";
|
|
18
18
|
export * from "./client/index.js";
|
|
19
19
|
export * from "./stream/index.js";
|
|
20
20
|
export { DeserializeError, ResponseError } from './wire/error.utils.js';
|
|
@@ -81,6 +81,7 @@ declare const systemAPI: (c: ClientProvider) => {
|
|
|
81
81
|
getStats: (arg: void) => Promise<import("./system/get-stats.command.js").Stats>;
|
|
82
82
|
snapshot: (arg: void | import("./system/snapshot.command.js").SnapshotOptions) => Promise<Buffer<ArrayBufferLike>>;
|
|
83
83
|
describeOptions: (arg: import("./system/describe-options.command.js").DescribeOptions) => Promise<import("./system/describe-options.command.js").OptionSpec[]>;
|
|
84
|
+
getMetadataChanges: (arg: import("./system/get-metadata-changes.command.js").GetMetadataChanges) => Promise<import("./system/get-metadata-changes.command.js").MetadataChanges>;
|
|
84
85
|
};
|
|
85
86
|
type SystemAPI = ReturnType<typeof systemAPI>;
|
|
86
87
|
declare const clusterAPI: (c: ClientProvider) => {
|
package/dist/wire/command-set.js
CHANGED
|
@@ -50,6 +50,7 @@ import { createPartition } from './partition/create-partition.command.js';
|
|
|
50
50
|
import { deletePartition } from './partition/delete-partition.command.js';
|
|
51
51
|
import { deleteSegments } from './segment/delete-segments.command.js';
|
|
52
52
|
import { describeOptions } from './system/describe-options.command.js';
|
|
53
|
+
import { getMetadataChanges } from './system/get-metadata-changes.command.js';
|
|
53
54
|
import { getStats } from './system/get-stats.command.js';
|
|
54
55
|
import { snapshot } from './system/snapshot.command.js';
|
|
55
56
|
import { ping } from './system/ping.command.js';
|
|
@@ -134,7 +135,8 @@ const systemAPI = (c) => ({
|
|
|
134
135
|
ping: ping(c),
|
|
135
136
|
getStats: getStats(c),
|
|
136
137
|
snapshot: snapshot(c),
|
|
137
|
-
describeOptions: describeOptions(c)
|
|
138
|
+
describeOptions: describeOptions(c),
|
|
139
|
+
getMetadataChanges: getMetadataChanges(c)
|
|
138
140
|
});
|
|
139
141
|
const clusterAPI = (c) => ({
|
|
140
142
|
getClusterMetadata: getClusterMetadata(c)
|