perf(bridge): bound metadata cache and persisted JSON bytes

This commit is contained in:
zarzet committed 2026-10-04 01:40:56 +07:00
1 parent a57b4504de
commit 0419b4485e
3 files changed
+258 -26

No files matched your search

+123 -26
View File
@@ -22,6 +22,29 @@ bool isForegroundServiceStartNotAllowed(Object error) {
Object? _decodeJsonInBackground(String json) => jsonDecode(json);
String _encodeJsonInBackground(Object? value) => jsonEncode(value);
String _encodeBoundedLookupCacheInBackground(Map<String, dynamic> entries) {
final result = StringBuffer('{');
var bytes = 2; // Opening and closing braces.
var count = 0;
for (final entry in entries.entries) {
final fragment =
'${_encodeJsonInBackground(entry.key)}:${_encodeJsonInBackground(entry.value)}';
final entryBytes = utf8.encode(fragment).length;
final separatorBytes = count == 0 ? 0 : 1;
if (entryBytes > PlatformBridge._persistentCacheMaxEntryBytes ||
bytes + separatorBytes + entryBytes >
PlatformBridge._persistentCacheMaxBytes) {
continue;
}
if (count > 0) result.write(',');
result.write(fragment);
bytes += separatorBytes + entryBytes;
count++;
}
result.write('}');
return result.toString();
}
Object? _decodeJsonFileInBackground(String path) {
final contents = File(path).readAsStringSync();
return contents.isEmpty ? null : jsonDecode(contents);
@@ -142,8 +165,13 @@ bool shouldResetRestoredInstallation({
class _BridgeCacheEntry {
final Map<String, dynamic> value;
final DateTime expiresAt;
final int bytes;
const _BridgeCacheEntry({required this.value, required this.expiresAt});
const _BridgeCacheEntry({
required this.value,
required this.expiresAt,
required this.bytes,
});
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
@@ -151,8 +179,13 @@ class _BridgeCacheEntry {
class _BridgeListCacheEntry {
final List<Map<String, dynamic>> value;
final DateTime expiresAt;
final int bytes;
const _BridgeListCacheEntry({required this.value, required this.expiresAt});
const _BridgeListCacheEntry({
required this.value,
required this.expiresAt,
required this.bytes,
});
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
@@ -190,6 +223,12 @@ class PlatformBridge {
static const _urlHandleCacheTtl = Duration(minutes: 5);
static const _customSearchCacheTtl = Duration(minutes: 2);
static const _bridgeCacheMaxEntries = 256;
// Bound retained JSON containers as well as strings. Entry counts alone do
// not bound album/playlist responses, which can contain thousands of tracks.
static const _bridgeCacheMaxEntryBytes = 4 * 1024 * 1024;
static const _bridgeCacheMaxBytes = 8 * 1024 * 1024;
static const _persistentCacheMaxEntryBytes = 1024 * 1024;
static const _persistentCacheMaxBytes = 2 * 1024 * 1024;
static const _lookupCachePersistDebounce = Duration(milliseconds: 500);
static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1';
static const _downloadProgressEvents = EventChannel(
@@ -397,15 +436,7 @@ class PlatformBridge {
Duration ttl,
String persistentCacheKey,
) {
_pruneExpiredBridgeCache(cache);
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
cache.remove(cache.keys.first);
}
cache[key] = _BridgeCacheEntry(
// Loader results stay private; every caller receives its own deep copy.
value: value,
expiresAt: DateTime.now().add(ttl),
);
_putMemoryCachedMap(cache, key, value, ttl);
_scheduleLookupCachePersist(
cache,
persistentCacheKey,
@@ -413,6 +444,37 @@ class PlatformBridge {
);
}
/// Conservative retained-size estimate, with early exit for oversized JSON.
/// Avoids allocating another encoded response just to decide cache admission.
static int _payloadBytes(Object? value) {
var bytes = 0;
void visit(Object? item) {
if (bytes > _bridgeCacheMaxEntryBytes) return;
if (item is String) {
bytes += 32 + item.length * 2;
} else if (item is Map) {
bytes += 64;
for (final entry in item.entries) {
bytes += 48;
visit(entry.key);
visit(entry.value);
if (bytes > _bridgeCacheMaxEntryBytes) break;
}
} else if (item is List) {
bytes += 32 + item.length * 8;
for (final entry in item) {
visit(entry);
if (bytes > _bridgeCacheMaxEntryBytes) break;
}
} else {
bytes += 16;
}
}
visit(value);
return bytes;
}
static void _pruneExpiredBridgeCache(Map<String, _BridgeCacheEntry> cache) {
if (cache.isEmpty) return;
final now = DateTime.now();
@@ -495,6 +557,10 @@ class PlatformBridge {
) async {
final raw = prefs.getString(prefsKey);
if (raw == null || raw.isEmpty) return;
if (raw.length > _persistentCacheMaxBytes) {
await prefs.remove(prefsKey);
return;
}
final decoded = await _decodeJsonStringAsync(raw);
if (generation != _lookupCacheGeneration) return;
@@ -514,9 +580,17 @@ class PlatformBridge {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(expiresAtMs);
if (!expiresAt.isAfter(now)) continue;
final map = Map<String, dynamic>.from(value);
final bytes = _payloadBytes(map) + key.length * 2;
if (bytes > _bridgeCacheMaxEntryBytes) continue;
if (target.values.fold<int>(0, (sum, item) => sum + item.bytes) + bytes >
_bridgeCacheMaxBytes) {
break;
}
target[key] = _BridgeCacheEntry(
value: Map<String, dynamic>.from(value),
value: map,
expiresAt: expiresAt,
bytes: bytes,
);
}
}
@@ -528,16 +602,25 @@ class PlatformBridge {
) async {
try {
_pruneExpiredBridgeCache(cache);
final data = <String, dynamic>{
for (final entry in cache.entries)
entry.key: {
'expires_at': entry.value.expiresAt.millisecondsSinceEpoch,
'value': entry.value.value,
},
};
final encoded = data.length >= 32
? await compute(_encodeJsonInBackground, data)
: jsonEncode(data);
final data = <String, dynamic>{};
var bytes = 64;
for (final entry in cache.entries.toList().reversed) {
final cost = entry.value.bytes + 128;
if (cost > _persistentCacheMaxEntryBytes ||
bytes + cost > _persistentCacheMaxBytes) {
continue;
}
bytes += cost;
data[entry.key] = {
'expires_at': entry.value.expiresAt.millisecondsSinceEpoch,
'value': entry.value.value,
};
}
// Escaping and multibyte UTF-8 can outgrow the retained-size estimate.
// Enforce both encoded limits in the isolate, without UI-side encoding.
final encoded = bytes >= _backgroundJsonDecodeThresholdBytes
? await compute(_encodeBoundedLookupCacheInBackground, data)
: _encodeBoundedLookupCacheInBackground(data);
if (generation != _lookupCacheGeneration) return;
final prefs = await SharedPreferences.getInstance();
if (generation != _lookupCacheGeneration) return;
@@ -2054,12 +2137,19 @@ class PlatformBridge {
Duration ttl,
) {
_pruneExpiredBridgeCache(cache);
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
cache.remove(cache.keys.first);
cache.remove(key);
final bytes = _payloadBytes(value) + key.length * 2;
if (bytes > _bridgeCacheMaxEntryBytes) return;
var retained = cache.values.fold<int>(0, (sum, item) => sum + item.bytes);
while (cache.isNotEmpty &&
(cache.length >= _bridgeCacheMaxEntries ||
retained + bytes > _bridgeCacheMaxBytes)) {
retained -= cache.remove(cache.keys.first)!.bytes;
}
cache[key] = _BridgeCacheEntry(
value: value,
expiresAt: DateTime.now().add(ttl),
bytes: bytes,
);
}
@@ -2084,12 +2174,19 @@ class PlatformBridge {
Duration ttl,
) {
_pruneExpiredBridgeListCache(cache);
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
cache.remove(cache.keys.first);
cache.remove(key);
final bytes = _payloadBytes(value) + key.length * 2;
if (bytes > _bridgeCacheMaxEntryBytes) return;
var retained = cache.values.fold<int>(0, (sum, item) => sum + item.bytes);
while (cache.isNotEmpty &&
(cache.length >= _bridgeCacheMaxEntries ||
retained + bytes > _bridgeCacheMaxBytes)) {
retained -= cache.remove(cache.keys.first)!.bytes;
}
cache[key] = _BridgeListCacheEntry(
value: value,
expiresAt: DateTime.now().add(ttl),
bytes: bytes,
);
}
@@ -13,6 +13,29 @@ void main() {
tearDown(() => messenger.setMockMethodCallHandler(channel, null));
test(
'oversized custom-search lists are displayed without accumulating',
() async {
final rows = [
{'id': 'large', 'name': 'x' * (3 * 1024 * 1024)},
];
final payload = jsonEncode(rows);
var calls = 0;
messenger.setMockMethodCallHandler(channel, (_) async {
calls++;
return payload;
});
for (var index = 0; index < 2; index++) {
final result = await PlatformBridge.customSearchWithExtension(
'oversized-search-test',
'large',
);
expect(result, rows);
}
expect(calls, 2);
},
);
for (final format in ['small-json', 'large-json', 'native-list']) {
test('custom search decodes and caches $format results', () async {
final rows = List.generate(
+112
View File
@@ -41,6 +41,118 @@ void main() {
Future<Map<String, dynamic>> load() =>
PlatformBridge.getProviderMetadata('example', 'album', 'collection');
test(
'oversized responses are returned and coalesced but not retained',
() async {
final payload = jsonEncode({'text': 'x' * (3 * 1024 * 1024)});
var calls = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
calls++;
return payload;
});
final results = await Future.wait([load(), load()]);
expect(calls, 1);
expect((results.first['text'] as String).length, 3 * 1024 * 1024);
results.first['text'] = 'changed';
expect(results.last['text'], isNot('changed'));
expect((await load())['text'], isNot('changed'));
expect(calls, 2);
await Future<void>.delayed(const Duration(milliseconds: 550));
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString('bridge_metadata_lookup_cache_v1');
expect(stored == null || jsonDecode(stored) is Map, true);
expect(stored ?? '', isNot(contains('collection')));
},
);
test('byte budget evicts old collections before the count limit', () async {
var calls = 0;
final payload = jsonEncode({'text': 'x' * (700 * 1024)});
messenger.setMockMethodCallHandler(channel, (call) async {
calls++;
return payload;
});
Future<Map<String, dynamic>> collectionAt(int index) =>
PlatformBridge.getProviderMetadata('example', 'album', 'album-$index');
for (var index = 0; index < 8; index++) {
await collectionAt(index);
}
expect(calls, 8);
await collectionAt(7);
expect(calls, 8);
await collectionAt(0);
expect(calls, 9);
});
test(
'persistence keeps small metadata and skips a large collection',
() async {
messenger.setMockMethodCallHandler(channel, (call) async {
final id = (call.arguments as Map)['resource_id'];
return jsonEncode({
'text': id == 'large' ? 'x' * (600 * 1024) : 'small',
});
});
await PlatformBridge.getProviderMetadata('example', 'album', 'large');
await PlatformBridge.getProviderMetadata('example', 'track', 'small');
await Future<void>.delayed(const Duration(milliseconds: 650));
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString('bridge_metadata_lookup_cache_v1')!;
expect(utf8.encode(stored).length, lessThanOrEqualTo(2 * 1024 * 1024));
expect(jsonDecode(stored), contains('example:track:small'));
expect(jsonDecode(stored), isNot(contains('example:album:large')));
},
);
test(
'persistence skips entries oversized after JSON escaping or UTF-8',
() async {
messenger.setMockMethodCallHandler(channel, (call) async {
final id = (call.arguments as Map)['resource_id'];
return jsonEncode({
'text': switch (id) {
'escaped' => '\u0000' * 250000,
'multibyte' => '音' * 380000,
_ => 'small',
},
});
});
for (final id in ['escaped', 'multibyte', 'small']) {
await PlatformBridge.getProviderMetadata('example', 'album', id);
}
await Future<void>.delayed(const Duration(milliseconds: 750));
final prefs = await SharedPreferences.getInstance();
final stored =
jsonDecode(prefs.getString('bridge_metadata_lookup_cache_v1')!)
as Map;
expect(stored.keys, ['example:album:small']);
},
);
test('persistence bounds the total encoded multibyte payload', () async {
messenger.setMockMethodCallHandler(
channel,
(_) async => jsonEncode({'text': '音' * 250000}),
);
for (var index = 0; index < 3; index++) {
await PlatformBridge.getProviderMetadata('example', 'album', '$index');
}
await Future<void>.delayed(const Duration(milliseconds: 750));
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('bridge_metadata_lookup_cache_v1')!;
expect(utf8.encode(raw).length, lessThanOrEqualTo(2 * 1024 * 1024));
final stored = jsonDecode(raw) as Map;
expect(stored.keys, ['example:album:2', 'example:album:1']);
for (final entry in stored.entries) {
expect(
utf8
.encode('${jsonEncode(entry.key)}:${jsonEncode(entry.value)}')
.length,
lessThanOrEqualTo(1024 * 1024),
);
}
});
void mutate(Map<String, dynamic> value) {
(value['album_info'] as Map<String, dynamic>)['name'] = 'Changed';
final first = (value['track_list'] as List).first as Map<String, dynamic>;