Files
pyramid/benchmark/db_benchmark_test.dart
Bernd SteckmeisterandClaude Opus 5.5 df1b392ed8 feat: Benchmarks (DB Klartext vs. SQLCipher, Migration) + scripts/test.ps1
benchmark/ misst Sync-Schreiben, App-Start, Chat oeffnen, lokale Suche und die
Migrationsdauer (A-B-B-A, Aufwaermlauf, Median), schreibt build/benchmarks/*.json
und vergleicht mit benchmark/baseline/. scripts/test.ps1 fuehrt Analyse,
Unit-Tests, optional Live-Test (-Live) und Benchmarks (-Bench) mit einem Befehl aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:30 +02:00

258 lines
9.1 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<Database> 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: <String, List<double>>{},
};
void record(String mode, String metric, Iterable<double> values) =>
timings[mode]!.putIfAbsent(metric, () => []).addAll(values);
// 1. Sync-Schreiben – je Runde eine frische DB.
final paths = <String, String>{};
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 = <String, Client>{};
final roomLists = <String, List<Room>>{};
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)),
);
}