Skip to content

Optimize publish to avoid copying message data - #129

Merged
lalinsky merged 1 commit into
mainfrom
optimize-publish-no-copy
Jan 28, 2026
Merged

Optimize publish to avoid copying message data#129
lalinsky merged 1 commit into
mainfrom
optimize-publish-no-copy

Conversation

@lalinsky

Copy link
Copy Markdown
Owner

Added appendMany() to ConcurrentWriteBuffer that accepts multiple slices and appends them in a single operation. Modified publishMsgInternal() to build control line and headers separately, then use appendMany() to send [control+headers, msg.data, "\r\n"] without copying the message data.

Changes

  • Added ConcurrentWriteBuffer.appendMany() method that takes multiple slices
  • Refactored publishMsgInternal() to build control line and headers separately
  • Use appendMany() to avoid copying msg.data

Benefits

  • Reduces memory allocation - for a 1MB message, we now allocate ~4KB instead of ~1MB
  • Eliminates one copy of the message data - data is copied only once (into write_buffer chunks) instead of twice
  • Takes write_buffer's internal lock only once instead of multiple times

While socket I/O is still the main bottleneck, this optimization helps avoid unnecessary memory allocation and copying, especially for large messages.

Added appendMany() to ConcurrentWriteBuffer that accepts multiple slices
and appends them in a single operation. Modified publishMsgInternal() to
build control line and headers separately, then use appendMany() to send
[control+headers, msg.data, "\r\n"] without copying the message data.

This reduces memory allocation and eliminates one copy of the message data.
For large messages, we now allocate ~4KB instead of allocating space for
the entire message body.
@coderabbitai

coderabbitai Bot commented Jan 28, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR introduces a new appendMany() method to ConcurrentWriteBuffer that appends multiple slices in a single locked operation. connection.zig is updated to use this method instead of individual writes, reducing buffer copies and consolidating operations.

Changes

Cohort / File(s) Summary
Queue buffer optimization
src/queue.zig
Added appendMany(rt, slices) public method to ConcurrentWriteBuffer. Acquires mutex once to append multiple []const u8 slices atomically, with overflow-safe size validation, chunk allocation/sealing, and condition variable signaling. Returns PushError on closed/frozen state or allocation failure.
Connection buffering refactor
src/connection.zig
Replaced individual buffer writes with calls to appendMany(). Reduced control buffer capacity from MAX_CONTROL_LINE_SIZE + total_payload to MAX_CONTROL_LINE_SIZE + headers_len. Consolidated message data and trailing CRLF into a shared slices array for unified appending.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~15 minutes

Possibly related PRs

  • PR #64: Modifies connection buffering to route publishes/subscriptions through the shared write buffer, complementing this PR's appendMany addition.
  • PR #22: Introduces the original ConcurrentWriteBuffer implementation that this PR extends with the appendMany() method.
  • PR #60: Updates ConcurrentWriteBuffer with pause/resume/reset primitives that work alongside the new atomic append operation.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main optimization: avoiding message data copying in the publish operation by using the new appendMany() method.
Description check ✅ Passed The description clearly explains what was changed (added appendMany(), refactored publishMsgInternal()) and why (memory allocation and copying reduction).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch optimize-publish-no-copy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/queue.zig`:
- Around line 653-706: The appendMany implementation accumulates total_size and
later does self.queue.items_available += total_size without overflow checks;
replace the unchecked additions with overflow-checked arithmetic (use
`@addWithOverflow`) when summing total_size from slices and again when computing
new_items_available = self.queue.items_available + total_size, and if either
addition overflows return a suitable error (e.g., PushError.OutOfMemory or a new
overflow error); update the max_size checks to use the overflow-safe result so
the max_size guard cannot be bypassed by wrapping.

Comment thread src/queue.zig
Comment on lines +653 to +706
/// Append multiple slices to the buffer in a single operation
/// More efficient than calling append() multiple times as it only takes the lock once
pub fn appendMany(self: *Self, rt: *zio.Runtime, slices: []const []const u8) (zio.Cancelable || PushError)!void {
try self.queue.mutex.lock(rt);
defer self.queue.mutex.unlock(rt);

if (self.queue.is_closed) {
return PushError.QueueClosed;
}
if (self.queue.is_frozen) {
return PushError.BufferFrozen;
}

// Calculate total size needed
var total_size: usize = 0;
for (slices) |slice| {
total_size += slice.len;
}

// Check size limit before adding (overflow-safe)
if (self.queue.max_size > 0) {
const max_items = self.queue.max_size;
if (self.queue.items_available >= max_items) {
return PushError.OutOfMemory;
}
if (total_size > max_items - self.queue.items_available) {
return PushError.OutOfMemory;
}
}

// Append each slice
for (slices) |slice| {
var remaining = slice;

while (remaining.len > 0) {
const chunk = try self.queue.ensureWritableChunk();

const available = chunk.availableToWrite();
const to_write = @min(available, remaining.len);

@memcpy(chunk.getWriteSlice()[0..to_write], remaining[0..to_write]);

chunk.write_pos += to_write;
remaining = remaining[to_write..];

if (chunk.availableToWrite() == 0) {
chunk.is_sealed = true;
}
}
}

self.queue.items_available += total_size;
self.queue.data_cond.signal(rt);
}

@coderabbitai coderabbitai Bot Jan 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and examine the relevant lines
if [ -f "src/queue.zig" ]; then
  wc -l src/queue.zig
  echo "---"
  sed -n '653,706p' src/queue.zig
else
  echo "File not found, searching for queue.zig"
  find . -name "queue.zig" -type f
fi

Repository: lalinsky/nats.zig

Length of output: 2109


🏁 Script executed:

# Also check for any overflow handling utilities or patterns in the codebase
rg "@addWithOverflow|@mulWithOverflow|std.math.add" --type zig -B 2 -A 2

Repository: lalinsky/nats.zig

Length of output: 43


🏁 Script executed:

# Check if there's any existing overflow handling in queue operations
rg "appendMany|overflow|OutOfMemory" src/queue.zig -B 3 -A 3

Repository: lalinsky/nats.zig

Length of output: 3224


Guard against usize overflow in appendMany size accounting.

The code accumulates total_size and updates items_available with wrapping arithmetic (+=), not overflow-checked operations. If either wraps, the max_size checks are bypassed and the counter becomes corrupted. The misleading "overflow-safe" comment doesn't match the implementation. Use @addWithOverflow with error handling and fail if either accumulation overflows.

Proposed fix
-            var total_size: usize = 0;
-            for (slices) |slice| {
-                total_size += slice.len;
-            }
+            var total_size: usize = 0;
+            for (slices) |slice| {
+                const sum = `@addWithOverflow`(total_size, slice.len);
+                if (sum[1] != 0) return PushError.OutOfMemory;
+                total_size = sum[0];
+            }
@@
-            self.queue.items_available += total_size;
+            const new_total = `@addWithOverflow`(self.queue.items_available, total_size);
+            if (new_total[1] != 0) return PushError.OutOfMemory;
+            self.queue.items_available = new_total[0];
             self.queue.data_cond.signal(rt);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Append multiple slices to the buffer in a single operation
/// More efficient than calling append() multiple times as it only takes the lock once
pub fn appendMany(self: *Self, rt: *zio.Runtime, slices: []const []const u8) (zio.Cancelable || PushError)!void {
try self.queue.mutex.lock(rt);
defer self.queue.mutex.unlock(rt);
if (self.queue.is_closed) {
return PushError.QueueClosed;
}
if (self.queue.is_frozen) {
return PushError.BufferFrozen;
}
// Calculate total size needed
var total_size: usize = 0;
for (slices) |slice| {
total_size += slice.len;
}
// Check size limit before adding (overflow-safe)
if (self.queue.max_size > 0) {
const max_items = self.queue.max_size;
if (self.queue.items_available >= max_items) {
return PushError.OutOfMemory;
}
if (total_size > max_items - self.queue.items_available) {
return PushError.OutOfMemory;
}
}
// Append each slice
for (slices) |slice| {
var remaining = slice;
while (remaining.len > 0) {
const chunk = try self.queue.ensureWritableChunk();
const available = chunk.availableToWrite();
const to_write = @min(available, remaining.len);
@memcpy(chunk.getWriteSlice()[0..to_write], remaining[0..to_write]);
chunk.write_pos += to_write;
remaining = remaining[to_write..];
if (chunk.availableToWrite() == 0) {
chunk.is_sealed = true;
}
}
}
self.queue.items_available += total_size;
self.queue.data_cond.signal(rt);
}
/// Append multiple slices to the buffer in a single operation
/// More efficient than calling append() multiple times as it only takes the lock once
pub fn appendMany(self: *Self, rt: *zio.Runtime, slices: []const []const u8) (zio.Cancelable || PushError)!void {
try self.queue.mutex.lock(rt);
defer self.queue.mutex.unlock(rt);
if (self.queue.is_closed) {
return PushError.QueueClosed;
}
if (self.queue.is_frozen) {
return PushError.BufferFrozen;
}
// Calculate total size needed
var total_size: usize = 0;
for (slices) |slice| {
const sum = `@addWithOverflow`(total_size, slice.len);
if (sum[1] != 0) return PushError.OutOfMemory;
total_size = sum[0];
}
// Check size limit before adding (overflow-safe)
if (self.queue.max_size > 0) {
const max_items = self.queue.max_size;
if (self.queue.items_available >= max_items) {
return PushError.OutOfMemory;
}
if (total_size > max_items - self.queue.items_available) {
return PushError.OutOfMemory;
}
}
// Append each slice
for (slices) |slice| {
var remaining = slice;
while (remaining.len > 0) {
const chunk = try self.queue.ensureWritableChunk();
const available = chunk.availableToWrite();
const to_write = `@min`(available, remaining.len);
`@memcpy`(chunk.getWriteSlice()[0..to_write], remaining[0..to_write]);
chunk.write_pos += to_write;
remaining = remaining[to_write..];
if (chunk.availableToWrite() == 0) {
chunk.is_sealed = true;
}
}
}
const new_total = `@addWithOverflow`(self.queue.items_available, total_size);
if (new_total[1] != 0) return PushError.OutOfMemory;
self.queue.items_available = new_total[0];
self.queue.data_cond.signal(rt);
}
🤖 Prompt for AI Agents
In `@src/queue.zig` around lines 653 - 706, The appendMany implementation
accumulates total_size and later does self.queue.items_available += total_size
without overflow checks; replace the unchecked additions with overflow-checked
arithmetic (use `@addWithOverflow`) when summing total_size from slices and again
when computing new_items_available = self.queue.items_available + total_size,
and if either addition overflows return a suitable error (e.g.,
PushError.OutOfMemory or a new overflow error); update the max_size checks to
use the overflow-safe result so the max_size guard cannot be bypassed by
wrapping.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really not concened about the total size of each message

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lalinsky, understood. Zig's overflow detection in Debug and ReleaseSafe modes will catch any issues during development, and in practice message sizes won't approach usize limits.


✏️ Learnings added
Learnt from: lalinsky
Repo: lalinsky/nats.zig PR: 129
File: src/queue.zig:653-706
Timestamp: 2026-01-28T05:45:40.445Z
Learning: In the nats.zig project's ConcurrentWriteBuffer.appendMany() method in src/queue.zig, the maintainer is not concerned about explicit overflow checking for total message size calculations, preferring to rely on Zig's built-in overflow detection in Debug and ReleaseSafe modes rather than using addWithOverflow for size accounting.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: lalinsky
Repo: lalinsky/nats.zig PR: 108
File: src/dispatcher.zig:60-65
Timestamp: 2025-09-09T19:51:02.201Z
Learning: In the NATS Zig codebase dispatcher.zig, when decrementing pending_bytes (atomic u64) with message_data_len (usize) using fetchSub, explicit casting is not required as Zig handles the implicit conversion automatically and the code builds successfully.

Learnt from: lalinsky
Repo: lalinsky/nats.zig PR: 60
File: src/queue.zig:390-393
Timestamp: 2025-08-30T07:40:28.186Z
Learning: In the nats.zig project, the maintainer prefers to rely on Zig's built-in integer overflow detection rather than implementing manual saturated conversions for timeout calculations (timeout_ms * std.time.ns_per_ms). Zig automatically fails on integer overflow in Debug and ReleaseSafe modes.

Learnt from: lalinsky
Repo: lalinsky/nats.zig PR: 62
File: src/queue.zig:716-718
Timestamp: 2025-08-30T09:35:56.288Z
Learning: In ConcurrentWriteBuffer, gatherReadVectors should be a blocking operation with a timeout parameter (like getSlice and pop), blocking when no data is available OR when the buffer is frozen. It should return a VectorGather structure containing reset ID, first chunk reference, iovecs array, total bytes, and a consume method that validates the reset ID and first chunk to ensure thread safety and prevent concurrent consumers. The consume method should not check freeze state since it's consuming already-gathered data.

Learnt from: lalinsky
Repo: lalinsky/nats.zig PR: 63
File: src/socket.zig:79-83
Timestamp: 2025-08-30T12:18:09.213Z
Learning: In the nats.zig codebase, the maintainer (lalinsky) states that Socket.write() is atomic - either it writes some data or returns an error, never partial data with error. This may differ from standard std.net.Stream.write() semantics. writeAll() is described as NOT atomic because it can write partial data and then fail.

@lalinsky
lalinsky merged commit 2a8c375 into main Jan 28, 2026
8 checks passed
@lalinsky
lalinsky deleted the optimize-publish-no-copy branch January 28, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant