This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git
The following commit(s) were added to refs/heads/main by this push:
new b88cae3d47 Add zstd compression and decompression for pekko streams
(heap buffer APIs) (#3639)
b88cae3d47 is described below
commit b88cae3d47c8457e8bc49558e52d7be4f120ca9f
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Oct 23 21:34:10 2026 +0100
Add zstd compression and decompression for pekko streams (heap buffer APIs)
(#3639)
* Add zstd compression and decompression for pekko streams
* Use zstd-jni heap buffer streaming APIs for zstd compression
Motivation:
zstd-jni 1.5.7-23 added ZstdCompressCtx.compressByteBufferStream and
ZstdDecompressCtx.decompressByteBufferStream, which accept array-backed
heap ByteBuffers (luben/zstd-jni#324). The zstd stages no longer need to
copy every element into fixed size direct buffers.
Modification:
- Upgrade zstd-jni to 1.5.7-24.
- Rewrite ZstdCompressor on ZstdCompressCtx and ZstdDecompressor on
ZstdDecompressCtx using heap buffers.
- Add ZstdBuffers to feed ByteString fragments to zstd and to hand
output arrays to ByteStrings without copying where possible.
- Fail zstdDecompress on truncated input, emit chunks of at most
maxBytesPerChunk and pass the dictionary offset, length and level to
ZstdDictCompress in the right order.
- Revert the ByteBufferCleaner visibility change, it is no longer used.
Result:
zstd compression and decompression avoid direct buffers and most array
copies, and detect truncated streams.
Tests:
- sbt "stream-tests/testOnly org.apache.pekko.stream.io.compression.*
org.apache.pekko.stream.scaladsl.CompressionSpec": passed
- sbt "stream/mimaReportBinaryIssues": passed
References:
Refs #2404, Refs #2409, Refs luben/zstd-jni#324
* Add ByteString.asByteBuffersUnsafe for zero-copy heap buffers
Motivation:
zstd-jni needs array-backed buffers, but asByteBuffers returns read-only
buffers with no accessible array and code outside ByteString can only get
at a sliced fragment's bytes via toArrayUnsafe, which copies.
Modification:
Add private[pekko] ByteString.asByteBuffersUnsafe, which wraps each
non-empty fragment's backing array (with its start index and length)
without copying, and use it for the zstd input buffers.
Result:
zstd compression and decompression no longer copy sliced or fragmented
input ByteStrings.
Tests:
- sbt "actor-tests/testOnly org.apache.pekko.util.ByteString*": passed
- sbt "stream-tests/testOnly org.apache.pekko.stream.io.compression.*
org.apache.pekko.stream.scaladsl.CompressionSpec": passed
- sbt "actor/mimaReportBinaryIssues" "stream/mimaReportBinaryIssues": passed
References:
Refs #2409, Refs luben/zstd-jni#324
* Make the zstd-jni dependency of pekko-stream optional
Motivation:
zstd-jni is a 6.5 MB jar of native libraries that most pekko-stream
users do not need. The Compression objects called zstd-jni during their
initialization, so gzip and deflate users loaded the native library and
would fail without zstd-jni on the classpath.
Modification:
- Declare zstd-jni as Optional for pekko-stream (and as an optional
OSGi import), add it to the stream-tests test classpath.
- Replace the zstd-jni calls for the zstd constants in the scaladsl and
javadsl Compression objects with libzstd's values, documented in code.
- Document the dependency in the zstd operator docs.
Result:
pekko-stream does not pull in zstd-jni, and the other compression
operators work without it.
Tests:
- sbt "stream-tests/testOnly org.apache.pekko.stream.io.compression.*
org.apache.pekko.stream.scaladsl.CompressionSpec": passed,
including a gzip/deflate round trip in a class loader without zstd-jni
and a check that the constants match zstd-jni
- sbt "stream/mimaReportBinaryIssues": passed
- sbt "stream/makePom" "stream/packageBin": zstd-jni is optional in the
POM and the OSGi Import-Package
References:
Refs #2409, Refs luben/zstd-jni#324
* Add JMH benchmark for gzip and zstd compression flows
Motivation:
There were no benchmarks for the stream compression operators.
Modification:
Add CompressionBenchmark comparing gzip and zstd compression and
decompression of 16 MiB of JSON-like text in 1 KiB and 64 KiB elements,
and add zstd-jni to bench-jmh (it is optional in pekko-stream).
Result:
The zstd operators can be benchmarked against gzip.
Tests:
- sbt "bench-jmh/Jmh/run -wi 3 -w 2s -i 5 -r 2s -f 1
.*CompressionBenchmark.*": ran
References:
Refs #2409
* Only hand over nearly full zstd output buffers without copying
Motivation:
ZstdBuffers.takeOutput handed the output array over to the emitted
ByteString once it was half full, so e.g. 64 KiB decompressed chunks kept
a 128 KiB array alive and every chunk allocated a new 128 KiB array.
Modification:
Hand the array over only when at least 7/8 of it is used, otherwise copy
the output into an exact-size array and reuse the buffer.
Result:
Emitted ByteStrings retain at most 1/8 unused space.
Tests:
- sbt "stream-tests/testOnly org.apache.pekko.stream.io.compression.*
org.apache.pekko.stream.scaladsl.CompressionSpec": passed
References:
Refs #2409
* Remove the zstd-jni default from ZstdDecompressor
Motivation:
ZstdDecompressor's maxBytesPerChunk defaulted to Zstd.blockSizeMax(), the
only
zstd default not taken from the documented Compression constants. The gzip
and
deflate decompressor stages have no default, and every caller passes the
value.
Modification:
Make maxBytesPerChunk a required parameter of ZstdDecompressor.
Result:
ZstdDecompressor matches the other decompressor stages, and callers pick the
chunk size explicitly (Compression.zstdDecompress already does).
Tests:
sbt "stream/scalafmt" "stream-tests/testOnly
org.apache.pekko.stream.io.compression.*"
(145 succeeded, 0 failed)
References:
Review comment
https://github.com/apache/pekko/pull/3639#issuecomment-6347243585
* Keep the zstd-free class loader open in CompressionWithoutZstdSpec
Closing the URLClassLoader right after the nested ActorSystem terminated
let a late dispatcher shutdown task fail with NoClassDefFoundError, which
jvm-exit-on-fatal-error turned into an exit of the forked stream-tests JVM.
Leave the loader open and disable jvm-exit-on-fatal-error for that system.
* Tidy zstd test idioms
Motivation:
Review nits: an[ClassNotFoundException] should read
a[ClassNotFoundException],
and charset names should not be passed as strings.
Modification:
Use a[ClassNotFoundException] in CompressionWithoutZstdSpec and
StandardCharsets.UTF_8 in ZstdSpec.
Result:
No behaviour change.
Tests:
sbt "stream-tests/testOnly *ZstdSpec *CompressionWithoutZstdSpec" (24
passed)
References:
None
* Fix zstd decompression of chunk-aligned output and address review
Motivation:
ZstdDecompressor failed valid streams whose decompressed size is an exact
multiple of maxBytesPerChunk. When the output buffer filled exactly at the
end
of a frame, outputPending stayed true and one more decompress call was made.
zstd then waited for a new frame and the stage failed with "Truncated zstd
stream" after all data had been emitted. The default chunk is 128 KiB, so
any
128 KiB-aligned payload was affected. The review also found an incomplete
operator index, inaccurate operator docs, String dictionaries encoded with
the
platform default charset, and a Java source under src/main/scala.
Modification:
- Only treat output as pending while the frame is incomplete: a completed
frame
is fully flushed.
- Add the zstd operators to the generated stream operator index.
- Document the zstd overloads and autoFlush, place Dependency before
Signature,
note that zstdDecompress does not support dictionaries, and fix the
zstdDecompress error description.
- Encode String dictionaries as UTF-8 in the scaladsl and javadsl
ZstdDictionary.
- Move javadsl ZstdDictionary.java to src/main/java and make its field
private.
- Use imports instead of fully qualified names in new code.
Result:
Chunk-aligned and concatenated zstd streams decompress correctly, and String
dictionaries give the same bytes on every node.
Tests:
- New ZstdSpec test for chunk-aligned single and concatenated frames (fails
without the fix with "Truncated zstd stream").
- New ZstdSpec test for UTF-8 String dictionaries.
- sbt "actor-tests/testOnly org.apache.pekko.util.ByteStringSpec" (246
passed)
- sbt "stream-tests/testOnly org.apache.pekko.stream.io.compression.*" (149
passed)
- sbt "actor/mimaReportBinaryIssues" "stream/mimaReportBinaryIssues"
- scalafmt and javafmtCheck (JDK 17)
References:
Review
https://github.com/apache/pekko/pull/3639#pullrequestreview-5624956465
* Add dictionary support to zstdDecompress
Motivation:
Compression.zstd accepts a dictionary, but zstdDecompress did not, so output
compressed with a dictionary could not be decompressed with Pekko Streams.
Modification:
- Add zstdDecompress(maxBytesPerChunk, dictionary) to the scaladsl and
javadsl
Compression APIs.
- ZstdDecompressor builds one ZstdDictDecompress per stage, which digests
the
dictionary (or its offset/length slice, without copying it in Java) once,
and
loads it by reference into the decompression context of every
materialization. The dictionary level only applies to compression and is
ignored. A dictionary with only one of offset and length is rejected when
the
flow is created.
- Document the new overload.
Result:
Data compressed with a zstd dictionary can be decompressed with
zstdDecompress.
Tests:
- New ZstdSpec tests: round trips with a whole dictionary and a dictionary
slice
through the scaladsl and javadsl APIs, using incompressible data so the
frames
must reference the dictionary; decompressing without the dictionary
fails; an
invalid offset/length combination is rejected.
- sbt "stream-tests/testOnly org.apache.pekko.stream.io.compression.*
org.apache.pekko.stream.scaladsl.CompressionSpec"
(158 passed)
- sbt "stream/mimaReportBinaryIssues" "bench-jmh/compile"
References:
https://github.com/apache/pekko/pull/3639
* feat: mark the zstd compression API as ApiMayChange
Motivation:
The zstd API is new in 2.0.0 and is likely to change: limiting the
compression and decompression windows (RFC 9659) needs new parameters
once zstd-jni exposes them, and the scaladsl ZstdDictionary case class
cannot gain fields without breaking binary compatibility.
Modification:
- Mark the zstd and zstdDecompress overloads and the Zstd* constants in
scaladsl and javadsl Compression as @ApiMayChange.
- Mark the scaladsl ZstdDictionary class and companion, and the javadsl
ZstdDictionary class, as @ApiMayChange.
- Document toImpl as INTERNAL API in both ZstdDictionary classes, since
the javadsl one is public to Java callers.
Result:
The zstd API can still evolve after 2.0.0.
Tests:
- Annotations and docs only, no behaviour change
- sbt "stream/compile" "stream/javafmtCheck"
- scalafmt on the changed Scala files
References:
https://github.com/apache/pekko/pull/3639
* feat: add zstd compression and decompression settings, with windowLog
Motivation:
Every new zstd option needed another Compression.zstd or zstdDecompress
overload in both DSLs. Limiting the compression window (RFC 9659 caps the
HTTP zstd content-coding at windowLog 23) would have added a fourth
compression overload, and the decompression window limit is next.
Modification:
- Add ZstdCompressionSettings (level, autoFlush, windowLog, dictionary) and
ZstdDecompressionSettings (maxBytesPerChunk, dictionary) in
org.apache.pekko.stream, shared by both DSLs like RestartSettings. They
are
final classes with private constructors and withX methods, so fields can
be added compatibly. withDictionary accepts the scaladsl and the javadsl
ZstdDictionary. Both are @ApiMayChange.
- Add zstd(settings) and zstdDecompress(settings) to both DSLs. Keep zstd,
zstd(level, dictionary) and zstdDecompress(maxBytesPerChunk), and remove
the unreleased zstd(level, dictionary, autoFlush) and
zstdDecompress(maxBytesPerChunk, dictionary) overloads.
- ZstdCompressor sets ZstdCompressCtx.setWindowLog when windowLog is not 0.
Add the ZstdMinWindowLog and ZstdMaxWindowLog constants.
- Update the zstd and zstdDecompress operator pages.
Result:
The zstd window can be limited for peers such as HTTP clients, and new zstd
options no longer need new overloads.
Tests:
- New ZstdSpec tests: a frame compressed at level 22 is rejected by a
decoder
limited to windowLog 23 and accepted with withWindowLog(23), from both
DSLs and with a dictionary; settings defaults, validation and withX
methods. Existing dictionary and auto-flush tests use the settings.
- sbt "stream-tests/testOnly ...ZstdSpec ...ZstdAutoFlushSpec
...CompressionWithoutZstdSpec" (62 passed, 1 canceled by an existing assume)
- sbt "++3.3.8" "stream/compile" "stream-tests/Test/compile"
- sbt "stream/javafmtCheck", "stream/headerCreate", scalafmt on changed
files
References:
https://github.com/apache/pekko/pull/3639
Refs #3674
* fix: release zstd compressor native resources on setup failure and close
Motivation:
Review of #3639 found that ZstdCompressor could leak native memory: a
failing dictionary setup (e.g. a negative slice offset) threw after the
ZstdCompressCtx was allocated, and the stage never got to close it. A
ZstdDictCompress was never closed explicitly, so its memory waited for
finalization and leaked with finalization disabled. Neither has a Cleaner.
Modification:
- Close the context if setting the level, window log or dictionary fails,
and close a ZstdDictCompress if loading it into the context fails.
- Keep the ZstdDictCompress and close it after the context in close().
- Make the javadsl ZstdDictionary final.
Result:
The zstd compressor releases its native memory on every path.
Tests:
- New ZstdSpec tests: the context is closed after a failed setup, and both
dictionary-with-level variants are closed with the compressor. Both fail
without the fix.
- sbt "stream-tests/testOnly ...ZstdSpec ...ZstdAutoFlushSpec
...CompressionWithoutZstdSpec ...CompressionSpec"
- sbt "++3.3.8" "stream-tests/Test/compile"
- sbt "stream/javafmtCheck", scalafmt on the changed Scala files
References:
https://github.com/apache/pekko/pull/3639
* fix: free the zstd decompression dictionary with its context
Motivation:
ZstdDecompressor digested the dictionary once into a ZstdDictDecompress
shared by every materialization. It could not be closed when one
materialization stopped and has no Cleaner, so its native memory was only
freed by finalization, and never with finalization disabled. This is the
decompression side of the ZstdDictCompress review finding on #3639.
Modification:
- Load the dictionary bytes into each materialization's ZstdDecompressCtx,
which owns the digested dictionary and frees it when the stage closes
the context.
- Copy the dictionary bytes, or the slice, when the operator is created, and
validate the slice bounds there.
- Close the context if loading the dictionary fails, since postStop is not
called then.
Result:
The zstd decompressor no longer relies on finalization to free dictionary
memory. Each materialization digests the dictionary itself, as the
compressor already does for dictionaries without a level.
Tests:
- New ZstdSpec tests: slices outside the dictionary are rejected when the
operator is created, and the operator decompresses with a copy of the
dictionary, for more than one materialization. The native memory itself
is not observable from the tests.
- sbt "stream-tests/testOnly ...ZstdSpec ...ZstdAutoFlushSpec
...CompressionWithoutZstdSpec ...CompressionSpec" (73 passed, 1 canceled by an
existing assume)
- scalafmt on the changed files
References:
https://github.com/apache/pekko/pull/3639
* fix: validate zstd compression dictionary slices and allow them without a
level
Motivation:
Review of #3639: ZstdCompressor did not check that a dictionary slice is
within the dictionary, so a bad offset or length failed in zstd-jni with
a less clear error. A slice without a level, which the Java
ZstdDictionary constructors allow and zstdDecompress accepts, was
rejected by the compressor.
Modification:
- Share the slice bounds check between ZstdCompressor and
ZstdDecompressor in the internal ZstdDictionaryImpl object.
- ZstdCompressor checks the slice before digesting it, and loads a copy
of the slice into the context when no level is set.
Result:
Out-of-bounds dictionary slices fail with the same
IllegalArgumentException for compression and decompression, and a slice
without a level can be used for compression.
Tests:
- sbt "stream-tests/testOnly
org.apache.pekko.stream.io.compression.ZstdSpec
org.apache.pekko.stream.io.compression.ZstdAutoFlushSpec
org.apache.pekko.stream.io.compression.CompressionWithoutZstdSpec": 76 passed
- With the ZstdCompressor change reverted, the new slice tests fail
References:
Refs #3639
* fix: hold the scaladsl ZstdDictionary bytes in a ByteString
Motivation:
Review of #3639: the scaladsl ZstdDictionary is a case class with an
Array[Byte] field, so it was mutable and its equals and hashCode
compared the array by reference.
Modification:
- The dictionary field is now an immutable ByteString. The Array[Byte]
factory methods copy the array.
- toImpl hands the operators a copy of the bytes, so whoever holds the
impl cannot modify the dictionary.
Result:
The scaladsl ZstdDictionary is immutable and has value semantics.
Tests:
- sbt "stream-tests/testOnly
org.apache.pekko.stream.io.compression.ZstdSpec
org.apache.pekko.stream.io.compression.ZstdAutoFlushSpec
org.apache.pekko.stream.io.compression.CompressionWithoutZstdSpec
org.apache.pekko.stream.scaladsl.CompressionSpec": 81 passed
- sbt "++3.3" "stream-tests/Test/compile": ok
References:
Refs #3639
* fix: make the javadsl ZstdDictionary immutable
Motivation:
The javadsl ZstdDictionary had setters and held the caller's byte[],
so it could be changed after an operator was created from it, and it
compared by reference. The scaladsl ZstdDictionary was already made
immutable.
Modification:
- Final fields, with the bytes held in a ByteString. The byte[]
constructors copy the array.
- Remove the setters. getDictionary returns the ByteString.
- Add equals, hashCode and toString.
- toImpl hands the operators a copy of the bytes.
Result:
The javadsl ZstdDictionary is immutable and has value semantics, the
same as the scaladsl one.
Tests:
- sbt "stream/javafmt" (JDK 17)
- sbt "stream-tests/testOnly
org.apache.pekko.stream.io.compression.ZstdSpec
org.apache.pekko.stream.io.compression.ZstdAutoFlushSpec
org.apache.pekko.stream.io.compression.CompressionWithoutZstdSpec
org.apache.pekko.stream.scaladsl.CompressionSpec": 83 passed
- sbt "++3.3" "stream-tests/Test/compile": ok
References:
Refs #3639
---------
Co-authored-by: Matthew de Detrich <[email protected]>
---
.../org/apache/pekko/util/ByteStringSpec.scala | 30 ++
.../scala/org/apache/pekko/util/ByteString.scala | 30 ++
.../pekko/stream/io/CompressionBenchmark.scala | 108 +++++++
.../paradox/stream/operators/Compression/zstd.md | 57 ++++
.../stream/operators/Compression/zstdDecompress.md | 43 +++
docs/src/main/paradox/stream/operators/index.md | 4 +
project/Dependencies.scala | 7 +-
project/OSGi.scala | 2 +-
.../pekko/stream/io/compression/CoderSpec.scala | 7 +-
.../compression/CompressionWithoutZstdSpec.scala | 91 ++++++
.../pekko/stream/io/compression/DeflateSpec.scala | 2 +-
.../pekko/stream/io/compression/GzipSpec.scala | 4 +-
.../stream/io/compression/ZstdAutoFlushSpec.scala | 31 ++
.../pekko/stream/io/compression/ZstdSpec.scala | 343 +++++++++++++++++++++
.../pekko/stream/scaladsl/CompressionSpec.scala | 15 +-
.../stream/javadsl/compression/ZstdDictionary.java | 194 ++++++++++++
.../pekko/stream/ZstdCompressionSettings.scala | 121 ++++++++
.../pekko/stream/ZstdDecompressionSettings.scala | 92 ++++++
.../stream/impl/io/compression/ZstdBuffers.scala | 56 ++++
.../impl/io/compression/ZstdCompressor.scala | 142 +++++++++
.../impl/io/compression/ZstdDecompressor.scala | 129 ++++++++
.../impl/io/compression/ZstdDictionaryImpl.scala | 37 +++
.../apache/pekko/stream/javadsl/Compression.scala | 104 +++++++
.../apache/pekko/stream/scaladsl/Compression.scala | 114 +++++++
.../scaladsl/compression/ZstdDictionary.scala | 86 ++++++
25 files changed, 1838 insertions(+), 11 deletions(-)
diff --git
a/actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala
b/actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala
index 271d982c0e..9e45d4dd51 100644
--- a/actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala
+++ b/actor-tests/src/test/scala/org/apache/pekko/util/ByteStringSpec.scala
@@ -2277,6 +2277,36 @@ class ByteStringSpec extends AnyWordSpec with Matchers
with Checkers {
}
}
+ "asByteBuffersUnsafe" in {
+ check { (a: ByteString) =>
+ a.asByteBuffersUnsafe.foldLeft(ByteString.empty) { (bs, bb) =>
+ bs ++ ByteString(bb)
+ } == a
+ }
+ check { (a: ByteString) =>
+ a.asByteBuffersUnsafe.forall(bb => bb.hasArray && !bb.isReadOnly &&
bb.hasRemaining)
+ }
+ check { (a: ByteString) =>
+ a.asByteBuffersUnsafe.size == a.asByteBuffers.count(_.hasRemaining)
+ }
+ }
+
+ "asByteBuffersUnsafe wraps the backing arrays without copying" in {
+ val array = Array[Byte](0, 1, 2, 3, 4, 5)
+ val compact = ByteString1C(array)
+ compact.asByteBuffersUnsafe.map(_.array()).toSeq should ===(Seq(array))
+
+ val sliced = ByteString1(array, 1, 3)
+ val slicedBuffer = sliced.asByteBuffersUnsafe.head
+ (slicedBuffer.array() should be).theSameInstanceAs(array)
+ (slicedBuffer.position(), slicedBuffer.limit()) should ===((1, 4))
+
+ val other = Array[Byte](6, 7)
+ val fragmented = sliced ++ ByteString1C(other) ++ ByteString1(array,
4, 2)
+ fragmented.asByteBuffersUnsafe.map(_.array()).toSeq should
===(Seq(array, other, array))
+ ByteString.empty.asByteBuffersUnsafe shouldBe empty
+ }
+
"toString should start with ByteString(" in {
check { (bs: ByteString) =>
bs.toString.startsWith("ByteString(")
diff --git a/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
b/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
index ee94979efe..0aa17cf7e9 100644
--- a/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
+++ b/actor/src/main/scala/org/apache/pekko/util/ByteString.scala
@@ -201,6 +201,9 @@ object ByteString {
override def asByteBuffers:
scala.collection.immutable.Iterable[ByteBuffer] = List(asByteBuffer)
+ private[pekko] override def asByteBuffersUnsafe:
immutable.Iterable[ByteBuffer] =
+ if (isEmpty) Nil else List(ByteBuffer.wrap(bytes))
+
override def decodeString(charset: String): String =
if (isEmpty) "" else new String(bytes, charset)
@@ -618,6 +621,12 @@ object ByteString {
def asByteBuffers: scala.collection.immutable.Iterable[ByteBuffer] =
List(asByteBuffer)
+ private[pekko] override def asByteBuffersUnsafe:
immutable.Iterable[ByteBuffer] =
+ if (isEmpty) Nil else List(byteBufferUnsafe)
+
+ /** INTERNAL API: see [[ByteString.asByteBuffersUnsafe]] */
+ private[pekko] def byteBufferUnsafe: ByteBuffer = ByteBuffer.wrap(bytes,
startIndex, length)
+
override def decodeString(charset: String): String =
if (isEmpty) ""
else new String(bytes, startIndex, length, charset)
@@ -1187,6 +1196,12 @@ object ByteString {
def asByteBuffers: scala.collection.immutable.Iterable[ByteBuffer] =
List(first.asByteBuffer, second.asByteBuffer)
+ private[pekko] override def asByteBuffersUnsafe:
immutable.Iterable[ByteBuffer] = {
+ val builder = new VectorBuilder[ByteString1]
+ addFragmentsTo(builder)
+ builder.result().collect { case fragment if fragment.nonEmpty =>
fragment.byteBufferUnsafe }
+ }
+
override def asInputStream: InputStream =
new SequenceInputStream(Iterator(first.asInputStream,
second.asInputStream).asJavaEnumeration)
@@ -1753,6 +1768,9 @@ object ByteString {
def asByteBuffers: scala.collection.immutable.Iterable[ByteBuffer] =
bytestrings.map { _.asByteBuffer }
+ private[pekko] override def asByteBuffersUnsafe:
immutable.Iterable[ByteBuffer] =
+ bytestrings.collect { case fragment if fragment.nonEmpty =>
fragment.byteBufferUnsafe }
+
override def asInputStream: InputStream =
new
SequenceInputStream(bytestrings.iterator.map(_.asInputStream).asJavaEnumeration)
@@ -2826,6 +2844,18 @@ object ByteString {
*/
def asByteBuffers: immutable.Iterable[ByteBuffer]
+ /**
+ * INTERNAL API
+ *
+ * Returns array-backed ByteBuffers that wrap the non-empty fragments of
this ByteString without
+ * copying, for APIs that need heap buffers with an accessible array
(read-only buffers have no
+ * accessible array). Unlike [[asByteBuffers]] the buffers are writable and
expose the backing
+ * arrays, so callers must never write to them nor leak them. The bytes of
each fragment are
+ * between the position and the limit of its buffer. Empty for an empty
ByteString.
+ */
+ private[pekko] def asByteBuffersUnsafe: immutable.Iterable[ByteBuffer] =
+ if (isEmpty) Nil else List(ByteBuffer.wrap(toArrayUnsafe()))
+
/**
* Java API: Returns an Iterable of read-only ByteBuffers that directly
wraps this ByteStrings
* all fragments. Will always have at least one entry.
diff --git
a/bench-jmh/src/main/scala/org/apache/pekko/stream/io/CompressionBenchmark.scala
b/bench-jmh/src/main/scala/org/apache/pekko/stream/io/CompressionBenchmark.scala
new file mode 100644
index 0000000000..c9bbd7ce65
--- /dev/null
+++
b/bench-jmh/src/main/scala/org/apache/pekko/stream/io/CompressionBenchmark.scala
@@ -0,0 +1,108 @@
+/*
+ * 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 org.apache.pekko.stream.io
+
+import java.util.concurrent.TimeUnit
+
+import scala.collection.immutable
+import scala.concurrent.Await
+import scala.concurrent.duration._
+import scala.util.Random
+
+import org.openjdk.jmh.annotations._
+
+import org.apache.pekko
+import pekko.Done
+import pekko.NotUsed
+import pekko.actor.ActorSystem
+import pekko.stream.SystemMaterializer
+import pekko.stream.scaladsl.{ Compression, Flow, Sink, Source }
+import pekko.util.ByteString
+
+import com.typesafe.config.ConfigFactory
+
+/**
+ * Compresses and decompresses 16 MiB of JSON-like text split into `chunkSize`
elements.
+ */
+@State(Scope.Benchmark)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@BenchmarkMode(Array(Mode.AverageTime))
+class CompressionBenchmark {
+
+ private val TotalBytes = 16 * 1024 * 1024
+
+ implicit val system: ActorSystem = ActorSystem(
+ "CompressionBenchmark",
+ ConfigFactory.parseString("""
+ pekko.loglevel = "OFF"
+ pekko.stdout-loglevel = "OFF"
+ """).withFallback(ConfigFactory.load()))
+
+ @Param(Array("gzip", "zstd"))
+ var algorithm = ""
+
+ @Param(Array("1024", "65536"))
+ var chunkSize = 0
+
+ private var chunks: immutable.Seq[ByteString] = _
+ private var compressedChunks: immutable.Seq[ByteString] = _
+ private var compressFlow: Flow[ByteString, ByteString, NotUsed] = _
+ private var decompressFlow: Flow[ByteString, ByteString, NotUsed] = _
+
+ @Setup
+ def setup(): Unit = {
+ SystemMaterializer(system).materializer
+
+ val random = new Random(42)
+ val builder = ByteString.newBuilder
+ var i = 0
+ while (builder.length < TotalBytes) {
+ builder ++= ByteString(
+
s"""{"id":$i,"name":"user-${random.nextInt(10000)}","score":${random.nextDouble()},"tags":["a","b","${random
+ .alphanumeric
+ .take(8)
+ .mkString}"]}
+""")
+ i += 1
+ }
+ chunks = builder.result().take(TotalBytes).grouped(chunkSize).toVector
+
+ algorithm match {
+ case "gzip" =>
+ compressFlow = Compression.gzip
+ decompressFlow = Compression.gzipDecompress()
+ case "zstd" =>
+ compressFlow = Compression.zstd
+ decompressFlow = Compression.zstdDecompress()
+ }
+ compressedChunks =
Await.result(Source(chunks).via(compressFlow).runWith(Sink.seq), 1.minute)
+ val ratio = TotalBytes.toDouble / compressedChunks.map(_.length).sum
+ println(f"$algorithm chunkSize=$chunkSize compression ratio $ratio%.2f")
+ }
+
+ @TearDown
+ def shutdown(): Unit = Await.result(system.terminate(), 5.seconds)
+
+ @Benchmark
+ def compress(): Done =
+ Await.result(Source(chunks).via(compressFlow).runWith(Sink.ignore),
1.minute)
+
+ @Benchmark
+ def decompress(): Done =
+
Await.result(Source(compressedChunks).via(decompressFlow).runWith(Sink.ignore),
1.minute)
+}
diff --git a/docs/src/main/paradox/stream/operators/Compression/zstd.md
b/docs/src/main/paradox/stream/operators/Compression/zstd.md
new file mode 100644
index 0000000000..209bf29f76
--- /dev/null
+++ b/docs/src/main/paradox/stream/operators/Compression/zstd.md
@@ -0,0 +1,57 @@
+# Compression.zstd
+
+Creates a flow that zstd-compresses a stream of ByteStrings.
+
+@ref[Compression operators](../index.md#compression-operators)
+
+## Dependency
+
+The zstd operators use [zstd-jni](https://github.com/luben/zstd-jni), an
optional dependency of Pekko Streams
+that has to be added explicitly:
+
+@@dependency[sbt,Maven,Gradle] {
+ group=com.github.luben
+ artifact=zstd-jni
+ version="$zstd_jni_version$"
+}
+
+## Signature
+
+@apidoc[Compression.zstd](stream.*.Compression$) {
scala="#zstd:org.apache.pekko.stream.scaladsl.Flow[org.apache.pekko.util.ByteString,org.apache.pekko.util.ByteString,org.apache.pekko.NotUsed]"
java="#zstd()" }
+@apidoc[Compression.zstd](stream.*.Compression$) {
scala="#zstd(level:Int,dictionary:Option[org.apache.pekko.stream.scaladsl.compression.ZstdDictionary]):org.apache.pekko.stream.scaladsl.Flow[org.apache.pekko.util.ByteString,org.apache.pekko.util.ByteString,org.apache.pekko.NotUsed]"
java="#zstd(int,java.util.Optional)" }
+@apidoc[Compression.zstd](stream.*.Compression$) {
scala="#zstd(settings:org.apache.pekko.stream.ZstdCompressionSettings):org.apache.pekko.stream.scaladsl.Flow[org.apache.pekko.util.ByteString,org.apache.pekko.util.ByteString,org.apache.pekko.NotUsed]"
java="#zstd(org.apache.pekko.stream.ZstdCompressionSettings)" }
+
+## Description
+
+Creates a flow that zstd-compresses a stream of ByteStrings. Note that the
compressor
+will SYNC_FLUSH after every @apidoc[util.ByteString] so that it is guaranteed
that every @apidoc[util.ByteString]
+coming out of the flow can be fully decompressed without waiting for
additional data. This may
+come at a compression performance cost for very small chunks.
+
+Use the `zstd(level, dictionary)` overload to control the compression level
and, optionally, to compress with a
+dictionary. Output compressed with a dictionary can only be decompressed with
the same dictionary, see
+@ref[zstdDecompress](zstdDecompress.md).
+
+The `zstd(settings)` overload takes a
@apidoc[ZstdCompressionSettings](stream.ZstdCompressionSettings), which sets
the level and dictionary as well as:
+
+* `withAutoFlush`: if `true` (the default), the compressor flushes after every
single element in the stream. If it is
+ `false`, the compressor only emits output when the compression algorithm
produces it, which can improve the
+ compression ratio for small chunks, but an emitted `ByteString` may not be
fully decompressible until more data
+ arrives.
+* `withWindowLog`: limits the window size to `2^windowLog` bytes. The
decompressor has to buffer a window of this size,
+ and since the size of a stream is not known up front, a frame declares the
default window of its compression level,
+ which is 128 MiB (`windowLog` 27) for the highest levels. Set `windowLog`
when the output is decompressed by a peer
+ that limits the window, for example to 23 (8 MiB) for the HTTP `zstd`
content-coding, as required by
+ [RFC 9659](https://www.rfc-editor.org/rfc/rfc9659). The default of 0 uses
the default of the compression level.
+
+## Reactive Streams semantics
+
+@@@div { .callout }
+
+**emits** when the compression algorithm produces output for the received
`ByteString`
+
+**backpressures** when downstream backpressures
+
+**completes** when upstream completes
+
+@@@
diff --git
a/docs/src/main/paradox/stream/operators/Compression/zstdDecompress.md
b/docs/src/main/paradox/stream/operators/Compression/zstdDecompress.md
new file mode 100644
index 0000000000..d7e0774f59
--- /dev/null
+++ b/docs/src/main/paradox/stream/operators/Compression/zstdDecompress.md
@@ -0,0 +1,43 @@
+# Compression.zstdDecompress
+
+Creates a flow that zstd-decompresses a stream of ByteStrings.
+
+@ref[Compression operators](../index.md#compression-operators)
+
+## Dependency
+
+The zstd operators use [zstd-jni](https://github.com/luben/zstd-jni), an
optional dependency of Pekko Streams
+that has to be added explicitly:
+
+@@dependency[sbt,Maven,Gradle] {
+ group=com.github.luben
+ artifact=zstd-jni
+ version="$zstd_jni_version$"
+}
+
+## Signature
+
+@apidoc[Compression.zstdDecompress](stream.*.Compression$) {
scala="#zstdDecompress(maxBytesPerChunk:Int):org.apache.pekko.stream.scaladsl.Flow[org.apache.pekko.util.ByteString,org.apache.pekko.util.ByteString,org.apache.pekko.NotUsed]"
java="#zstdDecompress(int)" }
+@apidoc[Compression.zstdDecompress](stream.*.Compression$) {
scala="#zstdDecompress(settings:org.apache.pekko.stream.ZstdDecompressionSettings):org.apache.pekko.stream.scaladsl.Flow[org.apache.pekko.util.ByteString,org.apache.pekko.util.ByteString,org.apache.pekko.NotUsed]"
java="#zstdDecompress(org.apache.pekko.stream.ZstdDecompressionSettings)" }
+
+## Description
+
+Creates a flow that zstd-decompresses a stream of ByteStrings. If the input is
truncated or corrupt this operator
+fails with a `com.github.luben.zstd.ZstdException`.
+
+The `zstdDecompress(settings)` overload takes a
@apidoc[ZstdDecompressionSettings](stream.ZstdDecompressionSettings), which
sets the maximum chunk size
+and, with `withDictionary`, a dictionary to decompress data that was
compressed with one. It must be the same
+dictionary (including any offset and length) that was used for compression;
the dictionary level only applies to
+compression and is ignored.
+
+## Reactive Streams semantics
+
+@@@div { .callout }
+
+**emits** when the decompression algorithm produces output for the received
`ByteString` (the emitted `ByteString` is of `maxBytesPerChunk` maximum length)
+
+**backpressures** when downstream backpressures
+
+**completes** when upstream completes
+
+@@@
diff --git a/docs/src/main/paradox/stream/operators/index.md
b/docs/src/main/paradox/stream/operators/index.md
index b2bc7b616f..c1df767850 100644
--- a/docs/src/main/paradox/stream/operators/index.md
+++ b/docs/src/main/paradox/stream/operators/index.md
@@ -370,6 +370,8 @@ Flow operators to (de)compress.
|Compression|<a name="gzip"></a>@ref[gzip](Compression/gzip.md)|Creates a flow
that gzip-compresses a stream of ByteStrings. |
|Compression|<a
name="gzipdecompress"></a>@ref[gzipDecompress](Compression/gzipDecompress.md)|Creates
a flow that gzip-decompresses a stream of ByteStrings. |
|Compression|<a
name="inflate"></a>@ref[inflate](Compression/inflate.md)|Creates a flow that
deflate-decompresses a stream of ByteStrings. |
+|Compression|<a name="zstd"></a>@ref[zstd](Compression/zstd.md)|Creates a flow
that zstd-compresses a stream of ByteStrings.|
+|Compression|<a
name="zstddecompress"></a>@ref[zstdDecompress](Compression/zstdDecompress.md)|Creates
a flow that zstd-decompresses a stream of ByteStrings.|
## Error handling
@@ -629,5 +631,7 @@ For more background see the @ref[Error Handling in
Streams](../stream-error.md)
* [zipWith](Source-or-Flow/zipWith.md)
* [zipWithIndex](Source-or-Flow/zipWithIndex.md)
* [zipWithN](Source/zipWithN.md)
+* [zstd](Compression/zstd.md)
+* [zstdDecompress](Compression/zstdDecompress.md)
@@@
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index a4d46c3159..55980dad0f 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -383,11 +383,13 @@ object Dependencies {
Docs.gson,
Provided.levelDB)
- lazy val benchJmh = l ++= Seq(logback, Provided.levelDB,
Provided.levelDBNative, Compile.jctools)
+ lazy val benchJmh = l ++= Seq(logback, Provided.levelDB,
Provided.levelDBNative, Compile.jctools, zstdJni)
// pekko stream
- lazy val stream = l ++= Seq[sbt.ModuleID](reactiveStreams, jspecify,
TestDependencies.scalatest)
+ // zstd-jni is optional, users of the zstd compression operators need to add
it themselves
+ lazy val stream =
+ l ++= Seq[sbt.ModuleID](reactiveStreams, jspecify, zstdJni % Optional,
TestDependencies.scalatest)
lazy val streamTyped = l ++= Seq[sbt.ModuleID](jspecify)
@@ -398,6 +400,7 @@ object Dependencies {
TestDependencies.junit)
lazy val streamTests = l ++= Seq(
+ zstdJni % Test,
TestDependencies.scalatest,
TestDependencies.scalatestScalaCheck,
TestDependencies.junit,
diff --git a/project/OSGi.scala b/project/OSGi.scala
index d54563cb5b..61f8b464ef 100644
--- a/project/OSGi.scala
+++ b/project/OSGi.scala
@@ -100,7 +100,7 @@ object OSGi {
lazy val stream =
exports(
packages = Seq("org.apache.pekko.stream.*"),
- imports = Seq(scalaParsingCombinatorImport()))
+ imports = Seq(scalaParsingCombinatorImport(),
optionalResolution("com.github.luben.zstd.*")))
lazy val streamTestkit = exports(Seq("org.apache.pekko.stream.testkit.*"))
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/CoderSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/CoderSpec.scala
index af7191b0d7..abbf778fbc 100644
---
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/CoderSpec.scala
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/CoderSpec.scala
@@ -15,11 +15,11 @@ package org.apache.pekko.stream.io.compression
import java.io.{ ByteArrayOutputStream, InputStream, OutputStream }
import java.util.concurrent.ThreadLocalRandom
-import java.util.zip.DataFormatException
import scala.annotation.tailrec
import scala.concurrent.Await
import scala.concurrent.duration._
+import scala.reflect.ClassTag
import scala.util.control.NoStackTrace
import org.apache.pekko
@@ -31,7 +31,8 @@ import pekko.util.ByteString
import org.scalatest.Inspectors
import org.scalatest.wordspec.AnyWordSpec
-abstract class CoderSpec(codecName: String) extends AnyWordSpec with
CodecSpecSupport with Inspectors {
+abstract class CoderSpec[CorruptInputException: ClassTag](codecName: String)
extends AnyWordSpec with CodecSpecSupport
+ with Inspectors {
import CompressionTestingTools._
protected def newCompressor(): Compressor
@@ -85,7 +86,7 @@ abstract class CoderSpec(codecName: String) extends
AnyWordSpec with CodecSpecSu
"throw an error on corrupt input" in {
(the[RuntimeException] thrownBy {
ourDecode(corruptContent)
- }).ultimateCause should be(a[DataFormatException])
+ }).ultimateCause should be(a[CorruptInputException])
}
}
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/CompressionWithoutZstdSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/CompressionWithoutZstdSpec.scala
new file mode 100644
index 0000000000..e92f7dc0f3
--- /dev/null
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/CompressionWithoutZstdSpec.scala
@@ -0,0 +1,91 @@
+/*
+ * 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 org.apache.pekko.stream.io.compression
+
+import java.io.File
+import java.net.URLClassLoader
+import java.util.concurrent.TimeUnit
+
+import scala.concurrent.Await
+import scala.concurrent.duration._
+
+import org.apache.pekko
+import pekko.actor.ActorSystem
+import pekko.stream.javadsl
+import pekko.stream.scaladsl.{ Compression, Sink, Source }
+import pekko.util.ByteString
+
+import com.typesafe.config.ConfigFactory
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+/**
+ * zstd-jni is an optional dependency of pekko-stream, so the other
compression operators must
+ * work without it on the classpath.
+ */
+class CompressionWithoutZstdSpec extends AnyWordSpec with Matchers {
+
+ "Compression" should {
+ "support gzip and deflate without zstd-jni on the classpath" in {
+ // stream-tests run in a forked JVM, so java.class.path is the test
classpath
+ val urls = System
+ .getProperty("java.class.path")
+ .split(File.pathSeparator)
+ .filterNot(new File(_).getName.startsWith("zstd-jni"))
+ .map(new File(_).toURI.toURL)
+ // not closed: dispatcher shutdown tasks of the terminated ActorSystem
may still load classes from it
+ val loader = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader)
+ a[ClassNotFoundException] should be thrownBy
loader.loadClass("com.github.luben.zstd.Zstd")
+
+ val roundTrip =
loader.loadClass(classOf[CompressionWithoutZstdRoundTrip].getName)
+ val thread = Thread.currentThread()
+ val contextClassLoader = thread.getContextClassLoader
+ thread.setContextClassLoader(loader)
+ val result =
+ try
roundTrip.getMethod("run").invoke(roundTrip.getDeclaredConstructor().newInstance())
+ finally thread.setContextClassLoader(contextClassLoader)
+ result should ===("gzip:hello deflate:hello javadsl:hello zstd-level:3")
+ }
+ }
+}
+
+/** Loaded and run in a class loader without zstd-jni by
[[CompressionWithoutZstdSpec]]. */
+class CompressionWithoutZstdRoundTrip {
+ def run(): String = {
+ // use this class loader (without zstd-jni) rather than one found from the
context or call stack
+ val classLoader = getClass.getClassLoader
+ // a fatal error in this nested ActorSystem must fail the test, not exit
the forked test JVM
+ val config = ConfigFactory
+ .parseString("pekko.jvm-exit-on-fatal-error = off")
+ .withFallback(ConfigFactory.load(classLoader))
+ val system = ActorSystem("CompressionWithoutZstd", Some(config),
Some(classLoader), None)
+ try {
+ def roundTrip(source: Source[ByteString, Any]): String =
+ Await.result(source.runWith(Sink.fold(ByteString.empty)(_ ++
_))(pekko.stream.Materializer(system)), 10.seconds)
+ .utf8String
+ val data = Source.single(ByteString("hello"))
+ val gzip =
roundTrip(data.via(Compression.gzip).via(Compression.gzipDecompress()))
+ val deflate =
roundTrip(data.via(Compression.deflate).via(Compression.inflate()))
+ val javaDeflate =
roundTrip(data.via(javadsl.Compression.deflate).via(javadsl.Compression.inflate(1024)))
+ s"gzip:$gzip deflate:$deflate javadsl:$javaDeflate
zstd-level:${Compression.ZstdDefaultCompressionLevel}"
+ } finally {
+ system.terminate()
+ Await.ready(system.whenTerminated, FiniteDuration(10, TimeUnit.SECONDS))
+ }
+ }
+}
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/DeflateSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/DeflateSpec.scala
index 90a7f3551b..d63b29ea92 100644
---
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/DeflateSpec.scala
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/DeflateSpec.scala
@@ -21,7 +21,7 @@ import pekko.stream.impl.io.compression.{ Compressor,
DeflateCompressor }
import pekko.stream.scaladsl.{ Compression, Flow }
import pekko.util.ByteString
-class DeflateSpec extends CoderSpec("deflate") {
+class DeflateSpec extends CoderSpec[DataFormatException]("deflate") {
import CompressionTestingTools._
protected def newCompressor(): Compressor = new DeflateCompressor
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/GzipSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/GzipSpec.scala
index 177bbc0974..19d56902a7 100644
---
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/GzipSpec.scala
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/GzipSpec.scala
@@ -15,14 +15,14 @@ package org.apache.pekko.stream.io.compression
import java.io.{ InputStream, OutputStream }
import java.nio.charset.StandardCharsets
-import java.util.zip.{ GZIPInputStream, GZIPOutputStream, ZipException }
+import java.util.zip.{ DataFormatException, GZIPInputStream, GZIPOutputStream,
ZipException }
import org.apache.pekko
import pekko.stream.impl.io.compression.{ Compressor, GzipCompressor }
import pekko.stream.scaladsl.{ Compression, Flow }
import pekko.util.ByteString
-class GzipSpec extends CoderSpec("gzip") {
+class GzipSpec extends CoderSpec[DataFormatException]("gzip") {
import CompressionTestingTools._
protected def newCompressor(): Compressor = new GzipCompressor
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/ZstdAutoFlushSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/ZstdAutoFlushSpec.scala
new file mode 100644
index 0000000000..0e8e35a0c8
--- /dev/null
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/ZstdAutoFlushSpec.scala
@@ -0,0 +1,31 @@
+/*
+ * 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 org.apache.pekko.stream.io.compression
+
+import org.apache.pekko
+import pekko.stream.ZstdCompressionSettings
+import pekko.stream.scaladsl.{ Compression, Flow }
+import pekko.util.ByteString
+
+class ZstdAutoFlushSpec extends ZstdSpec {
+ override protected val encoderFlow: Flow[ByteString, ByteString, Any] =
+ Compression.zstd(ZstdCompressionSettings().withAutoFlush(false))
+
+ override protected val autoFlush: Boolean = false
+
+}
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/ZstdSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/ZstdSpec.scala
new file mode 100644
index 0000000000..d9025524dc
--- /dev/null
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/compression/ZstdSpec.scala
@@ -0,0 +1,343 @@
+/*
+ * 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 org.apache.pekko.stream.io.compression
+
+import java.io.{ ByteArrayInputStream, IOException, InputStream, OutputStream }
+import java.nio.charset.StandardCharsets
+
+import scala.collection.mutable
+import scala.concurrent.duration._
+import scala.util.Random
+
+import com.github.luben.zstd.{
+ Zstd,
+ ZstdCompressCtx,
+ ZstdException,
+ ZstdInputStream,
+ ZstdInputStreamNoFinalizer,
+ ZstdOutputStream
+}
+
+import org.apache.pekko
+import pekko.stream.{ ZstdCompressionSettings, ZstdDecompressionSettings }
+import pekko.stream.impl.io.compression.{ Compressor, ZstdCompressor }
+import pekko.stream.javadsl.{ Compression => JavaCompression }
+import pekko.stream.javadsl.compression.{ ZstdDictionary => JavaZstdDictionary
}
+import pekko.stream.scaladsl.{ Compression, Flow, Source }
+import pekko.stream.scaladsl.compression.ZstdDictionary
+import pekko.util.ByteString
+
+class ZstdSpec extends CoderSpec[ZstdException]("zstd") {
+ import CompressionTestingTools._
+
+ override protected def newCompressor(): Compressor = new ZstdCompressor
+
+ override protected def encoderFlow: Flow[ByteString, ByteString, Any] =
Compression.zstd
+
+ override protected def decoderFlow(maxBytesPerChunk: Int): Flow[ByteString,
ByteString, Any] =
+ Compression.zstdDecompress(maxBytesPerChunk)
+
+ override protected def newDecodedInputStream(underlying: InputStream):
InputStream =
+ new ZstdInputStream(underlying)
+
+ override protected def newEncodedOutputStream(underlying: OutputStream):
OutputStream =
+ new ZstdOutputStream(underlying)
+
+ override def extraTests(): Unit = {
+ "decode concatenated compressions" in {
+ ourDecode(Seq(encode("Hello, "), encode("dear "), encode("User!")).join)
should readAs("Hello, dear User!")
+ }
+ "throw an error on truncated input" in {
+ val ex = the[RuntimeException] thrownBy
ourDecode(streamEncode(smallTextBytes).dropRight(5))
+ ex.ultimateCause.getMessage should equal("Truncated zstd stream")
+ }
+ "throw an error if compressed data is just missing the end of the frame"
in {
+ val ex = the[RuntimeException] thrownBy
ourDecode(newCompressor().compressAndFlush(smallTextBytes))
+ ex.ultimateCause.getMessage should equal("Truncated zstd stream")
+ }
+ "decompress input whose size is an exact multiple of maxBytesPerChunk" in {
+ val input = ByteString(Array.fill(4096)(1.toByte))
+ val compressed = ourEncode(input)
+ for (chunkSize <- Seq(4096, 2048, 1024, 4095, 4097)) {
+ withClue(s"maxBytesPerChunk = $chunkSize: ") {
+
Source.single(compressed).via(decoderFlow(chunkSize)).join.awaitResult(3.seconds)
should ===(input)
+ Source(List(compressed, compressed))
+ .via(decoderFlow(chunkSize))
+ .join
+ .awaitResult(3.seconds) should ===(input ++ input)
+ }
+ }
+ }
+ "compress and decompress multi-fragment ByteStrings" in {
+ // slices of a single array, so the fragments do not cover their whole
backing arrays
+ def fragmented(bytes: ByteString): ByteString =
+ ByteString(0.toByte +:
bytes.toArray).drop(1).grouped(100).foldLeft(ByteString.empty)(_ ++ _)
+ val input = fragmented(ByteString(largeText, "UTF8"))
+ val compressed = ourEncode(input)
+ streamDecode(compressed) should equal(input)
+ ourDecode(fragmented(compressed)) should equal(input)
+ }
+ "define the same constants as zstd-jni" in {
+ Compression.ZstdMinCompressionLevel should
===(Zstd.minCompressionLevel())
+ Compression.ZstdMaxCompressionLevel should
===(Zstd.maxCompressionLevel())
+ Compression.ZstdDefaultCompressionLevel should
===(Zstd.defaultCompressionLevel())
+ Compression.ZstdDecompressMaxBlockSize should ===(Zstd.blockSizeMax())
+ Compression.ZstdMinWindowLog should ===(Zstd.windowLogMin())
+ Compression.ZstdMaxWindowLog should ===(Zstd.windowLogMax())
+ }
+ "limit the compression window with windowLog" in {
+ val input = ByteString(Array.fill(1 << 16)(1.toByte))
+ def compress(flow: Flow[ByteString, ByteString, Any]): ByteString =
+ Source.single(input).via(flow).join.awaitResult(3.seconds)
+ // a decoder that rejects frames with a window larger than 2^windowLogMax
+ def decodeWithLongMax(compressed: ByteString, windowLogMax: Int):
ByteString = {
+ val in = new ZstdInputStreamNoFinalizer(new
ByteArrayInputStream(compressed.toArray))
+ try {
+ in.setLongMax(windowLogMax)
+ ByteString(in.readAllBytes())
+ } finally in.close()
+ }
+
+ val highest =
ZstdCompressionSettings().withLevel(Compression.ZstdMaxCompressionLevel)
+
+ // the streamed size is unknown, so the frame declares the default
window of the level, 2^27 for level 22
+ val unlimited = compress(Compression.zstd(highest))
+ an[IOException] should be thrownBy decodeWithLongMax(unlimited, 23)
+
+ val limited = compress(Compression.zstd(highest.withWindowLog(23)))
+ decodeWithLongMax(limited, 23) should ===(input)
+ ourDecode(limited) should ===(input)
+ an[IOException] should be thrownBy decodeWithLongMax(limited, 22)
+
decodeWithLongMax(compress(JavaCompression.zstd(highest.withWindowLog(23)).asScala),
23) should ===(input)
+
+ val withDictionary =
+
compress(Compression.zstd(highest.withWindowLog(23).withAutoFlush(false).withDictionary(
+ ZstdDictionary(smallTextBytes))))
+ Zstd.decompress(withDictionary.toArray, smallTextBytes.toArray,
input.size) should ===(input.toArray)
+ }
+ "validate the compression settings" in {
+ val settings = ZstdCompressionSettings()
+ settings.level should ===(Compression.ZstdDefaultCompressionLevel)
+ settings.autoFlush should ===(true)
+ settings.windowLog should ===(0)
+ settings.dictionaryImpl should ===(None)
+ (ZstdCompressionSettings.create() should be).theSameInstanceAs(settings)
+
+ for (windowLog <- Seq(-1, Compression.ZstdMinWindowLog - 1,
Compression.ZstdMaxWindowLog + 1))
+ an[IllegalArgumentException] should be thrownBy
settings.withWindowLog(windowLog)
+ for (level <- Seq(Compression.ZstdMinCompressionLevel - 1,
Compression.ZstdMaxCompressionLevel + 1))
+ an[IllegalArgumentException] should be thrownBy
settings.withLevel(level)
+ an[IllegalArgumentException] should be thrownBy
+ Compression.zstd(Compression.ZstdMaxCompressionLevel + 1, None)
+
+ val updated =
settings.withLevel(19).withAutoFlush(false).withWindowLog(23)
+ .withDictionary(new JavaZstdDictionary(smallTextBytes.toArray))
+ (updated.level, updated.autoFlush, updated.windowLog) should ===((19,
false, 23))
+ updated.dictionaryImpl.map(_.dictionary.toSeq) should
===(Some(smallTextBytes.toSeq))
+ updated.withoutDictionary.dictionaryImpl should ===(None)
+ // the default settings are not changed
+ settings.level should ===(Compression.ZstdDefaultCompressionLevel)
+ }
+ "validate the decompression settings" in {
+ val settings = ZstdDecompressionSettings()
+ settings.maxBytesPerChunk should
===(Compression.ZstdDecompressMaxBlockSize)
+ settings.dictionaryImpl should ===(None)
+ (ZstdDecompressionSettings.create() should
be).theSameInstanceAs(settings)
+ an[IllegalArgumentException] should be thrownBy
settings.withMaxBytesPerChunk(0)
+ settings.withMaxBytesPerChunk(1024).maxBytesPerChunk should ===(1024)
+
settings.withDictionary(ZstdDictionary(smallTextBytes)).withoutDictionary.dictionaryImpl
should ===(None)
+ }
+ "encode String dictionaries as UTF-8" in {
+ val text = "dictionary é中"
+ val utf8 = text.getBytes(StandardCharsets.UTF_8)
+ ZstdDictionary(text).dictionary should ===(ByteString(utf8))
+ new JavaZstdDictionary(text).getDictionary should ===(ByteString(utf8))
+ }
+ "give the Java dictionary value semantics" in {
+ val dictBytes = smallTextBytes.toArray
+ val dictionary = new JavaZstdDictionary(dictBytes,
Compression.ZstdDefaultCompressionLevel)
+ dictionary should ===(new JavaZstdDictionary(dictBytes.clone(),
Compression.ZstdDefaultCompressionLevel))
+ dictionary.hashCode should ===(
+ new JavaZstdDictionary(smallTextBytes,
Compression.ZstdDefaultCompressionLevel).hashCode)
+ dictionary should not be new JavaZstdDictionary(dictBytes)
+ // the array is copied, so changing it does not affect the dictionary
+ java.util.Arrays.fill(dictBytes, 0.toByte)
+ dictionary.getDictionary should ===(smallTextBytes)
+ // and so is the array handed to the operators
+ java.util.Arrays.fill(dictionary.toImpl.dictionary, 0.toByte)
+ dictionary.getDictionary should ===(smallTextBytes)
+ }
+ "give the Scala dictionary value semantics" in {
+ val dictBytes = smallTextBytes.toArray
+ val dictionary = ZstdDictionary(dictBytes,
Compression.ZstdDefaultCompressionLevel)
+ dictionary should ===(ZstdDictionary(dictBytes.clone(),
Compression.ZstdDefaultCompressionLevel))
+ dictionary.hashCode should ===(ZstdDictionary(smallTextBytes,
Compression.ZstdDefaultCompressionLevel).hashCode)
+ dictionary should not be ZstdDictionary(dictBytes)
+ // the array is copied, so changing it does not affect the dictionary
+ java.util.Arrays.fill(dictBytes, 0.toByte)
+ dictionary.dictionary should ===(smallTextBytes)
+ // and so is the array handed to the operators
+ java.util.Arrays.fill(dictionary.toImpl.dictionary, 0.toByte)
+ dictionary.dictionary should ===(smallTextBytes)
+ }
+ "decompress with a dictionary" in {
+ // incompressible input that is only found in the dictionary, so the
frame must reference the dictionary
+ val dictBytes = new Array[Byte](4096)
+ new Random(42).nextBytes(dictBytes)
+ val input = ByteString(dictBytes)
+ val compressed = Source
+ .single(input)
+ .via(Compression.zstd(Compression.ZstdDefaultCompressionLevel,
Some(ZstdDictionary(dictBytes))))
+ .join
+ .awaitResult(3.seconds)
+ compressed.size should be < input.size / 10
+ // the dictionary level only applies to compression
+ for (dictionary <-
+ Seq(ZstdDictionary(dictBytes), ZstdDictionary(dictBytes,
Compression.ZstdMaxCompressionLevel))) {
+ Source
+ .single(compressed)
+
.via(Compression.zstdDecompress(ZstdDecompressionSettings().withDictionary(dictionary)))
+ .join
+ .awaitResult(3.seconds) should ===(input)
+ }
+ Source
+ .single(compressed)
+ .via(JavaCompression.zstdDecompress(
+ ZstdDecompressionSettings.create().withDictionary(new
JavaZstdDictionary(dictBytes))))
+ .join
+ .awaitResult(3.seconds) should ===(input)
+
+ val ex = the[RuntimeException] thrownBy ourDecode(compressed)
+ ex.ultimateCause shouldBe a[ZstdException]
+ }
+ "decompress with a dictionary slice" in {
+ val content = new Array[Byte](4096)
+ new Random(42).nextBytes(content)
+ val dictBytes = "padding".getBytes(StandardCharsets.UTF_8) ++ content
+ val dictionary = ZstdDictionary(dictBytes,
Compression.ZstdDefaultCompressionLevel, 7, content.length)
+ val input = ByteString(content)
+ val compressed = new ZstdCompressor(dictionary =
Some(dictionary.toImpl)).compressAndFinish(input)
+ compressed.size should be < input.size / 10
+
+ Source
+ .single(compressed)
+
.via(Compression.zstdDecompress(ZstdDecompressionSettings().withDictionary(dictionary)))
+ .join
+ .awaitResult(3.seconds) should ===(input)
+ }
+ "reject a decompression dictionary slice outside the dictionary" in {
+ for ((offset, length) <- Seq((-1, 4), (0, -1), (1,
smallTextBytes.length)))
+ withClue(s"offset $offset, length $length: ") {
+ an[IllegalArgumentException] should be thrownBy
Compression.zstdDecompress(
+ ZstdDecompressionSettings().withDictionary(
+ ZstdDictionary(smallTextBytes.toArray,
Compression.ZstdDefaultCompressionLevel, offset, length)))
+ }
+ }
+ "decompress with a copy of the dictionary" in {
+ val dictBytes = new Array[Byte](4096)
+ new Random(42).nextBytes(dictBytes)
+ val input = ByteString(dictBytes)
+ val compressed = new ZstdCompressor(dictionary =
Some(ZstdDictionary(dictBytes).toImpl)).compressAndFinish(input)
+ val decompress =
Compression.zstdDecompress(ZstdDecompressionSettings().withDictionary(ZstdDictionary(dictBytes)))
+ // changing the array after the operator is created does not affect it
+ java.util.Arrays.fill(dictBytes, 0.toByte)
+ for (_ <- 1 to 2) // every materialization loads the dictionary into its
own context
+ Source.single(compressed).via(decompress).join.awaitResult(3.seconds)
should ===(input)
+ }
+ "reject a decompression dictionary with only one of offset and length" in {
+ val dictionary = new JavaZstdDictionary(smallTextBytes.toArray, null:
Integer, Integer.valueOf(1), null: Integer)
+ an[IllegalArgumentException] should be thrownBy
+
JavaCompression.zstdDecompress(ZstdDecompressionSettings.create().withDictionary(dictionary))
+ }
+ "close the compression context when the compressor setup fails" in {
+ // the context is recorded outside of the subclass, whose fields are
only set after the constructor
+ val contexts = mutable.Buffer.empty[ZstdCompressCtx]
+ def compressor(dictionary: ZstdDictionary): ZstdCompressor =
+ new ZstdCompressor(dictionary = Some(dictionary.toImpl)) {
+ override private[pekko] def newCompressCtx(): ZstdCompressCtx = {
+ val ctx = super.newCompressCtx()
+ contexts += ctx
+ ctx
+ }
+ }
+
+ val valid = compressor(ZstdDictionary(smallTextBytes,
Compression.ZstdDefaultCompressionLevel))
+ contexts.head.setLevel(Compression.ZstdDefaultCompressionLevel) // still
open
+ valid.close()
+ an[IllegalStateException] should be thrownBy
contexts.head.setLevel(Compression.ZstdDefaultCompressionLevel)
+
+ contexts.clear()
+ an[Exception] should be thrownBy compressor(
+ ZstdDictionary(smallTextBytes,
Compression.ZstdDefaultCompressionLevel, -1, smallTextBytes.length))
+ contexts should have size 1
+ an[IllegalStateException] should be thrownBy
contexts.head.setLevel(Compression.ZstdDefaultCompressionLevel)
+ }
+ "close the compression dictionary with the compressor" in {
+ val dictBytes = ("padding" + smallText *
4).getBytes(StandardCharsets.UTF_8)
+ def loadInto(compressor: ZstdCompressor): Unit = {
+ val ctx = new ZstdCompressCtx()
+ try ctx.loadDict(compressor.compressDictionary.get)
+ finally ctx.close()
+ }
+ for (dictionary <- Seq(
+ ZstdDictionary(dictBytes, Compression.ZstdDefaultCompressionLevel),
+ ZstdDictionary(dictBytes, Compression.ZstdDefaultCompressionLevel,
7, dictBytes.length - 7))) {
+ val compressor = new ZstdCompressor(dictionary =
Some(dictionary.toImpl))
+ compressor.compressAndFinish(smallTextBytes)
+ loadInto(compressor) // still open
+ compressor.close()
+ // a closed dictionary can no longer be used
+ an[IllegalStateException] should be thrownBy loadInto(compressor)
+ }
+ // a dictionary without a level is loaded into the context and needs no
separate close
+ new ZstdCompressor(dictionary =
Some(ZstdDictionary(dictBytes).toImpl)).compressDictionary should ===(None)
+ }
+ "compress with a dictionary slice" in {
+ val dictBytes = ("padding" + smallText *
4).getBytes(StandardCharsets.UTF_8)
+ val dictionary = ZstdDictionary(dictBytes,
Compression.ZstdDefaultCompressionLevel, 7, dictBytes.length - 7)
+ val compressed = new ZstdCompressor(dictionary =
Some(dictionary.toImpl)).compressAndFinish(smallTextBytes)
+
+ val decompressed = Zstd.decompress(compressed.toArray,
dictBytes.drop(7), smallTextBytes.length)
+ ByteString(decompressed) should readAs(smallText)
+ }
+ "compress with a dictionary slice without a level" in {
+ val dictBytes = ("padding" + smallText *
4).getBytes(StandardCharsets.UTF_8)
+ val dictionary =
+ new JavaZstdDictionary(dictBytes, null: Integer, Integer.valueOf(7),
Integer.valueOf(dictBytes.length - 7))
+ val compressed = new ZstdCompressor(dictionary =
Some(dictionary.toImpl)).compressAndFinish(smallTextBytes)
+
+ val decompressed = Zstd.decompress(compressed.toArray,
dictBytes.drop(7), smallTextBytes.length)
+ ByteString(decompressed) should readAs(smallText)
+ }
+ "reject a compression dictionary slice outside the dictionary" in {
+ val dictBytes = smallTextBytes.toArray
+ for {
+ level <- Seq[Integer](null,
Integer.valueOf(Compression.ZstdDefaultCompressionLevel))
+ (offset, length) <- Seq((-1, 4), (0, -1), (1, dictBytes.length))
+ } withClue(s"level $level, offset $offset, length $length: ") {
+ val dictionary = new JavaZstdDictionary(dictBytes, level,
Integer.valueOf(offset), Integer.valueOf(length))
+ val ex = the[IllegalArgumentException] thrownBy new
ZstdCompressor(dictionary = Some(dictionary.toImpl))
+ ex.getMessage should include("must be within the dictionary")
+ }
+ }
+ "reject a compression dictionary with only one of offset and length" in {
+ val dictionary = new JavaZstdDictionary(smallTextBytes.toArray, null:
Integer, Integer.valueOf(1), null: Integer)
+ an[IllegalArgumentException] should be thrownBy new
ZstdCompressor(dictionary = Some(dictionary.toImpl))
+ }
+ }
+}
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/CompressionSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/CompressionSpec.scala
index 0b358da9f4..41f5af5a23 100644
---
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/CompressionSpec.scala
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/CompressionSpec.scala
@@ -16,8 +16,7 @@ package org.apache.pekko.stream.scaladsl
import java.nio.charset.StandardCharsets
import org.apache.pekko
-import pekko.stream.impl.io.compression.DeflateCompressor
-import pekko.stream.impl.io.compression.GzipCompressor
+import pekko.stream.impl.io.compression.{ DeflateCompressor, GzipCompressor,
ZstdCompressor }
import pekko.stream.testkit.StreamSpec
import pekko.util.ByteString
@@ -27,6 +26,8 @@ class CompressionSpec extends StreamSpec {
def deflate(s: String): ByteString = new
DeflateCompressor().compressAndFinish(ByteString(s))
+ def zstd(s: String): ByteString = new
ZstdCompressor().compressAndFinish(ByteString(s))
+
val data = "hello world"
"Gzip decompression" must {
@@ -47,4 +48,14 @@ class CompressionSpec extends StreamSpec {
res.futureValue should ===(data)
}
}
+
+ "Zstd decompression" must {
+ "be able to decompress a zstd stream" in {
+ val source =
+
Source.single(zstd(data)).via(Compression.zstdDecompress()).map(_.decodeString(StandardCharsets.UTF_8))
+
+ val res = source.runFold("")(_ + _)
+ res.futureValue should ===(data)
+ }
+ }
}
diff --git
a/stream/src/main/java/org/apache/pekko/stream/javadsl/compression/ZstdDictionary.java
b/stream/src/main/java/org/apache/pekko/stream/javadsl/compression/ZstdDictionary.java
new file mode 100644
index 0000000000..12bcb621b0
--- /dev/null
+++
b/stream/src/main/java/org/apache/pekko/stream/javadsl/compression/ZstdDictionary.java
@@ -0,0 +1,194 @@
+/*
+ * 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 org.apache.pekko.stream.javadsl.compression;
+
+import org.apache.pekko.annotation.ApiMayChange;
+import org.apache.pekko.annotation.InternalApi;
+import org.apache.pekko.stream.impl.io.compression.ZstdDictionaryImpl;
+import org.apache.pekko.util.ByteString;
+import org.apache.pekko.util.OptionalUtil;
+import scala.Option;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+import java.util.OptionalInt;
+
+/**
+ * Configuration class based on the official C zstd library to specify
dictionary options for
+ * compression.
+ *
+ * @see <a
+ *
href="https://github.com/facebook/zstd?tab=readme-ov-file#dictionary-compression-how-to">Zstd
+ * dictionary readme</a>
+ * @see <a
href="https://facebook.github.io/zstd/zstd_manual.html#Chapter10">Zstd C
manual</a>
+ * @since 2.0.0
+ */
+@ApiMayChange
+public final class ZstdDictionary {
+ private final ByteString dictionary;
+ private final OptionalInt level;
+ private final OptionalInt offset;
+ private final OptionalInt length;
+
+ private ZstdDictionary(
+ ByteString dictionary, OptionalInt level, OptionalInt offset,
OptionalInt length) {
+ this.dictionary = Objects.requireNonNull(dictionary, "dictionary");
+ this.level = level;
+ this.offset = offset;
+ this.length = length;
+ }
+
+ private static OptionalInt optionalInt(Integer value) {
+ return value == null ? OptionalInt.empty() : OptionalInt.of(value);
+ }
+
+ // the byte[] constructors copy the array, so later changes to it do not
affect the dictionary
+
+ public ZstdDictionary(byte[] dictionary) {
+ this(ByteString.fromArray(dictionary));
+ }
+
+ public ZstdDictionary(byte[] dictionary, int level) {
+ this(ByteString.fromArray(dictionary), level);
+ }
+
+ public ZstdDictionary(byte[] dictionary, Integer level) {
+ this(ByteString.fromArray(dictionary), level);
+ }
+
+ public ZstdDictionary(byte[] dictionary, int level, int offset, int length) {
+ this(ByteString.fromArray(dictionary), level, offset, length);
+ }
+
+ public ZstdDictionary(byte[] dictionary, Integer level, Integer offset,
Integer length) {
+ this(ByteString.fromArray(dictionary), level, offset, length);
+ }
+
+ public ZstdDictionary(ByteString dictionary) {
+ this(dictionary, OptionalInt.empty(), OptionalInt.empty(),
OptionalInt.empty());
+ }
+
+ public ZstdDictionary(ByteString dictionary, int level) {
+ this(dictionary, OptionalInt.of(level), OptionalInt.empty(),
OptionalInt.empty());
+ }
+
+ public ZstdDictionary(ByteString dictionary, Integer level) {
+ this(dictionary, optionalInt(level), OptionalInt.empty(),
OptionalInt.empty());
+ }
+
+ public ZstdDictionary(ByteString dictionary, int level, int offset, int
length) {
+ this(dictionary, OptionalInt.of(level), OptionalInt.of(offset),
OptionalInt.of(length));
+ }
+
+ public ZstdDictionary(ByteString dictionary, Integer level, Integer offset,
Integer length) {
+ this(dictionary, optionalInt(level), optionalInt(offset),
optionalInt(length));
+ }
+
+ public ZstdDictionary(String dictionary) {
+ this(ByteString.fromString(dictionary, StandardCharsets.UTF_8));
+ }
+
+ public ZstdDictionary(String dictionary, int level) {
+ this(ByteString.fromString(dictionary, StandardCharsets.UTF_8), level);
+ }
+
+ public ZstdDictionary(String dictionary, Integer level) {
+ this(ByteString.fromString(dictionary, StandardCharsets.UTF_8), level);
+ }
+
+ public ZstdDictionary(String dictionary, int level, int offset, int length) {
+ this(ByteString.fromString(dictionary, StandardCharsets.UTF_8), level,
offset, length);
+ }
+
+ public ZstdDictionary(String dictionary, Integer level, Integer offset,
Integer length) {
+ this(ByteString.fromString(dictionary, StandardCharsets.UTF_8), level,
offset, length);
+ }
+
+ public ByteString getDictionary() {
+ return dictionary;
+ }
+
+ public OptionalInt getLevel() {
+ return level;
+ }
+
+ public OptionalInt getOffset() {
+ return offset;
+ }
+
+ public OptionalInt getLength() {
+ return length;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) return true;
+ if (!(other instanceof ZstdDictionary)) return false;
+ ZstdDictionary that = (ZstdDictionary) other;
+ return dictionary.equals(that.dictionary)
+ && level.equals(that.level)
+ && offset.equals(that.offset)
+ && length.equals(that.length);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(dictionary, level, offset, length);
+ }
+
+ @Override
+ public String toString() {
+ return "ZstdDictionary("
+ + dictionary.size()
+ + " bytes, level="
+ + level
+ + ", offset="
+ + offset
+ + ", length="
+ + length
+ + ")";
+ }
+
+ /** INTERNAL API */
+ @InternalApi
+ public ZstdDictionaryImpl toImpl() {
+ // a copy, so that whoever holds the impl cannot modify the dictionary
+ final byte[] bytes = dictionary.toArray();
+ return new ZstdDictionaryImpl() {
+ @Override
+ public byte[] dictionary() {
+ return bytes;
+ }
+
+ @Override
+ public Option<Object> level() {
+ return OptionalUtil.convertOptionalToScala(level);
+ }
+
+ @Override
+ public Option<Object> offset() {
+ return OptionalUtil.convertOptionalToScala(offset);
+ }
+
+ @Override
+ public Option<Object> length() {
+ return OptionalUtil.convertOptionalToScala(length);
+ }
+ };
+ }
+}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/ZstdCompressionSettings.scala
b/stream/src/main/scala/org/apache/pekko/stream/ZstdCompressionSettings.scala
new file mode 100644
index 0000000000..e3781d84fb
--- /dev/null
+++
b/stream/src/main/scala/org/apache/pekko/stream/ZstdCompressionSettings.scala
@@ -0,0 +1,121 @@
+/*
+ * 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 org.apache.pekko.stream
+
+import org.apache.pekko
+import pekko.annotation.{ ApiMayChange, InternalApi }
+import pekko.stream.impl.io.compression.ZstdDictionaryImpl
+import pekko.stream.scaladsl.Compression
+
+/**
+ * Settings for the zstd compression operators, `Compression.zstd` in the
Scala and Java DSLs.
+ *
+ * @since 2.0.0
+ */
+@ApiMayChange
+final class ZstdCompressionSettings private (
+ val level: Int,
+ val autoFlush: Boolean,
+ val windowLog: Int,
+ dictionary: Option[ZstdDictionaryImpl]) {
+
+ require(
+ level >= Compression.ZstdMinCompressionLevel && level <=
Compression.ZstdMaxCompressionLevel,
+ s"level must be between ${Compression.ZstdMinCompressionLevel} and
${Compression.ZstdMaxCompressionLevel}, " +
+ s"was $level")
+ require(
+ windowLog == 0 || (windowLog >= Compression.ZstdMinWindowLog && windowLog
<= Compression.ZstdMaxWindowLog),
+ s"windowLog must be 0 or between ${Compression.ZstdMinWindowLog} and
${Compression.ZstdMaxWindowLog}, " +
+ s"was $windowLog")
+
+ /**
+ * The compression level, must be greater or equal to
`Compression.ZstdMinCompressionLevel` and less than or equal
+ * to `Compression.ZstdMaxCompressionLevel`. Defaults to
`Compression.ZstdDefaultCompressionLevel`.
+ */
+ def withLevel(level: Int): ZstdCompressionSettings = copy(level = level)
+
+ /**
+ * If true (the default) the compressor flushes after every single element
in the stream, so that every emitted
+ * `ByteString` can be fully decompressed without waiting for more data. If
false, the compressor only emits output
+ * when the compression algorithm produces it, which can improve the
compression ratio for small elements.
+ */
+ def withAutoFlush(autoFlush: Boolean): ZstdCompressionSettings =
copy(autoFlush = autoFlush)
+
+ /**
+ * The log2 of the maximum window size, which a decompressor needs to
buffer. 0 (the default) uses the default of
+ * the compression level, otherwise it must be greater or equal to
`Compression.ZstdMinWindowLog` and less than or
+ * equal to `Compression.ZstdMaxWindowLog`. For example, RFC 9659 limits the
HTTP `zstd` content-coding to a window
+ * log of 23 (8 MiB).
+ */
+ def withWindowLog(windowLog: Int): ZstdCompressionSettings = copy(windowLog
= windowLog)
+
+ /**
+ * Scala API: compress with a dictionary. The output can only be
decompressed with the same dictionary.
+ */
+ def withDictionary(dictionary: scaladsl.compression.ZstdDictionary):
ZstdCompressionSettings =
+ copy(dictionary = Some(dictionary.toImpl))
+
+ /**
+ * Java API: compress with a dictionary. The output can only be decompressed
with the same dictionary.
+ */
+ def withDictionary(dictionary: javadsl.compression.ZstdDictionary):
ZstdCompressionSettings =
+ copy(dictionary = Some(dictionary.toImpl))
+
+ /**
+ * Compress without a dictionary, the default.
+ */
+ def withoutDictionary: ZstdCompressionSettings = copy(dictionary = None)
+
+ /**
+ * INTERNAL API
+ */
+ @InternalApi
+ private[pekko] def dictionaryImpl: Option[ZstdDictionaryImpl] = dictionary
+
+ override def toString: String =
+
s"ZstdCompressionSettings(level=$level,autoFlush=$autoFlush,windowLog=$windowLog,dictionary=${dictionary.isDefined})"
+
+ private def copy(
+ level: Int = level,
+ autoFlush: Boolean = autoFlush,
+ windowLog: Int = windowLog,
+ dictionary: Option[ZstdDictionaryImpl] = dictionary):
ZstdCompressionSettings =
+ new ZstdCompressionSettings(level, autoFlush, windowLog, dictionary)
+}
+
+/**
+ * @since 2.0.0
+ */
+@ApiMayChange
+object ZstdCompressionSettings {
+
+ private val Default =
+ new ZstdCompressionSettings(Compression.ZstdDefaultCompressionLevel,
autoFlush = true, windowLog = 0, None)
+
+ /**
+ * Scala API: the default settings, the default compression level with auto
flush, the default window size of the
+ * level and no dictionary
+ */
+ def apply(): ZstdCompressionSettings = Default
+
+ /**
+ * Java API: the default settings, the default compression level with auto
flush, the default window size of the
+ * level and no dictionary
+ */
+ def create(): ZstdCompressionSettings = Default
+}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/ZstdDecompressionSettings.scala
b/stream/src/main/scala/org/apache/pekko/stream/ZstdDecompressionSettings.scala
new file mode 100644
index 0000000000..20191833ad
--- /dev/null
+++
b/stream/src/main/scala/org/apache/pekko/stream/ZstdDecompressionSettings.scala
@@ -0,0 +1,92 @@
+/*
+ * 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 org.apache.pekko.stream
+
+import org.apache.pekko
+import pekko.annotation.{ ApiMayChange, InternalApi }
+import pekko.stream.impl.io.compression.ZstdDictionaryImpl
+import pekko.stream.scaladsl.Compression
+
+/**
+ * Settings for the zstd decompression operators, `Compression.zstdDecompress`
in the Scala and Java DSLs.
+ *
+ * @since 2.0.0
+ */
+@ApiMayChange
+final class ZstdDecompressionSettings private (
+ val maxBytesPerChunk: Int,
+ dictionary: Option[ZstdDictionaryImpl]) {
+
+ require(maxBytesPerChunk > 0, s"maxBytesPerChunk must be greater than 0, was
$maxBytesPerChunk")
+
+ /**
+ * The maximum length of the emitted `ByteString`s. Defaults to
`Compression.ZstdDecompressMaxBlockSize`.
+ */
+ def withMaxBytesPerChunk(maxBytesPerChunk: Int): ZstdDecompressionSettings =
+ copy(maxBytesPerChunk = maxBytesPerChunk)
+
+ /**
+ * Scala API: decompress with a dictionary, which must be the one used for
compression. Its level is ignored.
+ */
+ def withDictionary(dictionary: scaladsl.compression.ZstdDictionary):
ZstdDecompressionSettings =
+ copy(dictionary = Some(dictionary.toImpl))
+
+ /**
+ * Java API: decompress with a dictionary, which must be the one used for
compression. Its level is ignored.
+ */
+ def withDictionary(dictionary: javadsl.compression.ZstdDictionary):
ZstdDecompressionSettings =
+ copy(dictionary = Some(dictionary.toImpl))
+
+ /**
+ * Decompress without a dictionary, the default.
+ */
+ def withoutDictionary: ZstdDecompressionSettings = copy(dictionary = None)
+
+ /**
+ * INTERNAL API
+ */
+ @InternalApi
+ private[pekko] def dictionaryImpl: Option[ZstdDictionaryImpl] = dictionary
+
+ override def toString: String =
+
s"ZstdDecompressionSettings(maxBytesPerChunk=$maxBytesPerChunk,dictionary=${dictionary.isDefined})"
+
+ private def copy(
+ maxBytesPerChunk: Int = maxBytesPerChunk,
+ dictionary: Option[ZstdDictionaryImpl] = dictionary):
ZstdDecompressionSettings =
+ new ZstdDecompressionSettings(maxBytesPerChunk, dictionary)
+}
+
+/**
+ * @since 2.0.0
+ */
+@ApiMayChange
+object ZstdDecompressionSettings {
+
+ private val Default = new
ZstdDecompressionSettings(Compression.ZstdDecompressMaxBlockSize, None)
+
+ /**
+ * Scala API: the default settings, chunks of at most
`Compression.ZstdDecompressMaxBlockSize` bytes and no dictionary
+ */
+ def apply(): ZstdDecompressionSettings = Default
+
+ /**
+ * Java API: the default settings, chunks of at most
`Compression.ZstdDecompressMaxBlockSize` bytes and no dictionary
+ */
+ def create(): ZstdDecompressionSettings = Default
+}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdBuffers.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdBuffers.scala
new file mode 100644
index 0000000000..a5634ff233
--- /dev/null
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdBuffers.scala
@@ -0,0 +1,56 @@
+/*
+ * 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 org.apache.pekko.stream.impl.io.compression
+
+import java.nio.ByteBuffer
+
+import org.apache.pekko
+import pekko.annotation.InternalApi
+import pekko.util.ByteString
+
+/**
+ * INTERNAL API
+ *
+ * zstd-jni accepts array-backed heap ByteBuffers for streaming
(de)compression, these helpers
+ * move data between ByteStrings and such buffers while avoiding array copies
where possible.
+ */
+@InternalApi private[compression] object ZstdBuffers {
+
+ /**
+ * The fragments of `input` as heap buffers that wrap the backing arrays
without copying, so
+ * that a multi-fragment ByteString does not need to be compacted. zstd only
reads from them.
+ */
+ def inputBuffers(input: ByteString): Iterator[ByteBuffer] =
input.asByteBuffersUnsafe.iterator
+
+ /**
+ * Takes the bytes written to `output` (from 0 until its position) as a
ByteString. When the
+ * buffer is nearly full (at least 7/8 used) its array is handed over
without copying and a new
+ * buffer is returned for further output. Otherwise the bytes are copied
into an exact-size
+ * array, so that emitted ByteStrings do not retain much unused space, and
the buffer is reused.
+ */
+ def takeOutput(output: ByteBuffer): (ByteString, ByteBuffer) = {
+ val produced = output.position()
+ val capacity = output.capacity()
+ if (produced >= capacity - (capacity >> 3))
+ (ByteString.fromArrayUnsafe(output.array(), 0, produced),
ByteBuffer.allocate(capacity))
+ else {
+ output.clear()
+ (ByteString.fromArray(output.array(), 0, produced), output)
+ }
+ }
+}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdCompressor.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdCompressor.scala
new file mode 100644
index 0000000000..8e6b9234e4
--- /dev/null
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdCompressor.scala
@@ -0,0 +1,142 @@
+/*
+ * 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 org.apache.pekko.stream.impl.io.compression
+
+import java.nio.ByteBuffer
+
+import scala.util.control.NonFatal
+
+import com.github.luben.zstd.{ EndDirective, ZstdCompressCtx, ZstdDictCompress
}
+
+import org.apache.pekko
+import pekko.annotation.InternalApi
+import pekko.stream.scaladsl.Compression
+import pekko.util.{ ByteString, ByteStringBuilder }
+
+/** INTERNAL API */
+@InternalApi private[pekko] object ZstdCompressor {
+ private val OutputBufferSize = 65536
+ private val EmptyInput = ByteBuffer.allocate(0)
+}
+
+/**
+ * INTERNAL API
+ *
+ * @param windowLog the log2 of the maximum window size, 0 uses the default of
the compression level
+ */
+@InternalApi private[pekko] class ZstdCompressor(
+ compressionLevel: Int = Compression.ZstdDefaultCompressionLevel,
+ dictionary: Option[ZstdDictionaryImpl] = None,
+ windowLog: Int = 0) extends Compressor {
+ import ZstdCompressor._
+
+ private val compressCtx = newCompressCtx()
+
+ // A dictionary with a level is digested into a ZstdDictCompress, which has
to be closed after the
+ // context. Neither has a Cleaner, and the stage cannot close them in
postStop if the compressor is
+ // not created, so close them here if the setup fails.
+ private val compressDict: Option[ZstdDictCompress] =
+ try {
+ compressCtx.setLevel(compressionLevel)
+ if (windowLog != 0) compressCtx.setWindowLog(windowLog)
+ dictionary.flatMap(loadDictionary)
+ } catch {
+ case NonFatal(e) =>
+ compressCtx.close()
+ throw e
+ }
+
+ // zstd-jni supports heap ByteBuffers for streaming compression, so no
direct buffers are needed
+ private var outputBuffer = ByteBuffer.allocate(OutputBufferSize)
+
+ /** For tests, to observe that the context is closed. */
+ private[pekko] def newCompressCtx(): ZstdCompressCtx = new ZstdCompressCtx()
+
+ /** For tests, to observe that the dictionary is closed. */
+ private[pekko] def compressDictionary: Option[ZstdDictCompress] =
compressDict
+
+ private def loadDictionary(dict: ZstdDictionaryImpl):
Option[ZstdDictCompress] =
+ (dict.level, dict.length, dict.offset) match {
+ case (None, None, None) =>
+ compressCtx.loadDict(dict.dictionary)
+ None
+ case (Some(dictLevel), None, None) =>
+ Some(loadDictCompress(new ZstdDictCompress(dict.dictionary,
dictLevel)))
+ case (Some(dictLevel), Some(dictLength), Some(dictOffset)) =>
+ ZstdDictionaryImpl.requireSlice(dict.dictionary, dictOffset,
dictLength)
+ Some(loadDictCompress(new ZstdDictCompress(dict.dictionary,
dictOffset, dictLength, dictLevel)))
+ case (None, Some(dictLength), Some(dictOffset)) =>
+ // the context can only load a whole array, so load a copy of the slice
+ ZstdDictionaryImpl.requireSlice(dict.dictionary, dictOffset,
dictLength)
+ compressCtx.loadDict(java.util.Arrays.copyOfRange(dict.dictionary,
dictOffset, dictOffset + dictLength))
+ None
+ case _ =>
+ throw new IllegalArgumentException("ZstdDictionary offset and length
must be set together")
+ }
+
+ private def loadDictCompress(dictCompress: ZstdDictCompress):
ZstdDictCompress =
+ try {
+ compressCtx.loadDict(dictCompress)
+ dictCompress
+ } catch {
+ case NonFatal(e) =>
+ dictCompress.close()
+ throw e
+ }
+
+ override def compress(input: ByteString): ByteString = compressStream(input,
EndDirective.CONTINUE)
+
+ override def flush(): ByteString = compressStream(ByteString.empty,
EndDirective.FLUSH)
+
+ override def finish(): ByteString = compressStream(ByteString.empty,
EndDirective.END)
+
+ override def compressAndFlush(input: ByteString): ByteString =
compressStream(input, EndDirective.FLUSH)
+
+ override def compressAndFinish(input: ByteString): ByteString =
compressStream(input, EndDirective.END)
+
+ override def close(): Unit =
+ try compressCtx.close()
+ finally compressDict.foreach(_.close())
+
+ private def compressStream(input: ByteString, endOp: EndDirective):
ByteString = {
+ val builder = ByteString.newBuilder
+ val inputBuffers = ZstdBuffers.inputBuffers(input)
+ // only the last fragment of the input gets endOp, the ones before it are
just compressed
+ while (inputBuffers.hasNext) {
+ val inputBuffer = inputBuffers.next()
+ compressBuffer(inputBuffer, if (inputBuffers.hasNext)
EndDirective.CONTINUE else endOp, builder)
+ }
+ if (input.isEmpty && endOp != EndDirective.CONTINUE)
+ compressBuffer(EmptyInput, endOp, builder)
+ builder.result()
+ }
+
+ private def compressBuffer(inputBuffer: ByteBuffer, endOp: EndDirective,
builder: ByteStringBuilder): Unit = {
+ var done = false
+ while (!done) {
+ val flushed = compressCtx.compressByteBufferStream(outputBuffer,
inputBuffer, endOp)
+ if (outputBuffer.position() > 0) {
+ val (output, nextBuffer) = ZstdBuffers.takeOutput(outputBuffer)
+ builder ++= output
+ outputBuffer = nextBuffer
+ }
+ // with CONTINUE zstd may keep data in its internal buffers, otherwise
loop until everything is flushed
+ done = if (endOp == EndDirective.CONTINUE) !inputBuffer.hasRemaining
else flushed
+ }
+ }
+}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdDecompressor.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdDecompressor.scala
new file mode 100644
index 0000000000..ecabddbe80
--- /dev/null
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdDecompressor.scala
@@ -0,0 +1,129 @@
+/*
+ * 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 org.apache.pekko.stream.impl.io.compression
+
+import java.nio.ByteBuffer
+
+import scala.annotation.tailrec
+import scala.util.control.NonFatal
+
+import com.github.luben.zstd.{ Zstd, ZstdDecompressCtx, ZstdException }
+
+import org.apache.pekko
+import pekko.annotation.InternalApi
+import pekko.stream.Attributes
+import pekko.stream.impl.fusing.GraphStages.SimpleLinearGraphStage
+import pekko.stream.stage.{ GraphStageLogic, InHandler, OutHandler }
+import pekko.util.ByteString
+
+/** INTERNAL API */
+@InternalApi private[pekko] object ZstdDecompressor {
+ private val EmptyInput = ByteBuffer.allocate(0)
+ private val NoInput: Iterator[ByteBuffer] = Iterator.empty
+
+ // The level only applies to compression. Each materialization loads the
dictionary into its own
+ // context, which owns the digested dictionary and frees it on close. A
shared ZstdDictDecompress
+ // could not be closed when one materialization stops, and has no Cleaner,
so it would only be freed
+ // by finalization. The bytes are copied so that later changes to the
caller's array have no effect.
+ private def dictionaryBytes(dictionary: ZstdDictionaryImpl): Array[Byte] = {
+ val bytes = dictionary.dictionary
+ (dictionary.offset, dictionary.length) match {
+ case (None, None) => bytes.clone()
+ case (Some(offset), Some(length)) =>
+ ZstdDictionaryImpl.requireSlice(bytes, offset, length)
+ java.util.Arrays.copyOfRange(bytes, offset, offset + length)
+ case _ =>
+ throw new IllegalArgumentException("ZstdDictionary offset and length
must be set together")
+ }
+ }
+}
+
+/** INTERNAL API */
+@InternalApi private[pekko] class ZstdDecompressor(maxBytesPerChunk: Int,
dictionary: Option[ZstdDictionaryImpl])
+ extends SimpleLinearGraphStage[ByteString] {
+ import ZstdDecompressor._
+
+ require(maxBytesPerChunk > 0, "maxBytesPerChunk must be greater than 0")
+
+ private val decompressDict = dictionary.map(dictionaryBytes)
+
+ override def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
{
+ new GraphStageLogic(shape) with InHandler with OutHandler {
+
+ private val decompressCtx = new ZstdDecompressCtx()
+ // postStop is not called if creating the logic fails, so close the
context here
+ try decompressDict.foreach(decompressCtx.loadDict)
+ catch {
+ case NonFatal(e) =>
+ decompressCtx.close()
+ throw e
+ }
+
+ // zstd-jni supports heap ByteBuffers for streaming decompression, so no
direct buffers are needed
+ private var outputBuffer = ByteBuffer.allocate(maxBytesPerChunk)
+ private var inputBuffer = EmptyInput
+ // the remaining fragments of the current element, see
ZstdBuffers.inputBuffers
+ private var inputBuffers = NoInput
+ // set when the last call filled the output buffer before the frame
completed, zstd may then
+ // still hold decompressed data. A completed frame is fully flushed, and
another call would make
+ // zstd wait for a new frame and report the stream as incomplete.
+ private var outputPending = false
+ // false while a frame has been started but not fully decompressed and
flushed
+ private var frameComplete = true
+
+ override def onPush(): Unit = {
+ inputBuffers = ZstdBuffers.inputBuffers(grab(in))
+ decompress()
+ }
+
+ override def onPull(): Unit = decompress()
+
+ override def onUpstreamFinish(): Unit =
+ if (!hasInput && !outputPending) finish()
+ else if (isAvailable(out)) decompress()
+ // otherwise the remaining input is decompressed on the next onPull
+
+ private def hasInput: Boolean = {
+ while (!inputBuffer.hasRemaining && inputBuffers.hasNext) inputBuffer
= inputBuffers.next()
+ inputBuffer.hasRemaining
+ }
+
+ @tailrec
+ private def decompress(): Unit =
+ if (hasInput || outputPending) {
+ frameComplete =
decompressCtx.decompressByteBufferStream(outputBuffer, inputBuffer)
+ outputPending = !frameComplete && !outputBuffer.hasRemaining
+ if (outputBuffer.position() > 0) {
+ val (output, nextBuffer) = ZstdBuffers.takeOutput(outputBuffer)
+ outputBuffer = nextBuffer
+ push(out, output)
+ } else decompress()
+ } else if (isClosed(in)) finish()
+ else pull(in)
+
+ private def finish(): Unit =
+ if (frameComplete) completeStage()
+ else failStage(new ZstdException(Zstd.errSrcSizeWrong(), "Truncated
zstd stream"))
+
+ override def postStop(): Unit = decompressCtx.close()
+
+ setHandlers(in, out, this)
+ }
+ }
+
+}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdDictionaryImpl.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdDictionaryImpl.scala
new file mode 100644
index 0000000000..0415a9f7ef
--- /dev/null
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/io/compression/ZstdDictionaryImpl.scala
@@ -0,0 +1,37 @@
+/*
+ * 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 org.apache.pekko.stream.impl.io.compression
+
+import org.apache.pekko.annotation.{ InternalApi, InternalStableApi }
+
+@InternalStableApi
+private[pekko] abstract class ZstdDictionaryImpl {
+ def dictionary: Array[Byte]
+ def level: Option[Int]
+ def offset: Option[Int]
+ def length: Option[Int]
+}
+
+/** INTERNAL API */
+@InternalApi private[pekko] object ZstdDictionaryImpl {
+ def requireSlice(bytes: Array[Byte], offset: Int, length: Int): Unit =
+ require(
+ offset >= 0 && length >= 0 && offset <= bytes.length - length,
+ s"ZstdDictionary offset [$offset] and length [$length] must be within
the dictionary of " +
+ s"[${bytes.length}] bytes")
+}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Compression.scala
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Compression.scala
index f143528da0..a2d82858b7 100644
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Compression.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Compression.scala
@@ -13,8 +13,15 @@
package org.apache.pekko.stream.javadsl
+import java.util.Optional
+
+import scala.jdk.OptionConverters.RichOptional
+
import org.apache.pekko
import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.stream.{ ZstdCompressionSettings, ZstdDecompressionSettings }
+import pekko.stream.javadsl.compression.ZstdDictionary
import pekko.stream.scaladsl
import pekko.util.ByteString
@@ -104,4 +111,101 @@ object Compression {
def deflate(level: Int, nowrap: Boolean, autoFlush: Boolean):
Flow[ByteString, ByteString, NotUsed] =
scaladsl.Compression.deflate(level, nowrap, autoFlush).asJava
+ // The zstd constants delegate to scaladsl.Compression, which explains why
they are copies of
+ // libzstd's values rather than calls to the optional zstd-jni dependency.
+
+ /**
+ * The minimum compression level supported by zstd
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ final val ZstdMinCompressionLevel: Int =
scaladsl.Compression.ZstdMinCompressionLevel
+
+ /**
+ * The maximum compression level supported by zstd
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ final val ZstdMaxCompressionLevel: Int =
scaladsl.Compression.ZstdMaxCompressionLevel
+
+ /**
+ * The zstd default compression level
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ final val ZstdDefaultCompressionLevel: Int =
scaladsl.Compression.ZstdDefaultCompressionLevel
+
+ /**
+ * The minimum window log supported by zstd compression
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ final val ZstdMinWindowLog: Int = scaladsl.Compression.ZstdMinWindowLog
+
+ /**
+ * The maximum window log supported by zstd compression on 64-bit platforms
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ final val ZstdMaxWindowLog: Int = scaladsl.Compression.ZstdMaxWindowLog
+
+ /**
+ * Creates a flow that zstd-compresses a stream of ByteStrings, with the
default compression level.
+ *
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstd: Flow[ByteString, ByteString, NotUsed] =
+ scaladsl.Compression.zstd.asJava
+
+ /**
+ * Same as [[zstd]] with a custom level and an optional dictionary.
+ *
+ * @param level The compression level, must be greater or equal to
[[ZstdMinCompressionLevel]] and less than or equal
+ * to [[ZstdMaxCompressionLevel]]
+ * @param dictionary An optional dictionary that can be used for compression
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstd(level: Int, dictionary: Optional[ZstdDictionary]): Flow[ByteString,
ByteString, NotUsed] = {
+ val settings = ZstdCompressionSettings.create().withLevel(level)
+ zstd(dictionary.toScala.fold(settings)(settings.withDictionary))
+ }
+
+ /**
+ * Same as [[zstd]] with the given settings, see
[[pekko.stream.ZstdCompressionSettings]] for the level, flush
+ * mode, window size and dictionary.
+ *
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstd(settings: ZstdCompressionSettings): Flow[ByteString, ByteString,
NotUsed] =
+ scaladsl.Compression.zstd(settings).asJava
+
+ /**
+ * The maximum block size used by zstd decompression
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ final val ZstdDecompressMaxBlockSize: Int =
scaladsl.Compression.ZstdDecompressMaxBlockSize
+
+ /**
+ * Creates a flow that zstd-decompresses a stream of ByteStrings.
+ *
+ * @param maxBytesPerChunk The maximum length of the emitted ByteStrings
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstdDecompress(maxBytesPerChunk: Int): Flow[ByteString, ByteString,
NotUsed] =
+ scaladsl.Compression.zstdDecompress(maxBytesPerChunk).asJava
+
+ /**
+ * Same as [[zstdDecompress]] with the given settings, see
[[pekko.stream.ZstdDecompressionSettings]] for the chunk
+ * size and dictionary.
+ *
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstdDecompress(settings: ZstdDecompressionSettings): Flow[ByteString,
ByteString, NotUsed] =
+ scaladsl.Compression.zstdDecompress(settings).asJava
}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Compression.scala
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Compression.scala
index 967ff09a87..d1de529b2f 100644
--- a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Compression.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Compression.scala
@@ -17,7 +17,10 @@ import java.util.zip.Deflater
import org.apache.pekko
import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.stream.{ ZstdCompressionSettings, ZstdDecompressionSettings }
import pekko.stream.impl.io.compression._
+import pekko.stream.scaladsl.compression.ZstdDictionary
import pekko.util.ByteString
object Compression {
@@ -104,4 +107,115 @@ object Compression {
*/
def inflate(maxBytesPerChunk: Int, nowrap: Boolean): Flow[ByteString,
ByteString, NotUsed] =
Flow[ByteString].via(new DeflateDecompressor(maxBytesPerChunk,
nowrap)).named("inflate")
+
+ // The zstd constants below are copies of libzstd's values instead of calls
to zstd-jni (e.g.
+ // Zstd.minCompressionLevel()). zstd-jni is an optional dependency: calling
it here would make
+ // initializing this object, and so using gzip or deflate, fail with
NoClassDefFoundError when
+ // zstd-jni is not on the classpath, and would load the native library even
for those users.
+ // ZstdSpec checks that they still match zstd-jni, so a zstd-jni upgrade
that changes one of
+ // them fails the tests.
+
+ /**
+ * The minimum compression level supported by zstd
+ * @since 2.0.0
+ */
+ // ZSTD_minCLevel() is -ZSTD_TARGETLENGTH_MAX, which is ZSTD_BLOCKSIZE_MAX
(1 << 17) in libzstd
+ @ApiMayChange
+ final val ZstdMinCompressionLevel: Int = -(1 << 17)
+
+ /**
+ * The maximum compression level supported by zstd
+ * @since 2.0.0
+ */
+ // ZSTD_maxCLevel() is ZSTD_MAX_CLEVEL in libzstd
+ @ApiMayChange
+ final val ZstdMaxCompressionLevel: Int = 22
+
+ /**
+ * The zstd default compression level
+ * @since 2.0.0
+ */
+ // ZSTD_defaultCLevel() is ZSTD_CLEVEL_DEFAULT in libzstd
+ @ApiMayChange
+ final val ZstdDefaultCompressionLevel: Int = 3
+
+ /**
+ * The minimum window log supported by zstd compression
+ * @since 2.0.0
+ */
+ // ZSTD_WINDOWLOG_MIN in libzstd
+ @ApiMayChange
+ final val ZstdMinWindowLog: Int = 10
+
+ /**
+ * The maximum window log supported by zstd compression on 64-bit platforms
+ * @since 2.0.0
+ */
+ // ZSTD_WINDOWLOG_MAX_64 in libzstd
+ @ApiMayChange
+ final val ZstdMaxWindowLog: Int = 31
+
+ /**
+ * Creates a flow that zstd-compresses a stream of ByteStrings, with the
default compression level.
+ *
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstd: Flow[ByteString, ByteString, NotUsed] =
zstd(ZstdCompressionSettings())
+
+ /**
+ * Same as [[zstd]] with a custom level and an optional dictionary.
+ *
+ * @param level The compression level, must be greater or equal to
[[ZstdMinCompressionLevel]] and less than or equal
+ * to [[ZstdMaxCompressionLevel]]
+ * @param dictionary An optional dictionary that can be used for compression
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstd(level: Int, dictionary: Option[ZstdDictionary]): Flow[ByteString,
ByteString, NotUsed] = {
+ val settings = ZstdCompressionSettings().withLevel(level)
+ zstd(dictionary.fold(settings)(settings.withDictionary))
+ }
+
+ /**
+ * Same as [[zstd]] with the given settings, see
[[pekko.stream.ZstdCompressionSettings]] for the level, flush
+ * mode, window size and dictionary.
+ *
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstd(settings: ZstdCompressionSettings): Flow[ByteString, ByteString,
NotUsed] =
+ CompressionUtils.compressorFlow(
+ () => new ZstdCompressor(settings.level, settings.dictionaryImpl,
settings.windowLog),
+ settings.autoFlush)
+
+ /**
+ * The maximum block size used by zstd decompression
+ * @since 2.0.0
+ */
+ // ZSTD_BLOCKSIZE_MAX in libzstd is 1 << ZSTD_BLOCKSIZELOG_MAX (17), i.e.
128 KiB
+ @ApiMayChange
+ final val ZstdDecompressMaxBlockSize: Int = 1 << 17
+
+ /**
+ * Creates a flow that zstd-decompresses a stream of ByteStrings.
+ *
+ * @param maxBytesPerChunk The maximum length of the emitted ByteStrings
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstdDecompress(maxBytesPerChunk: Int = ZstdDecompressMaxBlockSize):
Flow[ByteString, ByteString, NotUsed] =
+
zstdDecompress(ZstdDecompressionSettings().withMaxBytesPerChunk(maxBytesPerChunk))
+
+ /**
+ * Same as [[zstdDecompress]] with the given settings, see
[[pekko.stream.ZstdDecompressionSettings]] for the chunk
+ * size and dictionary.
+ *
+ * @since 2.0.0
+ */
+ @ApiMayChange
+ def zstdDecompress(settings: ZstdDecompressionSettings): Flow[ByteString,
ByteString, NotUsed] =
+ Flow[ByteString]
+ .via(new ZstdDecompressor(settings.maxBytesPerChunk,
settings.dictionaryImpl))
+ .named("zstdDecompress")
}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/compression/ZstdDictionary.scala
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/compression/ZstdDictionary.scala
new file mode 100644
index 0000000000..2899c87802
--- /dev/null
+++
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/compression/ZstdDictionary.scala
@@ -0,0 +1,86 @@
+/*
+ * 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 org.apache.pekko.stream.scaladsl.compression
+
+import java.nio.charset.StandardCharsets
+
+import org.apache.pekko
+import pekko.annotation.{ ApiMayChange, InternalApi }
+import pekko.stream.impl.io.compression.ZstdDictionaryImpl
+import pekko.util.ByteString
+
+/**
+ * Configuration class based on the official C zstd library to specify
dictionary options for
+ * compression.
+ *
+ * @see
https://github.com/facebook/zstd?tab=readme-ov-file#dictionary-compression-how-to
+ * @see https://facebook.github.io/zstd/zstd_manual.html#Chapter10
+ * @since 2.0.0
+ */
+@ApiMayChange
+final case class ZstdDictionary private (dictionary: ByteString, level:
Option[Int],
+ offset: Option[Int], length: Option[Int]) { clazz =>
+
+ /**
+ * INTERNAL API
+ */
+ @InternalApi
+ private[pekko] def toImpl: ZstdDictionaryImpl = new ZstdDictionaryImpl {
+ // a copy, so that whoever holds the impl cannot modify the dictionary
+ override val dictionary: Array[Byte] = clazz.dictionary.toArray
+ override val level: Option[Int] = clazz.level
+ override val offset: Option[Int] = clazz.offset
+ override val length: Option[Int] = clazz.length
+ }
+}
+
+/**
+ * @since 2.0.0
+ */
+@ApiMayChange
+object ZstdDictionary {
+
+ // the array overloads copy the array, so later changes to it do not affect
the dictionary
+
+ def apply(dictionary: Array[Byte]): ZstdDictionary =
+ ZstdDictionary(ByteString(dictionary))
+
+ def apply(dictionary: Array[Byte], level: Int): ZstdDictionary =
+ ZstdDictionary(ByteString(dictionary), level)
+
+ def apply(dictionary: Array[Byte], level: Int, offset: Int, length: Int):
ZstdDictionary =
+ ZstdDictionary(ByteString(dictionary), level, offset, length)
+
+ def apply(dictionary: ByteString): ZstdDictionary =
+ ZstdDictionary(dictionary, None, None, None)
+
+ def apply(dictionary: ByteString, level: Int): ZstdDictionary =
+ ZstdDictionary(dictionary, Some(level), None, None)
+
+ def apply(dictionary: ByteString, level: Int, offset: Int, length: Int):
ZstdDictionary =
+ ZstdDictionary(dictionary, Some(level), Some(offset), Some(length))
+
+ def apply(dictionary: String): ZstdDictionary =
+ ZstdDictionary(ByteString(dictionary, StandardCharsets.UTF_8))
+
+ def apply(dictionary: String, level: Int): ZstdDictionary =
+ ZstdDictionary(ByteString(dictionary, StandardCharsets.UTF_8), level)
+
+ def apply(dictionary: String, level: Int, offset: Int, length: Int):
ZstdDictionary =
+ ZstdDictionary(ByteString(dictionary, StandardCharsets.UTF_8), level,
offset, length)
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]