@ -21,56 +21,61 @@ import org.apache.kafka.common.{TopicIdPartition, TopicPartition, Uuid}
@@ -21,56 +21,61 @@ import org.apache.kafka.common.{TopicIdPartition, TopicPartition, Uuid}
import org.apache.kafka.server.log.remote.storage.RemoteStorageManager.IndexType
import org.apache.kafka.server.log.remote.storage. { RemoteLogSegmentId , RemoteLogSegmentMetadata , RemoteStorageManager }
import org.apache.kafka.server.util.MockTime
import org.apache.kafka.storage.internals.log. { OffsetIndex , OffsetPosition , TimeIndex }
import org.apache.kafka.test.TestUtils
import org.junit.jupiter.api.Assertions._
import org.apache.kafka.storage.internals.log. { LogFileUtils , OffsetIndex , OffsetPosition , TimeIndex , TransactionIndex }
import kafka.utils.TestUtils
import org.apache.kafka.common.utils.Utils
import org.junit.jupiter.api.Assertions. { assertTrue , _ }
import org.junit.jupiter.api. { AfterEach , BeforeEach , Test }
import org.mockito.ArgumentMatchers
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito._
import org.slf4j. { Logger , LoggerFactory }
import java.io. { File , FileInputStream }
import java.nio.file.Files
import java.util.Collections
import java.util.concurrent. { CountDownLatch , Executors , TimeUnit }
import scala.collection.mutable
class RemoteIndexCacheTest {
val time = new MockTime ( )
val partition = new TopicPartition ( "foo" , 0 )
val idPartition = new TopicIdPartition ( Uuid . randomUuid ( ) , partition )
val logDir : File = TestUtils . tempDirectory ( "kafka-logs" )
val tpDir : File = new File ( logDir , partition . toString )
val brokerId = 1
val baseOffset = 45L
val lastOffset = 75L
val segmentSize = 1024
val rsm : RemoteStorageManager = mock ( classOf [ RemoteStorageManager ] )
val cache : RemoteIndexCache = new RemoteIndexCache ( remoteStorageManager = rsm , logDir = logDir . toString )
val remoteLogSegmentId = new RemoteLogSegmentId ( idPartition , Uuid . randomUuid ( ) )
val rlsMetadata : RemoteLogSegmentMetadata = new RemoteLogSegmentMetadata ( remoteLogSegmentId , baseOffset , lastOffset ,
time . milliseconds ( ) , brokerId , time . milliseconds ( ) , segmentSize , Collections . singletonMap ( 0 , 0L ) )
private val logger : Logger = LoggerFactory . getLogger ( classOf [ RemoteIndexCacheTest ] )
private val time = new MockTime ( )
private val partition = new TopicPartition ( "foo" , 0 )
private val brokerId = 1
private val baseOffset = 45L
private val lastOffset = 75L
private val segmentSize = 1024
private val rsm : RemoteStorageManager = mock ( classOf [ RemoteStorageManager ] )
private var cache : RemoteIndexCache = _
private var rlsMetadata : RemoteLogSegmentMetadata = _
private var logDir : File = _
private var tpDir : File = _
@BeforeEach
def setup ( ) : Unit = {
val idPartition = new TopicIdPartition ( Uuid . randomUuid ( ) , partition )
logDir = TestUtils . tempDir ( )
tpDir = new File ( logDir , idPartition . toString )
Files . createDirectory ( tpDir . toPath )
val txnIdxFile = new File ( tpDir , "txn-index" + UnifiedLog . TxnIndexFileSuffix )
txnIdxFile . createNewFile ( )
val remoteLogSegmentId = new RemoteLogSegmentId ( idPartition , Uuid . randomUuid ( ) )
rlsMetadata = new RemoteLogSegmentMetadata ( remoteLogSegmentId , baseOffset , lastOffset ,
time . milliseconds ( ) , brokerId , time . milliseconds ( ) , segmentSize , Collections . singletonMap ( 0 , 0L ) )
cache = new RemoteIndexCache ( remoteStorageManager = rsm , logDir = logDir . toString )
when ( rsm . fetchIndex ( any ( classOf [ RemoteLogSegmentMetadata ] ) , any ( classOf [ IndexType ] ) ) )
. thenAnswer ( ans => {
val metadata = ans . getArgument [ RemoteLogSegmentMetadata ] ( 0 )
val indexType = ans . getArgument [ IndexType ] ( 1 )
val maxEntries = ( metadata . endOffset ( ) - metadata . startOffset ( ) ) . asInstanceOf [ Int ]
val offsetIdx = new OffsetIndex ( new File ( tpDir , String . valueOf ( metadata . startOffset ( ) ) + UnifiedLog . IndexFileSuffix ) ,
metadata . startOffset ( ) , maxEntries * 8 )
val timeIdx = new TimeIndex ( new File ( tpDir , String . valueOf ( metadata . startOffset ( ) ) + UnifiedLog . TimeIndexFileSuffix ) ,
metadata . startOffset ( ) , maxEntries * 12 )
val offsetIdx = createOffsetIndexForSegmentMetadata ( metadata )
val timeIdx = createTimeIndexForSegmentMetadata ( metadata )
val trxIdx = createTxIndexForSegmentMetadata ( metadata )
maybeAppendIndexEntries ( offsetIdx , timeIdx )
indexType match {
case IndexType . OFFSET => new FileInputStream ( offsetIdx . file )
case IndexType . TIMESTAMP => new FileInputStream ( timeIdx . file )
case IndexType . TRANSACTION => new FileInputStream ( txnIdxF ile )
case IndexType . TRANSACTION => new FileInputStream ( trxIdx . f ile )
case IndexType . LEADER_EPOCH => // leader - epoch - cache is not accessed .
case IndexType . PRODUCER_SNAPSHOT => // producer - snapshot is not accessed .
}
@ -80,8 +85,13 @@ class RemoteIndexCacheTest {
@@ -80,8 +85,13 @@ class RemoteIndexCacheTest {
@AfterEach
def cleanup ( ) : Unit = {
reset ( rsm )
cache . entries . forEach ( ( _ , v ) => v . cleanup ( ) )
cache . close ( )
// the files created for the test will be deleted automatically on thread exit since we use temp dir
Utils . closeQuietly ( cache , "RemoteIndexCache created for unit test" )
// best effort to delete the per - test resource . Even if we don 't delete , it is ok because the parent directory
// will be deleted at the end of test .
Utils . delete ( logDir )
// Verify no lingering threads
TestUtils . assertNoNonDaemonThreads ( RemoteIndexCache . remoteLogIndexCacheCleanerThread )
}
@Test
@ -117,51 +127,60 @@ class RemoteIndexCacheTest {
@@ -117,51 +127,60 @@ class RemoteIndexCacheTest {
@Test
def testCacheEntryExpiry ( ) : Unit = {
val cache = new RemoteIndexCache ( maxSize = 2 , rsm , logDir = logDir . toString )
// close existing cache created in test setup before creating a new one
Utils . closeQuietly ( cache , "RemoteIndexCache created for unit test" )
cache = new RemoteIndexCache ( maxSize = 2 , rsm , logDir = logDir . toString )
val tpId = new TopicIdPartition ( Uuid . randomUuid ( ) , new TopicPartition ( "foo" , 0 ) )
val metadataList = generateRemoteLogSegmentMetadata ( size = 3 , tpId )
assertEquals ( 0 , cache . entries . size ( ) )
assertCacheSize ( 0 )
// getIndex for first time will call rsm # fetchIndex
cache . getIndexEntry ( metadataList . head )
assertEquals ( 1 , cache . entries . size ( ) )
assertCacheSize ( 1 )
// Calling getIndex on the same entry should not call rsm # fetchIndex again , but it should retrieve from cache
cache . getIndexEntry ( metadataList . head )
assertEquals ( 1 , cache . entries . size ( ) )
assertCacheSize ( 1 )
verifyFetchIndexInvocation ( count = 1 )
// Here a new key metadataList ( 1 ) is invoked , that should call rsm # fetchIndex , making the count to 2
cache . getIndexEntry ( metadataList . head )
cache . getIndexEntry ( metadataList ( 1 ) )
assertEquals ( 2 , cache . entries . size ( ) )
assertCacheSize ( 2 )
verifyFetchIndexInvocation ( count = 2 )
// g etting index for metadataList . last should call rsm # fetchIndex , but metadataList ( 1 ) is already in cache .
cache . getIndexEntry ( metadataList . last )
cache . getIndexEntry ( metadataList ( 1 ) )
assertEquals ( 2 , cache . entries . size ( ) )
assertTrue ( cache . entries . containsKey ( metadataList . last . remoteLogSegmentId ( ) . id ( ) ) )
assertTrue ( cache . entries . containsKey ( metadataList ( 1 ) . remoteLogSegmentId ( ) . id ( ) ) )
// G etting index for metadataList . last should call rsm # fetchIndex
// to populate this entry one of the other 2 entries will be evicted . We don 't know which one since it 's based on
// a probabilistic formula for Window TinyLfu . See docs for RemoteIndexCache
assertNotNull ( cache . getIndexEntry ( metadataList . last ) )
assertAtLeastOnePresent ( cache , metadataList ( 1 ) . remoteLogSegmentId ( ) . id ( ) , metadataList . head . remoteLogSegmentId ( ) . id ( ) )
assertCacheSize ( 2 )
verifyFetchIndexInvocation ( count = 3 )
// getting index for metadataList . head should call rsm # fetchIndex as that entry was expired earlier ,
// but metadataList ( 1 ) is already in cache .
cache . getIndexEntry ( metadataList ( 1 ) )
cache . getIndexEntry ( metadataList . head )
assertEquals ( 2 , cache . entries . size ( ) )
assertFalse ( cache . entries . containsKey ( metadataList . last . remoteLogSegmentId ( ) . id ( ) ) )
// getting index for last expired entry should call rsm # fetchIndex as that entry was expired earlier
val missingEntryOpt = {
metadataList . find ( segmentMetadata => {
val segmentId = segmentMetadata . remoteLogSegmentId ( ) . id ( )
! cache . internalCache . asMap ( ) . containsKey ( segmentId )
} )
}
assertFalse ( missingEntryOpt . isEmpty )
cache . getIndexEntry ( missingEntryOpt . get )
assertCacheSize ( 2 )
verifyFetchIndexInvocation ( count = 4 )
}
@Test
def testGetIndexAfterCacheClose ( ) : Unit = {
val cache = new RemoteIndexCache ( maxSize = 2 , rsm , logDir = logDir . toString )
// close existing cache created in test setup before creating a new one
Utils . closeQuietly ( cache , "RemoteIndexCache created for unit test" )
cache = new RemoteIndexCache ( maxSize = 2 , rsm , logDir = logDir . toString )
val tpId = new TopicIdPartition ( Uuid . randomUuid ( ) , new TopicPartition ( "foo" , 0 ) )
val metadataList = generateRemoteLogSegmentMetadata ( size = 3 , tpId )
assertEquals ( 0 , cache . entries . size ( ) )
assertCacheSize ( 0 )
cache . getIndexEntry ( metadataList . head )
assertEquals ( 1 , cache . entries . size ( ) )
assertCacheSize ( 1 )
verifyFetchIndexInvocation ( count = 1 )
cache . close ( )
@ -170,35 +189,188 @@ class RemoteIndexCacheTest {
@@ -170,35 +189,188 @@ class RemoteIndexCacheTest {
assertThrows ( classOf [ IllegalStateException ] , ( ) => cache . getIndexEntry ( metadataList . head ) )
}
@Test
def testCloseIsIdempotent ( ) : Unit = {
// generate and add entry to cache
val spyEntry = generateSpyCacheEntry ( )
cache . internalCache . put ( rlsMetadata . remoteLogSegmentId ( ) . id ( ) , spyEntry )
cache . close ( )
cache . close ( )
// verify that entry is only closed once
verify ( spyEntry ) . close ( )
}
@Test
def testCacheEntryIsDeletedOnInvalidation ( ) : Unit = {
def getIndexFileFromDisk ( suffix : String ) = {
Files . walk ( tpDir . toPath )
. filter ( Files . isRegularFile ( _ ) )
. filter ( path => path . getFileName . toString . endsWith ( suffix ) )
. findAny ( )
}
val internalIndexKey = rlsMetadata . remoteLogSegmentId ( ) . id ( )
val cacheEntry = generateSpyCacheEntry ( )
// verify index files on disk
assertTrue ( getIndexFileFromDisk ( UnifiedLog . IndexFileSuffix ) . isPresent , s" Offset index file should be present on disk at ${ tpDir . toPath } " )
assertTrue ( getIndexFileFromDisk ( UnifiedLog . TxnIndexFileSuffix ) . isPresent , s" Txn index file should be present on disk at ${ tpDir . toPath } " )
assertTrue ( getIndexFileFromDisk ( UnifiedLog . TimeIndexFileSuffix ) . isPresent , s" Time index file should be present on disk at ${ tpDir . toPath } " )
// add the spied entry into the cache , it will overwrite the non - spied entry
cache . internalCache . put ( internalIndexKey , cacheEntry )
// no expired entries yet
assertEquals ( 0 , cache . expiredIndexes . size , "expiredIndex queue should be zero at start of test" )
// invalidate the cache . it should async mark the entry for removal
cache . internalCache . invalidate ( internalIndexKey )
// wait until entry is marked for deletion
TestUtils . waitUntilTrue ( ( ) => cacheEntry . markedForCleanup ,
"Failed to mark cache entry for cleanup after invalidation" )
TestUtils . waitUntilTrue ( ( ) => cacheEntry . cleanStarted ,
"Failed to cleanup cache entry after invalidation" )
// first it will be marked for cleanup , second time markForCleanup is called when cleanup ( ) is called
verify ( cacheEntry , times ( 2 ) ) . markForCleanup ( )
// after that async it will be cleaned up
verify ( cacheEntry ) . cleanup ( )
// verify that index ( s ) rename is only called 1 time
verify ( cacheEntry . timeIndex ) . renameTo ( any ( classOf [ File ] ) )
verify ( cacheEntry . offsetIndex ) . renameTo ( any ( classOf [ File ] ) )
verify ( cacheEntry . txnIndex ) . renameTo ( any ( classOf [ File ] ) )
// verify no index files on disk
assertFalse ( getIndexFileFromDisk ( UnifiedLog . IndexFileSuffix ) . isPresent ,
s" Offset index file should not be present on disk at ${ tpDir . toPath } " )
assertFalse ( getIndexFileFromDisk ( UnifiedLog . TxnIndexFileSuffix ) . isPresent ,
s" Txn index file should not be present on disk at ${ tpDir . toPath } " )
assertFalse ( getIndexFileFromDisk ( UnifiedLog . TimeIndexFileSuffix ) . isPresent ,
s" Time index file should not be present on disk at ${ tpDir . toPath } " )
assertFalse ( getIndexFileFromDisk ( LogFileUtils . DELETED_FILE_SUFFIX ) . isPresent ,
s" Index file marked for deletion should not be present on disk at ${ tpDir . toPath } " )
}
@Test
def testClose ( ) : Unit = {
val spyEntry = generateSpyCacheEntry ( )
cache . internalCache . put ( rlsMetadata . remoteLogSegmentId ( ) . id ( ) , spyEntry )
// close the cache
cache . close ( )
// closing the cache should close the entry
verify ( spyEntry ) . close ( )
// close for all index entries must be invoked
verify ( spyEntry . txnIndex ) . close ( )
verify ( spyEntry . offsetIndex ) . close ( )
verify ( spyEntry . timeIndex ) . close ( )
// index files must not be deleted
verify ( spyEntry . txnIndex , times ( 0 ) ) . deleteIfExists ( )
verify ( spyEntry . offsetIndex , times ( 0 ) ) . deleteIfExists ( )
verify ( spyEntry . timeIndex , times ( 0 ) ) . deleteIfExists ( )
// verify cleaner thread is shutdown
assertTrue ( cache . cleanerThread . isShutdownComplete )
}
@Test
def testConcurrentReadWriteAccessForCache ( ) : Unit = {
val tpId = new TopicIdPartition ( Uuid . randomUuid ( ) , new TopicPartition ( "foo" , 0 ) )
val metadataList = generateRemoteLogSegmentMetadata ( size = 3 , tpId )
assertCacheSize ( 0 )
// getIndex for first time will call rsm # fetchIndex
cache . getIndexEntry ( metadataList . head )
assertCacheSize ( 1 )
verifyFetchIndexInvocation ( count = 1 , Seq ( IndexType . OFFSET , IndexType . TIMESTAMP ) )
reset ( rsm )
// Simulate a concurrency situation where one thread is reading the entry already present in the cache ( cache hit )
// and the other thread is reading an entry which is not available in the cache ( cache miss ) . The expected behaviour
// is for the former thread to succeed while latter is fetching from rsm .
// In this this test we simulate the situation using latches . We perform the following operations :
// 1. Start the CacheMiss thread and wait until it starts executing the rsm . fetchIndex
// 2. Block the CacheMiss thread inside the call to rsm . fetchIndex .
// 3. Start the CacheHit thread . Assert that it performs a successful read .
// 4. On completion of successful read by CacheHit thread , signal the CacheMiss thread to release it 's block .
// 5. Validate that the test passes . If the CacheMiss thread was blocking the CacheHit thread , the test will fail .
//
val latchForCacheHit = new CountDownLatch ( 1 )
val latchForCacheMiss = new CountDownLatch ( 1 )
val readerCacheHit = ( ( ) => {
// Wait for signal to start executing the read
logger . debug ( s" Waiting for signal to begin read from ${ Thread . currentThread ( ) } " )
latchForCacheHit . await ( )
val entry = cache . getIndexEntry ( metadataList . head )
assertNotNull ( entry )
// Signal the CacheMiss to unblock itself
logger . debug ( s" Signaling CacheMiss to unblock from ${ Thread . currentThread ( ) } " )
latchForCacheMiss . countDown ( )
} ) : Runnable
when ( rsm . fetchIndex ( any ( classOf [ RemoteLogSegmentMetadata ] ) , any ( classOf [ IndexType ] ) ) )
. thenAnswer ( _ => {
logger . debug ( s" Signaling CacheHit to begin read from ${ Thread . currentThread ( ) } " )
latchForCacheHit . countDown ( )
logger . debug ( s" Waiting for signal to complete rsm fetch from ${ Thread . currentThread ( ) } " )
latchForCacheMiss . await ( )
} )
val readerCacheMiss = ( ( ) => {
val entry = cache . getIndexEntry ( metadataList . last )
assertNotNull ( entry )
} ) : Runnable
val executor = Executors . newFixedThreadPool ( 2 )
try {
executor . submit ( readerCacheMiss : Runnable )
executor . submit ( readerCacheHit : Runnable )
assertTrue ( latchForCacheMiss . await ( 30 , TimeUnit . SECONDS ) )
} finally {
executor . shutdownNow ( )
}
}
@Test
def testReloadCacheAfterClose ( ) : Unit = {
val cache = new RemoteIndexCache ( maxSize = 2 , rsm , logDir = logDir . toString )
// close existing cache created in test setup before creating a new one
Utils . closeQuietly ( cache , "RemoteIndexCache created for unit test" )
cache = new RemoteIndexCache ( maxSize = 2 , rsm , logDir = logDir . toString )
val tpId = new TopicIdPartition ( Uuid . randomUuid ( ) , new TopicPartition ( "foo" , 0 ) )
val metadataList = generateRemoteLogSegmentMetadata ( size = 3 , tpId )
assertEquals ( 0 , cache . entries . size ( ) )
assertCacheSize ( 0 )
// getIndex for first time will call rsm # fetchIndex
cache . getIndexEntry ( metadataList . head )
assertEquals ( 1 , cache . entries . size ( ) )
assertCacheSize ( 1 )
// Calling getIndex on the same entry should not call rsm # fetchIndex again , but it should retrieve from cache
cache . getIndexEntry ( metadataList . head )
assertEquals ( 1 , cache . entries . size ( ) )
assertCacheSize ( 1 )
verifyFetchIndexInvocation ( count = 1 )
// Here a new key metadataList ( 1 ) is invoked , that should call rsm # fetchIndex , making the count to 2
cache . getIndexEntry ( metadataList ( 1 ) )
assertEquals ( 2 , cache . entries . size ( ) )
assertCacheSize ( 2 )
// Calling getIndex on the same entry should not call rsm # fetchIndex again , but it should retrieve from cache
cache . getIndexEntry ( metadataList ( 1 ) )
assertEquals ( 2 , cache . entries . size ( ) )
assertCacheSize ( 2 )
verifyFetchIndexInvocation ( count = 2 )
// Here a new key metadataList ( 2 ) is invoked , that should call rsm # fetchIndex , making the count to 2
// Here a new key metadataList ( 2 ) is invoked , that should call rsm # fetchIndex
// The cache max size is 2 , it will remove one entry and keep the overall size to 2
cache . getIndexEntry ( metadataList ( 2 ) )
assertEquals ( 2 , cache . entries . size ( ) )
assertCacheSize ( 2 )
// Calling getIndex on the same entry should not call rsm # fetchIndex again , but it should retrieve from cache
cache . getIndexEntry ( metadataList ( 2 ) )
assertEquals ( 2 , cache . entries . size ( ) )
assertCacheSize ( 2 )
verifyFetchIndexInvocation ( count = 3 )
// Close the cache
@ -206,8 +378,33 @@ class RemoteIndexCacheTest {
@@ -206,8 +378,33 @@ class RemoteIndexCacheTest {
// Reload the cache from the disk and check the cache size is same as earlier
val reloadedCache = new RemoteIndexCache ( maxSize = 2 , rsm , logDir = logDir . toString )
assertEquals ( 2 , reloadedCache . entries . size ( ) )
assertEquals ( 2 , reloadedCache . internalCache . asMap ( ) . size ( ) )
reloadedCache . close ( )
verifyNoMoreInteractions ( rsm )
}
private def generateSpyCacheEntry ( ) : Entry = {
val timeIndex = spy ( createTimeIndexForSegmentMetadata ( rlsMetadata ) )
val txIndex = spy ( createTxIndexForSegmentMetadata ( rlsMetadata ) )
val offsetIndex = spy ( createOffsetIndexForSegmentMetadata ( rlsMetadata ) )
spy ( new Entry ( offsetIndex , timeIndex , txIndex ) )
}
private def assertAtLeastOnePresent ( cache : RemoteIndexCache , uuids : Uuid * ) : Unit = {
uuids . foreach {
uuid => {
if ( cache . internalCache . asMap ( ) . containsKey ( uuid ) ) return
}
}
fail ( "all uuids are not present in cache" )
}
private def assertCacheSize ( expectedSize : Int ) : Unit = {
// Cache may grow beyond the size temporarily while evicting , hence , run in a loop to validate
// that cache reaches correct state eventually
TestUtils . waitUntilTrue ( ( ) => cache . internalCache . asMap ( ) . size ( ) == expectedSize ,
msg = s" cache did not adhere to expected size of $expectedSize " )
}
private def verifyFetchIndexInvocation ( count : Int ,
@ -218,6 +415,24 @@ class RemoteIndexCacheTest {
@@ -218,6 +415,24 @@ class RemoteIndexCacheTest {
}
}
private def createTxIndexForSegmentMetadata ( metadata : RemoteLogSegmentMetadata ) : TransactionIndex = {
val txnIdxFile = new File ( tpDir , "txn-index" + UnifiedLog . TxnIndexFileSuffix )
txnIdxFile . createNewFile ( )
new TransactionIndex ( metadata . startOffset ( ) , txnIdxFile )
}
private def createTimeIndexForSegmentMetadata ( metadata : RemoteLogSegmentMetadata ) : TimeIndex = {
val maxEntries = ( metadata . endOffset ( ) - metadata . startOffset ( ) ) . asInstanceOf [ Int ]
new TimeIndex ( new File ( tpDir , String . valueOf ( metadata . startOffset ( ) ) + UnifiedLog . TimeIndexFileSuffix ) ,
metadata . startOffset ( ) , maxEntries * 12 )
}
private def createOffsetIndexForSegmentMetadata ( metadata : RemoteLogSegmentMetadata ) = {
val maxEntries = ( metadata . endOffset ( ) - metadata . startOffset ( ) ) . asInstanceOf [ Int ]
new OffsetIndex ( new File ( tpDir , String . valueOf ( metadata . startOffset ( ) ) + UnifiedLog . IndexFileSuffix ) ,
metadata . startOffset ( ) , maxEntries * 8 )
}
private def generateRemoteLogSegmentMetadata ( size : Int ,
tpId : TopicIdPartition ) : List [ RemoteLogSegmentMetadata ] = {
val metadataList = mutable . Buffer . empty [ RemoteLogSegmentMetadata ]