0018 — Experimental solid compression for Moodle core bundles (tar.zst / tar.br)¶
Status¶
Accepted (2026-07-05). This ADR records the format evaluation; ADR 0019 records the shipped streaming design.
The experiment concluded in favour of tar.zst, and ADR 0019 made it the sole
core-bundle format. The ZIP path and its PHP ZipArchive extractor were removed
entirely from the core boot: there is no ?bundle-format= flag, no
bundleAlternatives manifest field, and no ZIP fallback. The build produces a
single tar.zst per branch; the runtime streams it and fails loud on a
file-count parity mismatch. (ZIP is still used, unchanged, for untrusted
plugin/blueprint archives via lib/moodle-loader.js, and as a build-time
intermediate — see Implementation Notes.)
Context and Problem¶
On every cold boot the playground downloads a moodle-core-*.zip (deflate), reassembles
.part-NNN slices when the zip exceeds Cloudflare Pages' 25 MiB/file cap, verifies SHA-256,
writes the whole zip into MEMFS, and extracts it with PHP ZipArchive::extractTo(). Native
extraction was chosen deliberately: the JS paths (fflate unzipSync, per-entry
@php-wasm/stream-compression decodeZip) were too slow / memory-heavy for the ~23k-file
tree and pushed boot past the readiness gate (see lib/moodle-loader.js header comments and
ADR 0011).
The ZIP is compressed per file with a 32 KiB window, so it cannot exploit the massive
cross-file redundancy in a Moodle tree (thousands of near-identical language files, YUI
builds, vendored SDKs). A solid archive (one compression stream over a tar of the whole
tree) with a modern codec (zstd / brotli) should shrink the download materially. The open
question this experiment answers: is the download/boot win large enough to justify the added
extraction complexity, and does it survive real browser and memory constraints?
Explicitly out of scope as a primary candidate: zip.zst / zip.br (compressing an
already-compressed zip). Measured here only as secondary data — they gain ~12 % (below).
Options Considered¶
- Keep ZIP (baseline). Simple, native, proven; largest download.
zip.zst/zip.br— recompress the zip. Measured −12 % only; rejected as primary.tar.zst— solid tar + zstd. Best size/speed balance; needs a zstd decoder in the browser.tar.br— solid tar + brotli. Marginally smallest, but slowest to build and needs a brotli WASM decoder that no browser exposes natively.tar.gz— solid tar + gzip. The only solid format decodable natively in every browser (DecompressionStream("gzip")); a useful cross-browser control and a smaller (−27 %) win.
Extraction mechanism¶
The PHP WASM binary has zip and phar but not zstd/brotli, so decompression must
happen in JS; PHP then extracts the resulting .tar. Two approaches:
- (a) decode → full
.tarin MEMFS →PharData::extractTo(). Reuses proven native extraction (fast at any file count), minimal new code. Cost: the uncompressed tar (~250 MB, larger than the 74 MB zip) sits in the worker heap and MEMFS — a peak-memory increase. The ADR 0018 prototype measured (a). - (b) streaming JS tar parse writing each entry to MEMFS off the decode stream. Bounded
memory, but ~23k JS→WASM writes (the historically slow dimension). This is the path that
shipped (ADR 0019): it resolved (a)'s peak-memory blocker, so the runtime never
materializes the full tar and needs neither
PharDatanorphar/zip.
A subtle but decisive finding shaped the tar format: PHP PharData ignores PAX path
extended headers and writes long-named files under their truncated 100-byte name, colliding
and dropping 32 of Moodle's 23,324 files (measured). Switching the writer to the USTAR
prefix/name split + GNU ././@LongLink (both read correctly by PharData, bsdtar and GNU
tar) restores full 23,324-file parity. See scripts/lib/tar-ustar.mjs.
Browser decoder (the hard part)¶
Native DecompressionStream support, feature-detected at runtime (never assumed):
| Codec | Chrome 150 | Firefox | Notes |
|---|---|---|---|
| gzip / deflate | ✅ | ✅ | universal (spec-standard) |
| zstd | ❌ | ❌ | absent even in Chrome 150 |
| brotli | ❌ | ❌ | not exposed as a DecompressionStream algorithm anywhere |
So tar.zst and tar.br need a bundled decoder on all browsers, not just Firefox. We
evaluated candidates against the real 36 MB → 250 MB windowLog-27 frame:
- fzstd (pure JS, 8 KB) — REJECTED: it silently corrupts this frame (right size, wrong bytes from ~101 MB on), reproduced on Node and Bun, on two independent frames. Small frames pass, which is exactly the "worked on a small test" trap. Do not use.
- zstddec (libzstd → WASM, wasm embedded as base64, ~76 KB, MIT, 856k dl/wk) — CHOSEN:
byte-exact on the frame, ~230 ms decode, self-contained (no separate
.wasmfetch / CSP issue), has a streaming API to cap memory. - brotli-dec-wasm (208 KB) / brotli-wasm (1.06 MB) — correct but heavier; brotli's
frame is barely smaller than zstd's.
tar.brwas therefore measured for size only (no brotli decoder wired) and not carried forward; onlytar.zstshipped.
Build-runtime evaluation (Node 20 vs 24 LTS vs 26 vs Bun)¶
Compressed output bytes are runtime-independent for a fixed algo + level + codec-library version; the runtime axis is build speed, codec API availability, and CI risk:
- Node 20 (the CI pin before this work):
node:zlibhas no zstd (added in 22.15 / 23.8), which is why buildingtar.zstwith the native encoder needed a newer Node. Adoptingtar.zstmoved the CI pin to Node 24 LTS (.github/workflows/ci.yml);scripts/build-tar-zst-from-zip.mjsusesnode:zlibzstd directly and hard-errors on Node < 22.15. - Node 24 LTS: native zstd + brotli; the chosen (and now shipped) build target.
- Node 26: native zstd + brotli (used for all local measurements here); a non-LTS Current runtime — more risk to pin in production CI.
- Bun 1.3: native zstd via
node:zlib; fast, but a larger toolchain bet.
Build times (Node 26, 250 MB tar): zstd L19 ≈ 47 s, zstd L22 ≈ 75 s, brotli q11 ≈ 207 s (≈4× zstd for ~0.2 % less), gzip L9 ≈ 9 s. Brotli's build cost is a strike against it.
These build-speed and Node-20-no-zstd measurements were taken on the
experiment/core-bundle-solid-compression branch. The exploratory build/benchmark
matrix and its scripts were experiment-only and did not merge; the shipped build
simply runs scripts/build-tar-zst-from-zip.mjs on Node 24 in the normal CI build job.
Decision¶
Accepted: adopt tar.zst as the sole core-bundle format (via the ADR 0019 streaming
extractor). The multi-format exploration here (tar.zst / tar.br / tar.gz, secondary
zip.zst / zip.br) settled the format question decisively in favour of tar.zst: best
size/decoder trade, and zstddec exposes a true streaming generator that makes
bounded-memory extraction possible. Having settled it, the shipped implementation keeps
only tar.zst — there is no ?bundle-format= flag, no bundleAlternatives manifest
array, and no ZIP fallback. The build emits one tar.zst per branch and the runtime streams
it; a file-count parity mismatch fails loud rather than silently falling back. The
tar.br and tar.gz variants and the full-buffer (PharData) extraction path were
measured (below) but not shipped.
Results¶
Branch MOODLE_500_STABLE. Source: 23,324 files, 244.8 MiB uncompressed; canonical tar
262,742,016 B. Storage measured on Node 26.4.0 (zstd lib 1.5.7, brotli 1.2.0);
runtime measured on Chrome 150 (local, unthrottled). Modeled download uses
bytes / 200 KB·s⁻¹ (Fast-3G).
| Format | Build runtime | Build time | Bundle size | Chunks @24 MiB | Δ vs ZIP | Model DL Fast-3G | Decode/extract | Total cold boot | Peak JS heap (est.) | Peak WASM/MEMFS (est.) | Chrome | Firefox | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| zip (baseline) | prebuilt | — | 73,761,528 (70.3 MiB) | 3 | 0 % | ~369 s | 0 ms decode | ~3.4 s | ~74 MB zip | ~74 MB zip + tree | ✅ | ✅ | former default — now removed from core boot |
| tar (uncompressed) | Node 26 | 0.07 s | 262,742,016 | 11 | +256 % | — | — | — | — | — | — | — | intermediate only |
| tar.zst (L19+ldm+wlog27) | Node 26 | 47 s | 36,293,491 (34.6 MiB) | 2 | −50.8 % | ~181 s | 285 ms (zstddec) | ~3.8 s | ~250 MB tar + ~640 MB decoder¹ | ~250 MB tar + tree | ✅ | ⚠️² | needs zstddec (no native); decoder verified on FF |
| tar.zst (L22+ldm+wlog27) | Node 26 | 75 s | 35,792,501 (34.1 MiB) | 2 | −51.5 % | ~179 s | — | — | — | — | — | — | +0.7 % for +60 % build time |
| tar.br (q11) | Node 26 | 207 s | 35,641,029 (34.0 MiB) | 2 | −51.7 % | ~178 s | — | — | — | — | storage-only | storage-only | no browser brotli decoder wired |
| tar.gz (L9) | Node 26 | 9 s | 54,020,549 (51.5 MiB) | 3 | −26.8 % | ~270 s | 335 ms (native gzip) | ~4.2 s | ~250 MB tar | ~250 MB tar + tree | ✅ | ✅ (~10 s) | native decode everywhere |
| zip.zst (L19) | Node 26 | ~40 s | 64,902,390 | 3 | −12.0 % | — | — | — | — | — | — | — | secondary; recompresses a zip |
| zip.br (q11) | Node 26 | ~60 s | 64,606,506 | 3 | −12.4 % | — | — | — | — | — | — | — | secondary |
¹ zstddec one-shot peak RSS on the 250 MB output (measured in the decoder eval); its streaming
API roughly halves this (~366 MB) and is the recommended production path (approach b).
² Firefox: the zstddec decoder is proven cross-browser — a standalone Playwright/Firefox run
decoded the real 36 MB frame byte-exact in ~1.1 s. But the full tar.zst boot did not
complete within the harness's 120 s budget on Firefox, consistent with Firefox's known
nested-iframe boot flakiness (the admin-flows e2e is skipped in CI for the same reason;
Firefox boots are ~3× slower: zip ~13 s, tar.gz ~10 s here). So the codec works on Firefox;
confirming the end-to-end boot there needs a local retry, not a code change.
Verified end-to-end in Chrome 150 (each booted to the Moodle Dashboard). These formats
were compared on the experiment branch via a since-removed ?bundle-format= selector: zip
(decodeMs 0), tar.gz (native gzip, decodeMs ~335), tar.zst (zstddec, decodeMs ~285).
On Firefox, zip and tar.gz booted (~13 s / ~10 s); tar.zst decoding was verified in
isolation (byte-exact, ~1.1 s) while the full boot flaked on Firefox's nested-iframe path.
The ADR 0018 PharData probe extracted 23,324 files with full parity vs the ZIP baseline;
the shipped streaming parser (ADR 0019) keeps that same parity.
Acceptance criteria — assessment¶
| Criterion | Verdict |
|---|---|
| Storage reduction ≥ 15 % | Met — tar.zst −50.8 %, tar.br −51.7 %, tar.gz −26.8 % |
| Cold boot ≥ 10 % faster on a throttled profile | Met (modeled) — download −51 % dominates on slow networks; not visible on an instant local network |
| No peak-memory regression | NOT met by approach (a) — the uncompressed ~250 MB tar + ~640 MB decoder RSS exceed the zip path; needs streaming (approach b) |
| Chrome and Firefox both work | Chrome met (zip/tar.gz/tar.zst all boot); Firefox: zip + tar.gz boot, and the zstddec decoder is byte-exact on Firefox (~1.1 s), but the tar.zst end-to-end boot flaked on Firefox's known nested-iframe path — needs a local retry to fully confirm |
| No loss of checksum verification | Met — whole-artifact + per-part SHA-256 over the compressed artifact, reusing verifyBundle |
| No deploy regression with chunked assets | Met — chunk-bundles.mjs splits the oversized tar.zst into .part-NNN; lib/moodle-loader.js reassembles + re-verifies |
| File-count / PHP-count parity | Met — 23,324-file parity via USTAR prefix/name split + GNU longlink (read by both the ADR 0018 PharData probe and the shipped JS streaming parser; PAX would drop 32) |
| Maintainable decoder (no fragile lib) | Met for zstd (zstddec, 856k dl/wk, MIT); fzstd rejected (corrupts large frames) |
Recommendation¶
Adopt tar.zst. The storage/download win is the single largest lever this project has
found (−51 % download, 3 → 2 hosted chunks), checksum + parity are preserved, and the
decoder question has a sound answer (zstddec, not fzstd). The one unmet criterion here was
peak memory — approach (a) inflates the whole ~250 MB tar — and ADR 0019 resolved it by
switching extraction to approach (b) (zstddec streaming → incremental USTAR parse →
MEMFS), which caps the peak JS buffer at the largest single file (~6.6 MiB) and, unlike the
full-buffer path, boots on Firefox. Building on that, the ZIP core path was removed and
tar.zst became the sole format. tar.br was not adopted (heavier decoder, 4× build time,
~0.2 % smaller than zstd); tar.gz was not adopted (only −27 %, and it stays at 3 chunks),
though it remains the zero-dependency cross-browser fallback to reach for if the zstd decoder
ever proves problematic.
Consequences¶
Positive¶
- −51 % core-bundle download (tar.zst) and one fewer hosted chunk (3 → 2).
- A reusable, deterministic tar toolchain (
scripts/lib/tar-ustar.mjs) shared verbatim with the sibling*-playgroundrepos. - One code path: the core boot always streams
tar.zst, so there is no format-selection branching, no flag threading, and no ZIP/tar divergence to keep in sync.
Negative / Risks¶
- A new runtime dependency (
zstddecWASM, ~76 KB) bundled into the worker (lazy-imported at the extraction site). - The build now needs Node ≥ 22.15 for native
node:zlibzstd (CI pins Node 24) and azipintermediate is still produced then discarded (it remains the source of truth for the exclusion list + PHP-parity/required-file tripwires — seescripts/build-moodle-bundle.sh). - The tar core path is used only for the first-party, SHA-256-pinned core bundle — never
for untrusted plugin/blueprint archives, which still use the ZIP path in
lib/moodle-loader.js.
Implementation Notes¶
The shipped design lives in ADR 0019; the exploratory multi-format build/benchmark scripts
and the ?bundle-format= flag prototyped on the experiment branch did not merge. As shipped:
- Build:
scripts/build-tar-zst-from-zip.mjsconverts the trimmed, tripwire-verified core ZIP into the deterministictar.zst(scripts/lib/tar-ustar.mjsfor USTAR + GNU longlink;node:zlibzstd L19 + LDM, windowLog 24). Driven byscripts/build-moodle-bundle.sh, which discards the ZIP afterwards.scripts/chunk-bundles.mjssplits an oversizedtar.zstinto.part-NNN;scripts/generate-manifest.mjsrecordsbundle.format="tar.zst",bundle.container="tar",bundle.codec="zstd",bundle.fileCount, size + SHA-256. - Runtime:
lib/streaming-tar-extract.js(createDecodedTarStream,extractTarStreamToPhp,StreamingTarParser,sanitizeTarPath) decodes withzstddec/streamand writes each entry into MEMFS.src/runtime/bootstrap.jsdrives it (archive.manifest.bundle.codec→ decoder) and enforces the file-count parity tripwire (throws on mismatch).lib/moodle-loader.jsfetches/reassembles/verifies the (possibly chunked) compressed artifact. Requiresnpm run build-worker(the loader + extractor are bundled into the worker). - Tests:
tests/scripts/tar-ustar.test.js,tests/runtime/streaming-tar-extract.test.js.
Review Criteria¶
- Revisit when a browser ships native
DecompressionStream("zstd")— the zstddec dependency could then be dropped for that browser (keep it as the Firefox/older-Chrome fallback). - Streaming extraction (approach b) shipped in ADR 0019; the remaining follow-up is measuring the (now windowLog-24) zstd decode window on a memory-constrained mobile browser and a throttled network — tracked in ADR 0019's Review Criteria.
- The build now runs on Node 24 with native
node:zlibzstd (no CLI fallback); revisit the build codec/level/window if the Moodle tree's cross-file redundancy shifts materially. - Re-measure whenever the Moodle bundle's size or file count changes materially (a new branch), since the storage delta and chunk boundary (the 48–50 MB 2-vs-3-chunk edge) can shift.