mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-25 16:47:28 +00:00
design: key the mount's inode table on parent and name
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# Design: Keying the mount's inode table on parent and name
|
||||
|
||||
Issue #10020 — a mount over 32.7M files sits at 2.1GB RSS after the readdir fixes.
|
||||
Its heap profile is 94% one structure: `InodeToPath`.
|
||||
|
||||
## Where the bytes are
|
||||
|
||||
Measured by populating the table with 1M children and reading `HeapAlloc` around
|
||||
it, after the two cheap wins already landed:
|
||||
|
||||
221.5 B/inode at 85-character paths
|
||||
285.6 B/inode at 148-character paths
|
||||
|
||||
so roughly **125 B fixed plus the full path rounded to a Go size class**. The
|
||||
fixed part is `InodeEntry` (32) and the two maps (~93). Reading the reporter's
|
||||
profile against that model — `Lookup` flat / 125 — gives ~5.6M live inodes at
|
||||
~200-character paths, ~350 B each, which is the 2.0GB.
|
||||
|
||||
The path is the larger half and it grows with directory depth. It is stored once
|
||||
and referenced twice: as the `path2inode` key and as `InodeEntry.path`.
|
||||
|
||||
## Shape
|
||||
|
||||
Directories keep a materialized full path. Files keep only their parent's inode
|
||||
and their own name. There are orders of magnitude fewer directories than files,
|
||||
so the full paths that remain are a rounding error, and no operation has to walk
|
||||
a parent chain: a file's path is one concat off its parent's.
|
||||
|
||||
type childKey struct {
|
||||
parent uint64
|
||||
name string
|
||||
}
|
||||
|
||||
type InodeEntry struct { // 32 bytes, one object, no allocation of its own
|
||||
parent uint64
|
||||
name string
|
||||
nlookup uint64
|
||||
}
|
||||
|
||||
inodes map[uint64]*InodeEntry // every inode
|
||||
children map[childKey]uint64 // (parent, name) -> inode
|
||||
dirs map[uint64]*dirState // directories only; carries the path
|
||||
dirPaths map[util.FullPath]uint64 // directories only
|
||||
extraLinks map[uint64][]childKey // hard-linked inodes only
|
||||
|
||||
`InodeEntry.name` shares its bytes with the `children` key, exactly as the path
|
||||
does with `path2inode` today.
|
||||
|
||||
The name has to be copied on the way in. Every name the mount has to hand is a
|
||||
slice of something longer — `FullPath.Name()` slices the full path, and a
|
||||
readdir's `dirEntry.Name` slices the listed entry's — and a Go string slice
|
||||
keeps its whole backing array alive. Storing one as-is would pin the ~200-byte
|
||||
path it came from per inode and leave the table costing what it costs today.
|
||||
`strings.Clone` at the point of storage; it is one small allocation per new
|
||||
inode, which is what building the child path costs already.
|
||||
|
||||
Per inode: entry 32, name bytes ~32, `children` ~51, `inodes` ~26 — about
|
||||
**141 B, independent of path length**, against ~350 at the reporter's depth. At
|
||||
5.6M inodes that is 2.0GB down to 0.8GB.
|
||||
|
||||
## What each operation becomes
|
||||
|
||||
`GetPath(inode)` — root is 1; a directory returns `dirs[inode].path`; anything
|
||||
else is `dirs[e.parent].path.Child(e.name)`. O(1), one allocation. Today it
|
||||
returns an existing string and allocates nothing, so this is the trade: one
|
||||
~200-byte allocation on an op that is already about to talk to a filer. It is
|
||||
per FUSE op, not per readdir entry.
|
||||
|
||||
`GetInode(path)` — `dirPaths[path]`, else split and hit `children`. Two map
|
||||
lookups, no walk. It keeps taking a path, so its thirteen callers are untouched
|
||||
by any of this; only its body changes.
|
||||
|
||||
`Lookup` — the signature changes from a full path to `(parentInode, name)`. All
|
||||
nine call sites already hold the parent nodeid; the readdir loop stops building
|
||||
a child path per entry entirely. Only the key shape changes: reusing the inode
|
||||
already at that key, honouring `possibleInode`, probing past a collision for
|
||||
anything that is not a hard link, and counting `nlookup` only when `isLookup`
|
||||
all have to survive intact, and want tests of their own before the switch.
|
||||
|
||||
The directory-state methods (`InvalidateChildrenCache`, `TouchDirectory`,
|
||||
`MarkChildrenCached`, `AdjustSubdirCount`, ~30 call sites between them) resolve a
|
||||
directory by path and stay exactly as they are, against `dirPaths`.
|
||||
|
||||
`Forget` — unchanged in how it counts: it subtracts the request's count from
|
||||
`nlookup`, keeps every mapping while that stays above zero, and clamps a kernel
|
||||
over-decrement the way it does today. At zero it drops every one of the inode's
|
||||
child keys, not just the primary:
|
||||
`children[{parent,name}]` plus each key in `extraLinks`, then `inodes`, and for
|
||||
a directory `dirs` and `dirPaths`. Unlinking one link of a hard-linked file
|
||||
promotes a surviving key into `InodeEntry`, the same shape `removeOnePath` has
|
||||
today, so `GetPath` never answers with a name that is gone.
|
||||
|
||||
A directory has to outlive its children, since that is what resolves them.
|
||||
`dirState` carries a child count, incremented as `children` gains a key under
|
||||
that parent and decremented as one goes; `Forget` releases a directory only at
|
||||
nlookup 0 **and** count 0, and re-checks on the last child's release. Linux
|
||||
evicts dentries bottom-up so this should never fire, which is the reason to
|
||||
assert it rather than assume it.
|
||||
|
||||
## What it does to rename
|
||||
|
||||
Renaming a directory today is repaired entry by entry. The filer moves every
|
||||
descendant and emits an event for each, and the mount turns each one into a
|
||||
`MovePath`, because every cached path under the directory holds the old prefix
|
||||
as literal bytes. A rename of a directory with a million cached children is a
|
||||
million table mutations, and the table is only correct as long as every one of
|
||||
those events arrives and is applied.
|
||||
|
||||
Keyed on the parent, files under a renamed directory need nothing at all — their
|
||||
key never mentioned the prefix. Only descendant *directories* have to have their
|
||||
materialized path re-prefixed, and there are few of those.
|
||||
|
||||
`MovePath` today moves the renamed entry and nothing under it, which is visible
|
||||
directly against the table (this is current behavior, not the target):
|
||||
|
||||
itp := NewInodeToPath("/", 0)
|
||||
dirInode := itp.Lookup("/a", now, true, false, 0, true)
|
||||
childInode := itp.Lookup("/a/f.txt", now, false, false, 0, true)
|
||||
itp.MovePath("/a", "/b")
|
||||
itp.GetPath(childInode) // "/a/f.txt"
|
||||
|
||||
The per-descendant events are what keep that from being reachable in practice.
|
||||
|
||||
## Phases
|
||||
|
||||
Each is its own commit, and the table's behavior is unchanged until the second.
|
||||
|
||||
1. Materialize the directory path in `dirState`, add `dirPaths`, serve directory
|
||||
`GetPath` from it. Establishes the invariant and its tests with the file half
|
||||
untouched.
|
||||
2. `InodeEntry` becomes `(parent, name)` and `extraLinks` arrives with it;
|
||||
`path2inode` becomes `children`; `GetPath` and `GetInode` take their new
|
||||
form, and `MovePath` re-prefixes the materialized path of every descendant
|
||||
directory, with a regression test that walks a nested tree through both a
|
||||
local and a subscription-driven rename. The load-bearing commit. A
|
||||
hard-linked file has to keep every one of its names from this commit on, not
|
||||
from phase 4 — a phase that is individually correct cannot park them
|
||||
somewhere in between.
|
||||
3. Convert `Lookup` and its nine callers to `(parentInode, name)`. It keeps its
|
||||
path signature through phase 2, so that commit does not have to move both
|
||||
halves at once.
|
||||
4. Tighten `InodeEntry` back to the 32-byte size class and assert it.
|
||||
5. Shed entries the mount no longer wants to hold. See below.
|
||||
|
||||
## Bounding the table
|
||||
|
||||
Everything above lowers the constant. Growth is still unbounded, because the
|
||||
only thing that ever removes an entry is a kernel FORGET, which arrives when the
|
||||
kernel evicts the dentry — under dcache pressure, on unlink, or at unmount. A
|
||||
machine with memory to spare never sends one.
|
||||
|
||||
Two halves to that, and only one is ours to refuse. A readdirplus reference is
|
||||
speculative: the mount hands the kernel attributes for every name it lists,
|
||||
whether or not the client ever looks at them. That one can be declined by
|
||||
leaving the `EntryOut` zeroed — Linux reads a zero nodeid in a READDIRPLUS reply
|
||||
as "no attributes for this entry" (`fuse_direntplus_link`) and takes no
|
||||
reference. A LOOKUP reference cannot be declined: a zero nodeid there means
|
||||
ENOENT, which would be a lie about the file existing.
|
||||
|
||||
So a cap on its own only stops the mount paying for attributes nobody asked for,
|
||||
and never shrinks anything. Actually shedding entries means asking the kernel to
|
||||
drop the dentry with `EntryNotify(parent, name)`, from the invalidation worker
|
||||
and never from a request handler — the deadlock `invalidateKernelDirListing`
|
||||
already documents. That call wants exactly the pair phase 2 makes the table's
|
||||
key, which is why this comes last: an eviction sweep is cheap to write against
|
||||
`children` and awkward against a map of full paths. It names one dentry, so a
|
||||
hard-linked inode needs one call per name — the primary and every key in
|
||||
`extraLinks` — before the entry goes, or the kernel keeps the aliases it was
|
||||
never told about.
|
||||
|
||||
## Risks
|
||||
|
||||
A child must never outlive its parent in the table, or its path is
|
||||
unresolvable. Linux evicts dentries bottom-up so FORGET arrives child-first, but
|
||||
the table should refuse to release a directory that still has children rather
|
||||
than trust it.
|
||||
|
||||
Every rename path has to keep `dirs`/`dirPaths` in step, including the ones that
|
||||
arrive from the metadata subscription rather than from a local syscall.
|
||||
|
||||
A rename this mount never sees — a subscription gap across a reconnect — strands
|
||||
a stale path either way. Fewer entries hold one, since a file's key never
|
||||
mentioned the prefix, but that buys nothing at resolution time: a file resolves
|
||||
through `dirs[e.parent].path`, so one stale directory path is enough to make
|
||||
every file under it resolve stale too. The exposure is the same shape as today's
|
||||
and reconciling the table against the filer after a gap is separate work.
|
||||
Reference in New Issue
Block a user