This is an automated email from the ASF dual-hosted git repository.
spetz pushed a commit to branch go_sdk_vsr
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/go_sdk_vsr by this push:
new 86d62de13 fix
86d62de13 is described below
commit 86d62de133b79b4c9c93c62df6df1421448dcf52
Author: spetz <[email protected]>
AuthorDate: Fri Aug 7 18:23:58 2026 +0200
fix
---
core/sdk/src/vsr.rs | 21 +-
core/server-ng/src/dispatch.rs | 43 ++-
.../binary_response_deserializer.go | 51 +++-
.../vsr_response_deserializer_test.go | 73 +++++
foreign/go/client/tcp/tcp_connect_test.go | 1 +
foreign/go/client/tcp/tcp_core.go | 168 +++++++++--
foreign/go/client/tcp/tcp_core_encode_test.go | 128 +++++++++
foreign/go/client/tcp/tcp_core_review_test.go | 317 +++++++++++++++++++++
foreign/go/client/tcp/tcp_group_polling.go | 54 ++--
foreign/go/client/tcp/tcp_messaging.go | 7 +
foreign/go/client/tcp/tcp_session_management.go | 21 +-
foreign/go/client/tcp/tcp_testing_test.go | 14 +
foreign/go/client/tcp/tcp_topic_cache.go | 9 +-
foreign/go/contracts/client.go | 4 +
foreign/go/contracts/message_header.go | 24 +-
foreign/go/contracts/partitions.go | 11 +-
foreign/go/internal/command/message.go | 162 +++++------
foreign/go/internal/command/message_test.go | 77 +++++
foreign/go/internal/util/leader_aware.go | 40 +--
foreign/go/internal/util/leader_aware_test.go | 5 +
foreign/go/internal/vsr/envelope.go | 10 +-
foreign/go/internal/vsr/header.go | 13 +
foreign/go/internal/vsr/protocol_parity_test.go | 111 +++++++-
foreign/go/internal/vsr/reply.go | 10 +-
foreign/go/internal/vsr/reply_test.go | 4 +-
foreign/go/internal/vsr/session.go | 11 +-
26 files changed, 1170 insertions(+), 219 deletions(-)
diff --git a/core/sdk/src/vsr.rs b/core/sdk/src/vsr.rs
index 9bc5d1cd1..48fd28e31 100644
--- a/core/sdk/src/vsr.rs
+++ b/core/sdk/src/vsr.rs
@@ -90,12 +90,13 @@ pub(crate) fn encode_request_header(
_ => {
let operation = operation_for_code(code);
// NonReplicated ops (ping, reads) bypass server-side dedup --
- // `ClientTable` only tracks request_ids for replicated ops. If
- // they consumed the monotonic counter, the next replicated
- // request would skip an id and the primary's `request_preflight`
- // would see a `RequestGap` and silently drop it. Read the
- // current id without advancing; the server ignores it for
- // NonReplicated.
+ // `ClientTable` only tracks request_ids for replicated ops, and
+ // the table accepts any id above the watermark with no
+ // contiguity requirement (client_table.rs: "There is no
+ // `RequestGap`"), so consuming the counter would not break the
+ // next metadata op. Read the current id without advancing
+ // because the server ignores it for NonReplicated and burning
+ // ids for requests the table never sees buys nothing.
//
// They are also sessionless on the server (routed by transport
// id; protected codes are auth-gated server-side), so send with
@@ -111,9 +112,11 @@ pub(crate) fn encode_request_header(
} else if operation.is_partition() {
// Partition ops replicate in their own per-partition group,
// which is at-least-once with no `ClientTable` dedup -- the
- // metadata table never records their request ids. Consuming
- // the counter here would gap the NEXT metadata op's id and
- // `request_preflight` would silently drop it (`RequestGap`).
+ // metadata table never records their request ids, so there
+ // is nothing for a consumed id to deduplicate against. Every
+ // partition request on a session therefore carries the id
+ // the next metadata op will claim, and a partition-plane
+ // replay is at-least-once.
let session_id =
session.session().ok_or(IggyError::Unauthenticated)?;
(operation, session.current_request_id(), session_id)
} else {
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 30f2a05d2..9114d938d 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -2758,10 +2758,27 @@ async fn handle_logout_request<B, MJ, S, SB>(
SB: SuperblockStore + 'static,
{
let Some((vsr_client_id, session)) =
sessions.borrow().get_session(transport_client_id) else {
+ // Logout on an unbound transport: the desired state already holds,
+ // so answer ok. A silent drop would wedge the lockstep SDK on this
+ // connection until its socket read timeout, and the SDK routinely
+ // sends a logout before each re-login.
warn!(
transport_client_id,
- "dropping logout for unbound VSR session"
+ "logout for unbound VSR session; answering ok"
);
+ let commit = current_metadata_commit(shard);
+ let reply = build_empty_reply(request.header(), transport_client_id,
0, commit);
+ if let Err(error) = shard
+ .bus
+ .send_to_client(transport_client_id,
reply.into_generic().into_frozen())
+ .await
+ {
+ warn!(
+ transport_client_id,
+ error = %error,
+ "failed to send unbound logout reply"
+ );
+ }
return;
};
@@ -2769,7 +2786,29 @@ async fn handle_logout_request<B, MJ, S, SB>(
let commit = match submit_logout_on_owner(shard, vsr_client_id, session,
request_id).await {
Ok(commit) => commit,
Err(error) => {
- warn!(transport_client_id, error = %error, "logout/unregister
failed");
+ // Deny as transient instead of dropping the frame: the submit
+ // usually fails because this replica is not the metadata owner
+ // right now, and the SDK replays a transient rejection.
+ warn!(transport_client_id, error = %error, "logout/unregister
failed; denying transient");
+ let commit = current_metadata_commit(shard);
+ let reply = build_deny_reply(
+ request.header(),
+ vsr_client_id,
+ session,
+ commit,
+ IggyError::TransientNotAccepted.as_code(),
+ );
+ if let Err(send_error) = shard
+ .bus
+ .send_to_client(transport_client_id,
reply.into_generic().into_frozen())
+ .await
+ {
+ warn!(
+ transport_client_id,
+ error = %send_error,
+ "failed to send logout deny reply"
+ );
+ }
return;
}
};
diff --git a/foreign/go/binary_serialization/binary_response_deserializer.go
b/foreign/go/binary_serialization/binary_response_deserializer.go
index 1f1af561d..35cf03ac9 100644
--- a/foreign/go/binary_serialization/binary_response_deserializer.go
+++ b/foreign/go/binary_serialization/binary_response_deserializer.go
@@ -129,6 +129,13 @@ func DeserializeToStream(payload []byte, position int)
(iggcon.Stream, int, erro
}, totalSize, nil
}
+// pollBatchHeaderLength covers [partition_id u32][current_offset u64][count
u32].
+const pollBatchHeaderLength = 16
+
+// DeserializeFetchMessagesResponse decodes a poll reply. A truncated body is
+// a decode error rather than a shorter batch: silently dropping the tail
+// would let a consumer that commits CurrentOffset skip messages it never saw.
+// The returned messages alias the reply buffer; a retained message pins it.
func DeserializeFetchMessagesResponse(payload []byte, compression
iggcon.IggyMessageCompression) (*iggcon.PolledMessage, error) {
if len(payload) == 0 {
return &iggcon.PolledMessage{
@@ -139,31 +146,44 @@ func DeserializeFetchMessagesResponse(payload []byte,
compression iggcon.IggyMes
}
length := len(payload)
+ if length < pollBatchHeaderLength {
+ return nil, fmt.Errorf("poll response: %d bytes is short of the
batch header", length)
+ }
partitionId := binary.LittleEndian.Uint32(payload[0:4])
currentOffset := binary.LittleEndian.Uint64(payload[4:12])
messagesCount := binary.LittleEndian.Uint32(payload[12:16])
- position := 16
- var messages = make([]iggcon.IggyMessage, 0)
+ position := pollBatchHeaderLength
+
+ // The declared count is server-controlled; the allocation hint is
capped
+ // by what the body could possibly hold.
+ maxMessages := (length - pollBatchHeaderLength) /
iggcon.MessageHeaderSize
+ if int(messagesCount) < maxMessages {
+ maxMessages = int(messagesCount)
+ }
+ messages := make([]iggcon.IggyMessage, 0, maxMessages)
for position < length {
- if position+iggcon.MessageHeaderSize >= length {
- // body needs to be at least 1 byte
- break
+ if position+iggcon.MessageHeaderSize > length {
+ return nil, fmt.Errorf("poll response: truncated
message header at byte %d", position)
}
header, err := iggcon.MessageHeaderFromBytes(payload[position :
position+iggcon.MessageHeaderSize])
if err != nil {
return nil, err
}
position += iggcon.MessageHeaderSize
- payload_end := position + int(header.PayloadLength)
- if int(payload_end) > length {
- break
+ if uint64(header.PayloadLength) > uint64(length-position) {
+ return nil, fmt.Errorf(
+ "poll response: message payload of %d bytes
overruns the body", header.PayloadLength)
}
- payloadSlice := payload[position:payload_end]
- position = int(payload_end)
+ payloadSlice := payload[position :
position+int(header.PayloadLength)]
+ position += int(header.PayloadLength)
- var user_headers []byte = nil
+ if uint64(header.UserHeaderLength) > uint64(length-position) {
+ return nil, fmt.Errorf(
+ "poll response: user headers of %d bytes
overrun the body", header.UserHeaderLength)
+ }
+ var userHeaders []byte
if header.UserHeaderLength > 0 {
- user_headers = payload[position :
position+int(header.UserHeaderLength)]
+ userHeaders = payload[position :
position+int(header.UserHeaderLength)]
}
position += int(header.UserHeaderLength)
@@ -181,9 +201,14 @@ func DeserializeFetchMessagesResponse(payload []byte,
compression iggcon.IggyMes
messages = append(messages, iggcon.IggyMessage{
Header: *header,
Payload: payloadSlice,
- UserHeaders: user_headers,
+ UserHeaders: userHeaders,
})
}
+ if uint32(len(messages)) != messagesCount {
+ return nil, fmt.Errorf(
+ "poll response: %d decoded messages do not match the
declared %d",
+ len(messages), messagesCount)
+ }
// !TODO: Add message offset ordering
return &iggcon.PolledMessage{
diff --git a/foreign/go/binary_serialization/vsr_response_deserializer_test.go
b/foreign/go/binary_serialization/vsr_response_deserializer_test.go
index d029aeca4..7a43ab14c 100644
--- a/foreign/go/binary_serialization/vsr_response_deserializer_test.go
+++ b/foreign/go/binary_serialization/vsr_response_deserializer_test.go
@@ -167,6 +167,79 @@ func
TestDeserializeConsumerGroupAssignment_DoesNotAllocateOnABogusCount(t *test
assert.Error(t, err)
}
+// pollPayload builds a poll reply body carrying the given messages.
+func pollPayload(t *testing.T, partitionId uint32, payloads ...[]byte) []byte {
+ t.Helper()
+
+ body := binary.LittleEndian.AppendUint32(nil, partitionId)
+ body = binary.LittleEndian.AppendUint64(body, 42)
+ body = binary.LittleEndian.AppendUint32(body, uint32(len(payloads)))
+ for _, payload := range payloads {
+ message, err := iggcon.NewIggyMessage(payload)
+ require.NoError(t, err)
+ headerBytes, err := message.Header.AppendBinary(nil)
+ require.NoError(t, err)
+ body = append(body, headerBytes...)
+ body = append(body, message.Payload...)
+ }
+ return body
+}
+
+func TestDeserializeFetchMessagesResponse_DecodesABatch(t *testing.T) {
+ payload := pollPayload(t, 3, []byte("first"), []byte("second"))
+
+ polled, err := DeserializeFetchMessagesResponse(payload,
iggcon.MESSAGE_COMPRESSION_NONE)
+ require.NoError(t, err)
+ assert.Equal(t, uint32(3), polled.PartitionId)
+ assert.Equal(t, uint64(42), polled.CurrentOffset)
+ require.Len(t, polled.Messages, 2)
+ assert.Equal(t, []byte("first"), polled.Messages[0].Payload)
+ assert.Equal(t, []byte("second"), polled.Messages[1].Payload)
+}
+
+func TestDeserializeFetchMessagesResponse_TreatsAnEmptyPayloadAsAnEmptyBatch(t
*testing.T) {
+ polled, err := DeserializeFetchMessagesResponse(nil,
iggcon.MESSAGE_COMPRESSION_NONE)
+ require.NoError(t, err)
+ assert.Empty(t, polled.Messages)
+}
+
+func TestDeserializeFetchMessagesResponse_RejectsEveryTruncation(t *testing.T)
{
+ payload := pollPayload(t, 1, []byte("first"), []byte("second"))
+
+ for length := 1; length < len(payload); length++ {
+ _, err := DeserializeFetchMessagesResponse(
+ payload[:length], iggcon.MESSAGE_COMPRESSION_NONE)
+ assert.Error(t, err,
+ "a reply truncated to %d bytes must not decode as a
shorter batch", length)
+ }
+}
+
+func
TestDeserializeFetchMessagesResponse_RejectsACountAboveTheDecodedMessages(t
*testing.T) {
+ payload := pollPayload(t, 1, []byte("only"))
+ binary.LittleEndian.PutUint32(payload[12:16], 5)
+
+ _, err := DeserializeFetchMessagesResponse(payload,
iggcon.MESSAGE_COMPRESSION_NONE)
+ assert.Error(t, err,
+ "a declared count the body does not satisfy must surface, not
silently shrink")
+}
+
+func TestDeserializeFetchMessagesResponse_RejectsOverrunningUserHeaders(t
*testing.T) {
+ message, err := iggcon.NewIggyMessage([]byte("payload"))
+ require.NoError(t, err)
+ message.Header.UserHeaderLength = 64
+
+ body := binary.LittleEndian.AppendUint32(nil, 1)
+ body = binary.LittleEndian.AppendUint64(body, 0)
+ body = binary.LittleEndian.AppendUint32(body, 1)
+ headerBytes, err := message.Header.AppendBinary(nil)
+ require.NoError(t, err)
+ body = append(body, headerBytes...)
+ body = append(body, message.Payload...)
+
+ _, err = DeserializeFetchMessagesResponse(body,
iggcon.MESSAGE_COMPRESSION_NONE)
+ assert.Error(t, err, "user headers past the body must not panic or
pass")
+}
+
func TestDeserializeToTopic_PinsTheFieldOrderOfTheWireLayout(t *testing.T) {
const nameOffset = 50
name := "orders"
diff --git a/foreign/go/client/tcp/tcp_connect_test.go
b/foreign/go/client/tcp/tcp_connect_test.go
index 5e090040d..5a812b725 100644
--- a/foreign/go/client/tcp/tcp_connect_test.go
+++ b/foreign/go/client/tcp/tcp_connect_test.go
@@ -127,6 +127,7 @@ func listenVSR(t *testing.T, wrap func(net.Conn) net.Conn,
if answer == nil {
return
}
+ echoReplyRequest(answer, read)
if _, err := conn.Write(answer); err !=
nil {
return
}
diff --git a/foreign/go/client/tcp/tcp_core.go
b/foreign/go/client/tcp/tcp_core.go
index 526b864e3..e8ec0e950 100644
--- a/foreign/go/client/tcp/tcp_core.go
+++ b/foreign/go/client/tcp/tcp_core.go
@@ -18,6 +18,7 @@
package tcp
import (
+ "bufio"
"context"
"crypto/tls"
"crypto/x509"
@@ -28,6 +29,7 @@ import (
"log/slog"
"net"
"os"
+ "slices"
"sync"
"time"
@@ -51,8 +53,19 @@ func GetDefaultOptions() Options {
}
type IggyTcpClient struct {
- conn net.Conn
- mtx sync.Mutex
+ conn net.Conn
+ // reader buffers reads off conn, so a reply costs one syscall instead
of
+ // one for the header and one for the body; guarded by c.mtx.
+ reader *bufio.Reader
+ mtx sync.Mutex
+ // registerMtx single-flights the sign-in transaction: BeginRegister and
+ // Bind live in different c.mtx critical sections, and two interleaved
+ // sign-ins would commit one Register whose session is never bound.
+ registerMtx sync.Mutex
+ // closed unblocks a replay wait when Close is called, which cannot go
+ // through c.mtx because the replay loop holds it.
+ closed chan struct{}
+ closeOnce sync.Once
config config
logger *slog.Logger
MessageCompression iggcon.IggyMessageCompression
@@ -69,6 +82,9 @@ type IggyTcpClient struct {
// skipAutoLoginOnce suppresses the next automatic sign-in so a replayed
// login is not preempted by one the reconnect issues; guarded by c.mtx.
skipAutoLoginOnce bool
+ // loggedOut records an explicit sign-out, so a reconnect's automatic
+ // sign-in does not silently reverse it; guarded by c.mtx.
+ loggedOut bool
// groups caches the consumer-group assignments this client polls with.
groups groupAssignmentCache
// topics caches what a send needs to resolve a partition locally.
@@ -98,7 +114,9 @@ func defaultTcpClientConfig() config {
tls: defaultTLSConfig(),
autoLogin: AutoLogin{},
reconnection: defaultTcpClientReconnectionConfig(),
- noDelay: false,
+ // The lockstep request model stalls a multi-segment frame
behind
+ // Nagle's algorithm, so coalescing is off by default.
+ noDelay: true,
}
}
@@ -221,6 +239,15 @@ func WithTLSValidateCertificate(validate bool) TLSOption {
}
}
+// WithNoDelay controls TCP_NODELAY. It defaults to true because the lockstep
+// request model stalls behind Nagle's algorithm; pass false to re-enable
+// segment coalescing for bandwidth-bound workloads.
+func WithNoDelay(noDelay bool) Option {
+ return func(opts *Options) {
+ opts.config.noDelay = noDelay
+ }
+}
+
// NewIggyTcpClient creates a new Iggy TCP client with the given options.
// warning: don't use this function directly, use iggycli.NewIggyClient with
iggycli.WithTcp instead.
func NewIggyTcpClient(logger *slog.Logger, options ...Option) *IggyTcpClient {
@@ -245,6 +272,7 @@ func NewIggyTcpClient(logger *slog.Logger, options
...Option) *IggyTcpClient {
leaderRedirectionState: iggcon.LeaderRedirectionState{},
currentServerAddress: opts.config.serverAddress,
session: vsr.NewSession(),
+ closed: make(chan struct{}),
}
}
@@ -268,14 +296,12 @@ const (
failoverCheckInterval = 2 * time.Second
)
-// requestPrologue is the zeroed space a frame reserves ahead of its payload.
-// The header is stamped into it once the payload length is known.
-var requestPrologue [vsr.HeaderSize]byte
-
-// requestBufPool reuses wire-payload buffers across RPCs.
+// requestBufPool reuses wire-payload buffers across RPCs. A fresh buffer
+// already holds the header plus room for a small payload, so a metadata
+// command does not reallocate on its first byte.
var requestBufPool = sync.Pool{
New: func() any {
- b := make([]byte, 0, 256)
+ b := make([]byte, 0, vsr.HeaderSize+512)
return &b
},
}
@@ -285,7 +311,10 @@ func acquireRequestBuf() *[]byte {
}
func releaseRequestBuf(bp *[]byte) {
- const maxPooled = 64 * 1024
+ // Producer batches routinely grow to megabytes and reusing them is the
+ // point of the pool; the ceiling only stops a pathological frame from
+ // pinning memory for the process lifetime.
+ const maxPooled = 4 * 1024 * 1024
if cap(*bp) > maxPooled {
return
}
@@ -302,8 +331,12 @@ func (c *IggyTcpClient) read(expectedSize int) (int,
[]byte, error) {
return n, buffer, nil
}
-// readInto reads exactly len(buf) bytes from the connection into buf.
+// readInto reads exactly len(buf) bytes from the connection into buf, through
+// the buffered reader when the connect flow installed one.
func (c *IggyTcpClient) readInto(buf []byte) (int, error) {
+ if c.reader != nil {
+ return io.ReadFull(c.reader, buf)
+ }
var totalRead int
expected := len(buf)
for totalRead < expected {
@@ -342,10 +375,14 @@ func (c *IggyTcpClient) do(ctx context.Context, cmd
command.Command) ([]byte, er
defer releaseRequestBuf(bp)
frame, err := appendCommandFrame(*bp, cmd)
+ if frame != nil {
+ // Keep the grown buffer even when the encode failed, so the
pool gets
+ // it back at its new capacity.
+ *bp = frame
+ }
if err != nil {
return nil, err
}
- *bp = frame
return c.exchange(ctx, uint32(cmd.Code()), frame)
}
@@ -360,12 +397,20 @@ func (c *IggyTcpClient) SendBinaryRequest(ctx
context.Context, code uint32, payl
bp := acquireRequestBuf()
defer releaseRequestBuf(bp)
- frame := append(append((*bp)[:0], requestPrologue[:]...), payload...)
+ frame := append(reserveHeader(*bp), payload...)
*bp = frame
return c.exchange(ctx, code, frame)
}
+// reserveHeader returns buf truncated to exactly the header prologue, growing
+// it as needed. The prologue bytes are not zeroed here: EncodeRequestHeader
+// clears the full header when the frame is stamped, and zeroing twice would
+// write four extra cache lines per request.
+func reserveHeader(buf []byte) []byte {
+ return slices.Grow(buf[:0], vsr.HeaderSize)[:vsr.HeaderSize]
+}
+
func isSessionControlCode(code uint32) bool {
switch code {
case uint32(command.LoginUserCode),
@@ -386,16 +431,18 @@ func isRegisterCode(code uint32) bool {
}
// appendCommandFrame reserves the request prologue in buf and appends the
-// encoded command payload after it, so the frame is a single allocation the
-// header can be stamped into once the payload length is known.
+// encoded command payload after it. A command implementing
+// encoding.BinaryAppender encodes straight into the buffer, keeping the frame
+// a single allocation; the MarshalBinary fallback pays one extra body
+// allocation and copy.
func appendCommandFrame(buf []byte, cmd command.Command) ([]byte, error) {
- buf = append(buf[:0], requestPrologue[:]...)
+ buf = reserveHeader(buf)
if appender, ok := cmd.(encoding.BinaryAppender); ok {
return appender.AppendBinary(buf)
}
body, err := cmd.MarshalBinary()
if err != nil {
- return nil, err
+ return buf, err
}
return append(buf, body...), nil
}
@@ -405,6 +452,14 @@ func appendCommandFrame(buf []byte, cmd command.Command)
([]byte, error) {
// the stack, and re-entering it has no depth bound.
type connectScoped struct{}
+// localPreconditionError marks a request that failed before its frame was
+// written. The connection is healthy, so exchange must not tear it down and
+// re-dial over what is purely local state.
+type localPreconditionError struct{ err error }
+
+func (e *localPreconditionError) Error() string { return e.err.Error() }
+func (e *localPreconditionError) Unwrap() error { return e.err }
+
// exchange runs one request to completion, reconnecting and replaying it when
// the failure is one a fresh connection recovers from.
func (c *IggyTcpClient) exchange(ctx context.Context, code uint32, frame
[]byte) ([]byte, error) {
@@ -412,6 +467,10 @@ func (c *IggyTcpClient) exchange(ctx context.Context, code
uint32, frame []byte)
if err == nil || !isReconnectable(err) {
return response, err
}
+ var precondition *localPreconditionError
+ if errors.As(err, &precondition) {
+ return nil, err
+ }
if ctx.Value(connectScoped{}) != nil {
return nil, err
}
@@ -428,6 +487,14 @@ func (c *IggyTcpClient) exchange(ctx context.Context, code
uint32, frame []byte)
if !c.config.autoLogin.enabled && !login {
return nil, err
}
+ c.mtx.Lock()
+ loggedOut := c.loggedOut
+ c.mtx.Unlock()
+ if loggedOut && !login {
+ // An explicit sign-out stays signed out: the reconnect's
automatic
+ // sign-in would silently reverse it.
+ return nil, err
+ }
if !canReplay(code, err) {
c.logger.Warn("Not replaying a replicated request with an
unknown outcome.",
slog.Int("code", int(code)), slog.Any("error", err))
@@ -588,8 +655,10 @@ func (c *IggyTcpClient) attempt(
if !stamped {
// Stamp once per session: the header consumes a request id,
and a
// replay must carry the same one for the server to deduplicate
it.
+ // A stamp failure is local and pre-write, so it is marked as
such:
+ // the connection is healthy and must not be torn down for it.
if err := vsr.StampRequestHeader(c.session, code, frame); err
!= nil {
- return nil, false, err
+ return nil, false, &localPreconditionError{err}
}
stamped = true
}
@@ -642,26 +711,44 @@ func (c *IggyTcpClient) exchangeLocked(
c.logger.Debug("Sending a TCP request",
slog.Int("frame_length", len(frame)), slog.Int("code",
int(code)))
if _, err := c.write(frame); err != nil {
+ // Normalized like the read failures below, so callers
can match
+ // a dropped connection with one sentinel in both
directions.
+ c.logger.Error("Failed to write the request frame",
+ slog.Int("code", int(code)), slog.Any("error",
err))
c.invalidateConnLocked()
- return nil, err
+ return nil, ierror.ErrDisconnected
}
body, err := c.readReplyLocked(code)
if err != nil {
return nil, err
}
+ if vsr.PeekCommand(&c.respHeader) == vsr.FrameReply {
+ // A reply must answer the request in flight. An
unexpected echo
+ // means the stream is delivering some other request's
answer, and
+ // every later reply would pair off by one.
+ expected := vsr.StampedRequestID(frame)
+ if echoed := vsr.ReadReplyRequestID(&c.respHeader);
echoed != expected {
+ c.logger.Error("The reply answers a different
request",
+ slog.Uint64("expected_request",
expected),
+ slog.Uint64("echoed_request", echoed))
+ c.invalidateConnLocked()
+ return nil, ierror.ErrDisconnected
+ }
+ }
response, err := vsr.DecodeReply(&c.respHeader, body)
switch {
case errors.Is(err, ierror.ErrTransientNotCommitted) &&
time.Now().Before(readDeadline):
// The outcome is unknown, so only a replay of the same
request id
- // on this session is safe. The server's client table
answers from
- // its reply cache if the request did commit.
- if waitErr := waitBeforeReplay(ctx, readDeadline);
waitErr != nil {
+ // on this session is safe. On the metadata plane the
client table
+ // answers a committed request from its reply cache;
the partition
+ // plane keeps no client table, so its replay is
at-least-once.
+ if waitErr := c.waitBeforeReplay(ctx, readDeadline);
waitErr != nil {
return nil, waitErr
}
case errors.Is(err, ierror.ErrTransientNotAccepted) &&
time.Now().Before(transientDeadline):
- if waitErr := waitBeforeReplay(ctx, transientDeadline);
waitErr != nil {
+ if waitErr := c.waitBeforeReplay(ctx,
transientDeadline); waitErr != nil {
return nil, waitErr
}
default:
@@ -723,8 +810,11 @@ func (c *IggyTcpClient) handleReplyFailureLocked(err
error) {
}
// waitBeforeReplay pauses before resending a transiently rejected request,
-// never past the deadline and never past the caller's cancellation.
-func waitBeforeReplay(ctx context.Context, deadline time.Time) error {
+// never past the deadline, the caller's cancellation, or a client shutdown.
+// The shutdown channel matters because this wait runs with c.mtx held, which
+// is the lock Close needs; without it Close would block for the rest of the
+// request budget.
+func (c *IggyTcpClient) waitBeforeReplay(ctx context.Context, deadline
time.Time) error {
interval := replayInterval
if remaining := time.Until(deadline); remaining < interval {
interval = remaining
@@ -738,6 +828,8 @@ func waitBeforeReplay(ctx context.Context, deadline
time.Time) error {
select {
case <-ctx.Done():
return ctx.Err()
+ case <-c.closed:
+ return ierror.ErrClientShutdown
case <-timer.C:
return nil
}
@@ -750,7 +842,7 @@ func (c *IggyTcpClient) invalidateConnLocked() {
c.sessionState = iggcon.SessionStateUnauthenticated
c.session.Reset()
c.groups.clear()
- c.topics.clear()
+ c.topics.clearCounts()
}
// closeConnLocked closes and drops the current connection.
@@ -760,6 +852,7 @@ func (c *IggyTcpClient) closeConnLocked() error {
}
err := c.conn.Close()
c.conn = nil
+ c.reader = nil
return err
}
@@ -881,6 +974,7 @@ func (c *IggyTcpClient) Connect(ctx context.Context) error {
c.mtx.Lock()
c.conn = conn
+ c.reader = bufio.NewReaderSize(conn, 64*1024)
c.transportState = iggcon.TransportStateConnected
c.connectedAt = time.Now()
// The server fence does not survive the old socket, so the new
connection
@@ -940,6 +1034,16 @@ func (c *IggyTcpClient) establishSession(ctx
context.Context, skipAutoLogin bool
return err
}
if redirect {
+ if skipAutoLogin {
+ // The suppression belongs to the sign-in replay, not
to this
+ // connection attempt. Connect already consumed the
flag, so it is
+ // re-armed for the post-redirect Connect; otherwise
that nested
+ // Connect signs in automatically and the replayed
login then
+ // commits a second Register.
+ c.mtx.Lock()
+ c.skipAutoLoginOnce = true
+ c.mtx.Unlock()
+ }
return c.Connect(ctx)
}
@@ -1018,7 +1122,7 @@ func (c *IggyTcpClient) disconnect() error {
c.sessionState = iggcon.SessionStateUnauthenticated
c.session.Reset()
c.groups.clear()
- c.topics.clear()
+ c.topics.clearCounts()
err := c.closeConnLocked()
@@ -1028,6 +1132,14 @@ func (c *IggyTcpClient) disconnect() error {
}
func (c *IggyTcpClient) shutdown() error {
+ // Unblock any in-flight replay wait before taking the lock it holds,
+ // otherwise this call queues behind the rest of that request's budget.
+ c.closeOnce.Do(func() {
+ if c.closed != nil {
+ close(c.closed)
+ }
+ })
+
c.mtx.Lock()
defer c.mtx.Unlock()
@@ -1043,7 +1155,7 @@ func (c *IggyTcpClient) shutdown() error {
c.sessionState = iggcon.SessionStateUnauthenticated
c.session.Reset()
c.groups.clear()
- c.topics.clear()
+ c.topics.clearCounts()
c.logger.Info("Iggy TCP client has been shutdown.",
slog.String("client_address", c.clientAddress))
// TODO push shutdown event
return err
diff --git a/foreign/go/client/tcp/tcp_core_encode_test.go
b/foreign/go/client/tcp/tcp_core_encode_test.go
new file mode 100644
index 000000000..3ba348830
--- /dev/null
+++ b/foreign/go/client/tcp/tcp_core_encode_test.go
@@ -0,0 +1,128 @@
+// 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.
+
+package tcp
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ iggcon "github.com/apache/iggy/foreign/go/contracts"
+ "github.com/apache/iggy/foreign/go/internal/command"
+ "github.com/apache/iggy/foreign/go/internal/vsr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// marshalOnlyCmd implements command.Command without AppendBinary; it forces
+// appendCommandFrame down the MarshalBinary fallback branch.
+type marshalOnlyCmd struct {
+ body []byte
+ code command.Code
+ wantErr error
+}
+
+func (f *marshalOnlyCmd) Code() command.Code { return f.code }
+func (f *marshalOnlyCmd) MarshalBinary() ([]byte, error) {
+ if f.wantErr != nil {
+ return nil, f.wantErr
+ }
+ return bytes.Clone(f.body), nil
+}
+
+func testPollCmd(t *testing.T) *command.PollMessages {
+ t.Helper()
+ partitionId := uint32(7)
+ return &command.PollMessages{
+ Consumer: iggcon.NewSingleConsumer(numericIdentifier(t, 42)),
+ StreamId: numericIdentifier(t, 1),
+ TopicId: numericIdentifier(t, 2),
+ PartitionId: &partitionId,
+ Strategy: iggcon.FirstPollingStrategy(),
+ Count: 100,
+ AutoCommit: true,
+ }
+}
+
+func TestAppendCommandFrame_AppenderPathMatchesTheFallback(t *testing.T) {
+ cmd := testPollCmd(t)
+ body, err := cmd.MarshalBinary()
+ require.NoError(t, err)
+ fallback := &marshalOnlyCmd{body: body, code: cmd.Code()}
+
+ want, err := appendCommandFrame(nil, fallback)
+ require.NoError(t, err)
+ got, err := appendCommandFrame(nil, cmd)
+ require.NoError(t, err)
+
+ assert.Equal(t, want[vsr.HeaderSize:], got[vsr.HeaderSize:],
+ "the fast path diverges from the MarshalBinary fallback")
+ assert.Len(t, got, vsr.HeaderSize+len(body),
+ "the frame is the reserved prologue followed by the payload")
+}
+
+func TestAppendCommandFrame_FallbackPropagatesTheMarshalError(t *testing.T) {
+ sentinel := errors.New("marshal failed")
+ cmd := &marshalOnlyCmd{code: command.Code(1), wantErr: sentinel}
+
+ frame, err := appendCommandFrame(make([]byte, 0, 4), cmd)
+ assert.ErrorIs(t, err, sentinel)
+ assert.NotNil(t, frame, "the grown buffer survives the error so the
pool keeps it")
+}
+
+func TestAppendCommandFrame_GrowsAnUndersizedBuffer(t *testing.T) {
+ cmd := testPollCmd(t)
+ want, err := appendCommandFrame(make([]byte, 0, 4096), cmd)
+ require.NoError(t, err)
+
+ got, err := appendCommandFrame(make([]byte, 0, 4), cmd)
+ require.NoError(t, err)
+ assert.Equal(t, want[vsr.HeaderSize:], got[vsr.HeaderSize:])
+}
+
+func TestRequestBufPool_AcquireReleaseRoundTrip(t *testing.T) {
+ bp := acquireRequestBuf()
+ require.NotNil(t, bp)
+ require.NotNil(t, *bp)
+ assert.GreaterOrEqual(t, cap(*bp), vsr.HeaderSize,
+ "a fresh buffer already holds the header prologue")
+
+ *bp = append(*bp, []byte("hello")...)
+ releaseRequestBuf(bp)
+
+ next := acquireRequestBuf()
+ require.NotNil(t, next)
+ assert.Empty(t, *next, "a pooled buffer comes back empty")
+ releaseRequestBuf(next)
+}
+
+func TestRequestBufPool_KeepsABatchSizedBuffer(t *testing.T) {
+ batch := make([]byte, 0, 1<<20)
+ releaseRequestBuf(&batch)
+ // sync.Pool gives no delivery guarantee; what must hold is that the
+ // release accepted the megabyte buffer instead of dropping it on the
+ // floor, which the truncation observes.
+ assert.Empty(t, batch)
+}
+
+func TestRequestBufPool_DropsAPathologicalBuffer(t *testing.T) {
+ huge := make([]byte, 0, 8<<20)
+ huge = append(huge, 1)
+ releaseRequestBuf(&huge)
+ assert.Len(t, huge, 1, "an over-ceiling buffer is not truncated, it is
dropped")
+}
diff --git a/foreign/go/client/tcp/tcp_core_review_test.go
b/foreign/go/client/tcp/tcp_core_review_test.go
new file mode 100644
index 000000000..15261d62f
--- /dev/null
+++ b/foreign/go/client/tcp/tcp_core_review_test.go
@@ -0,0 +1,317 @@
+// 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.
+
+package tcp
+
+import (
+ "context"
+ "encoding/binary"
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ iggcon "github.com/apache/iggy/foreign/go/contracts"
+ ierror "github.com/apache/iggy/foreign/go/errors"
+ "github.com/apache/iggy/foreign/go/internal/command"
+ "github.com/apache/iggy/foreign/go/internal/vsr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// newUnboundPipeClient is newPipeClient without the session bind, for tests
+// that need the unauthenticated state.
+func newUnboundPipeClient(t *testing.T) (*IggyTcpClient, net.Conn) {
+ t.Helper()
+
+ serverConn, clientConn := net.Pipe()
+ client := newTestClient(t, clientConn)
+ t.Cleanup(func() {
+ _ = clientConn.Close()
+ _ = serverConn.Close()
+ })
+ return client, serverConn
+}
+
+func TestExchange_DoesNotReconnectOnALocalStampFailure(t *testing.T) {
+ client, serverConn := newUnboundPipeClient(t)
+ client.config.autoLogin =
NewAutoLogin(NewUsernamePasswordCredentials("iggy", "iggy"))
+ server := serve(serverConn, func(_ int, _ request) []byte {
+ return replyFrame(vsr.OperationCreateStream, resultSection())
+ })
+
+ // The session is unbound, so stamping a replicated request fails
locally
+ // before any byte reaches the wire.
+ _, err := client.do(context.Background(), &command.CreateStream{Name:
"orders"})
+
+ assert.ErrorIs(t, err, ierror.ErrUnauthenticated)
+ assert.Equal(t, iggcon.TransportStateConnected, client.transportState,
+ "a healthy connection survives a purely local failure")
+ assert.Empty(t, server.recorded(), "nothing reached the wire")
+}
+
+func TestLogoutUser_StaysSignedOutThroughTheReconnectPath(t *testing.T) {
+ var server *testListener
+ server = listenVSR(t, nil, func(_, _ int, read request) []byte {
+ switch {
+ case read.code() == uint32(command.GetClusterMetadataCode):
+ return clusterMetadataFrame(t, 0, server.address())
+ case read.operation() == vsr.OperationRegister:
+ return registerReplyFrame(7, 128)
+ case read.operation() == vsr.OperationLogout:
+ return replyFrame(vsr.OperationLogout, nil)
+ default:
+ // The server refuses the signed-out session, which is
the shape
+ // that previously drove reconnect plus automatic
re-login.
+ return evictionFrame(vsr.EvictionNoSession, 0, 0)
+ }
+ })
+
+ client := newDialingClient(t, server.address(),
+ WithAutoLogin(NewUsernamePasswordCredentials("iggy", "iggy")))
+ require.NoError(t, client.Connect(context.Background()))
+ require.NoError(t, client.LogoutUser(context.Background()))
+
+ err := client.Ping(context.Background())
+ assert.ErrorIs(t, err, ierror.ErrUnauthenticated,
+ "the caller hears about the missing session instead of being
re-logged in")
+
+ registers := 0
+ for _, read := range server.recorded() {
+ if read.operation() == vsr.OperationRegister {
+ registers++
+ }
+ }
+ assert.Equal(t, 1, registers, "an explicit sign-out is not reversed by
an automatic sign-in")
+ assert.Equal(t, 1, server.connections(), "no reconnect was attempted")
+}
+
+func TestLoginUser_ClearsTheExplicitLogoutSuppression(t *testing.T) {
+ var server *testListener
+ server = listenVSR(t, nil, func(connection, _ int, read request) []byte
{
+ switch {
+ case read.code() == uint32(command.GetClusterMetadataCode):
+ return clusterMetadataFrame(t, 0, server.address())
+ case read.operation() == vsr.OperationRegister:
+ return registerReplyFrame(7, uint64(128+connection))
+ case read.operation() == vsr.OperationLogout:
+ return replyFrame(vsr.OperationLogout, nil)
+ default:
+ return replyFrame(vsr.OperationNonReplicated, nil)
+ }
+ })
+
+ client := newDialingClient(t, server.address(),
+ WithAutoLogin(NewUsernamePasswordCredentials("iggy", "iggy")))
+ require.NoError(t, client.Connect(context.Background()))
+ require.NoError(t, client.LogoutUser(context.Background()))
+
+ _, err := client.LoginUser(context.Background(), "iggy", "iggy")
+ require.NoError(t, err)
+
+ client.mtx.Lock()
+ loggedOut := client.loggedOut
+ client.mtx.Unlock()
+ assert.False(t, loggedOut, "an explicit sign-in lifts the suppression")
+ require.NoError(t, client.Ping(context.Background()))
+}
+
+func TestConnect_RedirectDuringAReplayedLoginDoesNotAutoSignIn(t *testing.T) {
+ var leader *testListener
+ leader = listenVSR(t, nil, singleNodeHandler(t, func() string { return
leader.address() }))
+ follower := listenVSR(t, nil, func(_, _ int, read request) []byte {
+ if read.code() == uint32(command.GetClusterMetadataCode) {
+ return clusterMetadataFrame(t, 1, "127.0.0.1:1",
leader.address())
+ }
+ return replyFrame(vsr.OperationNonReplicated, nil)
+ })
+
+ client := newDialingClient(t, follower.address(),
+ WithAutoLogin(NewUsernamePasswordCredentials("iggy", "iggy")))
+ // The state a replayed login leaves behind before its reconnect.
+ client.skipAutoLoginOnce = true
+
+ require.NoError(t, client.Connect(context.Background()))
+
+ for _, read := range leader.recorded() {
+ assert.NotEqual(t, vsr.OperationRegister, read.operation(),
+ "the redirected Connect must keep suppressing the
automatic sign-in")
+ }
+ assert.False(t, client.skipAutoLoginOnce, "the suppression is consumed
exactly once")
+}
+
+func TestClose_InterruptsAnInFlightReplayWait(t *testing.T) {
+ client, serverConn := newPipeClient(t)
+ serve(serverConn, func(_ int, _ request) []byte {
+ return statusReplyFrame(vsr.OperationCreateStream,
+ uint32(ierror.TransientNotCommittedCode), nil)
+ })
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := client.do(context.Background(),
&command.CreateStream{Name: "orders"})
+ done <- err
+ }()
+ time.Sleep(50 * time.Millisecond)
+
+ closed := time.Now()
+ require.NoError(t, client.Close())
+ assert.Less(t, time.Since(closed), 5*time.Second,
+ "Close must not wait out the request budget")
+
+ select {
+ case err := <-done:
+ assert.ErrorIs(t, err, ierror.ErrClientShutdown)
+ case <-time.After(5 * time.Second):
+ t.Fatal("the in-flight request never returned after Close")
+ }
+}
+
+func TestExchange_DropsTheConnectionOnAMismatchedReplyEcho(t *testing.T) {
+ client, serverConn := newPipeClient(t)
+ client.config.reconnection.enabled = false
+ go func() {
+ read, err := readRequest(serverConn)
+ if err != nil {
+ return
+ }
+ answer := replyFrame(vsr.OperationCreateStream, resultSection())
+ binary.LittleEndian.PutUint64(answer[replyFrameOffsetRequest:],
+ read.requestID()+7)
+ _, _ = serverConn.Write(answer)
+ }()
+
+ _, err := client.do(context.Background(), &command.CreateStream{Name:
"orders"})
+ assert.ErrorIs(t, err, ierror.ErrDisconnected,
+ "a reply for some other request means the stream is desynced")
+ assert.Equal(t, iggcon.TransportStateDisconnected,
client.transportState)
+}
+
+func TestExchange_NormalizesAWriteFailure(t *testing.T) {
+ client, serverConn := newPipeClient(t)
+ client.config.reconnection.enabled = false
+ _ = serverConn.Close()
+
+ _, err := client.SendBinaryRequest(context.Background(),
uint32(command.PingCode), nil)
+ assert.ErrorIs(t, err, ierror.ErrDisconnected,
+ "a write failure classifies like a read failure")
+ assert.Equal(t, iggcon.TransportStateDisconnected,
client.transportState)
+}
+
+func TestLoginUser_ConcurrentSignInsAreSingleFlight(t *testing.T) {
+ var server *testListener
+ sessions := uint64(128)
+ var sessionMtx sync.Mutex
+ server = listenVSR(t, nil, func(_, _ int, read request) []byte {
+ switch {
+ case read.code() == uint32(command.GetClusterMetadataCode):
+ return clusterMetadataFrame(t, 0, server.address())
+ case read.operation() == vsr.OperationRegister:
+ sessionMtx.Lock()
+ sessions++
+ session := sessions
+ sessionMtx.Unlock()
+ return registerReplyFrame(7, session)
+ case read.operation() == vsr.OperationLogout:
+ return replyFrame(vsr.OperationLogout, nil)
+ default:
+ return replyFrame(vsr.OperationNonReplicated, nil)
+ }
+ })
+
+ client := newDialingClient(t, server.address())
+ require.NoError(t, client.Connect(context.Background()))
+
+ var group sync.WaitGroup
+ errs := make([]error, 2)
+ for i := range errs {
+ group.Add(1)
+ go func(index int) {
+ defer group.Done()
+ _, errs[index] = client.LoginUser(context.Background(),
"iggy", "iggy")
+ }(i)
+ }
+ group.Wait()
+
+ assert.NoError(t, errs[0])
+ assert.NoError(t, errs[1])
+ assert.True(t, client.session.Bound(),
+ "the surviving sign-in leaves one bound session, not an
orphaned register")
+}
+
+func TestLoginUser_DropsTheConnectionWhenTheBindFails(t *testing.T) {
+ var server *testListener
+ server = listenVSR(t, nil, func(_, _ int, read request) []byte {
+ if read.code() == uint32(command.GetClusterMetadataCode) {
+ return clusterMetadataFrame(t, 0, server.address())
+ }
+ // A zero session fence is not bindable.
+ return registerReplyFrame(7, 0)
+ })
+
+ client := newDialingClient(t, server.address())
+ require.NoError(t, client.Connect(context.Background()))
+
+ _, err := client.LoginUser(context.Background(), "iggy", "iggy")
+ require.Error(t, err)
+ assert.Equal(t, iggcon.TransportStateDisconnected,
client.transportState,
+ "the server committed a Register this client cannot adopt; the
connection is unusable")
+}
+
+func TestTopicCache_ClearCountsKeepsTheBalancedCursors(t *testing.T) {
+ cache := topicCache{}
+ key := topicKey{stream: "s", topic: "t"}
+ cache.setPartitionsCount(key, 4)
+ require.Equal(t, uint32(0), cache.nextBalanced(key, 4))
+ require.Equal(t, uint32(1), cache.nextBalanced(key, 4))
+
+ cache.clearCounts()
+
+ _, cached := cache.partitionsCount(key)
+ assert.False(t, cached, "the count must be reread after a reconnect")
+ assert.Equal(t, uint32(2), cache.nextBalanced(key, 4),
+ "the fairness cursor survives, so a fleet-wide reconnect does
not stampede partition 0")
+}
+
+func TestSendMessages_InvalidatesTheCountWhenThePartitionVanished(t
*testing.T) {
+ client, serverConn := newPipeClient(t)
+ serve(serverConn, func(_ int, read request) []byte {
+ if read.operation() == vsr.OperationSendMessages {
+ return statusReplyFrame(vsr.OperationSendMessages,
+ uint32(ierror.PartitionNotFoundCode), nil)
+ }
+ return replyFrame(vsr.OperationNonReplicated,
topicDetailsBody(t, 4))
+ })
+
+ streamId := numericIdentifier(t, 1)
+ topicId := numericIdentifier(t, 1)
+ message, err := iggcon.NewIggyMessage([]byte("payload"))
+ require.NoError(t, err)
+
+ _, err = client.SendMessages(context.Background(),
+ streamId, topicId, iggcon.None(), []iggcon.IggyMessage{message})
+ require.ErrorIs(t, err, ierror.ErrPartitionNotFound)
+
+ _, cached := client.topics.partitionsCount(newTopicKey(streamId,
topicId))
+ assert.False(t, cached,
+ "the topic was likely recreated smaller; the count must be
reread")
+}
+
+func TestGetDefaultOptions_DisablesNagle(t *testing.T) {
+ assert.True(t, GetDefaultOptions().config.noDelay,
+ "a lockstep client must not stall frames behind Nagle's
algorithm")
+}
diff --git a/foreign/go/client/tcp/tcp_group_polling.go
b/foreign/go/client/tcp/tcp_group_polling.go
index 246b6728c..92b8e5321 100644
--- a/foreign/go/client/tcp/tcp_group_polling.go
+++ b/foreign/go/client/tcp/tcp_group_polling.go
@@ -89,14 +89,37 @@ func (c *groupAssignmentCache) put(key groupKey, assignment
groupAssignment) {
c.entries[key] = &assignment
}
-// advance moves the round-robin cursor of a cached entry past the partition
-// that was just polled.
-func (c *groupAssignmentCache) advance(key groupKey, cursor int) {
+// nextPartition returns the partition the next poll targets and advances the
+// round-robin cursor, both under one lock. A separate read and advance would
+// let two concurrent polls target the same partition and skip another.
+func (c *groupAssignmentCache) nextPartition(key groupKey) (uint32, bool) {
c.mtx.Lock()
defer c.mtx.Unlock()
- if entry, ok := c.entries[key]; ok {
- entry.cursor = cursor
+ entry, ok := c.entries[key]
+ if !ok || len(entry.partitions) == 0 {
+ return 0, false
}
+ cursor := entry.cursor % len(entry.partitions)
+ entry.cursor = (cursor + 1) % len(entry.partitions)
+ return entry.partitions[cursor], true
+}
+
+// putSynced stores a fresh sync result, carrying the round-robin cursor
+// forward when the generation is unchanged, all under one lock so a
+// concurrent poll's advance is never overwritten with a stale value.
+func (c *groupAssignmentCache) putSynced(key groupKey, assignment
groupAssignment) {
+ c.mtx.Lock()
+ defer c.mtx.Unlock()
+ if c.entries == nil {
+ c.entries = make(map[groupKey]*groupAssignment)
+ }
+ if current, ok := c.entries[key]; ok && current.generation ==
assignment.generation {
+ // A same-generation refresh is not a rebalance: the member
still owns
+ // the same partitions, so the round-robin position carries over
+ // instead of resetting and starving the partitions behind it.
+ assignment.cursor = current.cursor
+ }
+ c.entries[key] = &assignment
}
func (c *groupAssignmentCache) drop(key groupKey) {
@@ -176,9 +199,11 @@ func (c *IggyTcpClient) pollGroup(
}, nil
}
- cursor := assignment.cursor % len(assignment.partitions)
- partitionId := assignment.partitions[cursor]
- c.groups.advance(key, (cursor+1)%len(assignment.partitions))
+ partitionId, owned := c.groups.nextPartition(key)
+ if !owned {
+ // The entry vanished between the sync and this read;
re-sync.
+ continue
+ }
polled, err := c.pollPartition(
ctx, streamId, topicId, consumer, strategy, count,
autoCommit, &partitionId)
@@ -191,8 +216,9 @@ func (c *IggyTcpClient) pollGroup(
}
// The server marks a stale assignment by answering an empty
batch on
- // the resync sentinel partition.
- if polled.PartitionId == iggcon.ResyncRequiredPartition &&
polled.MessageCount == 0 {
+ // the resync sentinel partition. The gate reads the decoded
batch, not
+ // the server-declared count, which is the same signal Rust
checks.
+ if polled.PartitionId == iggcon.ResyncRequiredPartition &&
len(polled.Messages) == 0 {
c.groups.drop(key)
continue
}
@@ -246,15 +272,9 @@ func (c *IggyTcpClient) ensureAssignment(
partitions: synced.Partitions,
fetchedAt: time.Now(),
}
- if current, ok := c.groups.get(key); ok && current.generation ==
synced.Generation {
- // A same-generation refresh is not a rebalance: the member
still owns
- // the same partitions, so the round-robin position carries over
- // instead of resetting and starving the partitions behind it.
- assignment.cursor = current.cursor
- }
c.logger.Debug("Synced the consumer group assignment",
slog.Uint64("generation", assignment.generation),
slog.Int("partitions", len(assignment.partitions)))
- c.groups.put(key, assignment)
+ c.groups.putSynced(key, assignment)
return assignment, nil
}
diff --git a/foreign/go/client/tcp/tcp_messaging.go
b/foreign/go/client/tcp/tcp_messaging.go
index 6e54b2f72..95441b90a 100644
--- a/foreign/go/client/tcp/tcp_messaging.go
+++ b/foreign/go/client/tcp/tcp_messaging.go
@@ -19,6 +19,7 @@ package tcp
import (
"context"
+ "errors"
"log/slog"
binaryserialization
"github.com/apache/iggy/foreign/go/binary_serialization"
@@ -59,6 +60,12 @@ func (c *IggyTcpClient) SendMessages(
Messages: messages,
})
if err != nil {
+ if errors.Is(err, ierror.ErrPartitionNotFound) {
+ // The cached count pointed this send at a partition
the server
+ // does not have, so the topic was likely recreated
smaller.
+ // Nothing else invalidates a count another client
changed.
+
c.topics.invalidatePartitionsCount(newTopicKey(streamId, topicId))
+ }
return nil, err
}
diff --git a/foreign/go/client/tcp/tcp_session_management.go
b/foreign/go/client/tcp/tcp_session_management.go
index 0102f38b3..3ca38e432 100644
--- a/foreign/go/client/tcp/tcp_session_management.go
+++ b/foreign/go/client/tcp/tcp_session_management.go
@@ -52,6 +52,14 @@ func (c *IggyTcpClient) LoginWithPersonalAccessToken(ctx
context.Context, token
// the existing session untouched, and a connection that dies mid-attempt is
// already reset by invalidateConnLocked.
func (c *IggyTcpClient) register(ctx context.Context, code uint32, body
[]byte) (*iggcon.IdentityInfo, error) {
+ // One sign-in at a time. BeginRegister runs inside the exchange lock
but
+ // Bind runs after it, so two interleaved sign-ins would let the second
+ // BeginRegister reset the identity the first is about to bind: one
+ // committed Register would be orphaned in the server's client table and
+ // the losing caller would see ErrSessionAlreadyBound.
+ c.registerMtx.Lock()
+ defer c.registerMtx.Unlock()
+
c.logger.Info("Iggy client is signing in...",
slog.String("client_address", c.clientAddress))
if err := c.endBoundSession(ctx); err != nil {
@@ -60,7 +68,7 @@ func (c *IggyTcpClient) register(ctx context.Context, code
uint32, body []byte)
bp := acquireRequestBuf()
defer releaseRequestBuf(bp)
- frame := append(append((*bp)[:0], requestPrologue[:]...), body...)
+ frame := append(reserveHeader(*bp), body...)
*bp = frame
response, err := c.exchange(ctx, code, frame)
@@ -77,6 +85,12 @@ func (c *IggyTcpClient) register(ctx context.Context, code
uint32, body []byte)
err = c.session.Bind(registered.Session)
if err == nil {
c.sessionState = iggcon.SessionStateAuthenticated
+ c.loggedOut = false
+ } else {
+ // The server committed a Register this client failed to adopt,
so the
+ // connection carries a session the local state does not track.
It is
+ // unusable; drop it like any other terminal session failure.
+ c.invalidateConnLocked()
}
c.mtx.Unlock()
if err != nil {
@@ -108,8 +122,11 @@ func (c *IggyTcpClient) LogoutUser(ctx context.Context)
error {
c.mtx.Lock()
c.sessionState = iggcon.SessionStateUnauthenticated
c.session.Reset()
+ // The sign-out is caller intent: it suppresses the automatic sign-in on
+ // the reconnect path until the caller explicitly signs in again.
+ c.loggedOut = true
c.groups.clear()
- c.topics.clear()
+ c.topics.clearCounts()
c.mtx.Unlock()
return nil
}
diff --git a/foreign/go/client/tcp/tcp_testing_test.go
b/foreign/go/client/tcp/tcp_testing_test.go
index 0f9d0ec78..b63671b0e 100644
--- a/foreign/go/client/tcp/tcp_testing_test.go
+++ b/foreign/go/client/tcp/tcp_testing_test.go
@@ -42,6 +42,7 @@ const (
frameOffsetSession = 192
frameOffsetReserved = 204
+ replyFrameOffsetRequest = 200
replyFrameOffsetOperation = 208
replyFrameOffsetStatus = 224
@@ -153,6 +154,17 @@ func clusterMetadataFrame(t *testing.T, leaderIndex int,
addresses ...string) []
return replyFrame(vsr.OperationNonReplicated, body)
}
+// echoReplyRequest stamps the request id into a reply frame the way the
+// server echoes it, so handlers do not repeat the correlation plumbing. An
+// eviction frame passes through untouched.
+func echoReplyRequest(answer []byte, read request) {
+ if len(answer) < vsr.HeaderSize || answer[frameOffsetCommand] != 8 {
+ return
+ }
+ copy(answer[replyFrameOffsetRequest:replyFrameOffsetRequest+8],
+ read.header[frameOffsetRequest:frameOffsetRequest+8])
+}
+
// readRequest reads one complete frame from conn.
func readRequest(conn net.Conn) (request, error) {
var read request
@@ -195,6 +207,7 @@ func serve(conn net.Conn, handler func(index int, read
request) []byte) *fakeSer
if answer == nil {
continue
}
+ echoReplyRequest(answer, read)
if _, err := conn.Write(answer); err != nil {
return
}
@@ -237,5 +250,6 @@ func newTestClient(t *testing.T, conn net.Conn)
*IggyTcpClient {
logger: slog.New(slog.DiscardHandler),
session: vsr.NewSession(),
config: defaultTcpClientConfig(),
+ closed: make(chan struct{}),
}
}
diff --git a/foreign/go/client/tcp/tcp_topic_cache.go
b/foreign/go/client/tcp/tcp_topic_cache.go
index f830b007c..dd0508235 100644
--- a/foreign/go/client/tcp/tcp_topic_cache.go
+++ b/foreign/go/client/tcp/tcp_topic_cache.go
@@ -112,10 +112,13 @@ func (c *topicCache) nextBalanced(key topicKey,
partitionsCount uint32) uint32 {
return partition
}
-// clear forgets topic state so a new session rereads current metadata.
-func (c *topicCache) clear() {
+// clearCounts forgets the partition counts so a new session rereads current
+// metadata. Balanced cursors survive on purpose: they are client-side
+// fairness state with no dependency on cluster metadata, and resetting them
+// on a reconnect would point every producer's first post-failover batch at
+// partition 0 at once.
+func (c *topicCache) clearCounts() {
c.mtx.Lock()
defer c.mtx.Unlock()
clear(c.partitionsCounts)
- clear(c.balancedCursors)
}
diff --git a/foreign/go/contracts/client.go b/foreign/go/contracts/client.go
index db4ee1792..6bcf71cd5 100644
--- a/foreign/go/contracts/client.go
+++ b/foreign/go/contracts/client.go
@@ -120,6 +120,10 @@ type Client interface {
// example after an explicit LeaveConsumerGroup. JoinConsumerGroup
// restores membership.
// - err == nil with a real partition id: messages were read.
+ //
+ // The returned message payloads and user headers alias the reply
buffer,
+ // so retaining one message pins the whole reply; copy the bytes out
when
+ // they outlive the poll.
PollMessages(
ctx context.Context,
streamId Identifier,
diff --git a/foreign/go/contracts/message_header.go
b/foreign/go/contracts/message_header.go
index 7167f18d8..8b6129280 100644
--- a/foreign/go/contracts/message_header.go
+++ b/foreign/go/contracts/message_header.go
@@ -74,17 +74,17 @@ func MessageHeaderFromBytes(data []byte) (*MessageHeader,
error) {
}
func (mh *MessageHeader) ToBytes() []byte {
- bytes := make([]byte, 0, MessageHeaderSize)
-
- bytes = binary.LittleEndian.AppendUint64(bytes, mh.Checksum)
- idBytes := mh.Id[:]
- bytes = append(bytes, idBytes...)
- bytes = binary.LittleEndian.AppendUint64(bytes, mh.Offset)
- bytes = binary.LittleEndian.AppendUint64(bytes, mh.Timestamp)
- bytes = binary.LittleEndian.AppendUint64(bytes, mh.OriginTimestamp)
- bytes = binary.LittleEndian.AppendUint32(bytes, mh.UserHeaderLength)
- bytes = binary.LittleEndian.AppendUint32(bytes, mh.PayloadLength)
- bytes = binary.LittleEndian.AppendUint64(bytes, mh.Reserved)
-
+ bytes, _ := mh.AppendBinary(make([]byte, 0, MessageHeaderSize))
return bytes
}
+
+func (mh *MessageHeader) AppendBinary(b []byte) ([]byte, error) {
+ b = binary.LittleEndian.AppendUint64(b, mh.Checksum)
+ b = append(b, mh.Id[:]...)
+ b = binary.LittleEndian.AppendUint64(b, mh.Offset)
+ b = binary.LittleEndian.AppendUint64(b, mh.Timestamp)
+ b = binary.LittleEndian.AppendUint64(b, mh.OriginTimestamp)
+ b = binary.LittleEndian.AppendUint32(b, mh.UserHeaderLength)
+ b = binary.LittleEndian.AppendUint32(b, mh.PayloadLength)
+ return binary.LittleEndian.AppendUint64(b, mh.Reserved), nil
+}
diff --git a/foreign/go/contracts/partitions.go
b/foreign/go/contracts/partitions.go
index c7043d3c0..309ea4412 100644
--- a/foreign/go/contracts/partitions.go
+++ b/foreign/go/contracts/partitions.go
@@ -120,9 +120,10 @@ func EntityIdGuid(value uuid.UUID) Partitioning {
}
func (p Partitioning) MarshalBinary() ([]byte, error) {
- bytes := make([]byte, 2+p.Length)
- bytes[0] = byte(p.Kind)
- bytes[1] = byte(p.Length)
- copy(bytes[2:], p.Value)
- return bytes, nil
+ return p.AppendBinary(make([]byte, 0, 2+p.Length))
+}
+
+func (p Partitioning) AppendBinary(b []byte) ([]byte, error) {
+ b = append(b, byte(p.Kind), byte(p.Length))
+ return append(b, p.Value...), nil
}
diff --git a/foreign/go/internal/command/message.go
b/foreign/go/internal/command/message.go
index 7e34a11d4..ef9bcc9a8 100644
--- a/foreign/go/internal/command/message.go
+++ b/foreign/go/internal/command/message.go
@@ -46,109 +46,91 @@ func (s *SendMessages) Code() Code {
return SendMessagesCode
}
+// zeroIndex is the blank per-message index entry reserved ahead of the
+// message section and filled in as messages are appended.
+var zeroIndex [indexSize]byte
+
func (s *SendMessages) MarshalBinary() ([]byte, error) {
- for i, message := range s.Messages {
- switch s.Compression {
- case iggcon.MESSAGE_COMPRESSION_S2:
- if len(message.Payload) < 32 {
- break
- }
- s.Messages[i].Payload = s2.Encode(nil, message.Payload)
- message.Header.PayloadLength =
uint32(len(message.Payload))
- case iggcon.MESSAGE_COMPRESSION_S2_BETTER:
- if len(message.Payload) < 32 {
- break
- }
- s.Messages[i].Payload = s2.EncodeBetter(nil,
message.Payload)
- message.Header.PayloadLength =
uint32(len(message.Payload))
- case iggcon.MESSAGE_COMPRESSION_S2_BEST:
- if len(message.Payload) < 32 {
- break
- }
- s.Messages[i].Payload = s2.EncodeBest(nil,
message.Payload)
- message.Header.PayloadLength =
uint32(len(message.Payload))
- }
- }
+ return s.AppendBinary(nil)
+}
- streamIdBytes, err := s.StreamId.MarshalBinary()
- if err != nil {
- return nil, err
+// AppendBinary encodes the batch straight into b: [metadata_length u32]
+// [stream id][topic id][partitioning][messages_count u32], the per-message
+// index section, then each message as header, payload, user headers.
+func (s *SendMessages) AppendBinary(b []byte) ([]byte, error) {
+ s.compressPayloads()
+
+ metadataStart := len(b)
+ b = binary.LittleEndian.AppendUint32(b, 0)
+ var err error
+ if b, err = s.StreamId.AppendBinary(b); err != nil {
+ return b, err
}
- topicIdBytes, err := s.TopicId.MarshalBinary()
- if err != nil {
- return nil, err
+ if b, err = s.TopicId.AppendBinary(b); err != nil {
+ return b, err
}
- partitioningBytes, err := s.Partitioning.MarshalBinary()
- if err != nil {
- return nil, err
+ if b, err = s.Partitioning.AppendBinary(b); err != nil {
+ return b, err
}
- metadataLenFieldSize := 4 // uint32
- messageCount := len(s.Messages)
- messagesCountFieldSize := 4 // uint32
- metadataLen := len(streamIdBytes) +
- len(topicIdBytes) +
- len(partitioningBytes) +
- messagesCountFieldSize
- indexesSize := messageCount * indexSize
- messageBytesCount := calculateMessageBytesCount(s.Messages)
- totalSize := metadataLenFieldSize +
- len(streamIdBytes) +
- len(topicIdBytes) +
- len(partitioningBytes) +
- messagesCountFieldSize +
- indexesSize +
- messageBytesCount
-
- bytes := make([]byte, totalSize)
-
- position := 0
-
- //metadata
- binary.LittleEndian.PutUint32(bytes[:4], uint32(metadataLen))
- position = 4
- //ids
- copy(bytes[position:position+len(streamIdBytes)], streamIdBytes)
- position += len(streamIdBytes)
- copy(bytes[position:position+len(topicIdBytes)], topicIdBytes)
- position += len(topicIdBytes)
-
- //partitioning
- copy(bytes[position:position+len(partitioningBytes)], partitioningBytes)
- position += len(partitioningBytes)
- binary.LittleEndian.PutUint32(bytes[position:position+4],
uint32(messageCount))
- position += 4
-
- currentIndexPosition := position
- for i := 0; i < indexesSize; i++ {
- bytes[position+i] = 0
+ b = binary.LittleEndian.AppendUint32(b, uint32(len(s.Messages)))
+ metadataLength := len(b) - metadataStart - 4
+ binary.LittleEndian.PutUint32(b[metadataStart:], uint32(metadataLength))
+
+ indexesStart := len(b)
+ for range s.Messages {
+ b = append(b, zeroIndex[:]...)
}
- position += indexesSize
msgSize := uint32(0)
- for _, message := range s.Messages {
- copy(bytes[position:position+iggcon.MessageHeaderSize],
message.Header.ToBytes())
-
copy(bytes[position+iggcon.MessageHeaderSize:position+iggcon.MessageHeaderSize+int(message.Header.PayloadLength)],
message.Payload)
- position += iggcon.MessageHeaderSize +
int(message.Header.PayloadLength)
-
copy(bytes[position:position+int(message.Header.UserHeaderLength)],
message.UserHeaders)
- position += int(message.Header.UserHeaderLength)
-
- msgSize += iggcon.MessageHeaderSize +
message.Header.PayloadLength + message.Header.UserHeaderLength
-
-
binary.LittleEndian.PutUint32(bytes[currentIndexPosition:currentIndexPosition+4],
0)
-
binary.LittleEndian.PutUint32(bytes[currentIndexPosition+4:currentIndexPosition+8],
uint32(msgSize))
-
binary.LittleEndian.PutUint32(bytes[currentIndexPosition+8:currentIndexPosition+12],
0)
- currentIndexPosition += indexSize
+ for i := range s.Messages {
+ message := &s.Messages[i]
+ // The header lengths and the appended slices must agree, or
every
+ // message boundary after a mismatch mis-frames; deriving both
from
+ // the same slice makes the disagreement impossible.
+ message.Header.PayloadLength = uint32(len(message.Payload))
+ message.Header.UserHeaderLength =
uint32(len(message.UserHeaders))
+ if b, err = message.Header.AppendBinary(b); err != nil {
+ return b, err
+ }
+ b = append(b, message.Payload...)
+ b = append(b, message.UserHeaders...)
+
+ msgSize += iggcon.MessageHeaderSize +
+ message.Header.PayloadLength +
message.Header.UserHeaderLength
+ binary.LittleEndian.PutUint32(b[indexesStart+i*indexSize+4:],
msgSize)
}
- return bytes, nil
+ return b, nil
}
-func calculateMessageBytesCount(messages []iggcon.IggyMessage) int {
- count := 0
- for _, msg := range messages {
- count += iggcon.MessageHeaderSize + len(msg.Payload) +
len(msg.UserHeaders)
+// compressPayloads compresses each payload in place. The header length is
+// updated through the slice index: writing it to a range copy would leave the
+// wire header claiming the uncompressed length, and the encoder would then
+// mis-frame every message that follows.
+func (s *SendMessages) compressPayloads() {
+ switch s.Compression {
+ case iggcon.MESSAGE_COMPRESSION_S2,
+ iggcon.MESSAGE_COMPRESSION_S2_BETTER,
+ iggcon.MESSAGE_COMPRESSION_S2_BEST:
+ default:
+ return
+ }
+
+ for i := range s.Messages {
+ payload := s.Messages[i].Payload
+ if len(payload) < 32 {
+ continue
+ }
+ switch s.Compression {
+ case iggcon.MESSAGE_COMPRESSION_S2:
+ s.Messages[i].Payload = s2.Encode(nil, payload)
+ case iggcon.MESSAGE_COMPRESSION_S2_BETTER:
+ s.Messages[i].Payload = s2.EncodeBetter(nil, payload)
+ case iggcon.MESSAGE_COMPRESSION_S2_BEST:
+ s.Messages[i].Payload = s2.EncodeBest(nil, payload)
+ }
+ s.Messages[i].Header.PayloadLength =
uint32(len(s.Messages[i].Payload))
}
- return count
}
type PollMessages struct {
diff --git a/foreign/go/internal/command/message_test.go
b/foreign/go/internal/command/message_test.go
index 305f5274b..1fbe43ca7 100644
--- a/foreign/go/internal/command/message_test.go
+++ b/foreign/go/internal/command/message_test.go
@@ -23,6 +23,7 @@ import (
"github.com/apache/iggy/foreign/go/contracts"
"github.com/google/uuid"
+ "github.com/klauspost/compress/s2"
)
func TestSerialize_TcpFetchMessagesRequest(t *testing.T) {
@@ -134,6 +135,82 @@ func TestSerialize_SendMessagesRequest(t *testing.T) {
}
}
+func TestSerialize_SendMessagesCompressesThePayloadCoherently(t *testing.T) {
+ // A compressible payload above the 32-byte floor.
+ payload := bytes.Repeat([]byte("abcdefgh"), 32)
+ message, err := iggcon.NewIggyMessage(payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ streamId, _ := iggcon.NewIdentifier(uint32(1))
+ topicId, _ := iggcon.NewIdentifier(uint32(1))
+ request := SendMessages{
+ StreamId: streamId,
+ TopicId: topicId,
+ Partitioning: iggcon.PartitionId(0),
+ Messages: []iggcon.IggyMessage{message},
+ Compression: iggcon.MESSAGE_COMPRESSION_S2,
+ }
+
+ serialized, err := request.MarshalBinary()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ compressed := request.Messages[0]
+ if got := int(compressed.Header.PayloadLength); got !=
len(compressed.Payload) {
+ t.Fatalf("header claims %d payload bytes, the slice holds %d:
the wire would mis-frame",
+ got, len(compressed.Payload))
+ }
+ if len(compressed.Payload) >= len(payload) {
+ t.Fatalf("the payload did not compress: %d >= %d",
len(compressed.Payload), len(payload))
+ }
+
+ // The message section must be framed by the compressed length: header,
+ // then exactly PayloadLength payload bytes, and nothing after.
+ messageStart := len(serialized) - int(compressed.Header.PayloadLength)
- iggcon.MessageHeaderSize
+ header, err := iggcon.MessageHeaderFromBytes(
+ serialized[messageStart :
messageStart+iggcon.MessageHeaderSize])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if header.PayloadLength != compressed.Header.PayloadLength {
+ t.Fatalf("wire header claims %d, in-memory header %d",
+ header.PayloadLength, compressed.Header.PayloadLength)
+ }
+
+ decoded, err := s2.Decode(nil,
serialized[messageStart+iggcon.MessageHeaderSize:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(decoded, payload) {
+ t.Fatal("the compressed payload does not round-trip")
+ }
+}
+
+func TestSerialize_SendMessagesSkipsCompressionBelowTheFloor(t *testing.T) {
+ message, err := iggcon.NewIggyMessage([]byte("short"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ streamId, _ := iggcon.NewIdentifier(uint32(1))
+ topicId, _ := iggcon.NewIdentifier(uint32(1))
+ request := SendMessages{
+ StreamId: streamId,
+ TopicId: topicId,
+ Partitioning: iggcon.PartitionId(0),
+ Messages: []iggcon.IggyMessage{message},
+ Compression: iggcon.MESSAGE_COMPRESSION_S2,
+ }
+
+ if _, err := request.MarshalBinary(); err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(request.Messages[0].Payload, []byte("short")) {
+ t.Fatal("a payload under 32 bytes must pass through
uncompressed")
+ }
+}
+
func createDefaultMessageHeaders() []iggcon.HeaderEntry {
return []iggcon.HeaderEntry{
{Key: iggcon.HeaderKey{Kind: iggcon.String, Value:
[]byte("HeaderKey1")}, Value: iggcon.HeaderValue{Kind: iggcon.String, Value:
[]byte("Value 1")}},
diff --git a/foreign/go/internal/util/leader_aware.go
b/foreign/go/internal/util/leader_aware.go
index 4be742362..ce4e86c83 100644
--- a/foreign/go/internal/util/leader_aware.go
+++ b/foreign/go/internal/util/leader_aware.go
@@ -149,30 +149,32 @@ func clusterNodeAddress(node *iggcon.ClusterNode,
transport iggcon.Protocol) (st
return net.JoinHostPort(node.IP, strconv.Itoa(int(port))), nil
}
-// isSameAddress returns true if two addresses refer to the same endpoint.
+// isSameAddress reports whether two addresses refer to the same endpoint.
+// The comparison is lexical after normalization, with an IP-literal fast path
+// for spelling differences like ::1 versus 0:0:0:0:0:0:0:1. It deliberately
+// never resolves names: both sides come from the same cluster-metadata roster
+// or config, and this runs on the failover path where a resolver lookup would
+// block with neither a context nor a deadline to interrupt it.
func isSameAddress(addr1, addr2 string) bool {
- a1 := parseAddress(addr1)
- a2 := parseAddress(addr2)
-
- if a1 != nil && a2 != nil {
- return a1.IP.Equal(a2.IP) && a1.Port == a2.Port
+ host1, port1 := splitAddress(normalizeAddress(addr1))
+ host2, port2 := splitAddress(normalizeAddress(addr2))
+ if port1 != port2 {
+ return false
}
-
- return normalizeAddress(addr1) == normalizeAddress(addr2)
+ if ip1, ip2 := net.ParseIP(host1), net.ParseIP(host2); ip1 != nil &&
ip2 != nil {
+ return ip1.Equal(ip2)
+ }
+ return host1 == host2
}
-// parseAddress attempts to parse an address into a *net.TCPAddr.
-func parseAddress(addr string) *net.TCPAddr {
- // Try direct parse
- if ta, err := net.ResolveTCPAddr("tcp", addr); err == nil {
- return ta
- }
- // Normalize then try again
- normalized := normalizeAddress(addr)
- if ta, err := net.ResolveTCPAddr("tcp", normalized); err == nil {
- return ta
+// splitAddress splits host:port, treating an unparsable address as a bare
+// host so the comparison degrades to a string match instead of failing.
+func splitAddress(addr string) (string, string) {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return addr, ""
}
- return nil
+ return host, port
}
// normalizeAddress canonicalizes address strings for fallback comparison.
diff --git a/foreign/go/internal/util/leader_aware_test.go
b/foreign/go/internal/util/leader_aware_test.go
index 5407bd792..7624b1623 100644
--- a/foreign/go/internal/util/leader_aware_test.go
+++ b/foreign/go/internal/util/leader_aware_test.go
@@ -139,6 +139,11 @@ func TestIsSameAddress(t *testing.T) {
{"localhost", "localhost:8090", "127.0.0.1:8090", true},
{"different port", "127.0.0.1:8090", "127.0.0.1:8091", false},
{"different ip", "192.168.1.1:8090", "127.0.0.1:8090", false},
+ {"ipv6 spellings", "[::1]:8090", "[0:0:0:0:0:0:0:1]:8090",
true},
+ {"same hostname", "IGGY.local:8090", "iggy.local:8090", true},
+ // Hostnames compare lexically: this path runs during failover,
where
+ // a resolver lookup could block with no deadline.
+ {"hostname versus its ip", "iggy.local:8090", "10.0.0.1:8090",
false},
}
for _, tc := range cases {
diff --git a/foreign/go/internal/vsr/envelope.go
b/foreign/go/internal/vsr/envelope.go
index a56a93874..690b5d910 100644
--- a/foreign/go/internal/vsr/envelope.go
+++ b/foreign/go/internal/vsr/envelope.go
@@ -52,8 +52,7 @@ func StampRequestHeader(session *Session, code uint32, frame
[]byte) error {
}
// Namespace derivation can fail on a malformed payload. Run it before
- // taking a request id so a local failure never burns one, which would
gap
- // the sequence and make the primary drop the next metadata request.
+ // taking a request id so a local failure leaves the counter untouched.
namespace, err := NamespaceForRequest(code, payload, operation)
if err != nil {
return err
@@ -71,9 +70,10 @@ func StampRequestHeader(session *Session, code uint32, frame
[]byte) error {
default:
sessionID = session.SessionID()
if IsPartition(operation) {
- // Partition operations replicate in their own
per-partition group
- // with no client-table dedup, so they read the
watermark without
- // consuming it.
+ // Partition operations replicate in per-partition
groups that
+ // keep no client table, so there is nothing to
deduplicate
+ // against: the watermark is read without being
consumed and a
+ // partition-plane replay is at-least-once.
request = session.CurrentRequestID()
} else if request, err = session.NextRequestID(); err != nil {
return err
diff --git a/foreign/go/internal/vsr/header.go
b/foreign/go/internal/vsr/header.go
index 39d33df14..99cc742f9 100644
--- a/foreign/go/internal/vsr/header.go
+++ b/foreign/go/internal/vsr/header.go
@@ -49,6 +49,7 @@ const (
const (
replyOffsetSize = 48
replyOffsetCommand = 60
+ replyOffsetRequest = 200
replyOffsetOperation = 208
replyOffsetNamespace = 216
replyOffsetStatus = 224
@@ -170,6 +171,18 @@ func ReadReplyOperation(header *[HeaderSize]byte)
Operation {
return Operation(header[replyOffsetOperation])
}
+// ReadReplyRequestID reads the request id the reply echoes from the request
+// it answers, which is how the transport correlates a reply with the request
+// in flight.
+func ReadReplyRequestID(header *[HeaderSize]byte) uint64 {
+ return binary.LittleEndian.Uint64(header[replyOffsetRequest:])
+}
+
+// StampedRequestID reads the request id back out of a stamped request frame.
+func StampedRequestID(frame []byte) uint64 {
+ return binary.LittleEndian.Uint64(frame[requestOffsetRequest:])
+}
+
// Eviction holds the eviction-header fields the client acts on.
type Eviction struct {
Reason EvictionReason
diff --git a/foreign/go/internal/vsr/protocol_parity_test.go
b/foreign/go/internal/vsr/protocol_parity_test.go
index 1c0a32206..f995ce25b 100644
--- a/foreign/go/internal/vsr/protocol_parity_test.go
+++ b/foreign/go/internal/vsr/protocol_parity_test.go
@@ -29,6 +29,7 @@ import (
"strings"
"testing"
+ ierror "github.com/apache/iggy/foreign/go/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -47,6 +48,8 @@ var rustSources = map[string]string{
"operation": "core/binary_protocol/src/consensus/operation.rs",
"namespace": "core/binary_protocol/src/namespace.rs",
"cargo": "core/binary_protocol/Cargo.toml",
+ "eviction": "core/common/src/error/eviction.rs",
+ "sdk": "core/sdk/src/vsr.rs",
}
// goOperations names every discriminant the codec declares. It exists so the
@@ -165,8 +168,10 @@ var rustFieldLayout = map[string][2]int{
"EvictionReason": {1, 1},
}
-// loadRustSources reads the protocol crate files, skipping the test when the
-// checkout is not available.
+// loadRustSources reads the protocol crate files. One sentinel path answers
+// "is this a full checkout" and is the only reason to skip; every other read
+// is required, so a moved or renamed Rust source fails the suite instead of
+// silently turning every parity assertion into a skip.
func loadRustSources(t *testing.T) map[string]string {
t.Helper()
@@ -174,12 +179,16 @@ func loadRustSources(t *testing.T) map[string]string {
require.NoError(t, err)
root := filepath.Clean(filepath.Join(workDir, "..", "..", "..", ".."))
+ sentinel := filepath.Join(root, "core", "binary_protocol", "Cargo.toml")
+ if _, err := os.Stat(sentinel); err != nil {
+ t.Skipf("not a full checkout, %s is missing: %v", sentinel, err)
+ }
+
sources := make(map[string]string, len(rustSources))
for name, relative := range rustSources {
content, err := os.ReadFile(filepath.Join(root, relative))
- if err != nil {
- t.Skipf("protocol sources are not available at %s: %v",
root, err)
- }
+ require.NoError(t, err,
+ "protocol source %s moved or vanished; update
rustSources", relative)
sources[name] = string(content)
}
return sources
@@ -505,6 +514,98 @@ func rustMatchesAllowlist(t *testing.T, source, predicate
string) map[string]str
return names
}
+// rustEvictionErrorCodes maps the IggyError variant names the canonical
+// eviction mapping uses to their wire codes.
+var rustEvictionErrorCodes = map[string]ierror.Code{
+ "InvalidCredentials": ierror.InvalidCredentialsCode,
+ "InvalidPersonalAccessToken": ierror.InvalidPersonalAccessTokenCode,
+ "Unauthenticated": ierror.UnauthenticatedCode,
+ "StaleClient": ierror.StaleClientCode,
+ "InvalidFormat": ierror.InvalidFormatCode,
+ "InvalidCommand": ierror.InvalidCommandCode,
+}
+
+func TestProtocolParity_EvictionReasonMapping(t *testing.T) {
+ sources := loadRustSources(t)
+ body := captureBlock(sources["eviction"], `match reason \{`)
+ require.NotEmpty(t, body, "the eviction_reason_to_error match was not
found")
+
+ armPattern := regexp.MustCompile(
+
`(?s)((?:EvictionReason::[A-Za-z0-9]+\s*\|?\s*)+)=>\s*IggyError::([A-Za-z0-9]+)`)
+ reasonPattern := regexp.MustCompile(`EvictionReason::([A-Za-z0-9]+)`)
+
+ checked := 0
+ for _, arm := range armPattern.FindAllStringSubmatch(body, -1) {
+ wantCode, ok := rustEvictionErrorCodes[arm[2]]
+ require.True(t, ok, "IggyError::%s is not in the parity table",
arm[2])
+ for _, reason := range
reasonPattern.FindAllStringSubmatch(arm[1], -1) {
+ value, ok := goEvictionReasons[reason[1]]
+ require.True(t, ok, "unknown eviction reason %s",
reason[1])
+ mapped := NewEvictionError(Eviction{Reason: value})
+ assert.Equal(t, wantCode, mapped.Code(),
"EvictionReason::%s", reason[1])
+ checked++
+ }
+ }
+ require.GreaterOrEqual(t, checked, 8, "the mapping arms did not parse")
+
+ // The fallback arm has teeth: a reason byte from a newer server must
map
+ // the same way on both sides, and never to a reconnectable error.
+ fallback := regexp.MustCompile(`_ =>
IggyError::([A-Za-z0-9]+)`).FindStringSubmatch(body)
+ require.NotNil(t, fallback, "the fallback arm was not found")
+ wantFallback, ok := rustEvictionErrorCodes[fallback[1]]
+ require.True(t, ok, "IggyError::%s is not in the parity table",
fallback[1])
+ mapped := NewEvictionError(Eviction{Reason: EvictionReason(0xEE)})
+ assert.Equal(t, wantFallback, mapped.Code(),
+ "the unknown-reason fallback diverges from core/common")
+ // IncompatibleProtocol carries its own window logic, covered by the
+ // dedicated eviction tests in reply_test.go.
+}
+
+func TestProtocolParity_NamespaceRouting(t *testing.T) {
+ sources := loadRustSources(t)
+ codeValues := rustCommandCodes(sources["codes"])
+ require.NotEmpty(t, codeValues)
+
+ body := captureBlock(sources["sdk"], `fn namespace_for_request\(`)
+ require.NotEmpty(t, body, "the Rust namespace_for_request routing was
not found")
+
+ armed := make(map[uint32]string)
+ for _, arm := range regexp.MustCompile(`(?m)^\s*([A-Z0-9_]+_CODE) =>
\{`).
+ FindAllStringSubmatch(body, -1) {
+ value, ok := codeValues[arm[1]]
+ require.True(t, ok, "unknown command constant %s", arm[1])
+ armed[uint32(value)] = arm[1]
+ }
+ require.NotEmpty(t, armed, "no payload-peek arms were parsed")
+
+ for name, value := range codeValues {
+ code := uint32(value)
+ operation := OperationForCode(code)
+ if operation == OperationRegister || operation ==
OperationLogout ||
+ operation == OperationNonReplicated ||
IsMetadata(operation) {
+ continue
+ }
+ // A code that reaches the payload peek fails on an empty
payload; a
+ // code Rust does not route is refused outright. The two errors
keep
+ // the routing decisions distinguishable without crafting
payloads.
+ _, err := NamespaceForRequest(code, nil, operation)
+ if _, peeked := armed[code]; peeked {
+ assert.ErrorIs(t, err, ierror.ErrInvalidCommand,
+ "%s must derive its namespace from the
payload", name)
+ } else {
+ assert.ErrorIs(t, err, ierror.ErrFeatureUnavailable,
+ "%s must be refused rather than routed
blindly", name)
+ }
+ }
+
+ for code, name := range armed {
+ operation := OperationForCode(code)
+ shortCircuited := operation == OperationRegister || operation
== OperationLogout ||
+ operation == OperationNonReplicated ||
IsMetadata(operation)
+ assert.False(t, shortCircuited, "%s never reaches the namespace
peek in Go", name)
+ }
+}
+
func TestProtocolParity_PackedProtocolVersion(t *testing.T) {
sources := loadRustSources(t)
pattern := regexp.MustCompile(`(?m)^version =
"([0-9]+)\.([0-9]+)\.([0-9]+)`)
diff --git a/foreign/go/internal/vsr/reply.go b/foreign/go/internal/vsr/reply.go
index 16b72b2af..0c6d8cdc5 100644
--- a/foreign/go/internal/vsr/reply.go
+++ b/foreign/go/internal/vsr/reply.go
@@ -157,12 +157,12 @@ func NewEvictionError(eviction Eviction) *EvictionError {
}
case EvictionMalformedLogin:
mapped = ierror.ErrInvalidFormat
- case EvictionReserved, EvictionClientReleaseTooLow,
EvictionClientReleaseTooHigh,
- EvictionInvalidRequestOperation, EvictionInvalidRequestBody,
- EvictionInvalidRequestBodySize:
- mapped = ierror.ErrInvalidCommand
default:
- mapped = ierror.ErrUnauthenticated
+ // Everything else, including a reason byte this SDK does not
know,
+ // maps to InvalidCommand like the core/common fallback. The
unknown
+ // byte must not map to a reconnectable error, or a newer
server's new
+ // reason silently drives a disconnect and re-login loop.
+ mapped = ierror.ErrInvalidCommand
}
return &EvictionError{
Reason: eviction.Reason,
diff --git a/foreign/go/internal/vsr/reply_test.go
b/foreign/go/internal/vsr/reply_test.go
index 7fe4d5fef..a6755613f 100644
--- a/foreign/go/internal/vsr/reply_test.go
+++ b/foreign/go/internal/vsr/reply_test.go
@@ -231,7 +231,9 @@ func TestNewEvictionError_MapsEveryReason(t *testing.T) {
{reason: EvictionSessionError, want:
ierror.UnauthenticatedCode},
{reason: EvictionStaleClient, want: ierror.StaleClientCode},
{reason: EvictionMalformedLogin, want:
ierror.InvalidFormatCode},
- {reason: EvictionReason(200), want: ierror.UnauthenticatedCode},
+ // An unrecognized reason maps like the core/common fallback
and must
+ // not become a reconnectable error.
+ {reason: EvictionReason(200), want: ierror.InvalidCommandCode},
}
for _, test := range tests {
got := NewEvictionError(Eviction{Reason: test.reason})
diff --git a/foreign/go/internal/vsr/session.go
b/foreign/go/internal/vsr/session.go
index e44a3ac03..78226f4a3 100644
--- a/foreign/go/internal/vsr/session.go
+++ b/foreign/go/internal/vsr/session.go
@@ -128,9 +128,14 @@ func (s *Session) NextRequestID() (uint64, error) {
}
// CurrentRequestID returns the watermark without advancing it. Non-replicated
-// and partition-plane requests use it: the server's client table tracks ids
-// only for replicated metadata, so consuming one here would gap the next
-// metadata request and the primary would drop it.
+// and partition-plane requests use it because neither consults the client
+// table: non-replicated requests route by transport identity, and the
+// partition plane replicates in per-partition groups with no dedup table at
+// all. The table accepts any id above the watermark with no contiguity
+// requirement, so consuming one here would not gap anything; the invariant
+// that matters is that every partition request on a session carries the id
+// the next metadata operation will claim, and that a partition-plane replay
+// is therefore at-least-once.
func (s *Session) CurrentRequestID() uint64 {
return s.requestCounter
}