github.com/onflow/flow-go@v0.33.17/network/p2p/inspector/internal/cache/cache_test.go (about) 1 package cache 2 3 import ( 4 "fmt" 5 "sync" 6 "testing" 7 "time" 8 9 "github.com/rs/zerolog" 10 "github.com/stretchr/testify/require" 11 12 "github.com/onflow/flow-go/model/flow" 13 "github.com/onflow/flow-go/module" 14 "github.com/onflow/flow-go/module/metrics" 15 "github.com/onflow/flow-go/utils/unittest" 16 ) 17 18 const defaultDecay = 0.99 19 20 // TestRecordCache_Init tests the Init method of the RecordCache. 21 // It ensures that the method returns true when a new record is initialized 22 // and false when an existing record is initialized. 23 func TestRecordCache_Init(t *testing.T) { 24 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 25 26 nodeID1 := unittest.IdentifierFixture() 27 nodeID2 := unittest.IdentifierFixture() 28 29 // test initializing a record for an node ID that doesn't exist in the cache 30 gauge, ok, err := cache.GetWithInit(nodeID1) 31 require.NoError(t, err) 32 require.True(t, ok, "expected record to exist") 33 require.Zerof(t, gauge, "expected gauge to be 0") 34 require.Equal(t, uint(1), cache.Size(), "expected cache to have one additional record") 35 36 // test initializing a record for an node ID that already exists in the cache 37 gaugeAgain, ok, err := cache.GetWithInit(nodeID1) 38 require.NoError(t, err) 39 require.True(t, ok, "expected record to still exist") 40 require.Zerof(t, gaugeAgain, "expected same gauge to be 0") 41 require.Equal(t, gauge, gaugeAgain, "expected records to be the same") 42 require.Equal(t, uint(1), cache.Size(), "expected cache to still have one additional record") 43 44 // test initializing a record for another node ID 45 gauge2, ok, err := cache.GetWithInit(nodeID2) 46 require.NoError(t, err) 47 require.True(t, ok, "expected record to exist") 48 require.Zerof(t, gauge2, "expected second gauge to be 0") 49 require.Equal(t, uint(2), cache.Size(), "expected cache to have two additional records") 50 } 51 52 // TestRecordCache_ConcurrentInit tests the concurrent initialization of records. 53 // The test covers the following scenarios: 54 // 1. Multiple goroutines initializing records for different node IDs. 55 // 2. Ensuring that all records are correctly initialized. 56 func TestRecordCache_ConcurrentInit(t *testing.T) { 57 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 58 59 nodeIDs := unittest.IdentifierListFixture(10) 60 61 var wg sync.WaitGroup 62 wg.Add(len(nodeIDs)) 63 64 for _, nodeID := range nodeIDs { 65 go func(id flow.Identifier) { 66 defer wg.Done() 67 gauge, found, err := cache.GetWithInit(id) 68 require.NoError(t, err) 69 require.True(t, found) 70 require.Zerof(t, gauge, "expected all gauge values to be initialized to 0") 71 }(nodeID) 72 } 73 74 unittest.RequireReturnsBefore(t, wg.Wait, 100*time.Millisecond, "timed out waiting for goroutines to finish") 75 } 76 77 // TestRecordCache_ConcurrentSameRecordInit tests the concurrent initialization of the same record. 78 // The test covers the following scenarios: 79 // 1. Multiple goroutines attempting to initialize the same record concurrently. 80 // 2. Only one goroutine successfully initializes the record, and others receive false on initialization. 81 // 3. The record is correctly initialized in the cache and can be retrieved using the GetWithInit method. 82 func TestRecordCache_ConcurrentSameRecordInit(t *testing.T) { 83 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 84 85 nodeID := unittest.IdentifierFixture() 86 const concurrentAttempts = 10 87 88 var wg sync.WaitGroup 89 wg.Add(concurrentAttempts) 90 91 for i := 0; i < concurrentAttempts; i++ { 92 go func() { 93 defer wg.Done() 94 gauge, found, err := cache.GetWithInit(nodeID) 95 require.NoError(t, err) 96 require.True(t, found) 97 require.Zero(t, gauge) 98 }() 99 } 100 101 unittest.RequireReturnsBefore(t, wg.Wait, 100*time.Millisecond, "timed out waiting for goroutines to finish") 102 103 // ensure that only one goroutine successfully initialized the record 104 require.Equal(t, uint(1), cache.Size()) 105 } 106 107 // TestRecordCache_ReceivedClusterPrefixedMessage tests the ReceivedClusterPrefixedMessage method of the RecordCache. 108 // The test covers the following scenarios: 109 // 1. Updating a record gauge for an existing node ID. 110 // 2. Attempting to update a record gauge for a non-existing node ID should not result in error. ReceivedClusterPrefixedMessage should always attempt to initialize the gauge. 111 // 3. Multiple updates on the same record only initialize the record once. 112 func TestRecordCache_ReceivedClusterPrefixedMessage(t *testing.T) { 113 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 114 115 nodeID1 := unittest.IdentifierFixture() 116 nodeID2 := unittest.IdentifierFixture() 117 118 gauge, err := cache.ReceivedClusterPrefixedMessage(nodeID1) 119 require.NoError(t, err) 120 require.Equal(t, float64(1), gauge) 121 122 // get will apply a slightl decay resulting 123 // in a gauge value less than gauge which is 1 but greater than 0.9 124 currentGauge, ok, err := cache.GetWithInit(nodeID1) 125 require.NoError(t, err) 126 require.True(t, ok) 127 require.LessOrEqual(t, currentGauge, gauge) 128 require.Greater(t, currentGauge, 0.9) 129 130 _, ok, err = cache.GetWithInit(nodeID2) 131 require.NoError(t, err) 132 require.True(t, ok) 133 134 // test adjusting the spam record for a non-existing node ID 135 nodeID3 := unittest.IdentifierFixture() 136 gauge3, err := cache.ReceivedClusterPrefixedMessage(nodeID3) 137 require.NoError(t, err) 138 require.Equal(t, float64(1), gauge3) 139 140 // when updated the value should be incremented from 1 -> 2 and slightly decayed resulting 141 // in a gauge value less than 2 but greater than 1.9 142 gauge3, err = cache.ReceivedClusterPrefixedMessage(nodeID3) 143 require.NoError(t, err) 144 require.LessOrEqual(t, gauge3, 2.0) 145 require.Greater(t, gauge3, 1.9) 146 } 147 148 // TestRecordCache_UpdateDecay ensures that a gauge in the record cache is eventually decayed back to 0 after some time. 149 func TestRecordCache_Decay(t *testing.T) { 150 cache := cacheFixture(t, 100, 0.09, zerolog.Nop(), metrics.NewNoopCollector()) 151 152 nodeID1 := unittest.IdentifierFixture() 153 154 // initialize spam records for nodeID1 and nodeID2 155 gauge, err := cache.ReceivedClusterPrefixedMessage(nodeID1) 156 require.Equal(t, float64(1), gauge) 157 require.NoError(t, err) 158 gauge, ok, err := cache.GetWithInit(nodeID1) 159 require.True(t, ok) 160 require.NoError(t, err) 161 // gauge should have been delayed slightly 162 require.True(t, gauge < float64(1)) 163 164 time.Sleep(time.Second) 165 166 gauge, ok, err = cache.GetWithInit(nodeID1) 167 require.True(t, ok) 168 require.NoError(t, err) 169 // gauge should have been delayed slightly, but closer to 0 170 require.Less(t, gauge, 0.1) 171 } 172 173 // TestRecordCache_Identities tests the NodeIDs method of the RecordCache. 174 // The test covers the following scenarios: 175 // 1. Initializing the cache with multiple records. 176 // 2. Checking if the NodeIDs method returns the correct set of node IDs. 177 func TestRecordCache_Identities(t *testing.T) { 178 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 179 180 // initialize spam records for a few node IDs 181 nodeID1 := unittest.IdentifierFixture() 182 nodeID2 := unittest.IdentifierFixture() 183 nodeID3 := unittest.IdentifierFixture() 184 185 _, ok, err := cache.GetWithInit(nodeID1) 186 require.NoError(t, err) 187 require.True(t, ok) 188 _, ok, err = cache.GetWithInit(nodeID2) 189 require.NoError(t, err) 190 require.True(t, ok) 191 _, ok, err = cache.GetWithInit(nodeID3) 192 require.NoError(t, err) 193 require.True(t, ok) 194 195 // check if the NodeIDs method returns the correct set of node IDs 196 identities := cache.NodeIDs() 197 require.Equal(t, 3, len(identities)) 198 require.ElementsMatch(t, identities, []flow.Identifier{nodeID1, nodeID2, nodeID3}) 199 } 200 201 // TestRecordCache_Remove tests the Remove method of the RecordCache. 202 // The test covers the following scenarios: 203 // 1. Initializing the cache with multiple records. 204 // 2. Removing a record and checking if it is removed correctly. 205 // 3. Ensuring the other records are still in the cache after removal. 206 // 4. Attempting to remove a non-existent node ID. 207 func TestRecordCache_Remove(t *testing.T) { 208 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 209 210 // initialize spam records for a few node IDs 211 nodeID1 := unittest.IdentifierFixture() 212 nodeID2 := unittest.IdentifierFixture() 213 nodeID3 := unittest.IdentifierFixture() 214 215 _, ok, err := cache.GetWithInit(nodeID1) 216 require.NoError(t, err) 217 require.True(t, ok) 218 _, ok, err = cache.GetWithInit(nodeID2) 219 require.NoError(t, err) 220 require.True(t, ok) 221 _, ok, err = cache.GetWithInit(nodeID3) 222 require.NoError(t, err) 223 require.True(t, ok) 224 225 numOfIds := uint(3) 226 require.Equal(t, numOfIds, cache.Size(), fmt.Sprintf("expected size of the cache to be %d", numOfIds)) 227 // remove nodeID1 and check if the record is removed 228 require.True(t, cache.Remove(nodeID1)) 229 require.NotContains(t, nodeID1, cache.NodeIDs()) 230 231 // check if the other node IDs are still in the cache 232 _, exists, err := cache.GetWithInit(nodeID2) 233 require.NoError(t, err) 234 require.True(t, exists) 235 _, exists, err = cache.GetWithInit(nodeID3) 236 require.NoError(t, err) 237 require.True(t, exists) 238 239 // attempt to remove a non-existent node ID 240 nodeID4 := unittest.IdentifierFixture() 241 require.False(t, cache.Remove(nodeID4)) 242 } 243 244 // TestRecordCache_ConcurrentRemove tests the concurrent removal of records for different node IDs. 245 // The test covers the following scenarios: 246 // 1. Multiple goroutines removing records for different node IDs concurrently. 247 // 2. The records are correctly removed from the cache. 248 func TestRecordCache_ConcurrentRemove(t *testing.T) { 249 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 250 251 nodeIDs := unittest.IdentifierListFixture(10) 252 for _, nodeID := range nodeIDs { 253 _, ok, err := cache.GetWithInit(nodeID) 254 require.NoError(t, err) 255 require.True(t, ok) 256 } 257 258 var wg sync.WaitGroup 259 wg.Add(len(nodeIDs)) 260 261 for _, nodeID := range nodeIDs { 262 go func(id flow.Identifier) { 263 defer wg.Done() 264 removed := cache.Remove(id) 265 require.True(t, removed) 266 require.NotContains(t, id, cache.NodeIDs()) 267 }(nodeID) 268 } 269 270 unittest.RequireReturnsBefore(t, wg.Wait, 100*time.Millisecond, "timed out waiting for goroutines to finish") 271 272 require.Equal(t, uint(0), cache.Size()) 273 } 274 275 // TestRecordCache_ConcurrentUpdatesAndReads tests the concurrent adjustments and reads of records for different 276 // node IDs. The test covers the following scenarios: 277 // 1. Multiple goroutines adjusting records for different node IDs concurrently. 278 // 2. Multiple goroutines getting records for different node IDs concurrently. 279 // 3. The adjusted records are correctly updated in the cache. 280 func TestRecordCache_ConcurrentUpdatesAndReads(t *testing.T) { 281 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 282 283 nodeIDs := unittest.IdentifierListFixture(10) 284 for _, nodeID := range nodeIDs { 285 _, ok, err := cache.GetWithInit(nodeID) 286 require.NoError(t, err) 287 require.True(t, ok) 288 } 289 290 var wg sync.WaitGroup 291 wg.Add(len(nodeIDs) * 2) 292 293 for _, nodeID := range nodeIDs { 294 // adjust spam records concurrently 295 go func(id flow.Identifier) { 296 defer wg.Done() 297 _, err := cache.ReceivedClusterPrefixedMessage(id) 298 require.NoError(t, err) 299 }(nodeID) 300 301 // get spam records concurrently 302 go func(id flow.Identifier) { 303 defer wg.Done() 304 _, found, err := cache.GetWithInit(id) 305 require.NoError(t, err) 306 require.True(t, found) 307 }(nodeID) 308 } 309 310 unittest.RequireReturnsBefore(t, wg.Wait, 100*time.Millisecond, "timed out waiting for goroutines to finish") 311 312 // ensure that the records are correctly updated in the cache 313 for _, nodeID := range nodeIDs { 314 gauge, found, err := cache.GetWithInit(nodeID) 315 require.NoError(t, err) 316 require.True(t, found) 317 // slight decay will result in 0.9 < gauge < 1 318 require.LessOrEqual(t, gauge, 1.0) 319 require.Greater(t, gauge, 0.9) 320 } 321 } 322 323 // TestRecordCache_ConcurrentInitAndRemove tests the concurrent initialization and removal of records for different 324 // node IDs. The test covers the following scenarios: 325 // 1. Multiple goroutines initializing records for different node IDs concurrently. 326 // 2. Multiple goroutines removing records for different node IDs concurrently. 327 // 3. The initialized records are correctly added to the cache. 328 // 4. The removed records are correctly removed from the cache. 329 func TestRecordCache_ConcurrentInitAndRemove(t *testing.T) { 330 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 331 332 nodeIDs := unittest.IdentifierListFixture(20) 333 nodeIDsToAdd := nodeIDs[:10] 334 nodeIDsToRemove := nodeIDs[10:] 335 336 for _, nodeID := range nodeIDsToRemove { 337 _, ok, err := cache.GetWithInit(nodeID) 338 require.NoError(t, err) 339 require.True(t, ok) 340 } 341 342 var wg sync.WaitGroup 343 wg.Add(len(nodeIDs)) 344 345 // initialize spam records concurrently 346 for _, nodeID := range nodeIDsToAdd { 347 go func(id flow.Identifier) { 348 defer wg.Done() 349 _, ok, err := cache.GetWithInit(id) 350 require.NoError(t, err) 351 require.True(t, ok) 352 }(nodeID) 353 } 354 355 // remove spam records concurrently 356 for _, nodeID := range nodeIDsToRemove { 357 go func(id flow.Identifier) { 358 defer wg.Done() 359 cache.Remove(id) 360 require.NotContains(t, id, cache.NodeIDs()) 361 }(nodeID) 362 } 363 364 unittest.RequireReturnsBefore(t, wg.Wait, 100*time.Millisecond, "timed out waiting for goroutines to finish") 365 366 // ensure that the initialized records are correctly added to the cache 367 // and removed records are correctly removed from the cache 368 require.ElementsMatch(t, nodeIDsToAdd, cache.NodeIDs()) 369 } 370 371 // TestRecordCache_ConcurrentInitRemoveUpdate tests the concurrent initialization, removal, and adjustment of 372 // records for different node IDs. The test covers the following scenarios: 373 // 1. Multiple goroutines initializing records for different node IDs concurrently. 374 // 2. Multiple goroutines removing records for different node IDs concurrently. 375 // 3. Multiple goroutines adjusting records for different node IDs concurrently. 376 func TestRecordCache_ConcurrentInitRemoveUpdate(t *testing.T) { 377 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 378 379 nodeIDs := unittest.IdentifierListFixture(30) 380 nodeIDsToAdd := nodeIDs[:10] 381 nodeIDsToRemove := nodeIDs[10:20] 382 nodeIDsToAdjust := nodeIDs[20:] 383 384 for _, nodeID := range nodeIDsToRemove { 385 _, ok, err := cache.GetWithInit(nodeID) 386 require.NoError(t, err) 387 require.True(t, ok) 388 } 389 390 var wg sync.WaitGroup 391 wg.Add(len(nodeIDs)) 392 393 // Initialize spam records concurrently 394 for _, nodeID := range nodeIDsToAdd { 395 go func(id flow.Identifier) { 396 defer wg.Done() 397 _, ok, err := cache.GetWithInit(id) 398 require.NoError(t, err) 399 require.True(t, ok) 400 }(nodeID) 401 } 402 403 // Remove spam records concurrently 404 for _, nodeID := range nodeIDsToRemove { 405 go func(id flow.Identifier) { 406 defer wg.Done() 407 cache.Remove(id) 408 require.NotContains(t, id, cache.NodeIDs()) 409 }(nodeID) 410 } 411 412 // Adjust spam records concurrently 413 for _, nodeID := range nodeIDsToAdjust { 414 go func(id flow.Identifier) { 415 defer wg.Done() 416 _, _ = cache.ReceivedClusterPrefixedMessage(id) 417 }(nodeID) 418 } 419 420 unittest.RequireReturnsBefore(t, wg.Wait, 100*time.Millisecond, "timed out waiting for goroutines to finish") 421 require.ElementsMatch(t, append(nodeIDsToAdd, nodeIDsToAdjust...), cache.NodeIDs()) 422 } 423 424 // TestRecordCache_EdgeCasesAndInvalidInputs tests the edge cases and invalid inputs for RecordCache methods. 425 // The test covers the following scenarios: 426 // 1. Initializing a record multiple times. 427 // 2. Adjusting a non-existent record. 428 // 3. Removing a record multiple times. 429 func TestRecordCache_EdgeCasesAndInvalidInputs(t *testing.T) { 430 cache := cacheFixture(t, 100, defaultDecay, zerolog.Nop(), metrics.NewNoopCollector()) 431 432 nodeIDs := unittest.IdentifierListFixture(20) 433 nodeIDsToAdd := nodeIDs[:10] 434 nodeIDsToRemove := nodeIDs[10:20] 435 436 for _, nodeID := range nodeIDsToRemove { 437 _, ok, err := cache.GetWithInit(nodeID) 438 require.NoError(t, err) 439 require.True(t, ok) 440 } 441 442 var wg sync.WaitGroup 443 wg.Add(len(nodeIDs) + 10) 444 445 // initialize spam records concurrently 446 for _, nodeID := range nodeIDsToAdd { 447 go func(id flow.Identifier) { 448 defer wg.Done() 449 retrieved, ok, err := cache.GetWithInit(id) 450 require.NoError(t, err) 451 require.True(t, ok) 452 require.Zero(t, retrieved) 453 }(nodeID) 454 } 455 456 // remove spam records concurrently 457 for _, nodeID := range nodeIDsToRemove { 458 go func(id flow.Identifier) { 459 defer wg.Done() 460 require.True(t, cache.Remove(id)) 461 require.NotContains(t, id, cache.NodeIDs()) 462 }(nodeID) 463 } 464 465 // call NodeIDs method concurrently 466 for i := 0; i < 10; i++ { 467 go func() { 468 defer wg.Done() 469 ids := cache.NodeIDs() 470 // the number of returned IDs should be less than or equal to the number of node IDs 471 require.True(t, len(ids) <= len(nodeIDs)) 472 // the returned IDs should be a subset of the node IDs 473 for _, id := range ids { 474 require.Contains(t, nodeIDs, id) 475 } 476 }() 477 } 478 unittest.RequireReturnsBefore(t, wg.Wait, 1*time.Second, "timed out waiting for goroutines to finish") 479 } 480 481 // recordFixture creates a new record entity with the given node id. 482 // Args: 483 // - id: the node id of the record. 484 // Returns: 485 // - RecordEntity: the created record entity. 486 func recordEntityFixture(id flow.Identifier) ClusterPrefixedMessagesReceivedRecord { 487 return ClusterPrefixedMessagesReceivedRecord{NodeID: id, Gauge: 0.0, lastUpdated: time.Now()} 488 } 489 490 // cacheFixture returns a new *RecordCache. 491 func cacheFixture(t *testing.T, sizeLimit uint32, recordDecay float64, logger zerolog.Logger, collector module.HeroCacheMetrics) *RecordCache { 492 recordFactory := func(id flow.Identifier) ClusterPrefixedMessagesReceivedRecord { 493 return recordEntityFixture(id) 494 } 495 config := &RecordCacheConfig{ 496 sizeLimit: sizeLimit, 497 logger: logger, 498 collector: collector, 499 recordDecay: recordDecay, 500 } 501 r, err := NewRecordCache(config, recordFactory) 502 require.NoError(t, err) 503 // expect cache to be empty 504 require.Equalf(t, uint(0), r.Size(), "cache size must be 0") 505 require.NotNil(t, r) 506 return r 507 }