fix(nwsync): stream emit so peak memory tracks the largest resource (#76) (#78)
build-binaries / build-binaries (push) Successful in 2m13s

Closes #76. Part of #54.

`nwsync emit` held roughly 5× the artifact size in RAM, so ovh-main (7 GB, no swap) OOM-killed it on any hak over ~1.4 GB. That blocked the backfill in sow-assets-manifest and would have killed the next release rebuilding a large hak.

## What it does

- `erf.ReadIndex` / `erf.ReadPayload` — parse the header and resource table only, read one payload on demand. `erf.Read` keeps its shape but returns payloads as subslices of the buffer instead of fresh copies, which removes one full copy for the `pipeline` callers too. Callers must not mutate `Resource.Data`; the doc comment says so and no caller does.
- `nwsync.Emit` opens the artifact, hashes it by streaming for the key check (through a section reader, so the file offset stays put), then hashes, compresses and stores one resource at a time. The archive is never resident. Shadowed duplicate resrefs are now never read at all.
- Bounds checks in `ReadIndex` moved to `int64`, so key/resource-list offsets can no longer overflow.

## Not in the issue, but memory-motivated

The zstd blob encoder ran at the default concurrency, which is one encoder per CPU, each holding a window-sized history — about 200 MB of live heap doing nothing on a 24-core runner. `EncodeAll` is single-threaded per call, so concurrency 1 costs nothing. `TestSingleThreadedEncoderMatchesDefault` pins the claim that blobs come out byte-identical.

## Measured

Peak heap during emit, sampled 1 ms:

| hak | before | after |
|-----|--------|-------|
| 8 MB | 155 MB | 21 MB |
| 64 MB | 289 MB | 22 MB |

Flat, as the acceptance asks. `TestEmitPeakMemoryDoesNotScaleWithArtifactSize` fails if the 64 MB fixture costs more than the 8 MB one plus 24 MB of slack.

## Gaps

- The regression check measures Go heap, not RSS, and its largest fixture is 64 MB — a multi-GB run was not done here. A 2.15 GB hak now needs about the same ~22 MB the 64 MB one does, so the 5 GB budget is not close, but that is inference from the flat curve, not a measurement.
- mmap was suggested in the issue and skipped. Payload buffers are still anonymous, but they are one resource each (≤15 MiB), so making them file-backed buys nothing now.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #78
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
This commit was merged in pull request #78.
This commit is contained in:
2026-07-31 11:16:23 +00:00
committed by archvillainette
parent 7437653f14
commit fa32dd411f
5 changed files with 313 additions and 93 deletions
+57 -43
View File
@@ -1,10 +1,10 @@
package nwsync
import (
"bytes"
"context"
"crypto/sha1"
"fmt"
"io"
"os"
"path"
"path/filepath"
@@ -79,11 +79,18 @@ type EmitOptions struct {
// real blobs in the zone and no index, which is unambiguous. Blob names are
// content hashes, so re-running skips whatever already landed.
func Emit(options EmitOptions) (EmitResult, error) {
artifact, err := os.ReadFile(options.ArtifactPath)
artifact, err := os.Open(options.ArtifactPath)
if err != nil {
return EmitResult{}, fmt.Errorf("read artifact: %w", err)
}
if err := checkArtifactKey(options.ArtifactKey, artifact); err != nil {
defer artifact.Close()
info, err := artifact.Stat()
if err != nil {
return EmitResult{}, fmt.Errorf("read artifact: %w", err)
}
// A section reader, not the file itself: hashing must not move the file
// offset out from under everything that reads the artifact afterwards.
if err := checkArtifactKey(options.ArtifactKey, io.NewSectionReader(artifact, 0, info.Size())); err != nil {
return EmitResult{}, err
}
name := options.As
@@ -98,7 +105,7 @@ func Emit(options EmitOptions) (EmitResult, error) {
return EmitResult{}, err
}
resources, err := readArtifact(options.ArtifactPath, artifact, name)
index, err := readArtifactIndex(options.ArtifactPath, artifact, info.Size(), name)
if err != nil {
return EmitResult{}, err
}
@@ -108,7 +115,7 @@ func Emit(options EmitOptions) (EmitResult, error) {
return EmitResult{}, err
}
entries, blobs, onDiskBytes, err := emitResources(resources, target)
entries, blobs, onDiskBytes, err := emitResources(artifact, index, target)
if err != nil {
return EmitResult{}, err
}
@@ -137,60 +144,64 @@ func openSink(outDir string, injected sink) (sink, error) {
return newZoneSink(context.Background(), os.Getenv)
}
// readArtifact returns the resources of an ERF/HAK/MOD, or the single resource
// a loose file represents. Upstream's resman does the same dispatch on the
// file's first three bytes. name is the artifact's published name, which for a
// loose file is also its resref.
func readArtifact(path string, data []byte, name string) ([]erf.Resource, error) {
if len(data) >= 3 {
switch string(data[:3]) {
case "ERF", "HAK":
archive, err := erf.Read(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return archive.Resources, nil
case "MOD":
// A persistent world never publishes module contents, so the .mod
// contributes no bytes to a manifest — it only says which haks and
// which TLK the manifest covers.
return nil, fmt.Errorf("%s: a module is never emitted; a manifest is haks plus the TLK", path)
// readArtifactIndex locates the resources of an ERF/HAK/MOD, or the single
// resource a loose file represents, without reading any payload. Upstream's
// resman does the same dispatch on the file's first three bytes. name is the
// artifact's published name, which for a loose file is also its resref.
func readArtifactIndex(path string, artifact io.ReaderAt, size int64, name string) ([]erf.IndexEntry, error) {
magic := make([]byte, 3)
if size >= 3 {
if _, err := artifact.ReadAt(magic, 0); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
}
switch string(magic) {
case "ERF", "HAK":
index, err := erf.ReadIndex(artifact, size)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return index.Entries, nil
case "MOD":
// A persistent world never publishes module contents, so the .mod
// contributes no bytes to a manifest — it only says which haks and
// which TLK the manifest covers.
return nil, fmt.Errorf("%s: a module is never emitted; a manifest is haks plus the TLK", path)
}
extension := filepath.Ext(filepath.Base(path))
restype, ok := erf.ResourceTypeForExtension(extension)
if !ok {
return nil, fmt.Errorf("%s: unknown resource type %q", path, extension)
}
return []erf.Resource{{
Name: name,
Type: restype,
Data: data,
}}, nil
return []erf.IndexEntry{{Name: name, Type: restype, Offset: 0, Size: size}}, nil
}
func emitResources(resources []erf.Resource, target sink) ([]Entry, int, int64, error) {
// emitResources hashes, compresses and stores one resource at a time, reading
// each payload from the artifact only when its turn comes. Peak memory
// therefore tracks the largest single resource, not the archive: a 2 GB hak
// must emit inside a runner's few spare GB.
func emitResources(artifact io.ReaderAt, index []erf.IndexEntry, target sink) ([]Entry, int, int64, error) {
// A resref appearing twice inside one artifact resolves to the last one,
// the way resman lets the last container added win.
order := make([]Identity, 0, len(resources))
latest := make(map[Identity]erf.Resource, len(resources))
order := make([]Identity, 0, len(index))
latest := make(map[Identity]erf.IndexEntry, len(index))
var tooBig []string
for _, resource := range resources {
if _, ok := erf.ExtensionForResourceType(resource.Type); !ok {
return nil, 0, 0, fmt.Errorf("resref %s is not resolvable (unknown restype %d)", resource.Name, resource.Type)
for _, entry := range index {
if _, ok := erf.ExtensionForResourceType(entry.Type); !ok {
return nil, 0, 0, fmt.Errorf("resref %s is not resolvable (unknown restype %d)", entry.Name, entry.Type)
}
if slices.Contains(skippedTypes, resource.Type) {
if slices.Contains(skippedTypes, entry.Type) {
continue
}
if len(resource.Data) > fileSizeLimit {
tooBig = append(tooBig, fmt.Sprintf("%s: %d bytes > %d", resource.Name, len(resource.Data), fileSizeLimit))
if entry.Size > fileSizeLimit {
tooBig = append(tooBig, fmt.Sprintf("%s: %d bytes > %d", entry.Name, entry.Size, fileSizeLimit))
continue
}
identity := Identity{ResRef: strings.ToLower(resource.Name), ResType: resource.Type}
identity := Identity{ResRef: strings.ToLower(entry.Name), ResType: entry.Type}
if _, seen := latest[identity]; !seen {
order = append(order, identity)
}
latest[identity] = resource
latest[identity] = entry
}
if len(tooBig) > 0 {
sort.Strings(tooBig)
@@ -201,15 +212,18 @@ func emitResources(resources []erf.Resource, target sink) ([]Entry, int, int64,
var blobs int
var onDiskBytes int64
for _, identity := range order {
resource := latest[identity]
sum := sha1.Sum(resource.Data)
payload, err := erf.ReadPayload(artifact, latest[identity])
if err != nil {
return nil, 0, 0, err
}
sum := sha1.Sum(payload)
entries = append(entries, Entry{
SHA1: sum,
Size: uint32(len(resource.Data)),
Size: uint32(len(payload)),
ResRef: identity.ResRef,
ResType: identity.ResType,
})
written, err := target.putBlob(fmt.Sprintf("%x", sum), func() []byte { return compressBlob(resource.Data) })
written, err := target.putBlob(fmt.Sprintf("%x", sum), func() []byte { return compressBlob(payload) })
if err != nil {
return nil, 0, 0, err
}