Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zstd compression in MN4 fails for large response payloads #11343

Closed
Jonathan-Hill opened this issue Nov 14, 2024 · 3 comments
Closed

Zstd compression in MN4 fails for large response payloads #11343

Jonathan-Hill opened this issue Nov 14, 2024 · 3 comments
Labels
type: bug Something isn't working

Comments

@Jonathan-Hill
Copy link

Expected Behavior

A large response payload (>32MB) should be compressed successfully when zstd compression is used, just as it is successful when other compression algorithms such as gzip are chosen.

Actual Behaviour

When Zstd is used, payloads larger than 32 MB fail to compress. The same payload works fine if gzip is used, eg. the client sends a request header of Accept-Encoding: gzip, deflate, br but fails if the header is Accept-Encoding: gzip, deflate, br, zstd
This seems to be caused by the ZstdOptions class passing in a value for maxEncodeSize of 32MB (as defined in the ZstdConstants class).
This has caused some requests for clients to start failing since we upgraded to MN4 (presumably because Zstd wasn't supported in MN and/or Netty prior to this?).
The following error is thrown, and no response is returned to the client at all:

io.netty.handler.codec.EncoderException: requested encode buffer size (33701888 bytes) exceeds the maximum allowable size (33554432 bytes)
	at io.netty.handler.codec.compression.ZstdEncoder.allocateBuffer(ZstdEncoder.java:120)
	at io.netty.handler.codec.compression.ZstdEncoder.allocateBuffer(ZstdEncoder.java:37)
	at io.netty.handler.codec.MessageToByteEncoder.write(MessageToByteEncoder.java:105)
	at io.netty.channel.AbstractChannelHandlerContext.invokeWrite0(AbstractChannelHandlerContext.java:893)
	at io.netty.channel.AbstractChannelHandlerContext.invokeWrite(AbstractChannelHandlerContext.java:875)
	at io.netty.channel.AbstractChannelHandlerContext.write(AbstractChannelHandlerContext.java:984)
	at io.netty.channel.AbstractChannelHandlerContext.write(AbstractChannelHandlerContext.java:868)
	at io.netty.channel.AbstractChannelHandlerContext.write(AbstractChannelHandlerContext.java:863)
	at io.netty.channel.DefaultChannelPipeline.write(DefaultChannelPipeline.java:959)
	at io.netty.channel.AbstractChannel.write(AbstractChannel.java:295)
	at io.netty.channel.embedded.EmbeddedChannel.write(EmbeddedChannel.java:754)
	at io.netty.channel.embedded.EmbeddedChannel.writeOutbound(EmbeddedChannel.java:432)
	at io.micronaut.http.server.netty.handler.Compressor$Session.push(Compressor.java:210)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler$OutboundHandler.writeCompressing0(PipeliningServerHandler.java:919)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler$OutboundHandler.writeCompressing(PipeliningServerHandler.java:913)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler$FullOutboundHandler.writeSome(PipeliningServerHandler.java:1017)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler.writeSome(PipeliningServerHandler.java:303)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler$OutboundAccessImpl.write(PipeliningServerHandler.java:816)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler$OutboundAccessImpl.writeFull(PipeliningServerHandler.java:846)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler$OutboundAccessImpl.write(PipeliningServerHandler.java:853)
	at io.micronaut.http.server.netty.RoutingInBoundHandler.lambda$writeResponse$0(RoutingInBoundHandler.java:299)
	at io.micronaut.core.execution.ImperativeExecutionFlowImpl.onComplete(ImperativeExecutionFlowImpl.java:132)
	at io.micronaut.http.server.netty.RoutingInBoundHandler.writeResponse(RoutingInBoundHandler.java:275)
	at io.micronaut.http.server.netty.NettyRequestLifecycle.handleNormal(NettyRequestLifecycle.java:105)
	at io.micronaut.http.server.netty.RoutingInBoundHandler.accept(RoutingInBoundHandler.java:236)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler$MessageInboundHandler.read(PipeliningServerHandler.java:394)
	at io.micronaut.http.server.netty.handler.PipeliningServerHandler.channelRead(PipeliningServerHandler.java:218)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:444)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:420)
	at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:412)
	at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:436)
	at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:346)
	at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:318)
	at io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:251)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:442)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:420)
	at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:412)
	at io.netty.handler.timeout.IdleStateHandler.channelRead(IdleStateHandler.java:289)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:442)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:420)
	at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:412)
	at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1357)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:440)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:420)
	at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:868)
	at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166)
	at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788)
	at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724)
	at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650)
	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997)
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
	at java.base/java.lang.Thread.run(Thread.java:833)

Steps To Reproduce

Clone https://github.com/jonhill1977/MN_zstd_demo (use branch master) then run the app with ./mvnw mn:run

git clone [email protected]:jonhill1977/MN_zstd_demo.git
cd MN_zstd_demo
./mvnw mn:run

Prove that gzip compression works for both small and large payloads:
curl --location 'http://localhost:8080/small' --header 'Accept-Encoding: gzip, deflate, br' --output -
curl --location 'http://localhost:8080/large' \--header 'Accept-Encoding: gzip, deflate, br' --output -

Prove that zstd works for small payloads, but fails for large payloads:
curl --location 'http://localhost:8080/small' --header 'Accept-Encoding: gzip, deflate, br, zstd' --output -
curl --location 'http://localhost:8080/large' \--header 'Accept-Encoding: gzip, deflate, br, zstd' --output -

You will see the first 3 requests all work (albeit curl outputs gibberish since it's compressed), but the 4th request causes an exception on the server. No response is sent to the client at all, and curl just sits waiting for a response.

This is problematic, because requests which used to work prior to MN4 now fail for some users.
The Chrome browser supports Zstd, therefore send an Accept-Encoding header like the second example - this causes Zstd to be used, which fails for large response payloads.
Safari (or a request sent via Postman using default options) does not support Zstd, therefore send an Accept-Encoding header like the first example, and the request works.
Since Zstd support seems to have been added in MN4, this means that users who use Chrome browser now experience failures from endpoints that used to work in previous versions of MN.

It would be good if we could solve this by:

  • Allow us to configure the compression options (e.g. via the MN config file), to set the maximum payload size allowed for Zstd compression (e.g. we could set this to a very high number to effectively allow any payload size for Zstd)
  • Prevent Zstd being used if the payload is larger than the max allowed, since we know compression will fail and no response will be returned to the client at all (e.g. maybe in the determineEncoding method of the io.micronaut.http.server.netty.handler.Compressor class?). If the payload is too large for a particular compression option, it should choose the "next best" option which can handle the data.

Environment Information

  • Operating System: MacOS Sequoia 15.1 (24B83)
  • JDK version: 21
    Note: I believe both of these are irrelevant to this bug report and the same issue would occur on any OS / JDK.

Example Application

https://github.com/jonhill1977/MN_zstd_demo

Version

4.7.0

@graemerocher graemerocher added the type: bug Something isn't working label Nov 14, 2024
@graemerocher graemerocher moved this to Todo in 4.7.1 Release Nov 14, 2024
@graemerocher
Copy link
Contributor

Thanks for the comprehensive report, we will look into it

@glorrian
Copy link
Contributor

glorrian commented Nov 20, 2024

It falls due to the exception that Netty sends during encoding when it tries to allocate a buffer. The error indicates that bufferSize exceeded the constant variable maxEncodeSize, which is specified in the original netty code by default and cannot be adjusted.

 if (bufferSize > maxEncodeSize || 0 > bufferSize) {
            throw new EncoderException("requested encode buffer size (" + bufferSize + " bytes) exceeds " +
                    "the maximum allowable size (" + maxEncodeSize + " bytes)");
        }
switch (encoding) {
                        case BR -> var10000 = this.makeBrotliEncoder();
                        case ZSTD -> var10000 = new ZstdEncoder(this.zstdOptions.compressionLevel(), this.zstdOptions.blockSize(), this.zstdOptions.maxEncodeSize());
                        case SNAPPY -> var10000 = new SnappyFrameEncoder();
                        case GZIP -> var10000 = ZlibCodecFactory.newZlibEncoder(ZlibWrapper.GZIP, this.gzipOptions.compressionLevel(), this.gzipOptions.windowBits(), this.gzipOptions.memLevel());
                        case DEFLATE -> var10000 = ZlibCodecFactory.newZlibEncoder(ZlibWrapper.ZLIB, this.deflateOptions.compressionLevel(), this.deflateOptions.windowBits(), this.deflateOptions.memLevel());
                        default -> throw new IncompatibleClassChangeError();
                    }
final class ZstdConstants {

    /**
     * Default compression level
     */
    static final int DEFAULT_COMPRESSION_LEVEL = Zstd.defaultCompressionLevel();

    /**
     * Min compression level
     */
    static final int MIN_COMPRESSION_LEVEL = Zstd.minCompressionLevel();

    /**
     * Max compression level
     */
    static final int MAX_COMPRESSION_LEVEL = Zstd.maxCompressionLevel();

    /**
     * Max block size
     */
    static final int MAX_BLOCK_SIZE = 1 << (DEFAULT_COMPRESSION_LEVEL + 7) + 0x0F;   //  32 M
    /**
     * Default block size
     */
    static final int DEFAULT_BLOCK_SIZE = 1 << 16;  // 64 KB

    private ZstdConstants() { }
}

The only thing that can be done is to output the config parameter to the micronaut to configure maxEncodeSize manually by the user

@sdelamo
Copy link
Contributor

sdelamo commented Nov 22, 2024

I assume this is closed via #11361

@sdelamo sdelamo closed this as completed Nov 22, 2024
@github-project-automation github-project-automation bot moved this from Todo to Done in 4.7.1 Release Nov 22, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: bug Something isn't working
Projects
No open projects
Status: Done
Development

No branches or pull requests

4 participants