github.com/swiftstack/proxyfs@v0.0.0-20201223034610-5434d919416e/bucketstats/api_test.go (about) 1 package bucketstats 2 3 import ( 4 "fmt" 5 "math/rand" 6 "regexp" 7 "testing" 8 ) 9 10 // a structure containing all of the bucketstats statistics types and other 11 // fields; useful for testing 12 type allStatTypes struct { 13 MyName string // not a statistic 14 bar int // also not a statistic 15 Total1 Total 16 Average1 Average 17 BucketLog2 BucketLog2Round 18 BucketLogRoot2 BucketLogRoot2Round 19 } 20 21 func TestTables(t *testing.T) { 22 // showDistr(log2RoundIdxTable[:]) 23 // showDistr(logRoot2RoundIdxTable[:]) 24 25 // generate the tables (arrays) for tables.go. the tables are already 26 // there, but this is where they came from. the stdout could be cut and 27 // pasted at the end of tables.go. 28 // 29 //genLog2Table() 30 //genLogRoot2Table() 31 } 32 33 // verify that all of the bucketstats statistics types satisfy the appropriate 34 // interface (this is really a compile time test; it fails if they don't) 35 func TestBucketStatsInterfaces(t *testing.T) { 36 var ( 37 Total1 Total 38 Average1 Average 39 BucketLogRoot2 BucketLog2Round 40 BucketRoot2 BucketLogRoot2Round 41 TotalIface Totaler 42 AverageIface Averager 43 BucketIface Bucketer 44 ) 45 46 // all the types are Totaler(s) 47 TotalIface = &Total1 48 TotalIface = &Average1 49 TotalIface = &BucketLogRoot2 50 TotalIface = &BucketRoot2 51 52 // most of the types are also Averager(s) 53 AverageIface = &Average1 54 AverageIface = &BucketLogRoot2 55 AverageIface = &BucketRoot2 56 57 // and the bucket types are Bucketer(s) 58 BucketIface = &BucketLogRoot2 59 BucketIface = &BucketRoot2 60 61 // keep the compiler happy by doing something with the local variables 62 AverageIface = BucketIface 63 TotalIface = AverageIface 64 _ = TotalIface 65 } 66 67 func TestRegister(t *testing.T) { 68 69 var ( 70 testFunc func() 71 panicStr string 72 ) 73 74 // registering a struct with all of the statist types should not panic 75 var myStats allStatTypes = allStatTypes{ 76 Total1: Total{Name: "mytotaler"}, 77 Average1: Average{Name: "First_Average"}, 78 BucketLog2: BucketLog2Round{Name: "bucket_log2"}, 79 BucketLogRoot2: BucketLogRoot2Round{Name: "bucket_logroot2"}, 80 } 81 Register("main", "myStats", &myStats) 82 83 // unregister-ing and re-register-ing myStats is also fine 84 UnRegister("main", "myStats") 85 Register("main", "myStats", &myStats) 86 87 // its also OK to unregister stats that don't exist 88 UnRegister("main", "neverStats") 89 90 // but registering it twice should panic 91 testFunc = func() { 92 Register("main", "myStats", &myStats) 93 } 94 panicStr = catchAPanic(testFunc) 95 if panicStr == "" { 96 t.Errorf("Register() of \"main\", \"myStats\" twice should have paniced") 97 } 98 UnRegister("main", "myStats") 99 100 // a statistics group must have at least one of package and group name 101 UnRegister("main", "myStats") 102 103 Register("", "myStats", &myStats) 104 UnRegister("", "myStats") 105 106 Register("main", "", &myStats) 107 UnRegister("main", "") 108 109 testFunc = func() { 110 Register("", "", &myStats) 111 } 112 panicStr = catchAPanic(testFunc) 113 if panicStr == "" { 114 t.Errorf("Register() of statistics group without a name didn't panic") 115 } 116 117 // Registering a struct without any bucketstats statistics is also OK 118 emptyStats := struct { 119 someInt int 120 someString string 121 someFloat float64 122 }{} 123 124 testFunc = func() { 125 Register("main", "emptyStats", &emptyStats) 126 } 127 panicStr = catchAPanic(testFunc) 128 if panicStr != "" { 129 t.Errorf("Register() of struct without statistics paniced: %s", panicStr) 130 } 131 132 // Registering unnamed and uninitialized statistics should name and init 133 // them, but not change the name if one is already assigned 134 var myStats2 allStatTypes = allStatTypes{} 135 Register("main", "myStats2", &myStats2) 136 if myStats2.Total1.Name != "Total1" || myStats.Total1.Name != "mytotaler" { 137 t.Errorf("After Register() a Totaler name is incorrect '%s' or '%s'", 138 myStats2.Total1.Name, myStats.Total1.Name) 139 } 140 if myStats2.Average1.Name != "Average1" || myStats.Average1.Name != "First_Average" { 141 t.Errorf("After Register() an Average name is incorrect '%s' or '%s'", 142 myStats2.Average1.Name, myStats.Average1.Name) 143 } 144 if myStats2.BucketLog2.Name != "BucketLog2" || myStats.BucketLog2.Name != "bucket_log2" { 145 t.Errorf("After Register() an Average name is incorrect '%s' or '%s'", 146 myStats2.BucketLog2.Name, myStats.BucketLog2.Name) 147 } 148 if myStats2.BucketLogRoot2.Name != "BucketLogRoot2" || myStats.BucketLogRoot2.Name != "bucket_logroot2" { 149 t.Errorf("After Register() an Average name is incorrect '%s' or '%s'", 150 myStats2.BucketLogRoot2.Name, myStats.BucketLogRoot2.Name) 151 } 152 // (values are somewhat arbitrary and can change) 153 if myStats2.BucketLog2.NBucket != 65 || myStats2.BucketLogRoot2.NBucket != 128 { 154 t.Errorf("After Register() NBucket was not initialized got %d and %d", 155 myStats2.BucketLog2.NBucket, myStats2.BucketLogRoot2.NBucket) 156 } 157 UnRegister("main", "myStats2") 158 159 // try with minimal number of buckets 160 var myStats3 allStatTypes = allStatTypes{ 161 BucketLog2: BucketLog2Round{NBucket: 1}, 162 BucketLogRoot2: BucketLogRoot2Round{NBucket: 1}, 163 } 164 Register("main", "myStats3", &myStats3) 165 // (minimum number of buckets is somewhat arbitrary and may change) 166 if myStats3.BucketLog2.NBucket != 10 || myStats3.BucketLogRoot2.NBucket != 17 { 167 t.Errorf("After Register() NBucket was not initialized got %d and %d", 168 myStats3.BucketLog2.NBucket, myStats3.BucketLogRoot2.NBucket) 169 } 170 UnRegister("main", "myStats3") 171 172 // two fields with the same name ("Average1") will panic 173 var myStats4 allStatTypes = allStatTypes{ 174 Total1: Total{Name: "mytotaler"}, 175 Average1: Average{}, 176 BucketLog2: BucketLog2Round{Name: "Average1"}, 177 } 178 testFunc = func() { 179 Register("main", "myStats4", &myStats4) 180 } 181 panicStr = catchAPanic(testFunc) 182 if panicStr == "" { 183 t.Errorf("Register() of struct with duplicate field names should panic") 184 } 185 } 186 187 // All of the bucketstats statistics are Totaler(s); test them 188 func TestTotaler(t *testing.T) { 189 var ( 190 totaler Totaler 191 totalerGroup allStatTypes = allStatTypes{} 192 totalerGroupMap map[string]Totaler 193 name string 194 total uint64 195 ) 196 197 totalerGroupMap = map[string]Totaler{ 198 "Total": &totalerGroup.Total1, 199 "Average": &totalerGroup.Average1, 200 "BucketLog2": &totalerGroup.BucketLog2, 201 "BucketLogRoot2": &totalerGroup.BucketLogRoot2, 202 } 203 204 // must be registered (inited) before use 205 Register("main", "TotalerStat", &totalerGroup) 206 207 // all totalers should start out at 0 208 for name, totaler = range totalerGroupMap { 209 if totaler.TotalGet() != 0 { 210 t.Errorf("%s started at total %d instead of 0", name, totaler.TotalGet()) 211 } 212 } 213 214 // after incrementing twice they should be 2 215 for _, totaler = range totalerGroupMap { 216 totaler.Increment() 217 totaler.Increment() 218 } 219 for name, totaler = range totalerGroupMap { 220 if totaler.TotalGet() != 2 { 221 t.Errorf("%s at total %d instead of 2 after 2 increments", name, totaler.TotalGet()) 222 } 223 } 224 225 // after adding 0 total should still be 2 226 for _, totaler = range totalerGroupMap { 227 totaler.Add(0) 228 totaler.Add(0) 229 } 230 for name, totaler = range totalerGroupMap { 231 if totaler.TotalGet() != 2 { 232 t.Errorf("%s got total %d instead of 2 after adding 0", name, totaler.TotalGet()) 233 } 234 } 235 236 // after adding 4 and 8 they must all total to 14 237 // 238 // (this does not work when adding values larger than 8 where the mean 239 // value of buckets for bucketized statistics diverges from the nominal 240 // value, i.e. adding 64 will produce totals of 70 for BucketLog2 and 67 241 // for BucketLogRoot2 because the meanVal for the bucket 64 is put in 242 // are 68 and 65, respectively) 243 for _, totaler = range totalerGroupMap { 244 totaler.Add(4) 245 totaler.Add(8) 246 } 247 for name, totaler = range totalerGroupMap { 248 if totaler.TotalGet() != 14 { 249 t.Errorf("%s at total %d instead of 6 after adding 4 and 8", name, totaler.TotalGet()) 250 } 251 } 252 253 // Sprint for each should do something for all stats types 254 // (not really making the effort to parse the string) 255 for name, totaler = range totalerGroupMap { 256 prettyPrint := totaler.Sprint(StatFormatParsable1, "main", "TestTotaler") 257 if prettyPrint == "" { 258 t.Errorf("%s returned an empty string for its Sprint() method", name) 259 } 260 } 261 262 // The Total returned for bucketized statistics will vary depending on 263 // the actual numbers used can can be off by more then 33% (Log2) or 264 // 17.2% (LogRoot2) in the worst case, and less in the average case. 265 // 266 // Empirically for 25 million runs using 1024 numbers each the error is 267 // no more than 10.0% (Log2 buckets) and 5.0% (LogRoot2 buckets). 268 // 269 // Run the test 1000 times -- note that go produces the same sequence of 270 // "random" numbers each time for the same seed, so statistical variation 271 // is not going to cause random test failures. 272 var ( 273 log2RoundErrorPctMax float64 = 33.3333333333333 274 log2RoundErrorPctLikely float64 = 10 275 logRoot2RoundErrorPctMax float64 = 17.241379310 276 logRoot2RoundErrorPctLikely float64 = 5.0 277 ) 278 279 rand.Seed(2) 280 for loop := 0; loop < 1000; loop++ { 281 282 var ( 283 newTotalerGroup allStatTypes 284 errPct float64 285 ) 286 287 totalerGroupMap = map[string]Totaler{ 288 "Total": &newTotalerGroup.Total1, 289 "Average": &newTotalerGroup.Average1, 290 "BucketLog2": &newTotalerGroup.BucketLog2, 291 "BucketLogRoot2": &newTotalerGroup.BucketLogRoot2, 292 } 293 294 // newTotalerGroup must be registered (inited) before use 295 296 UnRegister("main", "TotalerStat") 297 Register("main", "TotalerStat", &newTotalerGroup) 298 299 // add 1,0240 random numbers uniformly distributed [0, 6106906623) 300 // 301 // 6106906623 is RangeHigh for bucket 33 of BucketLog2Round and 302 // 5133828095 is RangeHigh for bucket 64 of BucketLogRoot2Round; 303 // using 5133828095 makes BucketLogRoot2Round look better and 304 // BucketLog2Round look worse. 305 total = 0 306 for i := 0; i < 1024; i++ { 307 randVal := uint64(rand.Int63n(6106906623)) 308 //randVal := uint64(rand.Int63n(5133828095)) 309 310 total += randVal 311 for _, totaler = range totalerGroupMap { 312 totaler.Add(randVal) 313 } 314 } 315 316 // validate total for each statistic; barring a run of extremely 317 // bad luck we expect the bucket stats will be less then 318 // log2RoundErrorPctLikely and logRoot2RoundErrorPctLikely, 319 // respectively 320 if newTotalerGroup.Total1.TotalGet() != total { 321 t.Errorf("Total1 total is %d instead of %d", newTotalerGroup.Total1.TotalGet(), total) 322 } 323 if newTotalerGroup.Average1.TotalGet() != total { 324 t.Errorf("Average1 total is %d instead of %d", newTotalerGroup.Average1.TotalGet(), total) 325 } 326 327 errPct = (float64(newTotalerGroup.BucketLog2.TotalGet())/float64(total) - 1) * 100 328 if errPct > log2RoundErrorPctMax || errPct < -log2RoundErrorPctMax { 329 t.Fatalf("BucketLog2Round total exceeds maximum possible error 33%%: "+ 330 "%d instead of %d error %1.3f%%", 331 newTotalerGroup.BucketLog2.TotalGet(), total, errPct) 332 333 } 334 if errPct > log2RoundErrorPctLikely || errPct < -log2RoundErrorPctLikely { 335 t.Errorf("BucketLog2Round total exceeds maximum likely error: %d instead of %d error %1.3f%%", 336 newTotalerGroup.BucketLog2.TotalGet(), total, errPct) 337 } 338 339 errPct = (float64(newTotalerGroup.BucketLogRoot2.TotalGet())/float64(total) - 1) * 100 340 if errPct > logRoot2RoundErrorPctMax || errPct < -logRoot2RoundErrorPctMax { 341 t.Fatalf("BucketLogRoot2Round total exceeds maximum possible error 17.2%%: "+ 342 "%d instead of %d error %1.3f%%", 343 newTotalerGroup.BucketLogRoot2.TotalGet(), total, errPct) 344 } 345 if errPct > logRoot2RoundErrorPctLikely || errPct < -logRoot2RoundErrorPctLikely { 346 t.Errorf("BucketLogRoot2Round total exceeds maximum likely error: "+ 347 "%d instead of %d error %1.3f%%", 348 newTotalerGroup.BucketLogRoot2.TotalGet(), total, errPct) 349 } 350 351 } 352 353 // Sprint for each should do something for all statistic types 354 // (without trying to validate the string) 355 for name, totaler = range totalerGroupMap { 356 prettyPrint := totaler.Sprint(StatFormatParsable1, "main", "TestTotaler") 357 if prettyPrint == "" { 358 t.Errorf("%s returned an empty string for its Sprint() method", name) 359 } 360 } 361 } 362 363 // Test Bucketer specific functionality (which is mostly buckets) 364 // 365 func TestBucketer(t *testing.T) { 366 367 var ( 368 bucketerGroup allStatTypes = allStatTypes{} 369 bucketerGroupMap map[string]Bucketer 370 bucketInfoTmp []BucketInfo 371 bucketer Bucketer 372 name string 373 //total uint64 374 ) 375 376 // describe the buckets for testing 377 bucketerGroupMap = map[string]Bucketer{ 378 "BucketLog2": &bucketerGroup.BucketLog2, 379 "BucketLogRoot2": &bucketerGroup.BucketLogRoot2, 380 } 381 382 // buckets must be registered (inited) before use 383 Register("main", "BucketerStat", &bucketerGroup) 384 385 // verify that each type has the right number of buckets, where "right" 386 // is implementation defined 387 if len(bucketerGroup.BucketLog2.DistGet()) != 65 { 388 t.Errorf("BucketLog2 has %d buckets should be 65", bucketerGroup.BucketLog2.DistGet()) 389 } 390 if len(bucketerGroup.BucketLogRoot2.DistGet()) != 128 { 391 t.Errorf("BucketLog2 has %d buckets should be 128", bucketerGroup.BucketLog2.DistGet()) 392 } 393 394 // all buckets should start out at 0 395 for name, bucketer = range bucketerGroupMap { 396 397 if bucketer.TotalGet() != 0 { 398 t.Errorf("%s started at total %d instead of 0", name, bucketer.TotalGet()) 399 } 400 if bucketer.AverageGet() != 0 { 401 t.Errorf("%s started at average %d instead of 0", name, bucketer.AverageGet()) 402 } 403 404 bucketInfoTmp = bucketer.DistGet() 405 for bucketIdx := 0; bucketIdx < len(bucketInfoTmp); bucketIdx++ { 406 if bucketInfoTmp[bucketIdx].Count != 0 { 407 t.Errorf("%s started out with bucket[%d].Count is %d instead of 0", 408 name, bucketIdx, bucketInfoTmp[bucketIdx].Count) 409 } 410 } 411 } 412 413 // verify that RangeLow, RangeHigh, NominalVal, and MeanVal are all 414 // placed in the the bucket that they should be 415 for name, bucketer = range bucketerGroupMap { 416 417 bucketInfoTmp = bucketer.DistGet() 418 for bucketIdx := 0; bucketIdx < len(bucketInfoTmp); bucketIdx++ { 419 bucketer.Add(bucketInfoTmp[bucketIdx].RangeLow) 420 bucketer.Add(bucketInfoTmp[bucketIdx].RangeHigh) 421 bucketer.Add(bucketInfoTmp[bucketIdx].NominalVal) 422 bucketer.Add(bucketInfoTmp[bucketIdx].MeanVal) 423 424 if bucketer.DistGet()[bucketIdx].Count != 4 { 425 t.Errorf("%s added 4 values to index %d but got %d", 426 name, bucketIdx, bucketer.DistGet()[bucketIdx].Count) 427 } 428 } 429 } 430 431 // verify RangeLow and RangeHigh are contiguous 432 for name, bucketer = range bucketerGroupMap { 433 434 // at least for these buckets, the first bucket always maps only 0 435 if bucketInfoTmp[0].RangeLow != 0 && bucketInfoTmp[0].RangeHigh != 0 { 436 t.Errorf("%s bucket 0 RangeLow %d or RangeHigh %d does not match 0", 437 name, bucketInfoTmp[0].RangeLow, bucketInfoTmp[0].RangeHigh) 438 } 439 440 lastRangeHigh := uint64(0) 441 bucketInfoTmp = bucketer.DistGet() 442 for bucketIdx := 1; bucketIdx < len(bucketInfoTmp); bucketIdx++ { 443 if bucketInfoTmp[bucketIdx].RangeLow != lastRangeHigh+1 { 444 t.Errorf("%s bucket %d RangeLow %d does not match last RangeHigh %d", 445 name, bucketIdx, bucketInfoTmp[bucketIdx].RangeLow, lastRangeHigh+1) 446 } 447 lastRangeHigh = bucketInfoTmp[bucketIdx].RangeHigh 448 } 449 450 if lastRangeHigh != (1<<64)-1 { 451 t.Errorf("%s last bucket RangeHigh is %d instead of %d", name, lastRangeHigh, uint64((1<<64)-1)) 452 } 453 } 454 455 for name, bucketer = range bucketerGroupMap { 456 prettyPrint := bucketer.Sprint(StatFormatParsable1, "main", "BucketGroup") 457 if prettyPrint == "" { 458 t.Errorf("%s returned an empty string for its Sprint() method", name) 459 } 460 } 461 462 } 463 464 func TestSprintStats(t *testing.T) { 465 466 // array of all valid StatStringFormat 467 statFmtList := []StatStringFormat{StatFormatParsable1} 468 469 var ( 470 testFunc func() 471 panicStr string 472 statFmt StatStringFormat 473 ) 474 475 // sprint'ing unregistered stats group should panic 476 testFunc = func() { 477 fmt.Print(SprintStats(statFmt, "main", "no-such-stats")) 478 } 479 panicStr = catchAPanic(testFunc) 480 if panicStr == "" { 481 t.Errorf("SprintStats() of unregistered statistic group did not panic") 482 } 483 484 // verify StatFormatParsable1, and other formats, handle illegal 485 // characters in names (StatFormatParsable1 replaces them with 486 // underscore ('_')) 487 for _, statFmt = range statFmtList { 488 489 var myStats5 allStatTypes = allStatTypes{ 490 Total1: Total{Name: ":colon #sharp \nNewline \tTab \bBackspace-are-changed"}, 491 Average1: Average{Name: "and*splat*is*also*changed"}, 492 BucketLog2: BucketLog2Round{Name: "spaces get replaced as well"}, 493 BucketLogRoot2: BucketLogRoot2Round{Name: "but !@$%^&()[]` are OK"}, 494 } 495 statisticNamesScrubbed := []string{ 496 "_colon__sharp__Newline__Tab__Backspace-are-changed", 497 "and_splat_is_also_changed", 498 "spaces_get_replaced_as_well", 499 "but_!@$%^&()[]`_are_OK", 500 } 501 502 pkgName := "m*a:i#n" 503 pkgNameScrubbed := "m_a_i_n" 504 statsGroupName := "m y s t a t s 5" 505 statsGroupNameScrubbed := "m_y_s_t_a_t_s_5" 506 507 Register(pkgName, statsGroupName, &myStats5) 508 509 switch statFmt { 510 511 default: 512 t.Fatalf("SprintStats(): unknown StatStringFormat %v\n", statFmt) 513 514 case StatFormatParsable1: 515 statsString := SprintStats(StatFormatParsable1, pkgName, statsGroupName) 516 if statsString == "" { 517 t.Fatalf("SprintStats(%s, %s,) did not find the statsgroup", pkgName, statsGroupName) 518 } 519 520 rowDelimiterRE := regexp.MustCompile("\n") 521 fieldDelimiterRe := regexp.MustCompile(" +") 522 523 for _, row := range rowDelimiterRE.Split(statsString, -1) { 524 if row == "" { 525 continue 526 } 527 528 statisticName := fieldDelimiterRe.Split(row, 2)[0] 529 matched := false 530 for _, scrubbedName := range statisticNamesScrubbed { 531 fu := pkgNameScrubbed + "." + statsGroupNameScrubbed + "." + scrubbedName 532 if statisticName == fu { 533 matched = true 534 break 535 } 536 } 537 538 if !matched { 539 t.Errorf("TestSprintStats: statisticName '%s' did not match any statistic name", 540 statisticName) 541 } 542 } 543 } 544 UnRegister("m*a:i#n", "m y s t a t s 5") 545 } 546 } 547 548 // Invoke function aFunc, which is expected to panic. If it does, return the 549 // value returned by recover() as a string, otherwise return the empty string. 550 // 551 // If panic() is called with a nil argument then this function also returns the 552 // empty string. 553 // 554 func catchAPanic(aFunc func()) (panicStr string) { 555 556 defer func() { 557 // if recover() returns !nil then return it as a string 558 panicVal := recover() 559 if panicVal != nil { 560 panicStr = fmt.Sprintf("%v", panicVal) 561 } 562 }() 563 564 aFunc() 565 return 566 }