Onboarding for agents working on the az codebase. If you are integrating
az as a dependency in another project, you don't need this file — use the
skills under skills/ instead.
az is a small, dependency-free Go compression library + CLI. It is a thin
facade over two vendored codecs:
- Levels 1–2 → LZ4 (
internal/lz4, a vendored fork ofpierrec/lz4) - Levels 3–5 → Zstandard (
internal/zstd, a vendored fork ofklauspost/compress/zstd)
There is no custom compression algorithm in the current tree — earlier versions
had a hand-written LZ77+entropy engine, but commit 377a7a5 replaced it with
LZ4/Zstd. Pantry/memory notes describing block types 0x01/0x02/0x04, FSE block
layouts, etc. are historical and no longer describe the shipping code.
The public package (github.com/go-again/az) auto-detects the format on decode
from the frame magic, so .az files are plain lz4 or zstd streams that standard
lz4/zstd CLIs can read.
| Path | What |
|---|---|
az.go |
Public facade: Compress/Decompress, Writer/Reader. |
encoder.go |
Public pooled codecs: Encoder/Decoder (EncodeAll/DecodeAll). |
options.go |
Level constants, Options, With* functional options. |
errors.go |
ErrCorrupted, ErrChecksumFail, ErrLevel. |
*_test.go |
Round-trip, format-identity, fuzz, and benchmarks. |
azhttp/ |
HTTP middleware: response compression, request decompression, client transport. |
cmd/az/ |
The CLI (-1..-5, -d, -c, -k, -t, -o, --no-checksum). |
examples/http/ |
Runnable azhttp demo (just example-http). |
internal/lz4, internal/zstd |
Vendored codecs az actually compiles against. |
internal/fse, internal/huff0, internal/le, internal/cpuinfo |
Dependencies of the vendored zstd. |
internal/fuzz, internal/testdata |
Fuzz corpus / fixtures. |
.compress/, .lz4/ |
Reference checkouts of the upstream projects. Dot-prefixed, so Go tooling ignores them. Use for diffing against upstream / cherry-picking fixes; they are NOT built. |
scripts/bench.sh, justfile |
Benchmark + comparison tooling. |
skills/ |
Consumer-facing agent skills (az-integration, az-pooling, az-http) — keep in sync with the public API. |
Use just (recipes in justfile):
just # default gate: build + test + lint
just --list # every recipe, grouped (build/test/lint/docs/bench/clean)
just build # build ./cmd/az → ./az
just run -3 -c F # build, then run the CLI
just test # go test -count=1 -timeout 5m ./...
just test-race # race detector
just test-one PAT # go test -run PAT -v ./...
just coverage # coverage summary + /tmp/az-cover.html
just lint # fmt-check + vet + staticcheck + golangci + modernize + tidy-check + deps-check
just lint-fix # apply what the linters can fix, then gofmt + go mod tidy
just fmt # gofmt -w (apply)
just tools # warm the module cache for the pinned lint tools
just fuzz # 30s fuzz of FuzzRoundtrip
just bench # go test -bench=. -benchmem -benchtime=3s ./...
just compare <dir> # size/speed table vs lz4/zstd/gzip/xz — wraps scripts/bench.sh
just cross # compile-only build of every GOOS/GOARCH CI covers
just ci # everything CI runs: build + test + race + lint + cross
just doc ./azhttp # go doc -all for a package
just api # exported API of every public package (diff target for "is this additive?")
just example-http # run the azhttp demo (add -serve to keep it up)Lint tools are pinned in the justfile (golangci, staticcheck,
modernize variables) and invoked with go run, so there is nothing to install
and a green just lint locally means a green lint job in CI. The same versions
are hard-coded in .github/workflows/ci.yml — bump both together.
Two checks exist to protect stated invariants, and both are in lint and CI:
tidy-check (go.mod stays tidy, without leaving it modified) and deps-check
(az stays dependency-free — the whole reason the codecs are vendored).
Lint scope: go vet runs over the whole module (./...) — it's clean on the
vendored internal/ trees, so they get vet coverage for free. The stricter
linters (staticcheck/golangci/modernize) run only over az's own packages
(the gopkgs variable in the justfile: ., ./azhttp/..., ./cmd/...,
./examples/... — keep it in sync when adding a package); the vendored
internal/ forks would otherwise flag ~19 upstream
idioms (SA4004/U1000/…) that aren't bugs and shouldn't drift from upstream
(see .golangci.yml). gofmt covers everything except internal/ and dot-dirs.
CI (.github/workflows/ci.yml, modeled on gosqlite.org) runs test
(ubuntu/macos/windows × Go 1.25/1.26), race, the same lint gate, and a
cross-build matrix. The lint job runs on Go 1.26 specifically: the pinned
gopls/modernize needs it to build, and setup-go sets GOTOOLCHAIN=local, so
the job can't fetch a newer toolchain the way a developer's default
GOTOOLCHAIN=auto does. az itself still targets 1.25 (go.mod).
.gitattributes sets * -text, and that is load-bearing. Git for Windows
defaults to core.autocrlf=true; the fixtures in internal/testdata are
compressed and compared byte for byte, and internal/fse's ExampleCompress
asserts an exact size. A CRLF-translated e.txt is 100004 bytes instead of
100003 and the example fails on Windows only, looking like a codec bug. Don't
"clean up" that file.
Plain go build ./... / go test ./... also work. Note: first compile of the
vendored zstd/lz4/fse packages is slow (tens of seconds) — be patient and
always pass -timeout when running tests in the background so a hang can't
sit at the default 10-minute limit (see the deadlock gotcha below).
Three code paths must produce byte-identical frames for the same input+level:
Compress(src, level)Encoder.EncodeAll(nil, src, level)- (decode side)
DecompressandDecoder.DecodeAllmust agree.
Consumers (e.g. gosqlite/blobstore) store Compress-format data at rest and
later mix it with EncodeAll-format data in the same store. Any divergence is
a silent at-rest format break with no migration path. This is enforced by
TestEncodeAllFormatIdentity / TestDecodeAllFormatIdentity in
encoder_test.go — never weaken those tests; if you change encoder options, the
change must be made in both Compress and EncodeAll (and Writer if it
should match) and the identity tests must stay green.
- lz4 concurrent writer can't be
Reset-reused afterClose. The concurrent lz4Writerstarts a block-manager goroutine that exits afterClose; the nextReset→Blocks.closethen deadlocks on a channel send (internal/lz4/stream/block.go). That's why the pooledEncoderusesConcurrencyOption(1)for lz4. Concurrency does not change output bytes, so identity withCompress(which usesGOMAXPROCS) holds; the only effect is no intra-call block parallelism on lz4 inputs >4 MB (single chunk per encoder is the intended usage anyway). Do not "restore concurrency" on the pooled lz4 writer without also fixing the upstream reset deadlock. - zstd: do not use klauspost
EncodeAllfor the pooled encoder. It would make big frames single-segment (window = whole payload) and is serial. Reuse a per-level*zstd.EncoderviaResetContentSize+Write+Closeinstead —Compressdoes exactly the same, which is what keeps the two byte-identical. ResetContentSize, notReset, in the one-shot zstd paths. BothCompressandEncoder.EncodeAlldeclarelen(src)so every frame carriesFrame_Content_Size; consumers single-shot decode off the header (get size → alloc → decompress). PlainResetwrites the header before the total is known, which silently drops the size for inputs larger than one block (~128 KB) and for empty input. Three small patches in the vendored zstd support this (all marked "az fork"):frameHeader.ContentSizeKnownwidensFCS_Field_Sizeto 4 bytes when a windowed frame must carry a size < 256 (the 2-byte encoding is biased by 256 and can't express it);encoderState.frameContentSizeKnowndistinguishes a declared size of 0 from "undeclared";encodeAllmarks the size known unconditionally. Pinned byTestOneShotAlwaysStoresContentSize.WithContentSizeis a no-op. One-shot frames always store the size and the streamingWriternever can. Kept only so the option doesn't vanish from the public surface.Writer/Readerare intentionally NOT byte-identical toCompress.Writerenables block checksums and omitsSizeOption. UseCompress/Encoderwhen at-rest identity matters,Writer/Readerfor general streaming.Reader.Resetmust release the sub-reader, not re-point it. Handing the old zstd decoder the sharedbufio.Reader(Reset(r.br)) starts a stream goroutine on the buffer the next stream is about to read: a data race, a goroutine leak, and stolen bytes ("lz4: invalid block size" two resets later).Close()cancels it and waits; the nextReadre-detects the format and builds a fresh sub-reader. Pinned byTestReaderResetReleasesSubReader, andazhttp's pooled response bodies depend on it.DecodeAllLimitmust stream, never one-shot. The bound is what makes it safe on untrusted frames, so it drives the per-format reader and capsdstgrowth atmax+1(readLimitAppend). Two traps if you touch it: (1) the zstdDecoder.Resetsynchronously decodes the whole frame when handed abyter(Bytes()+Len()) under ~128 KiB —*bytes.ReaderlacksBytes()so it streams, but don't "helpfully" switch to*bytes.Buffer/raw bytes or you re-arm the bomb; (2) on early stop (ErrTooLarge) the zstd stream goroutine is still running —Reset(nil)cancels it. The Decoder's lz4 reader isConcurrencyOption(1)precisely so the lz4 path has no goroutine to leak on early stop. There's a goroutine-leak regression test for this.
- Codec concurrency is pinned to 1, and that is load-bearing.
azhttppools oneaz.Writerper response and reuses it viaResetafterClose. Only a single-threaded lz4 writer survives that (see the lz4 deadlock above), and a server is already parallel across requests, so GOMAXPROCS workers per response would just multiply goroutines and memory. That's whataz.WithConcurrency(1)is for; don't drop it fromnewWriterPool. lz4is not a registered content coding. It is only ever selected when a client nameslz4explicitly — never fromAccept-Encoding: *, which is whyselectEncodingtreats the wildcard as zstd-only. Keep it that way: inferring lz4 from a wildcard would send LZ4 frames to browsers that asked for anything.zstdis the standard coding (RFC 8878) and needs no such care.- The decision is deferred, so header mutation has a deadline. The wrapper
buffers up to
max(MinSize, 512)bytes before choosing, and only then writes the status line. Anything that must be adjusted on a compressed response (Content-Length,Accept-Ranges,ETag,Content-Encoding) has to happen instartCompress/startPlain, beforeWriteHeaderreaches the real writer. Flushforces the decision. A handler that flushes belowMinSizegets compression started anyway — a client waiting on a flush can't also wait for the buffer to fill. Test coverage:TestFlushStartsCompressionEarly.- Reference implementation: klauspost's
gzhttp(same author as the vendored zstd). It is not vendored here, but~/go/pkg/mod/github.com/klauspost/compress@*/gzhttpis worth diffing against when changing negotiation or the buffering state machine — azhttp deliberately mirrors its structure, minus the BREACH jitter, theCloseNotifiershim, and the pluggable writer factories.
Compress, Decompress, Writer/NewWriter (incl. Flush), Reader/NewReader,
Encoder/NewEncoder/EncodeAll, Decoder/NewDecoder/DecodeAll/DecodeAllLimit,
the Level constants, With* options, and the sentinel errors
(ErrCorrupted, ErrChecksumFail, ErrLevel, ErrTooLarge) are the public
surface. azhttp adds Handler, NewWrapper, its Options, Transport and
its TransportOptions, and DecompressRequests. Keep additions additive;
the levels' on-disk format is a compatibility boundary, and the content-coding
tokens (zstd, lz4) are a wire boundary too.
just lintandjust test(andjust test-racefor concurrency-touching changes) green — this is the same gate CI enforces.just ciruns the lot, cross-build included.- If you changed level configs, codec options, or the wire path, re-run the format-identity tests and update the README levels/wire-format sections.
- If you changed the public API, update the consumer skills under
skills/(az-integration,az-pooling,az-http— including thedescription:frontmatter, which is what an agent matches on) and the README.just apiprints the exported surface if you need to see what moved. - Store a pantry note and update
~/.claudememory for non-obvious decisions (the project uses both — see the pantry skill).