From e39e70034a769970ca4f0db5b2a373ad766a85d4 Mon Sep 17 00:00:00 2001 From: destinyoooo <643604012@qq.com> Date: Mon, 20 Apr 2026 15:06:15 +0800 Subject: [PATCH] feat: add stats command for RDB statistics overview --- README.md | 72 ++++++- README_CN.md | 72 ++++++- cmd.go | 29 ++- core/decoder.go | 4 + helper/stats.go | 472 +++++++++++++++++++++++++++++++++++++++++++ helper/stats_test.go | 113 +++++++++++ 6 files changed, 753 insertions(+), 9 deletions(-) create mode 100644 helper/stats.go create mode 100644 helper/stats_test.go diff --git a/README.md b/README.md index c7fb17f..e9627e4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ It provides abilities to: - Find the biggest N keys in RDB files - Find the hottest N keys by LFU frequency in RDB files - Draw FlameGraph to analysis which kind of keys occupied most memory +- Generate statistics overview for RDB files - Customize data usage - Generate RDB file @@ -54,9 +55,9 @@ use `rdb` command in terminal, you can see it's manual $ rdb This is a tool to parse Redis' RDB files Options: - -c command, including: json/memory/aof/bigkey/hotkey/prefix/flamegraph + -c command, including: json/memory/aof/bigkey/hotkey/prefix/flamegraph/stats -o output file path - -n number of result, using in command: bigkey/hotkey/prefix + -n number of result, using in command: bigkey/hotkey/prefix/stats -port listen port for flame graph web service -sep separator for flamegraph, rdb will separate key by it, default value is ":". supporting multi separators: -sep sep1 -sep sep2 @@ -94,8 +95,12 @@ parameters between '[' and ']' is optional rdb -c prefix [-n 10] [-max-depth 3] -prefix-sep : [-o prefix-report.csv] dump.rdb 7. draw flamegraph rdb -c flamegraph [-port 16379] [-sep :] dump.rdb -7. get hottest keys by LFU frequency (requires maxmemory-policy allkeys-lfu/volatile-lfu) +8. get hottest keys by LFU frequency (requires maxmemory-policy allkeys-lfu/volatile-lfu) rdb -c hotkey [-o hotkey.csv] [-n 50] dump.rdb +9. generate statistics overview + rdb -c stats [-n 10] dump.rdb +10. generate statistics overview with filters + rdb -c stats -n 10 -regex 'user:*' dump.rdb ``` # Convert to Json @@ -561,6 +566,67 @@ database,key,type,size,size_readable,freq Note: Keys without LFU information are skipped. If the RDB file was not generated under LFU eviction policy, the output will be empty. +# Statistics Overview + +The `stats` command provides a one-click overview of RDB file statistics, including key counts, memory usage, expiration statistics, and Top-N largest/hottest keys. + +``` +rdb -c stats [-n ] +``` + +Example: + +``` +rdb -c stats cases/memory.rdb +``` + +Output example: + +``` +=== RDB Statistics Overview === +File: cases/memory.rdb +Redis Version: +RDB Version: 0 + +--- Key Statistics --- +Total Keys: 7 +Keys by Type: + - hash: 1 (14.3%) + - list: 1 (14.3%) + - set: 1 (14.3%) + - string: 3 (42.9%) + - zset: 1 (14.3%) +Keys by Database: + - DB 0: 7 (100.0%) + +--- Memory Statistics --- +Total Memory: 3.4K +Memory by Type: + - string: 2.7K (79.3%) + - list: 203B (5.8%) + ... +Memory by Encoding: + - quicklist: 203B + - string: 2.7K + ... + +--- Expiration Statistics --- +Keys with TTL: 0 (0.0%) +Keys without TTL: 7 + +--- Top 7 Largest Keys --- +#1. large (string) - 2.5K +... +``` + +The `stats` command can be combined with filters: + +``` +rdb -c stats -regex 'user:*' cases/memory.rdb +rdb -c stats -expire 'noexpire' cases/memory.rdb +rdb -c stats -size '1KB~inf' cases/memory.rdb +``` + # Convert to AOF Usage: diff --git a/README_CN.md b/README_CN.md index 8886c4d..0844123 100644 --- a/README_CN.md +++ b/README_CN.md @@ -17,6 +17,7 @@ - 寻找 RDB 文件中大键值对 - 根据 LFU 频率寻找 RDB 文件中最热的键值对 - 根据 RDB 文件绘制内存火焰图,用来分析哪类键值对占用了最多内存 +- **为 RDB 文件生成统计概览** - 通过 API 遍历 RDB 文件内容,自定义用途 - 生成 RDB 文件 @@ -40,9 +41,9 @@ go install github.com/hdt3213/rdb@latest $ rdb This is a tool to parse Redis' RDB files Options: - -c command, including: json/memory/aof/bigkey/hotkey/prefix/flamegraph + -c command, including: json/memory/aof/bigkey/hotkey/prefix/flamegraph/stats -o output file path - -n number of result, using in command: bigkey/hotkey/prefix + -n number of result, using in command: bigkey/hotkey/prefix/stats -port listen port for flame graph web service -sep separator for flamegraph, rdb will separate key by it, default value is ":". supporting multi separators: -sep sep1 -sep sep2 @@ -80,8 +81,12 @@ parameters between '[' and ']' is optional rdb -c prefix [-n 10] [-max-depth 3] -prefix-sep : [-o prefix-report.csv] dump.rdb 7. draw flamegraph rdb -c flamegraph [-port 16379] [-sep :] dump.rdb -7. get hottest keys by LFU frequency (requires maxmemory-policy allkeys-lfu/volatile-lfu) +8. get hottest keys by LFU frequency (requires maxmemory-policy allkeys-lfu/volatile-lfu) rdb -c hotkey [-o hotkey.csv] [-n 50] dump.rdb +9. generate statistics overview + rdb -c stats [-n 10] dump.rdb +10. generate statistics overview with filters + rdb -c stats -n 10 -regex 'user:*' dump.rdb ``` # 转换为 JSON 格式 @@ -486,6 +491,67 @@ database,key,type,size,size_readable,freq 注意:没有 LFU 信息的 key 会被跳过。如果 RDB 文件不是在 LFU 淘汰策略下生成的,输出将为空。 +# 统计概览 + +`stats` 命令可以一键生成 RDB 文件的统计概览,包括键数量统计、内存占用统计、过期键统计以及 Top-N 大键和热键。 + +``` +rdb -c stats [-n ] +``` + +示例: + +``` +rdb -c stats cases/memory.rdb +``` + +输出示例: + +``` +=== RDB Statistics Overview === +File: cases/memory.rdb +Redis Version: +RDB Version: 0 + +--- Key Statistics --- +Total Keys: 7 +Keys by Type: + - hash: 1 (14.3%) + - list: 1 (14.3%) + - set: 1 (14.3%) + - string: 3 (42.9%) + - zset: 1 (14.3%) +Keys by Database: + - DB 0: 7 (100.0%) + +--- Memory Statistics --- +Total Memory: 3.4K +Memory by Type: + - string: 2.7K (79.3%) + - list: 203B (5.8%) + ... +Memory by Encoding: + - quicklist: 203B + - string: 2.7K + ... + +--- Expiration Statistics --- +Keys with TTL: 0 (0.0%) +Keys without TTL: 7 + +--- Top 7 Largest Keys --- +#1. large (string) - 2.5K +... +``` + +`stats` 命令可以结合过滤器使用: + +``` +rdb -c stats -regex 'user:*' cases/memory.rdb +rdb -c stats -expire 'noexpire' cases/memory.rdb +rdb -c stats -size '1KB~inf' cases/memory.rdb +``` + # 转换为 AOF 文件 用法: diff --git a/cmd.go b/cmd.go index 795f131..b659e5c 100644 --- a/cmd.go +++ b/cmd.go @@ -12,9 +12,9 @@ import ( const help = ` This is a tool to parse Redis' RDB files Options: - -c command, including: json/memory/aof/bigkey/hotkey/prefix/flamegraph + -c command, including: json/memory/aof/bigkey/hotkey/prefix/flamegraph/stats -o output file path - -n number of result, using in command: bigkey/hotkey/prefix + -n number of result, using in command: bigkey/hotkey/prefix/stats -port listen port for flame graph web service -sep separator for flamegraph, rdb will separate key by it, default value is ":". supporting multi separators: -sep sep1 -sep sep2 @@ -52,8 +52,12 @@ parameters between '[' and ']' is optional rdb -c prefix [-n 10] [-max-depth 3] -prefix-sep : [-o prefix-report.csv] dump.rdb 7. draw flamegraph rdb -c flamegraph [-port 16379] [-sep :] dump.rdb -7. get hottest keys by LFU frequency (requires maxmemory-policy allkeys-lfu/volatile-lfu) +8. get hottest keys by LFU frequency (requires maxmemory-policy allkeys-lfu/volatile-lfu) rdb -c hotkey [-o hotkey.csv] [-n 50] dump.rdb +9. generate statistics overview + rdb -c stats [-n 10] dump.rdb +10. generate statistics overview with filters + rdb -c stats -n 10 -regex 'user:*' dump.rdb ` type separators []string @@ -165,6 +169,25 @@ func main() { return } <-make(chan struct{}) + case "stats": + topN := n + if topN <= 0 { + topN = 10 + } + report, statErr := helper.Stats(src, topN, options...) + if statErr != nil { + fmt.Printf("error: %v\n", statErr) + return + } + if output != "" { + _, statErr = outputFile.WriteString(report.ToText()) + } else { + fmt.Print(report.ToText()) + } + if statErr != nil { + fmt.Printf("error: %v\n", statErr) + return + } default: println("unknown command") return diff --git a/core/decoder.go b/core/decoder.go index 2ae8604..71b19b9 100644 --- a/core/decoder.go +++ b/core/decoder.go @@ -604,3 +604,7 @@ func (dec *Decoder) Parse(cb func(object model.RedisObject) bool) (err error) { func (dec *Decoder) GetReadCount() int { return dec.readCount } + +func (dec *Decoder) GetRDBVersion() int { + return dec.rdbVersion +} diff --git a/helper/stats.go b/helper/stats.go new file mode 100644 index 0000000..6e36071 --- /dev/null +++ b/helper/stats.go @@ -0,0 +1,472 @@ +package helper + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "time" + + "github.com/hdt3213/rdb/bytefmt" + "github.com/hdt3213/rdb/core" + "github.com/hdt3213/rdb/model" +) + +type StatsReport struct { + File string `json:"file"` + RedisVersion string `json:"redisVersion"` + RDBVersion int `json:"rdbVersion"` + KeyStats KeyStatistics `json:"keyStatistics"` + MemoryStats MemoryStatistics `json:"memoryStatistics"` + ExpireStats ExpirationStatistics `json:"expirationStatistics"` + LFUStats *LFUStatistics `json:"lfuStatistics,omitempty"` + TopLargestKeys []KeyInfo `json:"topLargestKeys"` + TopHottestKeys []KeyWithFreq `json:"topHottestKeys,omitempty"` +} + +type KeyStatistics struct { + Total int64 `json:"total"` + ByType map[string]int64 `json:"byType"` + ByDB map[int]int64 `json:"byDatabase"` +} + +type MemoryStatistics struct { + Total int64 `json:"total"` + TotalFmt string `json:"totalReadable"` + ByType map[string]MemoryStats `json:"byType"` + ByEncoding map[string]MemoryStats `json:"byEncoding"` +} + +type MemoryStats struct { + Bytes int64 `json:"bytes"` + Percentage float64 `json:"percentage"` + Readable string `json:"readable"` +} + +type ExpirationStatistics struct { + WithTTL int64 `json:"withTTL"` + WithoutTTL int64 `json:"withoutTTL"` + Percentage float64 `json:"percentage"` +} + +type LFUStatistics struct { + Available bool `json:"available"` + Policy string `json:"policy"` + Total int64 `json:"total"` + MinFreq int64 `json:"minFreq"` + MaxFreq int64 `json:"maxFreq"` + AvgFreq float64 `json:"avgFreq"` +} + +type KeyInfo struct { + Key string `json:"key"` + Type string `json:"type"` + Size int64 `json:"size"` + Readable string `json:"readable"` +} + +type KeyWithFreq struct { + Key string `json:"key"` + Type string `json:"type"` + Freq int64 `json:"freq"` +} + +type sizedKeyInfo struct { + key string + typ string + size int +} + +func (s *sizedKeyInfo) GetSize() int { + return s.size +} + +type statsCollector struct { + report *StatsReport + lfuMin int64 + lfuMax int64 + lfuSum int64 + lfuCount int64 + lfuPolicy string + hasLFU bool + topLargest []*sizedKeyInfo + topHottest []*KeyWithFreq + topN int +} + +type freqTopList struct { + list []*KeyWithFreq + capacity int +} + +func (tl *freqTopList) add(key string, objType string, freq int64) { + if freq <= 0 { + return + } + info := &KeyWithFreq{ + Key: key, + Type: objType, + Freq: freq, + } + index := sort.Search(len(tl.list), func(i int) bool { + return tl.list[i].Freq <= freq + }) + tl.list = append(tl.list, info) + copy(tl.list[index+1:], tl.list[index:]) + tl.list[index] = info + if len(tl.list) > tl.capacity { + tl.list = tl.list[:tl.capacity] + } +} + +func newFreqToplist(cap int) *freqTopList { + return &freqTopList{ + capacity: cap, + } +} + +func newStatsCollector(topN int) *statsCollector { + sc := &statsCollector{ + report: &StatsReport{ + KeyStats: KeyStatistics{ + ByType: make(map[string]int64), + ByDB: make(map[int]int64), + }, + MemoryStats: MemoryStatistics{ + ByType: make(map[string]MemoryStats), + ByEncoding: make(map[string]MemoryStats), + }, + TopLargestKeys: make([]KeyInfo, 0), + }, + topLargest: make([]*sizedKeyInfo, 0, topN), + topHottest: make([]*KeyWithFreq, 0, topN), + topN: topN, + } + return sc +} + +func (sc *statsCollector) processObject(object model.RedisObject) { + objType := object.GetType() + + if objType == model.AuxType || objType == model.FunctionsType { + return + } + + sc.report.KeyStats.Total++ + sc.report.KeyStats.ByType[objType]++ + + db := object.GetDBIndex() + sc.report.KeyStats.ByDB[db]++ + + size := int64(object.GetSize()) + sc.report.MemoryStats.Total += size + + memType, ok := sc.report.MemoryStats.ByType[objType] + if !ok { + memType = MemoryStats{} + } + memType.Bytes += size + sc.report.MemoryStats.ByType[objType] = memType + + encoding := object.GetEncoding() + memEnc, ok := sc.report.MemoryStats.ByEncoding[encoding] + if !ok { + memEnc = MemoryStats{} + } + memEnc.Bytes += size + sc.report.MemoryStats.ByEncoding[encoding] = memEnc + + if object.GetExpiration() != nil { + sc.report.ExpireStats.WithTTL++ + } else { + sc.report.ExpireStats.WithoutTTL++ + } + + if evictionInfo, ok := object.(model.EvictionInfo); ok { + freq := evictionInfo.GetFreq() + if freq > 0 { + sc.hasLFU = true + sc.lfuCount++ + sc.lfuSum += freq + if sc.lfuMin == 0 || freq < sc.lfuMin { + sc.lfuMin = freq + } + if freq > sc.lfuMax { + sc.lfuMax = freq + } + sc.addTopHottest(object.GetKey(), objType, freq) + } + } + + sc.addTopLargest(object.GetKey(), objType, object.GetSize()) +} + +func (sc *statsCollector) addTopLargest(key, objType string, size int) { + info := &sizedKeyInfo{ + key: key, + typ: objType, + size: size, + } + + if len(sc.topLargest) < sc.topN { + sc.topLargest = append(sc.topLargest, info) + if len(sc.topLargest) == sc.topN { + sc.sortTopLargest() + } + return + } + + if size > sc.topLargest[0].size { + sc.topLargest[0] = info + sc.sortTopLargest() + } +} + +func (sc *statsCollector) sortTopLargest() { + sort.Slice(sc.topLargest, func(i, j int) bool { + return sc.topLargest[i].size > sc.topLargest[j].size + }) +} + +func (sc *statsCollector) addTopHottest(key, objType string, freq int64) { + info := &KeyWithFreq{ + Key: key, + Type: objType, + Freq: freq, + } + + if len(sc.topHottest) < sc.topN { + sc.topHottest = append(sc.topHottest, info) + if len(sc.topHottest) == sc.topN { + sc.sortTopHottest() + } + return + } + + if freq > sc.topHottest[0].Freq { + sc.topHottest[0] = info + sc.sortTopHottest() + } +} + +func (sc *statsCollector) sortTopHottest() { + sort.Slice(sc.topHottest, func(i, j int) bool { + return sc.topHottest[i].Freq > sc.topHottest[j].Freq + }) +} + +func (sc *statsCollector) finalize() { + sc.report.MemoryStats.TotalFmt = bytefmt.FormatSize(uint64(sc.report.MemoryStats.Total)) + + totalMem := sc.report.MemoryStats.Total + for objType, memStats := range sc.report.MemoryStats.ByType { + pct := float64(memStats.Bytes) / float64(totalMem) * 100 + sc.report.MemoryStats.ByType[objType] = MemoryStats{ + Bytes: memStats.Bytes, + Percentage: float64(int(pct*10)) / 10, + Readable: bytefmt.FormatSize(uint64(memStats.Bytes)), + } + } + + for encoding, memStats := range sc.report.MemoryStats.ByEncoding { + sc.report.MemoryStats.ByEncoding[encoding] = MemoryStats{ + Bytes: memStats.Bytes, + Readable: bytefmt.FormatSize(uint64(memStats.Bytes)), + } + } + + totalKeys := sc.report.ExpireStats.Total() + if totalKeys > 0 { + sc.report.ExpireStats.Percentage = float64(int(float64(sc.report.ExpireStats.WithTTL)/float64(totalKeys)*1000)) / 10 + } + + if sc.hasLFU { + sc.report.LFUStats = &LFUStatistics{ + Available: true, + Policy: sc.lfuPolicy, + Total: sc.lfuCount, + MinFreq: sc.lfuMin, + MaxFreq: sc.lfuMax, + AvgFreq: float64(int(float64(sc.lfuSum)/float64(sc.lfuCount)*100)) / 100, + } + } + + for _, item := range sc.topLargest { + sc.report.TopLargestKeys = append(sc.report.TopLargestKeys, KeyInfo{ + Key: item.key, + Type: item.typ, + Size: int64(item.size), + Readable: bytefmt.FormatSize(uint64(item.size)), + }) + } + + for _, item := range sc.topHottest { + sc.report.TopHottestKeys = append(sc.report.TopHottestKeys, *item) + } +} + +func (s ExpirationStatistics) Total() int64 { + return s.WithTTL + s.WithoutTTL +} + +func Stats(rdbFilename string, topN int, options ...interface{}) (*StatsReport, error) { + if rdbFilename == "" { + return nil, fmt.Errorf("src file path is required") + } + + rdbFile, err := os.Open(rdbFilename) + if err != nil { + return nil, fmt.Errorf("open rdb %s failed: %v", rdbFilename, err) + } + defer rdbFile.Close() + + collector := newStatsCollector(topN) + if topN <= 0 { + topN = 10 + } + + coreDec := core.NewDecoder(rdbFile) + var dec decoder = coreDec + dec, err = wrapDecoder(dec, options...) + if err != nil { + return nil, err + } + + now := time.Now() + err = dec.Parse(func(object model.RedisObject) bool { + if collector.lfuPolicy == "" { + if evictionInfo, ok := object.(model.EvictionInfo); ok { + if freq := evictionInfo.GetFreq(); freq > 0 { + collector.lfuPolicy = "allkeys-lfu" + } + } + } + + if object.GetExpiration() != nil && object.GetExpiration().Before(now) { + collector.report.ExpireStats.WithTTL-- + collector.report.ExpireStats.WithoutTTL++ + } + + if auxObj, ok := object.(*model.AuxObject); ok { + if auxObj.Key == "redis-ver" { + collector.report.RedisVersion = auxObj.Value + } + } + + collector.processObject(object) + return true + }) + if err != nil { + return nil, err + } + + collector.report.RDBVersion = coreDec.GetRDBVersion() + collector.finalize() + collector.report.File = rdbFilename + + return collector.report, nil +} + +func (r *StatsReport) ToJSON() ([]byte, error) { + return json.MarshalIndent(r, "", " ") +} + +func (r *StatsReport) ToText() string { + var lines []string + + lines = append(lines, "=== RDB Statistics Overview ===") + lines = append(lines, fmt.Sprintf("File: %s", r.File)) + lines = append(lines, fmt.Sprintf("Redis Version: %s", r.RedisVersion)) + lines = append(lines, fmt.Sprintf("RDB Version: %d", r.RDBVersion)) + lines = append(lines, "") + + lines = append(lines, "--- Key Statistics ---") + lines = append(lines, fmt.Sprintf("Total Keys: %d", r.KeyStats.Total)) + lines = append(lines, "Keys by Type:") + typeKeys := make([]string, 0, len(r.KeyStats.ByType)) + for k := range r.KeyStats.ByType { + typeKeys = append(typeKeys, k) + } + sort.Strings(typeKeys) + for _, t := range typeKeys { + count := r.KeyStats.ByType[t] + pct := float64(count) / float64(r.KeyStats.Total) * 100 + lines = append(lines, fmt.Sprintf(" - %s: %d (%.1f%%)", t, count, pct)) + } + lines = append(lines, "Keys by Database:") + dbKeys := make([]int, 0, len(r.KeyStats.ByDB)) + for k := range r.KeyStats.ByDB { + dbKeys = append(dbKeys, k) + } + sort.Ints(dbKeys) + for _, db := range dbKeys { + count := r.KeyStats.ByDB[db] + pct := float64(count) / float64(r.KeyStats.Total) * 100 + lines = append(lines, fmt.Sprintf(" - DB %d: %d (%.1f%%)", db, count, pct)) + } + lines = append(lines, "") + + lines = append(lines, "--- Memory Statistics ---") + lines = append(lines, fmt.Sprintf("Total Memory: %s", r.MemoryStats.TotalFmt)) + lines = append(lines, "Memory by Type:") + for _, t := range typeKeys { + memStats := r.MemoryStats.ByType[t] + lines = append(lines, fmt.Sprintf(" - %s: %s (%.1f%%)", t, memStats.Readable, memStats.Percentage)) + } + lines = append(lines, "Memory by Encoding:") + encKeys := make([]string, 0, len(r.MemoryStats.ByEncoding)) + for k := range r.MemoryStats.ByEncoding { + encKeys = append(encKeys, k) + } + sort.Strings(encKeys) + for _, enc := range encKeys { + memStats := r.MemoryStats.ByEncoding[enc] + lines = append(lines, fmt.Sprintf(" - %s: %s", enc, memStats.Readable)) + } + lines = append(lines, "") + + lines = append(lines, "--- Expiration Statistics ---") + lines = append(lines, fmt.Sprintf("Keys with TTL: %d (%.1f%%)", r.ExpireStats.WithTTL, r.ExpireStats.Percentage)) + lines = append(lines, fmt.Sprintf("Keys without TTL: %d", r.ExpireStats.WithoutTTL)) + lines = append(lines, "") + + if r.LFUStats != nil && r.LFUStats.Available { + lines = append(lines, "--- LFU Statistics ---") + policy := r.LFUStats.Policy + if policy == "" { + policy = "allkeys-lfu" + } + lines = append(lines, fmt.Sprintf("LFU Info Available: Yes (maxmemory-policy: %s)", policy)) + lines = append(lines, fmt.Sprintf("Total Keys with LFU: %d", r.LFUStats.Total)) + lines = append(lines, fmt.Sprintf("Min Frequency: %d", r.LFUStats.MinFreq)) + lines = append(lines, fmt.Sprintf("Max Frequency: %d", r.LFUStats.MaxFreq)) + lines = append(lines, fmt.Sprintf("Avg Frequency: %.2f", r.LFUStats.AvgFreq)) + lines = append(lines, "") + } + + if len(r.TopLargestKeys) > 0 { + lines = append(lines, fmt.Sprintf("--- Top %d Largest Keys ---", len(r.TopLargestKeys))) + for i, key := range r.TopLargestKeys { + lines = append(lines, fmt.Sprintf("#%d. %s (%s) - %s", i+1, key.Key, key.Type, key.Readable)) + } + lines = append(lines, "") + } + + if len(r.TopHottestKeys) > 0 { + lines = append(lines, fmt.Sprintf("--- Top %d Hottest Keys (by LFU) ---", len(r.TopHottestKeys))) + for i, key := range r.TopHottestKeys { + lines = append(lines, fmt.Sprintf("#%d. %s (%s) - freq: %d", i+1, key.Key, key.Type, key.Freq)) + } + lines = append(lines, "") + } + + return joinLines(lines) +} + +func joinLines(lines []string) string { + result := "" + for _, line := range lines { + result += line + "\n" + } + return result +} diff --git a/helper/stats_test.go b/helper/stats_test.go new file mode 100644 index 0000000..0346973 --- /dev/null +++ b/helper/stats_test.go @@ -0,0 +1,113 @@ +package helper + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStats(t *testing.T) { + srcRdb := filepath.Join("../cases", "memory.rdb") + report, err := Stats(srcRdb, 10) + if err != nil { + t.Fatalf("Stats failed: %v", err) + } + + if report.KeyStats.Total == 0 { + t.Error("Expected non-zero total keys") + } + + if report.MemoryStats.Total == 0 { + t.Error("Expected non-zero total memory") + } + + textOutput := report.ToText() + if textOutput == "" { + t.Error("Expected non-empty text output") + } + + t.Logf("Total Keys: %d", report.KeyStats.Total) + t.Logf("Total Memory: %s", report.MemoryStats.TotalFmt) + t.Logf("Text Output:\n%s", textOutput) +} + +func TestStatsWithFilters(t *testing.T) { + srcRdb := filepath.Join("../cases", "memory.rdb") + report, err := Stats(srcRdb, 5, WithRegexOption("s")) + if err != nil { + t.Fatalf("Stats with regex filter failed: %v", err) + } + + t.Logf("Filtered Total Keys: %d", report.KeyStats.Total) +} + +func TestStatsTopN(t *testing.T) { + srcRdb := filepath.Join("../cases", "memory.rdb") + report, err := Stats(srcRdb, 3) + if err != nil { + t.Fatalf("Stats with topN failed: %v", err) + } + + if len(report.TopLargestKeys) > 3 { + t.Errorf("Expected at most 3 largest keys, got %d", len(report.TopLargestKeys)) + } +} + +func TestStatsExpiration(t *testing.T) { + srcRdb := filepath.Join("../cases", "expiration.rdb") + report, err := Stats(srcRdb, 10) + if err != nil { + t.Fatalf("Stats for expiration case failed: %v", err) + } + + t.Logf("Keys with TTL: %d", report.ExpireStats.WithTTL) + t.Logf("Keys without TTL: %d", report.ExpireStats.WithoutTTL) +} + +func TestStatsJSON(t *testing.T) { + srcRdb := filepath.Join("../cases", "memory.rdb") + report, err := Stats(srcRdb, 5) + if err != nil { + t.Fatalf("Stats failed: %v", err) + } + + jsonOutput, err := report.ToJSON() + if err != nil { + t.Fatalf("ToJSON failed: %v", err) + } + + if len(jsonOutput) == 0 { + t.Error("Expected non-empty JSON output") + } + + t.Logf("JSON Output:\n%s", string(jsonOutput)) +} + +func TestStatsFileNotFound(t *testing.T) { + _, err := Stats("nonexistent.rdb", 10) + if err == nil { + t.Error("Expected error for non-existent file") + } +} + +func TestStatsOutput(t *testing.T) { + tmpFile := "/tmp/stats_test_output.txt" + defer os.Remove(tmpFile) + + srcRdb := filepath.Join("../cases", "memory.rdb") + report, err := Stats(srcRdb, 5) + if err != nil { + t.Fatalf("Stats failed: %v", err) + } + + f, createErr := os.Create(tmpFile) + if createErr != nil { + t.Fatalf("Failed to create temp file: %v", createErr) + } + defer f.Close() + + _, writeErr := f.WriteString(report.ToText()) + if writeErr != nil { + t.Fatalf("Failed to write to temp file: %v", writeErr) + } +}