blob: 274b8e578d9f9f5e36ad355dc0afbf16fc2c69ef [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { describe, it } from 'node:test';
import type {
CommandResponse,
RawClient
} from '../../client/client.type.js';
import { COMMAND_CODE } from '../command.code.js';
import { ResponseError } from '../error.utils.js';
import { Consumer } from '../offset/offset.utils.js';
import {
PollingStrategy
} from './poll.utils.js';
import {
NO_ASSIGNED_PARTITION,
pollMessages,
type PollMessages
} from './poll-messages.command.js';
const response = (data: Buffer): CommandResponse => ({
status: 0,
length: data.length,
data
});
const assignment = (
generation: bigint,
partitions: number[]
): CommandResponse => {
const data = Buffer.alloc(12 + partitions.length * 4);
data.writeBigUInt64LE(generation, 0);
data.writeUInt32LE(partitions.length, 8);
partitions.forEach((partition, index) => {
data.writeUInt32LE(partition, 12 + index * 4);
});
return response(data);
};
const pollResponse = (
partitionId: number,
count = 0
): CommandResponse => {
const data = Buffer.alloc(16);
data.writeUInt32LE(partitionId, 0);
data.writeBigUInt64LE(0n, 4);
data.writeUInt32LE(count, 12);
return response(data);
};
const groupRequest: PollMessages = {
streamId: 1,
topicId: 2,
consumer: Consumer.Group(3),
partitionId: null,
pollingStrategy: PollingStrategy.Next,
count: 10,
autocommit: false
};
const stubClient = (
responses: (CommandResponse | Error)[],
onCommand?: (command: number, emitter: EventEmitter) => void,
): {
client: RawClient,
emitter: EventEmitter,
commands: { command: number, payload: Buffer }[]
} => {
const emitter = new EventEmitter();
const commands: { command: number, payload: Buffer }[] = [];
const client = {
protocol: 'vsr',
isAuthenticated: true,
sendCommand: async (command: number, payload: Buffer) => {
commands.push({ command, payload });
onCommand?.(command, emitter);
const next = responses.shift();
if (!next)
throw new Error('unexpected command');
if (next instanceof Error)
throw next;
return next;
},
authenticate: async () => true,
destroy: () => {},
on: emitter.on.bind(emitter),
once: emitter.once.bind(emitter),
getReadStream: () => Readable.from([])
} as RawClient;
return { client, emitter, commands };
};
describe('VSR consumer-group polling', () => {
it('rejects a missing assignment', async () => {
const { client } = stubClient([
response(Buffer.alloc(0)),
response(Buffer.alloc(0)),
response(Buffer.alloc(0))
]);
await assert.rejects(
() => pollMessages(async () => client)(groupRequest),
(error: unknown) =>
error instanceof ResponseError &&
error.errorCode === 5006 &&
error.message.includes('message: Consumer group member with client ID:')
);
});
it('joins when the initial sync has no membership', async () => {
const { client, commands } = stubClient([
response(Buffer.alloc(0)),
response(Buffer.alloc(0)),
assignment(2n, [7]),
pollResponse(7)
]);
assert.equal(
(await pollMessages(async () => client)(groupRequest)).partitionId,
7
);
assert.deepEqual(
commands.map(({ command }) => command),
[
COMMAND_CODE.SyncGroup,
COMMAND_CODE.JoinGroup,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages
]
);
});
it('rejoins when sync reports a missing member after session reset', async () => {
const { client, emitter, commands } = stubClient([
assignment(1n, [4]),
pollResponse(4),
new ResponseError(COMMAND_CODE.SyncGroup, 5006),
response(Buffer.alloc(0)),
assignment(2n, [7]),
pollResponse(7)
]);
const poll = pollMessages(async () => client);
assert.equal((await poll(groupRequest)).partitionId, 4);
emitter.emit('sessionReset');
assert.equal((await poll(groupRequest)).partitionId, 7);
assert.deepEqual(
commands.map(({ command }) => command),
[
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.JoinGroup,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages
]
);
});
it('returns immediately for an empty assignment', async () => {
const { client, commands } = stubClient([assignment(1n, [])]);
assert.deepEqual(
await pollMessages(async () => client)(groupRequest),
{
partitionId: NO_ASSIGNED_PARTITION,
currentOffset: 0n,
count: 0,
messages: []
}
);
assert.deepEqual(
commands.map(({ command }) => command),
[COMMAND_CODE.SyncGroup]
);
});
it('caches a cursor and refreshes it after a heartbeat', async () => {
const responses = [
assignment(1n, [4, 5]),
pollResponse(4),
pollResponse(5),
assignment(2n, [8]),
pollResponse(8)
];
const { client, emitter, commands } = stubClient(responses);
const poll = pollMessages(async () => client);
assert.equal((await poll(groupRequest)).partitionId, 4);
assert.equal((await poll(groupRequest)).partitionId, 5);
emitter.emit('heartbeat');
assert.equal((await poll(groupRequest)).partitionId, 8);
assert.deepEqual(
commands
.filter(({ command }) => command === COMMAND_CODE.PollMessages)
.map(({ payload }) => payload.readUInt32LE(20)),
[4, 5, 8]
);
assert.equal(
commands.filter(
({ command }) => command === COMMAND_CODE.SyncGroup
).length,
2
);
});
it('keeps the round-robin position across a heartbeat refresh', async () => {
const { client, emitter, commands } = stubClient([
assignment(1n, [4, 5]),
pollResponse(4),
assignment(1n, [4, 5]),
pollResponse(5)
]);
const poll = pollMessages(async () => client);
assert.equal((await poll(groupRequest)).partitionId, 4);
emitter.emit('heartbeat');
assert.equal((await poll(groupRequest)).partitionId, 5);
assert.deepEqual(
commands.map(({ command }) => command),
[
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages
]
);
});
it('retries a poll interrupted by a session reset', async () => {
let resetPending = true;
const { client, commands } = stubClient(
[
assignment(1n, [4]),
new Error('connection closed while waiting for response'),
response(Buffer.alloc(0)),
response(Buffer.alloc(0)),
assignment(2n, [7]),
pollResponse(7)
],
(command, emitter) => {
if (command === COMMAND_CODE.PollMessages && resetPending) {
resetPending = false;
emitter.emit('sessionReset');
}
}
);
assert.equal(
(await pollMessages(async () => client)(groupRequest)).partitionId,
7
);
assert.deepEqual(
commands.map(({ command }) => command),
[
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.JoinGroup,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages
]
);
});
it('resynchronizes twice before returning an empty result', async () => {
const resyncPartition = 0xFFFF_FFFF;
const { client, commands } = stubClient([
assignment(1n, [1]),
pollResponse(resyncPartition),
assignment(2n, [2]),
pollResponse(resyncPartition)
]);
assert.deepEqual(
await pollMessages(async () => client)(groupRequest),
{
partitionId: NO_ASSIGNED_PARTITION,
currentOffset: 0n,
count: 0,
messages: []
}
);
assert.deepEqual(
commands
.filter(({ command }) => command === COMMAND_CODE.PollMessages)
.map(({ payload }) => payload.readUInt32LE(20)),
[1, 2]
);
});
it('rejoins when a cached poll reports a missing member', async () => {
const { client, commands } = stubClient([
assignment(1n, [4]),
new ResponseError(COMMAND_CODE.PollMessages, 5006),
new ResponseError(COMMAND_CODE.SyncGroup, 5006),
response(Buffer.alloc(0)),
assignment(2n, [7]),
pollResponse(7)
]);
assert.equal(
(await pollMessages(async () => client)(groupRequest)).partitionId,
7
);
assert.deepEqual(
commands.map(({ command }) => command),
[
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.JoinGroup,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages
]
);
});
it('resynchronizes when a cached poll reports stale ownership', async () => {
const { client, commands } = stubClient([
assignment(1n, [4]),
new ResponseError(COMMAND_CODE.PollMessages, 5009),
assignment(2n, [7]),
pollResponse(7)
]);
assert.equal(
(await pollMessages(async () => client)(groupRequest)).partitionId,
7
);
assert.deepEqual(
commands.map(({ command }) => command),
[
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages,
COMMAND_CODE.SyncGroup,
COMMAND_CODE.PollMessages
]
);
});
it('uses kind-tagged cursor keys', async () => {
const { client, commands } = stubClient([
assignment(1n, [7]),
pollResponse(7),
assignment(1n, [8]),
pollResponse(8)
]);
const poll = pollMessages(async () => client);
assert.equal((await poll(groupRequest)).partitionId, 7);
assert.equal(
(await poll({
...groupRequest,
streamId: '1',
topicId: '2',
consumer: Consumer.Group('3')
})).partitionId,
8
);
assert.equal(
commands.filter(
({ command }) => command === COMMAND_CODE.SyncGroup
).length,
2
);
});
it('refreshes an expired assignment without a heartbeat', async () => {
const realNow = Date.now;
const { client, commands } = stubClient([
assignment(1n, [7]),
pollResponse(7),
assignment(2n, [8]),
pollResponse(8)
]);
const poll = pollMessages(async () => client);
try {
Date.now = () => 0;
assert.equal((await poll(groupRequest)).partitionId, 7);
Date.now = () => 5_000;
assert.equal((await poll(groupRequest)).partitionId, 8);
assert.equal(
commands.filter(
({ command }) => command === COMMAND_CODE.SyncGroup
).length,
2
);
} finally {
Date.now = realNow;
}
});
it('holds the raw client for the complete multi-command poll', async () => {
let held = false;
const { client } = stubClient(
[assignment(1n, [7]), pollResponse(7)],
() => assert.equal(held, true)
);
client.hold = () => {
held = true;
return () => { held = false; };
};
assert.equal(
(await pollMessages(async () => client)(groupRequest)).partitionId,
7
);
assert.equal(held, false);
});
});