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  }