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]>
258 lines
9.1 KiB
Dart
258 lines
9.1 KiB
Dart
// 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)),
|
||
);
|
||
}
|