fix(nwsync): stream emit so peak memory tracks the largest resource (#76)
Emit read the whole artifact into memory and erf.Read then allocated a second full copy of every payload, so a hak cost about 5x its size in RAM. The 7 GB runner was OOM-killed on any hak over ~1.4 GB, which blocks the NWSync backfill and every release that rebuilds a large hak. Emit now opens the artifact, hashes it by streaming for the key check, parses only the header and resource table via erf.ReadIndex, and reads, hashes, compresses and stores one payload at a time. erf.Read keeps its old shape but returns payloads as subslices instead of fresh copies, which removes the second copy for the other callers too. The zstd encoder pool also held one window-sized history per CPU — about 200 MB of live heap on a 24-core runner. EncodeAll is single-threaded per call, so concurrency 1 gives byte-identical blobs for far less memory. Peak heap is now flat at ~22 MB for both an 8 MB and a 64 MB hak, and a regression test asserts it does not scale with artifact size. Closes #76 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+91
-45
@@ -311,63 +311,109 @@ func Write(w io.Writer, archive Archive) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexEntry locates one resource inside an archive without holding its
|
||||
// payload. Streaming callers read one payload at a time from these, so peak
|
||||
// memory tracks the largest resource instead of the whole archive.
|
||||
type IndexEntry struct {
|
||||
Name string
|
||||
Type uint16
|
||||
Offset int64
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Index is the header plus the resource table of an ERF: everything except the
|
||||
// payloads.
|
||||
type Index struct {
|
||||
FileType string
|
||||
Version string
|
||||
Entries []IndexEntry
|
||||
}
|
||||
|
||||
// ReadIndex parses the tables of an ERF of the given size, reading only the
|
||||
// header, the key list and the resource list.
|
||||
func ReadIndex(r io.ReaderAt, size int64) (Index, error) {
|
||||
if size < headerSize {
|
||||
return Index{}, fmt.Errorf("erf file too small: %d bytes", size)
|
||||
}
|
||||
|
||||
var hdr header
|
||||
if err := binary.Read(io.NewSectionReader(r, 0, headerSize), binary.LittleEndian, &hdr); err != nil {
|
||||
return Index{}, fmt.Errorf("decode erf header: %w", err)
|
||||
}
|
||||
|
||||
if int64(hdr.KeyListOffset)+int64(hdr.EntryCount)*24 > size {
|
||||
return Index{}, fmt.Errorf("erf key list exceeds file bounds")
|
||||
}
|
||||
keys := make([]keyEntry, hdr.EntryCount)
|
||||
keyReader := io.NewSectionReader(r, int64(hdr.KeyListOffset), int64(hdr.EntryCount)*24)
|
||||
if err := binary.Read(keyReader, binary.LittleEndian, &keys); err != nil {
|
||||
return Index{}, fmt.Errorf("decode key list: %w", err)
|
||||
}
|
||||
|
||||
if int64(hdr.ResourceListOffset)+int64(hdr.EntryCount)*8 > size {
|
||||
return Index{}, fmt.Errorf("erf resource list exceeds file bounds")
|
||||
}
|
||||
entries := make([]resourceEntry, hdr.EntryCount)
|
||||
entryReader := io.NewSectionReader(r, int64(hdr.ResourceListOffset), int64(hdr.EntryCount)*8)
|
||||
if err := binary.Read(entryReader, binary.LittleEndian, &entries); err != nil {
|
||||
return Index{}, fmt.Errorf("decode resource list: %w", err)
|
||||
}
|
||||
|
||||
index := Index{
|
||||
FileType: string(hdr.FileType[:]),
|
||||
Version: string(hdr.Version[:]),
|
||||
Entries: make([]IndexEntry, 0, hdr.EntryCount),
|
||||
}
|
||||
for position, key := range keys {
|
||||
entry := entries[position]
|
||||
if int64(entry.Offset)+int64(entry.Size) > size {
|
||||
return Index{}, fmt.Errorf("resource %d exceeds file bounds", position)
|
||||
}
|
||||
index.Entries = append(index.Entries, IndexEntry{
|
||||
Name: string(bytes.TrimRight(key.ResRef[:], "\x00")),
|
||||
Type: key.ResourceType,
|
||||
Offset: int64(entry.Offset),
|
||||
Size: int64(entry.Size),
|
||||
})
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// ReadPayload returns one resource's bytes.
|
||||
func ReadPayload(r io.ReaderAt, entry IndexEntry) ([]byte, error) {
|
||||
payload := make([]byte, entry.Size)
|
||||
if _, err := r.ReadAt(payload, entry.Offset); err != nil {
|
||||
return nil, fmt.Errorf("read resource %q: %w", entry.Name, err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// Read materialises a whole archive. Payloads are subslices of the buffer the
|
||||
// archive was read into, so nothing is copied twice; callers that only need one
|
||||
// resource at a time should use ReadIndex instead.
|
||||
func Read(r io.Reader) (Archive, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return Archive{}, fmt.Errorf("read erf: %w", err)
|
||||
}
|
||||
if len(data) < headerSize {
|
||||
return Archive{}, fmt.Errorf("erf file too small: %d bytes", len(data))
|
||||
index, err := ReadIndex(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return Archive{}, err
|
||||
}
|
||||
|
||||
var hdr header
|
||||
if err := binary.Read(bytes.NewReader(data[:headerSize]), binary.LittleEndian, &hdr); err != nil {
|
||||
return Archive{}, fmt.Errorf("decode erf header: %w", err)
|
||||
}
|
||||
|
||||
keyStart := int(hdr.KeyListOffset)
|
||||
keyEnd := keyStart + int(hdr.EntryCount)*24
|
||||
if keyEnd > len(data) {
|
||||
return Archive{}, fmt.Errorf("erf key list exceeds file bounds")
|
||||
}
|
||||
keys := make([]keyEntry, hdr.EntryCount)
|
||||
if err := binary.Read(bytes.NewReader(data[keyStart:keyEnd]), binary.LittleEndian, &keys); err != nil {
|
||||
return Archive{}, fmt.Errorf("decode key list: %w", err)
|
||||
}
|
||||
|
||||
resourceStart := int(hdr.ResourceListOffset)
|
||||
resourceEnd := resourceStart + int(hdr.EntryCount)*8
|
||||
if resourceEnd > len(data) {
|
||||
return Archive{}, fmt.Errorf("erf resource list exceeds file bounds")
|
||||
}
|
||||
entries := make([]resourceEntry, hdr.EntryCount)
|
||||
if err := binary.Read(bytes.NewReader(data[resourceStart:resourceEnd]), binary.LittleEndian, &entries); err != nil {
|
||||
return Archive{}, fmt.Errorf("decode resource list: %w", err)
|
||||
}
|
||||
|
||||
resources := make([]Resource, 0, hdr.EntryCount)
|
||||
for index, key := range keys {
|
||||
entry := entries[index]
|
||||
start := int(entry.Offset)
|
||||
end := start + int(entry.Size)
|
||||
if end > len(data) {
|
||||
return Archive{}, fmt.Errorf("resource %d exceeds file bounds", index)
|
||||
}
|
||||
|
||||
resref := string(bytes.TrimRight(key.ResRef[:], "\x00"))
|
||||
payload := make([]byte, entry.Size)
|
||||
copy(payload, data[start:end])
|
||||
resources := make([]Resource, 0, len(index.Entries))
|
||||
for _, entry := range index.Entries {
|
||||
resources = append(resources, Resource{
|
||||
Name: resref,
|
||||
Type: key.ResourceType,
|
||||
Data: payload,
|
||||
Size: int64(entry.Size),
|
||||
Name: entry.Name,
|
||||
Type: entry.Type,
|
||||
Data: data[entry.Offset : entry.Offset+entry.Size],
|
||||
Size: entry.Size,
|
||||
})
|
||||
}
|
||||
|
||||
return Archive{
|
||||
FileType: string(hdr.FileType[:]),
|
||||
Version: string(hdr.Version[:]),
|
||||
FileType: index.FileType,
|
||||
Version: index.Version,
|
||||
Resources: resources,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user