diff --git a/e2e_test.go b/e2e_test.go index 7803cf8..75638a4 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -353,3 +353,28 @@ func TestE2E_GetMulti_missing(t *testing.T) { must.Eq(t, &Pair[int, error]{A: 3, B: nil}, results[2]) must.ErrorIs(t, ErrCacheMiss, results[1].B) } + +func TestE2E_Stats(t *testing.T) { + t.Parallel() + + address, done := memctest.LaunchTCP(t, nil) + t.Cleanup(done) + + c := New([]string{address}) + defer ignore.Close(c) + + // insert an item + err := Set(c, "mykey", "myvalue", TTL(1*time.Hour)) + must.NoError(t, err) + + s, serr := Stats(c) + must.NoError(t, serr) + + // spot check a few fields + must.StrHasPrefix(t, "1.", s.Runtime.Version) + must.Positive(t, s.Runtime.Threads) + must.Positive(t, s.Connections.Max) + must.Positive(t, s.Connections.Current) + must.One(t, s.Items.Current) + must.Eq(t, 71, s.Items.Bytes) +} diff --git a/stats.go b/stats.go new file mode 100644 index 0000000..5cc9a21 --- /dev/null +++ b/stats.go @@ -0,0 +1,169 @@ +// Copyright CattleCloud LLC 2025, 2026 +// SPDX-License-Identifier: BSD-3-Clause + +package memc + +import ( + "bufio" + "io" + "strconv" + "strings" +) + +type Statistics struct { + Runtime struct { + PID int `json:"pid"` + Uptime int `json:"uptime"` + Time int `json:"time"` + Version string `json:"version"` + LibEvent string `json:"libevent"` + PointerSize int `json:"pointer_size"` + Threads int `json:"threads"` + } + + Resources struct { + RUsageUser float64 `json:"rusage_user"` + RUsageSystem float64 `json:"rusage_system"` + } + + Connections struct { + Max int `json:"max_connections"` + Current int `json:"curr_connections"` + Total int `json:"total_connections"` + Rejected int `json:"rejected_connections"` + Structures int `json:"connection_structures"` + } + + Commands struct { + Get int `json:"cmd_get"` + Set int `json:"cmd_set"` + Flush int `json:"cmd_flush"` + Touch int `json:"cmd_touch"` + Meta int `json:"cmd_meta"` + + Hit struct { + Get int `json:"get_hits"` + Delete int `json:"delete_hits"` + Increment int `json:"incr_hits"` + Decrement int `json:"decr_hits"` + Touch int `json:"touch_hits"` + CAS int `json:"cas_hits"` + } + + Miss struct { + Get int `json:"get_misses"` + Delete int `json:"delete_misses"` + Increment int `json:"incr_misses"` + Decrement int `json:"decr_misses"` + Touch int `json:"touch_misses"` + CAS int `json:"cas_misses"` + } + + Failure struct { + GetExpired int `json:"get_expired"` + GetFlushed int `json:"get_flushed"` + CASBadValue int `json:"cas_badval"` + } + } + + Items struct { + Bytes int `json:"bytes"` + Current int `json:"curr_items"` + Total int `json:"total_items"` + } +} + +func stats(r io.Reader) (*Statistics, error) { + scanner := bufio.NewScanner(r) + m := make(map[string]string) + + // parse the contents of the stats output line by line + for scanner.Scan() { + line := scanner.Text() + if line == "END" { + break + } + + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] != "STAT" { + continue + } + + // skip fields[0], is STAT + key := fields[1] + value := fields[2] + m[key] = value + } + + // make sure the scanner was successful + if err := scanner.Err(); err != nil { + return nil, err + } + + s := new(Statistics) + + // map Runtime + s.Runtime.PID = toInt(m["pid"]) + s.Runtime.Uptime = toInt(m["uptime"]) + s.Runtime.Time = toInt(m["time"]) + s.Runtime.Version = m["version"] + s.Runtime.LibEvent = m["libevent"] + s.Runtime.PointerSize = toInt(m["pointer_size"]) + s.Runtime.Threads = toInt(m["threads"]) + + // map Resources + s.Resources.RUsageUser = toFloat64(m["rusage_user"]) + s.Resources.RUsageSystem = toFloat64(m["rusage_system"]) + + // map Connections + s.Connections.Max = toInt(m["max_connections"]) + s.Connections.Current = toInt(m["curr_connections"]) + s.Connections.Total = toInt(m["total_connections"]) + s.Connections.Rejected = toInt(m["rejected_connections"]) + s.Connections.Structures = toInt(m["connection_structures"]) + + // map Commands + s.Commands.Get = toInt(m["cmd_get"]) + s.Commands.Set = toInt(m["cmd_set"]) + s.Commands.Flush = toInt(m["cmd_flush"]) + s.Commands.Touch = toInt(m["cmd_touch"]) + s.Commands.Meta = toInt(m["cmd_meta"]) + + // map Hits + s.Commands.Hit.Get = toInt(m["get_hits"]) + s.Commands.Hit.Delete = toInt(m["delete_hits"]) + s.Commands.Hit.Increment = toInt(m["incr_hits"]) + s.Commands.Hit.Decrement = toInt(m["decr_hits"]) + s.Commands.Hit.Touch = toInt(m["touch_hits"]) + s.Commands.Hit.CAS = toInt(m["cas_hits"]) + + // map Misses + s.Commands.Miss.Get = toInt(m["get_misses"]) + s.Commands.Miss.Delete = toInt(m["delete_misses"]) + s.Commands.Miss.Increment = toInt(m["incr_misses"]) + s.Commands.Miss.Decrement = toInt(m["decr_misses"]) + s.Commands.Miss.Touch = toInt(m["touch_misses"]) + s.Commands.Miss.CAS = toInt(m["cas_misses"]) + + // map Failures + s.Commands.Failure.GetExpired = toInt(m["get_expired"]) + s.Commands.Failure.GetFlushed = toInt(m["get_flushed"]) + s.Commands.Failure.CASBadValue = toInt(m["cas_badval"]) + + // map Items + s.Items.Bytes = toInt(m["bytes"]) + s.Items.Current = toInt(m["curr_items"]) + s.Items.Total = toInt(m["total_items"]) + + return s, nil +} + +func toInt(s string) int { + v, _ := strconv.Atoi(s) + return v +} + +func toFloat64(s string) float64 { + v, _ := strconv.ParseFloat(s, 64) + return v +} diff --git a/stats_test.go b/stats_test.go new file mode 100644 index 0000000..3e12498 --- /dev/null +++ b/stats_test.go @@ -0,0 +1,119 @@ +// Copyright CattleCloud LLC 2025, 2026 +// SPDX-License-Identifier: BSD-3-Clause + +package memc + +import ( + "strings" + "testing" + + "github.com/shoenig/test/must" +) + +func Test_stats(t *testing.T) { + t.Parallel() + + input := strings.NewReader(realStats) + result, err := stats(input) + must.NoError(t, err) + + // spot check a few values + must.Eq(t, 714, result.Runtime.PID) + must.Eq(t, 1024, result.Connections.Max) +} + +const realStats = ` +STAT pid 714 +STAT uptime 2077665 +STAT time 1769190296 +STAT version 1.6.29 +STAT libevent 2.1.12-stable +STAT pointer_size 64 +STAT rusage_user 551.779188 +STAT rusage_system 808.853255 +STAT max_connections 1024 +STAT curr_connections 1 +STAT total_connections 13714 +STAT rejected_connections 0 +STAT connection_structures 5 +STAT response_obj_oom 0 +STAT response_obj_count 1 +STAT response_obj_bytes 65536 +STAT read_buf_count 8 +STAT read_buf_bytes 131072 +STAT read_buf_bytes_free 49152 +STAT read_buf_oom 0 +STAT reserved_fds 20 +STAT cmd_get 15249 +STAT cmd_set 12260 +STAT cmd_flush 0 +STAT cmd_touch 0 +STAT cmd_meta 0 +STAT get_hits 2918 +STAT get_misses 12331 +STAT get_expired 78 +STAT get_flushed 0 +STAT delete_misses 0 +STAT delete_hits 0 +STAT incr_misses 0 +STAT incr_hits 0 +STAT decr_misses 0 +STAT decr_hits 0 +STAT cas_misses 0 +STAT cas_hits 0 +STAT cas_badval 0 +STAT touch_hits 0 +STAT touch_misses 0 +STAT store_too_large 0 +STAT store_no_memory 0 +STAT auth_cmds 0 +STAT auth_errors 0 +STAT bytes_read 21752597 +STAT bytes_written 125490335 +STAT limit_maxbytes 2147483648 +STAT accepting_conns 1 +STAT listen_disabled_num 0 +STAT time_in_listen_disabled_us 0 +STAT threads 4 +STAT conn_yields 0 +STAT hash_power_level 16 +STAT hash_bytes 524288 +STAT hash_is_expanding 0 +STAT slab_reassign_rescues 0 +STAT slab_reassign_chunk_rescues 0 +STAT slab_reassign_evictions_nomem 0 +STAT slab_reassign_inline_reclaim 0 +STAT slab_reassign_busy_items 0 +STAT slab_reassign_busy_deletes 0 +STAT slab_reassign_running 0 +STAT slabs_moved 0 +STAT lru_crawler_running 0 +STAT lru_crawler_starts 1989 +STAT lru_maintainer_juggles 29456575 +STAT malloc_fails 0 +STAT log_worker_dropped 0 +STAT log_worker_written 0 +STAT log_watcher_skipped 0 +STAT log_watcher_sent 0 +STAT log_watchers 0 +STAT unexpected_napi_ids 0 +STAT round_robin_fallback 0 +STAT bytes 51321 +STAT curr_items 11 +STAT total_items 12260 +STAT slab_global_page_pool 0 +STAT expired_unfetched 11528 +STAT evicted_unfetched 0 +STAT evicted_active 0 +STAT evictions 0 +STAT reclaimed 11942 +STAT crawler_reclaimed 130 +STAT crawler_items_checked 3792 +STAT lrutail_reflocked 619 +STAT moves_to_cold 13354 +STAT moves_to_warm 1368 +STAT moves_within_lru 146 +STAT direct_reclaims 0 +STAT lru_bumps_dropped 0 +END +` diff --git a/verbs.go b/verbs.go index eab5622..8c8f6a4 100644 --- a/verbs.go +++ b/verbs.go @@ -450,6 +450,33 @@ func Decrement[T Countable](c *Client, key string, delta T) (T, error) { return result, err } +func Stats(c *Client) (*Statistics, error) { + var statistics *Statistics + + err := c.do("", func(conn *iopool.Buffer) error { + // write the header component + if _, err := fmt.Fprintf(conn, "stats\r\n"); err != nil { + return err + } + + // flush the connection, forcing bytes over the wire + if err := conn.Flush(); err != nil { + return err + } + + // extract the statistics payload + payload, perr := stats(conn.Reader) + if perr != nil { + return perr + } + statistics = payload + + return nil + }) + + return statistics, err +} + func unexpected(response []byte) error { return fmt.Errorf( "unexpected response from memcached %q",