Skip to content

Commit 1c8bcbf

Browse files
committed
perf: bound SABR memory caches
1 parent 75fad74 commit 1c8bcbf

10 files changed

Lines changed: 254 additions & 37 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package dev.typetype.server.services
2+
3+
import java.time.Duration
4+
import java.util.LinkedHashMap
5+
6+
internal class BoundedExpiringCache<K, V>(
7+
private val maxEntries: Int,
8+
private val maxWeight: Long = Long.MAX_VALUE,
9+
ttl: Duration,
10+
private val weigher: (V) -> Long = { 1L },
11+
private val clock: () -> Long = System::currentTimeMillis,
12+
) {
13+
private val ttlMs = ttl.toMillis()
14+
private val entries = LinkedHashMap<K, Entry<V>>(maxEntries.coerceAtMost(64), 0.75f, true)
15+
private var weight = 0L
16+
17+
init {
18+
require(maxEntries > 0) { "maxEntries must be positive" }
19+
require(maxWeight > 0L) { "maxWeight must be positive" }
20+
require(!ttl.isNegative && !ttl.isZero) { "ttl must be positive" }
21+
}
22+
23+
@Synchronized
24+
fun get(key: K): V? {
25+
evictExpired(clock())
26+
return entries[key]?.value
27+
}
28+
29+
@Synchronized
30+
fun put(key: K, value: V) {
31+
val now = clock()
32+
evictExpired(now)
33+
removeEntry(key)
34+
val entryWeight = weigher(value).coerceAtLeast(0L)
35+
if (entryWeight > maxWeight) return
36+
entries[key] = Entry(value, expiresAt(now), entryWeight)
37+
weight += entryWeight
38+
trim()
39+
}
40+
41+
@Synchronized
42+
fun remove(key: K): V? = removeEntry(key)?.value
43+
44+
@Synchronized
45+
fun removeIf(predicate: (K) -> Boolean) {
46+
val iterator = entries.iterator()
47+
while (iterator.hasNext()) {
48+
val entry = iterator.next()
49+
if (!predicate(entry.key)) continue
50+
weight -= entry.value.weight
51+
iterator.remove()
52+
}
53+
}
54+
55+
@Synchronized
56+
fun evictExpired() {
57+
evictExpired(clock())
58+
}
59+
60+
@Synchronized
61+
fun clear() {
62+
entries.clear()
63+
weight = 0L
64+
}
65+
66+
@Synchronized
67+
internal fun size(): Int = entries.size
68+
69+
@Synchronized
70+
internal fun weight(): Long = weight
71+
72+
private fun expiresAt(now: Long): Long =
73+
if (Long.MAX_VALUE - now < ttlMs) Long.MAX_VALUE else now + ttlMs
74+
75+
private fun evictExpired(now: Long) {
76+
val iterator = entries.iterator()
77+
while (iterator.hasNext()) {
78+
val entry = iterator.next().value
79+
if (entry.expiresAtMs > now) continue
80+
weight -= entry.weight
81+
iterator.remove()
82+
}
83+
}
84+
85+
private fun trim() {
86+
val iterator = entries.iterator()
87+
while ((entries.size > maxEntries || weight > maxWeight) && iterator.hasNext()) {
88+
weight -= iterator.next().value.weight
89+
iterator.remove()
90+
}
91+
}
92+
93+
private fun removeEntry(key: K): Entry<V>? =
94+
entries.remove(key)?.also { weight -= it.weight }
95+
96+
private data class Entry<V>(val value: V, val expiresAtMs: Long, val weight: Long)
97+
}
Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
11
package dev.typetype.server.services
22

3-
import kotlinx.serialization.Serializable
4-
import kotlinx.serialization.Transient
53
import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment
64
import java.util.Base64
75

8-
@Serializable
9-
internal data class CachedSabrSegment(
6+
internal class CachedSabrSegment(
107
val itag: Int,
118
val sequence: Int,
129
val init: Boolean,
1310
val startMs: Long,
1411
val durationMs: Long,
1512
val mimeType: String,
16-
val bytesBase64: String,
17-
val byteLength: Int = -1,
13+
val bytes: ByteArray,
1814
) {
19-
@Transient
20-
private var decodedBytes: ByteArray? = null
21-
val bytes: ByteArray get() = decodedBytes ?: Base64.getDecoder().decode(bytesBase64).also { decodedBytes = it }
22-
val length: Int get() = byteLength.takeIf { it >= 0 } ?: bytes.size
15+
constructor(
16+
itag: Int,
17+
sequence: Int,
18+
init: Boolean,
19+
startMs: Long,
20+
durationMs: Long,
21+
mimeType: String,
22+
bytesBase64: String,
23+
byteLength: Int = -1,
24+
) : this(
25+
itag,
26+
sequence,
27+
init,
28+
startMs,
29+
durationMs,
30+
mimeType,
31+
Base64.getDecoder().decode(bytesBase64).also {
32+
require(byteLength < 0 || byteLength == it.size) { "byteLength does not match decoded bytes" }
33+
},
34+
)
35+
36+
val length: Int get() = bytes.size
2337
}
2438

2539
internal fun SabrMediaSegment.toCachedSabrSegment(
@@ -32,6 +46,5 @@ internal fun SabrMediaSegment.toCachedSabrSegment(
3246
startMs = header.startMs,
3347
durationMs = header.durationMs,
3448
mimeType = mimeType,
35-
bytesBase64 = Base64.getEncoder().encodeToString(bytes),
36-
byteLength = bytes.size,
49+
bytes = bytes,
3750
)

src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ internal class SabrInfoFetcher(
6868
fun initializationFormat(videoId: String, target: YoutubeSabrFormat): YoutubeSabrFormat? =
6969
repository.initializationFormat(videoId, target)
7070

71+
fun evictExpired(): Unit = repository.evictExpired()
72+
7173
private suspend fun fetchPlayable(videoId: String, startTimeMs: Long): SabrPreparedInfo? =
7274
fetchInfoOnce(videoId, startTimeMs)?.let { repository.putPrepared(videoId, startTimeMs, it) }
7375

src/main/kotlin/dev/typetype/server/services/SabrInfoRepository.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ package dev.typetype.server.services
33
import dev.typetype.server.cache.CacheService
44
import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat
55
import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo
6-
import java.util.concurrent.ConcurrentHashMap
6+
import java.time.Duration
77

88
internal class SabrInfoRepository(
99
infoCache: SabrPreparedInfoCache,
1010
sharedCache: CacheService?,
1111
) {
1212
private val preparedInfos = infoCache
1313
private val sharedInfos = SabrInfoSharedCache(sharedCache)
14-
private val initializationInfos = ConcurrentHashMap<String, YoutubeSabrInfo>()
14+
private val initializationInfos = BoundedExpiringCache<String, YoutubeSabrInfo>(
15+
maxEntries = 256,
16+
ttl = Duration.ofHours(6),
17+
)
1518

1619
fun local(videoId: String, startTimeMs: Long): SabrPreparedInfo? {
1720
val cachedAtStart = preparedInfos.get(videoId, startTimeMs)
@@ -23,15 +26,15 @@ internal class SabrInfoRepository(
2326
}
2427

2528
suspend fun shared(videoId: String, token: SabrTokenBundle): SabrPreparedInfo? {
26-
sharedInfos.getInitialization(videoId)?.let { initializationInfos[videoId] = it }
29+
sharedInfos.getInitialization(videoId)?.let { initializationInfos.put(videoId, it) }
2730
val info = sharedInfos.getPlayback(videoId) ?: return null
2831
if (info.visitorData != token.visitorData) return null
2932
return putPrepared(videoId, startTimeMs = 0L, SabrPreparedInfo(info, token), share = false)
3033
}
3134

3235
suspend fun rememberInitialization(videoId: String, info: YoutubeSabrInfo): Unit {
3336
if (!SabrPreparedInfo(info, null).hasAudioAndVideoFormats()) return
34-
initializationInfos[videoId] = info
37+
initializationInfos.put(videoId, info)
3538
sharedInfos.putInitialization(videoId, info)
3639
}
3740

@@ -52,7 +55,12 @@ internal class SabrInfoRepository(
5255
}
5356

5457
fun initializationFormat(videoId: String, target: YoutubeSabrFormat): YoutubeSabrFormat? =
55-
initializationInfos[videoId]?.formats?.firstOrNull {
58+
initializationInfos.get(videoId)?.formats?.firstOrNull {
5659
it.itag == target.itag && it.audioTrackId == target.audioTrackId && it.xtags == target.xtags
5760
}
61+
62+
fun evictExpired() {
63+
preparedInfos.evictExpired()
64+
initializationInfos.evictExpired()
65+
}
5866
}

src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@ import org.schabi.newpipe.extractor.localization.Localization
66
import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest
77
import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat
88
import java.security.MessageDigest
9+
import java.time.Duration
910
import java.util.Base64
1011
import java.util.Collections
1112
import java.util.WeakHashMap
12-
import java.util.concurrent.ConcurrentHashMap
1313

1414
internal object SabrInitializationData {
1515
private const val CACHE_TTL_SECONDS = 21_600L
16-
private val memoryCache = ConcurrentHashMap<String, ByteArray>()
16+
private val memoryCache = BoundedExpiringCache<String, ByteArray>(
17+
maxEntries = 512,
18+
maxWeight = 32L * 1024L * 1024L,
19+
ttl = Duration.ofSeconds(CACHE_TTL_SECONDS),
20+
weigher = { it.size.toLong() },
21+
)
1722
private val formatCache = Collections.synchronizedMap(WeakHashMap<YoutubeSabrFormat, ByteArray>())
1823

1924
suspend fun ingest(
@@ -28,12 +33,12 @@ internal object SabrInitializationData {
2833
suspend fun fetch(videoId: String, format: YoutubeSabrFormat, cache: CacheService? = null): ByteArray? {
2934
val key = cacheKey(videoId, format)
3035
formatCache[format]?.let { return it }
31-
memoryCache[key]?.let {
36+
memoryCache.get(key)?.let {
3237
formatCache[format] = it
3338
return it
3439
}
3540
cache?.getBytes(key)?.let { bytes ->
36-
memoryCache[key] = bytes
41+
memoryCache.put(key, bytes)
3742
formatCache[format] = bytes
3843
return bytes
3944
}
@@ -47,7 +52,7 @@ internal object SabrInitializationData {
4752
cache: CacheService? = null,
4853
): Unit {
4954
val key = cacheKey(videoId, format)
50-
memoryCache[key] = bytes
55+
memoryCache.put(key, bytes)
5156
formatCache[format] = bytes
5257
cache?.setBytes(key, bytes, CACHE_TTL_SECONDS)
5358
}
@@ -57,6 +62,8 @@ internal object SabrInitializationData {
5762
return holder.session.streamState.ingestInitializationData(format, bytes)
5863
}
5964

65+
fun evictExpired(): Unit = memoryCache.evictExpired()
66+
6067
suspend fun bootstrap(
6168
holder: SabrSessionHolder,
6269
format: YoutubeSabrFormat,

src/main/kotlin/dev/typetype/server/services/SabrPreparedInfoCache.kt

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,41 @@
11
package dev.typetype.server.services
22

33
import java.time.Duration
4-
import java.time.Instant
5-
import java.util.concurrent.ConcurrentHashMap
64

75
internal class SabrPreparedInfoCache(
86
private val ttl: Duration = Duration.ofMinutes(10),
7+
maxEntries: Int = 256,
8+
clock: () -> Long = System::currentTimeMillis,
99
) {
10-
private val items = ConcurrentHashMap<Key, Entry>()
10+
private val items = BoundedExpiringCache<Key, SabrPreparedInfo>(
11+
maxEntries = maxEntries,
12+
ttl = ttl,
13+
clock = clock,
14+
)
1115

1216
fun get(videoId: String, startTimeMs: Long): SabrPreparedInfo? {
13-
val key = Key(videoId, startBucket(startTimeMs))
14-
val entry = items[key] ?: return null
15-
if (entry.createdAt.plus(ttl).isBefore(Instant.now())) {
16-
items.remove(key, entry)
17-
return null
18-
}
19-
return entry.value
17+
return items.get(Key(videoId, startBucket(startTimeMs)))
2018
}
2119

2220
fun remove(videoId: String, startTimeMs: Long): Unit {
2321
items.remove(Key(videoId, startBucket(startTimeMs)))
2422
}
2523

2624
fun remove(videoId: String): Unit {
27-
items.keys.removeIf { it.videoId == videoId }
25+
items.removeIf { it.videoId == videoId }
2826
}
2927

3028
fun put(videoId: String, startTimeMs: Long, value: SabrPreparedInfo): SabrPreparedInfo {
31-
items[Key(videoId, startBucket(startTimeMs))] = Entry(value, Instant.now())
29+
items.put(Key(videoId, startBucket(startTimeMs)), value)
3230
return value
3331
}
3432

33+
fun evictExpired(): Unit = items.evictExpired()
34+
3535
private fun startBucket(startTimeMs: Long): Long = startTimeMs.coerceAtLeast(0L) / START_BUCKET_MS
3636

3737
private data class Key(val videoId: String, val startBucket: Long)
3838

39-
private data class Entry(val value: SabrPreparedInfo, val createdAt: Instant)
40-
4139
private companion object {
4240
const val START_BUCKET_MS = 30_000L
4341
}

src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ internal class SabrSessionStore(
206206
while (true) {
207207
delay(15_000)
208208
registry.evictIdle(Instant.now().minus(idleEviction))
209+
infoFetcher.evictExpired()
210+
SabrInitializationData.evictExpired()
209211
}
210212
}
211213
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package dev.typetype.server.services
2+
3+
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Assertions.assertNull
5+
import org.junit.jupiter.api.Test
6+
import java.time.Duration
7+
8+
class BoundedExpiringCacheTest {
9+
@Test
10+
fun `least recently used entry is removed at capacity`() {
11+
val cache = BoundedExpiringCache<String, String>(
12+
maxEntries = 2,
13+
ttl = Duration.ofMinutes(1),
14+
)
15+
cache.put("first", "1")
16+
cache.put("second", "2")
17+
cache.get("first")
18+
19+
cache.put("third", "3")
20+
21+
assertEquals("1", cache.get("first"))
22+
assertNull(cache.get("second"))
23+
assertEquals("3", cache.get("third"))
24+
}
25+
26+
@Test
27+
fun `weight limit removes oldest entries`() {
28+
val cache = BoundedExpiringCache<String, String>(
29+
maxEntries = 10,
30+
maxWeight = 5,
31+
ttl = Duration.ofMinutes(1),
32+
weigher = { it.length.toLong() },
33+
)
34+
cache.put("first", "123")
35+
36+
cache.put("second", "456")
37+
38+
assertNull(cache.get("first"))
39+
assertEquals("456", cache.get("second"))
40+
assertEquals(3, cache.weight())
41+
}
42+
43+
@Test
44+
fun `new writes purge expired entries without reading their keys`() {
45+
var now = 0L
46+
val cache = BoundedExpiringCache<String, String>(
47+
maxEntries = 10,
48+
ttl = Duration.ofMillis(10),
49+
clock = { now },
50+
)
51+
cache.put("expired", "old")
52+
now = 10L
53+
54+
cache.put("current", "new")
55+
56+
assertNull(cache.get("expired"))
57+
assertEquals(1, cache.size())
58+
}
59+
}

0 commit comments

Comments
 (0)