Skip to content

Feature/nio direct bytebuffer encoder - #345

Draft
agentgt wants to merge 3 commits into
mainfrom
feature/nio-direct-bytebuffer-encoder
Draft

Feature/nio direct bytebuffer encoder#345
agentgt wants to merge 3 commits into
mainfrom
feature/nio-direct-bytebuffer-encoder

Conversation

@agentgt

@agentgt agentgt commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

No description provided.

No output in the codebase today actually relies on LogOutput's default
write(LogEvent, ByteBuffer, ContentType) - FileChannelOutput (the only
output advertising WriteMethod.BYTE_BUFFER) overrides that method itself
and bypasses the default entirely, and the only Buffer implementations
that exist (StringBuilderBuffer, JsonBuffer) both drain through the
byte[]/String overloads instead. So the default's correctness has never
actually been exercised.

Builds a real java.nio FileChannel-backed LogOutput plus a real
ByteBuffer-backed encoder Buffer (no mocks), driven through a full
RainbowGum route, to check it directly. Two scenarios:

- unflippedPositionAsLengthConventionWritesFullContent: the buffer is
  left unflipped (position == written length) after encoding, matching
  what the default method's `new byte[buf.position()]` sizing expects.
  Writes correctly.
- standardFlippedBufferConventionSilentlyWritesNothing: the buffer is
  flip()'d before drain, the standard java.nio idiom (write, then flip
  before handing off to be read/written) that any real future NIO output
  would very plausibly use. The default method still sizes off
  buf.position(), which is now 0 post-flip, so it silently writes a
  zero-length array instead of the actual content - quiet data loss, not
  an exception.

Confirms Adam's suspicion: LogOutput.write(LogEvent, ByteBuffer,
ContentType)'s default implementation only works for the non-standard
"position tracks written length, never flipped" convention, and produces
silent data loss for the conventional flipped-buffer NIO idiom. Not fixed
here - test only, pending a decision on which convention to standardize
on (switching to buf.remaining()/buf.get(arr) without rewind() would fix
the flipped case but break the unflipped one, and nothing in the
codebase depends on either today).
Sized its array off buf.position() and rewind()'d, which only worked for
a non-standard "buffer never flipped, position tracks written length"
convention - the standard java.nio idiom of filling a buffer then
flip()'ing it before handing it off (or building one with
ByteBuffer.wrap(byte[])) silently produced a zero-length write instead
of the actual content. Switched to buf.remaining()/buf.get(arr), the
conventional way to drain a buffer that's ready for reading, and
documented the expected contract. Nothing in the codebase depended on
the old convention (see previous commit), so this is a pure fix, not a
behavior change for any real output.

Updates NioByteBufferOutputTest to match: the flipped-buffer test now
asserts correct full content instead of demonstrating the bug, and adds
a second test confirming ByteBuffer.wrap(byte[]) (also standard, and
what a real caller would likely reach for first) works too.
…coder

DirectByteBufferEncoder/DirectByteBufferBuffer format into a reused
StringBuilder (same as the standard path) but then encode directly into a
reused ByteBuffer via a CharsetEncoder, skipping the intermediate
String/byte[] allocation that LogOutput.write(LogEvent, String)'s default
otherwise does on every call - the same technique Log4j2's garbage-free
encoders use, and one of the likely reasons it edges out RainbowGum in
some non-virtual-thread benchmarks. Sizes the byte buffer upfront from
CharsetEncoder.maxBytesPerChar() so a single encode() call can't overflow,
rather than a more complex overflow/retry loop.

Depends on the LogOutput.write(LogEvent, ByteBuffer, ContentType) fix
from the previous commit - this is exactly the API it exists to future-
proof for.

Pair with LogAppender.AppenderFlag#REUSE_BUFFER to get the full benefit,
since the byte buffer (and any grown capacity) only persists across
events if the appender keeps one Buffer instance instead of creating a
fresh one per event.

New module, no ServiceProvider registration - opt-in via
.encoder(DirectByteBufferEncoder.of(formatter)) like any other encoder,
not wired into any default. Tests are real full RainbowGum loads into a
real ListLogOutput (no mocks): ASCII, multi-byte UTF-8 (including a
surrogate-pair emoji), a message forcing buffer growth well past the
initial 8KB capacity, and a REUSE_BUFFER two-events-in-a-row case
guarding against a longer message's bytes leaking into a following
shorter one.
@agentgt
agentgt marked this pull request as draft August 24, 2026 16:08
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