Data formats · Lesson 21.8

Compress

Source
Compress a log with Compress in the raw, zlib and gzip formats and at each Level, get every byte back with Decompress, see the checksum refuse damaged data, and stream the same log into a .gz file and out again with CompressWriter and DecompressReader.

A web server's access log says the same few things over and over: the same paths, the same statuses, the same words. Compression finds what repeats and writes each repeat as a short reference back to an earlier copy, so a log, a JSON document or a page of HTML shrinks to a fraction of its size. The Compress package speaks Deflate, the method inside every .gz and .zip file and behind HTTP's Content-Encoding: gzip:

import Compress::{ Compress, CompressWriter, Decompress, DecompressReader, Format, Level };

This lesson compresses an 11,572-byte log to under 1,400 bytes, gets every byte back, and then does the same through a file, a piece at a time.

Three containers

The compressed stream is the same in each; what differs is what is wrapped around it:

FormatWrapped inOverheadWhere you meet it
Rawnothing: the bare Deflate stream0 bytesinside ZIP files
Zliba 2-byte header and an Adler-32 checksum6 bytesPNG images, many network protocols
Gzipa 10-byte header, a CRC-32 checksum and the original length18 bytes.gz files, HTTP

The program's output shows exactly that: 1377 bytes raw, 6 more as zlib, 18 more as gzip. Whoever reads the data must be told the same format it was written in.

Whole buffers

Compress takes the data as byte[..] and returns a Vector<byte> of the compressed bytes, allocated from the allocator you pass. Decompress takes them back:

let gzip = Compress(allocator, data, Format::Gzip)?;
let unpacked = Decompress(allocator, gzip.AsSlice(), Format::Gzip)?;

Both return Vector<byte> ! IoError. The log is text, so the program first views its characters as the bytes they are, with a pointer slice as the Hash lesson does:

func AsBytes(text: char8[..]) -> byte[..] {
    return (text.data as *byte)[..text.length];
}

How hard to try

An optional fourth argument, a Level, says how hard the compressor looks for repeats. Higher levels find longer and more of them, and take longer to:

LevelSearchesThe log, as gzip
Storenot at all: data copied as is11595 bytes
Fastesttakes the first match found1771 bytes
Defaulta balance, close to zlib's1395 bytes
Bestthe most thorough1360 bytes

Store is 23 bytes larger than the log: the gzip header and trailer, plus a few bytes per stored block. The level is not written into the data, so Decompress never needs to know it. For a given level the output is always the same, which is why these sizes are the same on every run.

What does not shrink

Compression only pays where something repeats. Hello, Rux! has nothing to repeat, so its 11 bytes come out as 31: the 18 bytes of gzip around a stream slightly longer than the text. Random bytes, and data that is already compressed, do not shrink either.

The checksum earns its place when something goes wrong. The program flips one bit in the middle of the compressed log, and Decompress refuses it with IoErrorKind::InvalidInput instead of returning something that is almost the log.

Streams

Compress needs all the data in memory at once. For a file of any size, CompressWriter is a Writer that compresses whatever is written to it into another writer — here a file:

var file = File::Open(allocator, path, OpenOptions::Writing())?;
var writer = CompressWriter::New(allocator, <-file, Format::Gzip)?;
var written: uint = 0;
while written < text.length {
    let end = written + 1000 < text.length ? written + 1000 : text.length;
    written = written + writer.Write(text[written..end])?;
}
writer.Finish()?;
flowchart LR
    w["`writer.Write`"] --> cw["`CompressWriter`"]
    cw -- "compressed blocks" --> f[("`access.log.gz`")]
    f --> dr["`DecompressReader`"]
    dr -- "`Read` into a buffer" --> r["the log, piece by piece"]

The writer takes the file — a file is move-only, hence <- — and closes it when the writer is destroyed at the end of the function. Nothing is complete until Finish writes the last block and the checksum. DecompressReader is the matching Reader: each Read fills a buffer with decompressed data, and the end of the data is reported, as IsEnd(), only after the checksum has agreed.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// Compression finds what repeats in data and writes each repeat as a short reference back to an
// earlier copy. A log, a JSON document or a page of HTML repeats a great deal and shrinks to a
// fraction of its size; random bytes do not repeat and do not shrink at all.
//
// The Compress package speaks Deflate, the method inside gzip, zlib and ZIP files and behind
// HTTP's `Content-Encoding: gzip`. The same compressed data travels in one of three containers:
//
//     Format::Raw   the bare Deflate stream
//     Format::Zlib  a 2-byte header, the stream, and an Adler-32 checksum of the data
//     Format::Gzip  a 10-byte header, the stream, a CRC-32 checksum and the length: a .gz file
//
// `Compress` and `Decompress` work on data already in memory. `CompressWriter` and
// `DecompressReader` do the same through the `Writer` and `Reader` interfaces, a piece at a time,
// so a file of any size can be compressed without holding all of it at once.
//
// The file this program writes lives in its package's `Bin/` folder, and is deleted at the end.
import Allocator::{ Allocator, SystemAllocator };
import Collections::{ CollectionError, Vector };
import Compress::{ Compress, CompressWriter, Decompress, DecompressReader, Format, Level };
import FileSystem::{ DeleteFile, File, OpenOptions };
import Io::{ IoError, IoErrorKind, PrintLine };
import Path::{ OsString, Path };
import Text::TextError;

// Text as the bytes it is made of, as in the Hash lesson. `Compress` takes bytes, and text is
// not accepted in their place:
//
//     Compress(allocator, "Hello, Rux!", Format::Gzip)
//         error: argument 2 to 'Compress' has type 'char8[..]', but parameter 'data' requires
//                'uint8[..]'
func AsBytes(text: char8[..]) -> byte[..] {
    return (text.data as *byte)[..text.length];
}

func SameBytes(left: byte[..], right: byte[..]) -> bool {
    if left.length != right.length { return false; }
    for i in 0..left.length {
        if left[i] != right[i] { return false; }
    }
    return true;
}

// A web server's access log of `count` requests: a few paths and statuses in an order that looks
// random but is the same on every run, and a time in milliseconds.
func MakeLog(allocator: Allocator, count: int) -> Vector<char8> ! CollectionError {
    let paths: char8[..][6] = ["/docs/learn/compress", "/docs/learn/url", "/images/logo.svg",
        "/v1/playground/run", "/blog", "/packages"];
    let statuses: char8[..][3] = [" 200 ", " 304 ", " 404 "];
    let digits = "0123456789";
    var log = Vector<char8>(allocator);
    var state: uint32 = 2026;
    for _ in 0..count {
        state = state * 1664525 + 1013904223;
        log.PushSlice("GET ")?;
        log.PushSlice(paths[((state >> 8) % 6) as uint])?;
        log.PushSlice(statuses[((state >> 16) % 3) as uint])?;
        log.Push(digits[((state >> 20) % 10) as uint])?;
        log.Push(digits[((state >> 24) % 10) as uint])?;
        log.PushSlice(" ms\n")?;
    }
    return <-log;
}

// Writes `text` into a .gz file. The writer takes the file and compresses whatever is written
// to it, here 1000 characters at a time; nothing is complete until `Finish` writes the last block
// and the checksum. When the function returns the writer is destroyed, and the file it holds is
// closed. A file is move-only, so handing it over takes `<-`:
//
//     CompressWriter::New(allocator, file, Format::Gzip)
//         error: move-only value 'file' requires an explicit '<-' in argument
func WriteLog(allocator: Allocator, path: Path, text: char8[..]) -> ! IoError {
    var file = File::Open(allocator, path, OpenOptions::Writing())?;
    var writer = CompressWriter::New(allocator, <-file, Format::Gzip)?;
    var written: uint = 0;
    while written < text.length {
        let end = written + 1000 < text.length ? written + 1000 : text.length;
        written = written + writer.Write(text[written..end])?;
    }
    writer.Finish()?;
}

// Reads the .gz file back a buffer at a time, until the reader reports the end of the data. That
// end comes only after the checksum agreed.
func ReadLog(allocator: Allocator, path: Path) -> ! IoError {
    var file = File::Open(allocator, path, OpenOptions::Reading())?;
    PrintLine("file                {} bytes", file.Size()?);
    var reader = DecompressReader::New(allocator, <-file, Format::Gzip)?;
    var buffer: char8[1024];
    var total: uint = 0;
    var lines: uint = 0;
    while true {
        let count = reader.Read(buffer[..]) catch {
            error if error.IsEnd() => break,
            error => fail error
        };
        for c in buffer[..count] {
            if c == '\n' { lines = lines + 1; }
        }
        total = total + count;
    }
    PrintLine("read back           {} bytes, {} lines", total, lines);
}

func Main() -> ! IoError | CollectionError | TextError {
    var system = SystemAllocator();
    let allocator: Allocator = system;

    let log = MakeLog(allocator, 400)?;
    let data = AsBytes(log.AsSlice());
    PrintLine("log                 {} bytes", data.length);

    // The same data in the three containers. The compressed stream inside is the same; the
    // containers differ only by the size of their header and checksum.
    let raw = Compress(allocator, data, Format::Raw)?;
    let zlib = Compress(allocator, data, Format::Zlib)?;
    let gzip = Compress(allocator, data, Format::Gzip)?;
    PrintLine("raw                 {} bytes", raw.Length());
    PrintLine("zlib                {} bytes", zlib.Length());
    PrintLine("gzip                {} bytes", gzip.Length());

    // How hard to look for repeats. `Store` does not compress at all, so it comes out a little
    // larger than the data; the others trade time for size. `Default` is what `Compress` uses
    // when no level is given.
    let levels: Level[4] = [Level::Store, Level::Fastest, Level::Default, Level::Best];
    let names: char8[..][4] = ["store", "fastest", "default", "best"];
    for i in 0..4 {
        let packed = Compress(allocator, data, Format::Gzip, levels[i])?;
        PrintLine("gzip, {:<13} {} bytes", names[i], packed.Length());
    }

    // The round trip gives back every byte. Decompressing needs only the format: the level is
    // not part of the data.
    let unpacked = Decompress(allocator, gzip.AsSlice(), Format::Gzip)?;
    PrintLine("round trip          {} bytes, identical: {}", unpacked.Length(),
        SameBytes(unpacked.AsSlice(), data));

    // Short text has nothing to repeat, and the container's header and checksum make it larger.
    let short = Compress(allocator, AsBytes("Hello, Rux!"), Format::Gzip)?;
    PrintLine("11 bytes as gzip    {} bytes", short.Length());

    // The checksum catches damage. Change one bit of the compressed data and decompressing
    // fails with `InvalidInput` instead of returning wrong data.
    var damaged = Vector<byte>(allocator);
    damaged.PushSlice(gzip.AsSlice())?;
    damaged.AsMutableSlice()[100] = damaged.AsSlice()[100] ^ 0x01;
    match Decompress(allocator, damaged.AsSlice(), Format::Gzip) {
        .Success(_) => PrintLine("damaged             accepted"),
        .Failure(error) => PrintLine("damaged             refused, invalid input: {}",
            error.kind == IoErrorKind::InvalidInput)
    }

    // Streaming into a .gz file, and back out of it.
    var holder = OsString::FromText(allocator, "Bin/access.log.gz")?;
    let path = Path::FromView(holder.View());
    WriteLog(allocator, path, log.AsSlice())?;
    ReadLog(allocator, path)?;
    DeleteFile(allocator, path)?;
}

Besides Io, its Rux.toml lists Allocator, Collections, Compress, FileSystem, Path and Text under [Dependencies].

Run it

cd Examples/DataFormats/Compress
rux run
log                 11572 bytes
raw                 1377 bytes
zlib                1383 bytes
gzip                1395 bytes
gzip, store         11595 bytes
gzip, fastest       1771 bytes
gzip, default       1395 bytes
gzip, best          1360 bytes
round trip          11572 bytes, identical: true
11 bytes as gzip    31 bytes
damaged             refused, invalid input: true
file                1395 bytes
read back           11572 bytes, 400 lines

Common mistakes

Try it yourself

  1. Compress gzip.AsSlice() a second time. Does compressed data shrink again?
  2. Decompress the gzip data cut short by three bytes, gzip.AsSlice()[..gzip.Length() - 3]. What happens?
  3. Make the log ten times longer. How do the sizes at each level change?
  4. Write the log as Format::Zlib in WriteLog, then read it with Format::Gzip in ReadLog, and see where it fails.

Learn more

  • Compress API — the formats, the levels, and Flush for a stream that must stay open
  • Memory stream — the Reader and Writer interfaces the streams are built on
  • File — opening, reading and writing files
  • Hash — checksums such as CRC-32, the one inside gzip
  • Error sum — a Main that can fail in three ways
  • Interfaces and Copy and move in the Rux Reference