Skip to content

Closes #78 Dataset JSON Compressed (DSJC) support - #99

Merged
mstackhouse merged 7 commits into
yyjson-nativefrom
dsjc
Sep 4, 2026
Merged

mstackhouse merged 7 commits into
yyjson-nativefrom
dsjc

Conversation

@mstackhouse

Copy link
Copy Markdown
Member

Closes #78. Targets yyjson-native (#98), not main — it builds directly on the native NDJSON reader and writer added there.

What DSJC is

The specification is deliberately minimal: a DSJC file is the Dataset NDJSON content of a dataset as a zLib stream, with no header, signature or wrapper of its own. .dsjc extension, MIME type application/vnd.cdisc.dataset-json.compressed, level 9 and a 15-bit window recommended.

So it sits directly on the NDJSON work already on #98.

Three functions

write_dataset_dsjc(x, file, level = 9) Returns a raw vector when no file is given, since the format is binary
read_dataset_dsjc(file) Path, URL, or raw vector
validate_dataset_dsjc(x) Decompresses and validates the content as Dataset NDJSON

Implementation note worth a reviewer's attention

memCompress(type = "gzip") does emit a conformant zLib stream despite the name — but at a fixed level 6, and the specification recommends 9. So this uses zlib directly (PKG_LIBS = -lz; R requires zlib on every platform it builds on). That also allows rows to be deflated as they are serialized, so writing a large dataset never holds its uncompressed NDJSON in memory — which is what the specification's memory-efficiency section asks for.

If the extra link is unwelcome, memCompress would work at level 6 and remove ~120 lines of C, at the cost of buffering the whole payload.

Verified

  • Header is 0x78 0xDA — zLib, level 9
  • Decompressed content is byte-identical to what write_dataset_ndjson() writes to a file
  • Interoperability both directions: streams written here decompress with R's own zlib at every level 0–9, and a stream compressed elsewhere reads back correctly
  • Corrupt and truncated input report rather than crash (invalid distances set, compressed stream ended before it was complete)
  • Numbers survive the compressed path bit-exactly, using the same no-tolerance identical() check as the other formats
  • URL support is binary-safe — read_from_url() reads lines, which would corrupt a stream, so there is a separate read_raw_from_url(). Tested over file://, which exercises the same path as http(s) without needing a network
26MB / 300k rows
level 1: write 0.21s  read 0.06s  11.9 MB (45.8%)
level 6: write 0.86s  read 0.06s  11.0 MB (42.1%)
level 9: write 1.05s  read 0.06s  10.9 MB (41.9%)

Level 9 costs 5x the write time for 4% smaller output. Kept as the default because the specification recommends it, with the trade-off documented.

Also here

inst/extdata now carries all three representations of the same dataset — dm.json, dm.ndjson, dm.dsjc — generated from dm.json in data-raw/data.R so they cannot drift. A test asserts they read back to the same dataset, including that dm.dsjc really is dm.ndjson compressed. The read_dataset_ndjson() and validate_dataset_ndjson() examples now read a real file rather than writing one first.

The Getting Started vignette gains a "Compressed files" section.

32 tests. R CMD check --as-cran: 0 errors, 0 warnings, 0 notes.


  • Place Closes #<insert_issue_number> into the beginning of your Pull Request Title
  • Code is formatted according to the tidyverse style guide
  • Updated relevant unit tests or have written new unit tests
  • If you removed/replaced any function and/or function parameters, did you fully follow the deprecation guidance? — no removals here
  • Update to all relevant roxygen headers and examples
  • Run devtools::document()
  • Address any updates needed for vignettes and/or templates
  • Update NEWS.md — deferred to the release cleanup pass
  • Build pkgdown site and check new functions appear on the Reference page
  • Address or fix all lintr warnings and errors
  • Run R CMD check locally and address all errors and warnings
  • Link the issue in the Development Section on the right hand side.
  • Address all merge conflicts and resolve appropriately
  • Pat yourself on the back for a job well done!

Comment thread tests/testthat/test-dsjc.R
Comment thread tests/testthat/test-dsjc.R Outdated
Comment thread tests/testthat/test-dsjc.R
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.66197% with 9 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (yyjson-native@a44cb5c). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/write.c 90.56% 5 Missing ⚠️
R/dsjc.R 92.68% 3 Missing ⚠️
R/utils.R 87.50% 1 Missing ⚠️
Additional details and impacted files
@@               Coverage Diff                @@
##             yyjson-native      #99   +/-   ##
================================================
  Coverage                 ?   97.76%           
================================================
  Files                    ?       14           
  Lines                    ?     1031           
  Branches                 ?        0           
================================================
  Hits                     ?     1008           
  Misses                   ?       23           
  Partials                 ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Closes #78.

DSJC is a zLib stream of Dataset NDJSON content with no wrapper of its
own, so it sits directly on the NDJSON reader and writer already here.

- write_dataset_dsjc() deflates rows as they are serialized, so a large
  dataset never holds its uncompressed NDJSON in memory. Returns a raw
  vector when no file is given, since the format is binary.
- read_dataset_dsjc() inflates and hands the bytes to the same NDJSON
  core, so the object returned matches the uncompressed readers exactly.
- validate_dataset_dsjc() decompresses and validates the content as
  Dataset NDJSON.

Implemented against zlib rather than memCompress(): R's "gzip" type does
produce a conformant zLib stream, but at a fixed level 6, and the
specification recommends 9. PKG_LIBS gains -lz; R itself requires zlib on
every platform it builds on.

Interoperability is verified both ways - streams written here decompress
with R's own zlib at every level, and a stream compressed elsewhere reads
back correctly. Corrupt and truncated input report rather than crash.

26MB / 300k rows: 10.9 MB at level 9 (41.9%), write 1.05s, read 0.06s.
Level 1 writes in a fifth of the time for a file 4% larger.
read_from_url() reads lines, which corrupts a compressed stream, so
read_raw_from_url() pulls the bytes through unchanged in chunks. Both
read_dataset_dsjc() and validate_dataset_dsjc() accept a URL now.
Tested over file://, which exercises the same binary path as http(s)
without needing a network.

inst/extdata gains dm.dsjc, generated from the dm.json already there so
the two stay in step; data-raw/data.R regenerates it. datasetjson_example()
picks it up with no change, and the roxygen examples now read a real file
rather than writing one first.
The example set covered JSON and the compressed form but not the NDJSON
in between, which is the format the compressed one is built from. All
three are now generated from dm.json in data-raw, and a test asserts they
read back to the same dataset - including that dm.dsjc really is
dm.ndjson compressed, once the creation timestamp each file carries is
normalised.

read_dataset_ndjson() and validate_dataset_ndjson() examples now read a
real file rather than writing one first.
Covers what DSJC is, the three functions, the size difference using the
shipped example files, the compression level trade-off, and the raw
vector return. Sits after the NDJSON section, since DSJC is that format
compressed.
The reader inflated into a malloc buffer and then copied the whole thing
into R_alloc memory, the copy existing only so a later parse error could
not leak the allocation. At scale that copy cost as much resident memory
as the decompressed content itself: reading a 419MB file peaked at
2437MB, which is more than reading the 999MB uncompressed NDJSON it came
from - the wrong way round for a format whose point is being smaller.

Inflating into a PROTECTed raw vector, grown with Rf_xlengthgets, needs
no copy and cannot leak if ndjson_core() raises.

1GB dataset: read peak 2437MB -> 2188MB. What remains above the NDJSON
reader is the compressed input buffer, which is held whole while parsing;
streaming the file through inflate would remove that too.
- Note why memDecompress(type = "gzip") inflates a zLib stream: R's
  "gzip" covers both RFC 1952 and RFC 1950, and DSJC is the latter.
- Say which zLib branch each malformed-input case targets, and cover
  truncation both ways - a heavy prefix cut and the loss of just the
  trailing checksum. Both reach the same branch: any prefix of a valid
  deflate stream is itself a valid partial stream, so inflate() asks for
  more input rather than reporting corruption. Corruption is covered
  separately by overwriting bytes mid-stream.
- Add a float_as_decimals test for the compressed writer. It shares
  prepare_dataset_for_write() with the other two, but passes as_decimal
  to its own C entry point, so it needs its own coverage. Checks the
  metadata flips to decimal/decimal, the numbers are quoted inside the
  compressed content, and values still round-trip bit-exactly.
Carries over the fix from #98's review; it lived in the merge commit the
rebase onto the rewritten base dropped.
@mstackhouse
mstackhouse merged commit 2d292bb into main Sep 4, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Compressed datasetjson

2 participants