package storage // The ZFS caching tiers, for the ZFS tab: what the ARC has done since boot and how it is // tuned, and for each pool which cache devices it has — L2ARC, a separate log (SLOG), a // special allocation class, a dedup table — or, as is common, none of them, which the tab // should say plainly rather than leave a blank. // // The live numbers (size, the hit rate over the last interval) come from the sampler in // internal/metrics, which also publishes them as zfs.arc.* sensors; this is the slower, // fuller picture, read when the tab asks. import ( "context" "os" "os/exec" "strconv" "strings" "sync" "time" ) // CacheDev is one member of a pool's cache, log, special, dedup or spare class. type CacheDev struct { Name string `json:"name"` Size uint64 `json:"size,omitempty"` Alloc uint64 `json:"alloc,omitempty"` Health string `json:"health,omitempty"` } // PoolCaching is what one pool has beyond its data vdevs, and the properties that steer // what is cached. type PoolCaching struct { Pool string `json:"pool"` Cache []CacheDev `json:"cache"` // L2ARC Log []CacheDev `json:"log"` // SLOG Special []CacheDev `json:"special"` // metadata / small blocks Dedup []CacheDev `json:"dedup"` // dedup table vdevs Spare []CacheDev `json:"spare"` // dataset properties at the pool root PrimaryCache string `json:"primarycache"` SecondaryCache string `json:"secondarycache"` Sync string `json:"sync"` LogBias string `json:"logbias"` DedupProp string `json:"dedup_prop"` Compression string `json:"compression"` RecordSize string `json:"recordsize"` DedupRatio string `json:"dedup_ratio"` // zpool's, e.g. "1.00x" } // ARCDetail is the full picture. type ARCDetail struct { Available bool `json:"available"` MemTotal uint64 `json:"mem_total"` Tunables map[string]string `json:"tunables"` // zfs module parameters that shape the cache; "" when unset Lifetime struct { Hits, Misses uint64 `json:",omitempty"` DemandDataHits, DemandDataMisses uint64 DemandMetaHits, DemandMetaMisses uint64 PrefetchDataHits, PrefetchDataMisses uint64 PrefetchMetaHits, PrefetchMetaMisses uint64 L2Hits, L2Misses, L2ReadBytes, L2WriteBytes uint64 L2Feeds, L2RebuildSize uint64 EvictL2Eligible uint64 // bytes evicted from RAM that an L2ARC could have caught Deleted, EvictSkip, MutexMiss, Prune, Throttle uint64 } `json:"lifetime"` Zfetch struct{ Hits, Misses, IOIssued uint64 } `json:"zfetch"` // the prefetcher ZIL struct { Commits uint64 `json:"commits"` NormalBytes, NormalWrite uint64 // written to the pool's own vdevs SlogBytes, SlogWrite uint64 // written to a separate log device } `json:"zil"` DBuf struct{ Size, Target, Hits, Misses uint64 } `json:"dbuf"` // the dbuf cache in front of the ARC Pools []PoolCaching `json:"pools"` } const kstatDir = "/proc/spl/kstat/zfs/" func readKstat(p string) map[string]uint64 { b, err := os.ReadFile(p) if err != nil { return nil } out := map[string]uint64{} for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line) if len(f) != 3 || f[1] != "4" { continue } if v, err := strconv.ParseUint(f[2], 10, 64); err == nil { out[f[0]] = v } } return out } var arcTunables = []string{"zfs_arc_max", "zfs_arc_min", "zfs_arc_sys_free", "zfs_arc_meta_balance", "zfs_prefetch_disable", "l2arc_write_max", "l2arc_write_boost", "l2arc_noprefetch", "l2arc_rebuild_enabled", "zfs_dirty_data_max"} const poolCachingEvery = 30 * time.Second // zpool list -v and zfs get are an exec each // ARC reads the kstats now and the pool layout from a short cache. func (m *Manager) ARC(ctx context.Context) ARCDetail { d := ARCDetail{Tunables: map[string]string{}, Pools: []PoolCaching{}} k := readKstat(kstatDir + "arcstats") if k == nil { return d } d.Available = true l := &d.Lifetime l.Hits, l.Misses = k["hits"], k["misses"] l.DemandDataHits, l.DemandDataMisses = k["demand_data_hits"], k["demand_data_misses"] l.DemandMetaHits, l.DemandMetaMisses = k["demand_metadata_hits"], k["demand_metadata_misses"] l.PrefetchDataHits, l.PrefetchDataMisses = k["prefetch_data_hits"], k["prefetch_data_misses"] l.PrefetchMetaHits, l.PrefetchMetaMisses = k["prefetch_metadata_hits"], k["prefetch_metadata_misses"] l.L2Hits, l.L2Misses, l.L2ReadBytes, l.L2WriteBytes = k["l2_hits"], k["l2_misses"], k["l2_read_bytes"], k["l2_write_bytes"] l.L2Feeds, l.L2RebuildSize, l.EvictL2Eligible = k["l2_feeds"], k["l2_rebuild_size"], k["evict_l2_eligible"] l.Deleted, l.EvictSkip, l.MutexMiss, l.Prune, l.Throttle = k["deleted"], k["evict_skip"], k["mutex_miss"], k["arc_prune"], k["memory_throttle_count"] if z := readKstat(kstatDir + "zfetchstats"); z != nil { d.Zfetch.Hits, d.Zfetch.Misses, d.Zfetch.IOIssued = z["hits"], z["misses"], z["io_issued"] } if z := readKstat(kstatDir + "zil"); z != nil { d.ZIL.Commits = z["zil_commit_count"] d.ZIL.NormalBytes, d.ZIL.NormalWrite = z["zil_itx_metaslab_normal_bytes"], z["zil_itx_metaslab_normal_write"] d.ZIL.SlogBytes, d.ZIL.SlogWrite = z["zil_itx_metaslab_slog_bytes"], z["zil_itx_metaslab_slog_write"] } if z := readKstat(kstatDir + "dbufstats"); z != nil { d.DBuf.Size, d.DBuf.Target, d.DBuf.Hits, d.DBuf.Misses = z["cache_size_bytes"], z["cache_target_bytes"], z["hash_hits"], z["hash_misses"] } for _, t := range arcTunables { if b, err := os.ReadFile("/sys/module/zfs/parameters/" + t); err == nil { d.Tunables[t] = strings.TrimSpace(string(b)) } } if b, err := os.ReadFile("/proc/meminfo"); err == nil { for _, line := range strings.Split(string(b), "\n") { if strings.HasPrefix(line, "MemTotal:") { f := strings.Fields(line) if len(f) >= 2 { d.MemTotal = u64(f[1]) << 10 } break } } } d.Pools = m.poolCaching(ctx) return d } var cachingMu sync.Mutex var cachingAt time.Time var cachingLast []PoolCaching func (m *Manager) poolCaching(ctx context.Context) []PoolCaching { cachingMu.Lock() defer cachingMu.Unlock() if cachingLast != nil && time.Since(cachingAt) < poolCachingEvery { return cachingLast } out := []PoolCaching{} bin, ok := lookPath("zpool") if !ok { return out } // zpool list -v needs no root; the vdev tree under each pool, with the class headers raw, code, err := m.runPlain(ctx, bin, "list", "-Hpv", "-o", "name,size,alloc,free,health") if err != nil || code != 0 { return out } out = parsePoolCaching(string(raw)) if zfs, ok := lookPath("zfs"); ok { for i := range out { p := &out[i] if props, code, err := m.runPlain(ctx, zfs, "get", "-Hp", "-o", "property,value", "primarycache,secondarycache,sync,logbias,dedup,compression,recordsize", p.Pool); err == nil && code == 0 { for _, line := range strings.Split(strings.TrimSpace(string(props)), "\n") { f := strings.Split(line, "\t") if len(f) < 2 { continue } switch f[0] { case "primarycache": p.PrimaryCache = f[1] case "secondarycache": p.SecondaryCache = f[1] case "sync": p.Sync = f[1] case "logbias": p.LogBias = f[1] case "dedup": p.DedupProp = f[1] case "compression": p.Compression = f[1] case "recordsize": p.RecordSize = humanSize(u64(f[1])) } } } } } for _, pool := range parsePools(func() []byte { b, _, _ := m.runPlain(ctx, bin, "list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup") return b }()) { for i := range out { if out[i].Pool == pool.Name { out[i].DedupRatio = pool.Dedup } } } cachingLast, cachingAt = out, time.Now() return out } // runPlain runs a command as ourselves, never through sudo: these are read-only zpool/zfs // queries that need no root on Linux, and a poll must not write to the auth log. func (m *Manager) runPlain(ctx context.Context, bin string, args ...string) ([]byte, int, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() out, err := exec.CommandContext(ctx, bin, args...).Output() if ee, ok := err.(*exec.ExitError); ok { return out, ee.ExitCode(), nil } return out, 0, err } var cacheClasses = map[string]bool{"logs": true, "log": true, "cache": true, "special": true, "dedup": true, "spares": true, "spare": true} // parsePoolCaching reads `zpool list -Hpv -o name,size,alloc,free,health`: a pool line, then // its vdevs indented, with a bare class name ("logs", "cache", "special", "dedup", "spares") // introducing each extra class. Only those classes' members are kept; the data vdevs are the // pool and are shown elsewhere. func parsePoolCaching(text string) []PoolCaching { var out []PoolCaching var cur *PoolCaching class := "" for _, raw := range strings.Split(text, "\n") { if strings.TrimSpace(raw) == "" { continue } indented := strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") f := strings.Split(strings.TrimSpace(raw), "\t") name := strings.TrimSpace(f[0]) if !indented { out = append(out, PoolCaching{Pool: name, Cache: []CacheDev{}, Log: []CacheDev{}, Special: []CacheDev{}, Dedup: []CacheDev{}, Spare: []CacheDev{}}) cur, class = &out[len(out)-1], "" continue } if cur == nil { continue } if cacheClasses[name] && (len(f) < 2 || f[1] == "-" || f[1] == "") { class = name continue } if class == "" { continue // a data vdev or one of its disks } // a mirror or raidz inside a class lists its members below it; keep them all, the // grouping is visible in the vdev tree on the pool card dev := CacheDev{Name: name} if len(f) >= 5 { dev.Size, dev.Alloc, dev.Health = u64(f[1]), u64(f[2]), strings.TrimSpace(f[4]) if f[1] == "-" { dev.Size = 0 } } switch class { case "logs", "log": cur.Log = append(cur.Log, dev) case "cache": cur.Cache = append(cur.Cache, dev) case "special": cur.Special = append(cur.Special, dev) case "dedup": cur.Dedup = append(cur.Dedup, dev) case "spares", "spare": cur.Spare = append(cur.Spare, dev) } } if out == nil { out = []PoolCaching{} } return out } func humanSize(b uint64) string { switch { case b >= 1<<20 && b%(1<<20) == 0: return strconv.FormatUint(b>>20, 10) + "M" case b >= 1<<10 && b%(1<<10) == 0: return strconv.FormatUint(b>>10, 10) + "K" } return strconv.FormatUint(b, 10) }