apache-iggy 0.10.0-edge.5 → 0.10.0-edge.7

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 CHANGED
@@ -161,7 +161,7 @@ npm run build
161
161
  ### test
162
162
 
163
163
  note: use env var `IGGY_TCP_ADDRESS="host:port"` to set the server
164
- address for bdd and e2e tests.
164
+ address for e2e tests. bdd tests need more variables, see below.
165
165
 
166
166
  #### unit tests
167
167
 
@@ -179,15 +179,27 @@ npm run test:e2e
179
179
 
180
180
  #### bdd tests
181
181
 
182
- bdd test expect an iggy-server at tcp://127.0.0.1:8090
182
+ the bdd suite has no defaults and fails when `IGGY_TCP_ADDRESS`,
183
+ `IGGY_ROOT_USERNAME` or `IGGY_ROOT_PASSWORD` is missing. from the repository
184
+ root run
183
185
 
184
186
  ```bash
185
- npm run test:bdd
187
+ ./scripts/run-bdd-tests.sh node
186
188
  ```
187
189
 
190
+ the script starts the server and sets every variable, so none of them have to be
191
+ exported by hand. to iterate against a server you started yourself, see
192
+ [src/bdd/README.md](./src/bdd/README.md).
193
+
188
194
  #### run all test
189
195
 
190
- `npm run test` runs unit, bdd and e2e tests suite (expect an iggy-server at tcp://127.0.0.1:8090)
196
+ `npm run test` runs unit, bdd and e2e tests suite against an iggy-server at
197
+ tcp://127.0.0.1:8090, started with the same root credentials
198
+
199
+ ```bash
200
+ IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \
201
+ npm run test
202
+ ```
191
203
 
192
204
  ### lint
193
205
 
@@ -158,6 +158,7 @@ export const translateErrorCode = (code) => {
158
158
  case '3021': return "Consumer offset for consumer with ID: {0} was not found.";
159
159
  case '3022': return "Failed to resolve consumer with ID: {0}";
160
160
  case '3023': return "Cannot open consumer offsets file for path: {0}";
161
+ case '3024': return "Consumer offset limit reached for partition, raise [partition] consumer_offsets_max";
161
162
  case '3013': return "Partition id space exhausted for this topic";
162
163
  // MESSAGE
163
164
  case '4000': return "Segment not found";
@@ -17,6 +17,9 @@
17
17
  import assert from 'node:assert/strict';
18
18
  import { it } from 'node:test';
19
19
  import { translateErrorCode } from './error.code.js';
20
+ it('translates the consumer-offset capacity error', () => {
21
+ assert.equal(translateErrorCode(3024), 'Consumer offset limit reached for partition, raise [partition] consumer_offsets_max');
22
+ });
20
23
  it('translates the consumer-group error range', () => {
21
24
  assert.equal(translateErrorCode(5000), 'Consumer group with ID: {0} for topic with ID: {1} was not found.');
22
25
  assert.equal(translateErrorCode(5001), 'error');
@@ -45,6 +45,20 @@ export declare const isValidMessageId: (x?: unknown) => x is MessageIdKind;
45
45
  * @throws Error if the ID format is invalid
46
46
  */
47
47
  export declare const serializeMessageId: (id?: unknown) => Buffer<ArrayBufferLike>;
48
+ /**
49
+ * Mints a random 16-byte message ID from the pool, refilling when drained.
50
+ *
51
+ * @returns 16-byte buffer of random bytes owned by the caller
52
+ */
53
+ export declare const mintMessageId: () => Buffer;
54
+ /**
55
+ * Resolves a message ID to a 16-byte little-endian buffer, minting a random
56
+ * one when the ID is absent or zero.
57
+ *
58
+ * @param id - Optional message ID
59
+ * @returns 16-byte little-endian buffer containing a non-zero ID
60
+ */
61
+ export declare const resolveMessageId: (id?: MessageIdKind) => Buffer;
48
62
  /**
49
63
  * Encodes messages into the canonical batch format.
50
64
  * Format: [batch header][frames], one frame per message.
@@ -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
- import { uuidv4 } from 'uuidv7';
17
+ import { randomFillSync } from 'node:crypto';
18
18
  import { uint32ToBuf, u128ToBuf, uint8ToBuf } from '../number.utils.js';
19
19
  import { serializeHeaders } from './header.utils.js';
20
20
  import { serializeIdentifier } from '../identifier.utils.js';
@@ -23,6 +23,8 @@ import { parse as parseUUID } from '../uuid.utils.js';
23
23
  import { BATCH_HEADER_SIZE, FRAME_HEADER_SIZE, batchChecksum, frameChecksum, serializeBatchHeader, } from './iggy-header.utils.js';
24
24
  /** Size of the message ID in bytes (u128) */
25
25
  const MESSAGE_ID_SIZE = 16;
26
+ /** Exclusive upper bound for a numeric message ID: it must be < 2^128 */
27
+ const MESSAGE_ID_UPPER_BOUND = 1n << BigInt((MESSAGE_ID_SIZE * 8));
26
28
  /** Largest representable frame timestamp delta (u32, microseconds) */
27
29
  const MAX_TIMESTAMP_DELTA = 0xffffffffn;
28
30
  /**
@@ -52,6 +54,8 @@ export const serializeMessageId = (id) => {
52
54
  if (id < 0)
53
55
  throw new Error(`invalid message id: '${id}' (numeric id must be >= 0)`);
54
56
  const idValue = 'number' === typeof id ? BigInt(id) : id;
57
+ if (idValue >= MESSAGE_ID_UPPER_BOUND)
58
+ throw new Error(`invalid message id: '${id}' (numeric id must be < 2^${MESSAGE_ID_SIZE * 8})`);
55
59
  return u128ToBuf(idValue);
56
60
  }
57
61
  try {
@@ -62,17 +66,41 @@ export const serializeMessageId = (id) => {
62
66
  throw new Error(`invalid message id: '${id}' (use uuid string | number | bigint >= 0)`, { cause: err });
63
67
  }
64
68
  };
69
+ /** Number of ids drawn from the pool per CSPRNG refill */
70
+ const ID_POOL_COUNT = 4096;
71
+ /** Pooled random bytes and a cursor into them, filled lazily on first mint */
72
+ const idPool = Buffer.allocUnsafe(ID_POOL_COUNT * MESSAGE_ID_SIZE);
73
+ let idPoolCursor = idPool.length; // past the end -> refill on first use
65
74
  /**
66
- * Serializes a message ID, minting a random UUID when the ID is
67
- * absent or zero.
75
+ * Mints a random 16-byte message ID from the pool, refilling when drained.
76
+ *
77
+ * @returns 16-byte buffer of random bytes owned by the caller
78
+ */
79
+ export const mintMessageId = () => {
80
+ if (idPoolCursor + MESSAGE_ID_SIZE > idPool.length) {
81
+ randomFillSync(idPool);
82
+ idPoolCursor = 0;
83
+ }
84
+ const id = Buffer.allocUnsafe(MESSAGE_ID_SIZE);
85
+ idPool.copy(id, 0, idPoolCursor, idPoolCursor + MESSAGE_ID_SIZE);
86
+ idPoolCursor += MESSAGE_ID_SIZE;
87
+ return id;
88
+ };
89
+ /**
90
+ * Resolves a message ID to a 16-byte little-endian buffer, minting a random
91
+ * one when the ID is absent or zero.
68
92
  *
69
93
  * @param id - Optional message ID
70
94
  * @returns 16-byte little-endian buffer containing a non-zero ID
71
95
  */
72
- const resolveMessageId = (id) => {
96
+ export const resolveMessageId = (id) => {
97
+ // An absent or zero id mints a random one.
98
+ if (id === undefined || id === 0 || id === 0n)
99
+ return mintMessageId();
73
100
  const bId = serializeMessageId(id);
74
- return bId.every((byte) => byte === 0)
75
- ? u128ToBuf(BigInt(`0x${uuidv4().replaceAll('-', '')}`))
101
+ // A string id can still be the all-zero nil UUID; mint in that case too.
102
+ return 'string' === typeof id && bId.every((byte) => byte === 0)
103
+ ? mintMessageId()
76
104
  : bId;
77
105
  };
78
106
  /**
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=message.utils.test.d.ts.map
@@ -0,0 +1,107 @@
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 { describe, it } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { u128ToBuf } from "../number.utils.js";
20
+ import { isValidMessageId, serializeMessageId, resolveMessageId, mintMessageId, } from "./message.utils.js";
21
+ const MESSAGE_ID_SIZE = 16;
22
+ const MAX_U128 = (1n << 128n) - 1n;
23
+ const NIL_UUID = "00000000-0000-0000-0000-000000000000";
24
+ const isZero = (b) => b.every((byte) => byte === 0);
25
+ describe("isValidMessageId", () => {
26
+ it("accepts undefined, string, number, and bigint", () => {
27
+ assert.ok(isValidMessageId(undefined));
28
+ assert.ok(isValidMessageId("id"));
29
+ assert.ok(isValidMessageId(7));
30
+ assert.ok(isValidMessageId(7n));
31
+ });
32
+ it("rejects other types", () => {
33
+ assert.ok(!isValidMessageId(null));
34
+ assert.ok(!isValidMessageId({}));
35
+ });
36
+ });
37
+ describe("serializeMessageId", () => {
38
+ it("serializes undefined to a zero u128", () => {
39
+ assert.deepEqual(serializeMessageId(), Buffer.alloc(MESSAGE_ID_SIZE, 0));
40
+ });
41
+ it("serializes a number as little-endian u128", () => {
42
+ assert.deepEqual(serializeMessageId(7), u128ToBuf(7n));
43
+ });
44
+ it("serializes a bigint as little-endian u128", () => {
45
+ assert.deepEqual(serializeMessageId(8n), u128ToBuf(8n));
46
+ });
47
+ it("serializes a UUID string to the same bytes as its numeric value", () => {
48
+ const uuid = "00000000-0000-0000-0000-000000000007";
49
+ assert.deepEqual(serializeMessageId(uuid), u128ToBuf(7n));
50
+ });
51
+ it("accepts the largest u128", () => {
52
+ assert.deepEqual(serializeMessageId(MAX_U128), u128ToBuf(MAX_U128));
53
+ });
54
+ it("rejects a numeric id at or above 2^128", () => {
55
+ assert.throws(() => serializeMessageId(1n << 128n), /2\^128/);
56
+ });
57
+ it("rejects a negative numeric id", () => {
58
+ assert.throws(() => serializeMessageId(-1n), />= 0/);
59
+ });
60
+ it("rejects an unparsable string", () => {
61
+ assert.throws(() => serializeMessageId("not-a-uuid"), /invalid message id/);
62
+ });
63
+ it("rejects an invalid type", () => {
64
+ assert.throws(() => serializeMessageId({}), /invalid message id/);
65
+ });
66
+ });
67
+ describe("resolveMessageId", () => {
68
+ it("mints a non-zero id for undefined, 0, and 0n", () => {
69
+ for (const id of [undefined, 0, 0n]) {
70
+ const b = resolveMessageId(id);
71
+ assert.equal(b.length, MESSAGE_ID_SIZE);
72
+ assert.ok(!isZero(b));
73
+ }
74
+ });
75
+ it("mints for the all-zero nil UUID string", () => {
76
+ assert.ok(!isZero(resolveMessageId(NIL_UUID)));
77
+ });
78
+ it("passes a provided non-zero id through unchanged", () => {
79
+ assert.deepEqual(resolveMessageId(7n), serializeMessageId(7n));
80
+ });
81
+ });
82
+ describe("mintMessageId", () => {
83
+ it("returns a non-zero 16-byte buffer", () => {
84
+ const b = mintMessageId();
85
+ assert.equal(b.length, MESSAGE_ID_SIZE);
86
+ assert.ok(!isZero(b));
87
+ });
88
+ it("produces unique, non-zero ids across pool refills", () => {
89
+ const seen = new Set();
90
+ for (let i = 0; i < 10_000; i++) {
91
+ const hex = mintMessageId().toString("hex");
92
+ assert.notEqual(hex, "0".repeat(MESSAGE_ID_SIZE * 2));
93
+ seen.add(hex);
94
+ }
95
+ assert.equal(seen.size, 10_000);
96
+ });
97
+ it("returns owned bytes that survive a later refill", () => {
98
+ const first = mintMessageId().toString("hex");
99
+ const held = mintMessageId();
100
+ const snapshot = held.toString("hex");
101
+ for (let i = 0; i < 10_000; i++)
102
+ mintMessageId();
103
+ assert.equal(held.toString("hex"), snapshot);
104
+ assert.notEqual(snapshot, first);
105
+ });
106
+ });
107
+ //# sourceMappingURL=message.utils.test.js.map
@@ -28,9 +28,8 @@ export type SendMessagesConfirmation = {
28
28
  *
29
29
  * Delivery is at-least-once, so an earlier retry of the same batch may
30
30
  * already have committed at a lower offset: this never identifies a batch
31
- * uniquely. A batch is confirmed once it is committed in memory, not once it
32
- * is fsynced, so a crash-restart can stamp a later batch with an offset a
33
- * client has already recorded.
31
+ * uniquely. Confirmation follows VSR quorum commit. Persisted message
32
+ * durability also requires recoverable stable-storage copies on the quorum.
34
33
  */
35
34
  baseOffset: bigint;
36
35
  };
@@ -76,13 +76,18 @@ export declare const floatToBuf: (v: number) => Buffer<ArrayBuffer>;
76
76
  */
77
77
  export declare const doubleToBuf: (v: number) => Buffer<ArrayBuffer>;
78
78
  /**
79
- * Converts a BigInt to a 128-bit unsigned integer Buffer in little-endian format.
79
+ * Converts a u128 value to a 16-byte unsigned integer Buffer in little-endian
80
+ * format.
80
81
  *
81
- * @param num - BigInt value to convert
82
- * @param width - Width in bytes (default: 16)
83
- * @returns Buffer containing the value in little-endian byte order
82
+ * Writes the two 64-bit halves directly rather than round-tripping through a hex
83
+ * string. `value` must be a non-negative integer below 2^128; anything outside
84
+ * that range throws (a `RangeError` from the underlying write), which is
85
+ * preferable to silently truncating a message id.
86
+ *
87
+ * @param value - u128 value (0 <= value < 2^128)
88
+ * @returns 16-byte buffer containing the value in little-endian byte order
84
89
  */
85
- export declare function u128ToBuf(num: bigint, width?: number): Buffer;
90
+ export declare function u128ToBuf(value: bigint): Buffer;
86
91
  /**
87
92
  * Converts a 128-bit unsigned integer Buffer in little-endian format to a BigInt.
88
93
  *
@@ -135,17 +135,25 @@ export const doubleToBuf = (v) => {
135
135
  b.writeDoubleLE(v);
136
136
  return b;
137
137
  };
138
+ /** Mask selecting the low 64 bits of a u128, for little-endian half-writes. */
139
+ const U64_MASK = 0xffffffffffffffffn;
138
140
  /**
139
- * Converts a BigInt to a 128-bit unsigned integer Buffer in little-endian format.
141
+ * Converts a u128 value to a 16-byte unsigned integer Buffer in little-endian
142
+ * format.
140
143
  *
141
- * @param num - BigInt value to convert
142
- * @param width - Width in bytes (default: 16)
143
- * @returns Buffer containing the value in little-endian byte order
144
+ * Writes the two 64-bit halves directly rather than round-tripping through a hex
145
+ * string. `value` must be a non-negative integer below 2^128; anything outside
146
+ * that range throws (a `RangeError` from the underlying write), which is
147
+ * preferable to silently truncating a message id.
148
+ *
149
+ * @param value - u128 value (0 <= value < 2^128)
150
+ * @returns 16-byte buffer containing the value in little-endian byte order
144
151
  */
145
- export function u128ToBuf(num, width = 16) {
146
- const hex = num.toString(16);
147
- const b = Buffer.from(hex.padStart(width * 2, '0').slice(0, width * 2), 'hex');
148
- return b.reverse();
152
+ export function u128ToBuf(value) {
153
+ const b = Buffer.allocUnsafe(16);
154
+ b.writeBigUInt64LE(value & U64_MASK, 0);
155
+ b.writeBigUInt64LE(value >> 64n, 8);
156
+ return b;
149
157
  }
150
158
  /**
151
159
  * Converts a 128-bit unsigned integer Buffer in little-endian format to a BigInt.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=number.utils.test.d.ts.map
@@ -0,0 +1,55 @@
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 { describe, it } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { u128ToBuf } from "./number.utils.js";
20
+ const MAX_U128 = (1n << 128n) - 1n;
21
+ const hex = (v) => u128ToBuf(v).toString("hex");
22
+ describe("u128ToBuf", () => {
23
+ it("encodes zero as 16 zero bytes", () => {
24
+ assert.equal(hex(0n), "0".repeat(32));
25
+ });
26
+ it("encodes a small value little-endian", () => {
27
+ assert.equal(hex(7n), "07" + "0".repeat(30));
28
+ });
29
+ it("orders all 16 bytes little-endian", () => {
30
+ // Distinct bytes so a byte-order slip is visible.
31
+ assert.equal(hex(0x0102030405060708090a0b0c0d0e0f10n), "100f0e0d0c0b0a090807060504030201");
32
+ });
33
+ it("spans the 64-bit half boundary", () => {
34
+ assert.equal(hex((1n << 64n) - 1n), "ff".repeat(8) + "00".repeat(8));
35
+ assert.equal(hex(1n << 64n), "00".repeat(8) + "01" + "00".repeat(7));
36
+ });
37
+ it("encodes the largest u128 as all ones", () => {
38
+ assert.equal(hex(MAX_U128), "ff".repeat(16));
39
+ });
40
+ it("round-trips through readBigUInt64LE halves", () => {
41
+ for (const v of [0n, 1n, 42n, 1n << 64n, MAX_U128]) {
42
+ const b = u128ToBuf(v);
43
+ const low = b.readBigUInt64LE(0);
44
+ const high = b.readBigUInt64LE(8);
45
+ assert.equal((high << 64n) | low, v);
46
+ }
47
+ });
48
+ it("throws for a value at or above 2^128", () => {
49
+ assert.throws(() => u128ToBuf(1n << 128n));
50
+ });
51
+ it("throws for a negative value", () => {
52
+ assert.throws(() => u128ToBuf(-1n));
53
+ });
54
+ });
55
+ //# sourceMappingURL=number.utils.test.js.map
@@ -28,22 +28,25 @@ const prefixed = (block) => {
28
28
  *
29
29
  * Rust pins the identical bytes in `core/binary_protocol/src/primitives/options.rs`,
30
30
  * as do the Go and Java SDKs. Round-tripping through this SDK's own decoder proves
31
- * nothing about interoperability; these bytes are the contract.
31
+ * nothing about interoperability. This insertion-order vector covers Bool, Uint64
32
+ * and String values, independently of Rust's sorted map order.
32
33
  */
33
34
  const GOLDEN_OPTIONS_BLOCK = Buffer.from([
34
- 2, 13, 0, 0, 0,
35
- ...Buffer.from('enforce_fsync'),
35
+ 2, 20, 0, 0, 0,
36
+ ...Buffer.from('preallocate_segments'),
36
37
  3, 1, 0, 0, 0, 1,
37
38
  2, 12, 0, 0, 0,
38
39
  ...Buffer.from('segment_size'),
39
40
  12, 8, 0, 0, 0,
40
- 0, 0, 0, 64, 0, 0, 0, 0
41
+ 0, 0, 0, 64, 0, 0, 0, 0,
42
+ 2, 10, 0, 0, 0, 100, 117, 114, 97, 98, 105, 108, 105, 116, 121, 2, 9, 0, 0, 0, 112, 101, 114, 115, 105, 115, 116, 101, 100
41
43
  ]);
42
44
  describe('serializeOptions', () => {
43
45
  it('encodes the cross-SDK golden vector byte for byte', () => {
44
46
  const encoded = serializeOptions([
45
- { key: 'enforce_fsync', value: HeaderValue.Bool(true) },
46
- { key: 'segment_size', value: HeaderValue.Uint64(1073741824n) }
47
+ { key: 'preallocate_segments', value: HeaderValue.Bool(true) },
48
+ { key: 'segment_size', value: HeaderValue.Uint64(1073741824n) },
49
+ { key: 'durability', value: HeaderValue.String('persisted') }
47
50
  ]);
48
51
  assert.deepEqual(encoded, GOLDEN_OPTIONS_BLOCK);
49
52
  });
@@ -1,7 +1,7 @@
1
1
  import type { CommandResponse } from '../../client/client.type.js';
2
2
  import { type Id } from '../identifier.utils.js';
3
3
  import { type OptionEntry } from '../options.utils.js';
4
- import { type Topic, type CompressionAlgorithm as CompressionAlgorithmT } from './topic.utils.js';
4
+ import { Durability, type Topic, type CompressionAlgorithm as CompressionAlgorithmT } from './topic.utils.js';
5
5
  /**
6
6
  * Parameters for the create topic command.
7
7
  */
@@ -28,8 +28,9 @@ export type CreateTopic = {
28
28
  maxTopicSize?: bigint;
29
29
  /** Segment size in bytes: 512-byte multiple between 1 MiB and 1 GiB */
30
30
  segmentSize?: bigint;
31
- /** Fsync every write instead of leaving it to the page cache */
32
- enforceFsync?: boolean;
31
+ /** Message completion policy. Defaults to replicated, independently of offsets. */
32
+ durability?: Durability;
33
+ consumerOffsetDurability?: Durability;
33
34
  /** Message count that triggers a save (must be non-zero) */
34
35
  messagesRequiredToSave?: number;
35
36
  /** Accumulated message bytes that trigger a save */
@@ -52,7 +53,7 @@ export type CreateTopic = {
52
53
  */
53
54
  export declare const CREATE_TOPIC: {
54
55
  code: number;
55
- serialize: ({ streamId, name, partitionCount, compressionAlgorithm, messageExpiry, maxTopicSize, segmentSize, enforceFsync, messagesRequiredToSave, sizeOfMessagesRequiredToSave, preallocateSegments, options: extraOptions }: CreateTopic) => Buffer<ArrayBuffer>;
56
+ serialize: ({ streamId, name, partitionCount, compressionAlgorithm, messageExpiry, maxTopicSize, segmentSize, durability, consumerOffsetDurability, messagesRequiredToSave, sizeOfMessagesRequiredToSave, preallocateSegments, options: extraOptions }: CreateTopic) => Buffer<ArrayBuffer>;
56
57
  deserialize: (r: CommandResponse) => Topic;
57
58
  };
58
59
  /**
@@ -19,7 +19,7 @@ import { wrapCommand } from '../command.utils.js';
19
19
  import { COMMAND_CODE } from '../command.code.js';
20
20
  import { dedupeOptions, serializeOptions } from '../options.utils.js';
21
21
  import { HeaderValue } from '../message/header.utils.js';
22
- import { isValidCompressionAlgorithm, CompressionAlgorithm, compressionAlgorithmName, deserializeTopic } from './topic.utils.js';
22
+ import { isValidCompressionAlgorithm, CompressionAlgorithm, Durability, compressionAlgorithmName, deserializeTopic } from './topic.utils.js';
23
23
  /**
24
24
  * Create topic command definition.
25
25
  * Creates a new topic within a stream.
@@ -28,7 +28,7 @@ import { isValidCompressionAlgorithm, CompressionAlgorithm, compressionAlgorithm
28
28
  */
29
29
  export const CREATE_TOPIC = {
30
30
  code: COMMAND_CODE.CreateTopic,
31
- serialize: ({ streamId, name, partitionCount, compressionAlgorithm = CompressionAlgorithm.None, messageExpiry = 0n, maxTopicSize = 0n, segmentSize, enforceFsync, messagesRequiredToSave, sizeOfMessagesRequiredToSave, preallocateSegments, options: extraOptions = [] }) => {
31
+ serialize: ({ streamId, name, partitionCount, compressionAlgorithm = CompressionAlgorithm.None, messageExpiry = 0n, maxTopicSize = 0n, segmentSize, durability = Durability.Replicated, consumerOffsetDurability = Durability.Replicated, messagesRequiredToSave, sizeOfMessagesRequiredToSave, preallocateSegments, options: extraOptions = [] }) => {
32
32
  // Topic ID is now auto-assigned by the server, not sent in the protocol
33
33
  const streamIdentifier = serializeIdentifier(streamId);
34
34
  const bName = Buffer.from(name);
@@ -60,10 +60,19 @@ export const CREATE_TOPIC = {
60
60
  options.push({
61
61
  key: 'segment_size', value: HeaderValue.Uint64(segmentSize)
62
62
  });
63
- if (enforceFsync !== undefined)
64
- options.push({
65
- key: 'enforce_fsync', value: HeaderValue.Bool(enforceFsync)
66
- });
63
+ for (const [key, policy] of [
64
+ ['durability', durability],
65
+ ['consumer_offset_durability', consumerOffsetDurability]
66
+ ]) {
67
+ if (policy !== Durability.Replicated && policy !== Durability.Persisted)
68
+ throw new Error(`Invalid ${key}: ${policy}`);
69
+ const expected = HeaderValue.String(policy);
70
+ for (const entry of extraOptions) {
71
+ if (entry.key === key && (entry.value.kind !== expected.kind || entry.value.value !== expected.value))
72
+ throw new Error(`Conflicting ${key}`);
73
+ }
74
+ options.push({ key, value: expected });
75
+ }
67
76
  if (messagesRequiredToSave !== undefined)
68
77
  options.push({
69
78
  key: 'messages_required_to_save',
@@ -16,6 +16,7 @@
16
16
  // under the License.
17
17
  import { describe, it } from 'node:test';
18
18
  import assert from 'node:assert/strict';
19
+ import { Durability } from './topic.utils.js';
19
20
  import { CREATE_TOPIC } from './create-topic.command.js';
20
21
  import { deserializeOptions } from '../options.utils.js';
21
22
  import { HeaderValue } from '../message/header.utils.js';
@@ -32,17 +33,19 @@ describe('CreateTopic', () => {
32
33
  // TLV field: [kind:u8][len:u32_le][bytes]
33
34
  const tlvSize = (bytes) => 1 + 4 + bytes;
34
35
  const identifierSize = 1 + 1 + 4; // numeric stream id
36
+ const defaultPolicySize = tlvSize('durability'.length) + tlvSize('replicated'.length)
37
+ + tlvSize('consumer_offset_durability'.length) + tlvSize('replicated'.length);
35
38
  const fixedSize = identifierSize + 4 + 1; // + partitions_count + name_len
36
39
  it('serialize name and default options into buffer', () => {
37
- // Server-default sentinels are omitted, leaving an empty options block.
38
- assert.deepEqual(CREATE_TOPIC.serialize(t1).length, fixedSize + t1.name.length);
40
+ // Durability defaults are explicit, while the other sentinels are omitted.
41
+ assert.deepEqual(CREATE_TOPIC.serialize(t1).length, fixedSize + t1.name.length + defaultPolicySize);
39
42
  });
40
43
  it('serialize partitionCount as a fixed u32 before the name', () => {
41
44
  const t = { ...t1, partitionCount: 7 };
42
45
  const b = CREATE_TOPIC.serialize(t);
43
46
  assert.equal(b.readUInt32LE(identifierSize), 7);
44
47
  assert.equal(b.readUInt8(identifierSize + 4), t.name.length);
45
- assert.equal(b.subarray(fixedSize).toString(), t.name);
48
+ assert.equal(b.subarray(fixedSize, fixedSize + t.name.length).toString(), t.name);
46
49
  });
47
50
  it('serialize non-default options into buffer', () => {
48
51
  const t = {
@@ -51,7 +54,7 @@ describe('CreateTopic', () => {
51
54
  messageExpiry: 42n,
52
55
  maxTopicSize: 1024n
53
56
  };
54
- assert.deepEqual(CREATE_TOPIC.serialize(t).length, fixedSize + t1.name.length
57
+ assert.deepEqual(CREATE_TOPIC.serialize(t).length, fixedSize + t1.name.length + defaultPolicySize
55
58
  + tlvSize('compression_algorithm'.length) + tlvSize('gzip'.length)
56
59
  + tlvSize('message_expiry'.length) + tlvSize(8)
57
60
  + tlvSize('max_topic_size'.length) + tlvSize(8));
@@ -60,14 +63,14 @@ describe('CreateTopic', () => {
60
63
  const t = {
61
64
  ...t1,
62
65
  segmentSize: 1048576n,
63
- enforceFsync: true,
66
+ durability: Durability.Persisted,
64
67
  messagesRequiredToSave: 1000,
65
68
  sizeOfMessagesRequiredToSave: 4096n,
66
69
  preallocateSegments: false
67
70
  };
68
- assert.deepEqual(CREATE_TOPIC.serialize(t).length, fixedSize + t1.name.length
71
+ assert.deepEqual(CREATE_TOPIC.serialize(t).length, fixedSize + t1.name.length + defaultPolicySize
69
72
  + tlvSize('segment_size'.length) + tlvSize(8)
70
- + tlvSize('enforce_fsync'.length) + tlvSize(1)
73
+ + 'persisted'.length - 'replicated'.length
71
74
  + tlvSize('messages_required_to_save'.length) + tlvSize(4)
72
75
  + tlvSize('size_of_messages_required_to_save'.length) + tlvSize(8)
73
76
  + tlvSize('preallocate_segments'.length) + tlvSize(1));
@@ -77,7 +80,7 @@ describe('CreateTopic', () => {
77
80
  ...t1,
78
81
  maxTopicSize: 4096n,
79
82
  options: [
80
- { key: 'enforce_fsync', value: HeaderValue.Bool(true) },
83
+ { key: 'preallocate_segments', value: HeaderValue.Bool(true) },
81
84
  // The typed field covers this key, so the caller's entry is dropped:
82
85
  // a duplicate key makes the server refuse the whole block.
83
86
  { key: 'max_topic_size', value: HeaderValue.String('1 GiB') }
@@ -86,10 +89,27 @@ describe('CreateTopic', () => {
86
89
  const b = CREATE_TOPIC.serialize(t);
87
90
  // The create payload runs its options block to the end, unprefixed.
88
91
  const options = deserializeOptions(b, fixedSize + t.name.length);
89
- assert.deepEqual(Object.keys(options).sort(), ['enforce_fsync', 'max_topic_size']);
90
- assert.equal(options.enforce_fsync, true);
92
+ assert.deepEqual(Object.keys(options).sort(), ['consumer_offset_durability', 'durability', 'max_topic_size', 'preallocate_segments']);
93
+ assert.equal(options.preallocate_segments, true);
91
94
  assert.equal(options.max_topic_size, 4096n);
92
95
  });
96
+ it('keeps each omitted durability policy replicated', () => {
97
+ for (const selected of [
98
+ { durability: Durability.Persisted },
99
+ { consumerOffsetDurability: Durability.Persisted }
100
+ ]) {
101
+ const input = { ...t1, ...selected };
102
+ const encoded = CREATE_TOPIC.serialize(input);
103
+ const options = deserializeOptions(encoded, fixedSize + input.name.length);
104
+ assert.equal(options.durability, selected.durability ?? 'replicated');
105
+ assert.equal(options.consumer_offset_durability, selected.consumerOffsetDurability ?? 'replicated');
106
+ }
107
+ });
108
+ it('rejects a conflicting raw durability instead of weakening it', () => {
109
+ assert.throws(() => CREATE_TOPIC.serialize({ ...t1, options: [
110
+ { key: 'durability', value: HeaderValue.String('persisted') }
111
+ ] }));
112
+ });
93
113
  it('throw on name < 1', () => {
94
114
  const t = { ...t1, name: '' };
95
115
  assert.throws(() => CREATE_TOPIC.serialize(t));
@@ -5,5 +5,5 @@ export * from './get-topics.command.js';
5
5
  export * from './purge-topic.command.js';
6
6
  export * from './update-topic.command.js';
7
7
  export * from './ensure-topic.virtual.command.js';
8
- export { CompressionAlgorithm } from './topic.utils.js';
8
+ export { CompressionAlgorithm, Durability } from './topic.utils.js';
9
9
  //# sourceMappingURL=index.d.ts.map
@@ -21,5 +21,5 @@ export * from './get-topics.command.js';
21
21
  export * from './purge-topic.command.js';
22
22
  export * from './update-topic.command.js';
23
23
  export * from './ensure-topic.virtual.command.js';
24
- export { CompressionAlgorithm } from './topic.utils.js';
24
+ export { CompressionAlgorithm, Durability } from './topic.utils.js';
25
25
  //# sourceMappingURL=index.js.map
@@ -138,5 +138,10 @@ export declare const deserializeTopic: (p: Buffer, pos?: number) => TopicSeriali
138
138
  * @returns Array of deserialized topics
139
139
  */
140
140
  export declare const deserializeTopics: (p: Buffer, pos?: number) => Topic[];
141
+ export declare const Durability: {
142
+ readonly Replicated: "replicated";
143
+ readonly Persisted: "persisted";
144
+ };
145
+ export type Durability = typeof Durability[keyof typeof Durability];
141
146
  export {};
142
147
  //# sourceMappingURL=topic.utils.d.ts.map
@@ -146,4 +146,8 @@ export const deserializeTopics = (p, pos = 0) => {
146
146
  }
147
147
  return topics;
148
148
  };
149
+ export const Durability = {
150
+ Replicated: 'replicated',
151
+ Persisted: 'persisted'
152
+ };
149
153
  //# sourceMappingURL=topic.utils.js.map
@@ -86,8 +86,10 @@ export type RequestHeaderFields = {
86
86
  };
87
87
  /**
88
88
  * Encodes a 256-byte request header. Only the six fields the server reads
89
- * are written; the checksums stay zero, matching the Rust SDK's contract
90
- * with the VSR server.
89
+ * are written. The checksums stay zero: the frame and body checksums are not
90
+ * read on the client request path, and `request_checksum` treats zero as
91
+ * unstamped, which opts out of the server's payload comparison. Stamping it is
92
+ * optional -- the Rust SDK does for deduped ops, this SDK does not yet.
91
93
  */
92
94
  export declare const encodeRequestHeader: (fields: RequestHeaderFields) => Buffer;
93
95
  /**
@@ -88,8 +88,10 @@ export const EvictionReason = {
88
88
  const U64_MASK = 0xffffffffffffffffn;
89
89
  /**
90
90
  * Encodes a 256-byte request header. Only the six fields the server reads
91
- * are written; the checksums stay zero, matching the Rust SDK's contract
92
- * with the VSR server.
91
+ * are written. The checksums stay zero: the frame and body checksums are not
92
+ * read on the client request path, and `request_checksum` treats zero as
93
+ * unstamped, which opts out of the server's payload comparison. Stamping it is
94
+ * optional -- the Rust SDK does for deduped ops, this SDK does not yet.
93
95
  */
94
96
  export const encodeRequestHeader = (fields) => {
95
97
  const header = Buffer.alloc(HEADER_SIZE);
@@ -18,7 +18,7 @@ import { createRequire } from 'node:module';
18
18
  import { COMMAND_CODE } from '../command.code.js';
19
19
  import { responseError } from '../error.utils.js';
20
20
  import { HEADER_SIZE, encodeRequestHeader } from './header.js';
21
- import { Operation, isPartition, operationForCode, } from './operation.js';
21
+ import { Operation, operationForCode } from './operation.js';
22
22
  import { deserializeLoginRegister, serializeLoginRegister, serializeLoginRegisterWithPat, } from './register.js';
23
23
  import { decodeResponse } from './reply.js';
24
24
  import { ConsensusSession } from './session.js';
@@ -61,9 +61,10 @@ export class VsrSession {
61
61
  else {
62
62
  if (this.state.session === null)
63
63
  throw responseError(command, UNAUTHENTICATED);
64
- request = isPartition(operation)
65
- ? this.state.currentRequestId()
66
- : this.state.nextRequestId();
64
+ // Partition ops consume an id too, even though no partition-plane dedup
65
+ // exists yet: dedup needs each send to carry a distinct number, and the
66
+ // metadata watermark tolerates the gaps.
67
+ request = this.state.nextRequestId();
67
68
  session = this.state.session;
68
69
  }
69
70
  const header = encodeRequestHeader({
@@ -50,8 +50,6 @@ export declare const isInternal: (operation: number) => boolean;
50
50
  * `Operation::is_metadata`.
51
51
  */
52
52
  export declare const isMetadata: (operation: number) => boolean;
53
- /** Partition band is a bare range, mirroring `Operation::is_partition`. */
54
- export declare const isPartition: (operation: number) => boolean;
55
53
  /** Whether a reply body leads with a committed result section. */
56
54
  export declare const isResultFramed: (operation: number) => boolean;
57
55
  /**
@@ -59,7 +59,6 @@ export const Operation = {
59
59
  };
60
60
  const INTERNAL_START = 64;
61
61
  const METADATA_START = 128;
62
- const PARTITION_START = 160;
63
62
  /**
64
63
  * Replicated command code to `Operation` mapping, the client half of the
65
64
  * dispatch table. Codes absent here are sent as non-replicated so the server
@@ -115,8 +114,6 @@ export const isMetadata = (operation) => {
115
114
  return operation >= METADATA_START &&
116
115
  operation <= Operation.LeaveConsumerGroup;
117
116
  };
118
- /** Partition band is a bare range, mirroring `Operation::is_partition`. */
119
- export const isPartition = (operation) => operation >= PARTITION_START;
120
117
  /** Whether a reply body leads with a committed result section. */
121
118
  export const isResultFramed = (operation) => isMetadata(operation) ||
122
119
  operation === Operation.StoreConsumerOffset ||
@@ -17,7 +17,7 @@
17
17
  import assert from 'node:assert/strict';
18
18
  import { describe, it } from 'node:test';
19
19
  import { COMMAND_CODE } from '../command.code.js';
20
- import { isInternal, isKnownOperation, isMetadata, isPartition, isResultFramed, Operation, operationForCode } from './operation.js';
20
+ import { isInternal, isKnownOperation, isMetadata, isResultFramed, Operation, operationForCode } from './operation.js';
21
21
  const replicated = new Map([
22
22
  [COMMAND_CODE.CreateStream, Operation.CreateStream],
23
23
  [COMMAND_CODE.UpdateStream, Operation.UpdateStream],
@@ -72,8 +72,6 @@ describe('VSR operation classification', () => {
72
72
  assert.equal(isMetadata(Operation.LeaveConsumerGroup), true);
73
73
  assert.equal(isMetadata(Operation.DeleteSegments), false);
74
74
  assert.equal(isMetadata(150), false);
75
- assert.equal(isPartition(Operation.SendMessages), true);
76
- assert.equal(isPartition(159), false);
77
75
  assert.equal(isResultFramed(Operation.StoreConsumerOffset), true);
78
76
  assert.equal(isResultFramed(Operation.DeleteConsumerOffset), true);
79
77
  assert.equal(isResultFramed(Operation.SendMessages), false);
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Each client instance generates an ephemeral random `clientId` (u128).
5
5
  * After a Register commits, the server assigns a `session` number (commit op
6
- * number). Replicated metadata requests advance a monotonic request watermark.
7
- * Non-replicated and partition-plane requests reuse the current value because
8
- * the server only applies request sequencing to replicated metadata.
6
+ * number). Every replicated request (metadata and partition) advances a
7
+ * monotonic request watermark; non-replicated requests reuse the current
8
+ * value because they bypass server-side request sequencing.
9
9
  */
10
10
  export declare class ConsensusSession {
11
11
  private _clientId;
@@ -21,9 +21,9 @@ const MAX_U64 = 0xffffffffffffffffn;
21
21
  *
22
22
  * Each client instance generates an ephemeral random `clientId` (u128).
23
23
  * After a Register commits, the server assigns a `session` number (commit op
24
- * number). Replicated metadata requests advance a monotonic request watermark.
25
- * Non-replicated and partition-plane requests reuse the current value because
26
- * the server only applies request sequencing to replicated metadata.
24
+ * number). Every replicated request (metadata and partition) advances a
25
+ * monotonic request watermark; non-replicated requests reuse the current
26
+ * value because they bypass server-side request sequencing.
27
27
  */
28
28
  export class ConsensusSession {
29
29
  _clientId;
@@ -48,17 +48,28 @@ describe('VSR custom request framing', () => {
48
48
  const frame = new VsrSession(7n).encode(prepared.command, prepared.payload);
49
49
  assert.equal(frame.readUInt8(REQUEST_OFFSET.operation), Operation.Register);
50
50
  });
51
- it('does not advance for non-replicated or partition operations', () => {
51
+ it('does not advance for non-replicated operations', () => {
52
52
  const session = new VsrSession(7n);
53
53
  session.bind(42n);
54
54
  const custom = session.encode(60_001, Buffer.alloc(0));
55
55
  assert.equal(custom.readBigUInt64LE(REQUEST_OFFSET.request), 1n);
56
- const partition = session.encode(COMMAND_CODE.SendMessages, serializeSendMessages(1, 2, [{ payload: 'x' }], Partitioning.PartitionId(3)));
57
- assert.equal(partition.readUInt8(REQUEST_OFFSET.operation), Operation.SendMessages);
58
- assert.equal(partition.readBigUInt64LE(REQUEST_OFFSET.request), 1n);
59
56
  const metadata = session.encode(COMMAND_CODE.CreateStream, Buffer.alloc(0));
60
57
  assert.equal(metadata.readBigUInt64LE(REQUEST_OFFSET.request), 1n);
61
58
  assert.equal(metadata.length, HEADER_SIZE);
62
59
  });
60
+ it('partition operations consume a distinct id per send', () => {
61
+ // Dedup identity requires each send to carry a distinct number, so
62
+ // partition ops advance the counter exactly like metadata ops and the
63
+ // two planes interleave on one sequence.
64
+ const session = new VsrSession(7n);
65
+ session.bind(42n);
66
+ const sendMessages = () => session.encode(COMMAND_CODE.SendMessages, serializeSendMessages(1, 2, [{ payload: 'x' }], Partitioning.PartitionId(3)));
67
+ const first = sendMessages();
68
+ assert.equal(first.readUInt8(REQUEST_OFFSET.operation), Operation.SendMessages);
69
+ assert.equal(first.readBigUInt64LE(REQUEST_OFFSET.request), 1n);
70
+ assert.equal(sendMessages().readBigUInt64LE(REQUEST_OFFSET.request), 2n);
71
+ const metadata = session.encode(COMMAND_CODE.CreateStream, Buffer.alloc(0));
72
+ assert.equal(metadata.readBigUInt64LE(REQUEST_OFFSET.request), 3n);
73
+ });
63
74
  });
64
75
  //# sourceMappingURL=vsr.test.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apache-iggy",
3
3
  "type": "module",
4
- "version": "0.10.0-edge.5",
4
+ "version": "0.10.0-edge.7",
5
5
  "description": "Official Apache Iggy NodeJS SDK",
6
6
  "keywords": [
7
7
  "iggy",
@@ -61,7 +61,7 @@
61
61
  "@cucumber/cucumber": "13.2.1",
62
62
  "@swc-node/register": "1.12.1",
63
63
  "@types/debug": "4.1.13",
64
- "@types/node": "26.2.0",
64
+ "@types/node": "26.4.0",
65
65
  "c8": "^12.0.0",
66
66
  "husky": "9.1.7",
67
67
  "typescript": "6.0.3",