File
Blob: chord/local_stats_handler.go
| 1 | package chord |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "html" |
| 7 | "net/http" |
| 8 | "runtime" |
| 9 | "slices" |
| 10 | "sort" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "time" |
| 14 | |
| 15 | "go.miragespace.co/specter/spec/chord" |
| 16 | "go.miragespace.co/specter/spec/rtt" |
| 17 | |
| 18 | "github.com/go-chi/chi/v5/middleware" |
| 19 | "github.com/jedib0t/go-pretty/v6/table" |
| 20 | "github.com/jedib0t/go-pretty/v6/text" |
| 21 | ) |
| 22 | |
| 23 | func minmax(nums []int) (min, max int) { |
| 24 | min = nums[0] |
| 25 | max = nums[0] |
| 26 | for _, num := range nums { |
| 27 | if num > max { |
| 28 | max = num |
| 29 | } |
| 30 | if num < min { |
| 31 | min = num |
| 32 | } |
| 33 | } |
| 34 | return |
| 35 | } |
| 36 | |
| 37 | func (n *LocalNode) fingerTrace() map[string]string { |
| 38 | ftMap := map[uint64][]int{} |
| 39 | n.fingerRangeView(func(i int, f chord.VNode) bool { |
| 40 | id := f.ID() |
| 41 | if _, found := ftMap[id]; !found { |
| 42 | ftMap[id] = make([]int, 0) |
| 43 | } |
| 44 | ftMap[id] = append(ftMap[id], i) |
| 45 | return true |
| 46 | }) |
| 47 | |
| 48 | keys := make([]uint64, 0, len(ftMap)) |
| 49 | for k := range ftMap { |
| 50 | keys = append(keys, k) |
| 51 | } |
| 52 | slices.Sort(keys) |
| 53 | |
| 54 | f := make(map[string]string) |
| 55 | for _, k := range keys { |
| 56 | min, max := minmax(ftMap[k]) |
| 57 | f[fmt.Sprintf("%d/%d", min, max)] = strconv.FormatUint(k, 10) |
| 58 | } |
| 59 | |
| 60 | return f |
| 61 | } |
| 62 | |
| 63 | func printSummary(ctx context.Context, w http.ResponseWriter, virtualNodes []*LocalNode) { |
| 64 | rootNode := virtualNodes[0] |
| 65 | numKeys := 0 |
| 66 | |
| 67 | phy := &strings.Builder{} |
| 68 | vir := &strings.Builder{} |
| 69 | lis := &strings.Builder{} |
| 70 | |
| 71 | nodesTable := table.NewWriter() |
| 72 | nodesTable.SetOutputMirror(vir) |
| 73 | nodesTable.AppendHeader(table.Row{"Kind", "ID", "State", "History", "Stablized", "Chord RPC QPS", "RPC Error", "KV RPC QPS", "Stale KV"}) |
| 74 | nodesTable.SetColumnConfigs([]table.ColumnConfig{ |
| 75 | {Number: 1, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 76 | {Number: 2, AlignHeader: text.AlignCenter}, |
| 77 | {Number: 3, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 78 | {Number: 4, AlignHeader: text.AlignCenter, WidthMax: 40, WidthMaxEnforcer: text.WrapSoft}, |
| 79 | {Number: 5, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 80 | {Number: 6, AutoMerge: true, AlignHeader: text.AlignCenter, Align: text.AlignRight}, |
| 81 | {Number: 7, AutoMerge: true, AlignHeader: text.AlignCenter, Align: text.AlignRight}, |
| 82 | {Number: 8, AutoMerge: true, AlignHeader: text.AlignCenter, Align: text.AlignRight}, |
| 83 | {Number: 9, AutoMerge: true, AlignHeader: text.AlignCenter, Align: text.AlignRight}, |
| 84 | }) |
| 85 | for i, node := range virtualNodes { |
| 86 | kind := "Root" |
| 87 | if i != 0 { |
| 88 | kind = "Virtual" |
| 89 | } |
| 90 | nodesTable.AppendRow(table.Row{ |
| 91 | kind, |
| 92 | fmt.Sprintf("%d", node.ID()), |
| 93 | node.state.Get().String(), |
| 94 | fmt.Sprintf("%v", node.state.History()), |
| 95 | node.lastStabilized.Load().Round(time.Second).Format(time.RFC3339), |
| 96 | fmt.Sprintf("%.2f", node.chordRate.RatePerInterval()), |
| 97 | fmt.Sprintf("%d", node.rpcErrorCount.Load()), |
| 98 | fmt.Sprintf("%.2f", node.kvRate.RatePerInterval()), |
| 99 | fmt.Sprintf("%d", node.kvStaleCount.Load()), |
| 100 | }) |
| 101 | } |
| 102 | nodesTable.SetStyle(table.StyleDefault) |
| 103 | nodesTable.Style().Options.SeparateRows = true |
| 104 | nodesTable.Render() |
| 105 | |
| 106 | for _, node := range virtualNodes { |
| 107 | pre, err := node.GetPredecessor() |
| 108 | if err != nil { |
| 109 | w.WriteHeader(http.StatusInternalServerError) |
| 110 | fmt.Fprintf(w, "error getting predecessor: %v", err) |
| 111 | return |
| 112 | } |
| 113 | succList, err := node.GetSuccessors() |
| 114 | if err != nil { |
| 115 | w.WriteHeader(http.StatusInternalServerError) |
| 116 | fmt.Fprintf(w, "error getting successor list: %v", err) |
| 117 | return |
| 118 | } |
| 119 | |
| 120 | sbs := table.NewWriter() |
| 121 | sbs.SetOutputMirror(lis) |
| 122 | |
| 123 | nodesString := &strings.Builder{} |
| 124 | nodesTable := table.NewWriter() |
| 125 | nodesTable.SetOutputMirror(nodesString) |
| 126 | nodesTable.AppendHeader(table.Row{"Position", "ID", "Address", "RTT (-10s)"}) |
| 127 | nodesTable.SetColumnConfigs([]table.ColumnConfig{ |
| 128 | {Number: 1, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 129 | {Number: 2, AlignHeader: text.AlignCenter}, |
| 130 | {Number: 3, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 131 | {Number: 4, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 132 | }) |
| 133 | nodesTable.AppendRows([]table.Row{ |
| 134 | { |
| 135 | "Predecessor", |
| 136 | fmt.Sprintf("%v", pre.ID()), |
| 137 | pre.Identity().GetAddress(), |
| 138 | node.NodesRTT.Snapshot(rtt.MakeMeasurementKey(pre.Identity()), time.Second*10).String(), |
| 139 | }, |
| 140 | { |
| 141 | "Local", |
| 142 | fmt.Sprintf("%v", node.ID()), |
| 143 | node.Identity().GetAddress(), |
| 144 | node.NodesRTT.Snapshot(rtt.MakeMeasurementKey(node.Identity()), time.Second*10).String(), |
| 145 | }, |
| 146 | }) |
| 147 | |
| 148 | for _, succ := range succList { |
| 149 | nodesTable.AppendRow(table.Row{ |
| 150 | fmt.Sprintf("Successor (L = %d)", chord.ExtendedSuccessorEntries), |
| 151 | fmt.Sprintf("%v", succ.ID()), |
| 152 | succ.Identity().GetAddress(), |
| 153 | node.NodesRTT.Snapshot(rtt.MakeMeasurementKey(succ.Identity()), time.Second*10).String(), |
| 154 | }) |
| 155 | } |
| 156 | nodesTable.SetStyle(table.StyleDefault) |
| 157 | nodesTable.Style().Options.SeparateRows = true |
| 158 | nodesTable.Render() |
| 159 | |
| 160 | fingerString := &strings.Builder{} |
| 161 | finger := node.fingerTrace() |
| 162 | fingerTable := table.NewWriter() |
| 163 | fingerTable.SetOutputMirror(fingerString) |
| 164 | fingerTable.AppendHeader(table.Row{"Range", "ID"}) |
| 165 | rows := make([]table.Row, 0) |
| 166 | for r, id := range finger { |
| 167 | rows = append(rows, table.Row{r, id}) |
| 168 | } |
| 169 | sort.SliceStable(rows, func(i, j int) bool { |
| 170 | a, b := rows[i][0], rows[j][0] |
| 171 | aParts, bParts := strings.Split(a.(string), "/"), strings.Split(b.(string), "/") |
| 172 | aMin, _ := strconv.ParseInt(aParts[0], 10, 64) |
| 173 | bMin, _ := strconv.ParseInt(bParts[0], 10, 64) |
| 174 | return aMin < bMin |
| 175 | }) |
| 176 | fingerTable.AppendRows(rows, table.RowConfig{AutoMerge: true}) |
| 177 | fingerTable.SetCaption("(range: %v)", chord.MaxIdentitifer) |
| 178 | fingerTable.SetStyle(table.StyleDefault) |
| 179 | fingerTable.Style().Options.SeparateRows = true |
| 180 | fingerTable.SetColumnConfigs([]table.ColumnConfig{ |
| 181 | {Number: 1, AlignHeader: text.AlignCenter}, |
| 182 | {Number: 2, AlignHeader: text.AlignCenter}, |
| 183 | }) |
| 184 | fingerTable.Render() |
| 185 | |
| 186 | sbs.AppendHeader(table.Row{"Finger", "Ring"}) |
| 187 | sbs.SetColumnConfigs([]table.ColumnConfig{ |
| 188 | {Number: 1, AlignHeader: text.AlignCenter}, |
| 189 | {Number: 2, AlignHeader: text.AlignCenter}, |
| 190 | }) |
| 191 | sbs.AppendRow(table.Row{fingerString.String(), nodesString.String()}) |
| 192 | sbs.SetStyle(table.StyleDefault) |
| 193 | sbs.Style().Options.DrawBorder = false |
| 194 | sbs.Style().Options.SeparateHeader = false |
| 195 | sbs.Style().Options.SeparateColumns = false |
| 196 | sbs.Style().Options.SeparateRows = false |
| 197 | sbs.Render() |
| 198 | |
| 199 | keysTable := table.NewWriter() |
| 200 | keysTable.SetOutputMirror(lis) |
| 201 | keysTable.AppendHeader(table.Row{"owner", "hash(key)", "key", "simple", "prefix", "lease"}) |
| 202 | keysTable.SetColumnConfigs([]table.ColumnConfig{ |
| 203 | {Number: 1, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 204 | {Number: 2, AlignHeader: text.AlignCenter}, |
| 205 | {Number: 3, AlignHeader: text.AlignCenter, WidthMin: 20}, |
| 206 | {Number: 4, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 207 | {Number: 5, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 208 | {Number: 6, AutoMerge: true, AlignHeader: text.AlignCenter}, |
| 209 | }) |
| 210 | |
| 211 | keys, err := node.kv.RangeKeys(ctx, 0, 0) |
| 212 | if err != nil { |
| 213 | http.Error(w, err.Error(), 500) |
| 214 | return |
| 215 | } |
| 216 | exp, err := node.kv.Export(ctx, keys) |
| 217 | if err != nil { |
| 218 | http.Error(w, err.Error(), 500) |
| 219 | return |
| 220 | } |
| 221 | for i, key := range keys { |
| 222 | plain := exp[i].GetSimpleValue() |
| 223 | children := exp[i].GetPrefixChildren() |
| 224 | lease := exp[i].GetLeaseToken() |
| 225 | |
| 226 | id := chord.Hash(key) |
| 227 | ownership := "" |
| 228 | if !chord.Between(pre.ID(), id, node.ID(), true) { |
| 229 | ownership = "X" |
| 230 | } |
| 231 | keysTable.AppendRow(table.Row{ |
| 232 | ownership, id, string(key), len(plain), len(children), lease, |
| 233 | }) |
| 234 | } |
| 235 | numKeys += len(keys) |
| 236 | keysTable.SetCaption("(With %d keys; X in owner column indicates incorrect owner)", len(keys)) |
| 237 | keysTable.SetStyle(table.StyleDefault) |
| 238 | keysTable.Style().Options.SeparateRows = true |
| 239 | keysTable.SuppressEmptyColumns() |
| 240 | keysTable.Render() |
| 241 | |
| 242 | fmt.Fprintf(lis, "---\n\n") |
| 243 | } |
| 244 | |
| 245 | // read mem stats |
| 246 | var rtm runtime.MemStats |
| 247 | runtime.ReadMemStats(&rtm) |
| 248 | phyTable := table.NewWriter() |
| 249 | phyTable.SetOutputMirror(phy) |
| 250 | phyTable.AppendRows([]table.Row{ |
| 251 | { |
| 252 | "Advertise Address", |
| 253 | rootNode.Identity().GetAddress(), |
| 254 | }, |
| 255 | { |
| 256 | "Outbound RPC qps", |
| 257 | fmt.Sprintf("%.2f", rootNode.ChordClient.RatePer(time.Second)), |
| 258 | }, |
| 259 | { |
| 260 | "Number of KV Keys", |
| 261 | fmt.Sprintf("%d", numKeys), |
| 262 | }, |
| 263 | {"Mallocs", fmt.Sprintf("%d", rtm.Alloc)}, |
| 264 | {"Frees", fmt.Sprintf("%d", rtm.Frees)}, |
| 265 | {"LiveObjects", fmt.Sprintf("%d", rtm.Mallocs-rtm.Frees)}, |
| 266 | {"HeapObjects", fmt.Sprintf("%d", rtm.HeapObjects)}, |
| 267 | {"HeapAlloc", fmt.Sprintf("%d", rtm.HeapAlloc)}, |
| 268 | {"NumGC", fmt.Sprintf("%d", rtm.NumGC)}, |
| 269 | {"PauseTotalNs", fmt.Sprintf("%d", rtm.PauseTotalNs)}, |
| 270 | {"LastGC", time.UnixMilli(int64(rtm.LastGC / 1_000_000)).Format(time.RFC3339)}, |
| 271 | }) |
| 272 | phyTable.SetStyle(table.StyleDefault) |
| 273 | phyTable.Style().Options.SeparateRows = true |
| 274 | phyTable.SetColumnConfigs([]table.ColumnConfig{ |
| 275 | {Number: 2, Align: text.AlignRight}, |
| 276 | }) |
| 277 | phyTable.Render() |
| 278 | |
| 279 | w.Write([]byte(phy.String())) |
| 280 | fmt.Fprintf(w, "---\n\n") |
| 281 | w.Write([]byte(vir.String())) |
| 282 | fmt.Fprintf(w, "---\n\n") |
| 283 | w.Write([]byte(lis.String())) |
| 284 | } |
| 285 | |
| 286 | func printKey(virtualNodes []*LocalNode, w http.ResponseWriter, r *http.Request, key string) { |
| 287 | for _, n := range virtualNodes { |
| 288 | val, err := n.kv.Get(r.Context(), []byte(key)) |
| 289 | if err != nil { |
| 290 | w.WriteHeader(http.StatusInternalServerError) |
| 291 | fmt.Fprintf(w, "error getting kv: %v", err) |
| 292 | return |
| 293 | } |
| 294 | if len(val) != 0 { |
| 295 | w.Write(val) |
| 296 | return |
| 297 | } |
| 298 | } |
| 299 | w.WriteHeader(http.StatusNotFound) |
| 300 | fmt.Fprint(w, "key not found on this node") |
| 301 | } |
| 302 | |
| 303 | func statsHandler(virtualNodes []*LocalNode) func(w http.ResponseWriter, r *http.Request) { |
| 304 | return func(w http.ResponseWriter, r *http.Request) { |
| 305 | query := r.URL.Query() |
| 306 | if query.Has("key") { |
| 307 | w.Header().Set("content-type", "text/plain; charset=utf-8") |
| 308 | printKey(virtualNodes, w, r, query.Get("key")) |
| 309 | return |
| 310 | } |
| 311 | |
| 312 | urlFormat, _ := r.Context().Value(middleware.URLFormatCtxKey).(string) |
| 313 | switch urlFormat { |
| 314 | case "html": |
| 315 | w.Header().Set("content-type", "text/html; charset=utf-8") |
| 316 | defer fmt.Fprint(w, `</pre></body></html>`) |
| 317 | fmt.Fprint(w, `<html><head><meta name="viewport" content="width=device-width, initial-scale=1.0"></head><body><pre>`) |
| 318 | w = escapedStatsWriter{ResponseWriter: w} |
| 319 | default: |
| 320 | w.Header().Set("content-type", "text/plain; charset=utf-8") |
| 321 | } |
| 322 | |
| 323 | printSummary(r.Context(), w, virtualNodes) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | // Preserve the text diagnostic's streaming behavior while treating stored keys, |
| 328 | // peer addresses and errors as text inside the HTML representation. |
| 329 | type escapedStatsWriter struct { |
| 330 | http.ResponseWriter |
| 331 | } |
| 332 | |
| 333 | func (w escapedStatsWriter) Write(p []byte) (int, error) { |
| 334 | if _, err := w.ResponseWriter.Write([]byte(html.EscapeString(string(p)))); err != nil { |
| 335 | return 0, err |
| 336 | } |
| 337 | return len(p), nil |
| 338 | } |