fix(nwsync): stream emit so peak memory tracks the largest resource (#76) (#78)
build-binaries / build-binaries (push) Successful in 2m13s
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:
+57
-43
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user