2026-09-14 13:39:07 -03:00
|
|
|
// Production-shaped tombstone space benchmark: many users and a large
|
2026-06-18 01:05:24 -03:00
|
|
|
// 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.
|
2026-09-14 13:39:07 -03:00
|
|
|
enum SpaceMode {
|
2026-06-18 01:05:24 -03:00
|
|
|
/// Plain SQLite session without CRDT metadata.
|
|
|
|
|
baseline,
|
|
|
|
|
|
|
|
|
|
/// CRDT session with a persistent user (single-user client pattern).
|
2026-09-14 13:39:07 -03:00
|
|
|
spaceScoped,
|
2026-06-18 01:05:24 -03:00
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
/// CRDT session without a user space (server admin reads).
|
2026-06-18 01:05:24 -03:00
|
|
|
unscoped,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
/// Timed results for one [SpaceMode], in average microseconds.
|
|
|
|
|
typedef SpaceMeasurement = ({double findAllMicros, double findByIdMicros});
|
2026-06-18 01:05:24 -03:00
|
|
|
|
|
|
|
|
/// 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]
|
2026-09-14 13:39:07 -03:00
|
|
|
/// extra spaces tracking [noiseCrdtRows] unrelated rows spread across every
|
2026-06-18 01:05:24 -03:00
|
|
|
/// synced table. Random UUIDs keep the noise ownership-conformant while still
|
|
|
|
|
/// exercising large CRDT metadata tables.
|
2026-09-14 13:39:07 -03:00
|
|
|
class TombstoneSpaceBenchmark {
|
|
|
|
|
TombstoneSpaceBenchmark(
|
2026-06-18 01:05:24 -03:00
|
|
|
this.name, {
|
|
|
|
|
required this.mode,
|
|
|
|
|
required this.rowCount,
|
|
|
|
|
required this.noiseUsers,
|
|
|
|
|
required this.noiseCrdtRows,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final String name;
|
2026-09-14 13:39:07 -03:00
|
|
|
final SpaceMode mode;
|
2026-06-18 01:05:24 -03:00
|
|
|
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;
|
2026-09-14 13:39:07 -03:00
|
|
|
OfflineSyncDatabaseSession? _offlineSyncSession;
|
2026-06-18 01:05:24 -03:00
|
|
|
var _hasOpenSession = false;
|
|
|
|
|
|
|
|
|
|
final UuidValue _userId = const Uuid().v7obj();
|
|
|
|
|
|
|
|
|
|
List<Types> _seededRows = [];
|
|
|
|
|
List<UuidValue> _sampleIds = [];
|
|
|
|
|
|
|
|
|
|
DatabaseSession get _activeSession =>
|
2026-09-14 13:39:07 -03:00
|
|
|
mode == SpaceMode.baseline ? _plainSession : _offlineSyncSession!;
|
2026-06-18 01:05:24 -03:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
if (mode != SpaceMode.baseline) {
|
|
|
|
|
_offlineSyncSession = OfflineSyncDatabaseSession.wraps(
|
2026-06-18 01:05:24 -03:00
|
|
|
_plainSession,
|
|
|
|
|
syncTables: benchmarkSyncTables,
|
2026-09-14 13:39:07 -03:00
|
|
|
persistentUserId: mode == SpaceMode.spaceScoped ? _userId : null,
|
2026-06-18 01:05:24 -03:00
|
|
|
);
|
2026-09-14 13:39:07 -03:00
|
|
|
await _offlineSyncSession!.db.initialize();
|
2026-06-18 01:05:24 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await _seedBenchmarkRows();
|
2026-09-14 13:39:07 -03:00
|
|
|
if (mode != SpaceMode.baseline) {
|
2026-06-18 01:05:24 -03:00
|
|
|
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(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
if (mode == SpaceMode.baseline) {
|
2026-06-18 01:05:24 -03:00
|
|
|
_seededRows = await Types.db.insert(_plainSession, rows);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-14 13:39:07 -03:00
|
|
|
_seededRows = await _offlineSyncSession!.db.transactionForUser(_userId, (tx) {
|
|
|
|
|
return Types.db.insert(_offlineSyncSession!, rows, transaction: tx);
|
2026-06-18 01:05:24 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
final benchSpace = await OfflineSyncSpace.db.findFirstRow(
|
2026-08-04 11:04:04 -03:00
|
|
|
_plainSession,
|
|
|
|
|
orderBy: (t) => t.id,
|
|
|
|
|
);
|
2026-09-14 13:39:07 -03:00
|
|
|
final benchUserId = benchSpace!.id!;
|
2026-06-18 01:05:24 -03:00
|
|
|
await db.unsafeExecute('''
|
|
|
|
|
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < $noiseUsers - 1)
|
2026-09-14 13:39:07 -03:00
|
|
|
INSERT INTO "offline_sync_spaces" ("currentNodeId") SELECT NULL FROM n
|
2026-06-18 01:05:24 -03:00
|
|
|
''');
|
|
|
|
|
await db.unsafeExecute('''
|
2026-07-03 01:25:15 -03:00
|
|
|
WITH local_node(id) AS (
|
2026-09-14 13:39:07 -03:00
|
|
|
SELECT "currentNodeId" FROM "offline_sync_spaces" WHERE "id" = $benchUserId
|
2026-07-03 01:25:15 -03:00
|
|
|
)
|
2026-09-14 13:39:07 -03:00
|
|
|
INSERT INTO "offline_sync_space_nodes" ("spaceId", "nodeId")
|
2026-07-03 01:25:15 -03:00
|
|
|
SELECT u."id", local_node.id
|
2026-09-14 13:39:07 -03:00
|
|
|
FROM "offline_sync_spaces" u
|
2026-07-03 01:25:15 -03:00
|
|
|
CROSS JOIN local_node
|
|
|
|
|
WHERE local_node.id IS NOT NULL
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1
|
2026-09-14 13:39:07 -03:00
|
|
|
FROM "offline_sync_space_nodes" sn
|
|
|
|
|
WHERE sn."spaceId" = u."id" AND sn."nodeId" = local_node.id
|
2026-07-03 01:25:15 -03:00
|
|
|
)
|
2026-06-18 01:05:24 -03:00
|
|
|
''');
|
|
|
|
|
|
|
|
|
|
// 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),
|
2026-07-03 01:25:15 -03:00
|
|
|
users(rowidx, uid, nodeId) AS (
|
|
|
|
|
SELECT ROW_NUMBER() OVER (ORDER BY s."id") - 1, s."id", sn."nodeId"
|
2026-09-14 13:39:07 -03:00
|
|
|
FROM "offline_sync_spaces" s
|
|
|
|
|
JOIN "offline_sync_space_nodes" sn ON sn."spaceId" = s."id"
|
2026-07-03 01:25:15 -03:00
|
|
|
WHERE s."id" <> $benchUserId
|
2026-06-18 01:05:24 -03:00
|
|
|
),
|
|
|
|
|
tbls(tblidx, tid) AS (
|
|
|
|
|
SELECT ROW_NUMBER() OVER (ORDER BY "id") - 1, "id" FROM "crdt_schema_tables"
|
|
|
|
|
)
|
|
|
|
|
INSERT INTO "crdt_data_rows"
|
2026-09-14 13:39:07 -03:00
|
|
|
("hlcDatetime", "hlcCounter", "spaceId", "tblId", "uuidRowId", "nodeId", "visibility")
|
2026-07-03 01:25:15 -03:00
|
|
|
SELECT 0, 0, u.uid, t.tid, randomblob(16), u.nodeId,
|
2026-06-18 01:05:24 -03:00
|
|
|
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(
|
2026-09-14 13:39:07 -03:00
|
|
|
'Space benchmark ($name) returned ${rows.length} rows '
|
2026-06-18 01:05:24 -03:00
|
|
|
'but expected $rowCount.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _runFindByIdBatch() async {
|
|
|
|
|
for (final id in _sampleIds) {
|
|
|
|
|
final row = await Types.db.findById(_activeSession, id);
|
|
|
|
|
if (row == null) {
|
2026-09-14 13:39:07 -03:00
|
|
|
throw Exception('Space benchmark ($name) could not find row $id.');
|
2026-06-18 01:05:24 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
2026-09-14 13:39:07 -03:00
|
|
|
Future<SpaceMeasurement> measure() async {
|
2026-06-18 01:05:24 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|