Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions benchmark/loon_benchmark.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// ignore_for_file: avoid_print

import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:loon/loon.dart';
import 'package:loon/utils/id.dart';

/// Performance harness for Loon's hot paths. Not part of the normal test
/// suite — run explicitly with:
///
/// flutter test benchmark/loon_benchmark.dart
///
/// Each `test` prints a small table to stdout. Numbers are wall-clock on the
/// current machine and only meaningful relative to each other / across runs.
void main() {
setUp(() async {
await _resetStore();
});
Comment on lines +17 to +19

tearDownAll(() async {
await _resetStore();
});
Comment on lines +21 to +23

test('Write throughput (broadcast off)', () async {
for (final n in [1000, 10000, 50000]) {
await _resetStore();
final col = Loon.collection<int>('bench');
final sw = Stopwatch()..start();
for (var i = 0; i < n; i++) {
col.doc('doc_$i').create(i, broadcast: false, persist: false);
}
sw.stop();
_report('write create', n, sw.elapsedMicroseconds);
}
});

test('Read throughput (cache hit on get)', () {
final col = Loon.collection<int>('bench');
const n = 50000;
for (var i = 0; i < n; i++) {
col.doc('doc_$i').create(i, broadcast: false, persist: false);
}
for (final reads in [50000, 200000]) {
final sw = Stopwatch()..start();
var sink = 0;
for (var i = 0; i < reads; i++) {
sink += col.doc('doc_${i % n}').get()!.data;
}
sw.stop();
expect(sink, greaterThan(0));
_report('doc get', reads, sw.elapsedMicroseconds);
}
});

test('ID generation', () {
const n = 100000;
for (final entry in {
'generateSecureId': generateSecureId,
'generateFastId': generateFastId,
}.entries) {
final gen = entry.value;
final sw = Stopwatch()..start();
for (var i = 0; i < n; i++) {
gen();
}
sw.stop();
_report(entry.key, n, sw.elapsedMicroseconds);
}
});

test('Subscription setup throughput', () async {
// Each observer creation generates an ID, opens two stream controllers,
// registers in the broadcast manager, and computes an initial value. The
// ID generator is the part this PR changes.
const n = 20000;
final col = Loon.collection<int>('sub');
for (var i = 0; i < n; i++) {
col.doc('doc_$i').create(i, broadcast: false, persist: false);
}

final subs = <StreamSubscription<DocumentSnapshot<int>?>>[];
final sw = Stopwatch()..start();
for (var i = 0; i < n; i++) {
subs.add(col.doc('doc_$i').observe().stream().listen((_) {}));
}
sw.stop();
_report('observe+listen', n, sw.elapsedMicroseconds);

for (final s in subs) {
await s.cancel();
}
});

test('Broadcast latency vs observer count', () async {
// Holds N idle document observers, then repeatedly writes to a single
// unrelated document and waits for that document's observer to emit. Every
// broadcast visits all N observers, so per-cycle cost should grow ~linearly
// with N if dispatch is O(all observers).
const rounds = 50;

print('\n broadcast: write 1 doc, $rounds rounds, N idle observers');
print(' ${'N observers'.padRight(14)} ${'µs/broadcast'.padLeft(14)}');

for (final n in [0, 100, 1000, 5000]) {
await _resetStore();

final col = Loon.collection<int>('obs');

// The document we will repeatedly write to and await.
final target = col.doc('target');
target.create(0, persist: false);
final emissions = <int>[];
final sub = target.stream().listen((snap) {
if (snap != null) emissions.add(snap.data);
});

// N idle observers on distinct documents that never change.
final idleSubs = [
for (var i = 0; i < n; i++)
col.doc('idle_$i').observe().stream().listen((_) {}),
];

// Let initial subscriptions settle.
await Future.delayed(const Duration(milliseconds: 5));
emissions.clear();

final sw = Stopwatch()..start();
for (var r = 0; r < rounds; r++) {
target.update(r + 1);
await Future.delayed(Duration.zero); // let the broadcast fire
}
sw.stop();

expect(emissions.length, rounds);

await sub.cancel();
for (final s in idleSubs) {
await s.cancel();
}

final perBroadcast = sw.elapsedMicroseconds / rounds;
print(
' ${n.toString().padRight(14)} ${perBroadcast.toStringAsFixed(1).padLeft(14)}');
}
});

test('Sorted query rebroadcast vs result-set size', () async {
// A sorted query over M documents receives a single-document update. The
// query re-sorts its entire result set on every rebroadcast, so per-update
// cost should grow ~M log M.
const rounds = 30;

print('\n sorted query: update 1 doc, $rounds rounds, M docs in result');
print(' ${'M docs'.padRight(14)} ${'µs/update'.padLeft(14)}');

for (final m in [100, 1000, 10000]) {
await _resetStore();

final col = Loon.collection<int>('q');
for (var i = 0; i < m; i++) {
col.doc('doc_$i').create(i, broadcast: false, persist: false);
}

final query = col.sortBy((a, b) => a.data.compareTo(b.data));
var emitted = 0;
final sub = query.stream().listen((_) => emitted++);

await Future.delayed(const Duration(milliseconds: 5));
emitted = 0;

final target = col.doc('doc_0');
final sw = Stopwatch()..start();
for (var r = 0; r < rounds; r++) {
target.update(-(r + 1)); // keep it sorting to the front
await Future.delayed(Duration.zero);
}
sw.stop();

expect(emitted, rounds);
await sub.cancel();

final perUpdate = sw.elapsedMicroseconds / rounds;
print(
' ${m.toString().padRight(14)} ${perUpdate.toStringAsFixed(1).padLeft(14)}');
}
});
}

Future<void> _resetStore() async {
Loon.unsubscribe();
await Loon.clearAll(broadcast: false);
}

void _report(String name, int ops, int micros) {
final perOp = micros / ops;
print(' ${name.padRight(16)} ${ops.toString().padLeft(8)} ops '
'${(micros / 1000).toStringAsFixed(1).padLeft(9)} ms '
'${perOp.toStringAsFixed(3).padLeft(9)} µs/op');
}
2 changes: 1 addition & 1 deletion example/lib/random_operation_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class RandomOperationRunner {
_logger.log('Persist $count');

for (int i = 0; i < count; i++) {
final id = generateId();
final id = generateSecureId();
UserModel.store.doc(id).create(UserModel(name: 'User $id'));
}
} else if (operationIndex >= 80 && operationIndex < 90) {
Expand Down
2 changes: 1 addition & 1 deletion lib/broadcast_observer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ mixin BroadcastObserver<T, S> {
_controllerValue = initialValue;
_controller.add(initialValue);

_observerId = "${path}__${generateId()}";
_observerId = "${path}__${generateFastId()}";

Loon._instance.broadcastManager.addObserver(this, initialValue);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/collection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class Collection<T> implements Queryable<T>, StoreReference {
Document<T> doc([String? id]) {
return Document<T>(
path,
id ?? generateId(),
id ?? generateSecureId(),
fromJson: fromJson,
toJson: toJson,
persistorSettings: persistorSettings,
Expand Down
2 changes: 1 addition & 1 deletion lib/persistor/worker/messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:loon/utils/id.dart';
abstract class Message {}

abstract class MessageRequest<T extends MessageResponse> extends Message {
final id = generateId();
final id = generateFastId();

MessageRequest();

Expand Down
26 changes: 21 additions & 5 deletions lib/utils/id.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ const String _alphabet =
final Uint8List _alphabytes = Uint8List.fromList(_alphabet.codeUnits);
const int _u32 = 0x100000000; // 2^32

final Random _rand = Random.secure();
final Random _secureRandom = Random.secure();
final Random _fastRandom = Random();

/// Generates a cryptographically secure, URL-safe random ID.
/// Default: 21 chars ≈ 126 bits of entropy.
String generateId([int size = 21]) {
String _generateRandomId(Random random, int size) {
final out = Uint8List(size);
var i = 0;

while (i < size) {
int r = _rand.nextInt(_u32); // A u32 can sample 5-characters (2^6)*5, 2 bits leftover.
// A u32 can sample 5 characters ((2^6) * 5), with 2 bits leftover.
int r = random.nextInt(_u32);

var k = 0;
while (k < 5 && i < size) {
Expand All @@ -29,3 +29,19 @@ String generateId([int size = 21]) {

return String.fromCharCodes(out);
}

/// Generates a cryptographically secure, URL-safe random ID for values that may
/// be user-visible, persisted, synced, or treated as unguessable by callers.
///
/// Use this for document IDs and other public identifiers.
/// Default: 21 chars, about 126 bits of entropy.
String generateSecureId([int size = 21]) =>
_generateRandomId(_secureRandom, size);

/// Generates a URL-safe random ID from a non-cryptographic PRNG.
///
/// Use this only for ephemeral internal identifiers that need local uniqueness
/// but do not need to be hard to guess, such as observer IDs or request
/// correlation IDs. Do not use it for document IDs, access tokens, or other
/// public identifiers where unpredictability matters.
String generateFastId([int size = 21]) => _generateRandomId(_fastRandom, size);
Loading