refactor!: Rename sync APIs and ownership spaces (#128)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-14 13:39:07 -03:00 • committed by GitHub
commit b89725f6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 43650 additions and 9417 deletions

View file

@ -3,7 +3,7 @@ import 'utils/conversion.dart';
import 'utils/merge.dart';
import 'utils/results.dart';
import 'utils/runner.dart';
import 'utils/scope.dart';
import 'utils/space.dart';
Future<void> main(List<String> args) async {
final runningInCI = args.contains('--ci');
@ -56,13 +56,13 @@ Future<void> main(List<String> args) async {
BenchmarkResults(
operation: operation,
baseline: baselineResult,
crdt: crdtResult,
offlineSync: crdtResult,
),
);
}
final spuriousBenchmarks = benchmarkResults.where(
(result) => result.baseline > result.crdt,
(result) => result.baseline > result.offlineSync,
);
if (spuriousBenchmarks.isNotEmpty) {
@ -77,7 +77,7 @@ Future<void> main(List<String> args) async {
for (final result in spuriousBenchmarks) {
final baselineDelay = result.baseline.toFormattedDuration();
final crdtDelay = result.crdt.toFormattedDuration();
final crdtDelay = result.offlineSync.toFormattedDuration();
print(
' - ${result.operation.label.toUpperCase()}: $baselineDelay > $crdtDelay',
);
@ -95,29 +95,29 @@ Future<void> main(List<String> args) async {
// Production-shaped CRDT metadata: many users and a crdt_data_rows table
// much larger than the queried rows, exercising the scoped and unscoped
// tombstone predicates.
const scopeNoiseUsers = 100;
final scopeNoiseCrdtRows = rowCount * 100;
final scopeMeasurements = <ScopeMode, ScopeMeasurement>{};
for (final scopeMode in ScopeMode.values) {
scopeMeasurements[scopeMode] = await runWithProgress(
'Running select scope benchmark (${scopeMode.name})',
() => TombstoneScopeBenchmark(
'select scope (${scopeMode.name})',
mode: scopeMode,
const spaceNoiseUsers = 100;
final spaceNoiseCrdtRows = rowCount * 100;
final spaceMeasurements = <SpaceMode, SpaceMeasurement>{};
for (final spaceMode in SpaceMode.values) {
spaceMeasurements[spaceMode] = await runWithProgress(
'Running select space benchmark (${spaceMode.name})',
() => TombstoneSpaceBenchmark(
'select space (${spaceMode.name})',
mode: spaceMode,
rowCount: rowCount,
noiseUsers: scopeNoiseUsers,
noiseCrdtRows: scopeNoiseCrdtRows,
noiseUsers: spaceNoiseUsers,
noiseCrdtRows: spaceNoiseCrdtRows,
).measure(),
skipProgress: runningInCI,
);
}
printScopeImpact(
ScopeBenchmarkResults(
baseline: scopeMeasurements[ScopeMode.baseline]!,
scoped: scopeMeasurements[ScopeMode.scoped]!,
unscoped: scopeMeasurements[ScopeMode.unscoped]!,
noiseUsers: scopeNoiseUsers,
noiseCrdtRows: scopeNoiseCrdtRows,
printSpaceImpact(
SpaceBenchmarkResults(
baseline: spaceMeasurements[SpaceMode.baseline]!,
spaceScoped: spaceMeasurements[SpaceMode.spaceScoped]!,
unscoped: spaceMeasurements[SpaceMode.unscoped]!,
noiseUsers: spaceNoiseUsers,
noiseCrdtRows: spaceNoiseCrdtRows,
),
rowCount: rowCount,
runningInCI: runningInCI,
@ -144,7 +144,7 @@ Future<void> main(List<String> args) async {
printStorageImpact(
StorageBenchmarkResults(
baseline: baselineStorageResult,
crdt: crdtStorageResult,
offlineSync: crdtStorageResult,
),
rowCount: rowCount,
runningInCI: runningInCI,

View file

@ -73,7 +73,7 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
late final File _dbFile;
late final ClientDatabaseSession _plainSession;
CrdtDatabaseSession? _crdtSession;
OfflineSyncDatabaseSession? _offlineSyncSession;
var _hasOpenSession = false;
final UuidValue _userId = const Uuid().v7obj();
@ -109,8 +109,8 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
(j) => _createTypesRow(start + j, baseTimestamp),
);
if (crdtEnabled) {
return _crdtSession!.db.transactionForUser(_userId, (tx) async {
return Types.db.insert(_crdtSession!, rows, transaction: tx);
return _offlineSyncSession!.db.transactionForUser(_userId, (tx) async {
return Types.db.insert(_offlineSyncSession!, rows, transaction: tx);
});
}
return Types.db.insert(_plainSession, rows);
@ -121,8 +121,8 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
return;
}
if (crdtEnabled) {
await _crdtSession!.db.transactionForUser(_userId, (tx) async {
await Types.db.delete(_crdtSession!, rows, transaction: tx);
await _offlineSyncSession!.db.transactionForUser(_userId, (tx) async {
await Types.db.delete(_offlineSyncSession!, rows, transaction: tx);
});
} else {
await Types.db.delete(_plainSession, rows);
@ -145,11 +145,11 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
);
}).toList();
if (crdtEnabled) {
_seededRows = await _crdtSession!.db.transactionForUser(
_seededRows = await _offlineSyncSession!.db.transactionForUser(
_userId,
(tx) async {
return Types.db.update(
_crdtSession!,
_offlineSyncSession!,
updated,
columns: (t) => [
t.aBool,
@ -211,11 +211,11 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
];
if (crdtEnabled) {
_seededRows = await _crdtSession!.db.transactionForUser(
_seededRows = await _offlineSyncSession!.db.transactionForUser(
_userId,
(tx) async {
return Types.db.upsert(
_crdtSession!,
_offlineSyncSession!,
batch,
conflictColumns: (t) => [t.id],
updateColumns: (t) => [
@ -252,7 +252,9 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
}
Future<List<Types>> _selectTypes() async {
final rows = await Types.db.find(crdtEnabled ? _crdtSession! : _plainSession);
final rows = await Types.db.find(
crdtEnabled ? _offlineSyncSession! : _plainSession,
);
if (rows.length != rowCount) {
throw Exception('Rows count is ${rows.length} but expected $rowCount.');
}
@ -261,12 +263,12 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
Future<void> _bootstrapDatabase() async {
await clearUserTables(_plainSession);
final wrapped = CrdtDatabaseSession.wraps(
final wrapped = OfflineSyncDatabaseSession.wraps(
_plainSession,
syncTables: benchmarkSyncTables,
);
await wrapped.db.initialize();
_crdtSession = crdtEnabled ? wrapped : null;
_offlineSyncSession = crdtEnabled ? wrapped : null;
}
@override
@ -339,12 +341,12 @@ class TypesTableBenchmark extends AsyncBenchmarkBase {
_seededRows = [];
await clearUserTables(_plainSession);
await _plainSession.db.unsafeExecute('VACUUM');
final wrapped = CrdtDatabaseSession.wraps(
final wrapped = OfflineSyncDatabaseSession.wraps(
_plainSession,
syncTables: benchmarkSyncTables,
);
await wrapped.db.initialize();
_crdtSession = crdtEnabled ? wrapped : null;
_offlineSyncSession = crdtEnabled ? wrapped : null;
}
@override

View file

@ -42,7 +42,7 @@ typedef MergeMeasurement = ({
);
}
/// Base harness for benchmarks of the merge path ([CrdtDatabase.mergeChanges]).
/// Base harness for benchmarks of the merge path ([OfflineSyncDatabase.mergeChanges]).
///
/// Changes are attributed to a single simulated remote node whose HLCs always
/// advance, so every merged change is newer than the local state and is
@ -60,7 +60,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
late final File _dbFile;
late final ClientDatabaseSession _plainSession;
late final QueryCountingDatabase _countingDb;
late CrdtDatabaseSession _crdtSession;
late OfflineSyncDatabaseSession _offlineSyncSession;
var _hasOpenSession = false;
final UuidValue _userId = const Uuid().v7obj();
@ -102,7 +102,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
CrdtMergeInsert insertChangeFor(TableRow<UuidValue?> row) {
final hlc = _nextRemoteHlc();
return CrdtMergeInsert(
uuidScopeId: _userId,
uuidSpaceId: _userId,
tableName: row.table.tableName,
uuidRowId: row.id!,
uuidNodeId: hlc.nodeId,
@ -119,7 +119,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
) {
final hlc = _nextRemoteHlc();
return CrdtMergeUpdate(
uuidScopeId: _userId,
uuidSpaceId: _userId,
tableName: row.table.tableName,
uuidRowId: row.id!,
uuidNodeId: hlc.nodeId,
@ -133,7 +133,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
CrdtMergeDelete deleteChangeFor(TableRow<UuidValue?> row) {
final hlc = _nextRemoteHlc();
return CrdtMergeDelete(
uuidScopeId: _userId,
uuidSpaceId: _userId,
tableName: row.table.tableName,
uuidRowId: row.id!,
uuidNodeId: hlc.nodeId,
@ -147,9 +147,9 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
/// Seeds [rows] through the merge path so they exist locally with remote
/// CRDT metadata, like rows previously received from the remote node.
Future<void> mergeSeedRows(List<TableRow<UuidValue?>> rows) {
return _crdtSession.db.mergeChanges(
return _offlineSyncSession.db.mergeChanges(
[for (final row in rows) insertChangeFor(row)],
scopeId: _userId,
spaceId: _userId,
);
}
@ -176,11 +176,11 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
await clearUserTables(_plainSession);
_countingDb = QueryCountingDatabase(_plainSession.db);
_crdtSession = CrdtDatabaseSession(
_offlineSyncSession = OfflineSyncDatabaseSession(
_countingDb,
syncTables: benchmarkSyncTables,
);
await _crdtSession.db.initialize();
await _offlineSyncSession.db.initialize();
await onSetup();
}
@ -223,7 +223,7 @@ abstract class MergeScenarioBenchmark extends AsyncBenchmarkBase {
final queriesBefore = _countingDb.queryCount;
final rowsBefore = _countingDb.rowsRead;
final rowsByTypeBefore = Map.of(_countingDb.rowsReadByType);
await _crdtSession.db.mergeChanges(_mergeSet, scopeId: _userId);
await _offlineSyncSession.db.mergeChanges(_mergeSet, spaceId: _userId);
_timedQueries += _countingDb.queryCount - queriesBefore;
_timedRowsRead += _countingDb.rowsRead - rowsBefore;
for (final entry in _countingDb.rowsReadByType.entries) {
@ -625,7 +625,7 @@ class SetDefaultMergeBenchmark extends MergeScenarioBenchmark {
// HLC advancement alone cannot supersede a higher visibility flag. Advance
// both on every delete/restore so no measured cycle becomes a stale no-op.
return CrdtMergeDelete(
uuidScopeId: _userId,
uuidSpaceId: _userId,
tableName: Town.t.tableName,
uuidRowId: town.id!,
uuidNodeId: hlc.nodeId,
@ -640,11 +640,14 @@ class SetDefaultMergeBenchmark extends MergeScenarioBenchmark {
@override
Future<void> validateCycle() async {
final company = await Company.db.findById(_crdtSession, _companies.first.id!);
final company = await Company.db.findById(
_offlineSyncSession,
_companies.first.id!,
);
if (company == null || company.townId != _expectedTownId) {
throw StateError('$name cycle $_cycle did not project the company as expected.');
}
final visibleTown = await Town.db.findById(_crdtSession, _changedTown.id!);
final visibleTown = await Town.db.findById(_offlineSyncSession, _changedTown.id!);
if ((visibleTown == null) != _alternate) {
throw StateError('$name cycle $_cycle did not apply its visibility change.');
}

View file

@ -2,7 +2,7 @@
import 'benchmark.dart';
import 'conversion.dart';
import 'scope.dart';
import 'space.dart';
void printPerformanceImpact(
BenchmarkResults results, {
@ -14,7 +14,7 @@ void printPerformanceImpact(
'\n📊 ${results.operation.label.toUpperCase()} performance impact:',
);
final baselineTime = results.baseline;
final crdtTime = results.crdt;
final crdtTime = results.offlineSync;
final runDelayUs = crdtTime - baselineTime;
final slowdown = runDelayUs / baselineTime * 100;
@ -41,35 +41,35 @@ void printStorageImpact(
_StorageStageComparison(
title: 'INSERT only',
baselineSize: benchmarkResults.baseline.sizeFor(StorageStage.insert),
crdtSize: benchmarkResults.crdt.sizeFor(StorageStage.insert),
crdtSize: benchmarkResults.offlineSync.sizeFor(StorageStage.insert),
baselineIncrement: benchmarkResults.baseline.sizeFor(StorageStage.insert),
crdtIncrement: benchmarkResults.crdt.sizeFor(StorageStage.insert),
crdtIncrement: benchmarkResults.offlineSync.sizeFor(StorageStage.insert),
unitCount: rowCount,
unitLabel: 'inserted row',
),
_StorageStageComparison(
title: 'INSERT + UPDATE',
baselineSize: benchmarkResults.baseline.sizeFor(StorageStage.update),
crdtSize: benchmarkResults.crdt.sizeFor(StorageStage.update),
crdtSize: benchmarkResults.offlineSync.sizeFor(StorageStage.update),
baselineIncrement:
benchmarkResults.baseline.sizeFor(StorageStage.update) -
benchmarkResults.baseline.previousSizeFor(StorageStage.update),
crdtIncrement:
benchmarkResults.crdt.sizeFor(StorageStage.update) -
benchmarkResults.crdt.previousSizeFor(StorageStage.update),
benchmarkResults.offlineSync.sizeFor(StorageStage.update) -
benchmarkResults.offlineSync.previousSizeFor(StorageStage.update),
unitCount: rowCount * TypesTableBenchmark.updatedColumnsPerRow,
unitLabel: 'updated column',
),
_StorageStageComparison(
title: 'INSERT + UPDATE + DELETE',
baselineSize: benchmarkResults.baseline.sizeFor(StorageStage.delete),
crdtSize: benchmarkResults.crdt.sizeFor(StorageStage.delete),
crdtSize: benchmarkResults.offlineSync.sizeFor(StorageStage.delete),
baselineIncrement:
benchmarkResults.baseline.sizeFor(StorageStage.delete) -
benchmarkResults.baseline.previousSizeFor(StorageStage.delete),
crdtIncrement:
benchmarkResults.crdt.sizeFor(StorageStage.delete) -
benchmarkResults.crdt.previousSizeFor(StorageStage.delete),
benchmarkResults.offlineSync.sizeFor(StorageStage.delete) -
benchmarkResults.offlineSync.previousSizeFor(StorageStage.delete),
unitCount: rowCount,
unitLabel: 'deleted row',
),
@ -79,7 +79,7 @@ void printStorageImpact(
print(
' Base database size: '
'${benchmarkResults.baseline.baseDatabaseSize.toFormattedStorageSize()} '
'--> ${benchmarkResults.crdt.baseDatabaseSize.toFormattedStorageSize()}',
'--> ${benchmarkResults.offlineSync.baseDatabaseSize.toFormattedStorageSize()}',
);
for (final stage in stages) {
@ -139,36 +139,36 @@ void printMergeImpact(
if (runningInCI) print('```');
}
void printScopeImpact(
ScopeBenchmarkResults results, {
void printSpaceImpact(
SpaceBenchmarkResults results, {
required int rowCount,
bool runningInCI = false,
}) {
String compare(
double baseline,
double scoped,
double spaceScoped,
double unscoped,
) {
String overhead(double value) =>
'${formatter2.format((value - baseline) / baseline * 100)}%';
return '${baseline.toFormattedDuration()} --> '
'${scoped.toFormattedDuration()} scoped (+${overhead(scoped)}) / '
'${spaceScoped.toFormattedDuration()} space-scoped (+${overhead(spaceScoped)}) / '
'${unscoped.toFormattedDuration()} unscoped (+${overhead(unscoped)})';
}
print(
'${runningInCI ? '```' : ''}'
'\n🔭 SELECT scope impact '
'\n🔭 SELECT space impact '
'(${formatter0.format(results.noiseUsers)} extra users, '
'${formatter0.format(results.noiseCrdtRows)} CRDT noise rows):',
);
print(
' find all (${formatter0.format(rowCount)} rows): '
'${compare(results.baseline.findAllMicros, results.scoped.findAllMicros, results.unscoped.findAllMicros)}',
'${compare(results.baseline.findAllMicros, results.spaceScoped.findAllMicros, results.unscoped.findAllMicros)}',
);
print(
' findById (per lookup): '
'${compare(results.baseline.findByIdMicros, results.scoped.findByIdMicros, results.unscoped.findByIdMicros)}',
'${compare(results.baseline.findByIdMicros, results.spaceScoped.findByIdMicros, results.unscoped.findByIdMicros)}',
);
if (runningInCI) print('```');
}
@ -177,26 +177,26 @@ class BenchmarkResults {
const BenchmarkResults({
required this.operation,
required this.baseline,
required this.crdt,
required this.offlineSync,
});
final Operation operation;
final double baseline;
final double crdt;
final double offlineSync;
}
class ScopeBenchmarkResults {
const ScopeBenchmarkResults({
class SpaceBenchmarkResults {
const SpaceBenchmarkResults({
required this.baseline,
required this.scoped,
required this.spaceScoped,
required this.unscoped,
required this.noiseUsers,
required this.noiseCrdtRows,
});
final ScopeMeasurement baseline;
final ScopeMeasurement scoped;
final ScopeMeasurement unscoped;
final SpaceMeasurement baseline;
final SpaceMeasurement spaceScoped;
final SpaceMeasurement unscoped;
final int noiseUsers;
final int noiseCrdtRows;
}
@ -231,11 +231,11 @@ class MergeBenchmarkResults {
class StorageBenchmarkResults {
const StorageBenchmarkResults({
required this.baseline,
required this.crdt,
required this.offlineSync,
});
final StorageBenchmarkRun baseline;
final StorageBenchmarkRun crdt;
final StorageBenchmarkRun offlineSync;
}
class _StorageStageComparison {

238
benchmark/utils/space.dart Normal file
View file

@ -0,0 +1,238 @@
// Production-shaped tombstone space benchmark: many users and a large
// crdt_data_rows table, measuring scoped vs unscoped SELECT cost.
import 'dart:io';
import 'dart:typed_data';
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 'benchmark.dart';
import 'tables.dart';
/// Which session executes the measured selects.
enum SpaceMode {
/// Plain SQLite session without CRDT metadata.
baseline,
/// CRDT session with a persistent user (single-user client pattern).
spaceScoped,
/// CRDT session without a user space (server admin reads).
unscoped,
}
/// Timed results for one [SpaceMode], in average microseconds.
typedef SpaceMeasurement = ({double findAllMicros, double findByIdMicros});
/// Measures SELECT cost against a database whose `crdt_data_rows` table holds
/// production-shaped metadata instead of a single user's rows.
///
/// On top of the [rowCount] benchmark rows, the CRDT modes seed [noiseUsers]
/// extra spaces tracking [noiseCrdtRows] unrelated rows spread across every
/// synced table. Random UUIDs keep the noise ownership-conformant while still
/// exercising large CRDT metadata tables.
class TombstoneSpaceBenchmark {
TombstoneSpaceBenchmark(
this.name, {
required this.mode,
required this.rowCount,
required this.noiseUsers,
required this.noiseCrdtRows,
});
final String name;
final SpaceMode mode;
final int rowCount;
final int noiseUsers;
final int noiseCrdtRows;
static const _clientUrl = 'http://localhost:8081/';
static const _warmupMillis = 100;
static const _measurementMillis = 2000;
static const _findByIdLookupsPerCycle = 100;
late final File _dbFile;
late final ClientDatabaseSession _plainSession;
OfflineSyncDatabaseSession? _offlineSyncSession;
var _hasOpenSession = false;
final UuidValue _userId = const Uuid().v7obj();
List<Types> _seededRows = [];
List<UuidValue> _sampleIds = [];
DatabaseSession get _activeSession =>
mode == SpaceMode.baseline ? _plainSession : _offlineSyncSession!;
Future<void> _setup() async {
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;
if (mode != SpaceMode.baseline) {
_offlineSyncSession = OfflineSyncDatabaseSession.wraps(
_plainSession,
syncTables: benchmarkSyncTables,
persistentUserId: mode == SpaceMode.spaceScoped ? _userId : null,
);
await _offlineSyncSession!.db.initialize();
}
await _seedBenchmarkRows();
if (mode != SpaceMode.baseline) {
await _seedCrdtNoise();
}
final sampleStep = (rowCount / _findByIdLookupsPerCycle).ceil();
_sampleIds = [
for (var i = 0; i < rowCount; i += sampleStep) _seededRows[i].id!,
];
}
Future<void> _seedBenchmarkRows() async {
final baseTimestamp = DateTime.now();
final rows = List.generate(
rowCount,
(i) => Types(
aBool: i.isEven,
aDateTime: baseTimestamp.add(Duration(seconds: i)),
aText: 'Text $i',
anInt: i,
anInt64: BigInt.from(i),
aReal: i.toDouble(),
aBlob: ByteData(0),
anEnum: null,
optionalText: null,
optionalUuid: const Uuid().v7obj(),
),
);
if (mode == SpaceMode.baseline) {
_seededRows = await Types.db.insert(_plainSession, rows);
return;
}
_seededRows = await _offlineSyncSession!.db.transactionForUser(_userId, (tx) {
return Types.db.insert(_offlineSyncSession!, rows, transaction: tx);
});
}
/// Injects CRDT metadata noise with raw SQL, bypassing the recorder: the
/// noise only needs to be visible to the tombstone predicates, which read
/// `crdt_data_rows` alone.
Future<void> _seedCrdtNoise() async {
final db = _plainSession.db;
final hiddenVisibility = CrdtDataRowVisibility.userDelete.index;
final benchSpace = await OfflineSyncSpace.db.findFirstRow(
_plainSession,
orderBy: (t) => t.id,
);
final benchUserId = benchSpace!.id!;
await db.unsafeExecute('''
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseUsers - 1)
INSERT INTO "offline_sync_spaces" ("currentNodeId") SELECT NULL FROM n
''');
await db.unsafeExecute('''
WITH local_node(id) AS (
SELECT "currentNodeId" FROM "offline_sync_spaces" WHERE "id" = $benchUserId
)
INSERT INTO "offline_sync_space_nodes" ("spaceId", "nodeId")
SELECT u."id", local_node.id
FROM "offline_sync_spaces" u
CROSS JOIN local_node
WHERE local_node.id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM "offline_sync_space_nodes" sn
WHERE sn."spaceId" = u."id" AND sn."nodeId" = local_node.id
)
''');
// Noise rows for every noise user, spread across all synced tables, with
// 10% tombstoned. Random UUIDs keep them unrelated to the domain rows.
await db.unsafeExecute('''
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseCrdtRows - 1),
users(rowidx, uid, nodeId) AS (
SELECT ROW_NUMBER() OVER (ORDER BY s."id") - 1, s."id", sn."nodeId"
FROM "offline_sync_spaces" s
JOIN "offline_sync_space_nodes" sn ON sn."spaceId" = s."id"
WHERE s."id" <> $benchUserId
),
tbls(tblidx, tid) AS (
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id" FROM "crdt_schema_tables"
)
INSERT INTO "crdt_data_rows"
("hlcDatetime", "hlcCounter", "spaceId", "tblId", "uuidRowId", "nodeId", "visibility")
SELECT 0, 0, u.uid, t.tid, randomblob(16), u.nodeId,
CASE WHEN n.i % 10 = 0 THEN $hiddenVisibility ELSE 0 END
FROM n
JOIN users u ON u.rowidx = n.i % $noiseUsers
JOIN tbls t ON t.tblidx = n.i % (SELECT COUNT(*) FROM "crdt_schema_tables")
''');
await db.unsafeExecute('ANALYZE');
}
Future<void> _runFindAll() async {
final rows = await Types.db.find(_activeSession);
if (rows.length != rowCount) {
throw Exception(
'Space benchmark ($name) returned ${rows.length} rows '
'but expected $rowCount.',
);
}
}
Future<void> _runFindByIdBatch() async {
for (final id in _sampleIds) {
final row = await Types.db.findById(_activeSession, id);
if (row == null) {
throw Exception('Space benchmark ($name) could not find row $id.');
}
}
}
Future<double> _measureQuery(Future<void> Function() run) async {
Future<void> noPrepare() async {}
await measurePreparedCycles(_warmupMillis, prepare: noPrepare, run: run);
return measurePreparedCycles(
_measurementMillis,
prepare: noPrepare,
run: run,
);
}
/// Seeds the database once and measures both query shapes.
Future<SpaceMeasurement> measure() async {
await _setup();
try {
final findAllMicros = await _measureQuery(_runFindAll);
final findByIdBatchMicros = await _measureQuery(_runFindByIdBatch);
return (
findAllMicros: findAllMicros,
findByIdMicros: findByIdBatchMicros / _sampleIds.length,
);
} finally {
await _teardown();
}
}
Future<void> _teardown() async {
if (!_hasOpenSession) return;
await _plainSession.close();
_hasOpenSession = false;
deleteDatabaseFiles(_dbFile);
}
}

View file

@ -1,9 +1,9 @@
// Keeps CRDT scope aligned with integration tests plus the benchmark table.
// Keeps CRDT space 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.
/// [Types] is benchmark-only; the rest mirrors `client_session.dart` sync space.
List<Table> get benchmarkSyncTables => [
Address.t,
City.t,