// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie // sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt // der Analyzer sie nicht als Tests. // ignore_for_file: invalid_use_of_visible_for_testing_member @TestOn('windows') library; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:matrix/matrix.dart'; import 'package:path/path.dart' as p; import 'package:pyramid/core/app_database.dart'; import 'package:pyramid/features/search/local_message_search.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import '../test/support/synthetic_matrix_data.dart'; import 'support/bench.dart'; /// Datenbank-Benchmark: Was kostet die SQLCipher-Verschlüsselung im Alltag? /// /// Misst – jeweils für eine Klartext-DB und eine SQLCipher-DB mit DERSELBEN /// Bibliothek aus dem App-Build – die vier Wege, auf denen Nutzer die DB /// spüren: /// 1. Sync-Schreiben (Nachrichten landen in Transaktionen wie beim Sync), /// 2. App-Start (DB öffnen + SDK-Schema + Raumliste laden), /// 3. Chat öffnen (50 neueste Nachrichten eines Raums), /// 4. lokale Suche (Strg+K) – ohne Treffer = Worst Case, scannt alles. /// /// Daten sind synthetisch (`test/support/synthetic_matrix_data.dart`), laufen /// aber über die echte SDK-Speicherschicht. Lauf: /// flutter test benchmark/db_benchmark_test.dart /// oder alles zusammen über `scripts/test.ps1 -Bench`. const _modes = ['klartext', 'sqlcipher']; const _benchKey = '0b1e2c3d4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e'; void main() { final skip = findCipherDll() == null ? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen' : null; test( 'DB: Klartext vs. SQLCipher', () async { final rooms = scaled(20); final eventsPerRoom = scaled(500); final totalEvents = rooms * eventsPerRoom; final report = BenchReport( 'db', params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom}, ); final factory = cipherFactory(); final tempDir = await Directory.systemTemp.createTemp('pyramid_bench_db'); Future openBenchDb(String mode, String path) async { final db = mode == 'sqlcipher' ? await AppDatabase.openEncrypted(factory, path, _benchKey) : await factory.openDatabase(path); await applyAppPragmas(db); return db; } try { // Ungemessener Aufwärm-Durchlauf (JIT, DB-Isolate, SQLCipher-Init). // Bewusst mit voller Raumgröße: Der JIT optimiert die SDK-Pfade erst // nach vielen Aufrufen mit wachsender Fragment-Liste – mit einem // Mini-Durchlauf war die erste Messrunde noch ~45 % langsamer. for (final mode in _modes) { final path = p.join(tempDir.path, 'warmup_$mode.sqlite'); final client = await SyntheticMatrixData.openClient( await openBenchDb(mode, path), ); await SyntheticMatrixData.populate( client, rooms: (rooms ~/ 4).clamp(1, rooms), eventsPerRoom: eventsPerRoom, ); await SyntheticMatrixData.loadRooms(client); await localMessageSearch(client, 'aufwärmen'); await client.dispose(closeDatabase: true); } // Gemessen wird abwechselnd A-B-B-A: Die Schreibkosten sind von // Dart-Code (SDK-JSON) dominiert, und der JIT wird während des Laufs // weiter schneller. Ohne das Muster gewinnt einfach der später // gemessene Modus (2026-10-07 nachgemessen: ±20 % je nach Reihenfolge). const order = ['klartext', 'sqlcipher', 'sqlcipher', 'klartext']; final timings = { for (final mode in _modes) mode: >{}, }; void record(String mode, String metric, Iterable values) => timings[mode]!.putIfAbsent(metric, () => []).addAll(values); // 1. Sync-Schreiben – je Runde eine frische DB. final paths = {}; for (var round = 0; round < order.length; round++) { final mode = order[round]; final path = p.join(tempDir.path, '${mode}_$round.sqlite'); final client = await SyntheticMatrixData.openClient( await openBenchDb(mode, path), ); final watch = Stopwatch()..start(); await SyntheticMatrixData.populate( client, rooms: rooms, eventsPerRoom: eventsPerRoom, needleEvery: 97, ); watch.stop(); await client.dispose(closeDatabase: true); record(mode, 'write', [watch.elapsedMicroseconds / 1000]); paths[mode] = path; } // 2. App-Start: DB öffnen + SDK-Schema + Raumliste, danach wieder // schließen – wie ein Neustart. Eigene Runde VOR den Lese-Messungen: // sqflite liefert für denselben Pfad dieselbe Verbindung zurück, // ein Schließen hier würde sonst deren offene Verbindung kappen. for (final mode in order) { record( mode, 'startup', await sample(() async { final c = await SyntheticMatrixData.openClient( await openBenchDb(mode, paths[mode]!), ); await SyntheticMatrixData.loadRooms(c); await c.dispose(closeDatabase: true); }, runs: 3), ); } // 3.–4. Lesen auf der jeweils zuletzt geschriebenen DB. final clients = {}; final roomLists = >{}; for (final mode in _modes) { final client = await SyntheticMatrixData.openClient( await openBenchDb(mode, paths[mode]!), ); clients[mode] = client; roomLists[mode] = await SyntheticMatrixData.loadRooms(client); expect( roomLists[mode], hasLength(rooms), reason: 'Raumliste vollständig ($mode)', ); } for (final mode in order) { final client = clients[mode]!; record( mode, 'open_chat', (await sample(() async { for (final room in roomLists[mode]!) { final page = await client.database.getEventList( room, limit: 50, ); if (page.length != 50) throw StateError('Seite unvollständig'); } }, runs: 3)).map((t) => t / rooms), ); record( mode, 'search_miss', await sample( () => localMessageSearch(client, 'gibtsnichtwort'), runs: 2, ), ); record( mode, 'search_hit', await sample( () => localMessageSearch(client, 'nadelwort'), runs: 2, ), ); } for (final client in clients.values) { await client.dispose(closeDatabase: true); } for (final mode in _modes) { final t = timings[mode]!; final writeMs = median(t['write']!); report.addTimings( '$mode.write_total_ms', '[$mode] Sync-Schreiben, $totalEvents Nachrichten', t['write']!, ); report.addValue( '$mode.write_per_event_us', '[$mode] Sync-Schreiben je Nachricht', writeMs * 1000 / totalEvents, 'µs', compare: true, ); report.addTimings( '$mode.startup_ms', '[$mode] App-Start: DB + Raumliste', t['startup']!, ); report.addTimings( '$mode.open_chat_ms', '[$mode] Chat öffnen (50 Nachr.)', t['open_chat']!, ); report.addTimings( '$mode.search_miss_ms', '[$mode] Suche ohne Treffer (Worst Case)', t['search_miss']!, ); report.addTimings( '$mode.search_hit_ms', '[$mode] Suche mit Treffern', t['search_hit']!, ); report.addValue( '$mode.size_mb', '[$mode] DB-Größe inkl. WAL', dbSizeMb(paths[mode]!), 'MB', ); } for (final (id, label) in [ ('write_total_ms', 'Sync-Schreiben'), ('startup_ms', 'App-Start'), ('open_chat_ms', 'Chat öffnen'), ('search_miss_ms', 'Suche ohne Treffer'), ]) { final plain = report.valueOf('klartext.$id'); final enc = report.valueOf('sqlcipher.$id'); report.addValue( 'cipher_overhead.$id', 'Aufschlag Verschlüsselung: $label', (enc / plain - 1) * 100, '%', ); } final regressions = await report.finish(); if (strictBench) expect(regressions, isEmpty); } finally { try { await tempDir.delete(recursive: true); } catch (_) {} } }, skip: skip, timeout: const Timeout(Duration(minutes: 20)), ); }