Files
seaweedfs/weed/filer/entry_codec_attributes.go
T
Chris LuandGitHub b46946ece5 filer: list directories without decoding chunk lists (#10616)
* filer: decode a listed entry without building its chunk list

A readdir reads attributes and never looks at chunks, but decoding an
entry builds the whole chunk list first: four allocations per chunk, all
of it thrown away. On a directory of ordinary 4MB-chunked files that is
most of what listing costs.

DecodeAttributesOnly walks the wire format and hands everything except
the chunks to the generated unmarshaller, so new fields in filer.proto
need no attention here. The chunks are still measured, because the S3
copy and multipart paths deliberately store a zero FileSize and let the
chunk extents define the size, but nothing is allocated to do it.

The blob is only re-encoded once a chunk is actually seen, so an entry
without any -- every directory, for one -- is unmarshalled where it lies
and pays nothing for the walk.

Listings opt in through the context, the way the lazy remote paths
already do; a store that ignores it stays correct.

    chunks   full      attrs-only              allocs
    0        312.8n    310.1n    ~              1 ->  1
    1        686.1n    411.1n    -40.07%        7 ->  1
    4        1.742u    667.4n    -61.69%       24 ->  1
    16       5.770u    1.544u    -73.25%       86 ->  1
    64       25.23u    6.004u    -76.20%      328 ->  1

* mount: list directories with chunk lists omitted

The two meta cache listings behind a readdir are the only callers, and
neither reads a chunk. On 200k single-chunk files one enumeration goes
from 364ms to 277ms and drops a million allocations.

The read-through listing still fetches whole entries from the filer,
which would need the request to say it wants attributes only.

* mount: give the readdir benchmark's entries a chunk

Chunkless entries made the decode look far cheaper than it is, which is
the part of a listing worth measuring.

* filer: let a listing ask for entries without their chunk lists

The read-through readdir fetches whole entries over gRPC, and for a wide
directory the chunk lists are most of what crosses the wire and most of
what the client then unmarshals. A 4MB-chunked file is 113 bytes of
entry against 46 without its chunk.

ListEntriesRequest gains omit_chunks. The size a client needs is already
in the attributes, where the store decode folded the chunk extents in,
so dropping the list costs the client nothing.

The filer still reads the entries whole. A listing is where a TTL-expired
entry gets collected and deleted, and deleting one needs its chunks to
find the data, so omitting them there would leak. Only the response is
trimmed.

The hint moves to filer_pb so one context flag serves both transports:
the gRPC request sets omit_chunks, and a listing served from the local
store skips building the chunks. Cache population is unaffected either
way, since EnsureVisited starts from its own context.

* filer: reject a chunk the full decoder would reject

The walk skipped a chunk's bytes without looking inside them, so a
FileChunk carrying a corrupt nested fid, or a string that is not valid
UTF-8, sailed past the listing decoder while every other read of the same
entry still failed. The file listed with a plausible size and then gave
EIO on open, and corruption that used to fail the listing loudly was
hidden instead.

The chunk bytes are the one part of the blob the generated unmarshaller
never sees, so the two checks it would have made are made here: a
submessage has to parse, and a proto3 string has to be valid UTF-8.
FileChunk's only submessages are FileIds of scalars, so walking them is a
complete check. A descriptor-driven test fails if FileChunk ever gains a
field of either kind that the walk does not know to check, which is the
part that keeps this honest as filer.proto grows.

Taking the scratch buffer lazily, only once a chunk is actually dropped,
also takes the pool out of the path for entries that have none. Those
were measurably slower than the full decoder before; they are now level
with it. Each chunk's length prefix is parsed once rather than twice.

    chunks   full       attrs-only   vs base
    0        171.4n     176.9n       ~ (p=0.670)
    1        366.6n     259.4n       -29.24%
    4        1.034u     500.2n       -51.60%
    16       3.905u     1.464u       -62.52%
    64       13.48u     5.195u       -61.46%

* filer: carry the size before dropping chunks over the wire

Dropping the chunk list assumed every store folds the chunk extents into
FileSize when it decodes. A store that keeps entries as JSON rather than
as an encoded Entry never re-derives it, so an object written with a zero
FileSize kept its real size only in the chunks, and stripping them left
the client reading the file as empty. Stamp the size into the attributes
first, which costs nothing and does not depend on how the store loaded
the entry.

* mount: test that the readdir context reaches the store decode

Everything else exercises the decoder directly, so a refactor that
stopped threading the context would have reverted the whole thing with
every test still passing.

The benchmark's chunks also carried a constant legacy FileId, which
BeforeEntrySerialization reparses over Fid on the way in, so all 200k
entries stored one byte-identical chunk rather than the varying fixture
it looked like.
2026-08-07 12:03:18 -07:00

214 lines
6.5 KiB
Go

package filer
import (
"context"
"errors"
"fmt"
"sync"
"unicode/utf8"
"google.golang.org/protobuf/encoding/protowire"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// Field numbers from filer.proto. Only the ones this decoder has to recognise
// are named; every other Entry field is handed to the generated unmarshaller
// as-is.
const (
entryChunksField = 3 // Entry.chunks
fileChunkOffsetField = 2 // FileChunk.offset
fileChunkSizeField = 3 // FileChunk.size
)
// The chunk bytes are the one part of the blob the generated unmarshaller never
// sees, so the checks it would have made are made here instead: a submessage
// has to parse, and a proto3 string has to be valid UTF-8. Anything else in a
// FileChunk is a scalar, which walking it already validates.
// TestChunkValidationCoversEveryField fails if FileChunk gains a field of
// either kind that is missing from these.
var (
fileChunkMessageFields = map[protowire.Number]bool{
7: true, // fid, a FileId of scalars only, so walking it is a full check
8: true, // source_fid
}
fileChunkStringFields = map[protowire.Number]bool{
1: true, // file_id
5: true, // e_tag
6: true, // source_file_id
}
)
// attributesScratchPool holds the re-encoded entry, which is the blob minus its
// chunks and so much smaller than what came in.
var attributesScratchPool = sync.Pool{
New: func() any {
b := make([]byte, 0, 256)
return &b
},
}
// DecodeListedEntry decodes one listed entry, dropping the chunk list when the
// listing asked for attributes only.
func DecodeListedEntry(ctx context.Context, entry *Entry, blob []byte) error {
if filer_pb.ChunksOmitted(ctx) {
return entry.DecodeAttributesOnly(blob)
}
return entry.DecodeAttributesAndChunks(blob)
}
// DecodeAttributesOnly fills entry from blob without building its chunk list,
// which is the bulk of the work for anything but a tiny file. Chunks are still
// measured, because an entry whose stored FileSize is zero takes its size from
// them, but no FileChunk is allocated.
//
// entry.Chunks is left nil. The one exception is a hard link, whose attributes
// and chunks are replaced wholesale by a full decode of its own record in
// FilerStoreWrapper.maybeReadHardLink straight after the store listing. Only a
// caller that reads attributes and nothing else may use this.
func (entry *Entry) DecodeAttributesOnly(blob []byte) error {
// The scratch buffer is only taken once a chunk is actually found, so an
// entry with none — every directory, for one — neither re-encodes nor
// touches the pool, and is unmarshalled where it lies.
var scratchPtr *[]byte
var scratch []byte
defer func() {
if scratchPtr != nil {
*scratchPtr = scratch
attributesScratchPool.Put(scratchPtr)
}
}()
var chunkExtent uint64
attributes := blob
for pos := 0; pos < len(blob); {
rest := blob[pos:]
num, typ, tagLen := protowire.ConsumeTag(rest)
if tagLen < 0 {
return fmt.Errorf("decoding value blob for %s: %w", entry.FullPath, protowire.ParseError(tagLen))
}
var valLen int
if num == entryChunksField && typ == protowire.BytesType {
chunk, n := protowire.ConsumeBytes(rest[tagLen:])
if n < 0 {
return fmt.Errorf("decoding value blob for %s: %w", entry.FullPath, protowire.ParseError(n))
}
valLen = n
end, err := chunkExtentEnd(chunk)
if err != nil {
return fmt.Errorf("decoding value blob for %s: %w", entry.FullPath, err)
}
if end > chunkExtent {
chunkExtent = end
}
if scratchPtr == nil {
scratchPtr = attributesScratchPool.Get().(*[]byte)
scratch = append((*scratchPtr)[:0], blob[:pos]...)
}
} else {
valLen = protowire.ConsumeFieldValue(num, typ, rest[tagLen:])
if valLen < 0 {
return fmt.Errorf("decoding value blob for %s: %w", entry.FullPath, protowire.ParseError(valLen))
}
if scratchPtr != nil {
scratch = append(scratch, rest[:tagLen+valLen]...)
}
}
pos += tagLen + valLen
}
if scratchPtr != nil {
attributes = scratch
}
message := pbEntryPool.Get().(*filer_pb.Entry)
defer func() {
resetPbEntry(message)
pbEntryPool.Put(message)
}()
if err := proto.Unmarshal(attributes, message); err != nil {
return fmt.Errorf("decoding value blob for %s: %v", entry.FullPath, err)
}
FromPbEntryToExistingEntry(message, entry)
// FromPbEntryToExistingEntry took the size over a chunk list that is not
// there, so fold in what the chunks actually reached. This is TotalSize.
if chunkExtent > entry.FileSize {
entry.FileSize = chunkExtent
}
return nil
}
// chunkExtentEnd reports where one encoded FileChunk ends, the offset plus size
// that TotalSize maximises over, without building the chunk. A chunk this
// rejects is one the full decoder rejects too, so a listing never reports a
// size for an entry that cannot be opened.
//
// A manifest chunk needs no special handling: it carries the offset and size of
// the whole range it stands for, and TotalSize does not resolve it either.
func chunkExtentEnd(chunk []byte) (uint64, error) {
var offset int64
var size uint64
for len(chunk) > 0 {
num, typ, tagLen := protowire.ConsumeTag(chunk)
if tagLen < 0 {
return 0, protowire.ParseError(tagLen)
}
chunk = chunk[tagLen:]
if typ == protowire.VarintType && (num == fileChunkOffsetField || num == fileChunkSizeField) {
v, n := protowire.ConsumeVarint(chunk)
if n < 0 {
return 0, protowire.ParseError(n)
}
if num == fileChunkOffsetField {
offset = int64(v)
} else {
size = v
}
chunk = chunk[n:]
continue
}
if typ == protowire.BytesType {
v, n := protowire.ConsumeBytes(chunk)
if n < 0 {
return 0, protowire.ParseError(n)
}
if fileChunkMessageFields[num] {
if err := validateMessage(v); err != nil {
return 0, err
}
} else if fileChunkStringFields[num] && !utf8.Valid(v) {
return 0, errors.New("invalid UTF-8 in string field")
}
chunk = chunk[n:]
continue
}
n := protowire.ConsumeFieldValue(num, typ, chunk)
if n < 0 {
return 0, protowire.ParseError(n)
}
chunk = chunk[n:]
}
return uint64(offset + int64(size)), nil
}
// validateMessage walks an encoded message to check it parses, which is all the
// generated unmarshaller would do for one whose fields are scalars.
func validateMessage(b []byte) error {
for len(b) > 0 {
num, typ, tagLen := protowire.ConsumeTag(b)
if tagLen < 0 {
return protowire.ParseError(tagLen)
}
valLen := protowire.ConsumeFieldValue(num, typ, b[tagLen:])
if valLen < 0 {
return protowire.ParseError(valLen)
}
b = b[tagLen+valLen:]
}
return nil
}