Skip to content

Commit c213a63

Browse files
dfa1claude
andcommitted
feat: load/ref dictionaries on contexts (loadDictionary / refDictionary)
Bind ZSTD_CCtx/DCtx_loadDictionary on the one-shot contexts (previously only on streams) and the new ZSTD_CCtx_refCDict / ZSTD_DCtx_refDDict. A sticky dictionary on a context lets compression combine a dictionary with the advanced parameters (checksum, window log, long-distance matching) via the compress2 path — impossible through the per-call compress(src, dict) overloads, which route the legacy dictionary path. refDictionary attaches a pre-digested CDict/DDict by reference (no copy, no per-call digest), the pooled-context hot path that pairs with reset. A parameter reset clears either. loadDictionary takes a ZstdDictionary or a native MemorySegment (zero-copy); refDictionary borrows the digest, so the caller keeps it alive. Tests cover dict+checksum combine, ref-across-session-reset, reset/null clearing, segment load, and zstd-jni interop on both frames. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4a8976b commit c213a63

8 files changed

Lines changed: 351 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ git tags, which trigger publication to Maven Central.
1212
it. `SESSION_ONLY` keeps the level, parameters, and dictionary; `PARAMETERS` /
1313
`SESSION_AND_PARAMETERS` restore the defaults. Binds `ZSTD_CCtx_reset` /
1414
`ZSTD_DCtx_reset`.
15+
- `ZstdCompressCtx.loadDictionary(...)` / `ZstdDecompressCtx.loadDictionary(...)`
16+
(a `ZstdDictionary` or a native `MemorySegment`) and `refDictionary(...)` (a
17+
pre-digested `ZstdCompressDict` / `ZstdDecompressDict`, attached by reference,
18+
no copy). A sticky dictionary on the context lets compression combine a
19+
dictionary with the advanced parameters (checksum, window log, long-distance
20+
matching) — impossible through the per-call `compress(src, dict)` overloads,
21+
which route the legacy dictionary path. A parameter `reset(...)` clears it.
22+
Binds `ZSTD_CCtx_loadDictionary` / `ZSTD_DCtx_loadDictionary` (now on contexts,
23+
not just streams), `ZSTD_CCtx_refCDict`, `ZSTD_DCtx_refDDict`.
1524

1625
### Changed
1726
- `NativeLibrary.classifier()` now throws a clear `UnsatisfiedLinkError` naming

docs/how-to.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,39 @@ try (ZstdCompressCtx cctx = new ZstdCompressCtx().level(19)) {
4343
allocation; reset lets a long-lived or pooled context return to a known state
4444
without churning native memory.
4545

46+
## Compress with a dictionary *and* advanced parameters
47+
48+
The per-call `compress(src, dict)` overloads take the legacy dictionary path,
49+
which ignores the advanced parameters (checksum, window log, long-distance
50+
matching) set on the context. To combine the two, make the dictionary *sticky*
51+
with `loadDictionary` — then the normal `compress` path honours both:
52+
53+
```java
54+
try (ZstdCompressCtx cctx = new ZstdCompressCtx().level(19).checksum(true)) {
55+
cctx.loadDictionary(dict); // ZstdDictionary, or a native MemorySegment
56+
byte[] frame = cctx.compress(record); // dictionary + checksum, together
57+
}
58+
```
59+
60+
For a dictionary reused across a pool of contexts, digest it once and attach it
61+
by reference — no per-call digesting, no copy. It pairs with `reset` for a
62+
pooled, recycled context:
63+
64+
```java
65+
try (ZstdCompressDict cdict = new ZstdCompressDict(dict, 19)) {
66+
// one cctx per pooled worker, all sharing the one digested dictionary
67+
try (ZstdCompressCtx cctx = new ZstdCompressCtx()) {
68+
cctx.refDictionary(cdict); // borrowed; cdict must outlive cctx
69+
byte[] a = cctx.compress(first);
70+
cctx.reset(ZstdResetDirective.SESSION_ONLY); // recycle, keep the dictionary
71+
byte[] b = cctx.compress(second);
72+
}
73+
}
74+
```
75+
76+
A loaded or referenced dictionary stays until replaced, cleared with `null`, or
77+
dropped by a parameter `reset`. `ZstdDecompressCtx` mirrors all of this.
78+
4679
## Compress many small payloads with a dictionary
4780

4881
For many small, similar payloads (log lines, JSON records, protobufs), a

docs/supported.md

Lines changed: 7 additions & 6 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 | 10 / 38 | all `ZSTD_cParameter` + `ZSTD_dParameter` via `ZstdCompressParameter`/`ZstdDecompressParameter`; `compress2`, `C/DCtx_setParameter`, `C/DCtx_reset`, `loadDictionary`, `c/dParam_getBounds`; MT inert on single-thread build |
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 |
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 |
@@ -67,7 +67,8 @@ rather than the deprecated `ZSTD_getDecompressedSize`.
6767
| `ZSTD_getDictID_fromCDict`, `ZSTD_getDictID_fromDDict` | `ZstdCompressDict.id()` / `ZstdDecompressDict.id()` |
6868
| `ZSTD_getErrorString` | `ZstdErrorCode.description()` |
6969
| `ZSTD_cParam_getBounds`, `ZSTD_dParam_getBounds` | `ZstdCompressParameter.bounds()` / `ZstdDecompressParameter.bounds()` (`ZstdBounds`) |
70-
| `ZSTD_CCtx_loadDictionary`, `ZSTD_DCtx_loadDictionary` | `ZstdOutputStream` / `ZstdInputStream` dictionary constructors |
70+
| `ZSTD_CCtx_loadDictionary`, `ZSTD_DCtx_loadDictionary` | `ZstdCompressCtx.loadDictionary` / `ZstdDecompressCtx.loadDictionary`; `ZstdOutputStream` / `ZstdInputStream` dictionary constructors |
71+
| `ZSTD_CCtx_refCDict`, `ZSTD_DCtx_refDDict` | `ZstdCompressCtx.refDictionary` / `ZstdDecompressCtx.refDictionary` |
7172
| `ZSTD_isFrame`, `ZSTD_findFrameCompressedSize`, `ZSTD_decompressBound`, `ZSTD_getDictID_fromFrame`, `ZSTD_getFrameHeader`, `ZSTD_isSkippableFrame`, `ZSTD_writeSkippableFrame`, `ZSTD_readSkippableFrame` | `ZstdFrame` (+ `ZstdFrameHeader`, `ZstdFrameType`, `ZstdSkippableContent`) |
7273
| `ZSTD_getErrorCode` | `ZstdException.code()` (+ `ZstdErrorCode`) |
7374
| `ZSTD_getFrameProgression` | `ZstdCompressStream.progress()` (`ZstdFrameProgression`) |
@@ -91,7 +92,7 @@ zstd-jni's JNI sources (v1.5.7-11, `src/main/native/*.c`). The latter is
9192
symbol-exact, not functional equivalence: zstd-jni may expose an operation through
9293
a different symbol than this library — e.g. it routes one-shot compression through
9394
`ZSTD_compress2`, so `ZSTD_compress` reads `` for it even though `Zstd.compress`
94-
works. zstd-jni references 53 of these symbols; this library binds 57. They
95+
works. zstd-jni references 53 of these symbols; this library binds 59. They
9596
overlap on the modern context/streaming API and diverge mainly on zstd-jni's
9697
sequence-producer hooks vs this library's frame-inspection and typed-error surface.
9798

@@ -232,7 +233,7 @@ sequence-producer hooks vs this library's frame-inspection and typed-error surfa
232233
| `ZSTD_resetDStream` | — ᵈ ||
233234
| `ZSTD_sizeof_DStream` |||
234235

235-
### Advanced parameters (10/38)
236+
### Advanced parameters (12/38)
236237

237238
| Symbol | Bound | zstd-jni |
238239
|---|:---:|:---:|
@@ -246,7 +247,7 @@ sequence-producer hooks vs this library's frame-inspection and typed-error surfa
246247
| `ZSTD_CCtx_loadDictionary` |||
247248
| `ZSTD_CCtx_loadDictionary_advanced` |||
248249
| `ZSTD_CCtx_loadDictionary_byReference` |||
249-
| `ZSTD_CCtx_refCDict` | ||
250+
| `ZSTD_CCtx_refCDict` | ||
250251
| `ZSTD_CCtx_refPrefix` |||
251252
| `ZSTD_CCtx_refPrefix_advanced` |||
252253
| `ZSTD_CCtx_refThreadPool` |||
@@ -261,7 +262,7 @@ sequence-producer hooks vs this library's frame-inspection and typed-error surfa
261262
| `ZSTD_DCtx_loadDictionary` |||
262263
| `ZSTD_DCtx_loadDictionary_advanced` |||
263264
| `ZSTD_DCtx_loadDictionary_byReference` |||
264-
| `ZSTD_DCtx_refDDict` | ||
265+
| `ZSTD_DCtx_refDDict` | ||
265266
| `ZSTD_DCtx_refPrefix` |||
266267
| `ZSTD_DCtx_refPrefix_advanced` |||
267268
| `ZSTD_DCtx_reset` |||

integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdJniInteropTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.github.luben.zstd.ZstdDictDecompress;
55
import io.github.dfa1.zstd.Zstd;
66
import io.github.dfa1.zstd.ZstdCompressCtx;
7+
import io.github.dfa1.zstd.ZstdCompressDict;
78
import io.github.dfa1.zstd.ZstdDecompressCtx;
89
import io.github.dfa1.zstd.ZstdDictionary;
910
import io.github.dfa1.zstd.ZstdInputStream;
@@ -124,6 +125,39 @@ void jniDictCompressJavaDictDecompress() {
124125
assertThat(restored).isEqualTo(record);
125126
}
126127

128+
@Test
129+
void javaLoadedDictWithChecksumJniDictDecompress() {
130+
// A sticky loaded dictionary combined with an advanced parameter
131+
// (checksum) — the COMPRESS2 path — must still produce a frame zstd-jni
132+
// decodes against the same dictionary.
133+
ZstdDictionary dict = trainDict();
134+
byte[] record = record(33);
135+
136+
byte[] frame;
137+
try (ZstdCompressCtx ctx = new ZstdCompressCtx().checksum(true)) {
138+
ctx.loadDictionary(dict);
139+
frame = ctx.compress(record);
140+
}
141+
ZstdDictDecompress jniDict = new ZstdDictDecompress(dict.toByteArray());
142+
assertThat(com.github.luben.zstd.Zstd.decompress(frame, jniDict, record.length)).isEqualTo(record);
143+
}
144+
145+
@Test
146+
void javaReferencedDigestedDictJniDictDecompress() {
147+
// A frame from a context referencing a digested CDict must decode in zstd-jni.
148+
ZstdDictionary dict = trainDict();
149+
byte[] record = record(44);
150+
151+
byte[] frame;
152+
try (ZstdCompressDict cdict = new ZstdCompressDict(dict, Zstd.defaultCompressionLevel());
153+
ZstdCompressCtx ctx = new ZstdCompressCtx()) {
154+
ctx.refDictionary(cdict);
155+
frame = ctx.compress(record);
156+
}
157+
ZstdDictDecompress jniDict = new ZstdDictDecompress(dict.toByteArray());
158+
assertThat(com.github.luben.zstd.Zstd.decompress(frame, jniDict, record.length)).isEqualTo(record);
159+
}
160+
127161
private ZstdDictionary trainDict() {
128162
List<byte[]> samples = new ArrayList<>();
129163
for (int i = 0; i < 3000; i++) {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,10 @@ final class Bindings {
248248
static final MethodHandle COMPRESS_USING_CDICT =
249249
NativeLibrary.lookup("ZSTD_compress_usingCDict",
250250
FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS));
251+
// size_t ZSTD_CCtx_refCDict(ZSTD_CCtx*, const ZSTD_CDict*)
252+
static final MethodHandle CCTX_REF_CDICT =
253+
NativeLibrary.lookup("ZSTD_CCtx_refCDict",
254+
FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS));
251255

252256
// ZSTD_DDict* ZSTD_createDDict(const void* dict, size_t dictSize)
253257
static final MethodHandle CREATE_DDICT =
@@ -260,6 +264,10 @@ final class Bindings {
260264
static final MethodHandle DECOMPRESS_USING_DDICT =
261265
NativeLibrary.lookup("ZSTD_decompress_usingDDict",
262266
FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS));
267+
// size_t ZSTD_DCtx_refDDict(ZSTD_DCtx*, const ZSTD_DDict*)
268+
static final MethodHandle DCTX_REF_DDICT =
269+
NativeLibrary.lookup("ZSTD_DCtx_refDDict",
270+
FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS));
263271

264272
// --- dictionary training (ZDICT, from dictBuilder) ---
265273

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,71 @@ public ZstdCompressCtx reset(ZstdResetDirective directive) {
114114
return this;
115115
}
116116

117+
/// Loads `dict` as the sticky dictionary for this context, so subsequent
118+
/// [#compress(byte[])] / [#compress(MemorySegment, MemorySegment)] calls
119+
/// compress against it **while still honouring the advanced parameters**
120+
/// (checksum, window log, long-distance matching) set on this context — the
121+
/// combination the per-call `compress(src, dict)` overloads cannot give you,
122+
/// since they route through the legacy dictionary path.
123+
///
124+
/// The dictionary is digested using this context's current level and
125+
/// parameters and copied internally, so `dict` may be discarded afterwards.
126+
/// It stays loaded until replaced, cleared with [#loadDictionary(ZstdDictionary)]
127+
/// passing `null`, or dropped by a parameter [#reset(ZstdResetDirective)]. For
128+
/// a dictionary reused across many contexts, digest it once and attach it with
129+
/// [#refDictionary(ZstdCompressDict)] instead.
130+
///
131+
/// @param dict the dictionary to load, or `null` to clear the loaded dictionary
132+
/// @return `this`, for chaining
133+
/// @throws ZstdException if the dictionary cannot be loaded
134+
public ZstdCompressCtx loadDictionary(ZstdDictionary dict) {
135+
if (dict == null) {
136+
return loadDictionary(MemorySegment.NULL, 0L);
137+
}
138+
try (Arena arena = Arena.ofConfined()) {
139+
byte[] raw = dict.raw();
140+
return loadDictionary(Zstd.copyIn(arena, raw), raw.length);
141+
}
142+
}
143+
144+
/// Loads dictionary content straight from a native [MemorySegment], without a
145+
/// heap copy — the zero-copy path when your dictionary is already off-heap
146+
/// (e.g. an mmap slice). Otherwise identical to
147+
/// [#loadDictionary(ZstdDictionary)].
148+
///
149+
/// @param dict native dictionary content; its bytes are copied into the context
150+
/// @return `this`, for chaining
151+
/// @throws ZstdException if the dictionary cannot be loaded
152+
public ZstdCompressCtx loadDictionary(MemorySegment dict) {
153+
NativeCall.requireNative(dict, "dict");
154+
return loadDictionary(dict, dict.byteSize());
155+
}
156+
157+
private ZstdCompressCtx loadDictionary(MemorySegment dict, long size) {
158+
NativeCall.checkReturnValue(() -> (long) Bindings.CCTX_LOAD_DICTIONARY.invokeExact(ptr(), dict, size));
159+
return this;
160+
}
161+
162+
/// Attaches a pre-digested `dict` to this context by reference — no per-call
163+
/// digesting and no copy. Subsequent [#compress(byte[])] /
164+
/// [#compress(MemorySegment, MemorySegment)] calls compress against it while
165+
/// honouring this context's advanced parameters; the compression level comes
166+
/// from the [ZstdCompressDict]. This is the hot path for a pooled context
167+
/// recycled with [#reset(ZstdResetDirective)] between frames.
168+
///
169+
/// The reference is borrowed: `dict` must stay open for as long as this
170+
/// context uses it. The reference is dropped by a parameter
171+
/// [#reset(ZstdResetDirective)] or by passing `null`.
172+
///
173+
/// @param dict the digested dictionary to reference, or `null` to clear it
174+
/// @return `this`, for chaining
175+
/// @throws ZstdException if the dictionary cannot be referenced
176+
public ZstdCompressCtx refDictionary(ZstdCompressDict dict) {
177+
MemorySegment cdict = dict == null ? MemorySegment.NULL : dict.ptr();
178+
NativeCall.checkReturnValue(() -> (long) Bindings.CCTX_REF_CDICT.invokeExact(ptr(), cdict));
179+
return this;
180+
}
181+
117182
/// Compresses `src` into a new zstd frame using this context and its
118183
/// advanced parameters.
119184
///

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,67 @@ public ZstdDecompressCtx reset(ZstdResetDirective directive) {
6969
return this;
7070
}
7171

72+
/// Loads `dict` as the sticky dictionary for this context, so subsequent
73+
/// [#decompress(byte[], int)] / [#decompress(MemorySegment, MemorySegment)]
74+
/// calls decode frames compressed against it while still honouring the
75+
/// advanced parameters (e.g. window-log max) set on this context.
76+
///
77+
/// The dictionary is copied internally, so `dict` may be discarded afterwards.
78+
/// It stays loaded until replaced, cleared with [#loadDictionary(ZstdDictionary)]
79+
/// passing `null`, or dropped by a parameter [#reset(ZstdResetDirective)]. For
80+
/// a dictionary reused across many contexts, digest it once and attach it with
81+
/// [#refDictionary(ZstdDecompressDict)] instead.
82+
///
83+
/// @param dict the dictionary to load, or `null` to clear the loaded dictionary
84+
/// @return `this`, for chaining
85+
/// @throws ZstdException if the dictionary cannot be loaded
86+
public ZstdDecompressCtx loadDictionary(ZstdDictionary dict) {
87+
if (dict == null) {
88+
return loadDictionary(MemorySegment.NULL, 0L);
89+
}
90+
try (Arena arena = Arena.ofConfined()) {
91+
byte[] raw = dict.raw();
92+
return loadDictionary(Zstd.copyIn(arena, raw), raw.length);
93+
}
94+
}
95+
96+
/// Loads dictionary content straight from a native [MemorySegment], without a
97+
/// heap copy — the zero-copy path when your dictionary is already off-heap
98+
/// (e.g. an mmap slice). Otherwise identical to
99+
/// [#loadDictionary(ZstdDictionary)].
100+
///
101+
/// @param dict native dictionary content; its bytes are copied into the context
102+
/// @return `this`, for chaining
103+
/// @throws ZstdException if the dictionary cannot be loaded
104+
public ZstdDecompressCtx loadDictionary(MemorySegment dict) {
105+
NativeCall.requireNative(dict, "dict");
106+
return loadDictionary(dict, dict.byteSize());
107+
}
108+
109+
private ZstdDecompressCtx loadDictionary(MemorySegment dict, long size) {
110+
NativeCall.checkReturnValue(() -> (long) Bindings.DCTX_LOAD_DICTIONARY.invokeExact(ptr(), dict, size));
111+
return this;
112+
}
113+
114+
/// Attaches a pre-digested `dict` to this context by reference — no per-call
115+
/// digesting and no copy. Subsequent [#decompress(byte[], int)] /
116+
/// [#decompress(MemorySegment, MemorySegment)] calls decode against it while
117+
/// honouring this context's advanced parameters. This is the hot path for a
118+
/// pooled context recycled with [#reset(ZstdResetDirective)] between frames.
119+
///
120+
/// The reference is borrowed: `dict` must stay open for as long as this
121+
/// context uses it. The reference is dropped by a parameter
122+
/// [#reset(ZstdResetDirective)] or by passing `null`.
123+
///
124+
/// @param dict the digested dictionary to reference, or `null` to clear it
125+
/// @return `this`, for chaining
126+
/// @throws ZstdException if the dictionary cannot be referenced
127+
public ZstdDecompressCtx refDictionary(ZstdDecompressDict dict) {
128+
MemorySegment ddict = dict == null ? MemorySegment.NULL : dict.ptr();
129+
NativeCall.checkReturnValue(() -> (long) Bindings.DCTX_REF_DDICT.invokeExact(ptr(), ddict));
130+
return this;
131+
}
132+
72133
/// Decompresses a frame into a buffer of at most `maxSize` bytes.
73134
///
74135
/// @param compressed a complete zstd frame

0 commit comments

Comments
 (0)