Skip to content

Commit 53aced5

Browse files
dfa1claude
andcommitted
feat: frame inspection — ZstdFrame
Inspect zstd frames without decompressing: parse concatenated frames, size buffers, route by dictionary id. byte[] and zero-copy MemorySegment forms. - ZstdFrame.isZstdFrame / compressedSize (first frame length) / decompressedBound (multi-frame upper bound) / dictId (0 if none) - bind ZSTD_isFrame, ZSTD_findFrameCompressedSize, ZSTD_decompressBound, ZSTD_getDictID_fromFrame; compatibility.md now 43/186 Tests: recognises frames vs garbage; compressedSize locates the first-frame boundary in concatenated frames; decompressedBound >= original; dictId is 0 without a dictionary and matches the dictionary used. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fdb6a44 commit 53aced5

4 files changed

Lines changed: 271 additions & 8 deletions

File tree

docs/compatibility.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ and semantics follow the [official manual](https://facebook.github.io/zstd/doc/a
55

66
- zstd version: **1.6.0** (vendored `third_party/zstd`)
77
- Public symbols exported by `libzstd`: **186**
8-
- Bound so far: **39** (~21%)
8+
- Bound so far: **43** (~23%)
99

1010
"Bound" means the symbol has a `MethodHandle` in `Bindings` and is reachable
1111
through the public Java API. The rest are reachable from native code but not yet
@@ -28,7 +28,7 @@ low-level variants that an idiomatic Java API does not need.
2828
| Streaming — compress | 3 / 22 | `ZstdOutputStream` (compressStream2 + buffer sizes) |
2929
| Streaming — decompress | 3 / 15 | `ZstdInputStream` (decompressStream + buffer sizes) |
3030
| Advanced parameters | 4 / 38 | `CCtx_setParameter` + `compress2` (level, checksum, LDM, windowLog) and `C/DCtx_loadDictionary` (dictionary streams); MT/getBounds not bound |
31-
| Frame inspection | 1 / 13 | only `getFrameContentSize`; header/skippable/dictID-from-frame not bound |
31+
| Frame inspection | 5 / 13 | `ZstdFrame`: isFrame, compressedSize, decompressedBound, dictId, + getFrameContentSize; getFrameHeader/skippable not bound |
3232
| Memory sizing | 0 / 14 | `sizeof_*` / `estimate*` accounting not bound |
3333
| Low-level block | 0 / 12 | expert block/continue API not bound |
3434
| Sequences | 0 / 5 | sequence producer API not bound |
@@ -56,12 +56,13 @@ low-level variants that an idiomatic Java API does not need.
5656
| `ZSTD_decompressStream`, `ZSTD_DStreamInSize`, `ZSTD_DStreamOutSize` | `ZstdInputStream` |
5757
| `ZSTD_compress2`, `ZSTD_CCtx_setParameter` | `ZstdCompressCtx.parameter` / `checksum` / `longDistanceMatching` / `windowLog` (+ `ZstdCompressParameter`) |
5858
| `ZSTD_CCtx_loadDictionary`, `ZSTD_DCtx_loadDictionary` | `ZstdOutputStream` / `ZstdInputStream` dictionary constructors |
59+
| `ZSTD_isFrame`, `ZSTD_findFrameCompressedSize`, `ZSTD_decompressBound`, `ZSTD_getDictID_fromFrame` | `ZstdFrame` |
5960

6061
## Roadmap (priority order)
6162

6263
1. ~~**Streaming**~~ — done: `ZstdOutputStream` / `ZstdInputStream` (`compressStream2`, `decompressStream`, bounded buffers, dictionary constructors). Remaining: `MemorySegment`-buffer driver, `pledgedSrcSize`.
6364
2. **Advanced parameters** — done for compression: `CCtx_setParameter` + `compress2` via `ZstdCompressCtx` (`checksum`, `longDistanceMatching`, `windowLog`, generic `parameter`). Remaining: `cParam_getBounds`, `pledgedSrcSize`, and `nbWorkers` (needs a multithreaded native build).
64-
3. **Frame inspection**`getFrameHeader`, `isFrame`, `findFrameCompressedSize`, `decompressBound`, `getDictID_from*`, skippable frames.
65+
3. **Frame inspection**done: `ZstdFrame` (`isFrame`, `compressedSize`, `decompressedBound`, `dictId`). Remaining: `getFrameHeader` (struct out-param), skippable frames, `getDictID_fromDict/CDict/DDict`.
6566
4. **Better dictionaries**`ZDICT_optimizeTrainFromBuffer_cover` / `_fastCover`, `finalizeDictionary`.
6667
5. **Typed errors**`ZSTD_getErrorCode` / `ZSTD_getErrorString`.
6768

@@ -136,7 +137,7 @@ low-level variants that an idiomatic Java API does not need.
136137
| `ZSTD_getDictID_fromCDict` ||
137138
| `ZSTD_getDictID_fromDDict` ||
138139
| `ZSTD_getDictID_fromDict` ||
139-
| `ZSTD_getDictID_fromFrame` | |
140+
| `ZSTD_getDictID_fromFrame` | |
140141

141142
### Dictionary training, ZDICT (4/12)
142143

@@ -245,20 +246,20 @@ low-level variants that an idiomatic Java API does not need.
245246
| `ZSTD_estimateCCtxSize_usingCCtxParams` ||
246247
| `ZSTD_freeCCtxParams` ||
247248

248-
### Frame inspection (1/13)
249+
### Frame inspection (5/13)
249250

250251
| Symbol | Bound |
251252
|---|:---:|
252253
| `ZSTD_getFrameContentSize` ||
253-
| `ZSTD_decompressBound` | |
254+
| `ZSTD_decompressBound` | |
254255
| `ZSTD_findDecompressedSize` ||
255-
| `ZSTD_findFrameCompressedSize` | |
256+
| `ZSTD_findFrameCompressedSize` | |
256257
| `ZSTD_frameHeaderSize` ||
257258
| `ZSTD_getDecompressedSize` ||
258259
| `ZSTD_getFrameHeader` ||
259260
| `ZSTD_getFrameHeader_advanced` ||
260261
| `ZSTD_getFrameProgression` ||
261-
| `ZSTD_isFrame` | |
262+
| `ZSTD_isFrame` | |
262263
| `ZSTD_isSkippableFrame` ||
263264
| `ZSTD_readSkippableFrame` ||
264265
| `ZSTD_writeSkippableFrame` ||

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@ final class Bindings {
3636
NativeLibrary.lookup("ZSTD_getFrameContentSize",
3737
FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG));
3838

39+
// unsigned ZSTD_isFrame(const void* buffer, size_t size)
40+
static final MethodHandle IS_FRAME =
41+
NativeLibrary.lookup("ZSTD_isFrame", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG));
42+
// size_t ZSTD_findFrameCompressedSize(const void* src, size_t srcSize)
43+
static final MethodHandle FIND_FRAME_COMPRESSED_SIZE =
44+
NativeLibrary.lookup("ZSTD_findFrameCompressedSize",
45+
FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG));
46+
// unsigned long long ZSTD_decompressBound(const void* src, size_t srcSize)
47+
static final MethodHandle DECOMPRESS_BOUND =
48+
NativeLibrary.lookup("ZSTD_decompressBound",
49+
FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG));
50+
// unsigned ZSTD_getDictID_fromFrame(const void* src, size_t srcSize)
51+
static final MethodHandle GET_DICT_ID_FROM_FRAME =
52+
NativeLibrary.lookup("ZSTD_getDictID_fromFrame",
53+
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG));
54+
3955
// unsigned ZSTD_isError(size_t code)
4056
static final MethodHandle IS_ERROR =
4157
NativeLibrary.lookup("ZSTD_isError", FunctionDescriptor.of(JAVA_INT, JAVA_LONG));
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package io.github.dfa1.zstdffm;
2+
3+
import java.lang.foreign.Arena;
4+
import java.lang.foreign.MemorySegment;
5+
6+
/// Inspection of zstd frames without decompressing them — useful for parsing a
7+
/// stream of concatenated frames, sizing buffers, or routing by dictionary id.
8+
///
9+
/// Each method has a `byte[]` form and a zero-copy {@link MemorySegment} form for
10+
/// data already off-heap (e.g. an mmap slice); see `docs/zero-copy.md`.
11+
public final class ZstdFrame {
12+
13+
/// Sentinel returned by `ZSTD_decompressBound` when the input is not valid.
14+
private static final long CONTENTSIZE_ERROR = -2L;
15+
16+
/// Tests whether `data` begins with a valid zstd frame (standard or skippable).
17+
///
18+
/// @param data the bytes to inspect
19+
/// @return `true` if `data` starts with a zstd frame
20+
public static boolean isZstdFrame(byte[] data) {
21+
try (Arena arena = Arena.ofConfined()) {
22+
return isZstdFrame(Zstd.copyIn(arena, data), data.length);
23+
}
24+
}
25+
26+
/// Tests whether the native `data` begins with a valid zstd frame.
27+
///
28+
/// @param data the native bytes to inspect
29+
/// @return `true` if `data` starts with a zstd frame
30+
public static boolean isZstdFrame(MemorySegment data) {
31+
return isZstdFrame(data, data.byteSize());
32+
}
33+
34+
/// Compressed size of the first frame in `data`, including its header and
35+
/// epilogue — i.e. where the next frame would begin.
36+
///
37+
/// @param data one or more concatenated zstd frames
38+
/// @return the byte length of the first frame
39+
/// @throws ZstdException if the input is not a valid frame
40+
public static long compressedSize(byte[] data) {
41+
try (Arena arena = Arena.ofConfined()) {
42+
return compressedSize(Zstd.copyIn(arena, data), data.length);
43+
}
44+
}
45+
46+
/// Compressed size of the first frame in native `data`.
47+
///
48+
/// @param data one or more concatenated zstd frames
49+
/// @return the byte length of the first frame
50+
/// @throws ZstdException if the input is not a valid frame
51+
public static long compressedSize(MemorySegment data) {
52+
return compressedSize(data, data.byteSize());
53+
}
54+
55+
/// Upper bound on the total decompressed size of all frames in `data`,
56+
/// large enough to never overflow during decompression.
57+
///
58+
/// @param data one or more concatenated zstd frames
59+
/// @return an upper bound on the combined decompressed size
60+
/// @throws ZstdException if the input is not valid zstd data
61+
public static long decompressedBound(byte[] data) {
62+
try (Arena arena = Arena.ofConfined()) {
63+
return decompressedBound(Zstd.copyIn(arena, data), data.length);
64+
}
65+
}
66+
67+
/// Upper bound on the total decompressed size of all frames in native `data`.
68+
///
69+
/// @param data one or more concatenated zstd frames
70+
/// @return an upper bound on the combined decompressed size
71+
/// @throws ZstdException if the input is not valid zstd data
72+
public static long decompressedBound(MemorySegment data) {
73+
return decompressedBound(data, data.byteSize());
74+
}
75+
76+
/// Dictionary id recorded in the first frame's header.
77+
///
78+
/// @param data a zstd frame
79+
/// @return the dictionary id, or `0` if the frame uses no dictionary or does
80+
/// not record one
81+
public static int dictId(byte[] data) {
82+
try (Arena arena = Arena.ofConfined()) {
83+
return dictId(Zstd.copyIn(arena, data), data.length);
84+
}
85+
}
86+
87+
/// Dictionary id recorded in native frame `data`.
88+
///
89+
/// @param data a zstd frame
90+
/// @return the dictionary id, or `0` if none
91+
public static int dictId(MemorySegment data) {
92+
return dictId(data, data.byteSize());
93+
}
94+
95+
private static boolean isZstdFrame(MemorySegment data, long size) {
96+
try {
97+
return ((int) Bindings.IS_FRAME.invokeExact(data, size)) != 0;
98+
} catch (Throwable t) {
99+
throw rethrow(t);
100+
}
101+
}
102+
103+
private static long compressedSize(MemorySegment data, long size) {
104+
return Zstd.call(() -> (long) Bindings.FIND_FRAME_COMPRESSED_SIZE.invokeExact(data, size));
105+
}
106+
107+
private static long decompressedBound(MemorySegment data, long size) {
108+
long bound;
109+
try {
110+
bound = (long) Bindings.DECOMPRESS_BOUND.invokeExact(data, size);
111+
} catch (Throwable t) {
112+
throw rethrow(t);
113+
}
114+
if (bound == CONTENTSIZE_ERROR) {
115+
throw new ZstdException("not valid zstd data");
116+
}
117+
return bound;
118+
}
119+
120+
private static int dictId(MemorySegment data, long size) {
121+
try {
122+
return (int) Bindings.GET_DICT_ID_FROM_FRAME.invokeExact(data, size);
123+
} catch (Throwable t) {
124+
throw rethrow(t);
125+
}
126+
}
127+
128+
@SuppressWarnings("unchecked")
129+
private static <E extends Throwable> RuntimeException rethrow(Throwable t) throws E {
130+
throw (E) t;
131+
}
132+
133+
private ZstdFrame() {
134+
// no instances
135+
}
136+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package io.github.dfa1.zstdffm;
2+
3+
import org.junit.jupiter.api.Nested;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.io.ByteArrayOutputStream;
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
13+
14+
class ZstdFrameTest {
15+
16+
private static final byte[] PAYLOAD =
17+
"frame inspection payload ".repeat(500).getBytes(StandardCharsets.UTF_8);
18+
19+
@Nested
20+
class IsFrame {
21+
22+
@Test
23+
void recognisesAZstdFrame() {
24+
assertThat(ZstdFrame.isZstdFrame(Zstd.compress(PAYLOAD))).isTrue();
25+
}
26+
27+
@Test
28+
void rejectsGarbage() {
29+
assertThat(ZstdFrame.isZstdFrame("not a frame".getBytes(StandardCharsets.UTF_8))).isFalse();
30+
}
31+
}
32+
33+
@Nested
34+
class CompressedSize {
35+
36+
@Test
37+
void equalsLengthForSingleFrame() {
38+
// Given one frame
39+
byte[] frame = Zstd.compress(PAYLOAD);
40+
41+
// Then its compressed size is the whole buffer
42+
assertThat(ZstdFrame.compressedSize(frame)).isEqualTo(frame.length);
43+
}
44+
45+
@Test
46+
void locatesFirstFrameBoundaryWhenConcatenated() throws Exception {
47+
// Given two frames back to back
48+
byte[] first = Zstd.compress(PAYLOAD);
49+
byte[] second = Zstd.compress("second".getBytes(StandardCharsets.UTF_8));
50+
ByteArrayOutputStream both = new ByteArrayOutputStream();
51+
both.write(first);
52+
both.write(second);
53+
54+
// Then compressedSize reports where the second frame begins
55+
assertThat(ZstdFrame.compressedSize(both.toByteArray())).isEqualTo(first.length);
56+
}
57+
58+
@Test
59+
void rejectsGarbage() {
60+
assertThatThrownBy(() -> ZstdFrame.compressedSize("xxxx".getBytes(StandardCharsets.UTF_8)))
61+
.isInstanceOf(ZstdException.class);
62+
}
63+
}
64+
65+
@Nested
66+
class DecompressedBound {
67+
68+
@Test
69+
void boundsTheDecompressedSize() {
70+
byte[] frame = Zstd.compress(PAYLOAD);
71+
assertThat(ZstdFrame.decompressedBound(frame)).isGreaterThanOrEqualTo(PAYLOAD.length);
72+
}
73+
74+
@Test
75+
void rejectsGarbage() {
76+
assertThatThrownBy(() -> ZstdFrame.decompressedBound("xx".getBytes(StandardCharsets.UTF_8)))
77+
.isInstanceOf(ZstdException.class);
78+
}
79+
}
80+
81+
@Nested
82+
class DictId {
83+
84+
@Test
85+
void isZeroForDictionarylessFrame() {
86+
assertThat(ZstdFrame.dictId(Zstd.compress(PAYLOAD))).isZero();
87+
}
88+
89+
@Test
90+
void matchesTheDictionaryUsed() {
91+
// Given a frame compressed with a dictionary
92+
ZstdDictionary dict = trainDict();
93+
byte[] frame;
94+
try (ZstdCompressCtx ctx = new ZstdCompressCtx()) {
95+
frame = ctx.compress(PAYLOAD, dict);
96+
}
97+
98+
// Then the frame's dictionary id matches the dictionary
99+
assertThat(ZstdFrame.dictId(frame)).isEqualTo(dict.id());
100+
}
101+
102+
private ZstdDictionary trainDict() {
103+
List<byte[]> samples = new ArrayList<>();
104+
for (int i = 0; i < 3000; i++) {
105+
samples.add(("{\"id\":" + i + ",\"k\":\"v" + (i % 30) + "\"}").getBytes(StandardCharsets.UTF_8));
106+
}
107+
return ZstdDictionary.train(samples, 8 * 1024);
108+
}
109+
}
110+
}

0 commit comments

Comments
 (0)