Skip to content

Commit 04602ae

Browse files
dfa1claude
andauthored
feat: prefix dictionaries (refPrefix) for delta compression (#27)
* 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> * test: close refPrefix coverage gaps - prove refPrefix is single-use: prefix set once, two compressions, only the first frame shrinks (random data at level 19); second frame decodes with a plain decoder, confirming it does not stick across frames. - assert ZstdDecompressCtx.refPrefix also rejects a heap segment (was only covered on the compress side). - import java.util.Arrays instead of the fully-qualified inline reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 83fce25 commit 04602ae

6 files changed

Lines changed: 286 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: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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.Arrays;
10+
import java.util.Random;
11+
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
12+
import org.junit.jupiter.api.Test;
13+
14+
class RefPrefixTest {
15+
16+
@Test
17+
void roundTripsWithMatchingPrefix() {
18+
// Given
19+
byte[] prefixBytes = "the quick brown fox jumps over the lazy dog".getBytes();
20+
byte[] dataBytes = "the quick brown fox jumps over the lazy cat".getBytes();
21+
try (Arena arena = Arena.ofConfined();
22+
ZstdCompressCtx cctx = new ZstdCompressCtx();
23+
ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
24+
MemorySegment prefix = copy(arena, prefixBytes);
25+
MemorySegment src = copy(arena, dataBytes);
26+
MemorySegment frame = arena.allocate(Zstd.compressBound(dataBytes.length));
27+
MemorySegment out = arena.allocate(dataBytes.length);
28+
29+
// When
30+
cctx.refPrefix(prefix);
31+
long n = cctx.compress(frame, src);
32+
dctx.refPrefix(prefix);
33+
long m = dctx.decompress(out, frame.asSlice(0, n));
34+
35+
// Then
36+
assertThat(m).isEqualTo(dataBytes.length);
37+
assertThat(bytes(out, (int) m)).isEqualTo(dataBytes);
38+
}
39+
}
40+
41+
@Test
42+
void prefixIsAppliedAndRequiredToDecode() {
43+
// Given — incompressible (random) data identical to the prefix. With no
44+
// internal redundancy the encoder can only shrink it by matching against
45+
// the prefix, so a much smaller frame proves the prefix is applied.
46+
// (Level 19: the level-3 match finder is too weak to reach into the prefix
47+
// for random content.)
48+
byte[] random = randomBytes(0xBEEF, 16384);
49+
try (Arena arena = Arena.ofConfined()) {
50+
MemorySegment prefix = copy(arena, random);
51+
MemorySegment src = copy(arena, random);
52+
53+
long baseline;
54+
try (ZstdCompressCtx cctx = new ZstdCompressCtx().level(19)) {
55+
baseline = cctx.compress(arena, src).byteSize();
56+
}
57+
byte[] frame;
58+
try (ZstdCompressCtx cctx = new ZstdCompressCtx().level(19)) {
59+
cctx.refPrefix(prefix);
60+
MemorySegment f = cctx.compress(arena, src);
61+
frame = bytes(f, (int) f.byteSize());
62+
}
63+
64+
// Then — the prefix slashes the frame (16 KiB random → tens of bytes)
65+
assertThat((long) frame.length).isLessThan(baseline / 10);
66+
67+
// And — it round-trips with the same prefix
68+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
69+
MemorySegment out = arena.allocate(random.length);
70+
dctx.refPrefix(prefix);
71+
long m = dctx.decompress(out, copy(arena, frame));
72+
assertThat(bytes(out, (int) m)).isEqualTo(random);
73+
}
74+
75+
// But — it cannot be recovered without the prefix
76+
boolean reproduced;
77+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
78+
MemorySegment out = arena.allocate(random.length);
79+
long m = dctx.decompress(out, copy(arena, frame));
80+
reproduced = Arrays.equals(bytes(out, (int) m), random);
81+
} catch (ZstdException e) {
82+
reproduced = false;
83+
}
84+
assertThat(reproduced).isFalse();
85+
}
86+
}
87+
88+
@Test
89+
void clearingPrefixWithNullCompressesPlainly() {
90+
// Given
91+
byte[] dataBytes = "the quick brown fox".getBytes();
92+
try (Arena arena = Arena.ofConfined();
93+
ZstdCompressCtx cctx = new ZstdCompressCtx()) {
94+
MemorySegment prefix = copy(arena, "a prior version of the text".getBytes());
95+
MemorySegment src = copy(arena, dataBytes);
96+
MemorySegment frame = arena.allocate(Zstd.compressBound(dataBytes.length));
97+
98+
// When — set then clear the prefix before compressing
99+
cctx.refPrefix(prefix);
100+
cctx.refPrefix((MemorySegment) null);
101+
long n = cctx.compress(frame, src);
102+
103+
// Then — a plain decoder with no prefix decodes it
104+
byte[] restored;
105+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
106+
MemorySegment out = arena.allocate(dataBytes.length);
107+
long m = dctx.decompress(out, frame.asSlice(0, n));
108+
restored = bytes(out, (int) m);
109+
}
110+
assertThat(restored).isEqualTo(dataBytes);
111+
}
112+
}
113+
114+
@Test
115+
void prefixIsSingleUseAndDoesNotStickAcrossFrames() {
116+
// Given — random data identical to the prefix (see prefixIsAppliedAndRequiredToDecode),
117+
// and one context kept open across two compressions with the prefix set once.
118+
byte[] random = randomBytes(0xCAFE, 16384);
119+
try (Arena arena = Arena.ofConfined();
120+
ZstdCompressCtx cctx = new ZstdCompressCtx().level(19)) {
121+
MemorySegment prefix = copy(arena, random);
122+
MemorySegment src = copy(arena, random);
123+
MemorySegment first = arena.allocate(Zstd.compressBound(random.length));
124+
MemorySegment second = arena.allocate(Zstd.compressBound(random.length));
125+
126+
// When — first frame consumes the prefix; second is compressed with no re-set
127+
cctx.refPrefix(prefix);
128+
long n1 = cctx.compress(first, src);
129+
long n2 = cctx.compress(second, src);
130+
131+
// Then — the prefix shrank only the first frame; the second got no prefix
132+
// (incompressible random with no prefix stays ~full size)
133+
assertThat(n1).isLessThan(n2 / 10);
134+
135+
// And — the second frame carries no prefix, so a plain decoder decodes it
136+
byte[] restored;
137+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
138+
MemorySegment out = arena.allocate(random.length);
139+
long m = dctx.decompress(out, second.asSlice(0, n2));
140+
restored = bytes(out, (int) m);
141+
}
142+
assertThat(restored).isEqualTo(random);
143+
}
144+
}
145+
146+
@Test
147+
void compressRefPrefixRejectsAHeapSegment() {
148+
// Given
149+
MemorySegment heap = MemorySegment.ofArray("prefix".getBytes());
150+
151+
// When
152+
ThrowingCallable result = () -> {
153+
try (ZstdCompressCtx cctx = new ZstdCompressCtx()) {
154+
cctx.refPrefix(heap);
155+
}
156+
};
157+
158+
// Then
159+
assertThatThrownBy(result)
160+
.isInstanceOf(IllegalArgumentException.class)
161+
.hasMessageContaining("prefix");
162+
}
163+
164+
@Test
165+
void decompressRefPrefixRejectsAHeapSegment() {
166+
// Given
167+
MemorySegment heap = MemorySegment.ofArray("prefix".getBytes());
168+
169+
// When
170+
ThrowingCallable result = () -> {
171+
try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
172+
dctx.refPrefix(heap);
173+
}
174+
};
175+
176+
// Then
177+
assertThatThrownBy(result)
178+
.isInstanceOf(IllegalArgumentException.class)
179+
.hasMessageContaining("prefix");
180+
}
181+
182+
private static MemorySegment copy(Arena arena, byte[] src) {
183+
MemorySegment seg = arena.allocate(src.length);
184+
MemorySegment.copy(src, 0, seg, JAVA_BYTE, 0, src.length);
185+
return seg;
186+
}
187+
188+
private static byte[] bytes(MemorySegment seg, int len) {
189+
byte[] out = new byte[len];
190+
MemorySegment.copy(seg, JAVA_BYTE, 0, out, 0, len);
191+
return out;
192+
}
193+
194+
private static byte[] randomBytes(long seed, int n) {
195+
byte[] b = new byte[n];
196+
new Random(seed).nextBytes(b);
197+
return b;
198+
}
199+
}

0 commit comments

Comments
 (0)