Skip to content

Commit a4e5eec

Browse files
dfa1claude
andcommitted
feat: prefix dictionaries on contexts (refPrefix) for delta compression
Bind ZSTD_CCtx_refPrefix / ZSTD_DCtx_refPrefix as ZstdCompressCtx.refPrefix(MemorySegment) / ZstdDecompressCtx.refPrefix(...): a single-use, by-reference raw-content dictionary for the next frame only — the building block for delta compression (compress a new version against a similar previous one). No digest, no copy, no dictionary ID written; the decompressor must reference the same prefix to decode. Segment-only by design. refPrefix references the prefix by pointer (unlike loadDictionary, which copies internally), so the prefix must outlive the next compression — a contract only a caller-owned native segment can honour. A byte[] overload would have to copy, defeating the zero-copy point and forcing a context-held arena to keep the copy alive; heap callers should use the copying loadDictionary instead. RefPrefixTest proves the prefix is actually applied (16 KiB of random data, identical to the prefix, compresses to tens of bytes at level 19 only with the prefix) and is required to decode — a round-trip-only test would pass even if refPrefix were a no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 83fce25 commit a4e5eec

6 files changed

Lines changed: 235 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes to this project are documented here. Format loosely follows
44
[Keep a Changelog](https://keepachangelog.com/); versions are released as `v*`
55
git tags, which trigger publication to Maven Central.
66

7+
## [Unreleased]
8+
9+
### Added
10+
- `ZstdCompressCtx.refPrefix(MemorySegment)` / `ZstdDecompressCtx.refPrefix(...)`
11+
— reference native content as a single-use prefix (raw-content dictionary) for
12+
the next frame only: the building block for delta compression (compress a new
13+
version against a similar previous one). The prefix is referenced, not copied
14+
or digested, and writes no dictionary ID; the decompressor must reference the
15+
same prefix to decode. Binds `ZSTD_CCtx_refPrefix` / `ZSTD_DCtx_refPrefix`.
16+
Segment-only by design — heap callers that need a copy should use
17+
`loadDictionary` instead.
18+
719
## [0.5]
820

921
### Added

docs/supported.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ rather than the deprecated `ZSTD_getDecompressedSize`.
3333
| Dictionary training (ZDICT) | 8 / 12 | trainFromBuffer, cover/fastCover optimizers, finalizeDictionary, getDictHeaderSize |
3434
| Streaming — compress | 3 / 22 | `ZstdOutputStream` (compressStream2 + buffer sizes) |
3535
| Streaming — decompress | 3 / 15 | `ZstdInputStream` (decompressStream + buffer sizes) |
36-
| Advanced parameters | 12 / 38 | all `ZSTD_cParameter` + `ZSTD_dParameter` via `ZstdCompressParameter`/`ZstdDecompressParameter`; `compress2`, `C/DCtx_setParameter`, `C/DCtx_reset`, `C/DCtx_loadDictionary`, `CCtx_refCDict`/`DCtx_refDDict`, `c/dParam_getBounds`; MT inert on single-thread build |
36+
| Advanced parameters | 14 / 38 | all `ZSTD_cParameter` + `ZSTD_dParameter` via `ZstdCompressParameter`/`ZstdDecompressParameter`; `compress2`, `C/DCtx_setParameter`, `C/DCtx_reset`, `C/DCtx_loadDictionary`, `CCtx_refCDict`/`DCtx_refDDict`, `C/DCtx_refPrefix`, `c/dParam_getBounds`; MT inert on single-thread build |
3737
| Frame inspection | 10 / 13 | `ZstdFrame` + getFrameProgression; `_advanced` not bound |
3838
| Memory sizing | 8 / 14 | sizeof_C/DCtx, sizeof_C/DDict, estimate C/DCtx + C/DDict size |
3939
| Low-level block | 0 / 12 | expert block/continue API not bound |
@@ -233,7 +233,7 @@ sequence-producer hooks vs this library's frame-inspection and typed-error surfa
233233
| `ZSTD_resetDStream` | — ᵈ ||
234234
| `ZSTD_sizeof_DStream` |||
235235

236-
### Advanced parameters (12/38)
236+
### Advanced parameters (14/38)
237237

238238
| Symbol | Bound | zstd-jni |
239239
|---|:---:|:---:|
@@ -248,7 +248,7 @@ sequence-producer hooks vs this library's frame-inspection and typed-error surfa
248248
| `ZSTD_CCtx_loadDictionary_advanced` |||
249249
| `ZSTD_CCtx_loadDictionary_byReference` |||
250250
| `ZSTD_CCtx_refCDict` |||
251-
| `ZSTD_CCtx_refPrefix` | ||
251+
| `ZSTD_CCtx_refPrefix` | ||
252252
| `ZSTD_CCtx_refPrefix_advanced` |||
253253
| `ZSTD_CCtx_refThreadPool` |||
254254
| `ZSTD_CCtx_reset` |||
@@ -263,7 +263,7 @@ sequence-producer hooks vs this library's frame-inspection and typed-error surfa
263263
| `ZSTD_DCtx_loadDictionary_advanced` |||
264264
| `ZSTD_DCtx_loadDictionary_byReference` |||
265265
| `ZSTD_DCtx_refDDict` |||
266-
| `ZSTD_DCtx_refPrefix` | ||
266+
| `ZSTD_DCtx_refPrefix` | ||
267267
| `ZSTD_DCtx_refPrefix_advanced` |||
268268
| `ZSTD_DCtx_reset` |||
269269
| `ZSTD_DCtx_setFormat` | — ᵈ ||

zstd/src/main/java/io/github/dfa1/zstd/Bindings.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,15 @@ final class Bindings {
210210
NativeLibrary.lookup("ZSTD_DCtx_loadDictionary",
211211
FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG));
212212

213+
// size_t ZSTD_CCtx_refPrefix(ZSTD_CCtx*, const void* prefix, size_t prefixSize)
214+
static final MethodHandle CCTX_REF_PREFIX =
215+
NativeLibrary.lookup("ZSTD_CCtx_refPrefix",
216+
FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG));
217+
// size_t ZSTD_DCtx_refPrefix(ZSTD_DCtx*, const void* prefix, size_t prefixSize)
218+
static final MethodHandle DCTX_REF_PREFIX =
219+
NativeLibrary.lookup("ZSTD_DCtx_refPrefix",
220+
FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG));
221+
213222
// size_t ZSTD_CStreamInSize(void) / ZSTD_CStreamOutSize(void)
214223
static final MethodHandle CSTREAM_IN_SIZE =
215224
NativeLibrary.lookup("ZSTD_CStreamInSize", FunctionDescriptor.of(JAVA_LONG));

zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressCtx.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,38 @@ public ZstdCompressCtx refDictionary(ZstdCompressDict dict) {
177177
return this;
178178
}
179179

180+
/// References native `prefix` content as a single-use dictionary for the
181+
/// **next** compression only — the building block for delta compression:
182+
/// compress a new version against a similar previous one as the prefix,
183+
/// storing little more than the difference.
184+
///
185+
/// Unlike [#loadDictionary(ZstdDictionary)], a prefix is referenced (no
186+
/// digest, no heap copy), writes no dictionary ID into the frame, and is
187+
/// consumed by the next [#compress(MemorySegment, MemorySegment)] /
188+
/// [#compress(byte[])] — it does not stick across frames. The decompressor
189+
/// must reference the **same** prefix with
190+
/// [ZstdDecompressCtx#refPrefix(MemorySegment)] to decode the frame.
191+
///
192+
/// Because the prefix is referenced, `prefix` must stay valid until the next
193+
/// compression completes. Heap callers that cannot manage native lifetime
194+
/// should use a copying dictionary ([#loadDictionary(ZstdDictionary)]) instead.
195+
///
196+
/// @param prefix native prefix content, or `null` / [MemorySegment#NULL] to clear it
197+
/// @return `this`, for chaining
198+
/// @throws ZstdException if the prefix cannot be referenced
199+
public ZstdCompressCtx refPrefix(MemorySegment prefix) {
200+
if (NativeCall.isNull(prefix)) {
201+
return refPrefix(MemorySegment.NULL, 0L);
202+
}
203+
NativeCall.requireNative(prefix, "prefix");
204+
return refPrefix(prefix, prefix.byteSize());
205+
}
206+
207+
private ZstdCompressCtx refPrefix(MemorySegment prefix, long size) {
208+
NativeCall.checkReturnValue(() -> (long) Bindings.CCTX_REF_PREFIX.invokeExact(ptr(), prefix, size));
209+
return this;
210+
}
211+
180212
/// Compresses `src` into a new zstd frame using this context and its
181213
/// advanced parameters.
182214
///

zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressCtx.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,36 @@ public ZstdDecompressCtx refDictionary(ZstdDecompressDict dict) {
126126
return this;
127127
}
128128

129+
/// References native `prefix` content as a single-use dictionary for decoding
130+
/// the **next** frame only — the decompression counterpart of
131+
/// [ZstdCompressCtx#refPrefix(MemorySegment)]. It must be the **same** content
132+
/// the frame was compressed against, or decoding fails.
133+
///
134+
/// The prefix is referenced, not copied (no digest, no heap copy): `prefix`
135+
/// must stay valid until the next [#decompress(byte[], int)] /
136+
/// [#decompress(MemorySegment, MemorySegment)], which consumes it — it does not
137+
/// stick across frames. Pass `null` / [MemorySegment#NULL] to clear a prefix
138+
/// set but not yet consumed.
139+
///
140+
/// Heap callers that cannot manage native lifetime should use a copying
141+
/// dictionary ([#loadDictionary(ZstdDictionary)]) instead.
142+
///
143+
/// @param prefix native prefix content, or `null` / [MemorySegment#NULL] to clear it
144+
/// @return `this`, for chaining
145+
/// @throws ZstdException if the prefix cannot be referenced
146+
public ZstdDecompressCtx refPrefix(MemorySegment prefix) {
147+
if (NativeCall.isNull(prefix)) {
148+
return refPrefix(MemorySegment.NULL, 0L);
149+
}
150+
NativeCall.requireNative(prefix, "prefix");
151+
return refPrefix(prefix, prefix.byteSize());
152+
}
153+
154+
private ZstdDecompressCtx refPrefix(MemorySegment prefix, long size) {
155+
NativeCall.checkReturnValue(() -> (long) Bindings.DCTX_REF_PREFIX.invokeExact(ptr(), prefix, size));
156+
return this;
157+
}
158+
129159
/// Decompresses a frame into a buffer of at most `maxSize` bytes.
130160
///
131161
/// @param compressed a complete zstd frame
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package io.github.dfa1.zstd;
2+
3+
import static java.lang.foreign.ValueLayout.JAVA_BYTE;
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
6+
7+
import java.lang.foreign.Arena;
8+
import java.lang.foreign.MemorySegment;
9+
import java.util.Random;
10+
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
11+
import org.junit.jupiter.api.Test;
12+
13+
class RefPrefixTest {
14+
15+
@Test
16+
void roundTripsWithMatchingPrefix() {
17+
// Given
18+
byte[] prefixBytes = "the quick brown fox jumps over the lazy dog".getBytes();
19+
byte[] dataBytes = "the quick brown fox jumps over the lazy cat".getBytes();
20+
try (Arena arena = Arena.ofConfined();
21+
ZstdCompressCtx cctx = new ZstdCompressCtx();
22+
ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
23+
MemorySegment prefix = copy(arena, prefixBytes);
24+
MemorySegment src = copy(arena, dataBytes);
25+
MemorySegment frame = arena.allocate(Zstd.compressBound(dataBytes.length));
26+
MemorySegment out = arena.allocate(dataBytes.length);
27+
28+
// When
29+
cctx.refPrefix(prefix);
30+
long n = cctx.compress(frame, src);
31+
dctx.refPrefix(prefix);
32+
long m = dctx.decompress(out, frame.asSlice(0, n));
33+
34+
// Then
35+
assertThat(m).isEqualTo(dataBytes.length);
36+
assertThat(bytes(out, (int) m)).isEqualTo(dataBytes);
37+
}
38+
}
39+
40+
@Test
41+
void prefixIsAppliedAndRequiredToDecode() {
42+
// Given — incompressible (random) data identical to the prefix. With no
43+
// internal redundancy the encoder can only shrink it by matching against
44+
// the prefix, so a much smaller frame proves the prefix is applied.
45+
// (Level 19: the level-3 match finder is too weak to reach into the prefix
46+
// for random content.)
47+
byte[] random = randomBytes(0xBEEF, 16384);
48+
try (Arena arena = Arena.ofConfined()) {
49+
MemorySegment prefix = copy(arena, random);
50+
MemorySegment src = copy(arena, random);
51+
52+
long baseline;
53+
try (ZstdCompressCtx cctx = new ZstdCompressCtx().level(19)) {
54+
baseline = cctx.compress(arena, src).byteSize();
55+
}
56+
byte[] frame;
57+
try (ZstdCompressCtx cctx = new ZstdCompressCtx().level(19)) {
58+
cctx.refPrefix(prefix);
59+
MemorySegment f = cctx.compress(arena, src);
60+
frame = bytes(f, (int) f.byteSize());
61+
}
62+
63+
// Then — the prefix slashes the frame (16 KiB random → tens of bytes)
64+
assertThat((long) frame.length).isLessThan(baseline / 10);
65+
66+
// And — it round-trips with the same prefix
67+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
68+
MemorySegment out = arena.allocate(random.length);
69+
dctx.refPrefix(prefix);
70+
long m = dctx.decompress(out, copy(arena, frame));
71+
assertThat(bytes(out, (int) m)).isEqualTo(random);
72+
}
73+
74+
// But — it cannot be recovered without the prefix
75+
boolean reproduced;
76+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
77+
MemorySegment out = arena.allocate(random.length);
78+
long m = dctx.decompress(out, copy(arena, frame));
79+
reproduced = java.util.Arrays.equals(bytes(out, (int) m), random);
80+
} catch (ZstdException e) {
81+
reproduced = false;
82+
}
83+
assertThat(reproduced).isFalse();
84+
}
85+
}
86+
87+
@Test
88+
void clearingPrefixWithNullCompressesPlainly() {
89+
// Given
90+
byte[] dataBytes = "the quick brown fox".getBytes();
91+
try (Arena arena = Arena.ofConfined();
92+
ZstdCompressCtx cctx = new ZstdCompressCtx()) {
93+
MemorySegment prefix = copy(arena, "a prior version of the text".getBytes());
94+
MemorySegment src = copy(arena, dataBytes);
95+
MemorySegment frame = arena.allocate(Zstd.compressBound(dataBytes.length));
96+
97+
// When — set then clear the prefix before compressing
98+
cctx.refPrefix(prefix);
99+
cctx.refPrefix((MemorySegment) null);
100+
long n = cctx.compress(frame, src);
101+
102+
// Then — a plain decoder with no prefix decodes it
103+
byte[] restored;
104+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
105+
MemorySegment out = arena.allocate(dataBytes.length);
106+
long m = dctx.decompress(out, frame.asSlice(0, n));
107+
restored = bytes(out, (int) m);
108+
}
109+
assertThat(restored).isEqualTo(dataBytes);
110+
}
111+
}
112+
113+
@Test
114+
void refPrefixRejectsAHeapSegment() {
115+
// Given
116+
MemorySegment heap = MemorySegment.ofArray("prefix".getBytes());
117+
118+
// When
119+
ThrowingCallable result = () -> {
120+
try (ZstdCompressCtx cctx = new ZstdCompressCtx()) {
121+
cctx.refPrefix(heap);
122+
}
123+
};
124+
125+
// Then
126+
assertThatThrownBy(result)
127+
.isInstanceOf(IllegalArgumentException.class)
128+
.hasMessageContaining("prefix");
129+
}
130+
131+
private static MemorySegment copy(Arena arena, byte[] src) {
132+
MemorySegment seg = arena.allocate(src.length);
133+
MemorySegment.copy(src, 0, seg, JAVA_BYTE, 0, src.length);
134+
return seg;
135+
}
136+
137+
private static byte[] bytes(MemorySegment seg, int len) {
138+
byte[] out = new byte[len];
139+
MemorySegment.copy(seg, JAVA_BYTE, 0, out, 0, len);
140+
return out;
141+
}
142+
143+
private static byte[] randomBytes(long seed, int n) {
144+
byte[] b = new byte[n];
145+
new Random(seed).nextBytes(b);
146+
return b;
147+
}
148+
}

0 commit comments

Comments
 (0)