blob: 8ed7c0b28960cdc7cf910df1d6b63699c75e559a [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 { uuidv4 } from 'uuidv7';
import { uint32ToBuf, u128ToBuf, uint8ToBuf } from '../number.utils.js';
import { serializeHeaders, type Headers } from './header.utils.js';
import { serializeIdentifier, type Id } from '../identifier.utils.js';
import { serializePartitioning, type Partitioning } from './partitioning.utils.js';
import { parse as parseUUID } from '../uuid.utils.js';
import {
BATCH_HEADER_SIZE,
FRAME_HEADER_SIZE,
batchChecksum,
frameChecksum,
serializeBatchHeader,
} from './iggy-header.utils.js';
/** Size of the message ID in bytes (u128) */
const MESSAGE_ID_SIZE = 16;
/** Largest representable frame timestamp delta (u32, microseconds) */
const MAX_TIMESTAMP_DELTA = 0xFFFF_FFFFn;
/** Valid types for message ID: numeric, bigint, or UUID string */
export type MessageIdKind = number | bigint | string;
/**
* Message creation parameters.
*/
export type CreateMessage = {
/** Optional message ID (auto-generated if not provided) */
id?: MessageIdKind,
/** Optional user-defined headers */
headers?: Headers,
/** Message payload as string or Buffer */
payload: string | Buffer,
/** Optional origin timestamp in microseconds (defaults to send time) */
originTimestamp?: bigint
};
/**
* A message prepared for batch encoding.
*/
export type MessageToEncode = {
/** Message ID as a 16-byte little-endian buffer */
id: Buffer,
/** Message payload */
payload: Buffer,
/** Serialized user headers */
userHeaders: Buffer,
/** Origin timestamp in microseconds */
originTimestamp: bigint
};
/**
* Type guard to check if a value is a valid message ID.
*
* @param x - Value to check
* @returns True if the value is a valid MessageIdKind
*/
export const isValidMessageId = (x?: unknown): x is MessageIdKind =>
x === undefined ||
'string' === typeof x ||
'bigint' === typeof x ||
'number' === typeof x;
/**
* Serializes a message ID to a 16-byte little-endian buffer.
* Supports undefined (zero), numeric, bigint, and UUID string formats.
*
* @param id - Message ID to serialize
* @returns 16-byte buffer containing the serialized ID
* @throws Error if the ID format is invalid
*/
export const serializeMessageId = (id?: unknown) => {
if(!isValidMessageId(id))
throw new Error(`invalid message id: '${id}' (use uuid string | number | bigint >= 0)`)
if(id === undefined)
return Buffer.alloc(MESSAGE_ID_SIZE, 0); // 0u128
if ('bigint' === typeof id || 'number' === typeof id) {
if (id < 0)
throw new Error(`invalid message id: '${id}' (numeric id must be >= 0)`)
const idValue = 'number' === typeof id ? BigInt(id) : id;
return u128ToBuf(idValue);
}
try {
const uuid = parseUUID(id);
return u128ToBuf(BigInt(`0x${uuid.toHex()}`));
} catch (err) {
throw new Error(
`invalid message id: '${id}' (use uuid string | number | bigint >= 0)`,
{ cause: err }
)
}
}
/**
* Serializes a message ID, minting a random UUID when the ID is
* absent or zero.
*
* @param id - Optional message ID
* @returns 16-byte little-endian buffer containing a non-zero ID
*/
const resolveMessageId = (id?: MessageIdKind): Buffer => {
const bId = serializeMessageId(id);
return bId.every((byte) => byte === 0)
? u128ToBuf(BigInt(`0x${uuidv4().replaceAll('-', '')}`))
: bId;
};
/**
* Serializes a single message frame.
* Format: [frame header][payload][user headers]
*
* @param message - Message to serialize
* @param index - Index of the message within the batch
* @param batchOriginTimestamp - Origin timestamp of the batch in microseconds
* @returns Serialized frame buffer
* @throws Error if the timestamp delta exceeds u32
*/
const serializeMessageFrame = (
{ id, payload, userHeaders, originTimestamp }: MessageToEncode,
index: number,
batchOriginTimestamp: bigint
): Buffer => {
if (id.length !== MESSAGE_ID_SIZE)
throw new Error(
`invalid message id length: ${id.length}, expected ${MESSAGE_ID_SIZE}`
);
const timestampDelta = originTimestamp - batchOriginTimestamp;
if (timestampDelta > MAX_TIMESTAMP_DELTA)
throw new Error(
`message timestamp delta ${timestampDelta} exceeds u32 range`
);
const frame = Buffer.alloc(
FRAME_HEADER_SIZE + payload.length + userHeaders.length
);
id.copy(frame, 8);
frame.writeUInt32LE(index, 24);
frame.writeUInt32LE(Number(timestampDelta), 28);
frame.writeUInt32LE(userHeaders.length, 32);
frame.writeUInt32LE(payload.length, 36);
payload.copy(frame, FRAME_HEADER_SIZE);
userHeaders.copy(frame, FRAME_HEADER_SIZE + payload.length);
frame.writeBigUInt64LE(frameChecksum(frame), 0);
return frame;
};
/**
* Encodes messages into the canonical batch format.
* Format: [batch header][frames], one frame per message.
*
* @param messages - Messages to encode
* @returns Serialized batch buffer
* @throws Error if the batch is empty
*/
export const encodeMessagesBatch = (messages: MessageToEncode[]): Buffer => {
if (messages.length === 0)
throw new Error('cannot encode an empty message batch');
const originTimestamp = messages.reduce(
(min, message) =>
message.originTimestamp < min ? message.originTimestamp : min,
messages[0].originTimestamp
);
const frames = messages.map((message, index) =>
serializeMessageFrame(message, index, originTimestamp)
);
const framesLength = frames.reduce((sum, frame) => sum + frame.length, 0);
const batchLength = BigInt(BATCH_HEADER_SIZE + framesLength);
const header = {
partitionId: 0n,
baseOffset: 0n,
baseTimestamp: 0n,
originTimestamp,
batchLength,
};
return Buffer.concat([
serializeBatchHeader({
...header,
batchChecksum: batchChecksum(
header,
frames.map((frame) => frame.readBigUInt64LE(0))
),
messageCount: messages.length,
}),
...frames,
]);
};
/**
* Serializes a send messages command payload.
* Format: [metadata length][stream id][topic id][partitioning]
* [messages count][batch].
*
* @param streamId - Stream identifier
* @param topicId - Topic identifier
* @param messages - Array of messages to send
* @param partitioning - Optional partitioning strategy
* @returns Serialized command payload
* @throws Error if the message array is empty
*/
export const serializeSendMessages = (
streamId: Id,
topicId: Id,
messages: CreateMessage[],
partitioning?: Partitioning,
) => {
if (messages.length === 0)
throw new Error('cannot send an empty message batch');
const streamIdentifier = serializeIdentifier(streamId);
const topicIdentifier = serializeIdentifier(topicId);
const bPartitioning = serializePartitioning(partitioning);
const bMessagesCount = uint32ToBuf(messages.length);
const bMetadataLen = uint32ToBuf(
streamIdentifier.length + topicIdentifier.length +
bPartitioning.length + bMessagesCount.length
);
const sendTimestamp = BigInt(Date.now()) * 1000n;
const bBatch = encodeMessagesBatch(messages.map(
({ id, headers, payload, originTimestamp }) => ({
id: resolveMessageId(id),
payload: 'string' === typeof payload ? Buffer.from(payload) : payload,
userHeaders: serializeHeaders(headers),
originTimestamp: originTimestamp ?? sendTimestamp
})
));
return Buffer.concat([
bMetadataLen,
streamIdentifier,
topicIdentifier,
bPartitioning,
bMessagesCount,
bBatch
]);
};
/**
* Serializes a flush unsaved buffers command payload.
*
* @param streamId - Stream identifier
* @param topicId - Topic identifier
* @param partitionId - Partition ID to flush
* @param fsync - Whether to force sync to disk
* @returns Serialized command payload
*/
export const serializeFlushUnsavedBuffers = (
streamId: Id,
topicId: Id,
partitionId: number,
fsync = false
) => {
const streamIdentifier = serializeIdentifier(streamId);
const topicIdentifier = serializeIdentifier(topicId);
const bPartitionId = uint32ToBuf(partitionId);
const bFSync = uint8ToBuf(fsync ? 1 : 0);
return Buffer.concat([
streamIdentifier,
topicIdentifier,
bPartitionId,
bFSync
]);
};