synchronized block and a mutable map:class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher ) { private val details = mutableMapOf<Company, CompanyDetails>() private val lock = Any() suspend fun getDetails(company: Company): CompanyDetails { val current = getDetailsOrNull(company) if (current == null) { val companyDetails = client.fetchDetails(company) synchronized(lock) { details[company] = companyDetails } return companyDetails } return current } fun getDetailsOrNull(company: Company): CompanyDetails? = synchronized(lock) { details[company] } fun getReadyDetails(): Map<Company, CompanyDetails> = synchronized(lock) { details.toMap() } fun clear() = synchronized(lock) { details.clear() } }
synchronized block and a read-only map:class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher ) { @Volatile private var details = mapOf<Company, CompanyDetails>() private val lock = Any() suspend fun getDetails(company: Company): CompanyDetails { val current = getDetailsOrNull(company) if (current == null) { val companyDetails = client.fetchDetails(company) synchronized(lock) { details = details + (company to companyDetails) } return companyDetails } return current } // Access to value is atomic so we don't need to synchronize fun getDetailsOrNull(company: Company): CompanyDetails? = details[company] // Access to value is atomic so we don't need to synchronize fun getReadyDetails(): Map<Company, CompanyDetails> = details // Setting value is atomic so we don't need to synchronize fun clear() { details = emptyMap() } }
class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher, ) { private val details = mutableMapOf<Company, CompanyDetails>() private val dispatcher = dispatcher.limitedParallelism(1) suspend fun getDetails(company: Company): CompanyDetails = withContext(dispatcher) { val current = getDetailsOrNull(company) if (current == null) { val companyDetails = client.fetchDetails(company) details[company] = companyDetails companyDetails } else { current } } suspend fun getDetailsOrNull( company: Company ): CompanyDetails? = withContext(dispatcher) { details[company] } suspend fun getReadyDetails(): Map<Company, CompanyDetails> = withContext(dispatcher) { details.toMap() } suspend fun clear() = withContext(dispatcher) { details.clear() } }
class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher, ) { @Volatile private var details = mapOf<Company, CompanyDetails>() private val dispatcher = dispatcher.limitedParallelism(1) suspend fun getDetails(company: Company): CompanyDetails = withContext(dispatcher) { val current = details[company] if (current == null) { val companyDetails = client.fetchDetails(company) details += (company to companyDetails) companyDetails } else { current } } fun getDetailsOrNull(company: Company): CompanyDetails? = details[company] fun getReadyDetails(): Map<Company, CompanyDetails> = details fun clear() { details = emptyMap() } }
AtomicReference and read-only map:class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher ) { private var details = AtomicReference<Map<Company, CompanyDetails>>(emptyMap()) suspend fun getDetails(company: Company): CompanyDetails { val current = getDetailsOrNull(company) if (current == null) { val companyDetails = client.fetchDetails(company) details.updateAndGet { it + (company to companyDetails) } return companyDetails } return current } fun getDetailsOrNull(company: Company): CompanyDetails? = details.get()[company] fun getReadyDetails(): Map<Company, CompanyDetails> = details.get() fun clear() { details.set(emptyMap()) } }
updateAndGet will recalculate its update. Since adding an element to a read-only collection is heavy, that is not a good solution.java.util.concurrent package:class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher, ) { private var details = ConcurrentHashMap<Company, CompanyDetails>() suspend fun getDetails(company: Company): CompanyDetails { val current = details[company] if (current == null) { val companyDetails = client.fetchDetails(company) details[company] = companyDetails return companyDetails } return current } fun getDetailsOrNull(company: Company): CompanyDetails? = details[company] fun getReadyDetails(): Map<Company, CompanyDetails> = details.toMap() fun clear() { details.clear() } }
class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher ) { private val details = mutableMapOf<Company, CompanyDetails>() private val mutex = Mutex() suspend fun getDetails(company: Company): CompanyDetails { val current = getDetailsOrNull(company) if (current == null) { val companyDetails = client.fetchDetails(company) mutex.withLock { details[company] = companyDetails } return companyDetails } return current } suspend fun getDetailsOrNull( company: Company ): CompanyDetails? = mutex.withLock { details[company] } suspend fun getReadyDetails(): Map<Company, CompanyDetails> = mutex.withLock { details.toMap() } suspend fun clear() = mutex.withLock { details.clear() } }
suspendLazy implementation from the exercise Suspended lazy):class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher, ) { private var details = ConcurrentHashMap<Company, SuspendLazy<CompanyDetails>>() suspend fun getDetails(company: Company): CompanyDetails { details.computeIfAbsent(company) { suspendLazy { client.fetchDetails(company) } } return details[company]!!.invoke() } fun getDetailsOrNull(company: Company): CompanyDetails? = details[company]?.valueOrNull() fun getReadyDetails(): Map<Company, CompanyDetails> = details .toMap() .mapNotNull { (k, v) -> v.valueOrNull()?.let { k to it } } .toMap() fun clear() { details.clear() } }
class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher, ) { private var cache = cacheBuilder<Company, CompanyDetails> { this.dispatcher = dispatcher }.build() suspend fun getDetails(company: Company): CompanyDetails = cache.get(company) { client.fetchDetails(company) } fun getDetailsOrNull(company: Company): CompanyDetails? = cache.getOrNull(company) fun getReadyDetails(): Map<Company, CompanyDetails> = cache.asDeferredMap() .filter { it.value.isCompleted } .mapValues { it.value.getCompleted() } fun clear() { cache.invalidateAll() } }
|-----------------------------------------|-----------|-----------------|-----------------|
| Synchronization block, mutable map | 6 | 2 | 4417 |
| Synchronization block, read-only map | 2 | 1 | 1 |
| Mutex, mutable map | 11 | 3 | 4193 |
| Mutex, read-only map | 2 | 1 | 1 |
| Single thread dispatcher, mutable map | 19 | 5 | 7925 |
| Single thread dispatcher, read-only map | 12 | 1 | 1 |
| ConcurrentHashMap | 2 | 1 | 1260 |
| Suspended lazy map | 2 | 2 | 3159 |
| Cache | 2 | 1 | 4970 |
import kotlinx.coroutines.* import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import java.math.BigDecimal import org.junit.Ignore import org.junit.Test import kotlin.test.assertEquals import kotlin.time.measureTime class CompanyDetailsRepository( private val client: CompanyDetailsClient, dispatcher: CoroutineDispatcher ) { private val details = mutableMapOf<Company, CompanyDetails>() private val lock = Any() suspend fun getDetails(company: Company): CompanyDetails { val current = getDetailsOrNull(company) if (current == null) { val companyDetails = client.fetchDetails(company) synchronized(lock) { details[company] = companyDetails } return companyDetails } return current } fun getDetailsOrNull(company: Company): CompanyDetails? = synchronized(lock) { details[company] } fun getReadyDetails(): Map<Company, CompanyDetails> = synchronized(lock) { details.toMap() } fun clear() = synchronized(lock) { details.clear() } } // Run in main suspend fun performanceTest(): Unit = coroutineScope { val companies = (0 until 100_000).map { Company(it.toString()) } val client = FakeCompanyDetailsClient( details = buildMap { companies.forEach { put(it, CompanyDetails("Company${it.id}", "Address${it.id}", BigDecimal.TEN)) } } ) val repository = CompanyDetailsRepository(client, Dispatchers.IO) val dispatcher = newFixedThreadPoolContext(100, "test") // The time of getting and storing details measureTime { companies.map { async(dispatcher) { repository.getDetails(it) } }.awaitAll() }.also { val averageTime = it.inWholeNanoseconds / companies.size println("Average time of getting details: $averageTime ns") } // The time of getting details from cache measureTime { companies.map { async(dispatcher) { repository.getDetailsOrNull(it) } }.awaitAll() }.also { val averageTime = it.inWholeNanoseconds / companies.size println("Average time of getting details from cache: $averageTime ns") } // The time of getting all details val repeats = 1000 measureTime { coroutineScope { repeat(repeats) { launch(dispatcher) { repository.getReadyDetails() } } } }.also { val averageTime = it.inWholeNanoseconds / repeats println("Time of getting all details: $averageTime ns") } } interface CompanyDetailsClient { suspend fun fetchDetails(company: Company): CompanyDetails } data class CompanyDetails(val name: String, val address: String, val revenue: BigDecimal) data class Company(val id: String) @OptIn(ExperimentalStdlibApi::class) class CompanyDetailsRepositoryTest { @Test fun `detailsFor should fetch details from client`() = runTest { // given val company = Company("1") val details = CompanyDetails("Company", "Address", BigDecimal.TEN) val client = FakeCompanyDetailsClient() client.setDetails(company, details) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) // when val result = repository.getDetails(company) // then assertEquals(details, result) } @Test fun `detailsFor should cache details`() = runTest { // given val company = Company("1") val details = CompanyDetails("Company", "Address", BigDecimal.TEN) val client = FakeCompanyDetailsClient( details = mapOf(company to details), delay = 1000 ) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) // when val result1 = repository.getDetails(company) // then assertEquals(details, result1) assertEquals(1000, currentTime) // when client.clear() // then val result = repository.getDetails(company) assertEquals(details, result) } @Test fun `getReadyDetails should return all details`() = runTest { // given val company1 = Company("1") val company2 = Company("2") val details1 = CompanyDetails("Company1", "Address1", BigDecimal.TEN) val details2 = CompanyDetails("Company2", "Address2", BigDecimal.ONE) val client = FakeCompanyDetailsClient( details = mapOf(company1 to details1, company2 to details2) ) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) // when repository.getDetails(company1) repository.getDetails(company2) val result = repository.getReadyDetails() // then assertEquals(mapOf(company1 to details1, company2 to details2), result) } @Test fun `getReadyDetails should fetch details asynchronously`() = runTest { // given val company1 = Company("1") val company2 = Company("2") val details1 = CompanyDetails("Company1", "Address1", BigDecimal.TEN) val details2 = CompanyDetails("Company2", "Address2", BigDecimal.ONE) val client = FakeCompanyDetailsClient( details = mapOf(company1 to details1, company2 to details2), delay = 1000, ) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) // when coroutineScope { launch { repository.getDetails(company1) assertEquals(1000, currentTime) } launch { repository.getDetails(company2) assertEquals(1000, currentTime) } } val result = repository.getReadyDetails() // then assertEquals(mapOf(company1 to details1, company2 to details2), result) assertEquals(1000, currentTime) } @Test fun `should not expose internal collection`() = runTest { // given val company = Company("1") val details = CompanyDetails("Company", "Address", BigDecimal.TEN) val client = FakeCompanyDetailsClient( details = mapOf(company to details) ) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) val detailsMap = repository.getReadyDetails() // when repository.getDetails(company) // then assertEquals(mapOf(), detailsMap) } @Test fun `getDetailsOrNull should return details if exists`() = runTest { // given val company = Company("1") val company2 = Company("2") val details = CompanyDetails("Company", "Address", BigDecimal.TEN) val client = FakeCompanyDetailsClient( details = mapOf(company to details) ) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) // then assertEquals(null, repository.getDetailsOrNull(company)) assertEquals(null, repository.getDetailsOrNull(company2)) // when repository.getDetails(company) // then assertEquals(details, repository.getDetailsOrNull(company)) assertEquals(null, repository.getDetailsOrNull(company2)) } // Synchronization tests @Test fun `should cache all details`() = runBlocking(Dispatchers.IO) { val parallelCalls = 10_000 val companies = (0 until parallelCalls).map { Company(it.toString()) } val client = FakeCompanyDetailsClient( details = buildMap { companies.forEach { put(it, CompanyDetails("Company${it.id}", "Address${it.id}", BigDecimal.TEN)) } } ) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) coroutineScope { for (company in companies) { launch { val details = repository.getDetails(company) assertEquals( CompanyDetails("Company${company.id}", "Address${company.id}", BigDecimal.TEN), details ) } } } assertEquals(parallelCalls, repository.getReadyDetails().size) } @Test fun `should not have conflict when changing details and getting all`() = runBlocking(Dispatchers.IO) { val parallelCalls = 10_000 val companies = (0 until parallelCalls).map { Company(it.toString()) } val client = FakeCompanyDetailsClient( details = buildMap { companies.forEach { put(it, CompanyDetails("Company${it.id}", "Address${it.id}", BigDecimal.TEN)) } } ) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) for (company in companies) { launch { repository.getDetails(company) } } repeat(1000) { launch { repository.getReadyDetails() } } } @Test @Ignore fun `should not fetch the same details twice`() = runTest { val company = Company("1") val details = CompanyDetails("Company", "Address", BigDecimal.TEN) val client = FakeCompanyDetailsClient( details = mapOf(company to details), delay = 1000 ) val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!) coroutineScope { launch { repository.getDetails(company) } launch { repository.getDetails(company) } } assertEquals(1, client.calls) } } class FakeCompanyDetailsClient( details: Map<Company, CompanyDetails> = emptyMap(), var delay: Long = 0, ) : CompanyDetailsClient { private val details: MutableMap<Company, CompanyDetails> = details.toMutableMap() var calls = 0 private set override suspend fun fetchDetails(company: Company): CompanyDetails { calls++ delay(delay) return details[company] ?: error("Company not found") } fun setDetails(company: Company, details: CompanyDetails) { this.details[company] = details } fun clear() { details.clear() delay = 0 } }