// Benchmark harness types — internal to the runner CLI. import 'dart:io'; import 'dart:typed_data'; import 'package:benchmark_harness/benchmark_harness.dart'; import 'package:path/path.dart' as p; import 'package:serverpod_database/serverpod_database.dart'; import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'; import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart'; import 'tables.dart'; enum Operation { insert, update, delete } enum StorageStage { insert, update, delete } class StorageBenchmarkRun { const StorageBenchmarkRun({ required this.baseDatabaseSize, required this.insertOnlySize, required this.insertUpdateSize, required this.insertUpdateDeleteSize, }); final int baseDatabaseSize; final int insertOnlySize; final int insertUpdateSize; final int insertUpdateDeleteSize; int sizeFor(StorageStage stage) => switch (stage) { StorageStage.insert => insertOnlySize, StorageStage.update => insertUpdateSize, StorageStage.delete => insertUpdateDeleteSize, }; int previousSizeFor(StorageStage stage) => switch (stage) { StorageStage.insert => 0, StorageStage.update => insertOnlySize, StorageStage.delete => insertUpdateSize, }; } /// Benchmarks plain SQLite writes vs CRDT-wrapped writes using the wide [Types] /// row (same shape idea as legacy Drift `TableWithEveryColumnType`). class TypesTableBenchmark extends AsyncBenchmarkBase { TypesTableBenchmark( super.name, { required this.crdtEnabled, required this.operation, required this.rowCount, }); /// Minimal blob column payload (shared; column stays non-null with zero bytes). static final ByteData _emptyBlob = ByteData(0); static const _warmupMillis = 100; static const _measurementMillis = 2000; static const updatedColumnsPerRow = 8; final bool crdtEnabled; final Operation operation; final int rowCount; static const _clientUrl = 'http://localhost:8081/'; late final File _dbFile; late final ClientDatabaseSession _plainSession; CrdtDatabaseSession? _crdtSession; var _hasOpenSession = false; final UuidValue _userId = const Uuid().v7obj(); int _valueSeq = 0; int _lastDatabaseSize = 0; int _lastRowsCount = 0; List _seededRows = []; Types _createTypesRow(int i, DateTime baseTimestamp) { return Types( aBool: i.isEven, aDateTime: baseTimestamp.add(Duration(seconds: i)), aText: 'Text $i', anInt: i, anInt64: BigInt.from(i), aReal: i.toDouble(), aBlob: _emptyBlob, anEnum: null, optionalText: null, optionalUuid: const Uuid().v7obj(), ); } Future> _insertTypes(int count) async { final baseTimestamp = DateTime.now(); final start = _valueSeq; _valueSeq += count; final rows = List.generate( count, (j) => _createTypesRow(start + j, baseTimestamp), ); if (crdtEnabled) { return _crdtSession!.db.transactionForUser(_userId, (tx) async { return Types.db.insert(_crdtSession!, rows, transaction: tx); }); } return Types.db.insert(_plainSession, rows); } Future _deleteTypes(List rows) async { if (rows.isEmpty) { return; } if (crdtEnabled) { await _crdtSession!.db.transactionForUser(_userId, (tx) async { await Types.db.delete(_crdtSession!, rows, transaction: tx); }); } else { await Types.db.delete(_plainSession, rows); } } Future _updateTypesInPlace() async { final baseTimestamp = DateTime.now(); final updated = _seededRows.map((row) { final i = _valueSeq++; return row.copyWith( aBool: i.isEven, aDateTime: baseTimestamp.add(Duration(seconds: i)), aText: 'Text $i', anInt: i, anInt64: BigInt.from(i), aReal: i.toDouble(), aBlob: _emptyBlob, optionalUuid: const Uuid().v7obj(), ); }).toList(); if (crdtEnabled) { _seededRows = await _crdtSession!.db.transactionForUser( _userId, (tx) async { return Types.db.update( _crdtSession!, updated, columns: (t) => [ t.aBool, t.aDateTime, t.aText, t.anInt, t.anInt64, t.aReal, t.aBlob, t.optionalUuid, ], transaction: tx, ); }, ); } else { _seededRows = await Types.db.update( _plainSession, updated, columns: (t) => [ t.aBool, t.aDateTime, t.aText, t.anInt, t.anInt64, t.aReal, t.aBlob, t.optionalUuid, ], ); } } Future _bootstrapDatabase() async { await clearUserTables(_plainSession); CrdtUserManager.clearCache(); HlcManager.reset(); final wrapped = CrdtDatabaseSession.wraps( _plainSession, syncTables: benchmarkSyncTables, ); await wrapped.db.initialize(); _crdtSession = crdtEnabled ? wrapped : null; } @override Future setup() async { _valueSeq = 0; _lastDatabaseSize = 0; _lastRowsCount = 0; final dbPath = p.join( Directory.systemTemp.path, 'offline_sync_benchmark_$name.db', ); _dbFile = File(dbPath); _deleteDatabaseFiles(_dbFile); _plainSession = await Client(_clientUrl).createSession( _dbFile.path, isDebugMode: true, ); _hasOpenSession = true; await _bootstrapDatabase(); } Future _prepareCycle() async { if (_seededRows.isNotEmpty) { await _deleteTypes(_seededRows); _seededRows = []; } switch (operation) { case Operation.insert: return; case Operation.update: _seededRows = await _insertTypes(rowCount); return; case Operation.delete: _seededRows = await _insertTypes(rowCount); return; } } Future _measurePreparedCycles(int minimumMillis) async { final minimumMicros = minimumMillis * 1000; final watch = Stopwatch()..start(); var totalTimedMicros = 0.0; var timedIterations = 0; while (watch.elapsedMicroseconds < minimumMicros) { await _prepareCycle(); final sw = Stopwatch()..start(); await run(); totalTimedMicros += sw.elapsedMicroseconds; timedIterations++; } return totalTimedMicros / timedIterations; } Future _captureDatabaseSize() async { try { await _plainSession.db.unsafeExecute('PRAGMA wal_checkpoint(TRUNCATE)'); } on Exception { // WAL checkpoint is best-effort (e.g. non-SQLite tests). } _lastDatabaseSize = _sqliteDbFootprintBytes(_dbFile); } Future _measureAverageMicroseconds() async { await _measurePreparedCycles(_warmupMillis); return _measurePreparedCycles(_measurementMillis); } Future _resetForStorageSample() async { _valueSeq = 0; _lastDatabaseSize = 0; _lastRowsCount = 0; _seededRows = []; await clearUserTables(_plainSession); await _plainSession.db.unsafeExecute('VACUUM'); CrdtUserManager.clearCache(); HlcManager.reset(); final wrapped = CrdtDatabaseSession.wraps( _plainSession, syncTables: benchmarkSyncTables, ); await wrapped.db.initialize(); _crdtSession = crdtEnabled ? wrapped : null; } @override Future measure() async { await setup(); try { return await _measureAverageMicroseconds(); } finally { await teardown(); } } @override Future<(double, int)> report() async { await setup(); try { final averageMicroseconds = await _measureAverageMicroseconds(); await _resetForStorageSample(); await _prepareCycle(); await run(); await _captureDatabaseSize(); return (averageMicroseconds, measureStorage()); } finally { await teardown(); } } @override Future run() async { switch (operation) { case Operation.insert: _seededRows = await _insertTypes(rowCount); _lastRowsCount = _seededRows.length; return; case Operation.update: await _updateTypesInPlace(); _lastRowsCount = _seededRows.length; return; case Operation.delete: await _deleteTypes(_seededRows); _seededRows = []; _lastRowsCount = 0; return; } } @override Future teardown() async { if (!_hasOpenSession) { return; } await _captureDatabaseSize(); await _plainSession.close(); _hasOpenSession = false; _deleteDatabaseFiles(_dbFile); } int measureStorage() { if (operation == Operation.delete) { return _lastDatabaseSize; } if (_lastDatabaseSize == 0) { throw Exception('Last database size is not set.'); } if (_lastRowsCount < rowCount) { throw Exception( 'Rows count is $_lastRowsCount but expected at least $rowCount.', ); } return _lastDatabaseSize ~/ (_lastRowsCount / rowCount); } } class TypesTableStorageBenchmark extends TypesTableBenchmark { TypesTableStorageBenchmark( super.name, { required super.crdtEnabled, required super.rowCount, }) : super(operation: Operation.insert); Future reportStorage() async { await setup(); try { await _plainSession.db.unsafeExecute('VACUUM'); final baseDatabaseSize = await _captureCurrentDatabaseSize(); _seededRows = await _insertTypes(rowCount); _lastRowsCount = _seededRows.length; final insertOnlySize = await _captureNetDatabaseSize(baseDatabaseSize); await _updateTypesInPlace(); _lastRowsCount = _seededRows.length; final insertUpdateSize = await _captureNetDatabaseSize(baseDatabaseSize); await _deleteTypes(_seededRows); _seededRows = []; _lastRowsCount = 0; final insertUpdateDeleteSize = await _captureNetDatabaseSize( baseDatabaseSize, ); return StorageBenchmarkRun( baseDatabaseSize: baseDatabaseSize, insertOnlySize: insertOnlySize, insertUpdateSize: insertUpdateSize, insertUpdateDeleteSize: insertUpdateDeleteSize, ); } finally { await teardown(); } } Future _captureCurrentDatabaseSize() async { await _captureDatabaseSize(); return _lastDatabaseSize; } Future _captureNetDatabaseSize(int baseDatabaseSize) async { final currentDatabaseSize = await _captureCurrentDatabaseSize(); final netDatabaseSize = currentDatabaseSize - baseDatabaseSize; return netDatabaseSize < 0 ? 0 : netDatabaseSize; } } /// Bytes used by SQLite for [mainDb], including WAL sidecars (often omitted by /// `mainDb.lengthSync()` alone). int _sqliteDbFootprintBytes(File mainDb) { var total = mainDb.existsSync() ? mainDb.lengthSync() : 0; final wal = File('${mainDb.path}-wal'); final shm = File('${mainDb.path}-shm'); if (wal.existsSync()) { total += wal.lengthSync(); } if (shm.existsSync()) { total += shm.lengthSync(); } return total; } void _deleteDatabaseFiles(File mainDb) { if (mainDb.existsSync()) { mainDb.deleteSync(); } for (final companion in [ File('${mainDb.path}-wal'), File('${mainDb.path}-shm'), ]) { if (companion.existsSync()) { companion.deleteSync(); } } }