refactor: Replace drift by serverpod as core engine (#33)

This commit is contained in:
Marcelo Mendonça Soares 2026-05-03 09:09:23 -03:00 • committed by GitHub
commit 3545bbba1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 6196 additions and 671 deletions

View file

@ -0,0 +1,13 @@
include: ../analysis_options.yaml
linter:
rules:
- public_member_api_docs: false
analyzer:
language:
strict-casts: true
strict-inference: true
formatter:
trailing_commas: preserve

21
benchmark/pubspec.yaml Normal file
View file

@ -0,0 +1,21 @@
name: serverpod_offline_sync_benchmark
description: Local CRDT vs baseline performance impact measurement.
publish_to: none
environment:
sdk: '^3.10.3'
resolution: workspace
dependencies:
benchmark_harness: ^2.4.0
intl: ^0.20.2
path: ^1.9.1
serverpod_database: 3.5.0-beta.5
serverpod_offline_sync_server:
path: ../packages/serverpod_offline_sync_server
serverpod_offline_sync_test_client:
path: ../test/serverpod_offline_sync_test_client
dev_dependencies:
lints: '>=3.0.0 <7.0.0'

100
benchmark/run.dart Normal file
View file

@ -0,0 +1,100 @@
import 'utils/benchmark.dart';
import 'utils/conversion.dart';
import 'utils/results.dart';
import 'utils/runner.dart';
Future<void> main(List<String> args) async {
final runningInCI = args.contains('--ci');
String? rowCountArg;
for (final arg in args) {
if (arg.startsWith('--rows=')) {
rowCountArg = arg.substring(7);
}
}
final rowCount = rowCountArg == null ? 1000 : int.parse(rowCountArg);
final rowsString = formatter0.format(rowCount);
if (!runningInCI) print('\n${'=' * 60}');
print('## Performance Benchmark ($rowsString rows)\n');
print(
[
'This benchmark measures the performance impact of using CRDT ',
'metadata synchronization on database operations.',
].join(runningInCI ? '' : '\n'),
);
if (!runningInCI) print('-' * 60);
final benchmarkResults = <BenchmarkResults>[];
for (final operation in Operation.values) {
final baselineResult = await runWithProgress(
'Running ${operation.name} benchmark (baseline)',
() => TypesTableBenchmark(
'${operation.name} (baseline)',
crdtEnabled: false,
operation: operation,
rowCount: rowCount,
).report(),
skipProgress: runningInCI,
);
final crdtResult = await runWithProgress(
'Running ${operation.name} benchmark (CRDT)',
() => TypesTableBenchmark(
'${operation.name} (CRDT)',
crdtEnabled: true,
operation: operation,
rowCount: rowCount,
).report(),
validator: (result) => result.$1 > baselineResult.$1,
skipProgress: runningInCI,
);
benchmarkResults.add(
BenchmarkResults(
operation: operation,
baseline: baselineResult,
crdt: crdtResult,
),
);
}
final spuriousBenchmarks = benchmarkResults.where(
(result) => result.baseline.$1 > result.crdt.$1,
);
if (spuriousBenchmarks.isNotEmpty) {
final leadingText = runningInCI ? '\n> ' : '\n';
print(
[
'$leadingText❌ Baseline time is greater than CRDT time. This is likely due ',
'to running with a very low number of rows ($rowsString). This is not ',
' a valid benchmark and the time comparison should be ignored.',
].join(runningInCI ? '' : '\n'),
);
for (final result in spuriousBenchmarks) {
final baselineDelay = result.baseline.$1.toFormattedDuration();
final crdtDelay = result.crdt.$1.toFormattedDuration();
print(
' - ${result.operation.name.toUpperCase()}: $baselineDelay > $crdtDelay',
);
}
}
for (final result in benchmarkResults) {
printPerformanceImpact(
result,
rowCount: rowCount,
runningInCI: runningInCI,
);
}
printStorageImpact(
benchmarkResults.first,
rowCount: rowCount,
runningInCI: runningInCI,
);
print('\n${'-' * 60}');
print('✅ Benchmark complete!\n');
}

View file

@ -0,0 +1,295 @@
// 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 }
/// 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);
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;
final UuidValue _userId = const Uuid().v7obj();
int _valueSeq = 0;
int _lastDatabaseSize = 0;
int _lastRowsCount = 0;
List<Types> _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<List<Types>> _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<void> _deleteTypes(List<Types> 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<void> _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<void> _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<void> setup() async {
_valueSeq = 0;
final dbPath = p.join(
Directory.systemTemp.path,
'offline_sync_benchmark_$name.db',
);
_dbFile = File(dbPath);
if (_dbFile.existsSync()) {
_dbFile.deleteSync();
}
_plainSession = await Client(_clientUrl).createSession(
_dbFile.path,
isDebugMode: true,
);
await _bootstrapDatabase();
switch (operation) {
case Operation.insert:
_seededRows = [];
case Operation.update:
_seededRows = await _insertTypes(rowCount);
case Operation.delete:
_seededRows = [];
}
}
@override
Future<void> warmup() async {}
@override
Future<void> run() async {
switch (operation) {
case Operation.insert:
await _insertTypes(rowCount);
case Operation.update:
await _updateTypesInPlace();
case Operation.delete:
await _deleteTypes(_seededRows);
}
}
Future<void> _prepareDeleteCycle() async {
await _bootstrapDatabase();
_seededRows = await _insertTypes(rowCount);
}
Future<void> _timedDeleteBatch() async {
await _deleteTypes(_seededRows);
}
@override
Future<double> measure() async {
if (operation == Operation.delete) {
await setup();
try {
await AsyncBenchmarkBase.measureFor(() async {}, 100);
const minimumMicros = 2000 * 1000;
final watch = Stopwatch()..start();
var totalTimedMicros = 0.0;
var timedIterations = 0;
while (watch.elapsedMicroseconds < minimumMicros) {
await _prepareDeleteCycle();
final sw = Stopwatch()..start();
await _timedDeleteBatch();
totalTimedMicros += sw.elapsedMicroseconds;
timedIterations++;
}
return totalTimedMicros / timedIterations;
} finally {
await teardown();
}
}
return super.measure();
}
@override
Future<(double, int)> report() async {
final averageMicroseconds = await measure();
return (averageMicroseconds, measureStorage());
}
@override
Future<void> teardown() 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);
_lastRowsCount = await Types.db.count(_plainSession);
await _plainSession.close();
if (_dbFile.existsSync()) {
_dbFile.deleteSync();
}
for (final companion in [
File('${_dbFile.path}-wal'),
File('${_dbFile.path}-shm'),
]) {
if (companion.existsSync()) {
companion.deleteSync();
}
}
}
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);
}
}
/// 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;
}

View file

@ -0,0 +1,53 @@
// Formatter helpers for CLI output only.
import 'package:intl/intl.dart';
final NumberFormat formatter0 = NumberFormat.decimalPatternDigits(
decimalDigits: 0,
);
final NumberFormat formatter2 = NumberFormat.decimalPatternDigits(
decimalDigits: 2,
);
extension FormattedUnits on num {
String toFormattedDuration() {
var time = this;
var unit = 1;
while (time.abs() > 1000 && unit < Duration.microsecondsPerSecond) {
time /= 1000;
unit *= 1000;
}
while (unit > Duration.microsecondsPerMinute && time.abs() > 60) {
time /= 60;
unit *= 60;
}
return '${formatter2.format(time)} ${switch (unit) {
1 => 'μs',
Duration.millisecondsPerSecond => 'ms',
Duration.microsecondsPerSecond => 's',
Duration.microsecondsPerMinute => 'min',
Duration.microsecondsPerHour => 'h',
_ => throw Exception('Unexpected time unit: $unit'),
}}';
}
String toFormattedStorageSize() {
var storageIncrease = toDouble();
var storageUnit = 1;
while (storageIncrease > 1024) {
storageIncrease /= 1024;
storageUnit *= 1024;
}
final storageUnitString = switch (storageUnit) {
1 => 'B',
1024 => 'KB',
const (1024 * 1024) => 'MB',
_ => throw Exception('Unexpected storage unit: $storageUnit'),
};
return '${formatter2.format(storageIncrease)} $storageUnitString';
}
}

View file

@ -0,0 +1,70 @@
// Pretty-printing benchmark comparison output.
import 'benchmark.dart';
import 'conversion.dart';
void printPerformanceImpact(
BenchmarkResults benchmarkResults, {
required int rowCount,
bool runningInCI = false,
}) {
print(
'${runningInCI ? '```' : ''}'
'\n📊 ${benchmarkResults.operation.name.toUpperCase()} performance impact:',
);
final (baselineTime, _) = benchmarkResults.baseline;
final (crdtTime, _) = benchmarkResults.crdt;
final runDelayUs = crdtTime - baselineTime;
final slowdown = runDelayUs / baselineTime * 100;
final baselineDelay = baselineTime.toFormattedDuration();
final crdtDelay = crdtTime.toFormattedDuration();
final runDelay = runDelayUs.toFormattedDuration();
final runDelayPerOperation = (runDelayUs / rowCount).toFormattedDuration();
print(' Time: $baselineDelay --> $crdtDelay ($runDelay)');
print(' CRDT overhead: ${formatter2.format(slowdown)}% slower');
print(
' Delay per ${benchmarkResults.operation.name}: $runDelayPerOperation',
);
if (runningInCI) print('```');
}
void printStorageImpact(
BenchmarkResults benchmarkResults, {
required int rowCount,
bool runningInCI = false,
}) {
print('${runningInCI ? '```' : ''}\n💽 Storage impact:');
final (_, baselineSize) = benchmarkResults.baseline;
final (_, crdtSize) = benchmarkResults.crdt;
final storageIncrease = crdtSize - baselineSize;
final storageIncPercent = storageIncrease / baselineSize * 100;
final extraStoragePerRow = storageIncrease / rowCount;
final storageIncreaseVolume = storageIncPercent > 100
? '(x${formatter2.format(storageIncPercent / 100)})'
: '(+${(crdtSize - baselineSize).toFormattedStorageSize()})';
print(
' Storage size: ${baselineSize.toFormattedStorageSize()} '
'--> ${crdtSize.toFormattedStorageSize()} $storageIncreaseVolume',
);
print(' CRDT overhead: ${formatter2.format(storageIncPercent)}% increase');
print(
' Extra storage per row: ${extraStoragePerRow.toFormattedStorageSize()}',
);
if (runningInCI) print('```');
}
class BenchmarkResults {
const BenchmarkResults({
required this.operation,
required this.baseline,
required this.crdt,
});
final Operation operation;
final (double, int) baseline;
final (double, int) crdt;
}

View file

@ -0,0 +1,20 @@
// Progress-style logging for the benchmark CLI.
Future<T> runWithProgress<T>(
String message,
Future<T> Function() runner, {
bool Function(T result)? validator,
bool skipProgress = false,
}) async {
if (!skipProgress) {
print('$message…');
}
final result = await runner();
final ok = validator?.call(result) ?? true;
if (!skipProgress) {
print(
ok ? ' done.' : ' failed validation (CRDT not slower than baseline).',
);
}
return result;
}

View file

@ -0,0 +1,31 @@
// Keeps CRDT scope aligned with integration tests plus the benchmark table.
import 'package:serverpod_database/serverpod_database.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
/// [Types] is benchmark-only; the rest mirrors `client_session.dart` sync scope.
List<Table> get benchmarkSyncTables => [
Address.t,
City.t,
Company.t,
Organization.t,
Person.t,
Town.t,
Types.t,
Unique.t,
UniqueUuid.t,
];
Future<void> clearUserTables(DatabaseSession session) async {
await session.db.unsafeExecute('PRAGMA foreign_keys = OFF');
final result = await session.db.unsafeQuery('''
SELECT name
FROM sqlite_master
WHERE (type = 'table') AND (name NOT LIKE 'serverpod_%')
''');
for (final row in result) {
final tableName = row[0] as String;
await session.db.unsafeExecute('DELETE FROM "$tableName"');
}
await session.db.unsafeExecute('PRAGMA foreign_keys = ON');
}